Files
reverse-engineering-anything/src/browser/CdpTargetSessionOwner.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

282 lines
8.9 KiB
TypeScript

import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { CdpConnection } from "./CdpConnection.js";
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
/** Typed ownership facts shared by transport errors and their adapter projections. */
class CdpTransportCleanupFailure extends ProviderCleanupError {
constructor(
readonly cleanupLeases: readonly AnalysisCleanupLease[],
failure: {
readonly resources: readonly string[];
readonly operation: string;
readonly diagnostics: Readonly<Record<string, JsonValue>>;
readonly cause: unknown;
},
) {
super("rea-cdp-browser", failure.resources, failure.diagnostics, {
operation: failure.operation,
cause: failure.cause,
});
new AnalysisCleanupLeaseGroup(this, cleanupLeases);
}
}
/** Bind a translated error without interpreting diagnostic cause chains. */
export const bindCdpTransportCleanup = <Failure extends AnalysisError>(
cause: unknown,
failure: Failure,
): Failure => {
if (cause instanceof CdpTransportCleanupFailure)
new AnalysisCleanupLeaseGroup(failure, cause.cleanupLeases);
return failure;
};
interface OwnedConnection {
readonly connection: CdpConnection;
readonly operation: BrowserObservationOperation;
readonly resource: string;
readonly cleanupLease: AnalysisCleanupLease;
active: boolean;
readonly settled: Promise<void>;
settle: () => void;
cleanup: (() => Promise<void>) | undefined;
cleanupAttempted: boolean;
failure: ProviderCleanupError | undefined;
}
/** Retains exact REA-owned CDP transports until their close is observed. */
export class CdpTargetSessionOwner {
readonly #connections = new Map<CdpConnection, OwnedConnection>();
#closing = false;
#admissions = 0;
#closePromise: Promise<void> | undefined;
readonly #admissionWaiters = new Set<() => void>();
/** Admit and own one WebSocket from handshake completion onward. */
async connect(
url: string,
operation: BrowserObservationOperation,
options: ExecutionOptions,
resource: string,
limits?: {
readonly maxPayloadBytes?: number;
readonly commandTimeoutMs?: number;
readonly handshakeTimeoutMs?: number;
},
): Promise<CdpConnection> {
if (this.#closing)
throw new ProviderCleanupError(
"rea-cdp-browser",
[resource],
{ reason: "CDP provider is closing and cannot admit another session." },
{ operation },
);
await this.retryPending();
if (this.#closing)
throw new ProviderCleanupError(
"rea-cdp-browser",
[resource],
{ reason: "CDP provider is closing and cannot admit another session." },
{ operation },
);
if (options.signal?.aborted === true)
throw new AnalysisCancelledError(operation, {
cause: options.signal.reason,
});
this.#admissions += 1;
let connection: CdpConnection | undefined;
try {
return await CdpConnection.connect(
url,
operation,
options.signal,
limits,
(opened) => {
connection = opened;
this.register(opened, operation, resource);
},
);
} catch (cause: unknown) {
if (connection === undefined) throw cause;
try {
await this.finish(connection);
} catch (cleanupCause: unknown) {
if (
cause instanceof AnalysisError &&
cleanupCause instanceof AnalysisError
)
throw bindCdpTransportCleanup(
cleanupCause,
analysisErrorWithCleanupFailure(cause, cleanupCause, operation),
);
throw new AggregateError(
[cause, cleanupCause],
`CDP operation ${operation} failed during handshake and cleanup remains incomplete.`,
{ cause },
);
}
throw cause;
} finally {
this.#admissions -= 1;
if (this.#admissions === 0) {
for (const resolve of this.#admissionWaiters) resolve();
this.#admissionWaiters.clear();
}
}
}
/** Register synchronously before awaiting the WebSocket handshake. */
register(
connection: CdpConnection,
operation: BrowserObservationOperation,
resource: string,
): void {
let settle = (): void => undefined;
const settled = new Promise<void>((resolve) => {
settle = resolve;
});
this.#connections.set(connection, {
connection,
operation,
resource,
cleanupLease: new AnalysisCleanupLease(
() => connection.close(),
[resource],
),
active: true,
settled,
settle,
cleanup: undefined,
cleanupAttempted: false,
failure: undefined,
});
}
/** Finish one admitted operation, retaining a failed transport for retry. */
async finish(
connection: CdpConnection,
cleanup?: () => Promise<void>,
): Promise<void> {
const record = this.#connections.get(connection);
if (record === undefined) return;
if (record.failure !== undefined) throw record.failure;
record.cleanup ??= cleanup;
try {
if (!record.cleanupAttempted && record.cleanup !== undefined) {
record.cleanupAttempted = true;
await record.cleanup();
}
await record.cleanupLease.release();
this.#connections.delete(connection);
} catch (cause: unknown) {
const failure = cleanupError(record, cause);
record.failure = failure;
throw failure;
} finally {
record.active = false;
record.settle();
}
}
/** Retry only retained failed transports; active peers finish their own cleanup. */
async close(): Promise<void> {
this.#closing = true;
this.#closePromise ??= this.#closeAll().catch((cause: unknown) => {
this.#closePromise = undefined;
throw cause;
});
return await this.#closePromise;
}
/** Retry retained failures without touching concurrently active sessions. */
async retryPending(): Promise<void> {
const failures: CdpTransportCleanupFailure[] = [];
for (const record of this.#connections.values()) {
if (record.active || record.failure === undefined) continue;
try {
await record.cleanupLease.release();
this.#connections.delete(record.connection);
} catch (cause: unknown) {
const failure = cleanupError(record, cause);
record.failure = failure;
failures.push(failure);
}
}
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw aggregateCleanupFailures(failures);
}
async #closeAll(): Promise<void> {
while (this.#admissions > 0)
await new Promise<void>((resolve) => this.#admissionWaiters.add(resolve));
while (true) {
const active = [...this.#connections.values()].filter(
({ active }) => active,
);
if (active.length === 0) break;
await Promise.all(active.map(({ settled }) => settled));
}
const failures: CdpTransportCleanupFailure[] = [];
for (const record of this.#connections.values()) {
try {
await record.cleanupLease.release();
this.#connections.delete(record.connection);
} catch (cause: unknown) {
const failure = cleanupError(record, cause);
record.failure = failure;
failures.push(failure);
}
}
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw aggregateCleanupFailures(failures);
}
}
const cleanupError = (
record: OwnedConnection,
cause: unknown,
): CdpTransportCleanupFailure =>
new CdpTransportCleanupFailure([record.cleanupLease], {
resources: [record.resource],
operation: record.operation,
cause,
diagnostics: {
reason:
cause instanceof AnalysisError
? (cause.userMessage ?? cause.message)
: cause instanceof Error
? cause.message
: String(cause),
},
});
const aggregateCleanupFailures = (
failures: readonly CdpTransportCleanupFailure[],
): CdpTransportCleanupFailure =>
new CdpTransportCleanupFailure(
failures.flatMap((failure) => failure.cleanupLeases),
{
resources: failures.flatMap(({ cleanupResources }) => cleanupResources),
operation: "close_cdp_transports",
cause: new AggregateError(
failures,
"One or more REA-owned CDP transports remain open.",
),
diagnostics: {
failures: failures.map(({ message, diagnostics }) => ({
message,
diagnostics: diagnostics ?? {},
})),
},
},
);