Public: anyone can read this; the crew can change it.
When an app cannot start, the studio shows the forge's own filesystem path #217
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/202-when-an-app-cannot-start-the-studio-show → main 1 commit fork
2 files
changed +147
−3
src/apps/process.ts +22 −3
| @@ -143,6 +143,22 @@ function operatorVars( | ||
| 143 | 143 | return out; |
| 144 | 144 | } |
| 145 | 145 | |
| 146 | /** | |
| 147 | * The forge's own directories stripped from a line of output — the data | |
| 148 | * directory, the app's id, the environment and the per-commit checkout are | |
| 149 | * the forge's private layout, not the app's; a stack trace naming | |
| 150 | * `dir/src/server.ts:1:11` reads as `src/server.ts:1:11` instead, the path | |
| 151 | * the app's own source control knows. Applied where output is captured, not | |
| 152 | * filtered — a line with no match passes through whole, so a message never | |
| 153 | * loses its stack for the sake of hiding a directory. | |
| 154 | */ | |
| 155 | function stripForgeDirs(dirs: readonly string[], line: string): string { | |
| 156 | return dirs.reduce( | |
| 157 | (out, dir) => out.split(`${dir}/`).join("").split(dir).join("."), | |
| 158 | line, | |
| 159 | ); | |
| 160 | } | |
| 161 | ||
| 146 | 162 | /** |
| 147 | 163 | * Run a step to completion, streaming its output. A non-zero exit, a |
| 148 | 164 | * timeout, or an abort is an Err with the reason a person would want. |
| @@ -166,6 +182,8 @@ export async function runStep( | ||
| 166 | 182 | stdout: "pipe", |
| 167 | 183 | stderr: "pipe", |
| 168 | 184 | }); |
| 185 | const scrub = (line: string): void => | |
| 186 | options.log(stripForgeDirs([options.cwd], line)); | |
| 169 | 187 | const timeoutMs = options.timeoutMs ?? STEP_TIMEOUT_MS; |
| 170 | 188 | let why: string | undefined; |
| 171 | 189 | const timer = setTimeout(() => { |
| @@ -180,8 +198,8 @@ export async function runStep( | ||
| 180 | 198 | else options.signal.addEventListener("abort", onAbort, { once: true }); |
| 181 | 199 | try { |
| 182 | 200 | const [, , code] = await Promise.all([ |
| 183 | pump(proc.stdout, options.log), | |
| 184 | pump(proc.stderr, options.log), | |
| 201 | pump(proc.stdout, scrub), | |
| 202 | pump(proc.stderr, scrub), | |
| 185 | 203 | proc.exited, |
| 186 | 204 | ]); |
| 187 | 205 | if (why !== undefined) return err(why); |
| @@ -262,7 +280,8 @@ export async function startProcess( | ||
| 262 | 280 | }); |
| 263 | 281 | // Output is pumped for the life of the process, not just the start. |
| 264 | 282 | const recent: string[] = []; |
| 265 | const remember = (line: string): void => { | |
| 283 | const remember = (raw: string): void => { | |
| 284 | const line = stripForgeDirs([input.dir, input.dataDir], raw); | |
| 266 | 285 | recent.push(line); |
| 267 | 286 | if (recent.length > 20) recent.shift(); |
| 268 | 287 | log(line); |
test/apps.test.ts +125
| @@ -1095,6 +1095,131 @@ exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch | ||
| 1095 | 1095 | }, 60_000); |
| 1096 | 1096 | }); |
| 1097 | 1097 | |
| 1098 | describe("a start failure is said in the app's own terms, not the forge's paths (issue #202)", () => { | |
| 1099 | let dataDir: string; | |
| 1100 | let app: App; | |
| 1101 | let servers: Servers; | |
| 1102 | let runtime: AppsRuntime; | |
| 1103 | let alice: User; | |
| 1104 | let cookie: string; | |
| 1105 | const DOMAIN = "leaky.test"; | |
| 1106 | ||
| 1107 | async function settled( | |
| 1108 | where: (run: Run) => boolean, | |
| 1109 | repoId: string, | |
| 1110 | timeoutMs = 30_000, | |
| 1111 | ): Promise<Run> { | |
| 1112 | const deadline = Date.now() + timeoutMs; | |
| 1113 | while (Date.now() < deadline) { | |
| 1114 | const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find( | |
| 1115 | where, | |
| 1116 | ); | |
| 1117 | if ( | |
| 1118 | run && | |
| 1119 | (run.state === "ok" || | |
| 1120 | run.state === "failed" || | |
| 1121 | run.state === "cancelled") | |
| 1122 | ) | |
| 1123 | return run; | |
| 1124 | await Bun.sleep(50); | |
| 1125 | } | |
| 1126 | throw new Error("run did not settle in time"); | |
| 1127 | } | |
| 1128 | ||
| 1129 | const FORGE_JSON = JSON.stringify({ | |
| 1130 | app: { run: "bun run src/server.ts", install: "", health: "/", ready: 3 }, | |
| 1131 | }); | |
| 1132 | ||
| 1133 | beforeAll(async () => { | |
| 1134 | dataDir = await mkdtemp(join(tmpdir(), "forge-leaky-")); | |
| 1135 | app = createApp({ dataDir, port: 0 }); | |
| 1136 | servers = await serve(app); | |
| 1137 | runtime = new AppsRuntime(app, { | |
| 1138 | domain: DOMAIN, | |
| 1139 | scheme: "http", | |
| 1140 | publicPort: servers.port, | |
| 1141 | forgeUrl: servers.url, | |
| 1142 | concurrency: 2, | |
| 1143 | }); | |
| 1144 | await runtime.start(); | |
| 1145 | const created = await createUser(app.db, { | |
| 1146 | username: "alice", | |
| 1147 | email: "[email protected]", | |
| 1148 | password: "a-good-passphrase", | |
| 1149 | adminIfFirst: true, | |
| 1150 | }); | |
| 1151 | if (!created.ok) throw new Error(created.error); | |
| 1152 | alice = created.value; | |
| 1153 | cookie = ( | |
| 1154 | ( | |
| 1155 | await fetch(`${servers.url}/signin`, { | |
| 1156 | method: "POST", | |
| 1157 | body: new URLSearchParams({ | |
| 1158 | username: "alice", | |
| 1159 | password: "a-good-passphrase", | |
| 1160 | }), | |
| 1161 | redirect: "manual", | |
| 1162 | }) | |
| 1163 | ).headers.get("set-cookie") ?? "" | |
| 1164 | ).split(";")[0]!; | |
| 1165 | }, 30_000); | |
| 1166 | ||
| 1167 | afterAll(async () => { | |
| 1168 | await runtime.stop(); | |
| 1169 | servers.stop(); | |
| 1170 | app.close(); | |
| 1171 | await rm(dataDir, { recursive: true, force: true }); | |
| 1172 | }); | |
| 1173 | ||
| 1174 | test("a deploy whose app throws on import names its own file, never the forge's data directory or the app's id", async () => { | |
| 1175 | const made = await createRepo(app, alice, { | |
| 1176 | name: "throws", | |
| 1177 | visibility: "public", | |
| 1178 | }); | |
| 1179 | expect(made.ok).toBe(true); | |
| 1180 | if (!made.ok) return; | |
| 1181 | const repo = made.value; | |
| 1182 | const committed = await commitToBranch(app, alice, repo, { | |
| 1183 | message: "An app that throws on import", | |
| 1184 | files: [ | |
| 1185 | { path: "forge.json", content: FORGE_JSON }, | |
| 1186 | { path: "src/server.ts", content: `throw new Error("boom");\n` }, | |
| 1187 | ], | |
| 1188 | }); | |
| 1189 | expect(committed.ok).toBe(true); | |
| 1190 | const enabled = enableApp(app, alice, repo, {}); | |
| 1191 | expect(enabled.ok).toBe(true); | |
| 1192 | if (!enabled.ok) return; | |
| 1193 | const record = enabled.value; | |
| 1194 | ||
| 1195 | const run = await settled((r) => r.env === "production", repo.id); | |
| 1196 | expect(run.state).toBe("failed"); | |
| 1197 | ||
| 1198 | const notForgePaths = (text: string): void => { | |
| 1199 | expect(text).toContain("src/server.ts"); | |
| 1200 | expect(text).not.toContain(dataDir); | |
| 1201 | expect(text).not.toContain(record.id); | |
| 1202 | }; | |
| 1203 | ||
| 1204 | // The error the runtime stores — the same one a run's summary and the | |
| 1205 | // studio's head card both draw from — names the app's own file. | |
| 1206 | const status = runtime.envStatus(record, "production"); | |
| 1207 | notForgePaths(status?.error ?? ""); | |
| 1208 | notForgePaths(run.summary ?? ""); | |
| 1209 | ||
| 1210 | const { lines } = readRunLog(dataDir, run.id); | |
| 1211 | notForgePaths(lines.join("\n")); | |
| 1212 | ||
| 1213 | // The studio's app page renders the same error in its head, for a | |
| 1214 | // reader who never asked to see the forge's own filesystem. | |
| 1215 | const page = await fetch(`${servers.url}/${repo.ownerName}/${repo.name}`, { | |
| 1216 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 1217 | }); | |
| 1218 | expect(page.status).toBe(200); | |
| 1219 | notForgePaths(await page.text()); | |
| 1220 | }, 60_000); | |
| 1221 | }); | |
| 1222 | ||
| 1098 | 1223 | describe("New app: turn the sentence into a plan and pick the crew", () => { |
| 1099 | 1224 | let dataDir: string; |
| 1100 | 1225 | let app: App; |
new above
offline