fix(browser): propagate cmux tab-close into the run body, not only the caller

Codex review of #4502 flagged that a bare `.catch(() => undefined)`
neutralizes the unhandledRejection but leaves the affected `runInTab`
call blocked inside `runCmuxCode` until timeout when the in-flight
code does not make another cmux socket request (e.g. `await
wait(60_000)`). `releaseTab` was signaling the run only by rejecting
an orphaned promise.

Wire the tab-close event all the way into the cmux run body:

- `PendingRun` gains a `closeAc: AbortController` that `releaseTab`
  aborts BEFORE calling `pending.reject`. `wait(...)` (via
  `waitForBrowserRun` -> `untilAborted`), in-flight cmux socket calls
  (via CmuxTab's `#request` -> `untilAborted`), and facade proxies
  (via `bindBrowserRunFacade`) all consume the composed signal, so
  the run body unwinds within a microtask instead of blocking to its
  own timeout.
- `runInTabWithSnapshot`'s cmux branch composes `closeAc.signal` into
  the run's signal (`AbortSignal.any([opts.signal, closeAc.signal])`)
  and now publishes `runCmuxCode(...)`'s outcome to the shared
  `promise` via `.then(resolve, reject)` and returns `await promise`.
  Both branches thus await the same promise, so `pending.reject`
  always has an attached handler (removing the original crash) AND
  the caller sees `Tab "..." was closed` immediately instead of
  waiting on the run's timeout.
- Drop the defensive `promise.catch(() => undefined)` — the promise
  is now actively consumed on both backends.

The new regression test adds a second case that exercises the
reviewer's exact scenario (`await wait(60_000);`) and asserts:
1. `pending.closeAc.signal.aborted` flips from `false` to `true`
   across `releaseTab`, with the tab-close error as its reason.
2. The awaited `runInTab(...)` rejects with `Tab "..." was closed`.
3. No `unhandledRejection` fires.

Verified locally by temporarily removing `closeAc.abort(...)` in
`releaseTab` — the new assertions fail; restoring it makes them pass.

Fixes #4499
This commit is contained in:
roboomp
2026-07-04 06:24:45 +00:00
parent 3622094e91
commit a6a8258945
3 changed files with 203 additions and 59 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
### Fixed
- Fixed cmux-backend `browser({action:"run"})` calls crashing the entire process with an unhandled rejection when the tab was released mid-run (e.g. a sibling subagent calling `browser({action:"close", all:true})` or a session-scoped tab reap). `runInTabWithSnapshot` in `tab-supervisor.ts` always creates a `Promise.withResolvers()` triple so `releaseTab` can signal in-flight runs, but the cmux branch awaits `runCmuxCode(...)` directly and never awaits/`.catch`es the local promise. When `releaseTab` rejected that orphaned promise ("Tab ... was closed"), Bun surfaced it as an unhandled rejection and the top-level handler tore the whole session down, killing every other tab and subagent sharing it. Attaching a no-op `.catch(() => undefined)` to the promise immediately after creation neutralizes the orphan without affecting the worker branch, which still awaits the same promise via `raceWithTimeout` ([#4499](https://github.com/can1357/oh-my-pi/issues/4499)).
- Fixed cmux-backend `browser({action:"run"})` calls crashing the entire process with an unhandled rejection when the tab was released mid-run (e.g. a sibling subagent calling `browser({action:"close", all:true})` or a session-scoped tab reap). `runInTabWithSnapshot` in `tab-supervisor.ts` creates a `Promise.withResolvers()` triple so `releaseTab` can signal in-flight runs, but the cmux branch used to await `runCmuxCode(...)` directly and never awaited the local promise. When `releaseTab` rejected that orphaned promise ("Tab ... was closed"), Bun surfaced it as an unhandled rejection and the top-level handler tore the whole session down, killing every other tab and subagent sharing it. Both backends now await the same `promise` (so `pending.reject` always has an attached handler AND the caller sees `Tab "..." was closed` immediately instead of blocking to the run's timeout), and a new `pending.closeAc` is composed into the cmux run's abort signal so `wait(...)`, in-flight cmux socket calls, and the facade proxies unwind promptly when the tab is closed rather than leaking to their own timeout ([#4499](https://github.com/can1357/oh-my-pi/issues/4499)).
## [16.3.5] - 2026-07-04
@@ -48,6 +48,16 @@ export interface PendingRun {
session: ToolSession;
signal?: AbortSignal;
toolCalls: Map<string, AbortController>;
/**
* Fires when `releaseTab` closes the tab out from under an in-flight run
* (sibling `browser close --all`, session-scoped reap, etc.). Composed
* into the cmux run's signal so `wait(...)`, cmux socket calls, and the
* facade proxies unwind promptly instead of blocking to the run's
* timeout. `pending.reject` still fires first so the awaiting caller
* sees the tab-close error immediately; `closeAc` propagates the
* cancellation into the still-running `runCmuxCode` body (issue #4499).
*/
closeAc?: AbortController;
}
interface TabSessionBase<TBrowser extends BrowserHandle = BrowserHandle> {
@@ -380,34 +390,47 @@ async function runInTabWithSnapshot(
if (tab.pending.size > 0) throw new ToolError(`Tab ${JSON.stringify(name)} is busy`);
const id = Snowflake.next();
const { promise, resolve, reject } = Promise.withResolvers<RunResultOk>();
// The cmux branch below never awaits `promise` — it awaits `runCmuxCode`
// directly, and only stashes {resolve, reject} here so `releaseTab` can
// signal in-flight runs when a tab dies out from under them (e.g. a
// sibling `browser close --all` while this run is in flight). With zero
// consumers, that `reject(...)` call surfaces as an unhandled rejection
// and the top-level handler tears the whole process down (issue #4499:
// "Tab ... was closed"). Attaching a no-op catch here is safe for the
// worker branch too — it still awaits the same promise via
// `raceWithTimeout` below, and adding a second handler to a promise is
// inert.
promise.catch(() => undefined);
// `releaseTab` calls `pending.reject(closeError)` when the tab dies
// out from under an in-flight run (sibling `browser close --all`,
// session-scoped reap, etc.). Both backends below MUST end up awaiting
// this same `promise` so:
// 1. The caller sees `Tab ... was closed` immediately instead of
// blocking to the run's timeout, and
// 2. `reject(...)` always has an attached handler — a zero-consumer
// rejection would fire `unhandledRejection` and the CLI's
// top-level handler would tear the whole session down, killing
// every other tab and subagent sharing the process (issue #4499).
// The cmux branch also composes `closeAc.signal` into the run's abort
// signal so `wait(...)`, cmux socket calls, and the facade proxies
// unwind promptly when the tab is closed — otherwise a `wait(60_000)`
// with no in-flight socket request would keep `runCmuxCode` blocked
// until timeout even after the tab is gone.
const closeAc = new AbortController();
const pending: PendingRun = {
resolve,
reject,
session: opts.session ?? ({} as ToolSession),
signal: opts.signal,
toolCalls: new Map(),
closeAc,
};
tab.pending.set(id, pending);
if (tab.backend === "cmux") {
const runSignal = opts.signal ? AbortSignal.any([opts.signal, closeAc.signal]) : closeAc.signal;
try {
return await runCmuxCode(tab.cmuxTab, {
// `runCmuxCode.then(resolve, reject)` publishes the run's real
// outcome to `promise`, but `releaseTab` may have already
// rejected it — `Promise.withResolvers` settles on the first
// call and later resolve/reject are no-ops, so the tab-close
// error still wins the race.
runCmuxCode(tab.cmuxTab, {
code: opts.code,
timeoutMs: opts.timeoutMs,
signal: opts.signal,
signal: runSignal,
session: pending.session,
snapshot,
});
}).then(resolve, reject);
return await promise;
} finally {
tab.pending.delete(id);
}
@@ -471,6 +494,16 @@ export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Pr
} catch {}
}
for (const ctrl of pending.toolCalls.values()) ctrl.abort(closeError);
// Propagate the closure into the cmux run's abort signal so
// `wait(...)`, in-flight cmux socket calls, and the facade proxies
// unwind promptly. Firing this BEFORE `pending.reject` means
// `runCmuxCode` finishes with `ToolAbortError` and its `.then(reject)`
// is a no-op — `promise` still settles with the tab-close error via
// the `reject` call below. Without it, a run that isn't currently
// making a socket request (e.g. `await wait(60_000)`) would keep
// `runCmuxCode` blocked until timeout even after `pending.reject`
// unblocked the caller (issue #4499 review feedback).
pending.closeAc?.abort(closeError);
pending.reject(closeError);
}
tab.pending.clear();
@@ -2,18 +2,30 @@
* Regression test for issue #4499: closing a cmux-backend tab while a
* `browser({ action: "run" })` call is in flight rejected an orphaned
* `Promise.withResolvers()` promise created in `runInTabWithSnapshot`. The
* cmux branch awaits `runCmuxCode(...)` directly and never awaits/`.catch`es
* the local `promise`; only `pending.reject` was stashed on the tab so
* `releaseTab` could signal in-flight runs. Zero consumers meant that
* `reject(...)` surfaced as an unhandled rejection and the top-level
* `unhandledRejection` handler tore the whole process down (killing sibling
* tabs and subagents).
* cmux branch originally awaited `runCmuxCode(...)` directly and never
* awaited/`.catch`ed the local `promise`; only `pending.reject` was stashed
* on the tab so `releaseTab` could signal in-flight runs. Zero consumers
* meant that `reject(...)` surfaced as an unhandled rejection and the
* top-level `unhandledRejection` handler tore the whole process down
* (killing sibling tabs and subagents).
*
* The fix in `runInTabWithSnapshot` attaches a no-op `.catch(() => undefined)`
* to that promise immediately after creation. This test drives real
* `acquireBrowser` / `acquireTab` / `runInTab` / `releaseTab` against a
* mocked `CmuxSocketClient` and asserts that racing `releaseTab` against an
* in-flight cmux run never triggers `process.on("unhandledRejection", ...)`.
* The fix in `runInTabWithSnapshot` makes both backends await the same
* `promise` (so `pending.reject` always has an attached handler AND the
* caller sees the tab-close error immediately) and composes a new
* `pending.closeAc` into the cmux run's abort signal, so `wait(...)` /
* in-flight cmux socket calls / facade proxies unwind promptly when the
* tab is closed. This test drives real `acquireBrowser` / `acquireTab` /
* `runInTab` / `releaseTab` against a mocked `CmuxSocketClient` and covers:
*
* 1. Racing `releaseTab` against an in-flight cmux run never triggers
* `process.on("unhandledRejection", ...)` — the original crash — AND
* the awaiting `runInTab` call now rejects with `Tab ... was closed`
* immediately instead of blocking to the run's timeout.
* 2. When the in-flight run is doing work that does NOT make another cmux
* socket request (e.g. `await wait(60_000)`), releasing the tab still
* unwinds the run — proving `closeAc.signal` reaches `waitForBrowserRun`
* and the facade proxies, not just the outer race. (Reviewer feedback
* from PR #4502.)
*/
import { afterEach, describe, expect, it, spyOn } from "bun:test";
@@ -27,7 +39,6 @@ import {
runInTab,
} from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index";
import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
function makeKind(socketSuffix: string): CmuxKind {
return {
@@ -60,7 +71,7 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => {
await drainAllTabs();
});
it("releaseTab() during an in-flight cmux run does not emit unhandledRejection", async () => {
it("releaseTab() during an in-flight cmux run rejects the run and never emits unhandledRejection", async () => {
spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined);
spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined);
@@ -72,8 +83,8 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => {
// -> this mock). This is the deterministic "the run is mid-flight" edge.
const navStarted = Promise.withResolvers<void>();
// Gate for the mocked `browser.navigate` response. Left pending across
// the window we care about, then resolved during teardown so
// `runCmuxCode` can settle and does not leak past the test.
// the window we care about, then resolved during teardown so nothing
// leaks past the test.
const navGate = Promise.withResolvers<Record<string, unknown>>();
spyOn(CmuxSocketClient.prototype, "request").mockImplementation(
@@ -86,9 +97,8 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => {
case "browser.snapshot":
return { page: { html: "" } };
case "browser.eval":
// Used by `readyInfo()` for `document.title` and geometry
// during `acquireCmuxTab` — return quickly so setup lands
// and the run gets a chance to reach `tab.goto` below.
// `readyInfo()` needs `document.title` + geometry during
// `acquireCmuxTab`; return quickly so setup lands.
return { value: "" };
case "browser.navigate":
navStarted.resolve();
@@ -108,8 +118,6 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => {
};
process.on("unhandledRejection", onUnhandled);
const runAc = new AbortController();
let runPromise: Promise<unknown> | undefined;
try {
const kind = makeKind("close-mid-run");
const browser = await acquireBrowser(kind, { cwd: "/tmp" });
@@ -121,18 +129,16 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => {
const session = makeSession("/tmp");
// Fire the run WITHOUT awaiting. `runtime.run` drives `tab.goto`,
// which drives `browser.navigate`, which stalls on `navGate`. So
// `runInTab` sits inside `runCmuxCode` with `tab.pending` holding
// the orphaned promise. Attach a swallowing catch so the eventual
// rejection from `runAc.abort()` in the finally block does not
// itself become an unhandled rejection.
runPromise = runInTab("docfinal", {
// which drives `browser.navigate`, which stalls on `navGate` — so
// `runInTab` sits inside `runCmuxCode` with `tab.pending` populated.
// The 60_000ms timeout is intentional: on `main` (or with only the
// no-op-catch fix), the call would block until this fires; a passing
// test proves `releaseTab` unblocks it immediately via `promise`.
const runPromise = runInTab("docfinal", {
code: 'await tab.goto("https://example.test");',
timeoutMs: 60_000,
session,
signal: runAc.signal,
});
runPromise.catch(() => undefined);
// Deterministic wait: proceed only once the cmux request is actually
// mid-flight (and therefore `tab.pending` is populated).
@@ -140,30 +146,135 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => {
const tabBeforeRelease = getTabsMapForTest().get("docfinal");
expect(tabBeforeRelease?.pending.size).toBeGreaterThan(0);
// This is the crash path from the reporter: `releaseTab` walks
// `tab.pending` and calls `pending.reject(new ToolError("Tab ... was closed"))`.
// Without the no-op catch on the orphaned promise, that rejection
// would surface as an unhandled rejection on the next microtask tick.
// `releaseTab` walks `tab.pending` and calls `pending.reject(new
// ToolError("Tab ... was closed"))`. On `main` this rejected an
// orphaned promise (unhandledRejection -> fatal). With the fix,
// the same reject settles the promise the caller is awaiting, so
// `runInTab` finishes with `Tab "docfinal" was closed`
// immediately — no 60s timeout wait.
const released = await releaseTab("docfinal", { kill: false });
expect(released).toBe(true);
// Drain the microtask queue so any pending unhandled-rejection would
// have fired by the time we assert. Two microtask ticks matches the
// pattern in `ipc-safe-send.test.ts` (one for the rejection, one for
// a downstream handler); a few extra loops cover chained handlers.
for (let i = 0; i < 8; i++) await Promise.resolve();
await expect(runPromise).rejects.toThrow(/Tab "docfinal" was closed/);
// Drain the microtask queue so any pending unhandled-rejection
// would have fired by the time we assert.
for (let i = 0; i < 8; i++) await Promise.resolve();
expect(unhandled).toEqual([]);
// Sanity: the tab really did leave the map.
expect(getTabsMapForTest().has("docfinal")).toBe(false);
} finally {
// Unblock the stalled `browser.navigate` and abort the run so
// `runInTab` can settle. Drop the listener last so the drain in
// `afterEach` does not accidentally trip a stray assertion in a
// follow-up test.
runAc.abort(new ToolAbortError("test cleanup"));
// Unblock the stalled `browser.navigate` so the abort signal
// composed into the cmux run gets a chance to short-circuit the
// in-flight request cleanly instead of leaking past the test.
navGate.resolve({ url: "https://example.test" });
await runPromise?.catch(() => undefined);
process.removeListener("unhandledRejection", onUnhandled);
}
});
it("releaseTab() unblocks a cmux run that is not making any socket request (wait(...) mid-flight)", async () => {
spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined);
spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined);
spyOn(CmuxSocketClient.prototype, "request").mockImplementation(
async (method: string, _params: Record<string, unknown>): Promise<Record<string, unknown>> => {
switch (method) {
case "browser.open_split":
return { surface_id: "surface-wait-mid-run", url: "about:blank" };
case "browser.url.get":
return { url: "about:blank" };
case "browser.snapshot":
return { page: { html: "" } };
case "browser.eval":
return { value: "" };
case "browser.wait":
case "surface.close":
return {};
default:
return {};
}
},
);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
try {
const kind = makeKind("wait-mid-run");
const browser = await acquireBrowser(kind, { cwd: "/tmp" });
const acquired = await acquireTab("docfinal", browser, {
timeoutMs: 5_000,
ownerSessionId: "session-wait-mid-run",
});
expect(acquired.tab.backend).toBe("cmux");
const session = makeSession("/tmp");
// The user code awaits `wait(60_000)` — which drives
// `waitForBrowserRun(60_000, signal)` -> `untilAborted(signal,
// () => Bun.sleep(60_000))` INSIDE the runtime. Nothing hits the
// cmux socket, so on `main` the reviewer's exact scenario applies:
// even after `pending.reject` unblocks the caller, `runCmuxCode`
// stays blocked in `Bun.sleep(60_000)` until the run's timeout,
// leaking the run past the tab lifetime.
//
// The fix composes `pending.closeAc.signal` into the run's abort
// signal, so `releaseTab` cancels `untilAborted` synchronously and
// the run unwinds within a microtask window.
const runPromise = runInTab("docfinal", {
code: "await wait(60_000);",
timeoutMs: 60_000,
session,
});
// Spin the microtask queue until the pending map is populated
// (`runInTab` sets it synchronously before the first await, but the
// call itself is async). One tick usually suffices; a small
// bounded loop keeps the test robust against future micro-batching
// changes without relying on real timers.
for (let i = 0; i < 32; i++) {
const tab = getTabsMapForTest().get("docfinal");
if (tab && tab.pending.size > 0) break;
await Promise.resolve();
}
const tabBeforeRelease = getTabsMapForTest().get("docfinal");
expect(tabBeforeRelease?.pending.size).toBeGreaterThan(0);
// Capture the pending run's `closeAc` BEFORE `releaseTab` clears
// the map. This is the wire the reviewer asked us to check: the
// tab-close event must reach the cmux run body, not only the
// awaiting caller. Its `.signal.aborted` is the observable proof
// that `waitForBrowserRun` / cmux socket calls will unwind
// synchronously (via `untilAborted`) instead of blocking to the
// 60_000ms timeout.
const pendingBeforeRelease = [...(tabBeforeRelease?.pending.values() ?? [])];
expect(pendingBeforeRelease.length).toBe(1);
const capturedCloseAc = pendingBeforeRelease[0]?.closeAc;
expect(capturedCloseAc).toBeDefined();
expect(capturedCloseAc?.signal.aborted).toBe(false);
// The scenario the reviewer flagged: no in-flight cmux request,
// so only the `closeAc` propagation can unwind the run body.
const released = await releaseTab("docfinal", { kill: false });
expect(released).toBe(true);
// Concrete contract: `releaseTab` MUST fire `closeAc.abort(...)` so
// the composed `runSignal` in `runInTabWithSnapshot` transitions
// to aborted. Without this line, the reviewer's failure mode
// stands: the run body keeps executing until its own timeout.
expect(capturedCloseAc?.signal.aborted).toBe(true);
expect(capturedCloseAc?.signal.reason).toBeInstanceOf(Error);
expect((capturedCloseAc?.signal.reason as Error).message).toMatch(/Tab "docfinal" was closed/);
// Caller-facing contract: `runInTab` rejects with the tab-close
// error immediately, not after the run's 60_000ms timeout.
await expect(runPromise).rejects.toThrow(/Tab "docfinal" was closed/);
for (let i = 0; i < 8; i++) await Promise.resolve();
expect(unhandled).toEqual([]);
expect(getTabsMapForTest().has("docfinal")).toBe(false);
} finally {
process.removeListener("unhandledRejection", onUnhandled);
}
});