fix(coding-agent): contained browser timeout rejections

Observed every browser facade continuation so fire-and-forget helper
timeouts cannot wedge or kill a tab worker. Preserved native Promise
identity for callers and test matchers.
This commit is contained in:
brymko
2026-08-05 14:31:03 +08:00
parent f7f8e040ee
commit 364e13c49e
5 changed files with 166 additions and 8 deletions
@@ -4,6 +4,9 @@ import { JsRuntime, type RuntimeHooks } from "../../src/eval/js/shared/runtime";
import { bindRunFacade, markHandled, waitForRun } from "../../src/tools/run-scope";
import { ToolAbortError } from "../../src/tools/tool-errors";
const runCancellationModuleUrl = new URL("../../src/tools/browser/run-cancellation.ts", import.meta.url).href;
const toolErrorsModuleUrl = new URL("../../src/tools/tool-errors.ts", import.meta.url).href;
async function collectUnhandledRejections(action: () => void | Promise<void>): Promise<unknown[]> {
const reasons: unknown[] = [];
const onUnhandled = (reason: unknown) => reasons.push(reason);
@@ -127,6 +130,40 @@ describe("browser run cancellation", () => {
expect(reasons).toEqual([]);
});
it("contains an unhandled descendant of a timed-out browser helper promise", async () => {
const script = `
import { bindBrowserRunFacade } from ${JSON.stringify(runCancellationModuleUrl)};
import { ToolError } from ${JSON.stringify(toolErrorsModuleUrl)};
const facade = bindBrowserRunFacade(
{
waitForResponse() {
return Promise.reject(new ToolError("tab.waitForResponse() timed out after 15ms"));
},
},
new AbortController().signal,
);
void facade.waitForResponse().then(() => undefined);
await Promise.resolve();
await Promise.resolve();
console.log("browser probe survived");
`;
const proc = Bun.spawn([process.execPath, "-e", script], {
cwd: process.cwd(),
stdout: "pipe",
stderr: "pipe",
});
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("browser probe survived");
expect(stderr).not.toContain("[Unhandled Rejection]");
});
it("rejects awaited facade method calls that settle after abort", async () => {
const controller = new AbortController();
const deferred = Promise.withResolvers<string>();
@@ -208,6 +208,44 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => {
}
}, 30_000);
it("keeps the tab worker alive after an unhandled waitForResponse timeout descendant", async () => {
const tool = new BrowserTool(makeSession());
const name = `response-timeout-descendant-${process.pid}`;
try {
await tool.execute("open", {
action: "open",
name,
url: "data:text/html,<h1>ready</h1>",
});
const tabSession = getTabsMapForTest().get(name);
if (tabSession?.backend !== "worker") throw new Error("Worker tab was not created");
expect(tabSession.worker.mode).toBe("worker");
const result = await tool.execute("run", {
action: "run",
name,
timeout: 2,
// Real worker timers are intentional: the rejection must cross an
// unhandledRejection turn while the browser run remains active.
code: `
void tab.waitForResponse("/never", { timeout: 10 }).then(() => undefined);
await Bun.sleep(50);
return "survived timeout";
`,
});
expect(result.content).toEqual([{ type: "text", text: "survived timeout" }]);
const followup = await tool.execute("run", {
action: "run",
name,
code: "return 42;",
});
expect(followup.content).toEqual([{ type: "text", text: "42" }]);
} finally {
await tool.execute("close", { action: "close", name, kill: true });
}
}, 30_000);
it("observes floating raw page promises when the target closes", async () => {
const tool = new BrowserTool(makeSession());
const name = `target-close-${process.pid}`;