Merge pull request #7354 from brymko/fix/browser-timeout-crash-recovery

Fix/browser timeout crash recovery
This commit is contained in:
Can Bölük
2026-08-05 12:39:14 +02:00
committed by GitHub
13 changed files with 1292 additions and 40 deletions
+7
View File
@@ -145,6 +145,13 @@
- Fixed heavily branched conversation trees shifting linear continuations into disconnected columns.
- Fixed plugin installation validation failures for legacy compatibility shims.
- Removed hard-coded references to disabled or absent agents in system and tool prompts.
### Added
- Added resumable session details to fatal crash output, including an `omp --resume <session-id>` command for every persisted live agent session.
### Fixed
- Fixed unobserved promise continuations from browser helpers such as `tab.waitForResponse()` wedging or killing the tab worker when they reject; browser facade promises now retain native promise behavior while observing every `then`, `catch`, and `finally` continuation, and late user continuation errors are logged instead of dropped after the run ends.
## [17.2.4] - 2026-08-01
@@ -82,6 +82,7 @@ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
import {
$env,
APP_NAME,
escapeXmlText,
formatDuration,
getAgentDbPath,
@@ -445,6 +446,7 @@ export class AgentSession {
// Event subscription state
#unsubscribeAgent?: () => void;
#cancelExitRecorder?: () => void;
#cancelFatalRecoveryHint?: () => void;
#exitRecorded = false;
#unsubscribeAppendOnly?: () => void;
#unsubscribeModelRoles?: () => void;
@@ -1376,6 +1378,14 @@ export class AgentSession {
this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => {
this.#recordSessionExit(reason);
});
this.#cancelFatalRecoveryHint = postmortem.registerFatalRecoveryHint(() => {
const sessionId = this.sessionManager.getSessionId();
if (!sessionId || !this.sessionManager.getSessionFile()) return undefined;
return {
label: this.#agentId ?? (this.#agentKind === "main" ? "Main" : "Agent"),
command: `${APP_NAME} --resume ${sessionId}`,
};
});
const advisorsHost: SessionAdvisorsHost = {
agent: this.agent,
@@ -3758,6 +3768,8 @@ export class AgentSession {
this.#recordSessionExit(options.reason ?? "dispose");
this.#cancelExitRecorder?.();
this.#cancelExitRecorder = undefined;
this.#cancelFatalRecoveryHint?.();
this.#cancelFatalRecoveryHint = undefined;
try {
await emitSessionShutdownEvent(this.#extensionRunner);
} catch (error) {
@@ -8,7 +8,16 @@ import { resizeImage } from "../../../utils/image-resize";
import type { ToolSession } from "../../index";
import { resolveToCwd } from "../../path-utils";
import { formatScreenshot } from "../../render-utils";
import { bindRunFacade, resolvePredicateTimeout, type WaitPredicateOptions, waitForRun } from "../../run-scope";
import {
bindRunFacade,
isBrowserRunOwnedRejection,
markBrowserRunRejection,
observeBrowserRunPromise,
resolvePredicateTimeout,
type WaitPredicateOptions,
waitForRun,
withBrowserPromiseCombinatorTracking,
} from "../../run-scope";
import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors";
import { type AriaSnapshotOptions, assertSelectorString, buildAriaSnapshotScript } from "../aria/aria-snapshot";
import { DEFAULT_VIEWPORT } from "../launch";
@@ -1368,6 +1377,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
const signal = AbortSignal.any(
opts.signal ? [timeoutSignal, opts.signal, runAc.signal] : [timeoutSignal, runAc.signal],
);
const runEndedError = postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended"));
const output = new RunOutput();
const screenshots: ScreenshotResult[] = [];
const runId = crypto.randomUUID();
@@ -1382,6 +1392,28 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
// handler to this promise; keep its armed rejection from surfacing as an
// unhandled rejection — the postmortem-fatal path this run guards against.
cancelRejection.catch(() => {});
const rejectionOwner = {};
const { promise: floatingFailure, reject: rejectFloatingFailure } = Promise.withResolvers<never>();
floatingFailure.catch(() => {});
let runActive = true;
let hasFloatingFailure = false;
const recordFloatingFailure = (reason: unknown): void => {
if (hasFloatingFailure || postmortem.isExpectedCleanupError(reason)) return;
const message = reason instanceof Error ? reason.message : String(reason);
if (!runActive) {
logger.warn("Unhandled rejection after browser run ended", { runId, error: message });
return;
}
hasFloatingFailure = true;
const error = new Error(`Unhandled rejection (missing await?): ${message}`, { cause: reason });
if (reason instanceof Error) error.name = reason.name;
rejectFloatingFailure(error);
};
const uninstallRejectionInterceptor = postmortem.interceptUnhandledRejections(reason => {
if (!isBrowserRunOwnedRejection(reason, rejectionOwner, `cmux-run-${runId}.js`)) return false;
recordFloatingFailure(reason);
return true;
});
const onAbort = (): void => {
if (timeoutSignal.aborted) {
reject(new ToolError(`Browser code execution timed out after ${opts.timeoutMs}ms`));
@@ -1402,15 +1434,16 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
// Keep both inside try so a concurrent in-process eval/browser run surfaces as
// a rejected promise the supervisor can report, never an unhandled rejection.
runtime.setCwd(opts.snapshot.cwd);
const runTab = bindRunFacade(tab, signal);
const runTab = bindRunFacade(tab, signal, rejectionOwner, recordFloatingFailure);
runtime.setRunScope({
page: bindRunFacade(tab.page, signal),
browser: bindRunFacade(tab.browser, signal),
page: bindRunFacade(tab.page, signal, rejectionOwner, recordFloatingFailure),
browser: bindRunFacade(tab.browser, signal, rejectionOwner, recordFloatingFailure),
tab: runTab,
assert: (cond: unknown, text?: string): void => {
if (!cond) throw new ToolError(text ?? "Assertion failed");
},
wait: (msOrPredicate: number | (() => unknown), waitOpts?: WaitPredicateOptions): Promise<unknown> =>
observeBrowserRunPromise(
waitForRun(
msOrPredicate,
signal,
@@ -1420,6 +1453,11 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
timeout: resolvePredicateTimeout(opts.timeoutMs, waitOpts?.timeout),
interval: waitOpts?.interval,
},
).catch(error => {
throw markBrowserRunRejection(error, rejectionOwner);
}),
rejectionOwner,
recordFloatingFailure,
),
});
@@ -1444,16 +1482,24 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
let runError: unknown;
let runFailed = false;
try {
returnValue = await Promise.race([
returnValue = await withBrowserPromiseCombinatorTracking(
rejectionOwner,
recordFloatingFailure,
async () =>
await Promise.race([
runtime.run(opts.code, filename, hooks, { runId, cwd: opts.snapshot.cwd }),
cancelRejection,
]);
floatingFailure,
]),
);
} catch (error) {
runFailed = true;
runError = error;
}
runAc.abort(runEndedError);
// Let rejection callbacks run while this run can still own guest-created promises.
await Bun.sleep(0);
if (hasFloatingFailure && !runFailed) await floatingFailure;
if (runFailed) {
for (const reason of activeRun.floatingRejections) {
logger.warn("Unhandled rejection accompanied a failed cmux browser run", { filename, error: reason });
@@ -1470,8 +1516,10 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
}
return { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots };
} finally {
runActive = false;
uninstallRejectionInterceptor();
signal.removeEventListener("abort", onAbort);
runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended")));
runAc.abort(runEndedError);
activeCmuxRuns.delete(filename);
rememberCmuxRunFile(filename);
tab.clearRunContext();
@@ -134,6 +134,7 @@ const tabs = new Map<string, TabSession>();
// awaits) cannot interleave and leak a worker + browser refCount.
const acquireChains = new Map<string, Promise<void>>();
const GRACE_MS = 750;
const WORKER_INIT_TIMEOUT_MS = 15_000;
// Names of tabs the supervisor force-killed (timeout past grace, failed recycle),
// mapped to the kill reason. Lets the next `run` on that name explain WHY the tab
// vanished instead of a bare "not alive". Cleared when the name is opened again.
@@ -272,7 +273,11 @@ async function acquireTabImpl(
}
let info: ReadyInfo;
try {
info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
info = await initializeTabWorker(
worker,
initPayload,
Math.max(WORKER_INIT_TIMEOUT_MS, opts.timeoutMs + GRACE_MS),
);
} catch (error) {
// `BuildMessage`-class failures arrive asynchronously via the worker's `error` event,
// after `spawnTabWorker`'s synchronous try/catch has already returned. Fall back to
@@ -287,7 +292,11 @@ async function acquireTabImpl(
});
worker = await spawnInlineWorker();
try {
info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
info = await initializeTabWorker(
worker,
initPayload,
Math.max(WORKER_INIT_TIMEOUT_MS, opts.timeoutMs + GRACE_MS),
);
} catch (inlineError) {
await worker.terminate().catch(() => undefined);
if (tempHold || browser.refCount === 0) await releaseBrowser(browser, { kill: false });
@@ -1014,7 +1023,7 @@ async function spawnInlineWorker(): Promise<WorkerHandle> {
close: () => {},
};
const { WorkerCore } = await import("./tab-worker");
new WorkerCore(workerTransport);
new WorkerCore(workerTransport, false);
return {
mode: "inline",
send: msg =>
@@ -26,4 +26,4 @@ const transport: Transport = {
},
};
new WorkerCore(transport);
new WorkerCore(transport, true);
@@ -23,10 +23,15 @@ import { formatScreenshot } from "../render-utils";
import {
bindRunFacade,
CELL_BUDGET_SLACK_MS,
installBrowserWorkerRejectionGuard,
isBrowserRunOwnedRejection,
markBrowserRunRejection,
markHandled,
observeBrowserRunPromise,
resolvePredicateTimeout,
type WaitPredicateOptions,
waitForRun,
withBrowserPromiseCombinatorTracking,
} from "../run-scope";
import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors";
import {
@@ -44,6 +49,7 @@ import {
loadPuppeteerInWorker,
} from "./launch";
import { extractReadableFromHtml, type ReadableFormat } from "./readable";
import { cloneSafe, RunOutput } from "./run-output";
import type {
Observation,
@@ -701,6 +707,9 @@ interface ActiveRun {
output: RunOutput;
screenshots: ScreenshotResult[];
pendingTools: Map<string, { resolve(value: unknown): void; reject(error: Error): void }>;
rejectionOwner: object;
floatingRejections: unknown[];
floatingFailure: { promise: Promise<never>; reject(reason?: unknown): void };
/** Helper invocations currently awaiting the page/network, keyed by op id. */
inflight: Map<number, InflightOp>;
opCounter: number;
@@ -748,17 +757,75 @@ export class WorkerCore {
#active: ActiveRun | null = null;
#runtime: JsRuntime | null = null;
#unsub: () => void;
#isolated: boolean;
#uninstallRejectionGuard: () => void;
#mode?: WorkerInitPayload["mode"];
#activateForScreenshot = true;
#dialogPolicy?: DialogPolicy;
#dialogHandler?: (dialog: Dialog) => void;
#openDialog?: OpenDialogInfo;
constructor(transport: Transport) {
constructor(transport: Transport, isolated: boolean) {
this.#transport = transport;
this.#isolated = isolated;
this.#unsub = this.#transport.onMessage(msg => {
void this.#handleMessage(msg as WorkerInbound);
});
this.#uninstallRejectionGuard = this.#installRejectionGuard();
}
#installRejectionGuard(): () => void {
if (!this.#isolated) {
return postmortem.interceptUnhandledRejections(reason => this.#consumeUnhandledRejection(reason));
}
return installBrowserWorkerRejectionGuard(reason => this.#consumeUnhandledRejection(reason));
}
#consumeUnhandledRejection(reason: unknown): boolean {
const active = this.#active;
if (!active) return false;
if (!isBrowserRunOwnedRejection(reason, active.rejectionOwner, `browser-run-${active.id}.js`)) return false;
this.#recordFloatingRejection(active, reason);
return true;
}
#recordFloatingRejection(active: ActiveRun, reason: unknown): void {
if (postmortem.isExpectedCleanupError(reason)) return;
if (this.#active !== active) {
this.#log("warn", "Unhandled rejection after browser run ended", {
runId: active.id,
error: reason instanceof Error ? reason.message : String(reason),
});
return;
}
const isFirst = active.floatingRejections.length === 0;
active.floatingRejections.push(reason);
if (isFirst) active.floatingFailure.reject(this.#floatingRejectionError(reason));
}
#floatingRejectionError(reason: unknown): Error {
const message = reason instanceof Error ? reason.message : String(reason);
const error = new Error(`Unhandled rejection (missing await?): ${message}`, { cause: reason });
if (reason instanceof Error) error.name = reason.name;
return error;
}
#foldFloatingRejections(active: ActiveRun, failure: { error: unknown } | undefined): { error: unknown } | undefined {
const rejections = active.floatingRejections;
if (rejections.length === 0) return failure;
let reported = rejections;
if (!failure) {
failure = { error: this.#floatingRejectionError(rejections[0]) };
reported = rejections.slice(1);
} else if (failure.error instanceof Error && failure.error.cause === rejections[0]) {
reported = rejections.slice(1);
}
for (const reason of reported) {
this.#log("warn", "Additional unhandled browser-run rejection", {
error: reason instanceof Error ? reason.message : String(reason),
});
}
return failure;
}
nextElementId(): number {
@@ -973,6 +1040,7 @@ export class WorkerCore {
const signal = AbortSignal.any([timeoutSignal, ac.signal, runAc.signal]);
const output = new RunOutput();
const screenshots: ScreenshotResult[] = [];
const floatingFailure = Promise.withResolvers<never>();
const active: ActiveRun = {
id: msg.id,
ac,
@@ -980,6 +1048,9 @@ export class WorkerCore {
output,
screenshots,
pendingTools: new Map(),
rejectionOwner: {},
floatingRejections: [],
floatingFailure,
inflight: new Map(),
opCounter: 0,
};
@@ -995,10 +1066,11 @@ export class WorkerCore {
const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, output, screenshots, active);
const runtime = this.#ensureRuntime(msg.session);
runtime.setCwd(msg.session.cwd);
const onFloatingRejection = (reason: unknown): void => this.#recordFloatingRejection(active, reason);
runtime.setRunScope({
page: bindRunFacade(runPage.page, signal),
browser: bindRunFacade(browser, signal),
tab: bindRunFacade(tabApi, signal),
page: bindRunFacade(runPage.page, signal, active.rejectionOwner, onFloatingRejection),
browser: bindRunFacade(browser, signal, active.rejectionOwner, onFloatingRejection),
tab: bindRunFacade(tabApi, signal, active.rejectionOwner, onFloatingRejection),
assert: (cond: unknown, text?: string): void => {
if (!cond) throw new ToolError(text ?? "Assertion failed");
},
@@ -1010,10 +1082,12 @@ export class WorkerCore {
typeof msOrPredicate === "number"
? undefined
: { timeout: resolvePredicateTimeout(msg.timeoutMs, opts?.timeout), interval: opts?.interval };
return markHandled(
return observeBrowserRunPromise(
this.#runOp(active, label, signal, Number.POSITIVE_INFINITY, sig =>
waitForRun(msOrPredicate, sig, resolved),
),
active.rejectionOwner,
onFloatingRejection,
);
},
});
@@ -1051,10 +1125,19 @@ export class WorkerCore {
try {
const hooks = this.#hooksForActiveRun();
if (!hooks) throw new ToolError("Browser runtime started without an active run");
returnValue = await Promise.race([
runtime.run(msg.code, `browser-run-${msg.id}.js`, hooks, { runId: msg.id, cwd: msg.session.cwd }),
returnValue = await withBrowserPromiseCombinatorTracking(
active.rejectionOwner,
onFloatingRejection,
async () =>
await Promise.race([
runtime.run(msg.code, `browser-run-${msg.id}.js`, hooks, {
runId: msg.id,
cwd: msg.session.cwd,
}),
cancelRejection,
]);
floatingFailure.promise,
]),
);
completed = true;
} finally {
signal.removeEventListener("abort", onCancel);
@@ -1063,11 +1146,13 @@ export class WorkerCore {
failure = { error };
} finally {
runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended")));
await Bun.sleep(0);
try {
await runPage?.cleanup();
} catch (error) {
failure = { error };
}
failure = this.#foldFloatingRejections(active, failure);
if (this.#active?.id === msg.id) this.#active = null;
}
if (failure) {
@@ -1182,9 +1267,12 @@ export class WorkerCore {
(opTimeout?.aborted || (err instanceof Error && err.name === "TimeoutError"))
) {
const hint = selector ? await this.#selectorTimeoutHint(selector) : "";
throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`);
throw markBrowserRunRejection(
new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`),
active.rejectionOwner,
);
}
throw err;
throw markBrowserRunRejection(err, active.rejectionOwner);
} finally {
earlyAc.abort();
active.inflight.delete(opId);
@@ -1868,6 +1956,7 @@ export class WorkerCore {
async #close(): Promise<void> {
this.#unsub();
this.#uninstallRejectionGuard();
this.#clearElementCache();
const page = this.#page;
if (this.#dialogHandler && page && !page.isClosed()) page.off("dialog", this.#dialogHandler);
+290 -4
View File
@@ -1,6 +1,285 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { untilAborted } from "@oh-my-pi/pi-utils/abortable";
import * as postmortem from "@oh-my-pi/pi-utils/postmortem";
import { ToolError, throwIfAborted } from "./tool-errors";
const browserRunRejections = new WeakMap<object, object>();
/** Associates a browser operation failure with its owning evaluated run. */
export function markBrowserRunRejection<T>(reason: T, owner: object): T {
if (reason !== null && (typeof reason === "object" || typeof reason === "function")) {
browserRunRejections.set(reason, owner);
}
return reason;
}
/** Returns whether a rejection was marked for the specified evaluated run. */
export function isBrowserRunRejection(reason: unknown, owner: object): boolean {
return (
reason !== null &&
(typeof reason === "object" || typeof reason === "function") &&
browserRunRejections.get(reason) === owner
);
}
/** Returns whether a rejection belongs to the marked browser run or its evaluated source file. */
export function isBrowserRunOwnedRejection(reason: unknown, owner: object, filename: string): boolean {
if (isBrowserRunRejection(reason, owner)) return true;
return reason instanceof Error && typeof reason.stack === "string" && reason.stack.includes(filename);
}
type FloatingRejectionHandler = (reason: unknown) => void;
interface ObservedPromiseState {
handled: boolean;
userContinuationFailed: boolean;
}
const observedBrowserPromises = new WeakMap<Promise<unknown>, ObservedPromiseState>();
const observedPromiseConstructor = { [Symbol.species]: Promise };
type PromiseCombinatorName = "all" | "race";
type PromiseCombinator = (this: PromiseConstructor, values: Iterable<unknown>) => Promise<unknown>;
interface PromiseCombinatorTrackingContext {
owner: object;
onFloatingRejection: FloatingRejectionHandler;
}
const PROMISE_COMBINATORS: readonly PromiseCombinatorName[] = ["all", "race"];
const NativePromise = Promise;
const nativePromiseCombinators: Record<PromiseCombinatorName, PromiseCombinator> = {
all: Promise.all,
race: Promise.race,
};
const promiseCombinatorTracking = new AsyncLocalStorage<PromiseCombinatorTrackingContext>();
let previousPromiseDescriptor: PropertyDescriptor | undefined;
let promiseCombinatorTrackingScopes = 0;
/**
* Observes native promise-combinator results derived from browser promises for
* the duration of one evaluated run. Native `await` remains unchanged; dropped
* user continuations from `Promise.all` and `Promise.race` are routed to the
* owning run.
*/
export async function withBrowserPromiseCombinatorTracking<T>(
owner: object,
onFloatingRejection: FloatingRejectionHandler,
run: () => Promise<T>,
): Promise<T> {
installPromiseCombinatorTracking();
try {
return await promiseCombinatorTracking.run({ owner, onFloatingRejection }, run);
} finally {
restorePromiseCombinatorTracking();
}
}
function installPromiseCombinatorTracking(): void {
if (promiseCombinatorTrackingScopes > 0) {
promiseCombinatorTrackingScopes++;
return;
}
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Promise");
if (!descriptor) throw new Error("Global Promise descriptor is unavailable");
const trackedPromise = createTrackedPromiseConstructor();
Object.defineProperty(globalThis, "Promise", { ...descriptor, value: trackedPromise });
previousPromiseDescriptor = descriptor;
promiseCombinatorTrackingScopes = 1;
}
function restorePromiseCombinatorTracking(): void {
if (promiseCombinatorTrackingScopes > 1) {
promiseCombinatorTrackingScopes--;
return;
}
const descriptor = previousPromiseDescriptor;
try {
if (!descriptor) throw new Error("Global Promise tracking scope is not installed");
Object.defineProperty(globalThis, "Promise", descriptor);
} finally {
previousPromiseDescriptor = undefined;
promiseCombinatorTrackingScopes = 0;
}
}
function createTrackedPromiseConstructor(): PromiseConstructor {
class TrackedPromise<T> extends NativePromise<T> {}
for (const name of PROMISE_COMBINATORS) {
const original = nativePromiseCombinators[name];
Object.defineProperty(TrackedPromise, name, {
configurable: true,
writable: true,
value(this: PromiseConstructor, values: Iterable<unknown>): Promise<unknown> {
let hasObservedInput = false;
const result = Reflect.apply(original, this, [
tapObservedBrowserPromises(values, () => {
hasObservedInput = true;
}),
]) as Promise<unknown>;
const context = promiseCombinatorTracking.getStore();
return hasObservedInput && context
? observeBrowserRunPromise(result, context.owner, context.onFloatingRejection)
: result;
},
});
}
return TrackedPromise;
}
function* tapObservedBrowserPromises(
values: Iterable<unknown>,
onObserved: () => void,
): Generator<unknown, void, undefined> {
for (const value of values) {
if (observedBrowserPromises.has(value as Promise<unknown>)) onObserved();
yield value;
}
}
/**
* Observes every explicit continuation of a browser promise without replacing
* the native promise. Browser failures remain contained; an unhandled error
* created by user continuation code is reported to the owning run.
*/
export function observeBrowserRunPromise<T>(
promise: Promise<T>,
owner: object,
onFloatingRejection: FloatingRejectionHandler,
): Promise<T> {
return observeBrowserRunPromiseWithState(promise, owner, onFloatingRejection, {
handled: false,
userContinuationFailed: false,
});
}
function observeBrowserRunPromiseWithState<T>(
promise: Promise<T>,
owner: object,
onFloatingRejection: FloatingRejectionHandler,
state: ObservedPromiseState,
): Promise<T> {
if (observedBrowserPromises.has(promise)) return promise;
observedBrowserPromises.set(promise, state);
const originalThen = promise.then.bind(promise);
const originalFinally = promise.finally.bind(promise);
void originalThen(undefined, reason => {
setTimeout(() => {
if (!state.handled && (state.userContinuationFailed || !isBrowserRunRejection(reason, owner))) {
onFloatingRejection(reason);
}
}, 0);
});
Object.defineProperties(promise, {
constructor: { configurable: true, value: observedPromiseConstructor },
// biome-ignore lint/suspicious/noThenProperty: native Promise continuations must remain thenable.
then: {
configurable: true,
value: <TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> => {
state.handled = true;
const childState = createContinuationState();
return observeBrowserRunPromiseWithState(
originalThen(
recordContinuationFailure(onFulfilled, childState),
recordContinuationFailure(onRejected, childState),
),
owner,
onFloatingRejection,
childState,
);
},
},
catch: {
configurable: true,
value: <TResult = never>(
onRejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
): Promise<T | TResult> => {
state.handled = true;
const childState = createContinuationState();
return observeBrowserRunPromiseWithState(
originalThen(undefined, recordContinuationFailure(onRejected, childState)),
owner,
onFloatingRejection,
childState,
);
},
},
finally: {
configurable: true,
value: (onFinally?: (() => void) | null): Promise<T> => {
state.handled = true;
const childState = createContinuationState();
return observeBrowserRunPromiseWithState(
originalFinally(recordContinuationFailure(onFinally, childState)),
owner,
onFloatingRejection,
childState,
);
},
},
});
return promise;
}
function createContinuationState(): ObservedPromiseState {
return { handled: false, userContinuationFailed: false };
}
function recordContinuationFailure<TArgs extends unknown[], TResult>(
continuation: ((...args: TArgs) => TResult | PromiseLike<TResult>) | null | undefined,
state: ObservedPromiseState,
): ((...args: TArgs) => TResult | PromiseLike<TResult>) | null | undefined {
if (!continuation) return continuation;
return (...args) => {
try {
const result = continuation(...args);
if (!isThenable(result)) return result;
return Promise.resolve(result).catch(reason => {
state.userContinuationFailed = true;
throw reason;
}) as PromiseLike<TResult>;
} catch (reason) {
state.userContinuationFailed = true;
throw reason;
}
};
}
function isThenable(value: unknown): value is PromiseLike<unknown> {
if (value === null || (typeof value !== "object" && typeof value !== "function")) return false;
return typeof Reflect.get(value, "then") === "function";
}
function trackBrowserRunPromise<T>(
promise: Promise<T>,
owner?: object,
onFloatingRejection?: FloatingRejectionHandler,
): Promise<T> {
if (!owner) return markHandled(promise);
const tracked = promise.catch(error => {
throw markBrowserRunRejection(error, owner);
});
return onFloatingRejection ? observeBrowserRunPromise(tracked, owner, onFloatingRejection) : tracked;
}
/**
* Installs worker-realm rejection routing. Consumed browser-run failures stay in
* the worker; unrelated failures retain the default fatal worker behavior.
*/
export function installBrowserWorkerRejectionGuard(consume: (reason: unknown) => boolean): () => void {
const onRejection = (reason: unknown): void => {
if (postmortem.isExpectedCleanupError(reason) || consume(reason)) return;
setTimeout(() => {
throw reason;
}, 0);
};
process.on("unhandledRejection", onRejection);
return () => process.off("unhandledRejection", onRejection);
}
/**
* Marks a run-scoped promise as observed without changing its behavior for awaited callers.
*
@@ -83,11 +362,16 @@ export function waitForRun(
await untilAborted(signal, async () => await Bun.sleep(interval));
}
})();
return markHandled(promise);
return trackBrowserRunPromise(promise);
}
/** Binds a long-lived scope facade (page/tab/desktop objects) to one evaluated run's abort signal. */
export function bindRunFacade<T extends object>(target: T, signal: AbortSignal): T {
export function bindRunFacade<T extends object>(
target: T,
signal: AbortSignal,
rejectionOwner?: object,
onFloatingRejection?: FloatingRejectionHandler,
): T {
const cache = new Map<PropertyKey, unknown>();
return new Proxy(target, {
get(current, prop) {
@@ -102,11 +386,13 @@ export function bindRunFacade<T extends object>(target: T, signal: AbortSignal):
if (result && typeof result === "object") {
const then = Reflect.get(result, "then");
if (typeof then === "function") {
return markHandled(
return trackBrowserRunPromise(
Promise.resolve(result).then(resolved => {
throwIfAborted(signal);
return resolved;
}),
rejectionOwner,
onFloatingRejection,
);
}
}
@@ -121,7 +407,7 @@ export function bindRunFacade<T extends object>(target: T, signal: AbortSignal):
// brand-check internal slots that a Proxy cannot forward, and reading a
// signal needs no abort gating anyway.
if (value instanceof AbortSignal) return value;
const wrapped = bindRunFacade(value, signal);
const wrapped = bindRunFacade(value, signal, rejectionOwner, onFloatingRejection);
cache.set(prop, wrapped);
return wrapped;
}
@@ -42,6 +42,7 @@ 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 * as logger from "@oh-my-pi/pi-utils/logger";
function makeKind(socketSuffix: string): CmuxKind {
return {
@@ -286,6 +287,146 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => {
}
});
it("logs a user continuation rejection after its cmux run ends", async () => {
spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined);
spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined);
spyOn(CmuxSocketClient.prototype, "request").mockImplementation(
async (method: string): Promise<Record<string, unknown>> => {
switch (method) {
case "browser.open_split":
return { surface_id: "surface-late-rejection", url: "about:blank" };
case "browser.url.get":
return { url: "about:blank" };
case "browser.snapshot":
return { page: { html: "" } };
case "browser.eval":
return { value: "" };
default:
return {};
}
},
);
const warningLogged = Promise.withResolvers<void>();
const warn = spyOn(logger, "warn").mockImplementation(message => {
if (message === "Unhandled rejection after browser run ended") warningLogged.resolve();
});
const browser = await acquireBrowser(makeKind("late-rejection"), { cwd: "/tmp" });
await acquireTab("late-rejection", browser, {
timeoutMs: 5_000,
ownerSessionId: "session-late-rejection",
});
const result = await runInTab("late-rejection", {
code: `
const continuationStarted = Promise.withResolvers();
void tab.title().then(async () => {
continuationStarted.resolve();
await Bun.sleep(50);
throw new Error("late cmux continuation failed");
});
await continuationStarted.promise;
return "completed";
`,
timeoutMs: 5_000,
session: makeSession("/tmp"),
});
expect(result.returnValue).toBe("completed");
await warningLogged.promise;
expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", {
runId: expect.any(String),
error: "late cmux continuation failed",
});
});
it("fails a browser error rethrown through a native promise combinator", async () => {
spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined);
spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined);
spyOn(CmuxSocketClient.prototype, "request").mockImplementation(
async (method: string): Promise<Record<string, unknown>> => {
switch (method) {
case "browser.open_split":
return { surface_id: "surface-combinator-rejection", url: "about:blank" };
case "browser.url.get":
return { url: "about:blank" };
case "browser.snapshot":
return { page: { html: "" } };
case "browser.eval":
return { value: "" };
case "browser.navigate":
throw new Error("navigation failed");
default:
return {};
}
},
);
const browser = await acquireBrowser(makeKind("combinator-rejection"), { cwd: "/tmp" });
await acquireTab("combinator-rejection", browser, {
timeoutMs: 5_000,
ownerSessionId: "session-combinator-rejection",
});
const run = runInTab("combinator-rejection", {
code: `
void Promise.all([
tab.goto("https://example.test"),
]).catch(reason => {
throw reason;
});
await wait(50);
return "incorrect success";
`,
timeoutMs: 5_000,
session: makeSession("/tmp"),
});
await expect(run).rejects.toThrow("Unhandled rejection (missing await?): navigation failed");
});
it("aborts the cmux run facade before draining floated continuations", async () => {
spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined);
spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined);
const navigatedUrls: string[] = [];
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-drain-abort", url: "about:blank" };
case "browser.url.get":
return { url: "about:blank" };
case "browser.snapshot":
return { page: { html: "" } };
case "browser.eval":
await Bun.sleep(0);
return { value: "ready" };
case "browser.navigate":
navigatedUrls.push(String(params.url));
return { url: params.url };
default:
return {};
}
},
);
const browser = await acquireBrowser(makeKind("drain-abort"), { cwd: "/tmp" });
await acquireTab("drain-abort", browser, {
timeoutMs: 5_000,
ownerSessionId: "session-drain-abort",
});
const result = await runInTab("drain-abort", {
code: `
void tab.title().then(() => tab.goto("https://late.example"));
return "completed";
`,
timeoutMs: 5_000,
session: makeSession("/tmp"),
});
expect(result.returnValue).toBe("completed");
await Bun.sleep(20);
expect(navigatedUrls).toEqual([]);
});
it("ignores the daemon screenshot path when no screenshot directory is configured", async () => {
spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined);
spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined);
@@ -1,9 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import { postmortem } from "@oh-my-pi/pi-utils";
import { JsRuntime, type RuntimeHooks } from "../../src/eval/js/shared/runtime";
import { bindRunFacade, markHandled, waitForRun } from "../../src/tools/run-scope";
import {
bindRunFacade,
isBrowserRunOwnedRejection,
isBrowserRunRejection,
markBrowserRunRejection,
markHandled,
waitForRun,
withBrowserPromiseCombinatorTracking,
} from "../../src/tools/run-scope";
import { ToolAbortError } from "../../src/tools/tool-errors";
const runScopeModuleUrl = new URL("../../src/tools/run-scope.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 +138,302 @@ describe("browser run cancellation", () => {
expect(reasons).toEqual([]);
});
it("scopes browser rejection markers to the owning run and direct reason", () => {
const owner = {};
const browserFailure = new Error("browser failed");
markBrowserRunRejection(browserFailure, owner);
expect(isBrowserRunRejection(browserFailure, owner)).toBe(true);
expect(isBrowserRunRejection(browserFailure, {})).toBe(false);
expect(isBrowserRunRejection(new Error("unrelated", { cause: browserFailure }), owner)).toBe(false);
});
it("keeps unrelated worker rejections outside the active browser run", () => {
const owner = {};
const workerFailure = new Error("transport failed");
workerFailure.stack = "Error: transport failed\n at tab-worker.ts:1:1";
const evaluatedFailure = new Error("evaluated failure");
evaluatedFailure.stack = "Error: evaluated failure\n at browser-run-run-1.js:1:1";
expect(isBrowserRunOwnedRejection(workerFailure, owner, "browser-run-run-1.js")).toBe(false);
expect(isBrowserRunOwnedRejection(evaluatedFailure, owner, "browser-run-run-1.js")).toBe(true);
expect(
isBrowserRunOwnedRejection(markBrowserRunRejection(workerFailure, owner), owner, "browser-run-run-1.js"),
).toBe(true);
});
it("keeps a later cause-wrapped rejection on the fatal path", async () => {
vi.useRealTimers();
const script = `
import { markBrowserRunRejection } from ${JSON.stringify(runScopeModuleUrl)};
const browserFailure = new Error("browser failure");
markBrowserRunRejection(browserFailure, {});
Promise.reject(new Error("unrelated fatal", { cause: browserFailure }));
await Promise.resolve();
`;
const proc = Bun.spawn([process.execPath, "-e", script], {
cwd: process.cwd(),
stdout: "pipe",
stderr: "pipe",
});
const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
expect(exitCode).toBe(1);
expect(stderr).toContain("[Unhandled Rejection] Error: unrelated fatal");
});
it("preserves a browser rejection marker through native await", async () => {
const owner = {};
const browserFailure = new Error("browser failed");
const facade = bindRunFacade(
{
fail(): Promise<never> {
return Promise.reject(browserFailure);
},
},
new AbortController().signal,
owner,
);
let caught: unknown;
try {
await (async () => await facade.fail())();
} catch (error) {
caught = error;
}
expect(caught).toBe(browserFailure);
expect(isBrowserRunRejection(caught, owner)).toBe(true);
});
it("reports user rethrows from native browser-promise combinators", async () => {
vi.useRealTimers();
for (const name of ["all", "race"] as const) {
const owner = {};
const browserFailure = new Error(`${name} browser failure`);
const floatingRejections: unknown[] = [];
const facade = bindRunFacade(
{
fail(): Promise<never> {
return Promise.reject(browserFailure);
},
},
new AbortController().signal,
owner,
reason => floatingRejections.push(reason),
);
const originalCombinator = Promise[name];
await withBrowserPromiseCombinatorTracking(
owner,
reason => floatingRejections.push(reason),
async () => {
const combined = name === "all" ? Promise.all([facade.fail()]) : Promise.race([facade.fail()]);
void combined.catch(reason => {
throw reason;
});
await Bun.sleep(20);
},
);
expect(floatingRejections).toEqual([browserFailure]);
expect(Promise[name]).toBe(originalCombinator);
}
});
it("preserves native await through a tracked browser-promise combinator", async () => {
vi.useRealTimers();
const owner = {};
const browserFailure = new Error("browser failed");
const floatingRejections: unknown[] = [];
const facade = bindRunFacade(
{
fail(): Promise<never> {
return Promise.reject(browserFailure);
},
},
new AbortController().signal,
owner,
reason => floatingRejections.push(reason),
);
let caught: unknown;
await withBrowserPromiseCombinatorTracking(
owner,
reason => floatingRejections.push(reason),
async () => {
try {
await Promise.all([facade.fail()]);
} catch (error) {
caught = error;
}
await Bun.sleep(10);
},
);
expect(caught).toBe(browserFailure);
expect(floatingRejections).toEqual([]);
});
it("keeps a real worker alive after floating browser and continuation rejections", async () => {
vi.useRealTimers();
const workerPath = `/tmp/omp-browser-rejections-${process.pid}.ts`;
await Bun.write(
workerPath,
`
import {
bindRunFacade,
installBrowserWorkerRejectionGuard,
} from ${JSON.stringify(runScopeModuleUrl)};
const failures = [];
const uninstall = installBrowserWorkerRejectionGuard(reason => {
failures.push(reason instanceof Error ? reason.message : String(reason));
return true;
});
const facade = bindRunFacade(
{
waitForResponse() {
return Promise.reject(new Error("browser timeout"));
},
title() {
return Promise.resolve("ready");
},
},
new AbortController().signal,
{},
);
void (async () => {
await facade.waitForResponse();
})();
void facade.title().then(() => {
throw new Error("continuation failed");
});
setTimeout(() => {
uninstall();
postMessage({ alive: true, failures });
}, 50);
`,
);
try {
const script = `
const worker = new Worker(${JSON.stringify(workerPath)}, { type: "module" });
const done = Promise.withResolvers();
worker.onmessage = event => done.resolve(event.data);
worker.onerror = event => done.reject(new Error(event.message));
try {
const result = await Promise.race([
done.promise,
Bun.sleep(1000).then(() => {
throw new Error("worker timed out");
}),
]);
console.log(JSON.stringify(result));
} finally {
await worker.terminate();
}
`;
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 timeout");
expect(stdout).toContain("continuation failed");
} finally {
await fs.rm(workerPath, { force: true });
}
});
it("does not mark errors thrown by user continuations", async () => {
const owner = {};
const continuationFailure = new Error("continuation failed");
const floatingRejections: unknown[] = [];
const facade = bindRunFacade(
{
ok: async (): Promise<string> => "ok",
},
new AbortController().signal,
owner,
reason => floatingRejections.push(reason),
);
const root = facade.ok();
expect(root).toBeInstanceOf(Promise);
expect(Object.getPrototypeOf(root)).toBe(Promise.prototype);
const continuation = root.then(() => {
throw continuationFailure;
});
await expect(continuation).rejects.toBe(continuationFailure);
expect(isBrowserRunRejection(continuationFailure, owner)).toBe(false);
expect(floatingRejections).toEqual([]);
});
it("reports unhandled errors from then, catch, and finally continuations", async () => {
vi.useRealTimers();
const owner = {};
const floatingRejections: unknown[] = [];
const facade = bindRunFacade(
{
fail: async (): Promise<never> => {
throw new Error("browser failure");
},
ok: async (): Promise<string> => "ok",
},
new AbortController().signal,
owner,
reason => floatingRejections.push(reason),
);
void facade.ok().then(() => {
throw new Error("then failed");
});
void facade.fail().catch(() => {
throw new Error("catch failed");
});
void facade.ok().finally(() => {
throw new Error("finally failed");
});
await Bun.sleep(20);
const messages = floatingRejections
.map(reason => (reason instanceof Error ? reason.message : String(reason)))
.sort();
expect(messages).toEqual(["catch failed", "finally failed", "then failed"]);
});
it("reports a browser error rethrown by a user rejection continuation", async () => {
vi.useRealTimers();
const owner = {};
const browserFailure = new Error("browser failure");
const floatingRejections: unknown[] = [];
const facade = bindRunFacade(
{
fail: (): Promise<never> => Promise.reject(browserFailure),
},
new AbortController().signal,
owner,
reason => floatingRejections.push(reason),
);
void facade.fail().catch(reason => {
throw reason;
});
await Bun.sleep(20);
expect(isBrowserRunRejection(browserFailure, owner)).toBe(true);
expect(floatingRejections).toEqual([browserFailure]);
});
it("rejects awaited facade method calls that settle after abort", async () => {
const controller = new AbortController();
const deferred = Promise.withResolvers<string>();
@@ -1,8 +1,9 @@
import { describe, expect, it } from "bun:test";
import { describe, expect, it, vi } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/sdk";
import { BrowserTool } from "@oh-my-pi/pi-coding-agent/tools/browser";
import { getTabsMapForTest } from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor";
import * as logger from "@oh-my-pi/pi-utils/logger";
import { chromiumAvailable } from "./chromium-probe";
const CHROMIUM_AVAILABLE = await chromiumAvailable();
@@ -208,6 +209,295 @@ 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("fails floated user continuations without killing the tab worker", async () => {
const tool = new BrowserTool(makeSession());
const name = `continuation-rejection-${process.pid}`;
try {
await tool.execute("open", {
action: "open",
name,
url: "data:text/html,<h1>ready</h1>",
});
let failure = "";
try {
await tool.execute("run", {
action: "run",
name,
timeout: 2,
code: `
void tab.title().then(() => {
throw new Error("continuation failed");
});
await Bun.sleep(50);
return "incorrect success";
`,
});
} catch (error) {
failure = error instanceof Error ? error.message : String(error);
}
expect(failure).toContain("Unhandled rejection (missing await?): continuation failed");
let rethrowFailure = "";
try {
await tool.execute("run", {
action: "run",
name,
timeout: 2,
code: `
void tab.waitForResponse("/never", { timeout: 10 }).catch(reason => {
throw reason;
});
await Bun.sleep(50);
return "incorrect success";
`,
});
} catch (error) {
rethrowFailure = error instanceof Error ? error.message : String(error);
}
expect(rethrowFailure).toContain(
"Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms",
);
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("fails a browser error rethrown through a native promise combinator", async () => {
const tool = new BrowserTool(makeSession());
const name = `combinator-rejection-${process.pid}`;
try {
await tool.execute("open", {
action: "open",
name,
url: "data:text/html,<h1>ready</h1>",
});
let failure = "";
try {
await tool.execute("run", {
action: "run",
name,
timeout: 2,
code: `
void Promise.all([
tab.waitForResponse("/never", { timeout: 10 }),
]).catch(reason => {
throw reason;
});
await Bun.sleep(50);
return "incorrect success";
`,
});
} catch (error) {
failure = error instanceof Error ? error.message : String(error);
}
expect(failure).toContain("Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms");
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("restores promise tracking after evaluated code freezes Promise", async () => {
const tool = new BrowserTool(makeSession());
const name = `frozen-promise-${process.pid}`;
try {
await tool.execute("open", {
action: "open",
name,
url: "data:text/html,<h1>ready</h1>",
});
const frozen = await tool.execute("run", {
action: "run",
name,
code: `
Object.freeze(Promise);
return Object.isFrozen(Promise);
`,
});
expect(frozen.content).toEqual([{ type: "text", text: "true" }]);
const followup = await tool.execute("run", {
action: "run",
name,
code: "return (await Promise.all([42]))[0];",
});
expect(followup.content).toEqual([{ type: "text", text: "42" }]);
} finally {
await tool.execute("close", { action: "close", name, kill: true });
}
}, 30_000);
it("aborts the run facade before draining floated continuations", async () => {
const tool = new BrowserTool(makeSession());
const name = `drain-abort-${process.pid}`;
const url = "data:text/html,<title>original</title><h1>ready</h1>";
try {
await tool.execute("open", {
action: "open",
name,
url,
});
const result = await tool.execute("run", {
action: "run",
name,
code: `
page.title = async () => {
await Bun.sleep(0);
return "ready";
};
void tab.title().then(() => tab.goto("data:text/html,<title>late</title>"));
return "completed";
`,
});
expect(result.content).toEqual([{ type: "text", text: "completed" }]);
await Bun.sleep(100);
const followup = await tool.execute("run", {
action: "run",
name,
code: "return tab.url();",
});
expect(followup.content).toEqual([{ type: "text", text: url }]);
} finally {
await tool.execute("close", { action: "close", name, kill: true });
}
}, 30_000);
it("folds a user continuation rejection that settles during cleanup", async () => {
const tool = new BrowserTool(makeSession());
const name = `cleanup-continuation-rejection-${process.pid}`;
try {
await tool.execute("open", {
action: "open",
name,
url: "data:text/html,<h1>ready</h1>",
});
let failure = "";
try {
await tool.execute("run", {
action: "run",
name,
code: `
await page.setRequestInterception(true);
page.setRequestInterception = async () => {
await Bun.sleep(50);
};
const continuationStarted = Promise.withResolvers();
void tab.title().then(async () => {
continuationStarted.resolve();
await Bun.sleep(10);
throw new Error("cleanup continuation failed");
});
await continuationStarted.promise;
return "incorrect success";
`,
});
} catch (error) {
failure = error instanceof Error ? error.message : String(error);
}
expect(failure).toContain("Unhandled rejection (missing await?): cleanup continuation failed");
} finally {
await tool.execute("close", { action: "close", name, kill: true });
}
}, 30_000);
it("logs a user continuation rejection after its browser run ends", async () => {
const warningLogged = Promise.withResolvers<void>();
const warn = vi.spyOn(logger, "warn").mockImplementation(message => {
if (message === "Unhandled rejection after browser run ended") warningLogged.resolve();
});
const tool = new BrowserTool(makeSession());
const name = `late-continuation-rejection-${process.pid}`;
try {
await tool.execute("open", {
action: "open",
name,
url: "data:text/html,<h1>ready</h1>",
});
const result = await tool.execute("run", {
action: "run",
name,
code: `
const continuationStarted = Promise.withResolvers();
void tab.title().then(async () => {
continuationStarted.resolve();
await Bun.sleep(50);
throw new Error("late continuation failed");
});
await continuationStarted.promise;
return "completed";
`,
});
expect(result.content).toEqual([{ type: "text", text: "completed" }]);
await warningLogged.promise;
expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", {
runId: expect.any(String),
error: "late continuation failed",
});
} finally {
warn.mockRestore();
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}`;
+3
View File
@@ -28,6 +28,9 @@
### Changed
- Updated the lightweight CLI runner to support static command metadata, allowing root help to render without importing full command implementations.
### Added
- Added postmortem fatal recovery hint providers so applications can print actionable recovery commands before cleanup starts.
## [17.2.4] - 2026-08-01
+44 -1
View File
@@ -67,6 +67,17 @@ function exitProcess(code: number): never {
let cleanupPromise: Promise<void> | undefined;
let stdioDisconnectRegistrations = 0;
/** User-facing command printed before fatal cleanup so interrupted work can be resumed. */
export interface FatalRecoveryHint {
/** Stable label identifying the recoverable session or process. */
label: string;
/** Complete shell command the user can execute to resume the interrupted work. */
command: string;
}
type FatalRecoveryHintProvider = () => FatalRecoveryHint | undefined;
const fatalRecoveryHintProviders = new Set<FatalRecoveryHintProvider>();
/**
* Internal: runs all registered cleanup callbacks for the given reason.
* Ensures each callback is invoked at most once. Handles errors and prevents reentrancy.
@@ -196,6 +207,38 @@ export function interceptUnhandledRejections(interceptor: (reason: unknown) => b
return () => rejectionInterceptors.delete(interceptor);
}
/**
* Register a synchronous recovery command to print when the process exits
* through an uncaught exception or unhandled rejection.
*/
export function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void {
fatalRecoveryHintProviders.add(provider);
return () => fatalRecoveryHintProviders.delete(provider);
}
function escapeFatalHintText(value: string): string {
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, char => {
const code = char.codePointAt(0) ?? 0;
return `\\u${code.toString(16).padStart(4, "0")}`;
});
}
function formatFatalRecoveryHints(): string {
const lines: string[] = [];
const seenCommands = new Set<string>();
for (const provider of fatalRecoveryHintProviders) {
try {
const hint = provider();
if (!hint?.command || seenCommands.has(hint.command)) continue;
seenCommands.add(hint.command);
lines.push(` ${escapeFatalHintText(hint.label)}: ${escapeFatalHintText(hint.command)}`);
} catch (err) {
logger.warn("Fatal recovery hint provider failed", { err });
}
}
return lines.length > 0 ? `\n[Recovery]\n${lines.join("\n")}\n` : "";
}
function formatFatalError(label: string, err: Error): string {
const name = err.name || "Error";
const message = err.message || "(no message)";
@@ -212,7 +255,7 @@ async function exitAfterFatal(label: string, logMessage: string, err: Error, rea
// A revoked terminal can make stream writes raise another fatal error. Use
// the descriptor directly so failure stays synchronous and contained.
try {
fs.writeSync(2, formatFatalError(label, err));
fs.writeSync(2, `${formatFatalError(label, err)}${formatFatalRecoveryHints()}`);
} catch {}
logger.error(logMessage, { err });
await runCleanup(reason);
@@ -123,6 +123,23 @@ describe("postmortem expected cleanup errors", () => {
expect(result.stderr).toContain("[Unhandled Rejection] Error: unexpected cleanup rejection");
});
it("prints registered recovery commands before fatal cleanup", async () => {
const result = await runPostmortemProbe(`
import { postmortem } from "${postmortemModuleUrl}";
postmortem.registerFatalRecoveryHint(() => ({
label: "Main",
command: "omp --resume 019cafe0-dead-beef",
}));
Promise.reject(new Error("session crashed"));
await Promise.resolve();
`);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("[Unhandled Rejection] Error: session crashed");
expect(result.stderr).toContain("[Recovery]\n Main: omp --resume 019cafe0-dead-beef");
});
it("exits after an uncaught exception when terminal stderr is revoked", async () => {
const result = await runPostmortemProbe(`
import { spyOn } from "bun:test";