mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 03:45:39 +00:00
* 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>
588 lines
19 KiB
TypeScript
588 lines
19 KiB
TypeScript
import WebSocket, { type RawData } from "ws";
|
|
|
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
|
import { CdpCommandRejection } from "./CdpCommandRejection.js";
|
|
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
|
import {
|
|
cdpCleanupGraceExpired,
|
|
cdpCommandDeadlineRecovery,
|
|
cdpCommandTimeoutIsSelectable,
|
|
cdpCommandTimeoutUnrelatedControl,
|
|
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
|
|
noRuntimeOverrideSentence,
|
|
} from "../domain/browserDeadline.js";
|
|
import { safeParseJson } from "../domain/safeJson.js";
|
|
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
|
|
|
|
// ws currently stores maxPayload as a signed 32-bit integer. Larger values can
|
|
// wrap to a negative number, which disables its limit check.
|
|
const MAX_WEBSOCKET_PAYLOAD_BYTES = 0x7fff_ffff;
|
|
|
|
export interface CdpEvent {
|
|
readonly method: string;
|
|
readonly params: unknown;
|
|
readonly sessionId?: string | undefined;
|
|
}
|
|
|
|
export interface CdpTransportEvent extends CdpEvent {
|
|
/** Original WebSocket message payload size, before JSON parsing. */
|
|
readonly wireBytes: number;
|
|
}
|
|
|
|
interface PendingCommand {
|
|
readonly method: string;
|
|
readonly sessionId?: string;
|
|
readonly resolve: (value: unknown) => void;
|
|
readonly reject: (error: AnalysisError) => void;
|
|
readonly dispose: () => void;
|
|
readonly reschedule: (timeoutMs: number) => void;
|
|
}
|
|
|
|
/** Correlated JSON-RPC transport for one CDP target operation. */
|
|
export class CdpConnection {
|
|
readonly #pending = new Map<number, PendingCommand>();
|
|
readonly #listeners = new Set<(event: CdpTransportEvent) => void>();
|
|
readonly #disconnectListeners = new Set<
|
|
(error: BrowserObservationError) => void
|
|
>();
|
|
readonly #protocolFailureListeners = new Set<
|
|
(error: BrowserObservationError) => void
|
|
>();
|
|
#nextId = 1;
|
|
#closed = false;
|
|
#closePromise: Promise<void> | undefined;
|
|
#protocolFailed = false;
|
|
#transportFailure: "disconnected" | "payload_limit" = "disconnected";
|
|
#commandTimeoutMs: number;
|
|
#deadlineKind: "command" | "cleanup" = "command";
|
|
|
|
private constructor(
|
|
private readonly socket: WebSocket,
|
|
private readonly operation: BrowserObservationOperation,
|
|
private readonly maxPayloadBytes: number,
|
|
commandTimeoutMs: number,
|
|
) {
|
|
this.#commandTimeoutMs = commandTimeoutMs;
|
|
socket.on("message", (data) => this.#receive(data));
|
|
socket.on("close", () => this.#disconnect());
|
|
socket.on("error", (cause) =>
|
|
this.#disconnect(
|
|
isRecord(cause) && cause.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"
|
|
? "payload_limit"
|
|
: "disconnected",
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Connect to one already-validated loopback CDP WebSocket. */
|
|
static async connect(
|
|
url: string,
|
|
operation: BrowserObservationOperation,
|
|
signal?: AbortSignal,
|
|
limits?: {
|
|
readonly maxPayloadBytes?: number;
|
|
readonly commandTimeoutMs?: number;
|
|
/** Handshake budget. Discovery passes its own deadline so a short command budget cannot start first. */
|
|
readonly handshakeTimeoutMs?: number;
|
|
},
|
|
onConnected?: (connection: CdpConnection) => void,
|
|
): Promise<CdpConnection> {
|
|
if (signal?.aborted === true)
|
|
throw new AnalysisCancelledError(operation, { cause: signal.reason });
|
|
const maxPayloadBytes =
|
|
limits?.maxPayloadBytes ?? WEB_RUNTIME_LIMITS.protocolBytes;
|
|
const commandTimeoutMs =
|
|
limits?.commandTimeoutMs ?? DEFAULT_CDP_COMMAND_TIMEOUT_MS;
|
|
const handshakeTimeoutMs = limits?.handshakeTimeoutMs ?? commandTimeoutMs;
|
|
if (
|
|
!Number.isSafeInteger(maxPayloadBytes) ||
|
|
maxPayloadBytes <= 0 ||
|
|
maxPayloadBytes > MAX_WEBSOCKET_PAYLOAD_BYTES
|
|
)
|
|
throw new RangeError(
|
|
`CDP maxPayloadBytes must be a positive safe integer no greater than ${String(MAX_WEBSOCKET_PAYLOAD_BYTES)}.`,
|
|
);
|
|
const socket = new WebSocket(url, {
|
|
// waitForOpen owns the deadline and typed handshake failures; the
|
|
// connection owner registers this socket before waiting for open.
|
|
handshakeTimeout: 0,
|
|
maxPayload: maxPayloadBytes,
|
|
perMessageDeflate: false,
|
|
});
|
|
const connection = new CdpConnection(
|
|
socket,
|
|
operation,
|
|
maxPayloadBytes,
|
|
commandTimeoutMs,
|
|
);
|
|
try {
|
|
onConnected?.(connection);
|
|
} catch (cause: unknown) {
|
|
await connection.close();
|
|
throw cause;
|
|
}
|
|
try {
|
|
await waitForOpen(socket, operation, signal, handshakeTimeoutMs);
|
|
} catch (cause: unknown) {
|
|
if (onConnected === undefined) {
|
|
try {
|
|
await connection.close();
|
|
} catch (cleanupCause: unknown) {
|
|
throw new AggregateError(
|
|
[cause, cleanupCause],
|
|
"CDP WebSocket handshake failed and its transport cleanup is incomplete.",
|
|
{ cause },
|
|
);
|
|
}
|
|
}
|
|
throw cause;
|
|
}
|
|
return connection;
|
|
}
|
|
|
|
/** Subscribe to validated CDP event envelopes. */
|
|
onEvent(listener: (event: CdpTransportEvent) => void): () => void {
|
|
this.#listeners.add(listener);
|
|
return () => this.#listeners.delete(listener);
|
|
}
|
|
|
|
/** Subscribe to an unexpected transport loss while an operation is active. */
|
|
onDisconnect(listener: (error: BrowserObservationError) => void): () => void {
|
|
if (this.#closed) {
|
|
listener(this.#transportError(this.#transportFailure));
|
|
return () => undefined;
|
|
}
|
|
this.#disconnectListeners.add(listener);
|
|
return () => this.#disconnectListeners.delete(listener);
|
|
}
|
|
|
|
/** Notify fatal wire parsing failure even when no command is outstanding. */
|
|
onProtocolFailure(
|
|
listener: (error: BrowserObservationError) => void,
|
|
): () => void {
|
|
if (this.#protocolFailed) {
|
|
listener(this.#transportError("protocol_error"));
|
|
return () => undefined;
|
|
}
|
|
this.#protocolFailureListeners.add(listener);
|
|
return () => this.#protocolFailureListeners.delete(listener);
|
|
}
|
|
|
|
/** Apply the post-discovery command deadline to commands that have not started. */
|
|
selectCommandTimeout(timeoutMs: number): void {
|
|
if (this.#deadlineKind === "cleanup") return;
|
|
this.#commandTimeoutMs = timeoutMs;
|
|
}
|
|
|
|
/**
|
|
* Restoration uses the fixed cleanup grace. Commands already waiting are
|
|
* rescheduled from now so a raised command deadline cannot extend them.
|
|
*/
|
|
beginCleanupGrace(timeoutMs: number): void {
|
|
this.#deadlineKind = "cleanup";
|
|
this.#commandTimeoutMs = timeoutMs;
|
|
for (const pending of this.#pending.values()) pending.reschedule(timeoutMs);
|
|
}
|
|
|
|
/** Bound each response separately from the caller-selected observation window. */
|
|
async send(
|
|
method: string,
|
|
params: Readonly<Record<string, unknown>> = {},
|
|
sessionId?: string,
|
|
signal?: AbortSignal,
|
|
): Promise<unknown> {
|
|
if (this.#protocolFailed)
|
|
throw new BrowserObservationError(this.operation, "protocol_error");
|
|
if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
|
|
throw this.#transportError(this.#transportFailure);
|
|
if (signal?.aborted === true)
|
|
throw new AnalysisCancelledError(this.operation, {
|
|
cause: signal.reason,
|
|
});
|
|
const id = this.#nextId;
|
|
this.#nextId += 1;
|
|
const message = JSON.stringify({
|
|
id,
|
|
method,
|
|
params,
|
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
});
|
|
return await new Promise((resolve, reject) => {
|
|
const onAbort = (): void => {
|
|
const pending = this.#pending.get(id);
|
|
if (pending === undefined) return;
|
|
this.#complete(id, pending);
|
|
reject(
|
|
new AnalysisCancelledError(this.operation, {
|
|
cause: signal?.reason,
|
|
}),
|
|
);
|
|
};
|
|
const failTimeout = (): void => {
|
|
const pending = this.#pending.get(id);
|
|
if (pending === undefined) return;
|
|
this.#complete(id, pending);
|
|
if (this.#deadlineKind === "cleanup") {
|
|
const detail = cdpCleanupGraceExpired();
|
|
reject(
|
|
new BrowserObservationError(this.operation, "timeout", {
|
|
detail,
|
|
remediationAction: detail,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const recovery = cdpCommandDeadlineRecovery(
|
|
this.#commandTimeoutMs,
|
|
"command",
|
|
method,
|
|
cdpCommandTimeoutIsSelectable(this.operation),
|
|
);
|
|
reject(
|
|
new BrowserObservationError(this.operation, "timeout", {
|
|
detail: recovery.userMessage,
|
|
remediationAction: recovery.remediationAction,
|
|
}),
|
|
);
|
|
};
|
|
let timer = setTimeout(failTimeout, this.#commandTimeoutMs);
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
this.#pending.set(id, {
|
|
method,
|
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
resolve,
|
|
reject,
|
|
reschedule: (timeoutMs: number): void => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(failTimeout, timeoutMs);
|
|
},
|
|
dispose: () => {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener("abort", onAbort);
|
|
},
|
|
});
|
|
this.socket.send(message, (error) => {
|
|
if (error === undefined || error === null) return;
|
|
const pending = this.#pending.get(id);
|
|
if (pending === undefined) return;
|
|
this.#complete(id, pending);
|
|
reject(
|
|
new BrowserObservationError(this.operation, "disconnected", {
|
|
cause: error,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Close only REA's socket; never close the browser or selected page. */
|
|
close(): Promise<void> {
|
|
this.#closePromise ??= this.#closeTransport().catch((cause: unknown) => {
|
|
this.#closePromise = undefined;
|
|
throw cause;
|
|
});
|
|
return this.#closePromise;
|
|
}
|
|
|
|
async #closeTransport(): Promise<void> {
|
|
this.#closed = true;
|
|
this.#failPending("disconnected");
|
|
if (socketIsClosed(this.socket)) return;
|
|
await new Promise<void>((resolve, reject) => {
|
|
let settled = false;
|
|
let fallbackStarted = false;
|
|
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const finish = (cause?: unknown): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (fallbackTimer !== undefined) clearTimeout(fallbackTimer);
|
|
this.socket.off("close", onClose);
|
|
if (cause === undefined) resolve();
|
|
else reject(cause);
|
|
};
|
|
const onClose = (): void => finish();
|
|
const forceClose = (gracefulFailure?: unknown): void => {
|
|
if (settled || fallbackStarted) return;
|
|
fallbackStarted = true;
|
|
clearTimeout(timer);
|
|
if (socketIsClosed(this.socket)) {
|
|
finish();
|
|
return;
|
|
}
|
|
try {
|
|
this.socket.terminate();
|
|
} catch (cause: unknown) {
|
|
gracefulFailure =
|
|
gracefulFailure === undefined
|
|
? cause
|
|
: new AggregateError(
|
|
[gracefulFailure, cause],
|
|
"Graceful and forced CDP WebSocket close both failed.",
|
|
{ cause: gracefulFailure },
|
|
);
|
|
}
|
|
if (socketIsClosed(this.socket)) finish();
|
|
else {
|
|
fallbackTimer = setTimeout(() => {
|
|
if (socketIsClosed(this.socket)) {
|
|
finish();
|
|
return;
|
|
}
|
|
finish(
|
|
gracefulFailure ??
|
|
new BrowserObservationError(this.operation, "cleanup_failed", {
|
|
detail: "REA could not confirm release of its CDP WebSocket.",
|
|
}),
|
|
);
|
|
}, 1_000);
|
|
}
|
|
};
|
|
const timer = setTimeout(() => forceClose(), 1_000);
|
|
this.socket.once("close", onClose);
|
|
try {
|
|
this.socket.close();
|
|
} catch (closeCause: unknown) {
|
|
forceClose(closeCause);
|
|
}
|
|
});
|
|
}
|
|
|
|
#receive(data: RawData): void {
|
|
if (this.#closed || this.#protocolFailed) return;
|
|
const parsed = safeParseJson(rawText(data));
|
|
if (!parsed.ok) {
|
|
this.#failPending("protocol_error");
|
|
return;
|
|
}
|
|
const message: unknown = parsed.value;
|
|
if (!isRecord(message)) {
|
|
this.#failPending("protocol_error");
|
|
return;
|
|
}
|
|
if ("id" in message) {
|
|
if (
|
|
typeof message.id !== "number" ||
|
|
!Number.isSafeInteger(message.id) ||
|
|
message.id < 1
|
|
) {
|
|
this.#failPending("protocol_error");
|
|
return;
|
|
}
|
|
this.#receiveResponse(message, message.id);
|
|
return;
|
|
}
|
|
if (
|
|
typeof message.method !== "string" ||
|
|
message.method.length === 0 ||
|
|
("params" in message && !isRecord(message.params)) ||
|
|
("sessionId" in message && typeof message.sessionId !== "string")
|
|
) {
|
|
this.#failPending("protocol_error");
|
|
return;
|
|
}
|
|
const wireBytes = rawByteLength(data);
|
|
const event: CdpTransportEvent = {
|
|
method: message.method,
|
|
params: "params" in message ? message.params : {},
|
|
wireBytes,
|
|
...(typeof message.sessionId === "string"
|
|
? { sessionId: message.sessionId }
|
|
: {}),
|
|
};
|
|
for (const listener of this.#listeners) listener(event);
|
|
}
|
|
|
|
#receiveResponse(message: Record<string, unknown>, id: number): void {
|
|
const pending = this.#pending.get(id);
|
|
if (pending === undefined) return;
|
|
const hasResult = "result" in message;
|
|
const hasError = "error" in message;
|
|
// Chromium cannot route a rejection through a flat session after that
|
|
// session disappears. Its browser-level reply omits sessionId, but the
|
|
// connection-global command id still binds this exact terminal error.
|
|
const detachedSessionError =
|
|
pending.sessionId !== undefined &&
|
|
!("sessionId" in message) &&
|
|
hasError &&
|
|
isRecord(message.error) &&
|
|
message.error.code === -32_001 &&
|
|
message.error.message === "Session with given id not found.";
|
|
if (
|
|
(pending.sessionId === undefined
|
|
? "sessionId" in message
|
|
: message.sessionId !== pending.sessionId && !detachedSessionError) ||
|
|
hasResult === hasError
|
|
) {
|
|
this.#failPending("protocol_error");
|
|
return;
|
|
}
|
|
if (hasResult) {
|
|
if (!isRecord(message.result)) {
|
|
this.#failPending("protocol_error");
|
|
return;
|
|
}
|
|
this.#complete(id, pending);
|
|
pending.resolve(message.result);
|
|
return;
|
|
}
|
|
const reported = message.error;
|
|
if (
|
|
!isRecord(reported) ||
|
|
typeof reported.code !== "number" ||
|
|
!Number.isSafeInteger(reported.code) ||
|
|
typeof reported.message !== "string"
|
|
) {
|
|
this.#failPending("protocol_error");
|
|
return;
|
|
}
|
|
this.#complete(id, pending);
|
|
pending.reject(
|
|
new CdpCommandRejection(
|
|
this.operation,
|
|
pending.method,
|
|
reported.code,
|
|
reported.message,
|
|
),
|
|
);
|
|
}
|
|
|
|
#complete(id: number, pending: PendingCommand): void {
|
|
pending.dispose();
|
|
this.#pending.delete(id);
|
|
}
|
|
|
|
#failPending(
|
|
reason: "disconnected" | "protocol_error" | "payload_limit",
|
|
): void {
|
|
const wasProtocolFailed = this.#protocolFailed;
|
|
if (reason !== "protocol_error") this.#closed = true;
|
|
else this.#protocolFailed = true;
|
|
for (const [id, pending] of this.#pending) {
|
|
this.#complete(id, pending);
|
|
pending.reject(this.#transportError(reason));
|
|
}
|
|
if (reason === "protocol_error" && !wasProtocolFailed) {
|
|
const error = this.#transportError(reason);
|
|
for (const listener of this.#protocolFailureListeners) listener(error);
|
|
this.#protocolFailureListeners.clear();
|
|
}
|
|
}
|
|
|
|
#disconnect(reason: "disconnected" | "payload_limit" = "disconnected"): void {
|
|
const wasClosed = this.#closed;
|
|
if (!wasClosed) this.#transportFailure = reason;
|
|
this.#failPending(reason);
|
|
if (wasClosed) return;
|
|
const error = this.#transportError(reason);
|
|
for (const listener of this.#disconnectListeners) listener(error);
|
|
this.#disconnectListeners.clear();
|
|
}
|
|
|
|
#transportError(
|
|
reason: "disconnected" | "protocol_error" | "payload_limit",
|
|
): BrowserObservationError {
|
|
return new BrowserObservationError(
|
|
this.operation,
|
|
reason,
|
|
reason === "payload_limit"
|
|
? {
|
|
detail: noRuntimeOverrideSentence(
|
|
`CDP message exceeded the ${String(this.maxPayloadBytes)} byte protocol budget.`,
|
|
cdpCommandTimeoutUnrelatedControl(this.operation),
|
|
),
|
|
}
|
|
: undefined,
|
|
);
|
|
}
|
|
}
|
|
|
|
const socketIsClosed = (socket: WebSocket): boolean =>
|
|
socket.readyState === WebSocket.CLOSED;
|
|
|
|
const rawText = (data: RawData): string =>
|
|
Array.isArray(data)
|
|
? Buffer.concat(data).toString("utf8")
|
|
: Buffer.isBuffer(data)
|
|
? data.toString("utf8")
|
|
: Buffer.from(data).toString("utf8");
|
|
|
|
const rawByteLength = (data: RawData): number =>
|
|
Array.isArray(data)
|
|
? data.reduce((bytes, chunk) => bytes + chunk.byteLength, 0)
|
|
: data.byteLength;
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
const waitForOpen = async (
|
|
socket: WebSocket,
|
|
operation: BrowserObservationOperation,
|
|
signal?: AbortSignal,
|
|
commandTimeoutMs: number = DEFAULT_CDP_COMMAND_TIMEOUT_MS,
|
|
): Promise<void> =>
|
|
await new Promise((resolve, reject) => {
|
|
const onOpen = (): void => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
const onFailure = (cause: unknown): void => {
|
|
cleanup();
|
|
reject(
|
|
new BrowserObservationError(operation, "endpoint_unreachable", {
|
|
cause,
|
|
}),
|
|
);
|
|
};
|
|
const onClose = (code: number, reason: Buffer): void => {
|
|
onFailure(
|
|
new Error(
|
|
`CDP WebSocket closed before opening (${code}: ${reason.toString("utf8")})`,
|
|
),
|
|
);
|
|
};
|
|
const onAbort = (): void => {
|
|
cleanup();
|
|
terminateSocket(socket);
|
|
reject(new AnalysisCancelledError(operation, { cause: signal?.reason }));
|
|
};
|
|
const cleanup = (): void => {
|
|
clearTimeout(timer);
|
|
socket.off("open", onOpen);
|
|
socket.off("error", onFailure);
|
|
socket.off("close", onClose);
|
|
signal?.removeEventListener("abort", onAbort);
|
|
};
|
|
const timer = setTimeout(() => {
|
|
terminateSocket(socket);
|
|
cleanup();
|
|
const recovery = cdpCommandDeadlineRecovery(
|
|
commandTimeoutMs,
|
|
"handshake",
|
|
undefined,
|
|
cdpCommandTimeoutIsSelectable(operation),
|
|
);
|
|
reject(
|
|
new BrowserObservationError(operation, "timeout", {
|
|
detail: recovery.userMessage,
|
|
remediationAction: recovery.remediationAction,
|
|
}),
|
|
);
|
|
}, commandTimeoutMs);
|
|
socket.once("open", onOpen);
|
|
socket.once("error", onFailure);
|
|
socket.once("close", onClose);
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
if (signal?.aborted === true) onAbort();
|
|
});
|
|
|
|
const terminateSocket = (socket: WebSocket): void => {
|
|
try {
|
|
socket.terminate();
|
|
} catch {
|
|
// The owning connection remains registered and will retry bounded close.
|
|
}
|
|
};
|