Files
rea/src/browser/CdpTargetSession.ts
T
morluto 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

279 lines
8.4 KiB
TypeScript

import {
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
} from "../domain/browserDeadline.js";
import type { BrowserObservationEndpoint } from "../domain/browserObservation.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { CdpConnection } from "./CdpConnection.js";
import {
CdpTargetSessionOwner,
bindCdpTransportCleanup,
} from "./CdpTargetSessionOwner.js";
import {
cdpTargetWebSocket,
discoverCdpBrowserSocket,
discoverCdpEndpoint,
withCdpDiscoveryDeadline,
type CdpEndpointDiscovery,
type CdpEndpointTarget,
} from "./CdpEndpoint.js";
/** One CDP connection scoped either by a flat browser session or page socket. */
export interface CdpTargetSession {
readonly connection: CdpConnection;
readonly sessionId: string | undefined;
}
/** Discovery for one caller-selected endpoint and the browser socket that read it. */
export interface CdpOpenedEndpoint {
readonly discovery: CdpEndpointDiscovery;
/**
* Browser socket kept for the target session, because a browser may ask
* the user to approve each socket. HTTP discovery opens no socket.
*/
readonly connection: CdpConnection | undefined;
}
/**
* Discover one passive browser endpoint through its selected transport. A
* browser socket's handshake and discovery share one discovery deadline.
*/
export const openCdpEndpoint = async (
endpoint: BrowserObservationEndpoint,
operation: BrowserObservationOperation,
signal?: AbortSignal,
timeoutMs?: number,
owner?: CdpTargetSessionOwner,
commandTimeoutMs?: number,
): Promise<CdpOpenedEndpoint> => {
if (endpoint.transport === "http_discovery")
return {
discovery: await discoverCdpEndpoint(endpoint.url, operation, signal),
connection: undefined,
};
const discoveryTimeoutMs = timeoutMs ?? CDP_DISCOVERY_RESPONSE_TIMEOUT_MS;
// The browser-socket handshake and discovery commands stay on the discovery
// deadline. The selected command deadline starts only after discovery returns.
const discoveryLimits = {
commandTimeoutMs: discoveryTimeoutMs,
// Stay past the discovery abort so a stalled open is labeled as discovery,
// not as a command_timeout_ms handshake.
handshakeTimeoutMs: discoveryTimeoutMs + 1_000,
};
return await withCdpDiscoveryDeadline(
operation,
signal,
async (deadlineSignal) => {
const connection =
owner === undefined
? await CdpConnection.connect(
endpoint.url,
operation,
deadlineSignal,
discoveryLimits,
)
: await owner.connect(
endpoint.url,
operation,
{ signal: deadlineSignal },
"browser-endpoint",
discoveryLimits,
);
try {
const discovery = await discoverCdpBrowserSocket(
connection,
endpoint.url,
operation,
deadlineSignal,
);
connection.selectCommandTimeout(
commandTimeoutMs ?? DEFAULT_CDP_COMMAND_TIMEOUT_MS,
);
return {
discovery,
connection,
};
} catch (cause: unknown) {
if (owner === undefined) {
await connection.close();
throw cause;
}
try {
await owner.finish(connection);
} catch (cleanupCause: unknown) {
throw combineOpenFailure(cause, cleanupCause, operation);
}
throw cause;
}
},
discoveryTimeoutMs,
);
};
/** Open one authorized target through its supported browser or page transport. */
export const openCdpTargetSession = async (
discovery: CdpEndpointDiscovery,
target: CdpEndpointTarget,
operation: BrowserObservationOperation,
signal?: AbortSignal,
limits?: {
readonly maxPayloadBytes?: number;
readonly commandTimeoutMs?: number;
},
owner?: CdpTargetSessionOwner,
): Promise<CdpTargetSession> => {
const webSocket = cdpTargetWebSocket(discovery, target, operation);
const connection =
owner === undefined
? await CdpConnection.connect(webSocket.url, operation, signal, limits)
: await owner.connect(
webSocket.url,
operation,
signal === undefined ? {} : { signal },
target.id,
limits,
);
if (webSocket.scope === "page") return { connection, sessionId: undefined };
try {
return await attachCdpTargetSession(
connection,
target,
operation,
signal,
owner,
);
} catch (cause: unknown) {
if (owner === undefined) throw cause;
try {
await owner.finish(connection);
} catch (cleanupCause: unknown) {
throw combineOpenFailure(cause, cleanupCause, operation);
}
throw cause;
}
};
/** Attach one target through an open browser socket, which the session then owns. */
export const attachCdpTargetSession = async (
connection: CdpConnection,
target: CdpEndpointTarget,
operation: BrowserObservationOperation,
signal?: AbortSignal,
owner?: CdpTargetSessionOwner,
): Promise<CdpTargetSession> => {
try {
const attached = await connection.send(
"Target.attachToTarget",
{ targetId: target.id, flatten: true },
undefined,
signal,
);
return { connection, sessionId: attachedSessionId(attached, operation) };
} catch (cause: unknown) {
if (owner === undefined) {
await connection.close();
throw cause;
}
throw cause;
}
};
const combineOpenFailure = (
primary: unknown,
cleanup: unknown,
operation: BrowserObservationOperation,
): unknown =>
primary instanceof AnalysisError && cleanup instanceof AnalysisError
? bindCdpTransportCleanup(
cleanup,
analysisErrorWithCleanupFailure(primary, cleanup, operation),
)
: new AggregateError(
[primary, cleanup],
`CDP operation ${operation} failed and its transport cleanup is incomplete.`,
{ cause: primary },
);
/** Disable enabled domains, detach browser sessions, and close REA's socket. */
export const closeCdpTargetSession = async (
targetSession: CdpTargetSession,
enabledDomains: readonly string[],
signal?: AbortSignal,
owner?: CdpTargetSessionOwner,
): Promise<void> => {
if (owner !== undefined) {
await owner.finish(targetSession.connection, () =>
cleanupCdpTargetCommands(targetSession, enabledDomains, signal),
);
return;
}
await cleanupCdpTargetCommands(targetSession, enabledDomains, signal);
await targetSession.connection.close();
};
const cleanupCdpTargetCommands = async (
targetSession: CdpTargetSession,
enabledDomains: readonly string[],
signal?: AbortSignal,
): Promise<void> => {
const { connection, sessionId } = targetSession;
const cleanup = new AbortController();
const domainSignal =
signal === undefined
? cleanup.signal
: AbortSignal.any([cleanup.signal, signal]);
const timeout = setTimeout(() => cleanup.abort(), 1_000);
timeout.unref();
try {
if (signal?.aborted !== true) {
for (const domain of enabledDomains) {
if (domainSignal.aborted) break;
try {
await connection.send(
`${domain}.disable`,
{},
sessionId,
domainSignal,
);
} catch {
// Best-effort domain cleanup; transport close is the definitive boundary.
}
}
}
// Cancellation skips optional domain shutdown but still releases the
// attached session using its independent, bounded cleanup signal.
if (!cleanup.signal.aborted && sessionId !== undefined)
try {
await connection.send(
"Target.detachFromTarget",
{ sessionId },
undefined,
cleanup.signal,
);
} catch {
// Best-effort detach; transport close is the definitive boundary.
}
} finally {
clearTimeout(timeout);
cleanup.abort();
}
};
const attachedSessionId = (
value: unknown,
operation: BrowserObservationOperation,
): string => {
if (
typeof value !== "object" ||
value === null ||
!("sessionId" in value) ||
typeof value.sessionId !== "string" ||
value.sessionId.length === 0
)
throw new BrowserObservationError(operation, "protocol_error");
return value.sessionId;
};