From 9b7e72f06e7bd0f6e2ff474c72af0b793293bab6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 31 Oct 2026 01:40:49 -0700 Subject: [PATCH] fix(hub): keep caught errors in the coder's build tools --- apps/hub/src/builds/import-scan.ts | 28 ++- apps/hub/src/builds/package-install.ts | 35 ++-- apps/hub/src/builds/posix-child.ts | 4 +- apps/hub/src/builds/posix-sandbox.ts | 262 ++++++++++++++----------- apps/hub/src/workflows/http-json.ts | 8 +- 5 files changed, 199 insertions(+), 138 deletions(-) diff --git a/apps/hub/src/builds/import-scan.ts b/apps/hub/src/builds/import-scan.ts index 8c931885..7103be3a 100644 --- a/apps/hub/src/builds/import-scan.ts +++ b/apps/hub/src/builds/import-scan.ts @@ -1,6 +1,8 @@ import { readFile } from "node:fs/promises"; import { builtinModules } from "node:module"; import { dirname, join, relative, resolve } from "node:path"; +import { type } from "arktype"; +import { hasCode } from "@intx/types/has-code"; import { DEPENDENCY_FIELDS, Manifest } from "./package-install.ts"; const LOADERS: Record = { @@ -25,11 +27,19 @@ const packageOf = (specifier: string) => { }; async function manifest(path: string) { + let parsed: unknown; try { - return Manifest.assert(JSON.parse(await readFile(path, "utf8"))); - } catch { - return undefined; + parsed = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if ( + (hasCode(error) && error.code === "ENOENT") || + error instanceof SyntaxError + ) + return undefined; + throw error; } + const found = Manifest(parsed); + return found instanceof type.errors ? undefined : found; } const declared = (found: typeof Manifest.infer | undefined) => [ @@ -47,11 +57,17 @@ export async function undeclaredImports(workspace: string, path: string) { if (!loader || relative(workspace, file).startsWith("..")) return undefined; let imported: string[]; try { + const source = await readFile(file, "utf8"); imported = new Bun.Transpiler({ loader }) - .scanImports(await readFile(file, "utf8")) + .scanImports(source) .map((entry) => entry.path); - } catch { - // A file that does not parse yet has no imports to check; check-app says why. + } catch (error) { + const expected = + error instanceof BuildMessage || + error instanceof AggregateError || + (hasCode(error) && error.code === "ENOENT"); + if (!expected) throw error; + // A file that does not parse yet, or was deleted mid-turn, has no imports to check; check-app says why. return undefined; } let directory = dirname(file); diff --git a/apps/hub/src/builds/package-install.ts b/apps/hub/src/builds/package-install.ts index 9b95bd50..78a40453 100644 --- a/apps/hub/src/builds/package-install.ts +++ b/apps/hub/src/builds/package-install.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { type } from "arktype"; +import { hasCode } from "@intx/types/has-code"; import type { ToolCall, ToolResult, ToolRunner } from "@intx/types/runtime"; import { PACKAGE_INSTALL_TOOL, @@ -42,14 +43,21 @@ const Lockfile = type({ "workspaces?": type({ "[string]": Manifest }) }); /** The packages bun.lock records as installed for some package of the workspace. */ export async function installedPackages(workspace: string) { const names = new Set(); - let lock: typeof Lockfile.infer; + let parsed: unknown; try { - lock = Lockfile.assert( - Bun.JSONC.parse(await readFile(join(workspace, "bun.lock"), "utf8")), + parsed = Bun.JSONC.parse( + await readFile(join(workspace, "bun.lock"), "utf8"), ); - } catch { - return names; + } catch (error) { + if ( + (hasCode(error) && error.code === "ENOENT") || + error instanceof SyntaxError + ) + return names; + throw error; } + const lock = Lockfile(parsed); + if (lock instanceof type.errors) return names; for (const manifest of Object.values(lock.workspaces ?? {})) for (const field of DEPENDENCY_FIELDS) for (const name of Object.keys(manifest[field] ?? {})) names.add(name); @@ -89,14 +97,19 @@ export function createPackageGate(workspace: string, executor: ToolRunner) { ) ).flat(); for (const path of manifests) { - let manifest: typeof Manifest.infer; + let parsed: unknown; try { - manifest = Manifest.assert( - JSON.parse(await readFile(join(workspace, path), "utf8")), - ); - } catch { - continue; + parsed = JSON.parse(await readFile(join(workspace, path), "utf8")); + } catch (error) { + if ( + (hasCode(error) && error.code === "ENOENT") || + error instanceof SyntaxError + ) + continue; + throw error; } + const manifest = Manifest(parsed); + if (manifest instanceof type.errors) continue; for (const field of DEPENDENCY_FIELDS) for (const [name, spec] of Object.entries(manifest[field] ?? {})) if (spec.startsWith("workspace:")) continue; diff --git a/apps/hub/src/builds/posix-child.ts b/apps/hub/src/builds/posix-child.ts index e3d2a4ef..08a386f0 100644 --- a/apps/hub/src/builds/posix-child.ts +++ b/apps/hub/src/builds/posix-child.ts @@ -53,9 +53,7 @@ export async function runHostGate( outputLimited = false, total = 0; const stop = () => { - try { - child.kill("SIGKILL"); - } catch {} + child.kill("SIGKILL"); }; const timer = setTimeout(() => { timedOut = true; diff --git a/apps/hub/src/builds/posix-sandbox.ts b/apps/hub/src/builds/posix-sandbox.ts index 1c48b6fc..e20bce39 100644 --- a/apps/hub/src/builds/posix-sandbox.ts +++ b/apps/hub/src/builds/posix-sandbox.ts @@ -17,6 +17,7 @@ import { } from "@anthropic-ai/sandbox-runtime"; import { type } from "arktype"; import { getLogger } from "@intx/log"; +import { hasCode } from "@intx/types/has-code"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; import { requestErrorMetadata } from "../request-error.ts"; import type { buildPackageLayout } from "./package-layout.ts"; @@ -114,7 +115,8 @@ export function publicHostName(host: string): boolean { let name: string; try { name = new URL(`http://${host}/`).hostname.toLowerCase().replace(/\.$/, ""); - } catch { + } catch (error) { + if (!(error instanceof TypeError)) throw error; return false; } return ( @@ -126,6 +128,11 @@ export function publicHostName(host: string): boolean { ); } +// macOS answers EPERM, not ESRCH, when every member of the group is an unreaped zombie. +function isGroupGone(error: unknown) { + return hasCode(error) && (error.code === "ESRCH" || error.code === "EPERM"); +} + // Playwright launches Chrome with its own sandbox off; the macOS sandbox profile lets no process // register Mach services, so Chrome also runs as one process, as check-app runs it. const BROWSER_CONFIG = JSON.stringify({ @@ -352,7 +359,8 @@ export async function createPosixBuildExecutor( const kill = (child: Bun.Subprocess) => { try { process.kill(-child.pid, "SIGKILL"); - } catch { + } catch (error) { + if (!isGroupGone(error)) throw error; child.kill("SIGKILL"); } }; @@ -419,7 +427,8 @@ export async function createPosixBuildExecutor( } try { process.kill(-child.pid, "SIGKILL"); - } catch { + } catch (error) { + if (!isGroupGone(error)) throw error; child.kill("SIGKILL"); } }; @@ -495,129 +504,150 @@ export async function createPosixBuildExecutor( devApp.view().stopped === undefined ) return Promise.resolve(devApp.view()); - devAppStarting ??= (async () => { - await stopApp(); - const [port, clientPort] = await Promise.all([freePort(), freePort()]); - const command = await SandboxManager.wrapWithSandbox( - `exec ${quote(executable)} run dev`, - "/bin/sh", - ); - const child = Bun.spawn(["/bin/sh", "-c", command], { - cwd, - env: { - ...childEnv, - PORT: String(port), - CLIENT_PORT: String(clientPort), - ...(process.platform === "darwin" ? MACOS_WATCH_POLLING : {}), - }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - detached: process.platform !== "linux", - }); - const lines: string[] = []; - let stopped: string | undefined; - const current = () => !disposed && devApp?.process === child; - // `bun --filter` keeps the other half running when one exits, so the dev server still - // answers while its API is gone. The next file change starts the whole app again. - const partStopped = (name: string) => { - stopped = name; - if (!devApp || !current()) return; - devApp.watcher = watch(cwd, { recursive: true }, (_event, file) => { - if (file === null || IGNORED_CHANGES.test(file) || !current()) - return; - void startApp().catch((error: unknown) => + const launchDevApp = async () => { + try { + await stopApp(); + const [port, clientPort] = await Promise.all([ + freePort(), + freePort(), + ]); + const command = await SandboxManager.wrapWithSandbox( + `exec ${quote(executable)} run dev`, + "/bin/sh", + ); + const child = Bun.spawn(["/bin/sh", "-c", command], { + cwd, + env: { + ...childEnv, + PORT: String(port), + CLIENT_PORT: String(clientPort), + ...(process.platform === "darwin" ? MACOS_WATCH_POLLING : {}), + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + detached: process.platform !== "linux", + }); + const lines: string[] = []; + let stopped: string | undefined; + const current = () => !disposed && devApp?.process === child; + // `bun --filter` keeps the other half running when one exits, so the dev server still + // answers while its API is gone. The next file change starts the whole app again. + const restartAfterChange = async () => { + try { + await startApp(); + } catch (error) { log.warn( "Restarting the dev app after a file change failed", requestErrorMetadata(error), - ), - ); - }); - }; - const collect = async (stream: ReadableStream) => { - const decoder = new TextDecoder(); - // The last complaint on this stream, which the frame on the next line locates. - let complaint: string | undefined; - for await (const chunk of stream) - for (const line of decoder - .decode(chunk, { stream: true }) - .split("\n")) { - if (!line.trim()) continue; - if (lines.push(line) > 200) lines.shift(); - const exited = PART_EXITED.exec(line); - if (exited && stopped === undefined) partStopped(exited[1]!); - if (!current()) continue; - const [, part = "app", said = line] = PART_LINE.exec(line) ?? []; - const text = said.replace(TIME_OF_DAY, ""); - if (FRAME.test(text)) { - if (complaint && !RUNTIME_FRAME.test(text)) { - complaints.delete(complaint); - complain(`${complaint} (${text.trim()})`); - } - complaint = undefined; - } else - complaint = - COMPLAINT.test(text) && - !SOURCE_LINE.test(text) && - !BARE_ERROR.test(text) - ? complain(`${part}: ${text}`) - : undefined; + ); } - }; - void Promise.all([collect(child.stdout), collect(child.stderr)]); - // Vite serves the client on CLIENT_PORT and proxies /api to the server on PORT. - const url = `http://127.0.0.1:${clientPort}`; - const view = (): DevApp => ({ - url, - running: child.exitCode === null, - log: lines.slice(-40), - ...(stopped === undefined ? {} : { stopped }), - }); - devApp = { process: child, view }; - if (options.pageWatcher) { - const page = Bun.spawn( - [ - "/bin/sh", - "-c", - await SandboxManager.wrapWithSandbox( - `exec ${[executable, "--no-env-file", pageWatcher, url].map(quote).join(" ")}`, - "/bin/sh", - ), - ], - { - cwd, - env: childEnv, - stdin: "pipe", - stdout: "pipe", - stderr: "ignore", - detached: process.platform !== "linux", - }, - ); - devApp.page = page; - void (async () => { + }; + const partStopped = (name: string) => { + stopped = name; + if (!devApp || !current()) return; + devApp.watcher = watch(cwd, { recursive: true }, (_event, file) => { + if (file === null || IGNORED_CHANGES.test(file) || !current()) + return; + void restartAfterChange(); + }); + }; + const collect = async (stream: ReadableStream) => { const decoder = new TextDecoder(); - for await (const chunk of page.stdout) + // The last complaint on this stream, which the frame on the next line locates. + let complaint: string | undefined; + for await (const chunk of stream) for (const line of decoder .decode(chunk, { stream: true }) .split("\n")) { - if (!line.trim() || !current()) continue; - try { - complain( - `page: ${PageError.assert(JSON.parse(line)).detail}`, - ); - } catch { - continue; - } + if (!line.trim()) continue; + if (lines.push(line) > 200) lines.shift(); + const exited = PART_EXITED.exec(line); + if (exited && stopped === undefined) partStopped(exited[1]!); + if (!current()) continue; + const match = PART_LINE.exec(line); + const part = match?.[1] ?? "app"; + const said = match?.[2] ?? line; + const text = said.replace(TIME_OF_DAY, ""); + if (FRAME.test(text)) { + if (complaint && !RUNTIME_FRAME.test(text)) { + complaints.delete(complaint); + complain(`${complaint} (${text.trim()})`); + } + complaint = undefined; + } else + complaint = + COMPLAINT.test(text) && + !SOURCE_LINE.test(text) && + !BARE_ERROR.test(text) + ? complain(`${part}: ${text}`) + : undefined; } - })().catch((error: unknown) => - log.warn( - "Reading the page watcher's output failed", - requestErrorMetadata(error), - ), - ); + }; + void Promise.all([collect(child.stdout), collect(child.stderr)]); + // Vite serves the client on CLIENT_PORT and proxies /api to the server on PORT. + const url = `http://127.0.0.1:${clientPort}`; + const view = (): DevApp => ({ + url, + running: child.exitCode === null, + log: lines.slice(-40), + ...(stopped === undefined ? {} : { stopped }), + }); + devApp = { process: child, view }; + if (options.pageWatcher) { + const page = Bun.spawn( + [ + "/bin/sh", + "-c", + await SandboxManager.wrapWithSandbox( + `exec ${[executable, "--no-env-file", pageWatcher, url].map(quote).join(" ")}`, + "/bin/sh", + ), + ], + { + cwd, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "ignore", + detached: process.platform !== "linux", + }, + ); + devApp.page = page; + async function readPageReports() { + const decoder = new TextDecoder(); + try { + for await (const chunk of page.stdout) + for (const line of decoder + .decode(chunk, { stream: true }) + .split("\n")) { + if (!line.trim() || !current()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + if (error instanceof SyntaxError) continue; + throw error; + } + const report = PageError(parsed); + if (!(report instanceof type.errors)) + complain(`page: ${report.detail}`); + } + } catch (error) { + log.warn( + "Reading the page watcher's output failed", + requestErrorMetadata(error), + ); + } + } + void readPageReports(); + } + return view(); + } finally { + devAppStarting = undefined; } - return view(); - })().finally(() => (devAppStarting = undefined)); + }; + devAppStarting ??= launchDevApp(); return devAppStarting; }; // Playwright's MCP server for the coder's browser, under the same sandbox as its app and driving diff --git a/apps/hub/src/workflows/http-json.ts b/apps/hub/src/workflows/http-json.ts index eaed00c5..943fc801 100644 --- a/apps/hub/src/workflows/http-json.ts +++ b/apps/hub/src/workflows/http-json.ts @@ -70,8 +70,11 @@ export async function mediatedHttpJson(options: { let refusalCode: string | undefined; let issues: { path: string; message: string }[] | undefined; let hint: string | undefined; + const text = await response.text(); try { - const parsed = JSON.parse(await response.text()) as { + const value: unknown = JSON.parse(text); + if (!value || typeof value !== "object") throw new SyntaxError(text); + const parsed = value as { code?: unknown; error?: { code?: unknown }; issues?: unknown; @@ -98,7 +101,8 @@ export async function mediatedHttpJson(options: { ] : [], ); - } catch { + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; // A refusal body that is not JSON carries no code. } throw new WorkflowHttpError(