diff --git a/packages/coding-agent/src/tools/browser/tab-supervisor.ts b/packages/coding-agent/src/tools/browser/tab-supervisor.ts index 11cbee89d..de77a734c 100644 --- a/packages/coding-agent/src/tools/browser/tab-supervisor.ts +++ b/packages/coding-agent/src/tools/browser/tab-supervisor.ts @@ -30,6 +30,7 @@ import type { interface WorkerHandle { send(msg: WorkerInbound, transferList?: Transferable[]): void; onMessage(handler: (msg: WorkerOutbound) => void): () => void; + onError(handler: (error: Error) => void): () => void; terminate(): Promise; readonly mode: "worker" | "inline"; } @@ -120,23 +121,14 @@ export async function acquireTab( const initPayload = await buildInitPayload(browser, opts); const worker = await spawnTabWorker(); - const { promise, resolve, reject } = Promise.withResolvers(); - const unlisten = worker.onMessage(msg => { - if (msg.type === "ready") resolve(msg.info); - else if (msg.type === "init-failed") reject(errorFromPayload(msg.error)); - else if (msg.type === "log") logWorkerMessage(msg); - }); let info: ReadyInfo; try { - worker.send({ type: "init", payload: initPayload }); - info = await raceWithTimeout(promise, opts.timeoutMs + GRACE_MS, "Timed out initializing browser tab worker"); + info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS); } catch (error) { - unlisten(); await worker.terminate().catch(() => undefined); if (browser.refCount === 0) await releaseBrowser(browser, { kill: false }); throw error; } - unlisten(); holdBrowser(browser); const tab: TabSession = { @@ -473,6 +465,17 @@ function wrapBunWorker(worker: Worker): WorkerHandle { worker.addEventListener("message", wrap); return () => worker.removeEventListener("message", wrap); }, + onError(handler) { + const onError = (event: ErrorEvent): void => handler(errorFromWorkerEvent(event)); + const onMessageError = (event: MessageEvent): void => + handler(new ToolError(`Tab worker message error: ${String(event.data)}`)); + worker.addEventListener("error", onError); + worker.addEventListener("messageerror", onMessageError); + return () => { + worker.removeEventListener("error", onError); + worker.removeEventListener("messageerror", onMessageError); + }; + }, async terminate() { worker.terminate(); }, @@ -511,6 +514,44 @@ async function spawnInlineWorker(): Promise { hostListeners.add(handler); return () => hostListeners.delete(handler); }, + onError: () => () => {}, async terminate() {}, }; } + +async function initializeTabWorker( + worker: WorkerHandle, + payload: WorkerInitPayload, + timeoutMs: number, +): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const unlisten = worker.onMessage(msg => { + if (msg.type === "ready") resolve(msg.info); + else if (msg.type === "init-failed") reject(errorFromPayload(msg.error)); + else if (msg.type === "log") logWorkerMessage(msg); + }); + const unlistenError = worker.onError(error => { + reject(new ToolError(`Tab worker failed during startup: ${error.message}`)); + }); + try { + worker.send({ type: "init", payload }); + return await raceWithTimeout(promise, timeoutMs, "Timed out initializing browser tab worker"); + } finally { + unlisten(); + unlistenError(); + } +} + +export function initializeTabWorkerForTest( + worker: WorkerHandle, + payload: WorkerInitPayload, + timeoutMs: number, +): Promise { + return initializeTabWorker(worker, payload, timeoutMs); +} + +function errorFromWorkerEvent(event: ErrorEvent): Error { + if (event.error instanceof Error) return event.error; + if (event.message) return new Error(event.message); + return new Error("Unknown tab worker error"); +} diff --git a/packages/coding-agent/test/tools/browser-tab-worker-startup.test.ts b/packages/coding-agent/test/tools/browser-tab-worker-startup.test.ts new file mode 100644 index 000000000..c884bff4f --- /dev/null +++ b/packages/coding-agent/test/tools/browser-tab-worker-startup.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "bun:test"; +import type { ReadyInfo, WorkerInbound, WorkerOutbound } from "../../src/tools/browser/tab-protocol"; +import { initializeTabWorkerForTest } from "../../src/tools/browser/tab-supervisor"; + +class FakeStartupWorker { + #errorHandlers = new Set<(error: Error) => void>(); + #messageHandlers = new Set<(msg: WorkerOutbound) => void>(); + readonly sent: WorkerInbound[] = []; + readonly mode = "worker" as const; + + send(msg: WorkerInbound): void { + this.sent.push(msg); + } + + onMessage(handler: (msg: WorkerOutbound) => void): () => void { + this.#messageHandlers.add(handler); + return () => this.#messageHandlers.delete(handler); + } + + onError(handler: (error: Error) => void): () => void { + this.#errorHandlers.add(handler); + return () => this.#errorHandlers.delete(handler); + } + + async terminate(): Promise {} + + emitReady(info: ReadyInfo): void { + for (const handler of this.#messageHandlers) handler({ type: "ready", info }); + } + + emitError(error: Error): void { + for (const handler of this.#errorHandlers) handler(error); + } +} + +const initPayload = { + mode: "headless" as const, + browserWSEndpoint: "ws://127.0.0.1/devtools/browser/test", + safeDir: "/tmp/omp-puppeteer", + timeoutMs: 1_000, +}; + +describe("browser tab worker startup", () => { + it("surfaces worker startup errors instead of waiting for the generic init timeout", async () => { + const worker = new FakeStartupWorker(); + const pending = initializeTabWorkerForTest(worker, initPayload, 1_000); + + worker.emitError(new Error("Cannot find tab-worker-entry.ts")); + + await expect(pending).rejects.toThrow("Tab worker failed during startup: Cannot find tab-worker-entry.ts"); + expect(worker.sent).toEqual([{ type: "init", payload: initPayload }]); + }); +});