Files
reverse-engineering-anything/src/browser/BrowserProcessStartup.ts
T
0155b89953 fix(browser): name web-bundle budgets and recover web deadlines (#6036)
* fix(browser): name web-bundle budgets and recover web deadlines

Name the existing heap, per-script, and total web-bundle phases, and reject a per-script deadline above the total. Promote capture, CDP command, source-map decode, and source-map fetch deadlines at today's defaults. Name DevTools timeoutMs, and state that discovery, cleanup grace, byte budgets, and Electron hook retention have no runtime override.

Refs #5910

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): apply selected web deadlines to the owning phase

Keep HTTP and browser-socket discovery on the fixed discovery deadline so a
short command_timeout_ms starts at the handshake. Apply scenario
command_timeout_ms to launch startup and the primary connect session, and
keep service-worker restoration on the fixed 5000 ms grace. Omit
command_timeout_ms from recovery text when the operation does not accept it.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): retain scenario commands that finish after their deadline

A timed-out scenario command stays owned so cleanup can close a late
browser or context and restore a mutation that lands after the deadline.
Child storage replies use that deadline, scenario recovery names
command_timeout_ms in the capture JSON, and the session test connection
implements beginCleanupGrace.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): keep child command timeouts as the public failure

A nested command such as Page.enable that is delivered but never answered
was stored as protocol_error, hiding the command name, selected deadline,
and recovery. Store the typed timeout and assert that public error.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): keep runtime cleanup on the fixed grace

Switch runtime restoration to the cleanup grace before the first send so a
short command_timeout_ms cannot fail an otherwise successful observation.
Stop advertising command_timeout_ms on list_browser_targets, which never
bounds a post-discovery command.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(browser): prove a short command deadline cannot fail runtime cleanup

Drive restoration through the runtime fixture. A cleanup command that
outlives command_timeout_ms still succeeds, and a stalled restoration
names the fixed 5000 ms grace.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): name the effective source-map decode deadline

codecBudgetLimitation reported a fixed 20-second deadline after a
caller selected decode_timeout_ms. Result and Evidence limitations
now name that effective millisecond deadline.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): separate handshake and target-attach deadlines

A browser-level Target.attachToTarget no longer shares the WebSocket
handshake budget. Blocked service-worker attachment keeps discovery on
its fixed deadline and applies command_timeout_ms to the later handshake
and attach.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): name the web-bundle resource each runtime exhausts

Bun ignores the V8 old-space flag. A Windows Job reports the process
commit limit, and other Bun hosts no longer suggest raising max_heap_mb.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): cite the browser-socket discovery message budget

WebSocket discovery enforces the 64 MiB protocol budget. Timeout
recovery no longer advertises the 8 MiB HTTP document budget.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): bound the blocked service-worker check before discovery

The first registration check has its own fixed 10s deadline. Discovery,
handshake, and Target.attachToTarget stay on their separate deadlines.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): name the operand that stopped web-bundle admission

Host heap pressure no longer claims the fixed result budget is exhausted.
A Windows Bun allocation before the commit limit still names that limit.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 05:47:38 +08:00

182 lines
6.1 KiB
TypeScript

