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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/270-a-deploy-that-fails-at-start-reports-a-s → main 1 commit fork

  • Merged. scout/270-a-deploy-that-fails-at-start-reports-a-s was merged into main as 77f150e.

Review

Sign in to review this change.
2 files changed +177 −10
src/apps/process.ts +50 −7
@@ -144,6 +144,44 @@ function operatorVars(
144144 return out;
145145 }
146146
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
147185 /**
148186 * Run a step to completion, streaming its output. A non-zero exit, a
149187 * timeout, or an abort is an Err with the reason a person would want.
@@ -167,8 +205,13 @@ export async function runStep(
167205 stdout: "pipe",
168206 stderr: "pipe",
169207 });
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 };
172215 const timeoutMs = options.timeoutMs ?? STEP_TIMEOUT_MS;
173216 let why: string | undefined;
174217 // A killed process's pipes are not guaranteed to close on their own
@@ -194,7 +237,10 @@ export async function runStep(
194237 proc.exited,
195238 ]);
196239 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 }
198244 return ok();
199245 } finally {
200246 clearTimeout(timer);
@@ -311,10 +357,7 @@ export async function startProcess(
311357 // left to verify, and must not outlive this failed run.
312358 testAbort.abort();
313359 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)}` : "";
318361 return err({
319362 stage: ready.error.stage,
320363 message: ready.error.message + tail,
test/apps.test.ts +127 −3
@@ -15,6 +15,8 @@ import { join } from "node:path";
1515 import { createApp, type App } from "../src/app.ts";
1616 import { AppsRuntime, STOPPED_SUMMARY } from "../src/apps/runtime.ts";
1717 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";
1820 import {
1921 enableApp,
2022 newApp,
@@ -1245,19 +1247,25 @@ describe("a start failure is said in the app's own terms, not the forge's paths
12451247 expect(run.state).toBe("failed");
12461248
12471249 const notForgePaths = (text: string): void => {
1248 expect(text).toContain("src/server.ts");
12491250 expect(text).not.toContain(dataDir);
12501251 expect(text).not.toContain(record.id);
12511252 };
12521253
12531254 // 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.
12551257 const status = runtime.envStatus(record, "production");
1258 expect(status?.error ?? "").toContain("boom");
12561259 notForgePaths(status?.error ?? "");
1260 expect(run.summary ?? "").toContain("boom");
12571261 notForgePaths(run.summary ?? "");
12581262
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.
12591265 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);
12611269
12621270 // The studio's app page renders the same error in its head, for a
12631271 // 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
18881896 expect(status?.error).toBeUndefined();
18891897 }, 30_000);
18901898 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs