Public: anyone can read this; the crew can change it.
A deploy that fails at start reports a stack frame, not what went wrong #312
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/270-a-deploy-that-fails-at-start-reports-a-s → main 1 commit fork
2 files
changed +177
−10
src/apps/process.ts +50 −7
| @@ -144,6 +144,44 @@ function operatorVars( | ||
| 144 | 144 | return out; |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | /** A stack frame in any of the languages a step's runtime might throw from. */ | |
| 148 | const STACK_FRAME_RE = /^at\s/; | |
| 149 | /** A runtime's version banner, printed after an uncaught error — `Bun v1.3.14 (Linux x64 baseline)`. */ | |
| 150 | const VERSION_BANNER_RE = /^\S+ v[\d.]+ *\(/; | |
| 151 | /** | |
| 152 | * A line that reads as an error rather than context around one — anchored | |
| 153 | * at the start, so a source line merely mentioning `Error` (`throw new | |
| 154 | * Error(...)`, echoed above the frames) is not mistaken for the message. | |
| 155 | */ | |
| 156 | const ERROR_LINE_RE = /^[A-Za-z]*Error\b/i; | |
| 157 | const TAIL_LINES = 20; | |
| 158 | const REASON_MAX = 300; | |
| 159 | ||
| 160 | /** | |
| 161 | * The reason a person would read out of a step's failed output: the first | |
| 162 | * line that isn't a blank, a stack frame, or a version banner, and reads as | |
| 163 | * an error — the message, printed before the frames that bury it. Falls | |
| 164 | * back to the tail when nothing qualifies, since some failures (a plain | |
| 165 | * `exit 1`) never print one. | |
| 166 | */ | |
| 167 | export function failureReason(recent: readonly string[]): string { | |
| 168 | for (const raw of recent) { | |
| 169 | const line = raw.trim(); | |
| 170 | if ( | |
| 171 | line === "" || | |
| 172 | STACK_FRAME_RE.test(line) || | |
| 173 | VERSION_BANNER_RE.test(line) | |
| 174 | ) | |
| 175 | continue; | |
| 176 | if (ERROR_LINE_RE.test(line)) return truncate(line); | |
| 177 | } | |
| 178 | return truncate(recent.slice(-3).join(" | ")); | |
| 179 | } | |
| 180 | ||
| 181 | function truncate(line: string): string { | |
| 182 | return line.length > REASON_MAX ? `${line.slice(0, REASON_MAX)}…` : line; | |
| 183 | } | |
| 184 | ||
| 147 | 185 | /** |
| 148 | 186 | * Run a step to completion, streaming its output. A non-zero exit, a |
| 149 | 187 | * timeout, or an abort is an Err with the reason a person would want. |
| @@ -167,8 +205,13 @@ export async function runStep( | ||
| 167 | 205 | stdout: "pipe", |
| 168 | 206 | stderr: "pipe", |
| 169 | 207 | }); |
| 170 | const scrub = (line: string): void => | |
| 171 | options.log(stripForgeDirs([options.cwd], line)); | |
| 208 | const recent: string[] = []; | |
| 209 | const scrub = (raw: string): void => { | |
| 210 | const line = stripForgeDirs([options.cwd], raw); | |
| 211 | recent.push(line); | |
| 212 | if (recent.length > TAIL_LINES) recent.shift(); | |
| 213 | options.log(line); | |
| 214 | }; | |
| 172 | 215 | const timeoutMs = options.timeoutMs ?? STEP_TIMEOUT_MS; |
| 173 | 216 | let why: string | undefined; |
| 174 | 217 | // A killed process's pipes are not guaranteed to close on their own |
| @@ -194,7 +237,10 @@ export async function runStep( | ||
| 194 | 237 | proc.exited, |
| 195 | 238 | ]); |
| 196 | 239 | if (why !== undefined) return err(why); |
| 197 | if (code !== 0) return err(`${name} exited with code ${code}`); | |
| 240 | if (code !== 0) { | |
| 241 | const reason = recent.length > 0 ? ` — ${failureReason(recent)}` : ""; | |
| 242 | return err(`${name} exited with code ${code}${reason}`); | |
| 243 | } | |
| 198 | 244 | return ok(); |
| 199 | 245 | } finally { |
| 200 | 246 | clearTimeout(timer); |
| @@ -311,10 +357,7 @@ export async function startProcess( | ||
| 311 | 357 | // left to verify, and must not outlive this failed run. |
| 312 | 358 | testAbort.abort(); |
| 313 | 359 | await tested.catch(() => {}); |
| 314 | const tail = | |
| 315 | recent.length > 0 | |
| 316 | ? ` — last output: ${recent.slice(-3).join(" | ")}` | |
| 317 | : ""; | |
| 360 | const tail = recent.length > 0 ? ` — ${failureReason(recent)}` : ""; | |
| 318 | 361 | return err({ |
| 319 | 362 | stage: ready.error.stage, |
| 320 | 363 | message: ready.error.message + tail, |
test/apps.test.ts +127 −3
| @@ -15,6 +15,8 @@ import { join } from "node:path"; | ||
| 15 | 15 | import { createApp, type App } from "../src/app.ts"; |
| 16 | 16 | import { AppsRuntime, STOPPED_SUMMARY } from "../src/apps/runtime.ts"; |
| 17 | 17 | import { envDataDir, envDir, previewEnv } from "../src/apps/data.ts"; |
| 18 | import { childEnv, runStep, startProcess } from "../src/apps/process.ts"; | |
| 19 | import type { AppManifest } from "../src/apps/manifest.ts"; | |
| 18 | 20 | import { |
| 19 | 21 | enableApp, |
| 20 | 22 | newApp, |
| @@ -1245,19 +1247,25 @@ describe("a start failure is said in the app's own terms, not the forge's paths | ||
| 1245 | 1247 | expect(run.state).toBe("failed"); |
| 1246 | 1248 | |
| 1247 | 1249 | const notForgePaths = (text: string): void => { |
| 1248 | expect(text).toContain("src/server.ts"); | |
| 1249 | 1250 | expect(text).not.toContain(dataDir); |
| 1250 | 1251 | expect(text).not.toContain(record.id); |
| 1251 | 1252 | }; |
| 1252 | 1253 | |
| 1253 | 1254 | // The error the runtime stores — the same one a run's summary and the |
| 1254 | // studio's head card both draw from — names the app's own file. | |
| 1255 | // studio's head card both draw from — is the thrown message (issue | |
| 1256 | // #270), not a stack frame; it never leaks the forge's own paths either. | |
| 1255 | 1257 | const status = runtime.envStatus(record, "production"); |
| 1258 | expect(status?.error ?? "").toContain("boom"); | |
| 1256 | 1259 | notForgePaths(status?.error ?? ""); |
| 1260 | expect(run.summary ?? "").toContain("boom"); | |
| 1257 | 1261 | notForgePaths(run.summary ?? ""); |
| 1258 | 1262 | |
| 1263 | // The full log still has the frame, path-stripped down to the app's | |
| 1264 | // own file rather than the forge's data directory or the app's id. | |
| 1259 | 1265 | const { lines } = readRunLog(dataDir, run.id); |
| 1260 | notForgePaths(lines.join("\n")); | |
| 1266 | const log = lines.join("\n"); | |
| 1267 | expect(log).toContain("src/server.ts"); | |
| 1268 | notForgePaths(log); | |
| 1261 | 1269 | |
| 1262 | 1270 | // The studio's app page renders the same error in its head, for a |
| 1263 | 1271 | // reader who never asked to see the forge's own filesystem. |
| @@ -1888,3 +1896,119 @@ describe("the forge's own restart never reads as this deploy's failure (issue #2 | ||
| 1888 | 1896 | expect(status?.error).toBeUndefined(); |
| 1889 | 1897 | }, 30_000); |
| 1890 | 1898 | }); |
| 1899 | ||
| 1900 | describe("a start that fails reports the message, not its last line (issue #270)", () => { | |
| 1901 | const manifest: AppManifest = { | |
| 1902 | run: "", | |
| 1903 | install: null, | |
| 1904 | test: null, | |
| 1905 | health: "/", | |
| 1906 | ready: 2, | |
| 1907 | tty: null, | |
| 1908 | looks: ["/"], | |
| 1909 | }; | |
| 1910 | ||
| 1911 | // A realistic crash: the message, a blank line, three frames, then the | |
| 1912 | // runtime's version banner — the exact shape that used to leave only | |
| 1913 | // the frame and the banner behind. | |
| 1914 | const CRASH = `TypeError [ERR_INVALID_URL]: "http://notes.127.0.0.1:3999" cannot be parsed as a URL | |
| 1915 | at src/env.ts:22:37 | |
| 1916 | at src/server.ts:3:1 | |
| 1917 | at node:internal/main:1:1 | |
| 1918 | ||
| 1919 | Bun v1.3.14 (Linux x64 baseline)`; | |
| 1920 | ||
| 1921 | test("start: the crash's message wins, the frames and banner do not", async () => { | |
| 1922 | const dir = await mkdtemp(join(tmpdir(), "forge-crash-")); | |
| 1923 | try { | |
| 1924 | const started = await startProcess({ | |
| 1925 | dir, | |
| 1926 | dataDir: dir, | |
| 1927 | manifest: { ...manifest, run: `printf '%s' '${CRASH}' 1>&2; exit 1` }, | |
| 1928 | env: "production", | |
| 1929 | url: "http://app.test", | |
| 1930 | commitOid: "0".repeat(40), | |
| 1931 | forgeUrl: "http://forge.test", | |
| 1932 | identityKey: "key", | |
| 1933 | log: () => {}, | |
| 1934 | signal: new AbortController().signal, | |
| 1935 | }); | |
| 1936 | expect(started.ok).toBe(false); | |
| 1937 | if (started.ok) return; | |
| 1938 | expect(started.error.message).toContain( | |
| 1939 | 'TypeError [ERR_INVALID_URL]: "http://notes.127.0.0.1:3999" cannot be parsed as a URL', | |
| 1940 | ); | |
| 1941 | expect(started.error.message).not.toContain("at src/env.ts"); | |
| 1942 | expect(started.error.message).not.toContain("Bun v1.3.14"); | |
| 1943 | } finally { | |
| 1944 | await rm(dir, { recursive: true, force: true }); | |
| 1945 | } | |
| 1946 | }, 20_000); | |
| 1947 | ||
| 1948 | test("install: the same crash, the same message", async () => { | |
| 1949 | const dir = await mkdtemp(join(tmpdir(), "forge-crash-install-")); | |
| 1950 | try { | |
| 1951 | const result = await runStep( | |
| 1952 | "install", | |
| 1953 | `printf '%s' '${CRASH}' 1>&2; exit 1`, | |
| 1954 | { | |
| 1955 | cwd: dir, | |
| 1956 | env: childEnv({}), | |
| 1957 | log: () => {}, | |
| 1958 | signal: new AbortController().signal, | |
| 1959 | }, | |
| 1960 | ); | |
| 1961 | expect(result.ok).toBe(false); | |
| 1962 | if (result.ok) return; | |
| 1963 | expect(result.error).toContain( | |
| 1964 | 'TypeError [ERR_INVALID_URL]: "http://notes.127.0.0.1:3999" cannot be parsed as a URL', | |
| 1965 | ); | |
| 1966 | expect(result.error).not.toContain("at src/env.ts"); | |
| 1967 | expect(result.error).not.toContain("Bun v1.3.14"); | |
| 1968 | } finally { | |
| 1969 | await rm(dir, { recursive: true, force: true }); | |
| 1970 | } | |
| 1971 | }, 20_000); | |
| 1972 | ||
| 1973 | test("test: the same crash, the same message", async () => { | |
| 1974 | const dir = await mkdtemp(join(tmpdir(), "forge-crash-test-")); | |
| 1975 | try { | |
| 1976 | const result = await runStep( | |
| 1977 | "test", | |
| 1978 | `printf '%s' '${CRASH}' 1>&2; exit 1`, | |
| 1979 | { | |
| 1980 | cwd: dir, | |
| 1981 | env: childEnv({}), | |
| 1982 | log: () => {}, | |
| 1983 | signal: new AbortController().signal, | |
| 1984 | }, | |
| 1985 | ); | |
| 1986 | expect(result.ok).toBe(false); | |
| 1987 | if (result.ok) return; | |
| 1988 | expect(result.error).toContain( | |
| 1989 | 'TypeError [ERR_INVALID_URL]: "http://notes.127.0.0.1:3999" cannot be parsed as a URL', | |
| 1990 | ); | |
| 1991 | expect(result.error).not.toContain("at src/env.ts"); | |
| 1992 | expect(result.error).not.toContain("Bun v1.3.14"); | |
| 1993 | } finally { | |
| 1994 | await rm(dir, { recursive: true, force: true }); | |
| 1995 | } | |
| 1996 | }, 20_000); | |
| 1997 | ||
| 1998 | test("a plain exit 1 with no output keeps the bare 'exited with code' message", async () => { | |
| 1999 | const dir = await mkdtemp(join(tmpdir(), "forge-crash-plain-")); | |
| 2000 | try { | |
| 2001 | const result = await runStep("check", "exit 1", { | |
| 2002 | cwd: dir, | |
| 2003 | env: childEnv({}), | |
| 2004 | log: () => {}, | |
| 2005 | signal: new AbortController().signal, | |
| 2006 | }); | |
| 2007 | expect(result.ok).toBe(false); | |
| 2008 | if (result.ok) return; | |
| 2009 | expect(result.error).toBe("check exited with code 1"); | |
| 2010 | } finally { | |
| 2011 | await rm(dir, { recursive: true, force: true }); | |
| 2012 | } | |
| 2013 | }, 20_000); | |
| 2014 | }); |
new above
offline