import type { ChildProcess } from "node:child_process";
import { readFile } from "node:fs/promises";
/** Terminal states that can prevent Chrome from publishing a CDP port. */
export type BrowserStartupFailure =
| "spawn-error"
| "exited"
| "signalled"
| "timeout";
/** Safe diagnostics for a browser process that did not publish a CDP port. */
export class BrowserStartupError extends Error {
readonly _tag = "BrowserStartupError" as const;
readonly failure: BrowserStartupFailure;
readonly executable: string;
readonly elapsedMs: number;
readonly startupTimeoutMs: number;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
readonly stderr: string;
constructor(
diagnostics: {
readonly failure: BrowserStartupFailure;
readonly executable: string;
readonly elapsedMs: number;
readonly startupTimeoutMs: number;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
readonly stderr: string;
},
options?: ErrorOptions,
) {
super(browserStartupMessage(diagnostics.failure, diagnostics), options);
this.failure = diagnostics.failure;
this.executable = diagnostics.executable;
this.elapsedMs = diagnostics.elapsedMs;
this.startupTimeoutMs = diagnostics.startupTimeoutMs;
this.exitCode = diagnostics.exitCode;
this.signalCode = diagnostics.signalCode;
this.stderr = diagnostics.stderr;
}
}
/**
* Single owner of the wait for Chrome to publish its DevToolsActivePort.
*
* Callers that need a different budget pass `timeoutMs`; nobody else should
* restate this number, because a per-caller literal silently changes how long
* a cold Chrome launch is tolerated before the failure names its ceiling.
*/
export const BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS = 60_000;
/** Owned child process and startup policy for Chrome CDP discovery. */
export interface BrowserStartupOptions {
readonly child: ChildProcess;
readonly executable: string;
readonly activePortPath: string;
readonly stderr: () => string;
readonly timeoutMs?: number;
readonly pollIntervalMs?: number;
}
/** Wait for Chrome's valid DevToolsActivePort or a terminal process outcome. */
export const waitForBrowserDevtoolsPort = async (
options: BrowserStartupOptions,
): Promise<number> => {
const timeoutMs = options.timeoutMs ?? BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS;
const pollIntervalMs = options.pollIntervalMs ?? 25;
const startedAt = performance.now();
const terminal = childTermination(options, startedAt, timeoutMs);
const deadline = startedAt + timeoutMs;
while (true) {
const terminalState = currentTerminalState(options.child);
if (terminalState !== null)
throw startupError(options, startedAt, timeoutMs, terminalState);
const port = await readDevtoolsPort(options.activePortPath);
if (port !== null) return port;
const remainingMs = deadline - performance.now();
if (remainingMs <= 0)
throw startupError(options, startedAt, timeoutMs, { failure: "timeout" });
const outcome = await Promise.race([
terminal,
delay(Math.min(pollIntervalMs, remainingMs)).then(() => null),
]);
if (outcome !== null) throw outcome;
}
};
type TerminalState =
| { readonly failure: "spawn-error"; readonly cause: unknown }
| { readonly failure: "exited" }
| { readonly failure: "signalled" }
| { readonly failure: "timeout" };
const childTermination = (
options: BrowserStartupOptions,
startedAt: number,
startupTimeoutMs: number,
): Promise<BrowserStartupError> =>
new Promise((resolve) => {
options.child.once("error", (cause: unknown) => {
resolve(
startupError(options, startedAt, startupTimeoutMs, {
failure: "spawn-error",
cause,
}),
);
});
options.child.once("exit", () => {
resolve(
startupError(
options,
startedAt,
startupTimeoutMs,
options.child.signalCode === null
? { failure: "exited" }
: { failure: "signalled" },
),
);
});
});
const currentTerminalState = (child: ChildProcess): TerminalState | null => {
if (child.signalCode !== null) return { failure: "signalled" };
if (child.exitCode !== null) return { failure: "exited" };
return null;
};
const startupError = (
options: BrowserStartupOptions,
startedAt: number,
startupTimeoutMs: number,
state: TerminalState,
): BrowserStartupError =>
new BrowserStartupError(
{
failure: state.failure,
executable: options.executable,
elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)),
startupTimeoutMs,
exitCode: options.child.exitCode,
signalCode: options.child.signalCode,
stderr: options.stderr(),
},
state.failure === "spawn-error" ? { cause: state.cause } : undefined,
);
const readDevtoolsPort = async (path: string): Promise<number | null> => {
try {
const firstLine = (await readFile(path, "utf8")).trim().split("\n")[0];
const port = Number(firstLine);
return Number.isSafeInteger(port) && port > 0 && port <= 65_535
? port
: null;
} catch (cause: unknown) {
// Missing or unreadable port files mean the port is unknown.
void cause;
return null;
}
};
const delay = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
const browserStartupMessage = (
failure: BrowserStartupFailure,
diagnostics: {
readonly executable: string;
readonly elapsedMs: number;
readonly startupTimeoutMs: number;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
readonly stderr: string;
},
): string =>
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; timeoutMs=${String(diagnostics.startupTimeoutMs)} (DevToolsActivePort wait; omitted means ${String(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS)} ms); exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;