feat(browser): reuse admitted browser-socket transport and allow discovery_timeout_ms (#9203)

* feat(browser): reuse admitted browser-socket transport and allow discovery_timeout_ms

* fix(browser): close unhealthy shared sockets and clamp max discovery timer

* fix(browser): retry failed shared-socket cleanup while peer leases remain active

* test(browser): race observation readiness with awaitAcknowledgement and join before mock restore

* fix(browser): isolate shared-socket discovery aborts and coalesce cold connects

* fix(browser): observe pending cold-connect cleanup failures and isolate fake CDP sessions

* refactor(browser): carry typed connect cleanup owners and gate concurrent session test

* test(browser): cover loopback socket reuse-key normalization

* test(browser): verify distinct browser socket path IDs stay separate
This commit is contained in:
Ryan S authored and GitHub committed 2026-11-08 23:06:56 +00:00
1 parent 94182f331e
commit 9d0e87f331
26 files changed
+2868 -411

No files matched your search

+22 -11
View File
@@ -239,10 +239,21 @@ file for you.
`observe_web_session`, `discover_webmcp_tools`, and `capture_web_screenshot`
accept a browser socket. Website runtime attribution, Electron inspection,
and scenario attach mode still require HTTP discovery.
- Each call opens one WebSocket and uses it for discovery
(`Browser.getVersion`, `Target.getTargets`) and for the selected page.
Discovery must finish within the same 10-second deadline as HTTP discovery.
Chrome may ask you to approve each connection.
- An admitted browser socket is retained by the active browser provider across
calls to the same normalized browser WebSocket URL, and concurrent cold-start
calls share one handshake so MCP sessions do not re-open the browser socket on
every step. Scheme case, uncompressed IPv6 loopback spellings, and leading
port zeros normalize to one reuse key, while `127.0.0.1`, `[::1]`, and
distinct `/devtools/browser/<id>` paths remain separate sockets. Each target
operation detaches its flattened target session when finished: unconfirmed
target attach or detach cleanup closes the shared browser socket for session
containment, whereas a caller's own discovery deadline or cancellation
releases only its lease when transport state remains clean. Provider shutdown
closes the retained browser socket. One-shot CLI invocations close their
provider on exit, so Chrome may still ask you to approve each separate CLI
connection. Discovery (`Browser.getVersion`, `Target.getTargets`) defaults to
a 10-second deadline and can be adjusted per call with `discovery_timeout_ms`
(`--discovery-timeout-ms` on the CLI).
- The browser identifier controls access to the socket. Evidence records the
endpoint as `ws://127.0.0.1:9222/devtools/browser/[REDACTED]`.
- `browser.revision` is the socket's `Browser.getVersion` value, such as
@@ -598,13 +609,13 @@ an unknown size, comparison reports unknown rather than treating the observation
as a resource addition or removal; complete inventory coverage still establishes
when an entire group is absent.
CDP discovery has a 10-second response deadline. HTTP discovery documents
use an 8 MiB budget. Browser-socket discovery (`Browser.getVersion`,
`Target.getTargets`) uses the 64 MiB protocol message budget. Neither budget
nor the discovery deadline has a runtime override. `list_browser_targets` accepts no
`command_timeout_ms`: HTTP discovery and browser-socket discovery
(`Browser.getVersion`, `Target.getTargets`) stay on that fixed deadline, and
the socket closes without a later command. Other CDP tools use
CDP discovery defaults to a 10-second response deadline, configurable per call
on passive browser tools with `discovery_timeout_ms` (default 10000). HTTP
discovery documents use an 8 MiB budget. Browser-socket discovery
(`Browser.getVersion`, `Target.getTargets`) uses the 64 MiB protocol message
budget. Neither byte budget has a runtime override. `list_browser_targets`
accepts `discovery_timeout_ms` and no `command_timeout_ms`, because it performs
only discovery without a post-discovery target command. Other CDP tools use
`command_timeout_ms` (default 20000) for each WebSocket handshake and command
response, separate from the caller-selected observation window, discovery, and
the 5000 ms cleanup grace. A browser-level socket's handshake and the following
+4
View File
@@ -164,6 +164,10 @@ const browserParameters = (
: "target_id" in input && observedOrigin !== undefined
? [observedOrigin]
: [],
...("discovery_timeout_ms" in input &&
input.discovery_timeout_ms !== undefined
? { discovery_timeout_ms: input.discovery_timeout_ms }
: {}),
...("command_timeout_ms" in input && input.command_timeout_ms !== undefined
? { command_timeout_ms: input.command_timeout_ms }
: {}),
+53 -26
View File
@@ -75,7 +75,10 @@ import {
type CdpTargetSession,
} from "./CdpTargetSession.js";
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
import {
selectedCdpCommandTimeoutMs,
selectedCdpDiscoveryTimeoutMs,
} from "../domain/browserDeadline.js";
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
import { authorizeCdpTarget } from "./CdpAuthorizedTarget.js";
import { observeCdpSession } from "./CdpObservationSession.js";
@@ -144,11 +147,16 @@ export class CdpBrowserProvider implements BrowserObservationPort {
input.cdp_endpoint,
"list_browser_targets",
options.signal,
undefined,
selectedCdpDiscoveryTimeoutMs(input.discovery_timeout_ms),
this.#owner,
undefined,
{ reuseBrowserSocket: true },
);
if (endpoint.connection !== undefined)
await this.#owner.finish(endpoint.connection);
if (endpoint.connection !== undefined) {
if (endpoint.reusableBrowserSocket === true)
await this.#owner.releaseBrowserSocket(endpoint.connection);
else await this.#owner.finish(endpoint.connection);
}
const { discovery } = endpoint;
const selected = selectTargets(
discovery,
@@ -391,11 +399,15 @@ interface CdpTargetOperationSession<Input> {
readonly progress?: NonNullable<ExecutionOptions["progress"]>;
}
/** Authorize one discovered target, run work in its session, and close REA's socket. */
/** Authorize one discovered target, run work in its session, and release or close REA's socket. */
const withTargetSession = async <
Input extends Pick<
InspectWebPageInput,
"cdp_endpoint" | "target_id" | "allowed_origins" | "command_timeout_ms"
| "cdp_endpoint"
| "target_id"
| "allowed_origins"
| "discovery_timeout_ms"
| "command_timeout_ms"
>,
Output,
>(
@@ -406,6 +418,9 @@ const withTargetSession = async <
owner: CdpTargetSessionOwner,
partialObservation?: (output: Output) => AnalysisPartialObservation,
): Promise<Output> => {
const discoveryTimeoutMs = selectedCdpDiscoveryTimeoutMs(
input.discovery_timeout_ms,
);
const commandTimeoutMs = selectedCdpCommandTimeoutMs(
input.command_timeout_ms,
);
@@ -413,11 +428,13 @@ const withTargetSession = async <
input.cdp_endpoint,
operation,
options.signal,
undefined,
discoveryTimeoutMs,
owner,
commandTimeoutMs,
{ reuseBrowserSocket: true },
);
let targetSession: CdpTargetSession | undefined;
let attachAttempted = false;
let outcome: Result<Output, unknown>;
let cleanupFailure: AnalysisError | undefined;
try {
@@ -431,23 +448,25 @@ const withTargetSession = async <
input.allowed_origins.length > 0
? input
: { ...input, allowed_origins: [...allowedOrigins] };
targetSession =
endpoint.connection === undefined
? await openCdpTargetSession(
endpoint.discovery,
target,
operation,
options.signal,
{ commandTimeoutMs },
owner,
)
: await attachCdpTargetSession(
endpoint.connection,
target,
operation,
options.signal,
owner,
);
if (endpoint.connection === undefined) {
targetSession = await openCdpTargetSession(
endpoint.discovery,
target,
operation,
options.signal,
{ commandTimeoutMs },
owner,
);
} else {
attachAttempted = true;
targetSession = await attachCdpTargetSession(
endpoint.connection,
target,
operation,
options.signal,
owner,
);
}
outcome = ok(
await work({
connection: targetSession.connection,
@@ -471,12 +490,20 @@ const withTargetSession = async <
CLEANUP_DOMAINS,
options.signal,
owner,
endpoint.reusableBrowserSocket === true,
);
if (
endpoint.connection !== undefined &&
endpoint.connection !== targetSession?.connection
)
await owner.finish(endpoint.connection);
) {
if (endpoint.reusableBrowserSocket === true) {
if (attachAttempted)
await owner.evictBrowserSocket(endpoint.connection);
else await owner.releaseBrowserSocket(endpoint.connection);
} else {
await owner.finish(endpoint.connection);
}
}
} catch (cause: unknown) {
cleanupFailure =
cause instanceof AnalysisError
+230 -135
View File
@@ -11,6 +11,7 @@ import {
cdpCommandTimeoutIsSelectable,
cdpCommandTimeoutUnrelatedControl,
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
MAX_BROWSER_DEADLINE_MS,
noRuntimeOverrideSentence,
} from "../domain/browserDeadline.js";
import { safeParseJson } from "../domain/safeJson.js";
@@ -33,6 +34,7 @@ export interface CdpTransportEvent extends CdpEvent {
interface PendingCommand {
readonly method: string;
readonly operation: BrowserObservationOperation;
readonly sessionId?: string;
readonly resolve: (value: unknown) => void;
readonly reject: (error: AnalysisError) => void;
@@ -40,31 +42,24 @@ interface PendingCommand {
readonly reschedule: (timeoutMs: number) => void;
}
/** Correlated JSON-RPC transport for one CDP target operation. */
export class CdpConnection {
class CdpTransportCore {
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
(reason: "disconnected" | "payload_limit") => void
>();
readonly #protocolFailureListeners = new Set<() => void>();
#nextId = 1;
#closed = false;
#closePromise: Promise<void> | undefined;
#protocolFailed = false;
#transportFailure: "disconnected" | "payload_limit" = "disconnected";
#commandTimeoutMs: number;
#deadlineKind: "command" | "cleanup" = "command";
root!: CdpConnection;
private constructor(
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) =>
@@ -76,129 +71,73 @@ export class CdpConnection {
);
}
/** 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,
isReusable(): boolean {
return (
!this.#closed &&
this.#closePromise === undefined &&
!this.#protocolFailed &&
this.socket.readyState === WebSocket.OPEN
);
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 {
onDisconnect(
operation: BrowserObservationOperation,
listener: (error: BrowserObservationError) => void,
): () => void {
if (this.#closed) {
listener(this.#transportError(this.#transportFailure));
listener(this.transportError(this.#transportFailure, operation));
return () => undefined;
}
this.#disconnectListeners.add(listener);
return () => this.#disconnectListeners.delete(listener);
const wrapped = (reason: "disconnected" | "payload_limit"): void => {
listener(this.transportError(reason, operation));
};
this.#disconnectListeners.add(wrapped);
return () => this.#disconnectListeners.delete(wrapped);
}
/** Notify fatal wire parsing failure even when no command is outstanding. */
onProtocolFailure(
operation: BrowserObservationOperation,
listener: (error: BrowserObservationError) => void,
): () => void {
if (this.#protocolFailed) {
listener(this.#transportError("protocol_error"));
listener(this.transportError("protocol_error", operation));
return () => undefined;
}
this.#protocolFailureListeners.add(listener);
return () => this.#protocolFailureListeners.delete(listener);
const wrapped = (): void => {
listener(this.transportError("protocol_error", operation));
};
this.#protocolFailureListeners.add(wrapped);
return () => this.#protocolFailureListeners.delete(wrapped);
}
/** Apply the post-discovery command deadline to commands that have not started. */
selectCommandTimeout(timeoutMs: number): void {
if (this.#deadlineKind === "cleanup") return;
this.#commandTimeoutMs = timeoutMs;
reschedulePending(ids: ReadonlySet<number>, timeoutMs: number): void {
for (const id of ids) this.#pending.get(id)?.reschedule(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,
params: Readonly<Record<string, unknown>>,
sessionId: string | undefined,
signal: AbortSignal | undefined,
context: {
readonly operation: BrowserObservationOperation;
readonly commandTimeoutMs: () => number;
readonly deadlineKind: () => "command" | "cleanup";
readonly scopedIds: Set<number>;
},
): Promise<unknown> {
const { operation } = context;
if (this.#protocolFailed)
throw new BrowserObservationError(this.operation, "protocol_error");
throw new BrowserObservationError(operation, "protocol_error");
if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
throw this.#transportError(this.#transportFailure);
throw this.transportError(this.#transportFailure, operation);
if (signal?.aborted === true)
throw new AnalysisCancelledError(this.operation, {
throw new AnalysisCancelledError(operation, {
cause: signal.reason,
});
const id = this.#nextId;
@@ -215,7 +154,7 @@ export class CdpConnection {
if (pending === undefined) return;
this.#complete(id, pending);
reject(
new AnalysisCancelledError(this.operation, {
new AnalysisCancelledError(operation, {
cause: signal?.reason,
}),
);
@@ -224,33 +163,36 @@ export class CdpConnection {
const pending = this.#pending.get(id);
if (pending === undefined) return;
this.#complete(id, pending);
if (this.#deadlineKind === "cleanup") {
if (context.deadlineKind() === "cleanup") {
const detail = cdpCleanupGraceExpired();
reject(
new BrowserObservationError(this.operation, "timeout", {
new BrowserObservationError(operation, "timeout", {
detail,
remediationAction: detail,
}),
);
return;
}
const timeoutMs = context.commandTimeoutMs();
const recovery = cdpCommandDeadlineRecovery(
this.#commandTimeoutMs,
timeoutMs,
"command",
method,
cdpCommandTimeoutIsSelectable(this.operation),
cdpCommandTimeoutIsSelectable(operation),
);
reject(
new BrowserObservationError(this.operation, "timeout", {
new BrowserObservationError(operation, "timeout", {
detail: recovery.userMessage,
remediationAction: recovery.remediationAction,
}),
);
};
let timer = setTimeout(failTimeout, this.#commandTimeoutMs);
let timer = setTimeout(failTimeout, context.commandTimeoutMs());
signal?.addEventListener("abort", onAbort, { once: true });
context.scopedIds.add(id);
this.#pending.set(id, {
method,
operation,
...(sessionId === undefined ? {} : { sessionId }),
resolve,
reject,
@@ -259,6 +201,7 @@ export class CdpConnection {
timer = setTimeout(failTimeout, timeoutMs);
},
dispose: () => {
context.scopedIds.delete(id);
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
},
@@ -269,7 +212,7 @@ export class CdpConnection {
if (pending === undefined) return;
this.#complete(id, pending);
reject(
new BrowserObservationError(this.operation, "disconnected", {
new BrowserObservationError(operation, "disconnected", {
cause: error,
}),
);
@@ -277,18 +220,18 @@ export class CdpConnection {
});
}
/** 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;
});
close(operation: BrowserObservationOperation): Promise<void> {
this.#closePromise ??= this.#closeTransport(operation).catch(
(cause: unknown) => {
this.#closePromise = undefined;
throw cause;
},
);
return this.#closePromise;
}
async #closeTransport(): Promise<void> {
this.#closed = true;
this.#failPending("disconnected");
async #closeTransport(operation: BrowserObservationOperation): Promise<void> {
this.#disconnect("disconnected");
if (socketIsClosed(this.socket)) return;
await new Promise<void>((resolve, reject) => {
let settled = false;
@@ -333,7 +276,7 @@ export class CdpConnection {
}
finish(
gracefulFailure ??
new BrowserObservationError(this.operation, "cleanup_failed", {
new BrowserObservationError(operation, "cleanup_failed", {
detail: "REA could not confirm release of its CDP WebSocket.",
}),
);
@@ -441,7 +384,7 @@ export class CdpConnection {
this.#complete(id, pending);
pending.reject(
new CdpCommandRejection(
this.operation,
pending.operation,
pending.method,
reported.code,
reported.message,
@@ -462,11 +405,10 @@ export class CdpConnection {
else this.#protocolFailed = true;
for (const [id, pending] of this.#pending) {
this.#complete(id, pending);
pending.reject(this.#transportError(reason));
pending.reject(this.transportError(reason, pending.operation));
}
if (reason === "protocol_error" && !wasProtocolFailed) {
const error = this.#transportError(reason);
for (const listener of this.#protocolFailureListeners) listener(error);
for (const listener of this.#protocolFailureListeners) listener();
this.#protocolFailureListeners.clear();
}
}
@@ -476,22 +418,22 @@ export class CdpConnection {
if (!wasClosed) this.#transportFailure = reason;
this.#failPending(reason);
if (wasClosed) return;
const error = this.#transportError(reason);
for (const listener of this.#disconnectListeners) listener(error);
for (const listener of this.#disconnectListeners) listener(reason);
this.#disconnectListeners.clear();
}
#transportError(
transportError(
reason: "disconnected" | "protocol_error" | "payload_limit",
operation: BrowserObservationOperation,
): BrowserObservationError {
return new BrowserObservationError(
this.operation,
operation,
reason,
reason === "payload_limit"
? {
detail: noRuntimeOverrideSentence(
`CDP message exceeded the ${String(this.maxPayloadBytes)} byte protocol budget.`,
cdpCommandTimeoutUnrelatedControl(this.operation),
cdpCommandTimeoutUnrelatedControl(operation),
),
}
: undefined,
@@ -499,6 +441,155 @@ export class CdpConnection {
}
}
/** Correlated JSON-RPC transport for one CDP target operation. */
export class CdpConnection {
readonly #scopedPendingIds = new Set<number>();
#commandTimeoutMs: number;
#deadlineKind: "command" | "cleanup" = "command";
private constructor(
private readonly core: CdpTransportCore,
private readonly operation: BrowserObservationOperation,
commandTimeoutMs: number,
) {
this.#commandTimeoutMs = commandTimeoutMs;
}
/** Canonical connection identity registered with `CdpTargetSessionOwner`. */
get transportRoot(): CdpConnection {
return this.core.root;
}
/** True while the underlying WebSocket remains open, unpoisoned, and outside cleanup grace. */
isReusable(): boolean {
return this.#deadlineKind === "command" && this.core.isReusable();
}
/** Bind an isolated operation and command-deadline view over this shared WebSocket. */
withOperation(
operation: BrowserObservationOperation,
commandTimeoutMs: number,
): CdpConnection {
return new CdpConnection(this.core, operation, commandTimeoutMs);
}
/** 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 core = new CdpTransportCore(socket, maxPayloadBytes);
const connection = new CdpConnection(core, operation, commandTimeoutMs);
core.root = connection;
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 {
return this.core.onEvent(listener);
}
/** Subscribe to an unexpected transport loss while an operation is active. */
onDisconnect(listener: (error: BrowserObservationError) => void): () => void {
return this.core.onDisconnect(this.operation, listener);
}
/** Notify fatal wire parsing failure even when no command is outstanding. */
onProtocolFailure(
listener: (error: BrowserObservationError) => void,
): () => void {
return this.core.onProtocolFailure(this.operation, 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;
this.core.reschedulePending(this.#scopedPendingIds, 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> {
return await this.core.send(method, params, sessionId, signal, {
operation: this.operation,
commandTimeoutMs: () => this.#commandTimeoutMs,
deadlineKind: () => this.#deadlineKind,
scopedIds: this.#scopedPendingIds,
});
}
/** Close only REA's socket; never close the browser or selected page. */
close(): Promise<void> {
return this.core.close(this.operation);
}
}
const socketIsClosed = (socket: WebSocket): boolean =>
socket.readyState === WebSocket.CLOSED;
@@ -555,11 +646,15 @@ const waitForOpen = async (
socket.off("close", onClose);
signal?.removeEventListener("abort", onAbort);
};
const boundedTimeoutMs = Math.min(
MAX_BROWSER_DEADLINE_MS,
commandTimeoutMs,
);
const timer = setTimeout(() => {
terminateSocket(socket);
cleanup();
const recovery = cdpCommandDeadlineRecovery(
commandTimeoutMs,
boundedTimeoutMs,
"handshake",
undefined,
cdpCommandTimeoutIsSelectable(operation),
@@ -570,7 +665,7 @@ const waitForOpen = async (
remediationAction: recovery.remediationAction,
}),
);
}, commandTimeoutMs);
}, boundedTimeoutMs);
socket.once("open", onOpen);
socket.once("error", onFailure);
socket.once("close", onClose);
+16 -10
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import {
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
cdpCommandTimeoutUnrelatedControl,
cdpDiscoveryTimeoutRecovery,
noRuntimeOverrideSentence,
} from "../domain/browserDeadline.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
@@ -32,7 +33,7 @@ const endpointVersionSchema = z.object({
webSocketDebuggerUrl: z.string().min(1),
});
/** Per-response bounds for loopback CDP discovery documents. There is no runtime override. */
/** Default per-response bounds for loopback CDP discovery documents. The byte budget has no runtime override. */
const CDP_DISCOVERY_RESPONSE_LIMITS = {
bytes: 8 * 1024 * 1024,
timeoutMs: CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
@@ -48,17 +49,15 @@ const discoveryTimeout = (
timeoutMs: number,
phase: "response" | "browser-socket",
): BrowserObservationError => {
const subject =
phase === "response"
? `CDP discovery response exceeded its ${String(timeoutMs)} ms deadline.`
: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`;
const fixed = noRuntimeOverrideSentence(
`${discoveryBudget(phase)} and the ${String(timeoutMs)} ms discovery deadline stay fixed.`,
cdpCommandTimeoutUnrelatedControl(operation),
const recovery = cdpDiscoveryTimeoutRecovery(
operation,
timeoutMs,
phase,
discoveryBudget(phase),
);
return new BrowserObservationError(operation, "timeout", {
detail: `${subject} ${fixed}`,
remediationAction: fixed,
detail: recovery.userMessage,
remediationAction: recovery.remediationAction,
});
};
@@ -116,16 +115,23 @@ export const discoverCdpEndpoint = async (
endpoint: string,
operation: BrowserObservationOperation,
signal?: AbortSignal,
timeoutMs: number = CDP_DISCOVERY_RESPONSE_LIMITS.timeoutMs,
): Promise<CdpEndpointDiscovery> => {
const limits: CdpDiscoveryResponseLimits = {
bytes: CDP_DISCOVERY_RESPONSE_LIMITS.bytes,
timeoutMs,
};
const versionInput = await readCdpJson(
new URL("/json/version", endpoint),
operation,
signal,
limits,
);
const targetsInput = await readCdpJson(
new URL("/json/list", endpoint),
operation,
signal,
limits,
);
const version = parseCdpEndpointValue(
endpointVersionSchema,
+116 -44
View File
@@ -1,12 +1,15 @@
import {
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
MAX_BROWSER_DEADLINE_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 { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { CdpCommandRejection } from "./CdpCommandRejection.js";
import { CdpConnection } from "./CdpConnection.js";
import {
CdpTargetSessionOwner,
@@ -35,6 +38,7 @@ export interface CdpOpenedEndpoint {
* the user to approve each socket. HTTP discovery opens no socket.
*/
readonly connection: CdpConnection | undefined;
readonly reusableBrowserSocket?: boolean;
}
/**
@@ -48,60 +52,104 @@ export const openCdpEndpoint = async (
timeoutMs?: number,
owner?: CdpTargetSessionOwner,
commandTimeoutMs?: number,
options?: { readonly reuseBrowserSocket?: boolean },
): Promise<CdpOpenedEndpoint> => {
const discoveryTimeoutMs = timeoutMs ?? CDP_DISCOVERY_RESPONSE_TIMEOUT_MS;
if (endpoint.transport === "http_discovery")
return {
discovery: await discoverCdpEndpoint(endpoint.url, operation, signal),
discovery: await discoverCdpEndpoint(
endpoint.url,
operation,
signal,
discoveryTimeoutMs,
),
connection: undefined,
};
const discoveryTimeoutMs = timeoutMs ?? CDP_DISCOVERY_RESPONSE_TIMEOUT_MS;
const reuseBrowserSocket =
owner !== undefined && options?.reuseBrowserSocket === true;
// The browser-socket handshake and discovery commands stay on the discovery
// deadline. The selected command deadline starts only after discovery returns.
const discoveryGuardTimeoutMs = Math.min(
MAX_BROWSER_DEADLINE_MS,
discoveryTimeoutMs + 1_000,
);
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,
// Stay past the discovery abort so a stalled open or discovery command is
// labeled as discovery, not as command_timeout_ms, without overflowing
// 32-bit timers.
commandTimeoutMs: discoveryGuardTimeoutMs,
handshakeTimeoutMs: discoveryGuardTimeoutMs,
};
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,
);
const acquired = reuseBrowserSocket
? await owner.acquireBrowserSocket(
endpoint.url,
operation,
{ signal: deadlineSignal },
discoveryLimits,
)
: {
connection:
owner === undefined
? await CdpConnection.connect(
endpoint.url,
operation,
deadlineSignal,
discoveryLimits,
)
: await owner.connect(
endpoint.url,
operation,
{ signal: deadlineSignal },
"browser-endpoint",
discoveryLimits,
),
reused: false,
};
try {
const discovery = await discoverCdpBrowserSocket(
connection,
acquired.connection,
endpoint.url,
operation,
deadlineSignal,
);
connection.selectCommandTimeout(
commandTimeoutMs ?? DEFAULT_CDP_COMMAND_TIMEOUT_MS,
);
const selectedTimeout =
commandTimeoutMs ?? DEFAULT_CDP_COMMAND_TIMEOUT_MS;
if (reuseBrowserSocket) {
owner.admitBrowserSocket(endpoint.url, acquired.connection);
return {
discovery,
connection: acquired.connection.withOperation(
operation,
selectedTimeout,
),
reusableBrowserSocket: true,
};
}
acquired.connection.selectCommandTimeout(selectedTimeout);
return {
discovery,
connection,
connection: acquired.connection,
};
} catch (cause: unknown) {
const releaseLeaseOnly =
reuseBrowserSocket &&
deadlineSignal.aborted &&
cause instanceof AnalysisCancelledError &&
acquired.connection.isReusable();
return await failCdpOpenAfterCleanup(
connection,
acquired.connection,
cause,
operation,
owner,
reuseBrowserSocket
? releaseLeaseOnly
? "release"
: "evict"
: "finish",
);
}
},
@@ -176,9 +224,14 @@ export const failCdpOpenAfterCleanup = async (
primary: unknown,
operation: BrowserObservationOperation,
owner?: CdpTargetSessionOwner,
ownerDisposition: "finish" | "evict" | "release" = "finish",
): Promise<never> => {
try {
if (owner === undefined) await connection.close();
else if (ownerDisposition === "release")
await owner.releaseBrowserSocket(connection);
else if (ownerDisposition === "evict")
await owner.evictBrowserSocket(connection);
else await owner.finish(connection);
} catch (cleanup: unknown) {
throw combineOpenFailure(primary, cleanup, operation);
@@ -202,17 +255,24 @@ const combineOpenFailure = (
{ cause: primary },
);
/** Disable enabled domains, detach browser sessions, and close REA's socket. */
/** Disable enabled domains, detach browser sessions, and release or close REA's socket. */
export const closeCdpTargetSession = async (
targetSession: CdpTargetSession,
enabledDomains: readonly string[],
signal?: AbortSignal,
owner?: CdpTargetSessionOwner,
reuseBrowserSocket = false,
): Promise<void> => {
if (owner !== undefined) {
await owner.finish(targetSession.connection, () =>
cleanupCdpTargetCommands(targetSession, enabledDomains, signal),
);
if (reuseBrowserSocket) {
await owner.releaseBrowserSocket(targetSession.connection, () =>
cleanupCdpTargetCommands(targetSession, enabledDomains, signal),
);
return;
}
await owner.finish(targetSession.connection, async () => {
await cleanupCdpTargetCommands(targetSession, enabledDomains, signal);
});
return;
}
await cleanupCdpTargetCommands(targetSession, enabledDomains, signal);
@@ -223,7 +283,7 @@ const cleanupCdpTargetCommands = async (
targetSession: CdpTargetSession,
enabledDomains: readonly string[],
signal?: AbortSignal,
): Promise<void> => {
): Promise<boolean> => {
const { connection, sessionId } = targetSession;
const cleanup = new AbortController();
const domainSignal =
@@ -244,29 +304,41 @@ const cleanupCdpTargetCommands = async (
domainSignal,
);
} catch {
// Best-effort domain cleanup; transport close is the definitive boundary.
// Best-effort domain cleanup; session detachment or transport close follows.
}
}
}
if (sessionId === undefined) return true;
// 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.
}
if (cleanup.signal.aborted) return false;
try {
await connection.send(
"Target.detachFromTarget",
{ sessionId },
undefined,
cleanup.signal,
);
return true;
} catch (cause: unknown) {
if (isAlreadyDetachedSessionRejection(cause)) return true;
// If detachment is unconfirmed, transport close becomes the definitive boundary.
return false;
}
} finally {
clearTimeout(timeout);
cleanup.abort();
}
};
const isAlreadyDetachedSessionRejection = (cause: unknown): boolean =>
cause instanceof CdpCommandRejection &&
(cause.code === -32_001 ||
(cause.reportedMessage !== null &&
/no session with given id|session with given id not found/iu.test(
cause.reportedMessage,
)));
const attachedSessionId = (
value: unknown,
operation: BrowserObservationOperation,
+520 -42
View File
@@ -3,7 +3,9 @@ import {
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { MAX_BROWSER_DEADLINE_MS } from "../domain/browserDeadline.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
@@ -13,6 +15,8 @@ import type { ExecutionOptions } from "../application/AnalysisProvider.js";
/** Typed ownership facts shared by transport errors and their adapter projections. */
class CdpTransportCleanupFailure extends ProviderCleanupError {
readonly transportCause: unknown;
constructor(
readonly cleanupLeases: readonly AnalysisCleanupLease[],
failure: {
@@ -26,10 +30,21 @@ class CdpTransportCleanupFailure extends ProviderCleanupError {
operation: failure.operation,
cause: failure.cause,
});
this.transportCause = failure.cause;
new AnalysisCleanupLeaseGroup(this, cleanupLeases);
}
}
interface ComposedConnectCleanupFailure {
readonly primary: AnalysisError;
readonly cleanupFailure: AnalysisError;
}
const composedConnectCleanupFailures = new WeakMap<
AnalysisError,
ComposedConnectCleanupFailure
>();
/** Bind a translated error without interpreting diagnostic cause chains. */
export const bindCdpTransportCleanup = <Failure extends AnalysisError>(
cause: unknown,
@@ -42,25 +57,364 @@ export const bindCdpTransportCleanup = <Failure extends AnalysisError>(
interface OwnedConnection {
readonly connection: CdpConnection;
readonly operation: BrowserObservationOperation;
operation: BrowserObservationOperation;
readonly resource: string;
readonly cleanupLease: AnalysisCleanupLease;
active: boolean;
readonly settled: Promise<void>;
activeLeases: number;
settled: Promise<void>;
settle: () => void;
reusableUrl: string | undefined;
evicted: boolean;
closingTransport: boolean;
statusUnsubscribe: (() => void) | undefined;
cleanup: (() => Promise<void>) | undefined;
cleanupAttempted: boolean;
failure: ProviderCleanupError | undefined;
}
interface BrowserSocketLimits {
readonly maxPayloadBytes?: number;
readonly commandTimeoutMs: number;
readonly handshakeTimeoutMs: number;
}
interface BrowserSocketWaiter {
readonly operation: BrowserObservationOperation;
readonly commandTimeoutMs: number;
readonly signal: AbortSignal | undefined;
readonly resolve: (acquired: {
readonly connection: CdpConnection;
readonly reused: boolean;
}) => void;
readonly reject: (cause: unknown) => void;
detachAbort: () => void;
}
interface PendingBrowserSocketOutcome {
readonly cleanupFailure: unknown;
}
interface PendingBrowserSocket {
readonly controller: AbortController;
readonly waiters: Set<BrowserSocketWaiter>;
done: Promise<PendingBrowserSocketOutcome | undefined>;
}
/** Retains exact REA-owned CDP transports until their close is observed. */
export class CdpTargetSessionOwner {
readonly #connections = new Map<CdpConnection, OwnedConnection>();
readonly #browserSockets = new Map<string, CdpConnection>();
readonly #candidateBrowserSockets = new Map<string, CdpConnection>();
readonly #pendingBrowserSockets = new Map<string, PendingBrowserSocket>();
#closing = false;
#admissions = 0;
#closePromise: Promise<void> | undefined;
readonly #admissionWaiters = new Set<() => void>();
/** Reuse an admitted healthy browser socket for `url`, or coalesce a cold connect. */
async acquireBrowserSocket(
url: string,
operation: BrowserObservationOperation,
options: ExecutionOptions,
limits: BrowserSocketLimits,
): Promise<{ readonly connection: CdpConnection; readonly reused: boolean }> {
this.#assertOpen(operation, "browser-endpoint");
await this.retryPending();
const leased = await this.#leaseExistingBrowserSocket(
url,
operation,
options,
limits.commandTimeoutMs,
);
if (leased !== undefined) return leased;
return this.#joinPendingBrowserSocket(url, operation, options, limits);
}
async #leaseExistingBrowserSocket(
url: string,
operation: BrowserObservationOperation,
options: ExecutionOptions,
commandTimeoutMs: number,
): Promise<
{ readonly connection: CdpConnection; readonly reused: boolean } | undefined
> {
while (true) {
this.#assertOpen(operation, "browser-endpoint");
if (options.signal?.aborted === true)
throw new AnalysisCancelledError(operation, {
cause: options.signal.reason,
});
const admitted = this.#browserSockets.get(url);
const socket = admitted ?? this.#candidateBrowserSockets.get(url);
if (socket === undefined) return undefined;
const record = this.#connections.get(socket);
if (
record !== undefined &&
record.failure === undefined &&
!record.evicted &&
socket.isReusable()
) {
if (record.activeLeases === 0) {
let settle = (): void => undefined;
record.settled = new Promise<void>((resolve) => {
settle = resolve;
});
record.settle = settle;
record.active = true;
}
record.activeLeases += 1;
record.operation = operation;
return {
connection: socket.withOperation(operation, commandTimeoutMs),
reused: admitted !== undefined,
};
}
if (admitted === socket) this.#browserSockets.delete(url);
if (this.#candidateBrowserSockets.get(url) === socket)
this.#candidateBrowserSockets.delete(url);
if (record !== undefined) {
this.#evictRecord(record);
if (record.activeLeases === 0)
await this.#releaseTransportRecord(record);
}
}
}
#joinPendingBrowserSocket(
url: string,
operation: BrowserObservationOperation,
options: ExecutionOptions,
limits: BrowserSocketLimits,
): Promise<{ readonly connection: CdpConnection; readonly reused: boolean }> {
let pending = this.#pendingBrowserSockets.get(url);
let startPending: (() => void) | undefined;
if (pending === undefined) {
const created: PendingBrowserSocket = {
controller: new AbortController(),
waiters: new Set(),
done: Promise.resolve(undefined),
};
this.#pendingBrowserSockets.set(url, created);
startPending = () => {
created.done = this.#runPendingBrowserSocket({
url,
pending: created,
initialOperation: operation,
limits,
});
};
pending = created;
}
const activePending = pending;
return new Promise((resolve, reject) => {
const waiter: BrowserSocketWaiter = {
operation,
commandTimeoutMs: limits.commandTimeoutMs,
signal: options.signal,
resolve,
reject,
detachAbort: () => undefined,
};
const onAbort = (): void => {
waiter.detachAbort();
if (!activePending.waiters.delete(waiter)) return;
const cancelError = new AnalysisCancelledError(operation, {
cause: options.signal?.reason,
});
if (activePending.waiters.size > 0) {
reject(cancelError);
return;
}
if (this.#pendingBrowserSockets.get(url) === activePending)
this.#pendingBrowserSockets.delete(url);
activePending.controller.abort(options.signal?.reason);
void activePending.done.then((outcome) => {
if (outcome !== undefined)
reject(rebindPendingConnectFailure(outcome.cleanupFailure, waiter));
else reject(cancelError);
}, reject);
};
if (options.signal !== undefined) {
options.signal.addEventListener("abort", onAbort, { once: true });
waiter.detachAbort = () =>
options.signal?.removeEventListener("abort", onAbort);
}
activePending.waiters.add(waiter);
if (options.signal?.aborted === true) {
onAbort();
return;
}
startPending?.();
});
}
async #runPendingBrowserSocket(context: {
readonly url: string;
readonly pending: PendingBrowserSocket;
readonly initialOperation: BrowserObservationOperation;
readonly limits: BrowserSocketLimits;
}): Promise<PendingBrowserSocketOutcome | undefined> {
const { url, pending, initialOperation, limits } = context;
try {
const root = await this.connect(
url,
initialOperation,
{ signal: pending.controller.signal },
"browser-endpoint",
{
...(limits.maxPayloadBytes === undefined
? {}
: { maxPayloadBytes: limits.maxPayloadBytes }),
commandTimeoutMs: limits.commandTimeoutMs,
handshakeTimeoutMs: MAX_BROWSER_DEADLINE_MS,
},
);
if (this.#pendingBrowserSockets.get(url) === pending)
this.#pendingBrowserSockets.delete(url);
if (pending.waiters.size === 0) {
await this.finish(root);
return undefined;
}
const record = this.#connections.get(root);
if (record !== undefined) {
record.reusableUrl = url;
record.activeLeases = pending.waiters.size;
if (!this.#closing && !record.evicted && root.isReusable())
this.#candidateBrowserSockets.set(url, root);
}
const snapshot = [...pending.waiters];
pending.waiters.clear();
for (const waiter of snapshot) {
waiter.detachAbort();
waiter.resolve({
connection: root.withOperation(
waiter.operation,
waiter.commandTimeoutMs,
),
reused: false,
});
}
return undefined;
} catch (cause: unknown) {
if (this.#pendingBrowserSockets.get(url) === pending)
this.#pendingBrowserSockets.delete(url);
const snapshot = [...pending.waiters];
pending.waiters.clear();
for (const waiter of snapshot) {
waiter.detachAbort();
waiter.reject(rebindPendingConnectFailure(cause, waiter));
}
if (
snapshot.length === 0 &&
((cause instanceof AnalysisError && cause.cleanupIncomplete) ||
cause instanceof AggregateError)
)
return { cleanupFailure: cause };
return undefined;
}
}
/** Publish a verified browser socket for reuse across subsequent operations. */
admitBrowserSocket(url: string, connection: CdpConnection): void {
const root = transportRoot(connection);
const record = this.#connections.get(root);
if (record === undefined) return;
record.reusableUrl = url;
if (this.#candidateBrowserSockets.get(url) === root)
this.#candidateBrowserSockets.delete(url);
if (this.#closing || record.evicted || !root.isReusable()) {
record.evicted = true;
return;
}
const current = this.#browserSockets.get(url);
if (current === root) return;
if (current !== undefined) {
const currentRecord = this.#connections.get(current);
if (
current.isReusable() &&
currentRecord !== undefined &&
currentRecord.failure === undefined &&
!currentRecord.evicted
) {
record.evicted = true;
return;
}
this.#browserSockets.delete(url);
if (currentRecord !== undefined) this.#evictRecord(currentRecord);
}
this.#browserSockets.set(url, root);
let offDisconnect: (() => void) | undefined;
let offProtocolFailure: (() => void) | undefined;
const onUnusable = (): void => {
if (this.#browserSockets.get(url) === root)
this.#browserSockets.delete(url);
record.evicted = true;
record.statusUnsubscribe?.();
record.statusUnsubscribe = undefined;
if (record.activeLeases === 0 && !this.#closing) {
void this.#releaseTransportRecord(record).catch(() => undefined);
}
};
record.statusUnsubscribe = () => {
offDisconnect?.();
offProtocolFailure?.();
};
offDisconnect = root.onDisconnect(onUnusable);
offProtocolFailure = root.onProtocolFailure(onUnusable);
}
/** Release one operation's lease on a browser socket, closing it when tainted or closing. */
async releaseBrowserSocket(
connection: CdpConnection,
cleanup?: () => Promise<boolean>,
): Promise<void> {
const root = transportRoot(connection);
const record = this.#connections.get(root);
if (record === undefined) return;
let keepSocket = !record.cleanupLease.released;
let cleanupFailure: unknown;
if (cleanup !== undefined && !record.cleanupLease.released) {
try {
keepSocket = await cleanup();
} catch (cause: unknown) {
keepSocket = false;
cleanupFailure = cause;
}
}
const mustCloseImmediately =
!keepSocket || !root.isReusable() || record.failure !== undefined;
if (record.activeLeases > 0) record.activeLeases -= 1;
const canStayOpen =
!mustCloseImmediately && !this.#closing && !record.evicted;
const admitted =
record.reusableUrl !== undefined &&
this.#browserSockets.get(record.reusableUrl) === root;
const retain = canStayOpen && (admitted || record.activeLeases > 0);
if (!retain) this.#evictRecord(record);
try {
if (mustCloseImmediately || (!retain && record.activeLeases === 0))
await this.#releaseTransportRecord(record, cleanupFailure);
if (cleanupFailure !== undefined) {
const failure =
cleanupFailure instanceof CdpTransportCleanupFailure
? cleanupFailure
: cleanupError(record, cleanupFailure);
throw failure;
}
} finally {
if (record.activeLeases === 0) {
record.active = false;
record.settle();
}
}
}
/** Evict a browser socket after failed discovery or unconfirmed attachment state. */
async evictBrowserSocket(connection: CdpConnection): Promise<void> {
await this.releaseBrowserSocket(connection, async () => false);
}
/** Admit and own one WebSocket from handshake completion onward. */
async connect(
url: string,
@@ -73,21 +427,9 @@ export class CdpTargetSessionOwner {
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 },
);
this.#assertOpen(operation, resource);
await this.retryPending();
if (this.#closing)
throw new ProviderCleanupError(
"rea-cdp-browser",
[resource],
{ reason: "CDP provider is closing and cannot admit another session." },
{ operation },
);
this.#assertOpen(operation, resource);
if (options.signal?.aborted === true)
throw new AnalysisCancelledError(operation, {
cause: options.signal.reason,
@@ -113,11 +455,17 @@ export class CdpTargetSessionOwner {
if (
cause instanceof AnalysisError &&
cleanupCause instanceof AnalysisError
)
throw bindCdpTransportCleanup(
) {
const combined = bindCdpTransportCleanup(
cleanupCause,
analysisErrorWithCleanupFailure(cause, cleanupCause, operation),
);
composedConnectCleanupFailures.set(combined, {
primary: cause,
cleanupFailure: cleanupCause,
});
throw combined;
}
throw new AggregateError(
[cause, cleanupCause],
`CDP operation ${operation} failed during handshake and cleanup remains incomplete.`,
@@ -140,21 +488,24 @@ export class CdpTargetSessionOwner {
operation: BrowserObservationOperation,
resource: string,
): void {
const root = transportRoot(connection);
let settle = (): void => undefined;
const settled = new Promise<void>((resolve) => {
settle = resolve;
});
this.#connections.set(connection, {
connection,
this.#connections.set(root, {
connection: root,
operation,
resource,
cleanupLease: new AnalysisCleanupLease(
() => connection.close(),
[resource],
),
cleanupLease: new AnalysisCleanupLease(() => root.close(), [resource]),
active: true,
activeLeases: 1,
settled,
settle,
reusableUrl: undefined,
evicted: false,
closingTransport: false,
statusUnsubscribe: undefined,
cleanup: undefined,
cleanupAttempted: false,
failure: undefined,
@@ -166,19 +517,24 @@ export class CdpTargetSessionOwner {
connection: CdpConnection,
cleanup?: () => Promise<void>,
): Promise<void> {
const record = this.#connections.get(connection);
const root = transportRoot(connection);
const record = this.#connections.get(root);
if (record === undefined) return;
if (record.failure !== undefined) throw record.failure;
this.#evictRecord(record);
record.cleanup ??= cleanup;
record.activeLeases = 0;
try {
if (!record.cleanupAttempted && record.cleanup !== undefined) {
record.cleanupAttempted = true;
await record.cleanup();
}
await record.cleanupLease.release();
this.#connections.delete(connection);
await this.#releaseTransportRecord(record);
} catch (cause: unknown) {
const failure = cleanupError(record, cause);
const failure =
cause instanceof CdpTransportCleanupFailure
? cause
: cleanupError(record, cause);
record.failure = failure;
throw failure;
} finally {
@@ -187,34 +543,92 @@ export class CdpTargetSessionOwner {
}
}
/** Retry only retained failed transports; active peers finish their own cleanup. */
/** Close retained browser sockets and retry failed transports after active peers finish. */
async close(): Promise<void> {
this.#closing = true;
this.#browserSockets.clear();
this.#candidateBrowserSockets.clear();
this.#closePromise ??= this.#closeAll().catch((cause: unknown) => {
this.#closePromise = undefined;
throw cause;
});
return await this.#closePromise;
return this.#closePromise;
}
/** Retry retained failures without touching concurrently active sessions. */
/** Retry retained failures, including failed shared-socket closes with active peers. */
async retryPending(): Promise<void> {
const failures: CdpTransportCleanupFailure[] = [];
for (const record of this.#connections.values()) {
if (record.active || record.failure === undefined) continue;
const needsRelease =
!record.cleanupLease.released &&
(record.failure !== undefined ||
record.closingTransport ||
(!record.active && record.evicted));
if (!needsRelease) continue;
try {
await record.cleanupLease.release();
this.#connections.delete(record.connection);
await this.#releaseTransportRecord(record);
} catch (cause: unknown) {
const failure = cleanupError(record, cause);
record.failure = failure;
failures.push(failure);
if (cause instanceof CdpTransportCleanupFailure) failures.push(cause);
else throw cause;
}
}
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw aggregateCleanupFailures(failures);
}
#assertOpen(operation: BrowserObservationOperation, resource: string): void {
if (!this.#closing) return;
throw new ProviderCleanupError(
"rea-cdp-browser",
[resource],
{ reason: "CDP provider is closing and cannot admit another session." },
{ operation },
);
}
#evictRecord(record: OwnedConnection): void {
record.evicted = true;
if (record.reusableUrl !== undefined) {
if (this.#browserSockets.get(record.reusableUrl) === record.connection)
this.#browserSockets.delete(record.reusableUrl);
if (
this.#candidateBrowserSockets.get(record.reusableUrl) ===
record.connection
)
this.#candidateBrowserSockets.delete(record.reusableUrl);
}
record.statusUnsubscribe?.();
record.statusUnsubscribe = undefined;
}
async #releaseTransportRecord(
record: OwnedConnection,
primaryCleanupCause?: unknown,
): Promise<void> {
record.closingTransport = true;
record.statusUnsubscribe?.();
record.statusUnsubscribe = undefined;
try {
await record.cleanupLease.release();
record.closingTransport = false;
record.failure = undefined;
if (record.activeLeases === 0)
this.#connections.delete(record.connection);
} catch (cause: unknown) {
const combinedCause =
primaryCleanupCause === undefined
? cause
: new AggregateError(
[primaryCleanupCause, cause],
"Target cleanup and CDP transport close both failed.",
{ cause: primaryCleanupCause },
);
const failure = cleanupError(record, combinedCause);
record.failure = failure;
throw failure;
}
}
async #closeAll(): Promise<void> {
while (this.#admissions > 0)
await new Promise<void>((resolve) => this.#admissionWaiters.add(resolve));
@@ -227,13 +641,12 @@ export class CdpTargetSessionOwner {
}
const failures: CdpTransportCleanupFailure[] = [];
for (const record of this.#connections.values()) {
this.#evictRecord(record);
try {
await record.cleanupLease.release();
this.#connections.delete(record.connection);
await this.#releaseTransportRecord(record);
} catch (cause: unknown) {
const failure = cleanupError(record, cause);
record.failure = failure;
failures.push(failure);
if (cause instanceof CdpTransportCleanupFailure) failures.push(cause);
else throw cause;
}
}
if (failures.length === 1) throw failures[0];
@@ -241,6 +654,71 @@ export class CdpTargetSessionOwner {
}
}
const transportRoot = (connection: CdpConnection): CdpConnection =>
(connection as Partial<Pick<CdpConnection, "transportRoot">>).transportRoot ??
connection;
const rebindPendingConnectFailure = (
cause: unknown,
waiter: Pick<BrowserSocketWaiter, "operation" | "signal">,
): unknown => {
if (cause instanceof BrowserObservationError) {
const rebound = cause.forOperation(waiter.operation);
return cause.cleanupIncomplete
? rebound.bindCleanupDependencies([cause])
: rebound;
}
if (cause instanceof AnalysisCancelledError) {
const rebound = new AnalysisCancelledError(waiter.operation, {
cause: waiter.signal?.reason ?? cause.cause,
cleanup: cause.cleanup,
});
return cause.cleanupIncomplete
? rebound.bindCleanupDependencies([cause])
: rebound;
}
if (cause instanceof AnalysisError) {
const composed = composedConnectCleanupFailures.get(cause);
if (composed !== undefined) {
const reboundPrimary = rebindPendingConnectFailure(
composed.primary,
waiter,
);
if (
reboundPrimary instanceof AnalysisError &&
reboundPrimary !== composed.primary
) {
const reboundComposed = bindCdpTransportCleanup(
composed.cleanupFailure,
analysisErrorWithCleanupFailure(
reboundPrimary,
composed.cleanupFailure,
waiter.operation,
),
);
composedConnectCleanupFailures.set(reboundComposed, {
primary: reboundPrimary,
cleanupFailure: composed.cleanupFailure,
});
return reboundComposed;
}
return cause;
}
}
if (
cause instanceof CdpTransportCleanupFailure &&
cause.operation !== waiter.operation
) {
return new CdpTransportCleanupFailure(cause.cleanupLeases, {
resources: cause.cleanupResources,
operation: waiter.operation,
diagnostics: cause.diagnostics ?? {},
cause: cause.transportCause,
});
}
return cause;
};
const cleanupError = (
record: OwnedConnection,
cause: unknown,
+6 -3
View File
@@ -4,8 +4,9 @@ import { Cli, z } from "incur";
import { browserCliError } from "./browserCliError.js";
import { createBrowserObservationProvider } from "../composition/browserObservation.js";
import {
browserScopeOptions,
passiveBrowserScopeOptions,
selectedCommandTimeout,
selectedDiscoveryTimeout,
} from "../cliObservationOptions.js";
import {
captureWebScreenshot,
@@ -66,7 +67,7 @@ const registerWebMcp = (
),
}),
options: z.object({
...browserScopeOptions,
...passiveBrowserScopeOptions,
observationMs: cliDecimalField(
discoverWebMcpToolsInputSchema.shape.observation_ms,
).describe("Observation duration in milliseconds"),
@@ -80,6 +81,7 @@ const registerWebMcp = (
allowed_origins: options.allowedOrigins,
target_id: args.targetId,
observation_ms: options.observationMs,
...selectedDiscoveryTimeout(options.discoveryTimeoutMs),
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = discoverWebMcpToolsInputSchema.safeParse(input);
@@ -183,7 +185,7 @@ const registerScreenshot = (
),
}),
options: z.object({
...browserScopeOptions,
...passiveBrowserScopeOptions,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
@@ -193,6 +195,7 @@ const registerScreenshot = (
cdp_endpoint: args.endpoint,
allowed_origins: options.allowedOrigins,
target_id: args.targetId,
...selectedDiscoveryTimeout(options.discoveryTimeoutMs),
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = captureWebScreenshotInputSchema.safeParse(input);
+10 -4
View File
@@ -30,9 +30,10 @@ import { cliDecimalField } from "./options.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import {
browserPageInspectionOptions,
browserScopeOptions,
observationDuration,
passiveBrowserScopeOptions,
selectedCommandTimeout,
selectedDiscoveryTimeout,
} from "../cliObservationOptions.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
@@ -61,7 +62,8 @@ const registerTargetList = (
),
}),
options: z.object({
allowedOrigins: browserScopeOptions.allowedOrigins,
allowedOrigins: passiveBrowserScopeOptions.allowedOrigins,
discoveryTimeoutMs: passiveBrowserScopeOptions.discoveryTimeoutMs,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
@@ -70,6 +72,7 @@ const registerTargetList = (
const input = {
cdp_endpoint: args.endpoint,
allowed_origins: options.allowedOrigins,
...selectedDiscoveryTimeout(options.discoveryTimeoutMs),
};
const parsed = listBrowserTargetsInputSchema.safeParse(input);
if (!parsed.success)
@@ -128,6 +131,7 @@ const registerPageInspection = (
include_script_sources: options.includeScriptSources,
include_storage_keys: options.includeStorageKeys,
include_storage_fingerprints: options.includeStorageFingerprints,
...selectedDiscoveryTimeout(options.discoveryTimeoutMs),
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = inspectWebPageInputSchema.safeParse(input);
@@ -171,7 +175,7 @@ const registerBundleAnalysis = (
),
}),
options: z.object({
...browserScopeOptions,
...passiveBrowserScopeOptions,
...webBundleDeliveryOptions,
observationMs: observationDuration(500),
fetchSourceMaps: z
@@ -220,6 +224,7 @@ const registerBundleAnalysis = (
: {
source_map_fetch_timeout_ms: options.sourceMapFetchTimeoutMs,
}),
...selectedDiscoveryTimeout(options.discoveryTimeoutMs),
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = analyzeWebBundleInputSchema.safeParse(input);
@@ -263,7 +268,7 @@ const registerObservationSession = (
),
}),
options: z.object({
...browserScopeOptions,
...passiveBrowserScopeOptions,
observationMs: observationDuration(10_000, 1),
}),
run: ({ args, options }) =>
@@ -275,6 +280,7 @@ const registerObservationSession = (
allowed_origins: options.allowedOrigins,
target_id: args.targetId,
observation_ms: options.observationMs,
...selectedDiscoveryTimeout(options.discoveryTimeoutMs),
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = observeWebSessionInputSchema.safeParse(input);
+19 -2
View File
@@ -1,7 +1,10 @@
import { javascriptRouteDeliveryOptions } from "./cli/javascriptRouteDelivery.js";
import { z } from "incur";
import { cliDecimalField } from "./cli/options.js";
import { webRuntimeCommandTimeoutSchema } from "./domain/browserDeadline.js";
import {
browserDiscoveryTimeoutSchema,
webRuntimeCommandTimeoutSchema,
} from "./domain/browserDeadline.js";
import { javaScriptAnalysisResourceControlsSchema } from "./domain/javascript/javascriptAnalysisResourceControls.js";
import { javaScriptSourcePathsSchema } from "./domain/javascript/javascriptSourceSelection.js";
@@ -25,6 +28,20 @@ export const browserScopeOptions = {
commandTimeoutMs: cliDecimalField(webRuntimeCommandTimeoutSchema),
};
/** Shared options for passive browser commands that also accept discovery_timeout_ms. */
export const passiveBrowserScopeOptions = {
...browserScopeOptions,
discoveryTimeoutMs: cliDecimalField(browserDiscoveryTimeoutSchema),
};
/** Keep an omitted discovery deadline absent so Evidence records the caller selection only. */
export const selectedDiscoveryTimeout = (
discoveryTimeoutMs: number | undefined,
): { readonly discovery_timeout_ms?: number } =>
discoveryTimeoutMs === undefined
? {}
: { discovery_timeout_ms: discoveryTimeoutMs };
/** Keep an omitted command deadline absent so Evidence records the caller selection only. */
export const selectedCommandTimeout = (
commandTimeoutMs: number | undefined,
@@ -34,7 +51,7 @@ export const selectedCommandTimeout = (
: { command_timeout_ms: commandTimeoutMs };
export const browserPageInspectionOptions = z.object({
...browserScopeOptions,
...passiveBrowserScopeOptions,
observationMs: observationDuration(500),
includeAccessibilityText: z
.boolean()
+1 -1
View File
@@ -171,7 +171,7 @@ export const BROWSER_TOOL_CONTRACTS = [
name: "list_browser_targets",
...toolContractMetadata("list_browser_targets"),
description:
"List page targets exposed by the selected loopback Chrome DevTools Protocol endpoint. Optionally filter by exact origin. URLs preserve query values and fragments; only URL userinfo credentials are removed. HTTP discovery and browser-socket discovery use the fixed 10-second discovery deadline, then the socket closes without a later command. This operation accepts no command_timeout_ms; that listing deadline has no runtime override.",
"List page targets exposed by the selected loopback Chrome DevTools Protocol endpoint. Optionally filter by exact origin. URLs preserve query values and fragments; only URL userinfo credentials are removed. HTTP discovery and browser-socket discovery use optional discovery_timeout_ms (default 10000 ms, range 1..2147483647, applied on the next call) without running a post-discovery target command, so this operation accepts no command_timeout_ms. An admitted browser-socket endpoint stays retained on the active browser provider for later calls and closes on unconfirmed target-session cleanup or when that provider closes; a discovery deadline or cancellation on a clean shared socket releases only that call's lease.",
kind: "browser-provider",
inputSchema: listBrowserTargetsInputSchema,
outputSchema: listOutputSchema,
+65 -2
View File
@@ -58,11 +58,33 @@ export const noRuntimeOverrideSentence = (
/** Handshake and per-command deadline shared by CDP browser tools. */
export const webRuntimeCommandTimeoutSchema = optionalBrowserDeadlineMs(
`CDP WebSocket handshake and per-command response deadline in milliseconds (default ${String(DEFAULT_CDP_COMMAND_TIMEOUT_MS)}). Omitted means ${String(DEFAULT_CDP_COMMAND_TIMEOUT_MS)} and applies on the next call. deadlineExpired for a handshake or command is not the caller observation window, not the ${String(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)} ms CDP discovery response deadline, and not the ${String(CDP_CLEANUP_GRACE_MS)} ms cleanup grace. It also bounds passive script-source collection and the inspection/source-join phase that share this deadline. Does not raise the fixed protocol, retained-event, or source byte budgets. CDP discovery and cleanup grace have no runtime override.`,
`CDP WebSocket handshake and per-command response deadline in milliseconds (default ${String(DEFAULT_CDP_COMMAND_TIMEOUT_MS)}). Omitted means ${String(DEFAULT_CDP_COMMAND_TIMEOUT_MS)} and applies on the next call. deadlineExpired for a handshake or command is not the caller observation window, not the CDP discovery deadline (default ${String(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)} ms, or discovery_timeout_ms where supported), and not the ${String(CDP_CLEANUP_GRACE_MS)} ms cleanup grace. It also bounds passive script-source collection and the inspection/source-join phase that share this deadline. Does not raise the fixed protocol, retained-event, or source byte budgets, or the cleanup grace, which have no runtime override.`,
);
/** Per-response HTTP discovery and browser-socket discovery deadline. */
export const browserDiscoveryTimeoutSchema = optionalBrowserDeadlineMs(
`Loopback CDP HTTP discovery per-response deadline and browser-socket handshake plus discovery (Browser.getVersion, Target.getTargets) deadline in milliseconds (default ${String(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)}). Omitted means ${String(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)} and applies on the next call. Separate from command_timeout_ms, the caller observation window, and the ${String(CDP_CLEANUP_GRACE_MS)} ms cleanup grace. Does not raise the fixed HTTP discovery document or WebSocket protocol byte budgets, which have no runtime override.`,
);
export type CdpCommandDeadlinePhase = "handshake" | "command" | "source-join";
/**
* Operations whose public input accepts discovery_timeout_ms. Other CDP callers
* keep the fixed numeric discovery deadline and must not advertise a setting they lack.
*/
const DISCOVERY_TIMEOUT_OPERATIONS = new Set<BrowserObservationOperation>([
"list_browser_targets",
"inspect_web_page",
"analyze_web_bundle",
"observe_web_session",
"discover_webmcp_tools",
"capture_web_screenshot",
]);
export const cdpDiscoveryTimeoutIsSelectable = (
operation: BrowserObservationOperation,
): boolean => DISCOVERY_TIMEOUT_OPERATIONS.has(operation);
/**
* Operations whose public input accepts command_timeout_ms. Other CDP callers
* keep the same numeric deadline and must not advertise a setting they lack.
@@ -121,7 +143,43 @@ export const cdpCommandDeadlineRecovery = (
}
return {
userMessage: `${subject} This is not the caller observation window, not the ${String(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)} ms CDP discovery deadline, and not the ${String(CDP_CLEANUP_GRACE_MS)} ms cleanup grace.`,
remediationAction: `${browserDeadlineIncrease("command_timeout_ms", timeoutMs, DEFAULT_CDP_COMMAND_TIMEOUT_MS, "--command-timeout-ms")} ${browserDeadlineRetry} command_timeout_ms does not raise the fixed protocol, retained-event, or source byte budgets, the ${String(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)} ms CDP discovery response deadline, or the ${String(CDP_CLEANUP_GRACE_MS)} ms cleanup grace. Those have no runtime override.`,
remediationAction: `${browserDeadlineIncrease("command_timeout_ms", timeoutMs, DEFAULT_CDP_COMMAND_TIMEOUT_MS, "--command-timeout-ms")} ${browserDeadlineRetry} command_timeout_ms does not raise the CDP discovery deadline (default ${String(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)} ms, or discovery_timeout_ms where supported), nor the fixed protocol, retained-event, or source byte budgets or the ${String(CDP_CLEANUP_GRACE_MS)} ms cleanup grace, which have no runtime override.`,
};
};
/** Name the CDP discovery deadline, and name discovery_timeout_ms only when the operation accepts it. */
export const cdpDiscoveryTimeoutRecovery = (
operation: BrowserObservationOperation,
timeoutMs: number,
phase: "response" | "browser-socket",
budgetDescription: string,
): { readonly userMessage: string; readonly remediationAction: string } => {
const unrelatedCommand = cdpCommandTimeoutUnrelatedControl(operation);
if (!cdpDiscoveryTimeoutIsSelectable(operation)) {
const subject =
phase === "response"
? `CDP discovery response exceeded its ${String(timeoutMs)} ms deadline.`
: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`;
const fixed = noRuntimeOverrideSentence(
`${budgetDescription} and the ${String(timeoutMs)} ms discovery deadline stay fixed.`,
unrelatedCommand,
);
return {
userMessage: `${subject} ${fixed}`,
remediationAction: fixed,
};
}
const subject =
phase === "response"
? `CDP discovery response exceeded its ${String(timeoutMs)} ms discovery_timeout_ms deadline (deadlineExpired).`
: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms discovery_timeout_ms deadline (deadlineExpired).`;
const fixedBudget = noRuntimeOverrideSentence(
`${budgetDescription} stays fixed.`,
unrelatedCommand,
);
return {
userMessage: `${subject} This is not the caller observation window and not the ${String(CDP_CLEANUP_GRACE_MS)} ms cleanup grace. ${fixedBudget}`,
remediationAction: `${browserDeadlineIncrease("discovery_timeout_ms", timeoutMs, CDP_DISCOVERY_RESPONSE_TIMEOUT_MS, "--discovery-timeout-ms")} ${browserDeadlineRetry} ${fixedBudget}`,
};
};
@@ -136,3 +194,8 @@ export const selectedCdpCommandTimeoutMs = (
selected: number | undefined,
): number =>
selectedBrowserDeadlineMs(selected, DEFAULT_CDP_COMMAND_TIMEOUT_MS);
export const selectedCdpDiscoveryTimeoutMs = (
selected: number | undefined,
): number =>
selectedBrowserDeadlineMs(selected, CDP_DISCOVERY_RESPONSE_TIMEOUT_MS);
+48
View File
@@ -142,6 +142,54 @@ describe("browser observation contracts", () => {
});
});
it("normalizes equivalent loopback browser-socket spellings while keeping IPv4, IPv6, and socket IDs distinct", () => {
expect(
browserObservationEndpointSchema.parse(
"WS://[0:0:0:0:0:0:0:1]:09222/devtools/browser/0b1c-socket-id",
),
).toEqual({
transport: "browser_websocket",
url: "ws://[::1]:9222/devtools/browser/0b1c-socket-id",
recorded: "ws://[::1]:9222/devtools/browser/[REDACTED]",
});
expect(
browserObservationEndpointSchema.parse(
"WS://127.0.0.1:0080/devtools/browser/0b1c-socket-id",
),
).toEqual({
transport: "browser_websocket",
url: "ws://127.0.0.1:80/devtools/browser/0b1c-socket-id",
recorded: "ws://127.0.0.1:80/devtools/browser/[REDACTED]",
});
expect(
browserObservationEndpointSchema.parse(
"WS://[0000:0000:0000:0000:0000:0000:0000:0001]:09222/devtools/browser/0b1c-socket-id",
).url,
).toBe(
browserObservationEndpointSchema.parse(
"ws://[::1]:9222/devtools/browser/0b1c-socket-id",
).url,
);
expect(
browserObservationEndpointSchema.parse(
"ws://127.0.0.1:9222/devtools/browser/0b1c-socket-id",
).url,
).not.toBe(
browserObservationEndpointSchema.parse(
"ws://[::1]:9222/devtools/browser/0b1c-socket-id",
).url,
);
expect(
browserObservationEndpointSchema.parse(
"ws://127.0.0.1:9222/devtools/browser/Socket-Id",
).url,
).not.toBe(
browserObservationEndpointSchema.parse(
"ws://127.0.0.1:9222/devtools/browser/socket-id",
).url,
);
});
describe("browser observation sensitive surfaces and retention", () => {
it("removes only URL userinfo and preserves the original query and fragment", () => {
expect(
+7 -3
View File
@@ -1,6 +1,9 @@
import { z } from "zod";
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
import {
browserDiscoveryTimeoutSchema,
webRuntimeCommandTimeoutSchema,
} from "./browserDeadline.js";
import { urlDeclaresQueryOrFragment } from "./urlComponents.js";
import {
parseRecordedTransportUrl,
@@ -194,12 +197,13 @@ export const browserAllowedOriginsSchema = z
const browserInput = {
cdp_endpoint: browserObservationEndpointSchema,
allowed_origins: browserAllowedOriginsSchema,
discovery_timeout_ms: browserDiscoveryTimeoutSchema,
};
/**
* Public input for complete discovery, optionally filtered by exact origin.
* Discovery uses the fixed deadline and the socket closes without a later
* command, so this operation accepts no command_timeout_ms.
* Discovery uses discovery_timeout_ms (default 10000 ms) without running a
* post-discovery target command, so this operation accepts no command_timeout_ms.
*/
export const listBrowserTargetsInputSchema = z.strictObject({
...browserInput,
+5 -1
View File
@@ -1,7 +1,10 @@
import { z } from "zod";
import { utcDateTimeSchema } from "./offsetDateTime.js";
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
import {
browserDiscoveryTimeoutSchema,
webRuntimeCommandTimeoutSchema,
} from "./browserDeadline.js";
import {
browserAllowedOriginsSchema,
browserObservationEndpointSchema,
@@ -13,6 +16,7 @@ import { browserVersionSchema } from "./browserObservationSchemas.js";
export const observeWebSessionInputSchema = z.strictObject({
cdp_endpoint: browserObservationEndpointSchema,
allowed_origins: browserAllowedOriginsSchema,
discovery_timeout_ms: browserDiscoveryTimeoutSchema,
target_id: z.string().min(1),
observation_ms: z.int().min(1).default(10_000),
command_timeout_ms: webRuntimeCommandTimeoutSchema,
+44
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
cdpCleanupGraceExpired,
cdpCommandDeadlineRecovery,
cdpDiscoveryTimeoutRecovery,
} from "./browserDeadline.js";
import { listBrowserTargetsInputSchema } from "./browserObservation.js";
import {
@@ -47,6 +48,7 @@ describe("web deadline controls", () => {
expect(parsed.total_analysis_timeout_ms).toBeUndefined();
expect(parsed.max_heap_mb).toBeUndefined();
expect(parsed.source_map_fetch_timeout_ms).toBeUndefined();
expect(parsed.discovery_timeout_ms).toBeUndefined();
expect(parsed.command_timeout_ms).toBeUndefined();
});
@@ -54,9 +56,22 @@ describe("web deadline controls", () => {
expect(
analyzeWebBundleInputSchema.safeParse({
...bundle,
discovery_timeout_ms: 2_147_483_647,
command_timeout_ms: 2_147_483_647,
}).success,
).toBe(true);
expect(
listBrowserTargetsInputSchema.safeParse({
cdp_endpoint: "http://127.0.0.1:9222",
discovery_timeout_ms: 15_000,
}).success,
).toBe(true);
expect(
listBrowserTargetsInputSchema.safeParse({
cdp_endpoint: "http://127.0.0.1:9222",
discovery_timeout_ms: 0,
}).success,
).toBe(false);
expect(
listBrowserTargetsInputSchema.safeParse({
cdp_endpoint: "http://127.0.0.1:9222",
@@ -108,6 +123,35 @@ describe("web deadline controls", () => {
expect(inspector.userMessage).not.toContain("command_timeout_ms");
expect(inspector.remediationAction).not.toContain("--command-timeout-ms");
expect(inspector.remediationAction).toContain("no runtime override");
const selectableDiscovery = cdpDiscoveryTimeoutRecovery(
"inspect_web_page",
15_000,
"browser-socket",
"The 67108864-byte browser-socket message budget",
);
expect(selectableDiscovery.userMessage).toContain(
"15000 ms discovery_timeout_ms",
);
expect(selectableDiscovery.remediationAction).toContain(
"Increase discovery_timeout_ms above 15000",
);
expect(selectableDiscovery.remediationAction).toContain(
"--discovery-timeout-ms",
);
expect(selectableDiscovery.remediationAction).toContain(
"command_timeout_ms does not raise it",
);
const fixedDiscovery = cdpDiscoveryTimeoutRecovery(
"observe_web_execution",
10_000,
"response",
"The 8388608-byte discovery document budget",
);
expect(fixedDiscovery.userMessage).not.toContain("discovery_timeout_ms");
expect(fixedDiscovery.remediationAction).not.toContain(
"--discovery-timeout-ms",
);
expect(fixedDiscovery.remediationAction).toContain("no runtime override");
});
});
+5 -1
View File
@@ -1,6 +1,9 @@
import { z } from "zod";
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
import {
browserDiscoveryTimeoutSchema,
webRuntimeCommandTimeoutSchema,
} from "./browserDeadline.js";
import { browserCompletenessSchema } from "./browserCompleteness.js";
import {
browserAllowedOriginsSchema,
@@ -15,6 +18,7 @@ import { digestSchema, prefixedDigestSchema } from "./../domain/digests.js";
export const discoverWebMcpToolsInputSchema = z.strictObject({
cdp_endpoint: browserObservationEndpointSchema,
allowed_origins: browserAllowedOriginsSchema,
discovery_timeout_ms: browserDiscoveryTimeoutSchema,
target_id: z.string().min(1),
observation_ms: z.int().min(0).default(100),
command_timeout_ms: webRuntimeCommandTimeoutSchema,
+5 -1
View File
@@ -3,7 +3,10 @@ import { createHash } from "node:crypto";
import { z } from "zod";
import { utcDateTimeSchema } from "./offsetDateTime.js";
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
import {
browserDiscoveryTimeoutSchema,
webRuntimeCommandTimeoutSchema,
} from "./browserDeadline.js";
import { browserCompletenessSchema } from "./browserCompleteness.js";
import {
browserAllowedOriginsSchema,
@@ -38,6 +41,7 @@ export type WebScreenshotArtifact = z.infer<typeof webScreenshotArtifactSchema>;
export const captureWebScreenshotInputSchema = z.strictObject({
cdp_endpoint: browserObservationEndpointSchema,
allowed_origins: browserAllowedOriginsSchema,
discovery_timeout_ms: browserDiscoveryTimeoutSchema,
target_id: z.string().min(1),
command_timeout_ms: webRuntimeCommandTimeoutSchema,
});
File diff suppressed because it is too large. Load diff
@@ -1,3 +1,4 @@
import { createServer as createHttpServer } from "node:http";
import { createServer, type Socket } from "node:net";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
@@ -11,12 +12,16 @@ import {
} from "../../../src/browser/PlaywrightScenarioServiceWorkers.js";
import { AnalysisTimeoutError } from "../../../src/domain/analysisErrorCore.js";
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
import { CDP_DISCOVERY_RESPONSE_TIMEOUT_MS } from "../../../src/domain/browserDeadline.js";
import {
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
MAX_BROWSER_DEADLINE_MS,
} from "../../../src/domain/browserDeadline.js";
import { BrowserObservationError } from "../../../src/domain/browserObservationError.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import {
browserObservationEndpointSchema,
inspectWebPageInputSchema,
listBrowserTargetsInputSchema,
} from "../../../src/domain/browserObservation.js";
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
import { WEB_RUNTIME_LIMITS } from "../../../src/domain/webRuntime.js";
@@ -709,3 +714,156 @@ it("names the fixed cleanup grace when runtime restoration stalls", async () =>
await browser.close();
}
});
it("applies caller-selected discovery_timeout_ms to browser-socket discovery", async () => {
const discoveryTimeoutMs = 45_000;
const browser = await startFakeCdpBrowser({
hangOnMethod: "Target.getTargets",
});
const provider = new CdpBrowserProvider();
try {
const pending = provider.listTargets(
listBrowserTargetsInputSchema.parse({
cdp_endpoint: browser.browserWebSocketUrl,
discovery_timeout_ms: discoveryTimeoutMs,
}),
);
await vi.waitFor(
() =>
expect(browser.commands.map(({ method }) => method)).toContain(
"Target.getTargets",
),
{ timeout: 5_000 },
);
expect(activeTimers(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)).toHaveLength(0);
expect(activeTimers(discoveryTimeoutMs)).not.toHaveLength(0);
expireEveryTimer(discoveryTimeoutMs);
const result = await pending;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected browser-socket discovery timeout");
const projected = projectAnalysisError(result.error);
expect(projected.code).toBe("provider_timeout");
expect(projected.message).toContain(
`${String(discoveryTimeoutMs)} ms discovery_timeout_ms`,
);
expect(projected.remediation?.action).toContain(
`Increase discovery_timeout_ms above ${String(discoveryTimeoutMs)}`,
);
expect(projected.remediation?.action).toContain("--discovery-timeout-ms");
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
} finally {
await provider.close();
await browser.close();
}
});
it("applies caller-selected discovery_timeout_ms to HTTP discovery independently of command_timeout_ms", async () => {
const discoveryTimeoutMs = 35_000;
const commandTimeoutMs = 25_000;
let versionRequested = false;
const server = createHttpServer((request) => {
if (request.url === "/json/version") versionRequested = true;
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Expected an HTTP listener address");
const provider = new CdpBrowserProvider();
try {
const pending = provider.inspectPage(
inspectWebPageInputSchema.parse({
cdp_endpoint: `http://127.0.0.1:${String(address.port)}`,
target_id: "allowed-page",
observation_ms: 0,
discovery_timeout_ms: discoveryTimeoutMs,
command_timeout_ms: commandTimeoutMs,
}),
);
await vi.waitFor(() => expect(versionRequested).toBe(true), {
timeout: 5_000,
});
expect(activeTimers(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)).toHaveLength(0);
expect(activeTimers(commandTimeoutMs)).toHaveLength(0);
expect(activeTimers(discoveryTimeoutMs)).toHaveLength(1);
expireTimer(discoveryTimeoutMs);
const result = await pending;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected HTTP discovery timeout");
const projected = projectAnalysisError(result.error);
expect(projected.code).toBe("provider_timeout");
expect(projected.message).toContain(
`${String(discoveryTimeoutMs)} ms discovery_timeout_ms`,
);
expect(projected.remediation?.action).toContain("--discovery-timeout-ms");
expect(projected.remediation?.action).toContain(
"command_timeout_ms does not raise it",
);
} finally {
await provider.close();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("clamps browser-socket handshake timers at MAX_BROWSER_DEADLINE_MS without 32-bit timer overflow", async () => {
const browser = await startFakeCdpBrowser({ handshakeDelayMs: 60 });
const provider = new CdpBrowserProvider();
try {
const listed = await provider.listTargets(
listBrowserTargetsInputSchema.parse({
cdp_endpoint: browser.browserWebSocketUrl,
discovery_timeout_ms: MAX_BROWSER_DEADLINE_MS,
}),
);
if (!listed.ok) throw listed.error;
expect(listed.value.targets.length).toBeGreaterThan(0);
const scheduledDelays = vi
.mocked(globalThis.setTimeout)
.mock.calls.flatMap(([, delay]) =>
typeof delay === "number" ? [delay] : [],
);
expect(scheduledDelays).toContain(MAX_BROWSER_DEADLINE_MS);
expect(Math.max(...scheduledDelays)).toBeLessThanOrEqual(
MAX_BROWSER_DEADLINE_MS,
);
} finally {
await provider.close();
await browser.close();
}
const sockets = new Set<Socket>();
const server = createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.resume();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Expected a TCP address");
try {
const pending = openCdpEndpoint(
browserObservationEndpointSchema.parse(
`ws://127.0.0.1:${String(address.port)}/devtools/browser/max-deadline`,
),
"list_browser_targets",
undefined,
MAX_BROWSER_DEADLINE_MS,
).catch((cause: unknown) => cause);
await vi.waitFor(() => expect(sockets.size).toBe(1), { timeout: 5_000 });
expireEveryTimer(MAX_BROWSER_DEADLINE_MS);
const failure = await pending;
expect(failure).toBeInstanceOf(BrowserObservationError);
if (!(failure instanceof BrowserObservationError))
throw new Error("Expected maximum discovery deadline failure");
expect(failure.userMessage).toContain(
`CDP browser-socket discovery exceeded its ${String(MAX_BROWSER_DEADLINE_MS)} ms discovery_timeout_ms deadline`,
);
expect(failure.remediationAction).toContain(
`discovery_timeout_ms is already at the maximum ${String(MAX_BROWSER_DEADLINE_MS)} ms`,
);
} finally {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -1,4 +1,5 @@
import { createServer } from "node:http";
import type { Duplex } from "node:stream";
import { WebSocket, WebSocketServer } from "ws";
import { expect, it, onTestFinished, vi } from "vitest";
@@ -8,8 +9,10 @@ import { CdpConnection } from "../../../src/browser/CdpConnection.js";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
it("joins a late owned handshake and waits for its active operation", async () => {
const http = createServer();
@@ -284,3 +287,192 @@ it("keeps retry aggregates incomplete until every reported socket is released",
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(1));
await owner.finish(active);
});
it("surfaces cold-start acquireBrowserSocket cleanup failure to live waiters without unhandled rejections", async () => {
const http = createServer();
http.on("upgrade", (_request, socket) => {
socket.destroy();
});
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve));
const address = http.address();
if (address === null || typeof address === "string")
throw new Error("Fixture did not bind a TCP port");
const url = `ws://127.0.0.1:${String(address.port)}/devtools/browser/rejected`;
const owner = new CdpTargetSessionOwner();
const originalClose = CdpConnection.prototype.close;
let denyClose = true;
const closeSpy = vi
.spyOn(CdpConnection.prototype, "close")
.mockImplementation(function (this: CdpConnection) {
return denyClose
? Promise.reject(new Error("controlled cold-connect close failure"))
: originalClose.call(this);
});
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
let first:
| ReturnType<CdpTargetSessionOwner["acquireBrowserSocket"]>
| undefined;
let second:
| ReturnType<CdpTargetSessionOwner["acquireBrowserSocket"]>
| undefined;
try {
const limits = { commandTimeoutMs: 1_000, handshakeTimeoutMs: 1_000 };
first = owner.acquireBrowserSocket(url, "list_browser_targets", {}, limits);
second = owner.acquireBrowserSocket(url, "inspect_web_page", {}, limits);
const [firstError, secondError] = await Promise.all([
first.then(
() => undefined,
(cause: unknown) => cause,
),
second.then(
() => undefined,
(cause: unknown) => cause,
),
]);
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setTimeout(resolve, 20));
expect(unhandled).toEqual([]);
if (
!(firstError instanceof AnalysisError) ||
!(secondError instanceof AnalysisError)
)
throw new Error("Expected typed cleanup-incomplete handshake errors");
expect(firstError.cleanupIncomplete).toBe(true);
expect(secondError.cleanupIncomplete).toBe(true);
expect(firstError.cleanupResources).toEqual(["browser-endpoint"]);
expect(secondError.cleanupResources).toEqual(["browser-endpoint"]);
expect(secondError.cleanupPrimary).toMatchObject({
_tag: "BrowserObservationError",
operation: "inspect_web_page",
});
const blockedRetry = await owner
.retryPending()
.catch((cause: unknown) => cause);
expect(blockedRetry).toBeInstanceOf(AnalysisError);
denyClose = false;
await owner.retryPending();
expect(firstError.cleanupIncomplete).toBe(false);
expect(secondError.cleanupIncomplete).toBe(false);
} finally {
process.off("unhandledRejection", onUnhandled);
denyClose = false;
await Promise.allSettled([first, second]);
closeSpy.mockRestore();
await owner.close();
await new Promise<void>((resolve, reject) =>
http.close((cause) => (cause ? reject(cause) : resolve())),
);
}
});
it("surfaces cold-start acquireBrowserSocket cleanup failure when the last waiter aborts without unhandled rejections", async () => {
const http = createServer();
const upgradeSockets = new Set<Duplex>();
let upgradeReceived: (() => void) | undefined;
const received = new Promise<void>((resolve) => {
upgradeReceived = resolve;
});
http.on("upgrade", (_request, socket) => {
upgradeSockets.add(socket);
socket.on("close", () => upgradeSockets.delete(socket));
socket.on("error", () => undefined);
upgradeReceived?.();
});
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve));
const address = http.address();
if (address === null || typeof address === "string")
throw new Error("Fixture did not bind a TCP port");
const url = `ws://127.0.0.1:${String(address.port)}/devtools/browser/stalled`;
const owner = new CdpTargetSessionOwner();
const originalClose = CdpConnection.prototype.close;
let denyClose = true;
const closeSpy = vi
.spyOn(CdpConnection.prototype, "close")
.mockImplementation(function (this: CdpConnection) {
return denyClose
? Promise.reject(new Error("controlled abort close failure"))
: originalClose.call(this);
});
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
const firstController = new AbortController();
const secondController = new AbortController();
let first:
| ReturnType<CdpTargetSessionOwner["acquireBrowserSocket"]>
| undefined;
let second:
| ReturnType<CdpTargetSessionOwner["acquireBrowserSocket"]>
| undefined;
try {
const limits = { commandTimeoutMs: 5_000, handshakeTimeoutMs: 5_000 };
first = owner.acquireBrowserSocket(
url,
"list_browser_targets",
{ signal: firstController.signal },
limits,
);
second = owner.acquireBrowserSocket(
url,
"inspect_web_page",
{ signal: secondController.signal },
limits,
);
await awaitAcknowledgement(
received,
Promise.all([first, second]),
"cold browser-socket upgrade",
);
firstController.abort(new Error("first caller cancelled"));
const firstError = await first.then(
() => undefined,
(cause: unknown) => cause,
);
expect(firstError).toBeInstanceOf(AnalysisCancelledError);
if (!(firstError instanceof AnalysisCancelledError))
throw new Error("Expected first waiter cancellation");
expect(firstError.cleanupIncomplete).toBe(false);
secondController.abort(new Error("last caller cancelled"));
const secondError = await second.then(
() => undefined,
(cause: unknown) => cause,
);
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setTimeout(resolve, 20));
expect(unhandled).toEqual([]);
if (!(secondError instanceof AnalysisError))
throw new Error("Expected last waiter cleanup-incomplete failure");
expect(secondError.cleanupIncomplete).toBe(true);
expect(secondError.cleanupResources).toEqual(["browser-endpoint"]);
expect(secondError.cleanupPrimary).toMatchObject({
_tag: "AnalysisCancelledError",
operation: "inspect_web_page",
});
denyClose = false;
await owner.retryPending();
expect(secondError.cleanupIncomplete).toBe(false);
} finally {
process.off("unhandledRejection", onUnhandled);
denyClose = false;
firstController.abort(new Error("test teardown"));
secondController.abort(new Error("test teardown"));
await Promise.allSettled([first, second]);
closeSpy.mockRestore();
await owner.close();
for (const socket of upgradeSockets) socket.destroy();
await new Promise<void>((resolve, reject) =>
http.close((cause) => (cause ? reject(cause) : resolve())),
);
}
});
@@ -95,6 +95,51 @@ it("rejects a command deadline on target listing and leaves it out of Evidence",
expect(evidence.parameters).not.toHaveProperty("command_timeout_ms");
});
it("records an explicit discovery deadline and omits the default", () => {
const explicitListing = createBrowserEvidence(
"list_browser_targets",
listBrowserTargetsInputSchema.parse({
cdp_endpoint: "ws://127.0.0.1:9222/devtools/browser/fake",
discovery_timeout_ms: 25_000,
}),
targetList,
CDP_BROWSER_PROVIDER_IDENTITY,
);
expect(explicitListing.parameters.discovery_timeout_ms).toBe(25_000);
const omittedListing = createBrowserEvidence(
"list_browser_targets",
listBrowserTargetsInputSchema.parse({
cdp_endpoint: "ws://127.0.0.1:9222/devtools/browser/fake",
}),
targetList,
CDP_BROWSER_PROVIDER_IDENTITY,
);
expect(omittedListing.parameters).not.toHaveProperty("discovery_timeout_ms");
const baseBundle = {
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page",
fetch_source_maps: false,
};
const explicitBundle = createBrowserEvidence(
"analyze_web_bundle",
analyzeWebBundleInputSchema.parse({
...baseBundle,
discovery_timeout_ms: 15_000,
}),
bundle,
CDP_BROWSER_PROVIDER_IDENTITY,
);
expect(explicitBundle.parameters.discovery_timeout_ms).toBe(15_000);
const omittedBundle = createBrowserEvidence(
"analyze_web_bundle",
analyzeWebBundleInputSchema.parse(baseBundle),
bundle,
CDP_BROWSER_PROVIDER_IDENTITY,
);
expect(omittedBundle.parameters).not.toHaveProperty("discovery_timeout_ms");
});
it("records an explicit source-map fetch deadline and omits the default", () => {
const base = {
cdp_endpoint: "http://127.0.0.1:9222",
+18
View File
@@ -812,6 +812,23 @@ it.each(["full", "compact"] as const)(
expect(tool?.inputSchema.properties?.cdp_endpoint).toMatchObject({
description: expect.stringContaining("/devtools/browser/ID"),
});
const listed = await connected.client.callTool({
name: "list_browser_targets",
arguments: {
cdp_endpoint: browser.browserWebSocketUrl,
discovery_timeout_ms: 15_000,
},
});
expect(listed.isError).not.toBe(true);
expect(listed.structuredContent).toMatchObject({
parameters: {
cdp_endpoint: browser.browserWebSocketUrl.replace(
/fake$/u,
"[REDACTED]",
),
discovery_timeout_ms: 15_000,
},
});
const inspected = await connected.client.callTool({
name: "inspect_web_page",
arguments: {
@@ -822,6 +839,7 @@ it.each(["full", "compact"] as const)(
},
});
expect(inspected.isError).not.toBe(true);
expect(browser.openedConnectionCount()).toBe(1);
expect(inspected.structuredContent).toMatchObject({
parameters: {
cdp_endpoint: browser.browserWebSocketUrl.replace(
+139 -36
View File
@@ -60,46 +60,52 @@ export const startFakeCdpBrowser = async (
});
webSockets.on("connection", (socket: WebSocket, request: IncomingMessage) => {
const directPage = request.url?.startsWith("/devtools/page/") === true;
const sessionState: SocketSessionState = {
nextSessionNumber: 1,
attachedSessions: new Set(),
};
openedConnections += 1;
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("message", (raw) => {
const command = parseCommand(raw.toString());
commands.push(command);
const commandError = options.commandError?.(command);
if (commandError !== undefined) {
sendCommandReply(socket, command, { error: commandError });
if (options.closeAfterMethod === command.method)
setImmediate(() => socket.close());
return;
}
if (options.closeOnMethod === command.method) {
socket.close();
return;
}
if (options.hangOnMethod === command.method) return;
if (options.malformedMessageOnMethod === command.method) {
socket.send("{not-json");
return;
}
if (options.unsupportedMethods?.includes(command.method) === true) {
sendCommandReply(socket, command, {
error: { code: -32_601, message: "Method not found" },
});
return;
}
if (
directPage &&
(command.method === "Target.attachToTarget" ||
command.sessionId !== undefined)
) {
sendCommandReply(socket, command, {
error: { code: -32_600, message: "Direct page socket expected" },
});
handleSimulatedWireFault(
socket,
command,
directPage,
options,
sessionState,
)
)
return;
if (command.method === "Page.getFrameTree") frameTreeReads += 1;
const delayMs = options.commandDelayMs?.(command);
if (delayMs !== undefined && delayMs > 0) {
const currentFrameReads = frameTreeReads;
const timer = setTimeout(() => {
if (socket.readyState === WebSocket.OPEN)
replyToCommand(
socket,
command,
port,
options,
currentFrameReads,
sessionState,
);
}, delayMs);
socket.once("close", () => clearTimeout(timer));
return;
}
if (command.method === "Page.getFrameTree") frameTreeReads += 1;
replyToCommand(socket, command, port, options, frameTreeReads);
replyToCommand(
socket,
command,
port,
options,
frameTreeReads,
sessionState,
);
});
});
await new Promise<void>((resolve, reject) => {
@@ -121,6 +127,9 @@ export const startFakeCdpBrowser = async (
emitEvent(event) {
for (const socket of sockets) socket.send(JSON.stringify(event));
},
closeActiveConnections() {
for (const socket of sockets) socket.close();
},
httpRequests,
async close() {
for (const socket of sockets) socket.terminate();
@@ -219,6 +228,65 @@ const sendCommandReply = (
);
};
interface SocketSessionState {
nextSessionNumber: number;
readonly attachedSessions: Set<string>;
}
const sessionNotFoundError = {
code: -32_001,
message: "Session with given id not found",
} as const;
const handleSimulatedWireFault = (
socket: WebSocket,
command: FakeCdpCommand,
directPage: boolean,
options: FakeOptions,
sessionState: SocketSessionState,
): boolean => {
const commandError = options.commandError?.(command);
if (commandError !== undefined) {
sendCommandReply(socket, command, { error: commandError });
if (options.closeAfterMethod === command.method)
setImmediate(() => socket.close());
return true;
}
if (options.closeOnMethod === command.method) {
socket.close();
return true;
}
if (options.hangOnMethod === command.method) return true;
if (options.malformedMessageOnMethod === command.method) {
socket.send("{not-json");
return true;
}
if (options.unsupportedMethods?.includes(command.method) === true) {
sendCommandReply(socket, command, {
error: { code: -32_601, message: "Method not found" },
});
return true;
}
if (
directPage &&
(command.method === "Target.attachToTarget" ||
command.sessionId !== undefined)
) {
sendCommandReply(socket, command, {
error: { code: -32_600, message: "Direct page socket expected" },
});
return true;
}
if (
command.sessionId !== undefined &&
!sessionState.attachedSessions.has(command.sessionId)
) {
sendCommandReply(socket, command, { error: sessionNotFoundError });
return true;
}
return false;
};
/** Emit a configured producer reply and its associated events. */
const replyToCommand = (
socket: WebSocket,
@@ -226,13 +294,48 @@ const replyToCommand = (
port: number,
options: FakeOptions,
frameTreeReads: number,
sessionState: SocketSessionState,
): void => {
if (
command.sessionId !== undefined &&
!sessionState.attachedSessions.has(command.sessionId)
) {
sendCommandReply(socket, command, { error: sessionNotFoundError });
return;
}
const customResult = options.commandResult?.(
command,
`http://127.0.0.1:${String(port)}`,
frameTreeReads,
);
if (
customResult === undefined &&
command.method === "Target.detachFromTarget"
) {
const sessionId = command.params.sessionId;
if (
typeof sessionId !== "string" ||
!sessionState.attachedSessions.delete(sessionId)
) {
sendCommandReply(socket, command, { error: sessionNotFoundError });
return;
}
}
const result =
options.commandResult?.(
command,
`http://127.0.0.1:${String(port)}`,
frameTreeReads,
) ?? resultFor(command, port, options, frameTreeReads);
customResult ??
resultFor(command, port, options, frameTreeReads, () => {
const sessionId = `session-${String(sessionState.nextSessionNumber)}`;
sessionState.nextSessionNumber += 1;
return sessionId;
});
if (
command.method === "Target.attachToTarget" &&
result !== undefined &&
typeof result.sessionId === "string" &&
result.sessionId.length > 0
) {
sessionState.attachedSessions.add(result.sessionId);
}
if (result === undefined) {
sendCommandReply(socket, command, {
error: {
+7 -3
View File
@@ -6,10 +6,11 @@ export const resultFor = (
port: number,
options: FakeOptions,
frameTreeReads: number,
allocateSessionId: () => string = () => "session-1",
): Readonly<Record<string, unknown>> | undefined => {
switch (command.method) {
case "Target.attachToTarget":
return attachToTargetResult(options);
return attachToTargetResult(options, allocateSessionId);
case "Page.getLayoutMetrics":
return {
cssVisualViewport: {
@@ -98,8 +99,11 @@ const browserVersionResult = () => ({
jsVersion: "13.0",
});
const attachToTargetResult = (options: FakeOptions) => ({
sessionId: options.invalidAttachedSession ? "" : "session-1",
const attachToTargetResult = (
options: FakeOptions,
allocateSessionId: () => string,
) => ({
sessionId: options.invalidAttachedSession ? "" : allocateSessionId(),
});
const frameTreeResult = (
+4
View File
@@ -20,6 +20,8 @@ export interface FakeCdpBrowser {
/** Deliver actual malformed wire data without bypassing the production transport parser. */
emitRawMessage(message: string): void;
emitEvent(event: CdpEvent): void;
/** Drop currently open WebSockets from the server side to exercise reconnect paths. */
closeActiveConnections(): void;
readonly httpRequests: readonly {
readonly url: string;
readonly authorization: string | undefined;
@@ -51,6 +53,8 @@ export interface FakeOptions {
readonly discoveryDelayMs?: number;
/** Delay WebSocket upgrade acceptance so handshake and attach deadlines stay distinct. */
readonly handshakeDelayMs?: number;
/** Delay selected wire replies so one caller's discovery deadline can expire alongside an active peer. */
readonly commandDelayMs?: (command: FakeCdpCommand) => number | undefined;
readonly malformedDiscovery?: boolean;
readonly oversizedDiscovery?: boolean;
readonly invalidBrowserWebSocket?: boolean;