fix(browser): name web-bundle budgets and recover web deadlines (#6036)

* fix(browser): name web-bundle budgets and recover web deadlines

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

Refs #5910

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-22 05:47:38 +08:00
1 parent 166fb0820f
commit 0155b89953
82 files changed
+3206 -470

No files matched your search

+40 -19
View File
@@ -385,7 +385,8 @@ section explicitly truncated.
Disallowed-origin entries are filtered before retention.
Selected script-source collection shares a 32 MiB aggregate UTF-8 retention
budget and a 20-second deadline across the capture. An oversized source is
budget and the `command_timeout_ms` deadline (default 20000 ms) across the
capture. The byte budget has no runtime override. An oversized source is
omitted while later smaller sources may still fit. When the deadline expires,
remaining source reads are skipped. Script metadata, per-item omission reasons,
and `script_sources` completeness remain available. A single CDP response can
@@ -393,19 +394,25 @@ still be materialized up to the separate 64 MiB transport limit before source
admission; the retention budget does not bound that transient allocation.
Bundle parsing and semantic inspection run sequentially in an owned child,
without executing captured code. `max_heap_mb` defaults to 1024 MiB V8
old-space; `analysis_timeout_ms` defaults to 30000 per script, including startup
and transfer. A failed or timed-out script becomes an explicit unknown while
completed findings and later scripts remain available. `total_analysis_timeout_ms`
defaults to 300000 across script analysis; expiry returns a typed timeout with
partial Evidence and identifies unstarted scripts. These controls are also
available as CLI flags with hyphenated names. Capture and optional source-map
fetching have separate budgets.
without executing captured code. These are separate phases. `max_heap_mb`
defaults to 1024 MiB V8 old-space (range 128..16384). `analysis_timeout_ms`
defaults to 30000 per script, including startup and transfer.
`total_analysis_timeout_ms` defaults to 300000 across script analysis. A
per-script deadline above the effective total is rejected. A failed or
timed-out script becomes an explicit unknown while completed findings and later
scripts remain available. Total-deadline expiry returns a typed timeout with
partial Evidence and identifies unstarted scripts. `source_map_fetch_timeout_ms`
defaults to 30000 and is an item limitation, not that analysis timeout. These
controls are also available as CLI flags with hyphenated names. Worker protocol
and result byte budgets have no runtime override.
One web-bundle capture/analysis is admitted per REA process at a time. Completed
script results share an 8 MiB serialized transfer budget, with host heap
reservation checked before decoding and validating another result.
Exhausted result capacity stops further script launches and returns a typed
When the host reservation is what reaches zero, the failure names that heap
headroom and the remaining fixed budget. When the 8 MiB allowance itself is
exhausted, the failure names that fixed budget and says it has no runtime
override. Either stop ends further script launches and returns a typed
resource failure with completed facts and explicit unstarted-script unknowns.
Worker control frames are limited to 64 KiB. Cleanup must confirm child termination
before releasing its private files; failed cleanup retains its exact owner and
@@ -415,8 +422,10 @@ facts, including cancellation during final cleanup.
The worker heap setting is not a process-family RSS limit. Node enforces V8
old-space flags; Bun ignores them. Windows Bun instead uses an owned process
commit limit with a 512 MiB runtime reserve; other Bun hosts have no enforced
memory limit. These bounds do not establish an aggregate host-memory limit
across other REA operations or external processes.
memory limit. An allocation failure before that Windows limit is reported as
reached still names the configured limit and says it was not reached. These
bounds do not establish an aggregate host-memory limit across other REA
operations or external processes.
Resource-tree inventories distinguish a valid empty `resources` array from a
missing or malformed collection. Invalid resource entries are omitted with
@@ -430,12 +439,24 @@ an invalid supplied size remains unknown with incomplete resource coverage while
the resource's other facts are retained. Zero and nonnegative fractional sizes
are preserved.
CDP discovery has a 10-second deadline. Each WebSocket handshake and command
response has its own 20-second deadline, separate from the caller-selected
observation window. A stalled handshake or command reports `provider_timeout`;
caller cancellation still reports cancellation. WebSocket messages remain subject
to the [64 MiB limit](#request-boundary). REA does not impose a message-count or
pending-command quota. On cancellation, it closes only its own WebSocket,
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
`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
`Target.attachToTarget` each receive that full deadline; they do not share one
budget. A stalled handshake or command reports
`provider_timeout` and names `command_timeout_ms` only when that operation
accepts it; caller cancellation still reports cancellation. WebSocket messages
remain subject to the [64 MiB limit](#request-boundary), which has no runtime
override. REA does
not impose a message-count or pending-command quota. On cancellation, it closes
only its own WebSocket,
waiting up to one second before terminating it. Malformed unsolicited events
and malformed replies to pending commands poison the connection so later
commands fail closed. A correlated reply's session ID must match the command's
@@ -444,7 +465,7 @@ rejects it as unknown; other command rejections retain their reported failure.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation uses `source_map_fetch_timeout_ms` (default 30000) and a 64 MiB aggregate streamed-response budget across redirects. The byte budget has no runtime override. A fetch overrun is an item limitation, not a thrown analysis timeout. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
source-map items retain the script's `declared_url` separately from the observed
`effective_url`. successful maps use the latter as their source-resolution base.
+15 -3
View File
@@ -23,9 +23,21 @@ that rejection does not roll back browser effects. REA removes its owned
future-document scripts, restores live frame methods, then disconnects.
Add secret declarations, storage seeds, and additional artifact or event
capture only when needed. Action, secret, and storage counts are not capped.
Duration, action, and navigation timeouts remain fixed provider-owned liveness
limits. `capture.limits` controls retained event and network-content budgets;
it does not change those liveness deadlines.
Duration, action, and navigation timeouts remain separate from CDP command
deadlines. `command_timeout_ms` (default 20000; omit it to keep that default
and leave it out of Evidence) bounds launch startup, the connect-over-CDP
handshake, target lookup, environment commands, and storage CDP commands,
including replies from attached child targets. Set it in the scenario JSON.
`capture-browser-scenario` has no `--command-timeout-ms` option.
It does not change action `timeout_ms`, `wait_for_timeout.duration_ms`, or
navigation. Service-worker restoration keeps a fixed 5000 ms grace and does
not inherit a raised `command_timeout_ms`. CDP discovery stays on its own
fixed deadline. When service workers are blocked, the registration check
before discovery has its own fixed 10000 ms deadline; `command_timeout_ms`
does not apply because discovery has not started. The discovery deadline
covers endpoint discovery only. The following WebSocket handshake and
`Target.attachToTarget` each use `command_timeout_ms`. `capture.limits` controls retained event and network-content
budgets; it does not change those liveness deadlines.
## Minimal launch example
+3 -2
View File
@@ -619,13 +619,14 @@ AgentEnv data plane, as
## Resource and lifecycle boundaries
Complete evidence has a 32 MiB input, 96 MiB decoder reply and 64-level nesting
budget. Exceeding a budget returns no partial success.
budget. Those budgets have no runtime override. `capture_timeout_ms` does not
raise them. Exceeding a budget returns no partial success.
Oversized selected captures return an input error identifying `capture_path`
and the byte budget; owned reply/output failures remain output or provider errors.
HAR, SAZ and qlog run in an owned
192 MiB Node old-generation heap with one V8 worker. Mitmproxy decoding applies
Linux limits of 768 MiB address space, 30 seconds CPU and 96 MiB file output;
both commands have a 30-second wall deadline and independently supervised
both commands use `capture_timeout_ms` (default 30000) and independently supervised
process cleanup. Owned decoder diagnostics retain at most 1 MiB across stdout
and stderr; the collector drops overflow as it arrives, reports the limit
failure and verifies cleanup. Heap limits alone do not establish aggregate RSS limits.
+1 -1
View File
@@ -52,7 +52,7 @@ The result contains directly registered listener types/flags, callback coordinat
## Bounds and verification
Each protocol message has a 64 MiB budget; script/context metadata and request metadata each have an 8 MiB retained budget. Complete retained source text has a 32 MiB budget. Oversized or malformed evidence returns no partial successful result. Each command and the complete inspection/source-join phase have a 20-second deadline; cleanup has a separate 5-second command deadline plus transport closure. The selected observation duration must fit the host timer's positive 32-bit millisecond range. REA does not control the externally owned browser's CPU or memory.
Each protocol message has a 64 MiB budget; script/context metadata and request metadata each have an 8 MiB retained budget. Complete retained source text has a 32 MiB budget. Oversized or malformed evidence returns no partial successful result. Each command and the complete inspection/source-join phase use `command_timeout_ms` (default 20000). Cleanup uses a separate 5000 ms grace; `command_timeout_ms` does not raise or shorten it, and this release has no runtime override for that grace. Transport closure follows the grace. The selected observation duration must fit the host timer's positive 32-bit millisecond range. REA does not control the externally owned browser's CPU or memory.
`npm run verify:browser:runtime` uses caller-supplied `REA_BROWSER_EXECUTABLE` and the native `ps` command on Linux/macOS against an owned synthetic fixture, through real public CLI and stdio MCP. It checks exact Unicode/CRLF text/digests, same-URL identities, same-document navigation, inert declared names, executed and zero-count branches, function-only unknowns on repeated observation, listener selection, initiator association, actual arming and preservation of the external page. A separate fixture page closes during an armed MCP window to verify prompt termination and inline cleanup diagnostics. The optional positional entrypoint to `scripts/verify-browser-runtime.mjs` permits the same checks against an isolated installed tarball.
+3 -2
View File
@@ -71,7 +71,7 @@ implement every current source-map extension.
One owned Node subprocess decodes the map. Its private request directory is
removed before success is returned. Only this child's inherited `NODE_OPTIONS`
is removed; explicit V8 flags set a 192 MiB old-generation and 8 MiB semi-space
budget, one V8 worker and a 20-second deadline. Reported actual V8 heap limits
budget, one V8 worker and `decode_timeout_ms` (default 20000). The codec heap, map budget, and reply budget have no runtime override. Reported actual V8 heap limits
are checked. These are heap limits, not an aggregate process RSS limit. When
REA runs on Bun, the subprocess is Bun, which ignores these V8 flags: `runtime`
records `id: "bun"`, the Bun version, its Node.js compatibility version and a
@@ -86,7 +86,8 @@ cleanup overrides success with `cleanup_incomplete` and the owning
operation/resources.
Complete evidence is bounded by a 4 MiB map, 32 MiB codec reply, 262144 decoded
rows/segments and 64 nested section levels. Exceeding a budget returns an explicit
rows/segments and 64 nested section levels. Those structural budgets and the
192 MiB codec heap have no runtime override. Exceeding a budget returns an explicit
failure, without partial results. The existing 32 MiB manifest and 16 MiB selected
script boundaries also apply. No global toolchain or browser configuration changes
are made.
+8
View File
@@ -138,6 +138,9 @@ const browserParameters = (
: "target_id" in input && observedOrigin !== undefined
? [observedOrigin]
: [],
...("command_timeout_ms" in input && input.command_timeout_ms !== undefined
? { command_timeout_ms: input.command_timeout_ms }
: {}),
};
if (!("target_id" in input)) return scope;
return {
@@ -171,6 +174,11 @@ const browserParameters = (
...(input.total_analysis_timeout_ms === undefined
? {}
: { total_analysis_timeout_ms: input.total_analysis_timeout_ms }),
...(input.source_map_fetch_timeout_ms === undefined
? {}
: {
source_map_fetch_timeout_ms: input.source_map_fetch_timeout_ms,
}),
}
: {}),
};
@@ -43,6 +43,30 @@ it.each(["REDACTED", "[", "]", "/capture.har"])(
},
);
it("records an explicit capture deadline and omits the default", async () => {
const seen: Array<number | undefined> = [];
const service = new WebNetworkCaptureService({
inspect: (input) => {
seen.push(input.capture_timeout_ms);
return Promise.resolve(ok(fixture()));
},
});
const explicit = await service.inspect({
capture_path: "/capture.har",
format: "har",
capture_timeout_ms: 250,
});
if (!explicit.ok) throw explicit.error;
expect(explicit.value.parameters.capture_timeout_ms).toBe(250);
const omitted = await service.inspect({
capture_path: "/capture.har",
format: "har",
});
if (!omitted.ok) throw omitted.error;
expect(omitted.value.parameters).not.toHaveProperty("capture_timeout_ms");
expect(seen).toEqual([250, undefined]);
});
it("keeps observed artifact identity when the entire sensitive path is excluded", async () => {
const result = await new WebNetworkCaptureService({
inspect: () => Promise.resolve(ok(fixture())),
@@ -250,6 +250,9 @@ export class WebNetworkCaptureService {
},
}),
sensitive_value_count: input.sensitive_values.length,
...(input.capture_timeout_ms === undefined
? {}
: { capture_timeout_ms: input.capture_timeout_ms }),
});
return ok(
createEvidence(
@@ -36,6 +36,7 @@ export interface WebSourceMapPort {
readonly path: string;
readonly url: string;
readonly position: WebSourceLocationInput["generated_position"];
readonly decodeTimeoutMs?: number;
},
options?: ExecutionOptions,
): Promise<Result<WebSourceMapReport, AnalysisError>>;
@@ -11,12 +11,55 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { ok } from "../domain/result.js";
import { webSourceLocationResultSchema } from "../domain/webSourceLocation.js";
import {
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
webSourceLocationResultSchema,
} from "../domain/webSourceLocation.js";
import { WebSourceLocationService } from "./WebSourceLocationService.js";
import type { WebSourceMapPort } from "./WebSourceLocationPorts.js";
const service = (data = webSourceLocationFixture(), port = decoder) =>
new WebSourceLocationService({ load: () => Promise.resolve(ok(data)) }, port);
const expectCodecDeadline = (
evidence: {
readonly limitations: readonly string[];
readonly normalized_result: unknown;
},
timeoutMs: number,
): void => {
const phrase = `${String(timeoutMs)} ms decode_timeout_ms deadline`;
const result = webSourceLocationResultSchema.parse(
evidence.normalized_result,
);
expect(
evidence.limitations.filter((text) => text.includes(phrase)),
).toHaveLength(1);
expect(
result.limitations.filter((text) => text.includes(phrase)),
).toHaveLength(1);
};
it("passes an explicit decode deadline and omits the default from Evidence", async () => {
const seen: Array<number | undefined> = [];
const port: WebSourceMapPort = {
trace: (input) => {
seen.push(input.decodeTimeoutMs);
return decoder.trace(input);
},
};
const explicit = await service(undefined, port).trace({
...args,
decode_timeout_ms: 250,
});
if (!explicit.ok) throw explicit.error;
expect(explicit.value.parameters.decode_timeout_ms).toBe(250);
expectCodecDeadline(explicit.value, 250);
const omitted = await service(undefined, port).trace(args);
if (!omitted.ok) throw omitted.error;
expect(omitted.value.parameters).not.toHaveProperty("decode_timeout_ms");
expectCodecDeadline(omitted.value, DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS);
expect(seen).toEqual([250, undefined]);
});
it.each([
{ ...args, manifest_path: "relative.json" },
{ ...args, script_index: -1 },
+14 -6
View File
@@ -17,6 +17,7 @@ import {
webArtifactFileSchema,
} from "../domain/webScriptArtifacts.js";
import {
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
WEB_SOURCE_MAP_LIMITS,
webSourceLocationInputSchema,
webSourceLocationResultSchema,
@@ -52,17 +53,19 @@ const LIMITATIONS = [
"Lookup returns all segments at the greatest generated position not greater than the selected point, including earlier lines and generated-only segments. A mapped point does not establish an executed range or UI causality.",
"Regular maps and inline indexed sections are supported. External section URLs are unsupported; unknown extensions are retained in raw map text and are not interpreted.",
];
const CODEC_BUDGET =
"Complete evidence has a 4 MiB map, 32 MiB reply, 262144 decoded rows/segments and 64-level section budget. The owned codec has a 20-second deadline";
const codecBudget = (timeoutMs: number): string =>
`Complete evidence has a 4 MiB map, 32 MiB reply, 262144 decoded rows/segments and 64-level section budget. The owned codec has a ${String(timeoutMs)} ms decode_timeout_ms deadline`;
/** Bun accepts but ignores the codec's V8 heap flags; Windows enforces a commit limit instead. */
const codecBudgetLimitation = (
runtime: WebSourceMapReport["runtime"],
timeoutMs: number,
): string => {
const budget = codecBudget(timeoutMs);
if (runtime.id === "node-v8")
return `${CODEC_BUDGET}, ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB old-generation heap and one V8 worker; cleanup may extend the deadline. V8 heap limits do not establish aggregate RSS limits.`;
return `${budget}, ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB old-generation heap and one V8 worker; cleanup may extend the deadline. V8 heap limits do not establish aggregate RSS limits.`;
return runtime.process_memory_limit_bytes === null
? `${CODEC_BUDGET}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags, so no codec heap limit is established.`
: `${CODEC_BUDGET}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags; a Windows Job Object limited its committed memory to ${String(runtime.process_memory_limit_bytes)} bytes, which is not a heap or RSS limit.`;
? `${budget}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags, so no codec heap limit is established.`
: `${budget}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags; a Windows Job Object limited its committed memory to ${String(runtime.process_memory_limit_bytes)} bytes, which is not a heap or RSS limit.`;
};
/** Join verified captured bytes with an explicitly selected source-map point. */
@@ -125,15 +128,20 @@ export class WebSourceLocationService {
path: map.file.path,
url: map.url,
position: input.generated_position,
...(input.decode_timeout_ms === undefined
? {}
: { decodeTimeoutMs: input.decode_timeout_ms }),
},
options,
);
if (!traced.ok) return traced;
const report = verifyReport(input, map.file.sha256, traced.value);
if (!report.ok) return report;
const decodeTimeoutMs =
input.decode_timeout_ms ?? DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS;
const limitations = [
...LIMITATIONS,
codecBudgetLimitation(report.value.runtime),
codecBudgetLimitation(report.value.runtime, decodeTimeoutMs),
];
const normalized = webSourceLocationResultSchema.safeParse({
...report.value,
@@ -56,6 +56,9 @@ const parameters = (
target_id: input.target_id,
observation_ms: input.observation_ms,
include_script_sources: input.include_script_sources,
...(input.command_timeout_ms === undefined
? {}
: { command_timeout_ms: input.command_timeout_ms }),
}
: {}),
});
+1 -1
View File
@@ -178,4 +178,4 @@ const browserStartupMessage = (
readonly stderr: string;
},
): string =>
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; startup_timeout_ms=${String(diagnostics.startupTimeoutMs)}; exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; timeoutMs=${String(diagnostics.startupTimeoutMs)} (DevToolsActivePort wait; omitted means ${String(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS)} ms); exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;
+7 -2
View File
@@ -68,6 +68,7 @@ import {
type CdpTargetSession,
} from "./CdpTargetSession.js";
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
import { authorizeCdpTarget } from "./CdpAuthorizedTarget.js";
import { observeCdpSession } from "./CdpObservationSession.js";
@@ -359,7 +360,7 @@ interface CdpTargetOperationSession<Input> {
const withTargetSession = async <
Input extends Pick<
InspectWebPageInput,
"cdp_endpoint" | "target_id" | "allowed_origins"
"cdp_endpoint" | "target_id" | "allowed_origins" | "command_timeout_ms"
>,
Output,
>(
@@ -370,12 +371,16 @@ const withTargetSession = async <
owner: CdpTargetSessionOwner,
partialObservation?: (output: Output) => AnalysisPartialObservation,
): Promise<Output> => {
const commandTimeoutMs = selectedCdpCommandTimeoutMs(
input.command_timeout_ms,
);
const endpoint = await openCdpEndpoint(
input.cdp_endpoint,
operation,
options.signal,
undefined,
owner,
commandTimeoutMs,
);
let targetSession: CdpTargetSession | undefined;
let outcome: Result<Output, unknown>;
@@ -390,7 +395,7 @@ const withTargetSession = async <
target,
operation,
options.signal,
undefined,
{ commandTimeoutMs },
owner,
)
: await attachCdpTargetSession(
+85 -12
View File
@@ -5,6 +5,14 @@ import { AnalysisError } from "../domain/analysisErrorBase.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { CdpCommandRejection } from "./CdpCommandRejection.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import {
cdpCleanupGraceExpired,
cdpCommandDeadlineRecovery,
cdpCommandTimeoutIsSelectable,
cdpCommandTimeoutUnrelatedControl,
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
noRuntimeOverrideSentence,
} from "../domain/browserDeadline.js";
import { safeParseJson } from "../domain/safeJson.js";
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
@@ -29,6 +37,7 @@ interface PendingCommand {
readonly resolve: (value: unknown) => void;
readonly reject: (error: AnalysisError) => void;
readonly dispose: () => void;
readonly reschedule: (timeoutMs: number) => void;
}
/** Correlated JSON-RPC transport for one CDP target operation. */
@@ -46,12 +55,16 @@ export class CdpConnection {
#closePromise: Promise<void> | undefined;
#protocolFailed = false;
#transportFailure: "disconnected" | "payload_limit" = "disconnected";
#commandTimeoutMs: number;
#deadlineKind: "command" | "cleanup" = "command";
private constructor(
private readonly socket: WebSocket,
private readonly operation: BrowserObservationOperation,
private readonly maxPayloadBytes: number,
commandTimeoutMs: number,
) {
this.#commandTimeoutMs = commandTimeoutMs;
socket.on("message", (data) => this.#receive(data));
socket.on("close", () => this.#disconnect());
socket.on("error", (cause) =>
@@ -68,15 +81,21 @@ export class CdpConnection {
url: string,
operation: BrowserObservationOperation,
signal?: AbortSignal,
limits?: { readonly maxPayloadBytes: number },
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 === undefined
? WEB_RUNTIME_LIMITS.protocolBytes
: limits.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 ||
@@ -92,7 +111,12 @@ export class CdpConnection {
maxPayload: maxPayloadBytes,
perMessageDeflate: false,
});
const connection = new CdpConnection(socket, operation, maxPayloadBytes);
const connection = new CdpConnection(
socket,
operation,
maxPayloadBytes,
commandTimeoutMs,
);
try {
onConnected?.(connection);
} catch (cause: unknown) {
@@ -100,7 +124,7 @@ export class CdpConnection {
throw cause;
}
try {
await waitForOpen(socket, operation, signal);
await waitForOpen(socket, operation, signal, handshakeTimeoutMs);
} catch (cause: unknown) {
if (onConnected === undefined) {
try {
@@ -146,6 +170,22 @@ export class CdpConnection {
return () => this.#protocolFailureListeners.delete(listener);
}
/** Apply the post-discovery command deadline to commands that have not started. */
selectCommandTimeout(timeoutMs: number): void {
if (this.#deadlineKind === "cleanup") return;
this.#commandTimeoutMs = timeoutMs;
}
/**
* Restoration uses the fixed cleanup grace. Commands already waiting are
* rescheduled from now so a raised command deadline cannot extend them.
*/
beginCleanupGrace(timeoutMs: number): void {
this.#deadlineKind = "cleanup";
this.#commandTimeoutMs = timeoutMs;
for (const pending of this.#pending.values()) pending.reschedule(timeoutMs);
}
/** Bound each response separately from the caller-selected observation window. */
async send(
method: string,
@@ -180,22 +220,44 @@ export class CdpConnection {
}),
);
};
const timer = setTimeout(() => {
const failTimeout = (): void => {
const pending = this.#pending.get(id);
if (pending === undefined) return;
this.#complete(id, pending);
if (this.#deadlineKind === "cleanup") {
const detail = cdpCleanupGraceExpired();
reject(
new BrowserObservationError(this.operation, "timeout", {
detail,
remediationAction: detail,
}),
);
return;
}
const recovery = cdpCommandDeadlineRecovery(
this.#commandTimeoutMs,
"command",
method,
cdpCommandTimeoutIsSelectable(this.operation),
);
reject(
new BrowserObservationError(this.operation, "timeout", {
detail: `CDP command ${method} exceeded its ${String(WEB_RUNTIME_LIMITS.commandTimeoutMs)} ms response deadline.`,
detail: recovery.userMessage,
remediationAction: recovery.remediationAction,
}),
);
}, WEB_RUNTIME_LIMITS.commandTimeoutMs);
};
let timer = setTimeout(failTimeout, this.#commandTimeoutMs);
signal?.addEventListener("abort", onAbort, { once: true });
this.#pending.set(id, {
method,
...(sessionId === undefined ? {} : { sessionId }),
resolve,
reject,
reschedule: (timeoutMs: number): void => {
clearTimeout(timer);
timer = setTimeout(failTimeout, timeoutMs);
},
dispose: () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
@@ -427,7 +489,10 @@ export class CdpConnection {
reason,
reason === "payload_limit"
? {
detail: `CDP message exceeded the selected ${String(this.maxPayloadBytes)} byte protocol budget.`,
detail: noRuntimeOverrideSentence(
`CDP message exceeded the ${String(this.maxPayloadBytes)} byte protocol budget.`,
cdpCommandTimeoutUnrelatedControl(this.operation),
),
}
: undefined,
);
@@ -456,6 +521,7 @@ const waitForOpen = async (
socket: WebSocket,
operation: BrowserObservationOperation,
signal?: AbortSignal,
commandTimeoutMs: number = DEFAULT_CDP_COMMAND_TIMEOUT_MS,
): Promise<void> =>
await new Promise((resolve, reject) => {
const onOpen = (): void => {
@@ -492,12 +558,19 @@ const waitForOpen = async (
const timer = setTimeout(() => {
terminateSocket(socket);
cleanup();
const recovery = cdpCommandDeadlineRecovery(
commandTimeoutMs,
"handshake",
undefined,
cdpCommandTimeoutIsSelectable(operation),
);
reject(
new BrowserObservationError(operation, "timeout", {
detail: `CDP WebSocket handshake exceeded its ${String(WEB_RUNTIME_LIMITS.commandTimeoutMs)} ms deadline.`,
detail: recovery.userMessage,
remediationAction: recovery.remediationAction,
}),
);
}, WEB_RUNTIME_LIMITS.commandTimeoutMs);
}, commandTimeoutMs);
socket.once("open", onOpen);
socket.once("error", onFailure);
socket.once("close", onClose);
+2 -2
View File
@@ -12,6 +12,7 @@ import {
ingestElectronScriptEvent,
type ElectronScriptDraft,
} from "./CdpElectronScriptEvents.js";
import { CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON } from "./CdpScriptSourceBudget.js";
import { captureElectronScripts } from "./CdpElectronScripts.js";
import { inspectElectronPageInputSchema } from "../domain/javascript/electronObservation.js";
@@ -221,8 +222,7 @@ it("admits a prefix and fitting suffix around an oversized Electron source", asy
expect(byHash.get("prefix")?.included).toBe(true);
expect(byHash.get("oversized")).toEqual({
included: false,
reason:
"script source was omitted because the aggregate 32 MiB source budget was exhausted",
reason: CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
});
expect(byHash.get("suffix")?.included).toBe(true);
expect(completeness.snapshot().excluded).toContainEqual({
+6 -1
View File
@@ -11,6 +11,7 @@ import {
type InspectElectronPageInput,
type ListElectronTargetsInput,
} from "../domain/javascript/electronObservation.js";
import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
@@ -277,7 +278,11 @@ export class CdpElectronProvider implements ElectronObservationPort {
target,
"inspect_electron_page",
options.signal,
undefined,
{
commandTimeoutMs: selectedCdpCommandTimeoutMs(
input.command_timeout_ms,
),
},
this.#owner,
);
result = ok(
+12 -4
View File
@@ -11,9 +11,10 @@ import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
import {
CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
cdpScriptSourceDeadlineOmissionReason,
CdpScriptSourceBudget,
} from "./CdpScriptSourceBudget.js";
@@ -34,9 +35,14 @@ export const captureElectronScripts = async (
let total = 0;
const items: ElectronPageInspection["scripts"]["items"] = [];
const seen = new Set<string>();
const commandTimeoutMs = selectedCdpCommandTimeoutMs(
input.request.command_timeout_ms,
);
const deadlineOmission =
cdpScriptSourceDeadlineOmissionReason(commandTimeoutMs);
const sourceBudget =
input.request.include_script_sources && input.scripts.length > 0
? new CdpScriptSourceBudget(input.signal)
? new CdpScriptSourceBudget(input.signal, commandTimeoutMs)
: undefined;
try {
for (const script of input.scripts) {
@@ -68,7 +74,7 @@ export const captureElectronScripts = async (
let source: ElectronScriptSource = input.request.include_script_sources
? {
included: false,
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
reason: deadlineOmission,
}
: {
included: false,
@@ -129,7 +135,9 @@ const captureScriptSource = async (
excludeScriptSource(input.completeness);
return {
included: false,
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
reason: cdpScriptSourceDeadlineOmissionReason(
sourceBudget.commandTimeoutMs,
),
};
}
if (!sourceBudget.admit(text)) {
+44
View File
@@ -3,6 +3,7 @@ import { once } from "node:events";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
import { afterEach, describe, expect, it } from "vitest";
@@ -206,6 +207,49 @@ describe("CDP discovery cancellation ownership", () => {
});
});
it("names the browser-socket message budget when that discovery deadline expires", async () => {
const failure = await withCdpDiscoveryDeadline(
"list_browser_targets",
undefined,
(signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), {
once: true,
});
}),
1,
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(BrowserObservationError);
if (!(failure instanceof BrowserObservationError)) return;
expect(failure.userMessage).toContain(
`${String(WEB_RUNTIME_LIMITS.protocolBytes)}-byte browser-socket message budget`,
);
expect(failure.userMessage).not.toContain(
"8388608-byte discovery document budget",
);
});
it("names the HTTP discovery document budget when that response deadline expires", async () => {
const { endpoint } = await listen(() => undefined);
const failure = await readCdpJson(
new URL(endpoint),
"list_browser_targets",
undefined,
{ bytes: 8 * 1024 * 1024, timeoutMs: 20 },
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(BrowserObservationError);
if (!(failure instanceof BrowserObservationError)) return;
expect(failure.userMessage).toContain(
"8388608-byte discovery document budget",
);
expect(failure.userMessage).toContain("20 ms");
expect(failure.userMessage).not.toContain(
`${String(WEB_RUNTIME_LIMITS.protocolBytes)}-byte browser-socket message budget`,
);
});
it("preserves an independent typed cancellation when the discovery deadline also expires", async () => {
const primary = new AnalysisCancelledError("inspect_web_page", {
cause: "independent cancellation",
+39 -15
View File
@@ -2,6 +2,11 @@ import { request, type ClientRequest, type IncomingMessage } from "node:http";
import { z } from "zod";
import {
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
cdpCommandTimeoutUnrelatedControl,
noRuntimeOverrideSentence,
} from "../domain/browserDeadline.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
@@ -9,6 +14,7 @@ import type { BrowserObservationOperation } from "../domain/browserObservationEr
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js";
import { safeParseJson } from "../domain/safeJson.js";
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
import type { CdpConnection } from "./CdpConnection.js";
const endpointVersionSchema = z.object({
@@ -30,12 +36,36 @@ const endpointTargetSchema = z.object({
});
const endpointTargetsSchema = z.array(endpointTargetSchema);
/** Per-response bounds for loopback CDP discovery documents. */
/** Per-response bounds for loopback CDP discovery documents. There is no runtime override. */
const CDP_DISCOVERY_RESPONSE_LIMITS = {
bytes: 8 * 1024 * 1024,
timeoutMs: 10_000,
timeoutMs: CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
} as const;
const discoveryBudget = (phase: "response" | "browser-socket"): string =>
phase === "response"
? `The ${String(CDP_DISCOVERY_RESPONSE_LIMITS.bytes)}-byte discovery document budget`
: `The ${String(WEB_RUNTIME_LIMITS.protocolBytes)}-byte browser-socket message budget`;
const discoveryTimeout = (
operation: BrowserObservationOperation,
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),
);
return new BrowserObservationError(operation, "timeout", {
detail: `${subject} ${fixed}`,
remediationAction: fixed,
});
};
interface CdpDiscoveryResponseLimits {
readonly bytes: number;
readonly timeoutMs: number;
@@ -181,9 +211,7 @@ export const withCdpDiscoveryDeadline = async <T>(
if (abortSource === "caller")
throw new AnalysisCancelledError(operation, { cause: abortReason });
if (abortSource === "deadline")
throw new BrowserObservationError(operation, "timeout", {
detail: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`,
});
throw discoveryTimeout(operation, timeoutMs, "browser-socket");
}
// A concrete failure produced before cancellation remains authoritative.
if (cause instanceof AnalysisError) {
@@ -192,9 +220,7 @@ export const withCdpDiscoveryDeadline = async <T>(
cause instanceof AnalysisCancelledError &&
cause.cause === abortReason
)
throw new BrowserObservationError(operation, "timeout", {
detail: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`,
});
throw discoveryTimeout(operation, timeoutMs, "browser-socket");
throw cause;
}
throw cause;
@@ -485,7 +511,10 @@ export const readCdpJson = async (
if (bytes > limits.bytes) {
finish(
new BrowserObservationError(operation, "payload_limit", {
detail: `CDP discovery response exceeds its ${String(limits.bytes)} byte limit.`,
detail: noRuntimeOverrideSentence(
`CDP discovery response exceeds its ${String(limits.bytes)}-byte limit.`,
cdpCommandTimeoutUnrelatedControl(operation),
),
}),
);
return;
@@ -535,12 +564,7 @@ export const readCdpJson = async (
onAbort();
if (settled) return;
timer = setTimeout(
() =>
finish(
new BrowserObservationError(operation, "timeout", {
detail: `CDP discovery response exceeded its ${String(limits.timeoutMs)} ms deadline.`,
}),
),
() => finish(discoveryTimeout(operation, limits.timeoutMs, "response")),
limits.timeoutMs,
);
request_.end();
+7 -6
View File
@@ -4,6 +4,10 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
import type { CdpConnection } from "./CdpConnection.js";
import {
CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
} from "./CdpScriptSourceBudget.js";
import { captureScripts } from "./CdpPageCaptureScripts.js";
import type { CaptureContext } from "./CdpPageCapture.js";
import type { CapturedScript } from "./CdpCaptureEventTypes.js";
@@ -74,8 +78,7 @@ it("admits a prefix and fitting suffix around an oversized script source", async
expect(byId.get("prefix")).toMatchObject({ included: true });
expect(byId.get("oversized")).toEqual({
included: false,
reason:
"script source was omitted because the aggregate 32 MiB source budget was exhausted",
reason: CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
});
expect(byId.get("suffix")).toMatchObject({ included: true });
expect(events.completeness.snapshot().excluded).toContainEqual({
@@ -174,13 +177,11 @@ it("stops source reads at the shared collection deadline and retains metadata",
expect(result.inventory.items.map((item) => item.source)).toEqual([
{
included: false,
reason:
"script source was omitted because the 20-second source collection deadline elapsed",
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
},
{
included: false,
reason:
"script source was omitted because the 20-second source collection deadline elapsed",
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
},
]);
expect(events.completeness.snapshot().excluded).toContainEqual({
+12 -4
View File
@@ -16,9 +16,10 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
import {
CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
cdpScriptSourceDeadlineOmissionReason,
CdpScriptSourceBudget,
} from "./CdpScriptSourceBudget.js";
@@ -59,9 +60,14 @@ export const captureScripts = async (
readonly sourceMapRawUrl: string | null;
}[] = [];
const scripts = [...events.scripts.values()];
const commandTimeoutMs = selectedCdpCommandTimeoutMs(
context.input.command_timeout_ms,
);
const deadlineOmission =
cdpScriptSourceDeadlineOmissionReason(commandTimeoutMs);
const sourceBudget =
context.input.include_script_sources && scripts.length > 0
? new CdpScriptSourceBudget(context.signal)
? new CdpScriptSourceBudget(context.signal, commandTimeoutMs)
: undefined;
try {
for (const script of scripts) {
@@ -76,7 +82,7 @@ export const captureScripts = async (
};
let source: WebPageInspection["scripts"]["items"][number]["source"] =
context.input.include_script_sources
? sourceExcluded(CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON)
? sourceExcluded(deadlineOmission)
: sourceExcluded("source capture was not selected");
if (
context.input.include_script_sources &&
@@ -166,7 +172,9 @@ const captureScriptSource = async (
"script_sources",
"resource_budget_exhausted",
);
return sourceExcluded(CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON);
return sourceExcluded(
cdpScriptSourceDeadlineOmissionReason(sourceBudget.commandTimeoutMs),
);
}
if (!sourceBudget.admit(content)) {
state.events.completeness.exclude(
+21 -7
View File
@@ -1,21 +1,35 @@
import {
noRuntimeOverrideSentence,
selectedCdpCommandTimeoutMs,
} from "../domain/browserDeadline.js";
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
const MIB = 1024 * 1024;
export const CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON = `script source was omitted because the aggregate ${String(WEB_RUNTIME_LIMITS.sourceBytes / MIB)} MiB source budget was exhausted`;
export const CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON = `script source was omitted because the ${String(WEB_RUNTIME_LIMITS.commandTimeoutMs / 1000)}-second source collection deadline elapsed`;
export const CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON = `script source was omitted because the aggregate ${String(WEB_RUNTIME_LIMITS.sourceBytes)}-byte (${String(WEB_RUNTIME_LIMITS.sourceBytes / MIB)} MiB) source budget was exhausted. ${noRuntimeOverrideSentence("That source budget stays fixed.", "command_timeout_ms")}`;
/** Name command_timeout_ms on a passive script-source collection miss. */
export const cdpScriptSourceDeadlineOmissionReason = (
timeoutMs: number,
): string =>
`script source was omitted because the ${String(timeoutMs)} ms command_timeout_ms source collection deadline elapsed (deadlineExpired). This is not the caller observation window. ${noRuntimeOverrideSentence(`The ${String(WEB_RUNTIME_LIMITS.sourceBytes)}-byte source budget stays fixed.`, "command_timeout_ms")}`;
export const CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON =
cdpScriptSourceDeadlineOmissionReason(selectedCdpCommandTimeoutMs(undefined));
/** Shared admission for source text retained from passive CDP script reads. */
export class CdpScriptSourceBudget {
readonly #deadline: ProviderStartupDeadline;
readonly commandTimeoutMs: number;
#retainedBytes = 0;
constructor(signal?: AbortSignal) {
this.#deadline = new ProviderStartupDeadline(
WEB_RUNTIME_LIMITS.commandTimeoutMs,
signal,
);
constructor(
signal?: AbortSignal,
commandTimeoutMs: number = selectedCdpCommandTimeoutMs(undefined),
) {
this.commandTimeoutMs = commandTimeoutMs;
this.#deadline = new ProviderStartupDeadline(commandTimeoutMs, signal);
}
get signal(): AbortSignal {
+36 -9
View File
@@ -1,3 +1,7 @@
import {
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
} from "../domain/browserDeadline.js";
import type { BrowserObservationEndpoint } from "../domain/browserObservation.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
@@ -43,33 +47,53 @@ export const openCdpEndpoint = async (
signal?: AbortSignal,
timeoutMs?: number,
owner?: CdpTargetSessionOwner,
commandTimeoutMs?: number,
): Promise<CdpOpenedEndpoint> => {
if (endpoint.transport === "http_discovery")
return {
discovery: await discoverCdpEndpoint(endpoint.url, operation, signal),
connection: undefined,
};
const discoveryTimeoutMs = timeoutMs ?? CDP_DISCOVERY_RESPONSE_TIMEOUT_MS;
// The browser-socket handshake and discovery commands stay on the discovery
// deadline. The selected command deadline starts only after discovery returns.
const discoveryLimits = {
commandTimeoutMs: discoveryTimeoutMs,
// Stay past the discovery abort so a stalled open is labeled as discovery,
// not as a command_timeout_ms handshake.
handshakeTimeoutMs: discoveryTimeoutMs + 1_000,
};
return await withCdpDiscoveryDeadline(
operation,
signal,
async (deadlineSignal) => {
const connection =
owner === undefined
? await CdpConnection.connect(endpoint.url, operation, deadlineSignal)
? await CdpConnection.connect(
endpoint.url,
operation,
deadlineSignal,
discoveryLimits,
)
: await owner.connect(
endpoint.url,
operation,
{ signal: deadlineSignal },
"browser-endpoint",
discoveryLimits,
);
try {
const discovery = await discoverCdpBrowserSocket(
connection,
endpoint.url,
operation,
deadlineSignal,
);
connection.selectCommandTimeout(
commandTimeoutMs ?? DEFAULT_CDP_COMMAND_TIMEOUT_MS,
);
return {
discovery: await discoverCdpBrowserSocket(
connection,
endpoint.url,
operation,
deadlineSignal,
),
discovery,
connection,
};
} catch (cause: unknown) {
@@ -85,7 +109,7 @@ export const openCdpEndpoint = async (
throw cause;
}
},
timeoutMs,
discoveryTimeoutMs,
);
};
@@ -95,7 +119,10 @@ export const openCdpTargetSession = async (
target: CdpEndpointTarget,
operation: BrowserObservationOperation,
signal?: AbortSignal,
limits?: { readonly maxPayloadBytes: number },
limits?: {
readonly maxPayloadBytes?: number;
readonly commandTimeoutMs?: number;
},
owner?: CdpTargetSessionOwner,
): Promise<CdpTargetSession> => {
const webSocket = cdpTargetWebSocket(discovery, target, operation);
+5 -1
View File
@@ -67,7 +67,11 @@ export class CdpTargetSessionOwner {
operation: BrowserObservationOperation,
options: ExecutionOptions,
resource: string,
limits?: { readonly maxPayloadBytes: number },
limits?: {
readonly maxPayloadBytes?: number;
readonly commandTimeoutMs?: number;
readonly handshakeTimeoutMs?: number;
},
): Promise<CdpConnection> {
if (this.#closing)
throw new ProviderCleanupError(
+17 -13
View File
@@ -1,7 +1,11 @@
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
import {
DEFAULT_WEB_BUNDLE_HEAP_MB,
DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS,
DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS,
webBundleTotalTimeoutRecovery,
type AnalyzeWebBundleInput,
type WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import { buildCapturedWebBundleAnalysis } from "../domain/webBundleAnalyzerResult.js";
import { isIncludedScript } from "../domain/webBundleAnalyzerInspection.js";
@@ -19,7 +23,6 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js";
// A process-wide admission, rather than one permit per provider instance, bounds
// simultaneous web analyzer children and their source/result transfer reservations.
@@ -147,8 +150,7 @@ export class IsolatedWebBundleAnalyzer {
];
const caller = AbortSignal.any(signals);
const deadline = new ProviderStartupDeadline(
input.total_analysis_timeout_ms ??
WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs,
input.total_analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS,
caller,
);
let failure: AnalysisError | undefined;
@@ -162,7 +164,7 @@ export class IsolatedWebBundleAnalyzer {
detail: caller.aborted
? "The operation was cancelled before this script was started."
: deadline.interruption === "timeout"
? `The total captured-script analysis deadline of ${String(input.total_analysis_timeout_ms ?? WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs)} ms expired before this script was started.`
? `The total_analysis_timeout_ms deadline of ${String(input.total_analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS)} ms expired before this script was started (deadlineExpired). This is not the per-script analysis_timeout_ms. A per-script timeout above the total is rejected.`
: resultCapacityExhausted
? "The host could not reserve more web-analysis result capacity; this script was not started."
: "An earlier analyzer failure prevented this script from starting.",
@@ -171,13 +173,13 @@ export class IsolatedWebBundleAnalyzer {
try {
await WebBundleAnalysisWorker.retryPending();
this.#worker = this.createWorker(
input.max_heap_mb ?? WEB_BUNDLE_WORKER_LIMITS.heapMb,
input.max_heap_mb ?? DEFAULT_WEB_BUNDLE_HEAP_MB,
);
for (const script of scripts) {
if (deadline.signal.aborted) break;
const analyzed = await this.#worker.analyze(
script,
input.analysis_timeout_ms ?? WEB_BUNDLE_WORKER_LIMITS.scriptTimeoutMs,
input.analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS,
deadline.signal,
);
if (analyzed.ok) results.push(analyzed.value);
@@ -198,8 +200,8 @@ export class IsolatedWebBundleAnalyzer {
detail:
error instanceof AnalysisResourceConstraintError &&
error.reportedLimits !== null
? `${error.message} Reported limits: ${JSON.stringify(error.reportedLimits)}`
: error.message,
? `${error.userMessage ?? error.reason} Reported limits: ${JSON.stringify(error.reportedLimits)}`
: (error.userMessage ?? error.message),
},
);
if (error.cleanupIncomplete) {
@@ -283,13 +285,15 @@ export class IsolatedWebBundleAnalyzer {
).bindCleanupDependencies([cancelled, failure])
: cancelled;
} else if (!hasPrimaryFailure && deadline.interruption === "timeout") {
const totalTimeoutMs =
input.total_analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS;
const timeout = new AnalysisTimeoutError(
"analyze_web_bundle",
input.total_analysis_timeout_ms ??
WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs,
totalTimeoutMs,
{
cause: deadline.signal.reason,
partialObservation: partial ?? inspection,
...webBundleTotalTimeoutRecovery(totalTimeoutMs),
},
);
failure =
@@ -205,7 +205,7 @@ export const projectElectronActiveCapture = (
"Process metrics are an Electron API snapshot and do not prove hostile-local-user isolation.",
...(hookSnapshot.dropped > 0
? [
`The active hook retained ${hookSnapshot.retained} of ${hookSnapshot.observed} observed events within a ${hookSnapshot.retention_budget_bytes}-byte retained-data budget; ${hookSnapshot.dropped} events were dropped (${hookSnapshot.dropped_ipc} IPC, ${hookSnapshot.dropped_runtime} runtime).`,
`The active hook retained ${hookSnapshot.retained} of ${hookSnapshot.observed} observed events within a ${hookSnapshot.retention_budget_bytes}-byte (${String(hookSnapshot.retention_budget_bytes / (1024 * 1024))} MiB) retained-data budget; ${hookSnapshot.dropped} events were dropped (${hookSnapshot.dropped_ipc} IPC, ${hookSnapshot.dropped_runtime} runtime). This release has no runtime override.`,
]
: []),
"External shell opens, external navigation, permission grants, downloads, popup windows, updater relaunches, and OS integration are blocked and recorded by the active hook; other application filesystem and network behavior is not sandboxed by this provider.",
@@ -13,6 +13,7 @@ import type {
Page,
} from "playwright-core";
import { DEFAULT_CDP_COMMAND_TIMEOUT_MS } from "../domain/browserDeadline.js";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import {
@@ -1011,3 +1012,313 @@ it("bounds an opening failure response while retaining the same late cleanup lea
vi.useRealTimers();
}
});
const scenarioActions = [
{ step_id: "settle", action: "wait_for_timeout" as const, duration_ms: 1 },
];
it("applies command_timeout_ms to launch startup", async () => {
let timeout: number | undefined;
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: process.execPath },
start_url: { url: "https://example.test" },
command_timeout_ms: 4_321,
actions: scenarioActions,
});
await expect(
openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async () => {
throw new Error("Unexpected connection");
},
launchPersistentContext: async (_profile, options) => {
timeout = options?.timeout;
throw new Error("launch boundary reached");
},
},
},
),
).rejects.toThrow("launch boundary reached");
expect(timeout).toBe(4_321);
});
it("uses the default command deadline for an omitted launch timeout", async () => {
let timeout: number | undefined;
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: process.execPath },
start_url: { url: "https://example.test" },
actions: scenarioActions,
});
await expect(
openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async () => {
throw new Error("Unexpected connection");
},
launchPersistentContext: async (_profile, options) => {
timeout = options?.timeout;
throw new Error("launch boundary reached");
},
},
},
),
).rejects.toThrow("launch boundary reached");
expect(timeout).toBe(DEFAULT_CDP_COMMAND_TIMEOUT_MS);
});
it("applies command_timeout_ms to the primary connect handshake when service workers are allowed", async () => {
let timeout: number | undefined;
const started = performance.now();
const scenario = browserScenarioSchema.parse({
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "primary-handshake",
},
start_url: { url: "https://example.test" },
environment: { service_workers: "allow" },
command_timeout_ms: 50,
actions: scenarioActions,
});
const opening = openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async (_endpoint, options) => {
timeout = options?.timeout;
return new Promise(() => undefined);
},
launchPersistentContext: async () => {
throw new Error("Unexpected launch");
},
},
},
);
const failure = await opening.then(
() => undefined,
(cause: unknown) => cause,
);
expect(failure).toMatchObject({
reason: "timeout",
userMessage: expect.stringContaining("50 ms command_timeout_ms"),
remediationAction: expect.stringContaining("capture-browser-scenario JSON"),
});
expect(
(failure as { remediationAction?: string }).remediationAction,
).not.toContain("--command-timeout-ms");
expect(timeout).toBe(50);
expect(performance.now() - started).toBeLessThan(2_000);
});
it("applies command_timeout_ms to primary target lookup when service workers are allowed", async () => {
const started = performance.now();
const scenario = browserScenarioSchema.parse({
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "primary-target-info",
},
start_url: { url: "https://example.test" },
environment: { service_workers: "allow" },
command_timeout_ms: 50,
actions: scenarioActions,
});
const browser = {
close: async () => undefined,
contexts: () => [
{
pages: () => [{} as Page],
newCDPSession: async () =>
({
send: () => new Promise(() => undefined),
detach: async () => undefined,
}) as unknown as CDPSession,
} as unknown as BrowserContext,
],
isConnected: () => true,
version: () => "fixture",
};
await expect(
openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async (_endpoint, options) => {
expect(options?.timeout).toBe(50);
return browser;
},
launchPersistentContext: async () => {
throw new Error("Unexpected launch");
},
},
},
),
).rejects.toMatchObject({
reason: "timeout",
userMessage: expect.stringContaining("Target.getTargetInfo"),
});
expect(performance.now() - started).toBeLessThan(2_000);
});
it("closes a context that launches after its deadline before removing the profile", async () => {
const order: string[] = [];
let profilePath = "";
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: process.execPath },
start_url: { url: "https://example.test" },
command_timeout_ms: 40,
actions: scenarioActions,
});
const failure = await openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async () => {
throw new Error("Unexpected connection");
},
launchPersistentContext: async (profile) => {
profilePath = profile;
await new Promise((resolve) => setTimeout(resolve, 80));
order.push("launched");
return {
close: async () => {
order.push("close");
},
} as unknown as BrowserContext;
},
},
},
).then(
() => undefined,
(cause: unknown) => cause,
);
expect(failure).toMatchObject({ reason: "timeout" });
expect(
(failure as { remediationAction?: string }).remediationAction,
).not.toContain("--command-timeout-ms");
expect(order).toEqual(["launched", "close"]);
await expect(access(profilePath)).rejects.toThrow();
});
it("closes a browser that connects after its deadline without waiting to report the timeout", async () => {
let closed = false;
const started = performance.now();
const scenario = browserScenarioSchema.parse({
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "late-connect-target",
},
start_url: { url: "https://example.test" },
environment: { service_workers: "allow" },
command_timeout_ms: 40,
actions: scenarioActions,
});
await expect(
openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async () => {
await new Promise((resolve) => setTimeout(resolve, 80));
return {
close: async () => {
closed = true;
},
contexts: () => [],
isConnected: () => true,
version: () => "late-connect",
};
},
launchPersistentContext: async () => {
throw new Error("Unexpected launch");
},
},
},
),
).rejects.toMatchObject({ reason: "timeout" });
expect(performance.now() - started).toBeLessThan(2_000);
for (let attempt = 0; attempt < 50 && !closed; attempt += 1)
await new Promise((resolve) => setTimeout(resolve, 10));
expect(closed).toBe(true);
});
it("restores an environment mutation that completes after its deadline", async () => {
let restored = false;
const scenario = browserScenarioSchema.parse({
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "late-environment-target",
},
start_url: { url: "https://example.test" },
environment: { service_workers: "allow" },
command_timeout_ms: 40,
actions: scenarioActions,
});
const discoverySession = {
send: vi.fn(async () => ({
targetInfo: { targetId: "late-environment-target" },
})),
detach: vi.fn(async () => undefined),
} as unknown as CDPSession;
const environmentSession = {
send: vi.fn(async () => undefined),
detach: vi.fn(async () => undefined),
} as unknown as CDPSession;
const page = {
emulateMedia: vi.fn(async (options: { colorScheme?: string | null }) => {
if (options.colorScheme === null) {
restored = true;
return;
}
await new Promise((resolve) => setTimeout(resolve, 80));
}),
isClosed: vi.fn(() => false),
} as unknown as Page;
const context = {
pages: () => [page],
newCDPSession: vi
.fn()
.mockResolvedValueOnce(discoverySession)
.mockResolvedValueOnce(environmentSession),
} as unknown as BrowserContext;
const browser = {
close: vi.fn(async () => undefined),
contexts: () => [context],
isConnected: vi.fn(() => true),
version: () => "late-environment",
};
await expect(
openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async () => browser,
launchPersistentContext: async () => {
throw new Error("Unexpected launch");
},
},
},
),
).rejects.toMatchObject({
reason: "timeout",
userMessage: expect.stringContaining("Emulation.setEmulatedMedia"),
});
expect(restored).toBe(true);
expect(environmentSession.send).not.toHaveBeenCalledWith(
"Emulation.clearDeviceMetricsOverride",
);
expect(browser.close).toHaveBeenCalledOnce();
});
+197 -66
View File
@@ -25,6 +25,13 @@ import {
withPlaywrightExecutionBoundary,
PLAYWRIGHT_CLEANUP_CONFIRMATION_TIMEOUT_MS,
} from "./PlaywrightExecutionBoundary.js";
import {
releaseLateScenarioCommand,
scenarioCommandPending,
scenarioCommandTimeoutMs,
settleScenarioCommand,
withScenarioCommandDeadline,
} from "./ScenarioCommandDeadline.js";
const OPERATION = "capture_browser_scenario" as const;
@@ -369,22 +376,58 @@ const allowedBrowserEnvironment = (
TERMUX_EXEC__PROC_SELF_EXE: process.execPath,
};
};
const ownLateScenarioCommand = (
cause: unknown,
retainCleanup: ((lease: AnalysisCleanupLease) => void) | undefined,
resource: string,
release: () => Promise<void> = () => releaseLateScenarioCommand(cause),
): boolean => {
if (scenarioCommandPending(cause) === undefined) return false;
const late = new AnalysisCleanupLease(release, [resource]);
retainCleanup?.(late);
void late.release().catch(() => undefined);
return true;
};
const findConnectedPage = async (
browser: Pick<Browser, "contexts">,
targetId: string,
signal: AbortSignal | undefined,
commandTimeoutMs: number,
retainCleanup: ((lease: AnalysisCleanupLease) => void) | undefined,
): Promise<{ readonly context: BrowserContext; readonly page: Page }> => {
for (const context of browser.contexts())
for (const page of context.pages()) {
const session = await context.newCDPSession(page);
let session: CDPSession;
try {
session = await withScenarioCommandDeadline(
() => context.newCDPSession(page),
commandTimeoutMs,
"command",
"Target.attachToTarget",
);
} catch (cause: unknown) {
ownLateScenarioCommand(cause, retainCleanup, "browser_transport");
throw cause;
}
try {
throwIfScenarioCancelled(signal);
const { targetInfo } = await session.send("Target.getTargetInfo");
const { targetInfo } = await withScenarioCommandDeadline(
() => session.send("Target.getTargetInfo"),
commandTimeoutMs,
"command",
"Target.getTargetInfo",
);
throwIfScenarioCancelled(signal);
if (targetInfo.targetId !== targetId) continue;
return { context, page };
} finally {
await session.detach();
await withScenarioCommandDeadline(
() => session.detach(),
commandTimeoutMs,
"command",
"Target.detachFromTarget",
);
}
}
throw new BrowserObservationError(OPERATION, "target_not_found");
@@ -402,15 +445,31 @@ class AttachedScenarioEnvironmentOwner {
private constructor(
private readonly page: Page,
private readonly session: CDPSession,
private readonly commandTimeoutMs: number,
) {}
static async create(
context: BrowserContext,
page: Page,
commandTimeoutMs: number,
retainCleanup: ((lease: AnalysisCleanupLease) => void) | undefined,
): Promise<AttachedScenarioEnvironmentOwner> {
let session: CDPSession;
try {
session = await withScenarioCommandDeadline(
() => context.newCDPSession(page),
commandTimeoutMs,
"command",
"Target.attachToTarget",
);
} catch (cause: unknown) {
ownLateScenarioCommand(cause, retainCleanup, "browser_transport");
throw cause;
}
return new AttachedScenarioEnvironmentOwner(
page,
await context.newCDPSession(page),
session,
commandTimeoutMs,
);
}
@@ -437,52 +496,85 @@ class AttachedScenarioEnvironmentOwner {
return application;
}
async #command(work: Promise<unknown>, method: string): Promise<void> {
await withScenarioCommandDeadline(
() => work,
this.commandTimeoutMs,
"command",
method,
);
}
async #applyEnvironment(
environment: BrowserScenario["environment"],
signal: AbortSignal | undefined,
): Promise<void> {
await this.page.emulateMedia({
colorScheme: environment.color_scheme,
reducedMotion: environment.reduced_motion,
});
this.#mediaOverride = true;
await this.#command(
this.page
.emulateMedia({
colorScheme: environment.color_scheme,
reducedMotion: environment.reduced_motion,
})
.then(() => {
this.#mediaOverride = true;
}),
"Emulation.setEmulatedMedia",
);
throwIfScenarioCancelled(signal);
// setViewportSize also resizes the external browser window. CDP metrics
// provide the scenario viewport without changing that caller-owned window.
await this.session.send("Emulation.setDeviceMetricsOverride", {
width: environment.viewport.width,
height: environment.viewport.height,
deviceScaleFactor: environment.viewport.device_scale_factor,
mobile: false,
dontSetVisibleSize: true,
});
this.#deviceMetricsOverride = true;
await this.#command(
this.session
.send("Emulation.setDeviceMetricsOverride", {
width: environment.viewport.width,
height: environment.viewport.height,
deviceScaleFactor: environment.viewport.device_scale_factor,
mobile: false,
dontSetVisibleSize: true,
})
.then(() => {
this.#deviceMetricsOverride = true;
}),
"Emulation.setDeviceMetricsOverride",
);
throwIfScenarioCancelled(signal);
await this.session.send("Emulation.setLocaleOverride", {
locale: environment.locale,
});
this.#localeOverride = true;
await this.#command(
this.session
.send("Emulation.setLocaleOverride", {
locale: environment.locale,
})
.then(() => {
this.#localeOverride = true;
}),
"Emulation.setLocaleOverride",
);
throwIfScenarioCancelled(signal);
await this.session.send("Emulation.setTimezoneOverride", {
timezoneId: environment.timezone,
});
this.#timezoneOverride = true;
await this.#command(
this.session
.send("Emulation.setTimezoneOverride", {
timezoneId: environment.timezone,
})
.then(() => {
this.#timezoneOverride = true;
}),
"Emulation.setTimezoneOverride",
);
throwIfScenarioCancelled(signal);
}
async close(): Promise<void> {
if (this.#detached) return;
// The execution boundary can report cancellation before a pending CDP
// command settles. Wait for that producer so every successful mutation is
// recorded before deciding which state this owner must restore.
// The execution boundary can report cancellation or a command deadline
// before a pending CDP command settles. Wait for that producer, including
// a command that finishes after its deadline, so every successful mutation
// is recorded before deciding which state this owner must restore.
try {
await this.#application;
} catch {
// The operation path reports application failures. Cleanup still owns
// and restores every mutation that completed before the failure.
} catch (cause: unknown) {
await settleScenarioCommand(cause);
}
const failures: unknown[] = [];
if (this.#timezoneOverride) {
@@ -577,6 +669,7 @@ type ScenarioBrowserLauncher = Pick<
> & {
readonly connectOverCDP: (
endpoint: string,
options?: { readonly timeout?: number },
) => Promise<Pick<Browser, "contexts" | "close" | "isConnected" | "version">>;
};
@@ -591,6 +684,7 @@ const openAttachedScenarioBrowser = async (
launcher: ScenarioBrowserLauncher,
signal: AbortSignal | undefined,
retainCleanup: ((lease: AnalysisCleanupLease) => void) | undefined,
commandTimeoutMs: number,
): Promise<OpenedScenarioBrowser> => {
const lease = await acquireAttachedScenario(browserRequest, signal);
let cleanupOwner: PlaywrightScenarioBrowserCleanupOwner | undefined;
@@ -600,7 +694,14 @@ const openAttachedScenarioBrowser = async (
if (priorOwner !== undefined) await priorOwner.close();
throwIfScenarioCancelled(signal);
const browser = await launcher.connectOverCDP(browserRequest.cdp_endpoint);
const browser = await withScenarioCommandDeadline(
() =>
launcher.connectOverCDP(browserRequest.cdp_endpoint, {
timeout: commandTimeoutMs,
}),
commandTimeoutMs,
"handshake",
);
const cleanup: PlaywrightScenarioBrowserCleanupOwner =
new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: async (disconnectAtBoundary) => {
@@ -651,14 +752,26 @@ const openAttachedScenarioBrowser = async (
lease.admission.cleanupOwner = cleanup;
throwIfScenarioCancelled(signal);
const target = await withPlaywrightExecutionBoundary(
() => findConnectedPage(browser, browserRequest.target_id, signal),
() =>
findConnectedPage(
browser,
browserRequest.target_id,
signal,
commandTimeoutMs,
retainCleanup,
),
undefined,
signal,
);
throwIfScenarioCancelled(signal);
const configuredEnvironment = await withPlaywrightExecutionBoundary(
() =>
AttachedScenarioEnvironmentOwner.create(target.context, target.page),
AttachedScenarioEnvironmentOwner.create(
target.context,
target.page,
commandTimeoutMs,
retainCleanup,
),
undefined,
signal,
);
@@ -676,7 +789,16 @@ const openAttachedScenarioBrowser = async (
};
} catch (cause: unknown) {
if (cleanupOwner === undefined) {
lease.release();
const owned = ownLateScenarioCommand(
cause,
retainCleanup,
"browser_transport",
async () => {
await releaseLateScenarioCommand(cause);
lease.release();
},
);
if (!owned) lease.release();
throw cause;
}
const cleanup = cleanupOwner;
@@ -703,6 +825,7 @@ export const openPlaywrightScenarioBrowser = async (
// Load Playwright only when a scenario opens; MCP startup stays off its module graph.
const launcher =
options.launcher ?? (await import("playwright-core")).chromium;
const commandTimeoutMs = scenarioCommandTimeoutMs(scenario);
if (scenario.browser.mode === "connect")
return openAttachedScenarioBrowser(
scenario.browser,
@@ -710,44 +833,52 @@ export const openPlaywrightScenarioBrowser = async (
launcher,
options.signal,
options.retainCleanup,
commandTimeoutMs,
);
const profilePath = await mkdtemp(join(tmpdir(), "rea-browser-scenario-"));
let context: BrowserContext;
const executablePath = scenario.browser.executable_path;
const headless = scenario.browser.headless;
try {
await chmod(profilePath, 0o700);
context = await launcher.launchPersistentContext(profilePath, {
executablePath: scenario.browser.executable_path,
headless: scenario.browser.headless,
acceptDownloads: false,
viewport: {
width: scenario.environment.viewport.width,
height: scenario.environment.viewport.height,
},
deviceScaleFactor: scenario.environment.viewport.device_scale_factor,
locale: scenario.environment.locale,
timezoneId: scenario.environment.timezone,
colorScheme: scenario.environment.color_scheme,
reducedMotion: scenario.environment.reduced_motion,
serviceWorkers: scenario.environment.service_workers,
env: allowedBrowserEnvironment(environment),
handleSIGHUP: false,
handleSIGINT: false,
handleSIGTERM: false,
timeout: 0,
// Termux's Chromium cannot create its renderer process through Android's
// executable boundary. Single-process mode keeps the browser and page in
// the Termux-launched process so scenario navigation remains available.
args:
process.platform === "android"
? ["--no-sandbox", "--single-process"]
: [],
});
} catch (cause: unknown) {
const cleanup = new AnalysisCleanupLease(
() => rm(profilePath, { recursive: true, force: true, maxRetries: 3 }),
["browser_profile"],
context = await withScenarioCommandDeadline(
() =>
launcher.launchPersistentContext(profilePath, {
executablePath,
headless,
acceptDownloads: false,
viewport: {
width: scenario.environment.viewport.width,
height: scenario.environment.viewport.height,
},
deviceScaleFactor: scenario.environment.viewport.device_scale_factor,
locale: scenario.environment.locale,
timezoneId: scenario.environment.timezone,
colorScheme: scenario.environment.color_scheme,
reducedMotion: scenario.environment.reduced_motion,
serviceWorkers: scenario.environment.service_workers,
env: allowedBrowserEnvironment(environment),
handleSIGHUP: false,
handleSIGINT: false,
handleSIGTERM: false,
timeout: commandTimeoutMs,
// Termux's Chromium cannot create its renderer process through Android's
// executable boundary. Single-process mode keeps the browser and page in
// the Termux-launched process so scenario navigation remains available.
args:
process.platform === "android"
? ["--no-sandbox", "--single-process"]
: [],
}),
commandTimeoutMs,
"handshake",
);
} catch (cause: unknown) {
const cleanup = new AnalysisCleanupLease(async () => {
await releaseLateScenarioCommand(cause);
await rm(profilePath, { recursive: true, force: true, maxRetries: 3 });
}, ["browser_profile"]);
try {
return await failBrowserScenarioOperation(cleanup, cause);
} catch (failure: unknown) {
+113 -31
View File
@@ -3,6 +3,15 @@ import { randomUUID } from "node:crypto";
import { z } from "zod";
import type { Frame, Page } from "playwright-core";
import {
CDP_CLEANUP_GRACE_MS,
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
selectedCdpCommandTimeoutMs,
} from "../domain/browserDeadline.js";
import {
AnalysisCancelledError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { browserObservationEndpointSchema } from "../domain/browserObservation.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { CdpConnection } from "./CdpConnection.js";
@@ -18,7 +27,64 @@ const OPERATION = "capture_browser_scenario" as const;
// Restoration performs several sequential CDP mutations before the transport's
// own bounded close. A single round-trip deadline is too short for the complete
// chain on a loaded Windows browser, while retries must still stay bounded.
const POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS = 5_000;
const POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS = CDP_CLEANUP_GRACE_MS;
/**
* First blocked-service-worker registration check, before discovery starts.
* It keeps the previous fixed 10-second bound. command_timeout_ms does not
* apply yet, and this timer is not the discovery or attach deadline.
*/
export const SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS =
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS;
const serviceWorkerPolicyCheckTimeout = (): AnalysisTimeoutError => {
const timeoutMs = SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS;
return new AnalysisTimeoutError(OPERATION, timeoutMs, {
userMessage: `Blocked service-worker policy check exceeded its ${String(timeoutMs)} ms deadline before CDP discovery. command_timeout_ms does not apply to this check.`,
remediationAction: `The ${String(timeoutMs)} ms blocked service-worker policy check stays fixed. This release has no runtime override. command_timeout_ms does not raise it.`,
});
};
const withServiceWorkerPolicyDeadline = <Value>(
work: () => Promise<Value>,
signal: AbortSignal,
): Promise<Value> => {
if (signal.aborted)
return Promise.reject(
new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
);
return new Promise<Value>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
fail(serviceWorkerPolicyCheckTimeout());
}, SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS);
timer.unref();
const cleanup = (): void => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
};
const succeed = (value: Value): void => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};
const fail = (error: unknown): void => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const onAbort = (): void => {
fail(new AnalysisCancelledError(OPERATION, { cause: signal.reason }));
};
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
onAbort();
return;
}
void work().then(succeed, fail);
});
};
const scriptResponse = z.object({ identifier: z.string() });
const restorationResponse = z.object({
result: z.object({ value: z.literal("policy_owned").nullable() }),
@@ -60,6 +126,9 @@ export class PlaywrightScenarioServiceWorkers {
private readonly page: Page,
private readonly endpoint: string,
private readonly targetId: string,
private readonly commandTimeoutMs: number = selectedCdpCommandTimeoutMs(
undefined,
),
) {}
initialize(): Promise<void> {
@@ -67,41 +136,47 @@ export class PlaywrightScenarioServiceWorkers {
}
private async install(): Promise<void> {
this.#session = await withCdpDiscoveryDeadline(
await withServiceWorkerPolicyDeadline(
() => this.assertBlocked(),
this.#setup.signal,
);
// Discovery stays on its fixed deadline. The following handshake and
// Target.attachToTarget each use command_timeout_ms.
const opened = await withCdpDiscoveryDeadline(
OPERATION,
this.#setup.signal,
async (signal) => {
await withPlaywrightExecutionBoundary(
() => this.assertBlocked(),
undefined,
signal,
);
const opened = await openCdpEndpoint(
(signal) =>
openCdpEndpoint(
browserObservationEndpointSchema.parse(this.endpoint),
OPERATION,
signal,
);
const target = opened.discovery.targets.find(
({ id }) => id === this.targetId,
);
if (target === undefined) {
await opened.connection?.close();
throw new BrowserObservationError(OPERATION, "target_not_found");
}
const connection =
opened.connection ??
(await CdpConnection.connect(
opened.discovery.webSocket.url,
OPERATION,
signal,
));
return await attachCdpTargetSession(
connection,
target,
OPERATION,
signal,
);
},
undefined,
undefined,
this.commandTimeoutMs,
),
);
const target = opened.discovery.targets.find(
({ id }) => id === this.targetId,
);
if (target === undefined) {
await opened.connection?.close();
throw new BrowserObservationError(OPERATION, "target_not_found");
}
const transport =
opened.connection ??
(await CdpConnection.connect(
opened.discovery.webSocket.url,
OPERATION,
this.#setup.signal,
{ commandTimeoutMs: this.commandTimeoutMs },
));
if (this.#closed)
transport.beginCleanupGrace(POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS);
this.#session = await attachCdpTargetSession(
transport,
target,
OPERATION,
this.#setup.signal,
);
const connection = this.#session.connection;
this.#removeListener = connection.onEvent((event) => {
@@ -318,6 +393,10 @@ export class PlaywrightScenarioServiceWorkers {
close(stopLoading = false): Promise<void> {
this.#closed = true;
this.#stopRequested ||= stopLoading;
// Restoration commands keep the fixed grace even when command_timeout_ms was raised.
this.#session?.connection.beginCleanupGrace(
POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS,
);
// Abort discovery/handshakes and reads, never an already-sent page mutation.
this.#setup.abort();
const restoration = (this.#restoration ??= this.restore());
@@ -372,6 +451,9 @@ export class PlaywrightScenarioServiceWorkers {
private async restore(): Promise<void> {
await this.#initialization?.catch(() => undefined);
this.#session?.connection.beginCleanupGrace(
POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS,
);
if (this.#released) return;
const session = this.#session;
if (session === undefined) return;
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
finishEvents: vi.fn(),
policySend: vi.fn(),
policyTransportClose: vi.fn(),
policyBeginCleanupGrace: vi.fn(),
policyEvent: undefined as
| ((event: { method: string; params: unknown; sessionId?: string }) => void)
| undefined,
@@ -33,6 +34,7 @@ vi.mock("./CdpTargetSession.js", () => ({
return () => undefined;
},
close: mocks.policyTransportClose,
beginCleanupGrace: mocks.policyBeginCleanupGrace,
},
}),
attachCdpTargetSession: async (connection: unknown) => ({
+1
View File
@@ -286,6 +286,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
opened.page,
scenario.browser.cdp_endpoint,
scenario.browser.target_id,
scenario.command_timeout_ms,
)
: undefined;
try {
@@ -403,6 +403,35 @@ it("cancels a pending storage-key reply and still releases its CDP owner", async
expect(cdp.detachCalls).toBe(1);
});
it("bounds a stalled child CDP reply by command_timeout_ms", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
const input = browserScenarioSchema.parse({
...scenario(),
command_timeout_ms: 40,
});
const started = performance.now();
const owner = await createInitializedStorage(contextApi, page, input);
expect(owner).toBeDefined();
cdp.emit("Target.attachedToTarget", {
sessionId: "iframe-session",
targetInfo: { type: "iframe", targetId: "iframe-target" },
});
await expect(owner?.settle()).rejects.toMatchObject({
_tag: "BrowserObservationError",
operation: "capture_browser_scenario",
reason: "timeout",
userMessage: expect.stringContaining(
"CDP command Page.enable exceeded its 40 ms command_timeout_ms",
),
remediationAction: expect.stringContaining(
"Set command_timeout_ms in the capture-browser-scenario JSON above 40",
),
});
expect(performance.now() - started).toBeLessThan(4_000);
await owner?.close();
});
it("releases the CDP owner when an attached target reply remains pending", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
+92 -22
View File
@@ -8,6 +8,12 @@ import type { BrowserScenario } from "../domain/browserScenario.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
import {
releaseLateScenarioCommand,
scenarioCommandPending,
scenarioCommandTimeoutMs,
withScenarioCommandDeadline,
} from "./ScenarioCommandDeadline.js";
const breakpointResponse = z.object({ breakpointId: z.string() });
const scriptIdentifierResponse = z.object({ identifier: z.string() });
@@ -87,6 +93,7 @@ interface StorageTarget {
readonly send: (
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
) => Promise<unknown>;
readonly contexts: Map<
number,
@@ -138,6 +145,7 @@ export class PlaywrightScenarioStorage {
private constructor(
private readonly cdp: CDPSession,
private readonly signal: AbortSignal | undefined,
private readonly commandTimeoutMs: number,
) {}
static async create(
@@ -154,11 +162,18 @@ export class PlaywrightScenarioStorage {
)
return undefined;
const commandTimeoutMs = scenarioCommandTimeoutMs(scenario);
const connection = context.newCDPSession(page);
let cdp: CDPSession;
try {
cdp = await withPlaywrightExecutionBoundary(
() => connection,
() =>
withScenarioCommandDeadline(
() => connection,
commandTimeoutMs,
"command",
"Target.attachToTarget",
),
undefined,
signal,
);
@@ -185,10 +200,20 @@ export class PlaywrightScenarioStorage {
() => cleanup.release(),
STORAGE_CLEANUP_TIMEOUT_MS,
).catch(() => undefined);
} else if (scenarioCommandPending(cause) !== undefined) {
const cleanup = new AnalysisCleanupLease(
() => releaseLateScenarioCommand(cause),
["browser_storage_transport"],
);
retainCleanup?.(cleanup);
void withPlaywrightExecutionBoundary(
() => cleanup.release(),
STORAGE_CLEANUP_TIMEOUT_MS,
).catch(() => undefined);
}
throw cause;
}
const owner = new PlaywrightScenarioStorage(cdp, signal);
const owner = new PlaywrightScenarioStorage(cdp, signal, commandTimeoutMs);
owner.addSeeds(scenario.storage.local_storage, "local", secrets);
owner.addSeeds(scenario.storage.session_storage, "session", secrets);
return owner;
@@ -209,14 +234,25 @@ export class PlaywrightScenarioStorage {
context: BrowserContext,
): Promise<void> {
const rootInfo = targetInfoResponse.parse(
await this.cdp.send("Target.getTargetInfo"),
await withScenarioCommandDeadline(
() => this.cdp.send("Target.getTargetInfo"),
this.commandTimeoutMs,
"command",
"Target.getTargetInfo",
),
);
if (this.#closed) return;
const root = this.createTarget(
undefined,
rootInfo.targetInfo.targetId,
(method, params) =>
this.cdp.send(method as Parameters<CDPSession["send"]>[0], params),
(method, params, timeoutMs = this.commandTimeoutMs) =>
withScenarioCommandDeadline(
() =>
this.cdp.send(method as Parameters<CDPSession["send"]>[0], params),
timeoutMs,
"command",
method,
),
);
this.#targets.set("", root);
this.listen(page, context);
@@ -401,11 +437,16 @@ export class PlaywrightScenarioStorage {
private fail(cause: unknown): void {
this.#generation += 1;
this.#failure ??= new BrowserObservationError(
"capture_browser_scenario",
"protocol_error",
{ cause },
);
// A child command deadline is already the caller-facing failure. Storing
// it keeps the command name, selected deadline, and recovery advice.
this.#failure ??=
cause instanceof BrowserObservationError && cause.reason === "timeout"
? cause
: new BrowserObservationError(
"capture_browser_scenario",
"protocol_error",
{ cause },
);
}
private async seedPausedFrame(
@@ -479,7 +520,8 @@ export class PlaywrightScenarioStorage {
let resumeFailure: unknown;
try {
await withPlaywrightExecutionBoundary(
() => target.send("Debugger.resume"),
() =>
target.send("Debugger.resume", undefined, STORAGE_CLEANUP_TIMEOUT_MS),
STORAGE_CLEANUP_TIMEOUT_MS,
);
} catch (cause: unknown) {
@@ -508,18 +550,41 @@ export class PlaywrightScenarioStorage {
event.targetInfo.type === "iframe"
? parent.topLevelTargetId
: event.targetInfo.targetId,
async (method, params = {}) => {
async (method, params = {}, timeoutMs = this.commandTimeoutMs) => {
const id = ++this.#sequence;
const reply = new Promise<unknown>((resolve, reject) =>
this.#replies.set(id, { sessionId, resolve, reject }),
);
let rejectReply: ((error: Error) => void) | undefined;
const reply = new Promise<unknown>((resolve, reject) => {
rejectReply = reject;
this.#replies.set(id, { sessionId, resolve, reject });
});
try {
const sent = parent.send("Target.sendMessageToTarget", {
sessionId,
message: JSON.stringify({ id, method, params }),
});
const [, result] = await Promise.all([sent, reply]);
const sent = parent.send(
"Target.sendMessageToTarget",
{
sessionId,
message: JSON.stringify({ id, method, params }),
},
timeoutMs,
);
// Delivery through the root session and the nested reply share one deadline.
const [, result] = await withScenarioCommandDeadline(
() => Promise.all([sent, reply]),
timeoutMs,
"command",
method,
);
return result;
} catch (cause: unknown) {
rejectReply?.(
cause instanceof Error
? cause
: new BrowserObservationError(
"capture_browser_scenario",
"protocol_error",
{ cause },
),
);
throw cause;
} finally {
this.#replies.delete(id);
}
@@ -545,7 +610,12 @@ export class PlaywrightScenarioStorage {
if (this.#targets.get(sessionId) !== target) return;
try {
await withPlaywrightExecutionBoundary(
() => target.send("Runtime.runIfWaitingForDebugger"),
() =>
target.send(
"Runtime.runIfWaitingForDebugger",
undefined,
STORAGE_CLEANUP_TIMEOUT_MS,
),
STORAGE_CLEANUP_TIMEOUT_MS,
);
target.waitingForDebugger = false;
@@ -633,7 +703,7 @@ export class PlaywrightScenarioStorage {
const root = this.#targets.get("");
if (root !== undefined) {
await withPlaywrightExecutionBoundary(
() => root.send("Page.stopLoading"),
() => this.cdp.send("Page.stopLoading"),
STORAGE_CLEANUP_TIMEOUT_MS,
);
this.#loadingStopped = true;
+158
View File
@@ -0,0 +1,158 @@
import { BrowserObservationError } from "../domain/browserObservationError.js";
import {
browserDeadlineRetry,
cdpCommandDeadlineRecovery,
CDP_CLEANUP_GRACE_MS,
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
MAX_BROWSER_DEADLINE_MS,
selectedCdpCommandTimeoutMs,
type CdpCommandDeadlinePhase,
} from "../domain/browserDeadline.js";
import type { BrowserScenario } from "../domain/browserScenario.js";
const OPERATION = "capture_browser_scenario" as const;
const pendingScenarioCommands = new WeakMap<
BrowserObservationError,
Promise<unknown>
>();
/** Selected scenario CDP deadline, or today's 20000 ms default when omitted. */
export const scenarioCommandTimeoutMs = (
scenario: Pick<BrowserScenario, "command_timeout_ms">,
): number => selectedCdpCommandTimeoutMs(scenario.command_timeout_ms);
const playwrightTimedOut = (cause: unknown): cause is Error =>
cause instanceof Error &&
(cause.name === "TimeoutError" || cause.message.startsWith("Timeout "));
/**
* `capture-browser-scenario` accepts only scenario JSON, so recovery names
* `command_timeout_ms` there and does not advertise a CLI flag.
*/
const scenarioCommandDeadlineRecovery = (
timeoutMs: number,
phase: Extract<CdpCommandDeadlinePhase, "handshake" | "command">,
method?: string,
): { readonly userMessage: string; readonly remediationAction: string } => {
const shared = cdpCommandDeadlineRecovery(timeoutMs, phase, method);
const increase =
timeoutMs < MAX_BROWSER_DEADLINE_MS
? `Set command_timeout_ms in the capture-browser-scenario JSON above ${String(timeoutMs)} (integer milliseconds from 1 to ${String(MAX_BROWSER_DEADLINE_MS)}; omitted means ${String(DEFAULT_CDP_COMMAND_TIMEOUT_MS)}).`
: `command_timeout_ms is already at the maximum ${String(MAX_BROWSER_DEADLINE_MS)} ms; do not increase it further.`;
return {
userMessage: shared.userMessage,
remediationAction: `${increase} ${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.`,
};
};
/** The scenario command still running after this deadline rejection, if any. */
export const scenarioCommandPending = (
cause: unknown,
): Promise<unknown> | undefined =>
cause instanceof BrowserObservationError
? pendingScenarioCommands.get(cause)
: undefined;
/**
* Observe a timed-out scenario command through completion.
* The result stays with its caller; this does not close or detach it.
*/
export const settleScenarioCommand = async (cause: unknown): Promise<void> => {
await scenarioCommandPending(cause)?.catch(() => undefined);
};
const releasableCommandResult = (
value: unknown,
): value is {
readonly close?: () => Promise<unknown>;
readonly detach?: () => Promise<unknown>;
} => typeof value === "object" && value !== null;
/** Close or detach a browser, context, or session that arrives after its deadline. */
export const releaseLateScenarioCommand = async (
cause: unknown,
): Promise<void> => {
const pending = scenarioCommandPending(cause);
if (pending === undefined) return;
let value: unknown;
try {
value = await pending;
} catch {
return;
}
if (!releasableCommandResult(value)) return;
if (typeof value.detach === "function") {
await value.detach();
return;
}
if (typeof value.close === "function") await value.close();
};
/**
* Bound one Playwright CDP handshake or command by the scenario command deadline.
* The underlying operation stays reachable after a timeout so its owner can
* close the returned handle or restore a mutation that lands late.
* Playwright's own timeout, when it rejects first, is reported as that deadline.
*/
export const withScenarioCommandDeadline = async <T>(
work: () => Promise<T>,
timeoutMs: number,
phase: Extract<CdpCommandDeadlinePhase, "handshake" | "command">,
method?: string,
): Promise<T> => {
let operation: Promise<T>;
try {
operation = work();
} catch (cause: unknown) {
operation = Promise.reject(cause);
}
let timer: ReturnType<typeof setTimeout> | undefined;
let settled = false;
const recovery = (): {
readonly userMessage: string;
readonly remediationAction: string;
} => scenarioCommandDeadlineRecovery(timeoutMs, phase, method);
try {
return await new Promise<T>((resolve, reject) => {
const fail = (cause: unknown): void => {
if (settled) return;
settled = true;
reject(cause);
};
timer = setTimeout(() => {
const deadline = recovery();
const timeout = new BrowserObservationError(OPERATION, "timeout", {
detail: deadline.userMessage,
remediationAction: deadline.remediationAction,
});
pendingScenarioCommands.set(timeout, operation);
fail(timeout);
}, timeoutMs);
operation.then(
(value) => {
if (settled) return;
settled = true;
resolve(value);
},
(cause: unknown) => {
if (playwrightTimedOut(cause)) {
const deadline = recovery();
fail(
new BrowserObservationError(OPERATION, "timeout", {
detail: deadline.userMessage,
remediationAction: deadline.remediationAction,
cause,
}),
);
return;
}
fail(cause);
},
);
});
} finally {
if (timer !== undefined) clearTimeout(timer);
}
};
+69 -27
View File
@@ -14,6 +14,13 @@ import {
resolvedAnalysisError,
type AnalysisError,
} from "../domain/analysisErrorBase.js";
import { javaScriptRuntime } from "../domain/runtimeVersion.js";
import {
webBundleHeapExhaustedReason,
webBundleHeapExhaustedRemediation,
webBundleMemoryExhaustion,
} from "../domain/webBundleAnalysis.js";
import { javaScriptWorkerMemoryLimitBytes } from "../process/JavaScriptWorkerMemoryLimit.js";
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
import {
spawnOwnedProviderProcess,
@@ -88,7 +95,8 @@ type TestMode =
| "wrong-job-failure"
| "resource-failure"
| "final-frame-held-pipe"
| "zero-exit-held-pipe";
| "zero-exit-held-pipe"
| "heap-exit";
};
const protocolWorkerProgram = (
@@ -105,7 +113,8 @@ const protocolWorkerProgram = (
| "wrong-job-failure"
| "resource-failure"
| "final-frame-held-pipe"
| "zero-exit-held-pipe",
| "zero-exit-held-pipe"
| "heap-exit",
): string => `
import { createInterface } from "node:readline";
import { spawn } from "node:child_process";
@@ -116,41 +125,43 @@ const protocolWorkerProgram = (
input.once("line", (line) => {
const request = JSON.parse(line);
${
failure === "zero-exit-held-pipe"
? `const program = "setTimeout(() => process.exit(0), 300);";
failure === "heap-exit"
? `process.stderr.write("allocation failed\\n", () => process.exit(7));`
: failure === "zero-exit-held-pipe"
? `const program = "setTimeout(() => process.exit(0), 300);";
spawn(process.execPath, ["-e", program], { stdio: ["ignore", "inherit", "inherit"] });
process.exit(0);`
: failure === "exit-7"
? `process.stderr.write("controlled exit 7\\n", () => process.exit(7));`
: failure === "tail-exit"
? `const lateProgram = "setTimeout(() => { process.stderr.write('late tail marker'); setTimeout(() => process.exit(0), 500); }, 50);";
: failure === "exit-7"
? `process.stderr.write("controlled exit 7\\n", () => process.exit(7));`
: failure === "tail-exit"
? `const lateProgram = "setTimeout(() => { process.stderr.write('late tail marker'); setTimeout(() => process.exit(0), 500); }, 50);";
spawn(process.execPath, ["-e", lateProgram], { stdio: ["ignore", "ignore", "inherit"] });
process.stderr.write("early exit marker\\n", () => process.exit(7));`
: failure === "truncated-exit"
? `process.stderr.write("x".repeat(300 * 1024), () => process.exit(7));`
: failure === "abort-first-exit"
? `process.stderr.write("late natural exit 7\\n", () => process.exit(7));`
: failure === "malformed"
? `process.stdout.write("{not-json}\\n"); setInterval(() => {}, 1_000);`
: failure === "wrong-job-failure"
? `send({ kind: "failure", id: ${JSON.stringify(randomUUID())}, reason: "output", message: "failure for another request" });
: failure === "truncated-exit"
? `process.stderr.write("x".repeat(300 * 1024), () => process.exit(7));`
: failure === "abort-first-exit"
? `process.stderr.write("late natural exit 7\\n", () => process.exit(7));`
: failure === "malformed"
? `process.stdout.write("{not-json}\\n"); setInterval(() => {}, 1_000);`
: failure === "wrong-job-failure"
? `send({ kind: "failure", id: ${JSON.stringify(randomUUID())}, reason: "output", message: "failure for another request" });
setInterval(() => {}, 1_000);`
: failure === "resource-failure"
? `send({ kind: "failure", id: request.id, reason: "resource", message: "bounded result capacity" });
: failure === "resource-failure"
? `send({ kind: "failure", id: request.id, reason: "resource", message: "bounded result capacity" });
setInterval(() => {}, 1_000);`
: failure === "final-frame-held-pipe"
? `const frame = JSON.stringify({ kind: "failure", id: request.id, reason: "output", message: "final frame from descendant" });
: failure === "final-frame-held-pipe"
? `const frame = JSON.stringify({ kind: "failure", id: request.id, reason: "output", message: "final frame from descendant" });
const program = "setTimeout(() => { process.stdout.write(" + JSON.stringify(frame + "\\n") + "); setInterval(() => {}, 1000); }, 100);";
spawn(process.execPath, ["-e", program], { stdio: ["ignore", "inherit", "inherit"] });
process.exit(7);`
: failure === "late-failure"
? `process.stdout.write(JSON.stringify({ kind: "failure", id: request.id, reason: "output", message: "failure frame before abort" }) + "\\n");
: failure === "late-failure"
? `process.stdout.write(JSON.stringify({ kind: "failure", id: request.id, reason: "output", message: "failure frame before abort" }) + "\\n");
setInterval(() => {}, 1_000);`
: failure === "wrong-id"
? `send({ kind: "phase", id: ${JSON.stringify(randomUUID())}, phase: "analyze" });`
: failure === "oversized-control"
? `process.stdout.write("x".repeat(70 * 1024) + "\\n");`
: `send({ kind: "complete", id: request.id, result: { bytes: request.result_budget_bytes + 1, sha256: "${"0".repeat(64)}" } });`
: failure === "wrong-id"
? `send({ kind: "phase", id: ${JSON.stringify(randomUUID())}, phase: "analyze" });`
: failure === "oversized-control"
? `process.stdout.write("x".repeat(70 * 1024) + "\\n");`
: `send({ kind: "complete", id: request.id, result: { bytes: request.result_budget_bytes + 1, sha256: "${"0".repeat(64)}" } });`
}
});
`;
@@ -676,6 +687,37 @@ it("consumes a final frame before an inherited descendant pipe closes", async ()
}
});
it("projects a heap-marker exit with the runtime's exhausted resource", async () => {
const heapMb = 256;
const worker = new WebBundleAnalysisWorker(
heapMb,
launcherFor({ kind: "protocol", failure: "heap-exit" }),
);
try {
const error = failureOf(
await worker.analyze(makeScript("report heap exit"), 15_000),
);
expect(error).toBeInstanceOf(AnalysisResourceConstraintError);
const exhaustion = webBundleMemoryExhaustion({
runtime: javaScriptRuntime(process.versions).id,
heapMb,
memoryLimitReached: false,
processMemoryLimitBytes: javaScriptWorkerMemoryLimitBytes(heapMb),
});
expect(error.message).toContain(webBundleHeapExhaustedReason(exhaustion));
expect(error).toMatchObject({
remediationAction: webBundleHeapExhaustedRemediation(exhaustion),
reportedLimits: {
max_heap_mb: heapMb,
worker_process_memory_limit_bytes:
javaScriptWorkerMemoryLimitBytes(heapMb),
},
});
} finally {
await worker.close();
}
});
it("does not classify heap-marker stderr from a healthy worker as exhaustion", async () => {
const worker = new WebBundleAnalysisWorker(
128,
+115 -41
View File
@@ -41,6 +41,17 @@ import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
import { javaScriptWorkerMemoryLimitBytes } from "../process/JavaScriptWorkerMemoryLimit.js";
import {
DEFAULT_WEB_BUNDLE_HEAP_MB,
MAX_WEB_BUNDLE_HEAP_MB,
MIN_WEB_BUNDLE_HEAP_MB,
webBundleFixedByteBudget,
webBundleHeapExhaustedReason,
webBundleHeapExhaustedRemediation,
webBundleMemoryExhaustion,
webBundleResultAdmissionFailure,
webBundleScriptTimeoutRecovery,
} from "../domain/webBundleAnalysis.js";
import {
WEB_BUNDLE_WORKER_LIMITS,
webBundleWorkerMessageSchema,
@@ -50,6 +61,15 @@ import {
const OPERATION = "analyze_web_bundle";
const PROVIDER = "web-bundle-static-analysis";
const scriptTimeout = (
timeoutMs: number,
cause?: unknown,
): AnalysisTimeoutError =>
new AnalysisTimeoutError(OPERATION, timeoutMs, {
...(cause === undefined ? {} : { cause }),
...webBundleScriptTimeoutRecovery(timeoutMs),
});
const MIB = 1024 * 1024;
/** Source/result buffers, decoded strings, parsed objects and validation copies. */
const RESULT_EXPANSION = 16;
@@ -75,6 +95,7 @@ type WebBundleObservedFailure =
readonly kind: "exit";
readonly code: number | null;
readonly signal: NodeJS.Signals | null;
readonly memoryLimitReached: boolean;
readonly heapExhaustion: boolean;
};
@@ -130,7 +151,12 @@ export class WebBundleAnalysisWorker {
Buffer.byteLength(chunk) + Buffer.byteLength(this.#pending) >
WEB_BUNDLE_WORKER_LIMITS.protocolBytes
)
throw new RangeError("Web analyzer exceeded its control-frame budget");
throw new RangeError(
webBundleFixedByteBudget(
WEB_BUNDLE_WORKER_LIMITS.protocolBytes,
"control-frame budget",
),
);
this.#pending +=
typeof chunk === "string" ? chunk : this.#decoder.write(chunk);
for (;;) {
@@ -156,12 +182,16 @@ export class WebBundleAnalysisWorker {
};
constructor(
readonly heapMb = 1024,
readonly heapMb = DEFAULT_WEB_BUNDLE_HEAP_MB,
launcher: typeof spawnOwnedProviderProcess = spawnOwnedProviderProcess,
) {
if (!Number.isSafeInteger(heapMb) || heapMb < 128 || heapMb > 16_384)
if (
!Number.isSafeInteger(heapMb) ||
heapMb < MIN_WEB_BUNDLE_HEAP_MB ||
heapMb > MAX_WEB_BUNDLE_HEAP_MB
)
throw new RangeError(
"Web analyzer heap must be an integer from 128 through 16384 MiB",
`Web analyzer heap must be an integer from ${String(MIN_WEB_BUNDLE_HEAP_MB)} through ${String(MAX_WEB_BUNDLE_HEAP_MB)} MiB (max_heap_mb)`,
);
this.#runtime = new OwnedWorkerRuntime({
rootPrefix: "rea-web-bundle-analysis-",
@@ -182,12 +212,15 @@ export class WebBundleAnalysisWorker {
(event.code !== 0 || event.signal !== null)
) {
const stderr = event.snapshot.stderr.text;
const memoryLimitReached =
event.snapshot.memoryLimitReached === true;
this.#latchFailure({
kind: "exit",
code: event.code,
signal: event.signal,
memoryLimitReached,
heapExhaustion:
event.snapshot.memoryLimitReached === true ||
memoryLimitReached ||
/heap out of memory|heap limit|allocation failed/iu.test(
stderr,
),
@@ -202,16 +235,21 @@ export class WebBundleAnalysisWorker {
this.#wakeFrameWaiters("failure");
} else if (event.type === "output") {
const failure = this.#observedFailure;
if (
failure?.kind === "exit" &&
(this.#runtime.supervisor?.snapshot().memoryLimitReached ===
true ||
(event.stream === "stderr" &&
/heap out of memory|heap limit|allocation failed/iu.test(
this.#runtime.supervisor?.snapshot().stderr.text ?? "",
)))
)
this.#observedFailure = { ...failure, heapExhaustion: true };
if (failure?.kind !== "exit") return;
const memoryLimitReached =
this.#runtime.supervisor?.snapshot().memoryLimitReached === true;
const heapMarker =
event.stream === "stderr" &&
/heap out of memory|heap limit|allocation failed/iu.test(
this.#runtime.supervisor?.snapshot().stderr.text ?? "",
);
if (!memoryLimitReached && !heapMarker) return;
this.#observedFailure = {
...failure,
heapExhaustion: true,
memoryLimitReached:
failure.memoryLimitReached || memoryLimitReached,
};
}
},
},
@@ -257,27 +295,12 @@ export class WebBundleAnalysisWorker {
try {
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause: signal.reason });
const resultBudget = Math.min(
WEB_BUNDLE_WORKER_LIMITS.resultBytes - this.#retainedResultBytes,
Math.max(
0,
Math.floor(
(getHeapStatistics().heap_size_limit -
process.memoryUsage().heapUsed -
64 * MIB) /
RESULT_EXPANSION,
),
),
);
if (resultBudget <= 0) {
const admitted = this.#admittedResultBudget();
if (!admitted.ok) {
continuation = "result_capacity_exhausted";
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
"The host has no remaining reserved web-analysis result capacity",
{ result_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes },
);
throw admitted.error;
}
const resultBudget = admitted.bytes;
await prepareProcessOwnershipInspection(signal);
deadline = new ProviderStartupDeadline(timeoutMs, signal);
this.#activeDeadline = deadline;
@@ -383,7 +406,7 @@ export class WebBundleAnalysisWorker {
const cancellationCause = this.#completedReadCancellation(cause);
const interrupted =
deadline?.interruption === "timeout"
? new AnalysisTimeoutError(OPERATION, timeoutMs, { cause })
? scriptTimeout(timeoutMs, cause)
: cancellationCause !== undefined
? new AnalysisCancelledError(OPERATION, {
cause: cancellationCause.reason,
@@ -464,6 +487,41 @@ export class WebBundleAnalysisWorker {
return result.ok ? result : failed(result.error);
}
#admittedResultBudget():
| { readonly ok: true; readonly bytes: number }
| { readonly ok: false; readonly error: AnalysisResourceConstraintError } {
const fixedRemaining =
WEB_BUNDLE_WORKER_LIMITS.resultBytes - this.#retainedResultBytes;
const hostCapacity = Math.max(
0,
Math.floor(
(getHeapStatistics().heap_size_limit -
process.memoryUsage().heapUsed -
64 * MIB) /
RESULT_EXPANSION,
),
);
const bytes = Math.min(fixedRemaining, hostCapacity);
if (bytes > 0) return { ok: true, bytes };
const failure = webBundleResultAdmissionFailure({
fixedBudgetBytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes,
fixedRemainingBytes: fixedRemaining,
hostCapacityBytes: hostCapacity,
});
return {
ok: false,
error: new AnalysisResourceConstraintError(
OPERATION,
"memory",
failure.reason,
failure.reportedLimits,
failure.kind === "host-capacity"
? { remediationAction: failure.remediationAction }
: undefined,
),
};
}
async #writeSource(
path: string,
script: IncludedScript,
@@ -485,7 +543,10 @@ export class WebBundleAnalysisWorker {
throw new AnalysisResourceConstraintError(
OPERATION,
"transport",
"Captured script metadata and source exceed the worker transfer budget",
webBundleFixedByteBudget(
WEB_BUNDLE_WORKER_LIMITS.sourceBytes,
"worker transfer budget",
),
{ source_bytes: WEB_BUNDLE_WORKER_LIMITS.sourceBytes },
);
await handle.writeFile(chunk);
@@ -544,7 +605,7 @@ export class WebBundleAnalysisWorker {
): Promise<WebBundleWorkerMessage> {
for (;;) {
if (deadline.interruption === "timeout")
throw new AnalysisTimeoutError(OPERATION, deadline.timeoutMs);
throw scriptTimeout(deadline.timeoutMs);
if (deadline.interruption === "cancelled")
throw new AnalysisCancelledError(OPERATION, {
cause: deadline.signal.reason,
@@ -579,7 +640,7 @@ export class WebBundleAnalysisWorker {
const completedFrame = this.#frames.shift();
if (completedFrame !== undefined) return completedFrame;
throw deadline.interruption === "timeout"
? new AnalysisTimeoutError(OPERATION, deadline.timeoutMs)
? scriptTimeout(deadline.timeoutMs)
: new AnalysisCancelledError(OPERATION, {
cause: deadline.signal.reason,
});
@@ -671,18 +732,31 @@ export class WebBundleAnalysisWorker {
{ capturedOutput },
);
case "exit":
if (failure.heapExhaustion)
if (failure.heapExhaustion) {
const exhaustion = webBundleMemoryExhaustion({
runtime: javaScriptRuntime(process.versions).id,
heapMb: this.heapMb,
memoryLimitReached: failure.memoryLimitReached,
processMemoryLimitBytes: javaScriptWorkerMemoryLimitBytes(
this.heapMb,
),
});
return new AnalysisResourceConstraintError(
OPERATION,
"memory",
"Owned web analyzer exhausted its configured memory limit",
webBundleHeapExhaustedReason(exhaustion),
{
worker_heap_limit_mb: this.heapMb,
max_heap_mb: this.heapMb,
worker_process_memory_limit_bytes:
javaScriptWorkerMemoryLimitBytes(this.heapMb),
},
{ capturedOutput },
{
capturedOutput,
remediationAction: webBundleHeapExhaustedRemediation(exhaustion),
},
);
}
return new AnalysisOutputError(
OPERATION,
"Owned web analyzer exited before completing this script",
@@ -50,7 +50,14 @@ it("refuses before spawning or reading source when no host result capacity remai
const result = await worker.analyze(script, 10_000);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected result-capacity refusal");
expect(result.error.error).toBeInstanceOf(AnalysisResourceConstraintError);
const refusal = result.error.error;
expect(refusal).toBeInstanceOf(AnalysisResourceConstraintError);
if (!(refusal instanceof AnalysisResourceConstraintError))
throw new Error("Expected a result-capacity refusal");
expect(refusal.message).toContain("host result capacity 0 bytes");
expect(refusal.message).toContain("still has 8388608 bytes");
expect(refusal.message).not.toContain("has no further room");
expect(refusal.remediationAction).toContain("NODE_OPTIONS");
expect(result.error.continuation).toBe("result_capacity_exhausted");
expect(result.error.completed).toBeNull();
expect(sourceReads).toBe(0);
@@ -3,12 +3,17 @@ import { digestSchema } from "../domain/digests.js";
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
import { webTextArtifactSchema } from "../domain/webContentArtifact.js";
import {
DEFAULT_WEB_BUNDLE_HEAP_MB,
DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS,
DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS,
} from "../domain/webBundleAnalysis.js";
/** Independent per-script worker limits for source records and control frames. */
export const WEB_BUNDLE_WORKER_LIMITS = {
heapMb: 1024,
scriptTimeoutMs: 30_000,
totalTimeoutMs: 300_000,
heapMb: DEFAULT_WEB_BUNDLE_HEAP_MB,
scriptTimeoutMs: DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS,
totalTimeoutMs: DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS,
protocolBytes: 64 * 1024,
sourceBytes: 8 * WEB_RUNTIME_LIMITS.sourceBytes,
resultBytes: 8 * 1024 * 1024,
+50 -18
View File
@@ -33,6 +33,11 @@ import {
SourceMapFormatFailure,
type SourceMapLeaf,
} from "../javascript/sourceMaps/SourceMapFormat.js";
import {
noRuntimeOverrideSentence,
selectedBrowserDeadlineMs,
} from "../domain/browserDeadline.js";
import { DEFAULT_SOURCE_MAP_FETCH_TIMEOUT_MS } from "../domain/webBundleAnalysis.js";
import { WEB_SOURCE_MAP_LIMITS } from "../domain/webSourceLocation.js";
export interface WebSourceMapRequest {
@@ -53,7 +58,10 @@ interface SourceMapFetchHost {
// Bound a stalled server to 30 seconds and bound raw map input retained for
// parsing to 64 MiB per inspection. Both limits apply at the network boundary;
// hitting either produces an explicit fetch_failed item, never truncated data.
const SOURCE_MAP_FETCH_TIMEOUT_MS = 30_000;
const SOURCE_MAP_FETCH_TIMEOUT_MS = DEFAULT_SOURCE_MAP_FETCH_TIMEOUT_MS;
const sourceMapFetchDeadlineLimitation = (timeoutMs: number): string =>
`Source-map fetch exceeded its ${String(timeoutMs)} ms source_map_fetch_timeout_ms deadline (fetch_failed). This is an item limitation, not a thrown analysis timeout, and not the source-map decode deadline. ${noRuntimeOverrideSentence(`The ${String(SOURCE_MAP_RESPONSE_BYTES)}-byte response budget stays fixed.`, "source_map_fetch_timeout_ms")}`;
const SOURCE_MAP_RESPONSE_BYTES = 64 * 1024 * 1024;
const SOURCE_MAP_OUTPUT_BYTES = WEB_SOURCE_MAP_LIMITS.outputBytes;
const SOURCE_MAP_FAILURE_LIMITATION_RESERVE = 512;
@@ -68,6 +76,7 @@ type ParsedSourceMapItem = Extract<
interface SourceMapDecodeContext {
readonly signal: AbortSignal | undefined;
readonly deadlineAt: number;
readonly deadlineLimitation: string;
readonly budget: {
records: number;
expandedBytes: number;
@@ -92,18 +101,29 @@ export const fetchWebSourceMaps = async (
});
const items: SourceMapItem[] = [];
const operationController = new AbortController();
const fetchTimeoutMs =
host.timeoutMs ??
selectedBrowserDeadlineMs(
input.source_map_fetch_timeout_ms,
SOURCE_MAP_FETCH_TIMEOUT_MS,
);
const fetchDeadlineLimitation =
sourceMapFetchDeadlineLimitation(fetchTimeoutMs);
const timeout = setTimeout(
() => operationController.abort(new SourceMapDeadlineError()),
host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS,
() =>
operationController.abort(
new SourceMapDeadlineError(fetchDeadlineLimitation),
),
fetchTimeoutMs,
);
const abortFromCaller = (): void => operationController.abort(signal?.reason);
signal?.addEventListener("abort", abortFromCaller, { once: true });
const operationSignal = operationController.signal;
const deadlineAt =
performance.now() + (host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS);
const deadlineAt = performance.now() + fetchTimeoutMs;
const budget = {
retainedBytes: 0,
deadlineAt,
deadlineLimitation: fetchDeadlineLimitation,
decodedRecords: {
records: 0,
expandedBytes: SOURCE_MAP_COLLECTION_ENVELOPE_RESERVE,
@@ -126,11 +146,7 @@ export const fetchWebSourceMaps = async (
budget.decodedRecords.expandedBytes += reservation;
if (operationSignal.aborted || performance.now() >= deadlineAt) {
items.push(
emptySourceMapItem(
request,
"fetch_failed",
"Source-map fetching exceeded its operation deadline.",
),
emptySourceMapItem(request, "fetch_failed", fetchDeadlineLimitation),
);
continue;
}
@@ -202,15 +218,17 @@ const sourceMapsStatus = (
};
class SourceMapDeadlineError extends Error {
constructor() {
super("Source-map fetching exceeded its operation deadline.");
constructor(message: string) {
super(message);
this.name = "SourceMapDeadlineError";
}
}
class SourceMapSizeLimitError extends Error {
constructor() {
super("Source-map response exceeded the retained-byte budget.");
super(
`Source-map response exceeded the ${String(SOURCE_MAP_RESPONSE_BYTES)}-byte retained budget. ${noRuntimeOverrideSentence("That response budget stays fixed.", "source_map_fetch_timeout_ms")}`,
);
this.name = "SourceMapSizeLimitError";
}
}
@@ -239,6 +257,7 @@ const fetchOne = async (
budget: {
retainedBytes: number;
deadlineAt: number;
deadlineLimitation: string;
decodedRecords: {
records: number;
expandedBytes: number;
@@ -303,7 +322,12 @@ const fetchOne = async (
fetchedUrl,
);
}
checkOperation(undefined, signal, budget.deadlineAt);
checkOperation(
undefined,
signal,
budget.deadlineAt,
budget.deadlineLimitation,
);
return normalizeSourceMap(
request,
await readBoundedText(
@@ -316,6 +340,7 @@ const fetchOne = async (
{
signal,
deadlineAt: budget.deadlineAt,
deadlineLimitation: budget.deadlineLimitation,
budget: budget.decodedRecords,
},
);
@@ -550,7 +575,12 @@ const normalizeSourceMap = (
const retainedBytesBeforeMap = context.budget.expandedBytes;
try {
const check = (): void =>
checkOperation(undefined, context.signal, context.deadlineAt);
checkOperation(
undefined,
context.signal,
context.deadlineAt,
context.deadlineLimitation,
);
const inspected = inspectSourceMapValue(parsedJson.value, {
profile: "browser-collection",
budget: context.budget,
@@ -818,10 +848,12 @@ const checkOperation = (
callerSignal: AbortSignal | undefined,
operationSignal: AbortSignal | undefined,
deadlineAt: number,
deadlineLimitation: string,
): void => {
if (callerSignal?.aborted === true) throw callerSignal.reason;
if (operationSignal?.aborted === true) throw operationSignal.reason;
if (performance.now() >= deadlineAt) throw new SourceMapDeadlineError();
if (performance.now() >= deadlineAt)
throw new SourceMapDeadlineError(deadlineLimitation);
};
const approvedUrl = (
@@ -930,7 +962,7 @@ const consumeExpandedBytes = (
const expandedOutputFailure = (): SourceMapFormatFailure =>
new SourceMapFormatFailure(
"limit",
`Expanded source-map evidence exceeds the ${WEB_SOURCE_MAP_LIMITS.outputBytes / (1024 * 1024)} MiB public representation budget.`,
`Expanded source-map evidence exceeds the ${String(WEB_SOURCE_MAP_LIMITS.outputBytes)}-byte public representation budget. ${noRuntimeOverrideSentence("That representation budget stays fixed.", "source_map_fetch_timeout_ms")}`,
);
const sourceMapOutputError = (
@@ -948,7 +980,7 @@ const sourceMapOutputError = (
const resolvedSourceLimitFailure = (): SourceMapFormatFailure =>
new SourceMapFormatFailure(
"limit",
`Resolved source-map identities exceed the ${SOURCE_MAP_RESPONSE_BYTES / (1024 * 1024)} MiB decoder representation budget.`,
`Resolved source-map identities exceed the ${String(SOURCE_MAP_RESPONSE_BYTES / (1024 * 1024))} MiB decoder representation budget. ${noRuntimeOverrideSentence("That representation budget stays fixed.", "source_map_fetch_timeout_ms")}`,
);
const jsonBytes = (value: string): number => {
+116 -77
View File
@@ -5,6 +5,11 @@ import { BrowserObservationError } from "../../domain/browserObservationError.js
import type { BrowserObservationOperation } from "../../domain/browserObservationErrors.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import {
cdpCleanupGraceExpired,
cdpCommandDeadlineRecovery,
selectedCdpCommandTimeoutMs,
} from "../../domain/browserDeadline.js";
import { CdpTargetSessionOwner } from "../CdpTargetSessionOwner.js";
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
import {
@@ -28,6 +33,7 @@ type Scope = {
readonly cdp_endpoint: string;
readonly target_id: string;
readonly allowed_origins: readonly string[];
readonly command_timeout_ms?: number | undefined;
};
/** Owned transport and instrumentation; the selected browser/page remain externally owned. */
@@ -44,6 +50,7 @@ export class CdpRuntimeSession {
readonly operation: BrowserObservationOperation,
readonly options: ExecutionOptions,
readonly owner: CdpTargetSessionOwner,
readonly commandTimeoutMs: number,
) {}
/** Acquire a validated target; failed setup releases its owned socket. */
@@ -53,75 +60,87 @@ export class CdpRuntimeSession {
options: ExecutionOptions,
owner: CdpTargetSessionOwner,
): Promise<CdpRuntimeSession> {
return runtimeDeadline(operation, options.signal, async (signal) => {
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
operation,
signal,
);
const { target, allowedOrigins } = authorizeCdpTarget(
discovery,
input,
operation,
);
const transport = await openCdpTargetSession(
discovery,
target,
operation,
signal,
{ maxPayloadBytes: WEB_RUNTIME_LIMITS.protocolBytes },
owner,
);
try {
const tree = runtimeFrameTreeSchema.parse(
await authorizedMainFrame({
...transport,
allowedOrigins,
signal,
operation,
}),
);
const frame = tree.frameTree.frame;
const url = sanitizeBrowserUrl(frame.url);
if (url.origin === null)
throw new BrowserObservationError(operation, "target_not_allowed");
return new CdpRuntimeSession(
transport,
discovery,
{
target_id: target.id,
initial_url: url.url,
origin: url.origin,
frame_id: frame.id,
loader_id: frame.loaderId ?? null,
},
allowedOrigins,
const commandTimeoutMs = selectedCdpCommandTimeoutMs(
input.command_timeout_ms,
);
// HTTP discovery keeps its own fixed deadline. The WebSocket handshake
// and a browser-level Target.attachToTarget each get commandTimeoutMs.
// One deadline around both would expire a valid attach after a slow handshake.
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
operation,
options.signal,
);
const { target, allowedOrigins } = authorizeCdpTarget(
discovery,
input,
operation,
);
const transport = await openCdpTargetSession(
discovery,
target,
operation,
options.signal,
{
maxPayloadBytes: WEB_RUNTIME_LIMITS.protocolBytes,
commandTimeoutMs,
},
owner,
);
try {
const tree = runtimeFrameTreeSchema.parse(
await runtimeDeadline(
operation,
options,
owner,
);
} catch (cause: unknown) {
try {
await owner.finish(transport.connection);
} catch (cleanupCause: unknown) {
if (
cause instanceof AnalysisError &&
cleanupCause instanceof AnalysisError
)
throw analysisErrorWithCleanupFailure(
cause,
cleanupCause,
options.signal,
(signal) =>
authorizedMainFrame({
...transport,
allowedOrigins,
signal,
operation,
);
throw new AggregateError(
[cause, cleanupCause],
`CDP runtime setup ${operation} failed and cleanup remains incomplete.`,
{ cause },
);
}
throw cause;
}),
commandTimeoutMs,
"command",
"Page.getFrameTree",
),
);
const frame = tree.frameTree.frame;
const url = sanitizeBrowserUrl(frame.url);
if (url.origin === null)
throw new BrowserObservationError(operation, "target_not_allowed");
return new CdpRuntimeSession(
transport,
discovery,
{
target_id: target.id,
initial_url: url.url,
origin: url.origin,
frame_id: frame.id,
loader_id: frame.loaderId ?? null,
},
allowedOrigins,
operation,
options,
owner,
commandTimeoutMs,
);
} catch (cause: unknown) {
try {
await owner.finish(transport.connection);
} catch (cleanupCause: unknown) {
if (
cause instanceof AnalysisError &&
cleanupCause instanceof AnalysisError
)
throw analysisErrorWithCleanupFailure(cause, cleanupCause, operation);
throw new AggregateError(
[cause, cleanupCause],
`CDP runtime setup ${operation} failed and cleanup remains incomplete.`,
{ cause },
);
}
});
throw cause;
}
}
/** Bound each command separately from the caller-selected observation duration. */
@@ -143,13 +162,22 @@ export class CdpRuntimeSession {
this.transport.sessionId,
signal,
),
this.commandTimeoutMs,
"command",
method,
);
} catch (cause: unknown) {
if (this.#completion?.signal.aborted && !this.options.signal?.aborted)
if (this.#completion?.signal.aborted && !this.options.signal?.aborted) {
const recovery = cdpCommandDeadlineRecovery(
this.commandTimeoutMs,
"source-join",
);
throw new AnalysisTimeoutError(
this.operation,
WEB_RUNTIME_LIMITS.commandTimeoutMs,
this.commandTimeoutMs,
recovery,
);
}
throw cause;
}
}
@@ -160,7 +188,7 @@ export class CdpRuntimeSession {
this.#completion = new AbortController();
this.#completionTimer = setTimeout(
() => this.#completion?.abort(),
WEB_RUNTIME_LIMITS.commandTimeoutMs,
this.commandTimeoutMs,
);
}
@@ -202,6 +230,11 @@ export class CdpRuntimeSession {
() => controller.abort(),
WEB_RUNTIME_LIMITS.cleanupTimeoutMs,
);
// Switch before the first restoration send. A short command_timeout_ms
// would otherwise expire the connection timer and report cleanup_failed.
this.transport.connection.beginCleanupGrace(
WEB_RUNTIME_LIMITS.cleanupTimeoutMs,
);
const failures: string[] = [];
let transportFailure: AnalysisError | undefined;
const send = async (
@@ -244,6 +277,7 @@ export class CdpRuntimeSession {
}
} finally {
clearTimeout(timer);
if (controller.signal.aborted) failures.push(cdpCleanupGraceExpired());
try {
await this.owner.finish(this.transport.connection);
} catch (cause: unknown) {
@@ -290,12 +324,12 @@ export const runtimeDeadline = async <T>(
operation: BrowserObservationOperation,
signal: AbortSignal | undefined,
run: (signal: AbortSignal) => Promise<T>,
timeoutMs: number = selectedCdpCommandTimeoutMs(undefined),
phase: "handshake" | "command" = "command",
method?: string,
): Promise<T> => {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
WEB_RUNTIME_LIMITS.commandTimeoutMs,
);
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await run(
signal === undefined
@@ -303,11 +337,16 @@ export const runtimeDeadline = async <T>(
: AbortSignal.any([signal, controller.signal]),
);
} catch (cause: unknown) {
if (controller.signal.aborted && signal?.aborted !== true)
throw new AnalysisTimeoutError(
operation,
WEB_RUNTIME_LIMITS.commandTimeoutMs,
if (controller.signal.aborted && signal?.aborted !== true) {
if (cause instanceof AnalysisError && cause.userMessage !== undefined)
throw cause;
const recovery = cdpCommandDeadlineRecovery(
timeoutMs,
phase,
method ?? (phase === "handshake" ? undefined : "session setup"),
);
throw new AnalysisTimeoutError(operation, timeoutMs, recovery);
}
throw cause;
} finally {
clearTimeout(timer);
+10 -3
View File
@@ -17,6 +17,8 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import {
WEB_NETWORK_CAPTURE_LIMITS,
selectedWebNetworkCaptureTimeoutMs,
webNetworkCaptureTimeoutRecovery,
type InspectWebNetworkCaptureInput,
} from "../../domain/webNetworkCapture.js";
import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
@@ -75,11 +77,16 @@ export const historicalCaptureFailure = (
: undefined
: cause.cancellation.reason,
});
if (cause.reason === "timeout")
if (cause.reason === "timeout") {
const timeoutMs = selectedWebNetworkCaptureTimeoutMs(
input.capture_timeout_ms,
);
return new AnalysisTimeoutError(
OPERATION,
WEB_NETWORK_CAPTURE_LIMITS.timeoutMs,
timeoutMs,
webNetworkCaptureTimeoutRecovery(timeoutMs),
);
}
if (cause.reason === "output-limit")
return new AnalysisOutputError(OPERATION, cause.message);
// The job's own notification is runtime-independent; Bun's exit status
@@ -140,7 +147,7 @@ export const historicalCaptureFailure = (
reason: cause.reason === "limit" ? "out_of_range" : "invalid_format",
message:
cause.reason === "limit"
? `${cause.message}. Select a ${sourceLabel} within the ${WEB_NETWORK_CAPTURE_LIMITS.inputBytes}-byte input budget.`
? `${cause.message}. Select a ${sourceLabel} within the ${String(WEB_NETWORK_CAPTURE_LIMITS.inputBytes)}-byte input budget. This release has no runtime override. capture_timeout_ms does not raise it.`
: cause.message,
...(cause.reason === "limit"
? {
+1 -1
View File
@@ -33,7 +33,7 @@ export const parseCaptureJson = (
if (frames.length > WEB_NETWORK_CAPTURE_LIMITS.depth)
throw new CaptureFormatError(
"input-limit",
`${format} exceeds the 64-level complete-evidence nesting budget.`,
`${format} exceeds the 64-level complete-evidence nesting budget. This release has no runtime override.`,
valuePointer(),
);
};
+1 -1
View File
@@ -132,7 +132,7 @@ const projectHar = (
if (depth > WEB_NETWORK_CAPTURE_LIMITS.depth)
throw new CaptureFormatError(
"input-limit",
"HAR exceeds the 64-level complete-evidence nesting budget.",
"HAR exceeds the 64-level complete-evidence nesting budget. This release has no runtime override.",
pointer,
);
if (
@@ -42,6 +42,7 @@ import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorClean
import { err, ok, type Result } from "../../domain/result.js";
import {
WEB_NETWORK_CAPTURE_LIMITS,
selectedWebNetworkCaptureTimeoutMs,
webNetworkCapturePayloadSchema,
type InspectWebNetworkCaptureInput,
type WebNetworkCapture,
@@ -175,7 +176,9 @@ export class HistoricalCaptureDecoder {
hostEnvironment: environment,
},
{
timeoutMs: WEB_NETWORK_CAPTURE_LIMITS.timeoutMs,
timeoutMs: selectedWebNetworkCaptureTimeoutMs(
input.capture_timeout_ms,
),
diagnosticBytes: 1024 * 1024,
},
options?.signal === undefined ? {} : { signal: options.signal },
@@ -285,7 +288,7 @@ export class HistoricalCaptureDecoder {
"This is retained producer evidence, without live browser transaction IDs, scenario provenance, execution attribution or deployment authenticity. Recorded URLs are never fetched.",
"Every record is decoded before selection; no partial success is returned on malformed input or a resource limit.",
"Known authentication headers/cookies and explicitly marked literal values are excluded. Unknown extension names do not establish sensitivity.",
`A 32 MiB input, 96 MiB reply and 64-level nesting budget bound complete evidence. ${nodeMemoryLimitation()}; mitmproxy decoding uses a 768 MiB Linux address-space limit. Each owned command has a 30-second deadline with independent cleanup.`,
`Complete evidence is bounded by a ${String(WEB_NETWORK_CAPTURE_LIMITS.inputBytes)}-byte input budget, a ${String(WEB_NETWORK_CAPTURE_LIMITS.outputBytes)}-byte reply budget, and a ${String(WEB_NETWORK_CAPTURE_LIMITS.depth)}-level nesting cap. Those have no runtime override. capture_timeout_ms is ${String(selectedWebNetworkCaptureTimeoutMs(input.capture_timeout_ms))} ms (omitted means ${String(WEB_NETWORK_CAPTURE_LIMITS.timeoutMs)}). ${nodeMemoryLimitation()}; mitmproxy decoding uses a 768 MiB Linux address-space limit.`,
],
};
if (input.format === "qlog") {
+2 -1
View File
@@ -62,7 +62,8 @@ const main = async (): Promise<void> => {
text = JSON.stringify({
ok: false,
reason: "limit",
message: "Capture reply exceeds the complete-evidence output budget.",
message:
"Capture reply exceeds the complete-evidence output budget. This release has no runtime override.",
pointer: "",
});
await writeFile(request.reply_path, text, { flag: "wx", mode: 0o600 });
+1 -1
View File
@@ -53,7 +53,7 @@ export const decodeSazCapture = async (
if (declaredBytes > WEB_NETWORK_CAPTURE_LIMITS.inputBytes)
throw new CaptureFormatError(
"expanded-limit",
"SAZ expanded members exceed the 32 MiB complete-evidence budget.",
"SAZ expanded members exceed the 32 MiB complete-evidence budget. This release has no runtime override.",
);
inventory.push({
path: entry.path,
+1 -1
View File
@@ -64,7 +64,7 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => {
if (depth > WEB_NETWORK_CAPTURE_LIMITS.depth)
throw new CaptureFormatError(
"input-limit",
"SAZ metadata exceeds the complete-evidence nesting budget.",
"SAZ metadata exceeds the complete-evidence nesting budget. This release has no runtime override.",
);
const children: JsonValue[] = [];
for (let child = node.firstChild; child !== null; child = child.nextSibling)
+1 -1
View File
@@ -30,7 +30,7 @@ export const projectSazRecord = (
if (depth > WEB_NETWORK_CAPTURE_LIMITS.depth)
throw new CaptureFormatError(
"input-limit",
"SAZ projection exceeds the complete-evidence nesting budget.",
"SAZ projection exceeds the complete-evidence nesting budget. This release has no runtime override.",
pointer,
);
if (parent !== undefined && redactor.harCredential(pointer, parent))
+6 -1
View File
@@ -1,7 +1,10 @@
import { Cli, z } from "incur";
import { browserContext, browserCliError } from "../cliBrowserContext.js";
import { browserScopeOptions } from "../cliObservationOptions.js";
import {
browserScopeOptions,
selectedCommandTimeout,
} from "../cliObservationOptions.js";
import {
captureWebScreenshot,
compareWebCaptureEvidence,
@@ -71,6 +74,7 @@ const registerWebMcp = (
allowed_origins: options.allowedOrigins,
target_id: args.targetId,
observation_ms: options.observationMs,
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = discoverWebMcpToolsInputSchema.safeParse(input);
if (!parsed.success)
@@ -182,6 +186,7 @@ const registerScreenshot = (
cdp_endpoint: args.endpoint,
allowed_origins: options.allowedOrigins,
target_id: args.targetId,
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = captureWebScreenshotInputSchema.safeParse(input);
if (!parsed.success)
+14 -1
View File
@@ -21,6 +21,7 @@ import {
browserPageInspectionOptions,
browserScopeOptions,
observationDuration,
selectedCommandTimeout,
} from "../cliObservationOptions.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
@@ -48,7 +49,9 @@ const registerTargetList = (
.string()
.describe("Loopback CDP HTTP endpoint or browser WebSocket URL"),
}),
options: z.object({ ...browserScopeOptions }),
options: z.object({
allowedOrigins: browserScopeOptions.allowedOrigins,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, "list-browser-targets", async () => {
@@ -112,6 +115,7 @@ const registerPageInspection = (
include_script_sources: options.includeScriptSources,
include_storage_keys: options.includeStorageKeys,
include_storage_fingerprints: options.includeStorageFingerprints,
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = inspectWebPageInputSchema.safeParse(input);
if (!parsed.success)
@@ -162,6 +166,8 @@ const registerBundleAnalysis = (
analysisTimeoutMs: analyzeWebBundleInputSchema.shape.analysis_timeout_ms,
totalAnalysisTimeoutMs:
analyzeWebBundleInputSchema.shape.total_analysis_timeout_ms,
sourceMapFetchTimeoutMs:
analyzeWebBundleInputSchema.shape.source_map_fetch_timeout_ms,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
@@ -183,6 +189,12 @@ const registerBundleAnalysis = (
...(options.totalAnalysisTimeoutMs === undefined
? {}
: { total_analysis_timeout_ms: options.totalAnalysisTimeoutMs }),
...(options.sourceMapFetchTimeoutMs === undefined
? {}
: {
source_map_fetch_timeout_ms: options.sourceMapFetchTimeoutMs,
}),
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = analyzeWebBundleInputSchema.safeParse(input);
if (!parsed.success)
@@ -234,6 +246,7 @@ const registerObservationSession = (
allowed_origins: options.allowedOrigins,
target_id: args.targetId,
observation_ms: options.observationMs,
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = observeWebSessionInputSchema.safeParse(input);
if (!parsed.success)
+2
View File
@@ -19,6 +19,7 @@ import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js";
import {
electronPageInspectionOptions,
javascriptApplicationOptions,
selectedCommandTimeout,
} from "../cliObservationOptions.js";
import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js";
import { withCommandCancellation } from "./commandCancellation.js";
@@ -266,6 +267,7 @@ const registerElectronPageInspection = (
target_id: args.targetId,
observation_ms: options.observationMs,
include_script_sources: options.includeScriptSources,
...selectedCommandTimeout(options.commandTimeoutMs),
};
const parsed = inspectElectronPageInputSchema.safeParse(input);
if (!parsed.success)
+9 -1
View File
@@ -1,5 +1,8 @@
import { z } from "incur";
import { WEB_NETWORK_CAPTURE_FORMATS } from "../domain/webNetworkCapture.js";
import {
WEB_NETWORK_CAPTURE_FORMATS,
inspectWebNetworkCaptureInputSchema,
} from "../domain/webNetworkCapture.js";
import { createWebNetworkCaptureService } from "../composition/webNetworkCaptures.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
@@ -57,6 +60,8 @@ export const registerWebNetworkCaptureCommands = (
.describe(
"Literal string values explicitly marked sensitive; repeat as needed",
),
captureTimeoutMs:
inspectWebNetworkCaptureInputSchema.shape.capture_timeout_ms,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
@@ -103,6 +108,9 @@ export const registerWebNetworkCaptureCommands = (
...(options.record === undefined
? {}
: { record_ordinals: options.record }),
...(options.captureTimeoutMs === undefined
? {}
: { capture_timeout_ms: options.captureTimeoutMs }),
},
{ signal },
),
+3
View File
@@ -4,6 +4,7 @@ import { browserCliError } from "../cliBrowserContext.js";
import {
browserScopeOptions,
observationDuration,
selectedCommandTimeout,
} from "../cliObservationOptions.js";
import { logCliCommand } from "../cliLogging.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
@@ -49,6 +50,7 @@ export const registerWebRuntimeCommands = (
target_id: args.targetId,
allowed_origins: options.allowedOrigins,
observation_ms: options.observationMs,
...selectedCommandTimeout(options.commandTimeoutMs),
},
{ progress: runtimeProgress, signal },
),
@@ -89,6 +91,7 @@ export const registerWebRuntimeCommands = (
target_id: args.targetId,
selector: args.selector,
allowed_origins: options.allowedOrigins,
...selectedCommandTimeout(options.commandTimeoutMs),
},
{ signal },
),
+8 -1
View File
@@ -1,5 +1,6 @@
import { z } from "incur";
import { createWebSourceLocationService } from "../composition/webSourceLocations.js";
import { webSourceLocationInputSchema } from "../domain/webSourceLocation.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "pino";
@@ -34,7 +35,10 @@ export const registerWebSourceLocationCommands = (
"Zero-based UTF-16 generated column",
),
}),
run: ({ args }) =>
options: z.object({
decodeTimeoutMs: webSourceLocationInputSchema.shape.decode_timeout_ms,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.traceWebSourceLocation, () =>
finishAnalysisCommand(
@@ -48,6 +52,9 @@ export const registerWebSourceLocationCommands = (
url: args.sourceMapUrl,
},
generated_position: { line: args.line, column: args.column },
...(options.decodeTimeoutMs === undefined
? {}
: { decode_timeout_ms: options.decodeTimeoutMs }),
},
{ signal },
),
+11
View File
@@ -1,4 +1,5 @@
import { z } from "incur";
import { webRuntimeCommandTimeoutSchema } from "./domain/browserDeadline.js";
import { javaScriptAnalysisResourceControlsSchema } from "./domain/javascript/javascriptAnalysisResourceControls.js";
import { javaScriptSourcePathsSchema } from "./domain/javascript/javascriptSourceSelection.js";
@@ -17,8 +18,17 @@ export const browserScopeOptions = {
.array(z.string().min(1))
.optional()
.describe("Optional exact-origin filter"),
commandTimeoutMs: webRuntimeCommandTimeoutSchema,
};
/** Keep an omitted command deadline absent so Evidence records the caller selection only. */
export const selectedCommandTimeout = (
commandTimeoutMs: number | undefined,
): { readonly command_timeout_ms?: number } =>
commandTimeoutMs === undefined
? {}
: { command_timeout_ms: commandTimeoutMs };
export const browserPageInspectionOptions = z.object({
...browserScopeOptions,
observationMs: observationDuration(500),
@@ -58,6 +68,7 @@ export const electronPageInspectionOptions = z.object({
.boolean()
.default(false)
.describe("Include JavaScript source text"),
commandTimeoutMs: webRuntimeCommandTimeoutSchema,
});
export const javascriptApplicationOptions = z.object({
+1 -1
View File
@@ -139,7 +139,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.",
"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.",
kind: "browser-provider",
inputSchema: listBrowserTargetsInputSchema,
outputSchema: listOutputSchema,
+5
View File
@@ -49,6 +49,11 @@ export const analysisErrorRemediationAction = (
error.remediationAction !== undefined
)
return error.remediationAction;
if (
error instanceof BrowserObservationError &&
error.remediationAction !== undefined
)
return error.remediationAction;
if (
error instanceof BrowserObservationError &&
error.reason === "service_worker_blocked"
+138
View File
@@ -0,0 +1,138 @@
import { z } from "zod";
import type { BrowserObservationOperation } from "./browserObservationErrors.js";
/** Largest delay supported without overflowing Node timers. */
export const MAX_BROWSER_DEADLINE_MS = 2_147_483_647;
/**
* CDP command and WebSocket handshake deadline. Discovery and cleanup grace
* are separate phases and stay fixed.
*/
export const DEFAULT_CDP_COMMAND_TIMEOUT_MS = 20_000;
/** Per-response deadline for loopback CDP discovery documents. */
export const CDP_DISCOVERY_RESPONSE_TIMEOUT_MS = 10_000;
/** Cleanup grace after a CDP runtime command. It does not extend the command deadline. */
export const CDP_CLEANUP_GRACE_MS = 5_000;
/** Optional caller deadline. An omitted field stays absent in Evidence. */
export const optionalBrowserDeadlineMs = (description: string) =>
z
.number()
.int()
.min(1)
.max(MAX_BROWSER_DEADLINE_MS)
.optional()
.describe(description);
export const selectedBrowserDeadlineMs = (
selected: number | undefined,
defaultMs: number,
): number => selected ?? defaultMs;
export const browserDeadlineIncrease = (
key: string,
timeoutMs: number,
defaultMs: number,
cli: string,
): string =>
timeoutMs < MAX_BROWSER_DEADLINE_MS
? `Increase ${key} above ${String(timeoutMs)} (integer milliseconds from 1 to ${String(MAX_BROWSER_DEADLINE_MS)}; omitted means ${String(defaultMs)}), or use ${cli} on the CLI.`
: `${key} is already at the maximum ${String(MAX_BROWSER_DEADLINE_MS)} ms; do not increase it further.`;
export const browserDeadlineRetry =
"Applies on the next call. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.";
/** Fixed allowance the caller cannot raise through a sibling deadline. */
export const noRuntimeOverrideSentence = (
allowance: string,
unrelatedControl?: string,
): string =>
`${allowance} This release has no runtime override.${
unrelatedControl === undefined
? ""
: ` ${unrelatedControl} does not raise it.`
}`;
/** 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.`,
);
export type CdpCommandDeadlinePhase = "handshake" | "command" | "source-join";
/**
* Operations whose public input accepts command_timeout_ms. Other CDP callers
* keep the same numeric deadline and must not advertise a setting they lack.
*/
const COMMAND_TIMEOUT_OPERATIONS = new Set<BrowserObservationOperation>([
"inspect_web_page",
"analyze_web_bundle",
"observe_web_session",
"observe_web_execution",
"inspect_web_event_listeners",
"discover_webmcp_tools",
"capture_web_screenshot",
"inspect_electron_page",
"capture_browser_scenario",
]);
export const cdpCommandTimeoutIsSelectable = (
operation: BrowserObservationOperation,
): boolean => COMMAND_TIMEOUT_OPERATIONS.has(operation);
/** Name command_timeout_ms as an unrelated control only when that operation accepts it. */
export const cdpCommandTimeoutUnrelatedControl = (
operation: BrowserObservationOperation,
): string | undefined =>
cdpCommandTimeoutIsSelectable(operation) ? "command_timeout_ms" : undefined;
const cdpDeadlineSubject = (
timeoutMs: number,
phase: CdpCommandDeadlinePhase,
method: string | undefined,
selectable: boolean,
): string => {
const budget = selectable
? `${String(timeoutMs)} ms command_timeout_ms`
: `fixed ${String(timeoutMs)} ms`;
if (phase === "handshake")
return `CDP WebSocket handshake exceeded its ${budget} deadline (deadlineExpired).`;
if (phase === "source-join")
return `The inspection/source-join phase exceeded its ${budget} deadline (deadlineExpired).`;
return `CDP command ${method ?? "request"} exceeded its ${budget} response deadline (deadlineExpired).`;
};
/** Name the CDP deadline, and name command_timeout_ms only when the operation accepts it. */
export const cdpCommandDeadlineRecovery = (
timeoutMs: number,
phase: CdpCommandDeadlinePhase,
method?: string,
selectable = true,
): { readonly userMessage: string; readonly remediationAction: string } => {
const subject = cdpDeadlineSubject(timeoutMs, phase, method, selectable);
if (!selectable) {
const fixed = noRuntimeOverrideSentence(
`${subject} This is not the caller observation window and not the ${String(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)} ms CDP discovery deadline.`,
);
return { userMessage: fixed, remediationAction: fixed };
}
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.`,
};
};
/** Cleanup grace is a fixed phase after the command deadline. */
export const cdpCleanupGraceExpired = (): string =>
noRuntimeOverrideSentence(
`CDP runtime cleanup exceeded its ${String(CDP_CLEANUP_GRACE_MS)} ms cleanup grace.`,
"command_timeout_ms",
);
export const selectedCdpCommandTimeoutMs = (
selected: number | undefined,
): number =>
selectedBrowserDeadlineMs(selected, DEFAULT_CDP_COMMAND_TIMEOUT_MS);
+7 -1
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
import { urlDeclaresQueryOrFragment } from "./urlComponents.js";
import {
parseRecordedTransportUrl,
@@ -169,7 +170,11 @@ const browserInput = {
allowed_origins: browserAllowedOriginsSchema,
};
/** Public input for complete discovery, optionally filtered by exact origin. */
/**
* 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.
*/
export const listBrowserTargetsInputSchema = z.strictObject({
...browserInput,
});
@@ -177,6 +182,7 @@ export const listBrowserTargetsInputSchema = z.strictObject({
const inspectWebPageInputFacts = {
...browserInput,
target_id: z.string().trim().min(1),
command_timeout_ms: webRuntimeCommandTimeoutSchema,
observation_ms: z.number().int().min(0).default(500),
include_accessibility_text: z.boolean().default(false),
include_console_text: z.boolean().default(false),
+6 -1
View File
@@ -14,15 +14,20 @@ export class BrowserObservationError extends AnalysisError {
override cleanupIncomplete: boolean;
override readonly userCategory: "cancelled" | undefined;
override readonly userMessage: string | undefined;
readonly remediationAction: string | undefined;
constructor(
readonly operation: BrowserObservationOperation,
readonly reason: BrowserObservationFailureReason,
options?: AnalysisErrorOptions & { readonly detail?: string },
options?: AnalysisErrorOptions & {
readonly detail?: string;
readonly remediationAction?: string;
},
) {
super(`Browser observation ${operation} failed: ${reason}`, options);
this.userMessage =
options?.detail ?? observationFailureMessage(operation, reason);
this.remediationAction = options?.remediationAction;
this.cleanupIncomplete =
this.cleanup !== undefined || reason === "cleanup_failed";
this.setCleanupResources(
+2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
import {
browserScenarioActionSchema,
BROWSER_SCENARIO_START_STEP_ID,
@@ -34,6 +35,7 @@ export const browserScenarioInputSchema = z.strictObject({
"Optional environment-variable references for values used in actions, URLs, storage, or replay, and for redacting matching observed text. Secret values are never supplied inline.",
),
capture: browserScenarioCaptureSchema,
command_timeout_ms: webRuntimeCommandTimeoutSchema,
});
type ScenarioShape = z.infer<typeof browserScenarioInputSchema>;
+2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
import {
browserAllowedOriginsSchema,
browserObservationEndpointSchema,
@@ -13,6 +14,7 @@ export const observeWebSessionInputSchema = z.strictObject({
allowed_origins: browserAllowedOriginsSchema,
target_id: z.string().trim().min(1),
observation_ms: z.number().int().min(1).default(10_000),
command_timeout_ms: webRuntimeCommandTimeoutSchema,
});
export type ObserveWebSessionInput = z.infer<
typeof observeWebSessionInputSchema
@@ -1,5 +1,6 @@
import { z } from "zod";
import { webRuntimeCommandTimeoutSchema } from "../browserDeadline.js";
import { browserCompletenessSchema } from "../browserCompleteness.js";
import { browserEndpointSchema } from "../browserObservation.js";
import { browserVersionSchema } from "../browserObservationSchemas.js";
@@ -23,6 +24,7 @@ const inspectElectronPageFacts = {
...approvedElectronInput,
target_id: z.string().trim().min(1),
observation_ms: z.number().int().min(0).default(100),
command_timeout_ms: webRuntimeCommandTimeoutSchema,
} as const;
export const inspectElectronPageInputSchema = z.union([
z.strictObject({
+205 -25
View File
@@ -1,43 +1,223 @@
import { z } from "zod";
import {
MAX_BROWSER_DEADLINE_MS,
browserDeadlineIncrease,
browserDeadlineRetry,
noRuntimeOverrideSentence,
optionalBrowserDeadlineMs,
} from "./browserDeadline.js";
import { emptyArraySchema } from "./emptyArraySchema.js";
import { inspectWebPageWithSourceInputSchema } from "./browserObservation.js";
import { webTextArtifactSchema } from "./webContentArtifact.js";
/** Isolated analyzer heap used when max_heap_mb is omitted. */
export const DEFAULT_WEB_BUNDLE_HEAP_MB = 1024;
export const MIN_WEB_BUNDLE_HEAP_MB = 128;
export const MAX_WEB_BUNDLE_HEAP_MB = 16_384;
/** Per-script deadline used when analysis_timeout_ms is omitted. */
export const DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS = 30_000;
/** Total analysis deadline used when total_analysis_timeout_ms is omitted. */
export const DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS = 300_000;
/** Source-map fetch deadline used when source_map_fetch_timeout_ms is omitted. */
export const DEFAULT_SOURCE_MAP_FETCH_TIMEOUT_MS = 30_000;
const webBundleHeapDescription = `Heap phase: maximum V8 old-space for the isolated inert-script analyzer, in MiB (default ${String(DEFAULT_WEB_BUNDLE_HEAP_MB)}, range ${String(MIN_WEB_BUNDLE_HEAP_MB)}..${String(MAX_WEB_BUNDLE_HEAP_MB)}). Omitted means ${String(DEFAULT_WEB_BUNDLE_HEAP_MB)} and applies on the next call. This is not a total process RSS limit, not analysis_timeout_ms, and not total_analysis_timeout_ms. Bun ignores V8 flags; Windows Bun uses an owned process commit limit plus a 512 MiB runtime reserve, while other Bun hosts have no enforced memory limit. Raising max_heap_mb does not raise the fixed worker protocol or result byte budgets.`;
const webBundleScriptTimeoutDescription = `Per-script deadline phase: maximum startup, analysis and result transfer time for one captured script, in milliseconds (default ${String(DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS)}, range 1..${String(MAX_BROWSER_DEADLINE_MS)}). Omitted means ${String(DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS)}. deadlineExpired for one script is not total_analysis_timeout_ms. A per-script value above the effective total is rejected. Timed-out scripts are explicit unknowns and other scripts may still be analyzed. Applies on the next call.`;
const webBundleTotalTimeoutDescription = `Total analysis deadline phase: maximum time for all captured-script analysis, in milliseconds (default ${String(DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS)}, range 1..${String(MAX_BROWSER_DEADLINE_MS)}). Omitted means ${String(DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS)}. This is not the per-script analysis_timeout_ms, not capture, and not source-map fetch. A per-script timeout above this total is rejected. Unstarted scripts remain explicit unknowns. Applies on the next call.`;
/** Capture-and-analyze input with optional source-map fetching. */
export const analyzeWebBundleInputSchema =
inspectWebPageWithSourceInputSchema.safeExtend({
export const analyzeWebBundleInputSchema = inspectWebPageWithSourceInputSchema
.safeExtend({
fetch_source_maps: z.boolean().default(false),
max_heap_mb: z
.number()
.int()
.min(128)
.max(16_384)
.min(MIN_WEB_BUNDLE_HEAP_MB)
.max(MAX_WEB_BUNDLE_HEAP_MB)
.optional()
.describe(
"Maximum V8 old-space heap in MiB for the isolated inert-script analyzer; defaults to 1024. This is not a total process RSS limit. Bun ignores V8 flags; Windows Bun uses an owned process commit limit plus a 512 MiB runtime reserve, while other Bun hosts have no enforced memory limit.",
),
analysis_timeout_ms: z
.number()
.int()
.min(1)
.max(2_147_483_647)
.optional()
.describe(
"Maximum startup, analysis and result transfer time per captured script, in milliseconds; defaults to 30000. Timed-out scripts are explicit unknowns and other scripts may still be analyzed.",
),
total_analysis_timeout_ms: z
.number()
.int()
.min(1)
.max(2_147_483_647)
.optional()
.describe(
"Maximum time for all captured-script analysis, in milliseconds; defaults to 300000. Capture and optional source-map fetching have separate budgets. Unstarted scripts remain explicit unknowns.",
),
.describe(webBundleHeapDescription),
analysis_timeout_ms: optionalBrowserDeadlineMs(
webBundleScriptTimeoutDescription,
),
total_analysis_timeout_ms: optionalBrowserDeadlineMs(
webBundleTotalTimeoutDescription,
),
source_map_fetch_timeout_ms: optionalBrowserDeadlineMs(
`Source-map fetch deadline in milliseconds (default ${String(DEFAULT_SOURCE_MAP_FETCH_TIMEOUT_MS)}). Omitted means ${String(DEFAULT_SOURCE_MAP_FETCH_TIMEOUT_MS)} and applies on the next fetch. A fetch overrun is an item limitation (fetch_failed), not a thrown analysis timeout. Does not raise the fixed 64 MiB response budget or the source-map decode deadline. The response budget has no runtime override.`,
),
})
.superRefine((input, context) => {
const perScript =
input.analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS;
const total =
input.total_analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS;
if (perScript <= total) return;
context.addIssue({
code: "custom",
path:
input.analysis_timeout_ms === undefined
? ["total_analysis_timeout_ms"]
: ["analysis_timeout_ms"],
message: `analysis_timeout_ms ${String(perScript)} ms is the per-script deadline and must be less than or equal to total_analysis_timeout_ms ${String(total)} ms. A per-script timeout above the total is rejected. Omitted analysis_timeout_ms means ${String(DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS)}; omitted total_analysis_timeout_ms means ${String(DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS)}.`,
});
});
export type AnalyzeWebBundleInput = z.infer<typeof analyzeWebBundleInputSchema>;
/** Per-script deadlineExpired text. It is not the total analysis deadline. */
export const webBundleScriptTimeoutRecovery = (
timeoutMs: number,
): { readonly userMessage: string; readonly remediationAction: string } => ({
userMessage: `analyze_web_bundle reached its ${String(timeoutMs)} ms per-script deadline (deadlineExpired) for analysis_timeout_ms. This is not total_analysis_timeout_ms and not the MCP client deadline.`,
remediationAction: `${browserDeadlineIncrease("analysis_timeout_ms", timeoutMs, DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS, "--analysis-timeout-ms")} Keep it less than or equal to total_analysis_timeout_ms (omitted means ${String(DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS)}); a per-script timeout above the total is rejected. ${browserDeadlineRetry} analysis_timeout_ms does not raise max_heap_mb, total_analysis_timeout_ms, or the fixed worker protocol and result byte budgets.`,
});
/** Total captured-script deadlineExpired text. */
export const webBundleTotalTimeoutRecovery = (
timeoutMs: number,
): { readonly userMessage: string; readonly remediationAction: string } => ({
userMessage: `analyze_web_bundle reached its ${String(timeoutMs)} ms total captured-script analysis deadline (deadlineExpired) for total_analysis_timeout_ms. This is not the per-script analysis_timeout_ms, not capture, and not source-map fetch.`,
remediationAction: `${browserDeadlineIncrease("total_analysis_timeout_ms", timeoutMs, DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS, "--total-analysis-timeout-ms")} A per-script analysis_timeout_ms above the total is rejected. ${browserDeadlineRetry} total_analysis_timeout_ms does not raise analysis_timeout_ms, max_heap_mb, source_map_fetch_timeout_ms, or the fixed worker byte budgets.`,
});
/**
* Which resource the analyzer actually exhausted.
* Node enforces the V8 old-space flag. Windows Bun enforces a process commit
* limit of that heap plus a 512 MiB reserve. Other Bun hosts enforce neither.
*/
export type WebBundleMemoryExhaustion =
| { readonly kind: "v8-heap"; readonly heapMb: number }
| {
readonly kind: "process-commit";
readonly heapMb: number;
readonly limitBytes: number;
}
| {
readonly kind: "bun-allocation";
readonly heapMb: number;
readonly processMemoryLimitBytes: number | null;
};
/** Map host facts onto the exhausted resource. The worker supplies those facts. */
export const webBundleMemoryExhaustion = (input: {
readonly runtime: "node" | "bun";
readonly heapMb: number;
readonly memoryLimitReached: boolean;
readonly processMemoryLimitBytes: number | null;
}): WebBundleMemoryExhaustion => {
if (input.runtime === "node")
return { kind: "v8-heap", heapMb: input.heapMb };
if (input.memoryLimitReached && input.processMemoryLimitBytes !== null)
return {
kind: "process-commit",
heapMb: input.heapMb,
limitBytes: input.processMemoryLimitBytes,
};
return {
kind: "bun-allocation",
heapMb: input.heapMb,
processMemoryLimitBytes: input.processMemoryLimitBytes,
};
};
const webBundleHeapRange = (): string =>
`range ${String(MIN_WEB_BUNDLE_HEAP_MB)}..${String(MAX_WEB_BUNDLE_HEAP_MB)}; omitted means ${String(DEFAULT_WEB_BUNDLE_HEAP_MB)}`;
const webBundleHeapIncrease = (heapMb: number): string =>
heapMb < MAX_WEB_BUNDLE_HEAP_MB
? `Increase max_heap_mb above ${String(heapMb)} (integer MiB from ${String(MIN_WEB_BUNDLE_HEAP_MB)} to ${String(MAX_WEB_BUNDLE_HEAP_MB)}; omitted means ${String(DEFAULT_WEB_BUNDLE_HEAP_MB)}), or use --max-heap-mb on the CLI.`
: `max_heap_mb is already at the maximum ${String(MAX_WEB_BUNDLE_HEAP_MB)} MiB; do not increase it further.`;
const webBundleHeapDoesNotRaise =
"Applies on the next call. Raising max_heap_mb does not raise analysis_timeout_ms, total_analysis_timeout_ms, or the fixed worker protocol and result byte budgets. Those byte budgets have no runtime override.";
/** Heap exhaustion names the resource this runtime actually exhausted. */
export const webBundleHeapExhaustedReason = (
exhaustion: WebBundleMemoryExhaustion,
): string => {
const range = webBundleHeapRange();
switch (exhaustion.kind) {
case "v8-heap":
return `Owned web analyzer exhausted its max_heap_mb V8 old-space heap of ${String(exhaustion.heapMb)} MiB (${range}). This is the isolated analyzer heap, not a process RSS limit.`;
case "process-commit":
return `Owned web analyzer reached its ${String(exhaustion.limitBytes)}-byte process commit limit. That limit is max_heap_mb ${String(exhaustion.heapMb)} MiB plus a 512 MiB Bun runtime reserve (${range}). Bun ignores the V8 old-space flag, so this is not V8 old-space exhaustion.`;
case "bun-allocation":
return exhaustion.processMemoryLimitBytes === null
? `Owned web analyzer ran out of memory. Bun ignores the V8 old-space flag, so this exit is not the configured max_heap_mb of ${String(exhaustion.heapMb)} MiB (${range}). This host enforces no analyzer process commit limit.`
: `Owned web analyzer ran out of memory before its ${String(exhaustion.processMemoryLimitBytes)}-byte process commit limit was reported as reached. That configured limit is max_heap_mb ${String(exhaustion.heapMb)} MiB plus a 512 MiB Bun runtime reserve (${range}). Bun ignores the V8 old-space flag, so this exit is not V8 old-space exhaustion.`;
}
};
export const webBundleHeapExhaustedRemediation = (
exhaustion: WebBundleMemoryExhaustion,
): string => {
switch (exhaustion.kind) {
case "v8-heap":
return `${webBundleHeapIncrease(exhaustion.heapMb)} ${webBundleHeapDoesNotRaise}`;
case "process-commit":
return `${webBundleHeapIncrease(exhaustion.heapMb)} That raises the Windows process commit limit (max_heap_mb plus the fixed 512 MiB Bun runtime reserve), not a V8 old-space heap. ${webBundleHeapDoesNotRaise}`;
case "bun-allocation":
return exhaustion.processMemoryLimitBytes === null
? `Bun ignores V8 heap flags, so raising max_heap_mb does not change this exit. A reached Windows process commit limit is reported separately. Other Bun hosts enforce no analyzer heap limit. Reduce the analysis instead of increasing max_heap_mb. ${webBundleHeapDoesNotRaise}`
: `The configured ${String(exhaustion.processMemoryLimitBytes)}-byte process commit limit was not reported as reached, so raising max_heap_mb does not change this exit. A reached Windows process commit limit is reported separately. Reduce the analysis instead of increasing max_heap_mb. ${webBundleHeapDoesNotRaise}`;
}
};
export const webBundleFixedByteBudget = (
budgetBytes: number,
label: string,
): string =>
noRuntimeOverrideSentence(
`The ${String(budgetBytes)}-byte web-bundle ${label} has no further room.`,
"max_heap_mb",
);
/**
* Result admission failed because `min(fixedRemaining, hostCapacity)` is not
* positive. The fixed allowance wins when it has no room left. Host pressure
* is named only while that allowance still has room.
*/
export const webBundleResultAdmissionFailure = (input: {
readonly fixedBudgetBytes: number;
readonly fixedRemainingBytes: number;
readonly hostCapacityBytes: number;
}):
| {
readonly kind: "fixed-budget";
readonly reason: string;
readonly reportedLimits: { readonly result_bytes: number };
}
| {
readonly kind: "host-capacity";
readonly reason: string;
readonly remediationAction: string;
readonly reportedLimits: {
readonly result_bytes: number;
readonly result_budget_remaining_bytes: number;
readonly host_result_capacity_bytes: number;
};
} => {
if (input.fixedRemainingBytes <= 0)
return {
kind: "fixed-budget",
reason: webBundleFixedByteBudget(input.fixedBudgetBytes, "result budget"),
reportedLimits: { result_bytes: input.fixedBudgetBytes },
};
return {
kind: "host-capacity",
reason: `The CLI/MCP host heap has no remaining reserved capacity for another web-bundle result (host result capacity ${String(input.hostCapacityBytes)} bytes). The fixed ${String(input.fixedBudgetBytes)}-byte result budget still has ${String(input.fixedRemainingBytes)} bytes available. This is host memory pressure, not exhaustion of that fixed allowance.`,
remediationAction:
"Retry when the CLI/MCP process has more heap headroom. On Node, set NODE_OPTIONS (for example --max-old-space-size=<MiB>) in the environment that starts the CLI or MCP server, then restart that process, to raise this host heap. Raising max_heap_mb changes the isolated analyzer, not this host admission. The fixed result budget is separate and was not exhausted.",
reportedLimits: {
result_bytes: input.fixedBudgetBytes,
result_budget_remaining_bytes: input.fixedRemainingBytes,
host_result_capacity_bytes: input.hostCapacityBytes,
},
};
};
const sourceLocationSchema = z.object({
script_key: z.string(),
line: z.number().int().min(1).nullable(),
+232
View File
@@ -0,0 +1,232 @@
import { describe, expect, it } from "vitest";
import {
cdpCleanupGraceExpired,
cdpCommandDeadlineRecovery,
} from "./browserDeadline.js";
import { listBrowserTargetsInputSchema } from "./browserObservation.js";
import {
analyzeWebBundleInputSchema,
webBundleHeapExhaustedReason,
webBundleHeapExhaustedRemediation,
webBundleMemoryExhaustion,
webBundleResultAdmissionFailure,
webBundleScriptTimeoutRecovery,
} from "./webBundleAnalysis.js";
import {
inspectWebNetworkCaptureInputSchema,
webNetworkCaptureTimeoutRecovery,
} from "./webNetworkCapture.js";
import {
webSourceLocationInputSchema,
webSourceMapDecodeTimeoutRecovery,
} from "./webSourceLocation.js";
const bundle = {
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page",
};
describe("web deadline controls", () => {
it("rejects a per-script deadline above the total", () => {
const parsed = analyzeWebBundleInputSchema.safeParse({
...bundle,
analysis_timeout_ms: 40_000,
total_analysis_timeout_ms: 30_000,
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(
parsed.error.issues.map((issue) => issue.message).join(" "),
).toContain("analysis_timeout_ms");
});
it("keeps omitted web-bundle deadlines at today's defaults", () => {
const parsed = analyzeWebBundleInputSchema.parse(bundle);
expect(parsed.analysis_timeout_ms).toBeUndefined();
expect(parsed.total_analysis_timeout_ms).toBeUndefined();
expect(parsed.max_heap_mb).toBeUndefined();
expect(parsed.source_map_fetch_timeout_ms).toBeUndefined();
expect(parsed.command_timeout_ms).toBeUndefined();
});
it("accepts the promoted deadlines inside 1..2147483647", () => {
expect(
analyzeWebBundleInputSchema.safeParse({
...bundle,
command_timeout_ms: 2_147_483_647,
}).success,
).toBe(true);
expect(
listBrowserTargetsInputSchema.safeParse({
cdp_endpoint: "http://127.0.0.1:9222",
command_timeout_ms: 1_500,
}).success,
).toBe(false);
expect(
inspectWebNetworkCaptureInputSchema.safeParse({
capture_path: "/tmp/capture.har",
format: "har",
capture_timeout_ms: 0,
}).success,
).toBe(false);
expect(
webSourceLocationInputSchema.safeParse({
manifest_path: "/tmp/manifest.json",
script_index: 0,
source_map: {
path: "/tmp/app.js.map",
url: "https://example.test/app.js.map",
},
generated_position: { line: 1, column: 0 },
decode_timeout_ms: 20_000,
}).success,
).toBe(true);
});
it("names each deadline and the fixed budgets it does not raise", () => {
expect(webBundleScriptTimeoutRecovery(30_000).userMessage).toContain(
"analysis_timeout_ms",
);
expect(
webNetworkCaptureTimeoutRecovery(30_000).remediationAction,
).toContain("no runtime override");
expect(webSourceMapDecodeTimeoutRecovery(20_000).userMessage).toContain(
"decode_timeout_ms",
);
expect(
cdpCommandDeadlineRecovery(20_000, "handshake").userMessage,
).toContain("command_timeout_ms");
expect(cdpCleanupGraceExpired()).toContain("5000 ms");
expect(cdpCleanupGraceExpired()).toContain("no runtime override");
const inspector = cdpCommandDeadlineRecovery(
20_000,
"handshake",
undefined,
false,
);
expect(inspector.userMessage).not.toContain("command_timeout_ms");
expect(inspector.remediationAction).not.toContain("--command-timeout-ms");
expect(inspector.remediationAction).toContain("no runtime override");
});
});
it("names web-bundle exhaustion by the runtime that enforced it", () => {
const node = webBundleMemoryExhaustion({
runtime: "node",
heapMb: 1024,
memoryLimitReached: true,
processMemoryLimitBytes: 99,
});
expect(node.kind).toBe("v8-heap");
expect(webBundleHeapExhaustedReason(node)).toContain(
"exhausted its max_heap_mb V8 old-space heap of 1024 MiB",
);
expect(webBundleHeapExhaustedRemediation(node)).toContain(
"Increase max_heap_mb above 1024",
);
const commitBytes = 1024 * 1024 * 1024 + 512 * 1024 * 1024;
const windows = webBundleMemoryExhaustion({
runtime: "bun",
heapMb: 1024,
memoryLimitReached: true,
processMemoryLimitBytes: commitBytes,
});
const windowsReason = webBundleHeapExhaustedReason(windows);
expect(windows.kind).toBe("process-commit");
expect(windowsReason).toContain(
`${String(commitBytes)}-byte process commit limit`,
);
expect(windowsReason).toContain("512 MiB Bun runtime reserve");
expect(windowsReason).not.toContain("V8 old-space heap of");
const windowsRemediation = webBundleHeapExhaustedRemediation(windows);
expect(windowsRemediation).toContain("Increase max_heap_mb above 1024");
expect(windowsRemediation).toContain("Windows process commit limit");
expect(windowsRemediation).toContain("not a V8 old-space heap");
expect(windowsRemediation).not.toContain(
"exhausted its max_heap_mb V8 old-space heap",
);
const bun = webBundleMemoryExhaustion({
runtime: "bun",
heapMb: 1024,
memoryLimitReached: false,
processMemoryLimitBytes: null,
});
expect(bun.kind).toBe("bun-allocation");
expect(webBundleHeapExhaustedReason(bun)).toContain(
"Bun ignores the V8 old-space flag",
);
expect(webBundleHeapExhaustedReason(bun)).not.toContain(
"V8 old-space heap of",
);
expect(webBundleHeapExhaustedRemediation(bun)).not.toContain(
"Increase max_heap_mb",
);
expect(webBundleHeapExhaustedRemediation(bun)).toContain(
"does not change this exit",
);
const beforeLimit = webBundleMemoryExhaustion({
runtime: "bun",
heapMb: 1024,
memoryLimitReached: false,
processMemoryLimitBytes: commitBytes,
});
expect(beforeLimit.kind).toBe("bun-allocation");
const beforeLimitReason = webBundleHeapExhaustedReason(beforeLimit);
expect(beforeLimitReason).toContain(
`${String(commitBytes)}-byte process commit limit was reported as reached`,
);
expect(beforeLimitReason).not.toContain(
"enforces no analyzer process commit limit",
);
expect(webBundleHeapExhaustedRemediation(beforeLimit)).toContain(
"was not reported as reached",
);
expect(webBundleHeapExhaustedRemediation(beforeLimit)).not.toContain(
"Increase max_heap_mb",
);
expect(webBundleHeapExhaustedReason(bun)).toContain(
"enforces no analyzer process commit limit",
);
expect(
webBundleHeapExhaustedRemediation({
kind: "v8-heap",
heapMb: 16_384,
}),
).toContain("already at the maximum 16384 MiB");
});
it("names host heap pressure only while the fixed result budget still has room", () => {
const host = webBundleResultAdmissionFailure({
fixedBudgetBytes: 8_388_608,
fixedRemainingBytes: 8_388_608,
hostCapacityBytes: 0,
});
expect(host.kind).toBe("host-capacity");
expect(host.reason).toContain("host result capacity 0 bytes");
expect(host.reason).toContain("still has 8388608 bytes available");
expect(host.reason).not.toContain("has no further room");
if (host.kind !== "host-capacity") return;
expect(host.remediationAction).not.toContain("has no further room");
expect(host.remediationAction).toContain("NODE_OPTIONS");
expect(host.reportedLimits).toEqual({
result_bytes: 8_388_608,
result_budget_remaining_bytes: 8_388_608,
host_result_capacity_bytes: 0,
});
const fixed = webBundleResultAdmissionFailure({
fixedBudgetBytes: 8_388_608,
fixedRemainingBytes: 0,
hostCapacityBytes: 0,
});
expect(fixed.kind).toBe("fixed-budget");
expect(fixed.reason).toContain(
"8388608-byte web-bundle result budget has no further room",
);
expect(fixed.reason).toContain("no runtime override");
expect(fixed.reason).not.toContain("host result capacity");
});
+2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
import { browserCompletenessSchema } from "./browserCompleteness.js";
import {
browserAllowedOriginsSchema,
@@ -15,6 +16,7 @@ export const discoverWebMcpToolsInputSchema = z.strictObject({
allowed_origins: browserAllowedOriginsSchema,
target_id: z.string().trim().min(1),
observation_ms: z.number().int().min(0).default(100),
command_timeout_ms: webRuntimeCommandTimeoutSchema,
});
export type DiscoverWebMcpToolsInput = z.infer<
typeof discoverWebMcpToolsInputSchema
+27
View File
@@ -1,4 +1,11 @@
import { z } from "zod";
import {
browserDeadlineIncrease,
browserDeadlineRetry,
noRuntimeOverrideSentence,
optionalBrowserDeadlineMs,
selectedBrowserDeadlineMs,
} from "./browserDeadline.js";
import { jsonValueSchema } from "./jsonValue.js";
import { localPathStringSchema } from "./localPath.js";
import {
@@ -16,6 +23,23 @@ export const WEB_NETWORK_CAPTURE_LIMITS = {
depth: 64,
} as const;
/** Capture deadline used when capture_timeout_ms is omitted. */
export const DEFAULT_WEB_NETWORK_CAPTURE_TIMEOUT_MS =
WEB_NETWORK_CAPTURE_LIMITS.timeoutMs;
export const selectedWebNetworkCaptureTimeoutMs = (
selected: number | undefined,
): number =>
selectedBrowserDeadlineMs(selected, DEFAULT_WEB_NETWORK_CAPTURE_TIMEOUT_MS);
/** Caller-visible capture deadline. Byte and depth caps stay fixed. */
export const webNetworkCaptureTimeoutRecovery = (
timeoutMs: number,
): { readonly userMessage: string; readonly remediationAction: string } => ({
userMessage: `inspect_web_network_capture reached its ${String(timeoutMs)} ms capture deadline (deadlineExpired) for capture_timeout_ms. No partial capture is returned. This is not the MCP client deadline.`,
remediationAction: `${browserDeadlineIncrease("capture_timeout_ms", timeoutMs, DEFAULT_WEB_NETWORK_CAPTURE_TIMEOUT_MS, "--capture-timeout-ms")} ${browserDeadlineRetry} ${noRuntimeOverrideSentence(`The ${String(WEB_NETWORK_CAPTURE_LIMITS.inputBytes)}-byte input budget, ${String(WEB_NETWORK_CAPTURE_LIMITS.outputBytes)}-byte reply budget, and ${String(WEB_NETWORK_CAPTURE_LIMITS.depth)}-level nesting cap stay fixed.`, "capture_timeout_ms")}`,
});
export const WEB_NETWORK_CAPTURE_FORMATS = [
"har",
"mitmproxy",
@@ -43,6 +67,9 @@ export const inspectWebNetworkCaptureInputSchema = z.strictObject({
.describe(
"Literal text excluded from capture payloads, byte/numeric projections, producer coordinates, artifact paths/digests and diagnostic text. Declaration strings are never persisted in Evidence parameters. Canonical contract keys, enums and provider identities remain intact.",
),
capture_timeout_ms: optionalBrowserDeadlineMs(
`Capture wall-clock deadline in milliseconds (default ${String(DEFAULT_WEB_NETWORK_CAPTURE_TIMEOUT_MS)}), from decoder start through process exit. Omitted means ${String(DEFAULT_WEB_NETWORK_CAPTURE_TIMEOUT_MS)} and applies on the next capture. deadlineExpired returns no partial capture. Does not raise the fixed ${String(WEB_NETWORK_CAPTURE_LIMITS.inputBytes)}-byte input budget, ${String(WEB_NETWORK_CAPTURE_LIMITS.outputBytes)}-byte reply budget, or ${String(WEB_NETWORK_CAPTURE_LIMITS.depth)}-level nesting cap. Those have no runtime override.`,
),
});
/** Read declarations through their owning schema for service and adapter failures. */
+8 -2
View File
@@ -3,6 +3,11 @@ import {
browserAllowedOriginsSchema,
browserEndpointSchema,
} from "./browserObservation.js";
import {
CDP_CLEANUP_GRACE_MS,
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
webRuntimeCommandTimeoutSchema,
} from "./browserDeadline.js";
import { jsonObjectSchema } from "./jsonValue.js";
// CDP positions are zero-based and relative to the resource named by url.
@@ -30,6 +35,7 @@ export const webRuntimeScopeSchema = z.strictObject({
cdp_endpoint: browserEndpointSchema,
allowed_origins: browserAllowedOriginsSchema,
target_id: z.string().min(1),
command_timeout_ms: webRuntimeCommandTimeoutSchema,
});
/** Source evidence is bound by the selected CDP session's script ID, never URL equality. */
@@ -90,6 +96,6 @@ export const WEB_RUNTIME_LIMITS = {
protocolBytes: 64 * 1024 * 1024,
retainedEventBytes: 8 * 1024 * 1024,
sourceBytes: 32 * 1024 * 1024,
commandTimeoutMs: 20_000,
cleanupTimeoutMs: 5_000,
commandTimeoutMs: DEFAULT_CDP_COMMAND_TIMEOUT_MS,
cleanupTimeoutMs: CDP_CLEANUP_GRACE_MS,
} as const;
+2
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { z } from "zod";
import { webRuntimeCommandTimeoutSchema } from "./browserDeadline.js";
import { browserCompletenessSchema } from "./browserCompleteness.js";
import {
browserAllowedOriginsSchema,
@@ -37,6 +38,7 @@ export const captureWebScreenshotInputSchema = z.strictObject({
cdp_endpoint: browserObservationEndpointSchema,
allowed_origins: browserAllowedOriginsSchema,
target_id: z.string().trim().min(1),
command_timeout_ms: webRuntimeCommandTimeoutSchema,
});
export type CaptureWebScreenshotInput = z.infer<
typeof captureWebScreenshotInputSchema
+21
View File
@@ -1,4 +1,10 @@
import { z } from "zod";
import {
browserDeadlineIncrease,
browserDeadlineRetry,
noRuntimeOverrideSentence,
optionalBrowserDeadlineMs,
} from "./browserDeadline.js";
import { digestSchema } from "./digests.js";
import { localPathStringSchema } from "./localPath.js";
import { exportedWebScriptSchema } from "./webScriptExport.js";
@@ -29,6 +35,9 @@ export const webSourceLocationInputSchema = selectedWebScriptInputSchema.extend(
),
}),
generated_position: webSourcePositionSchema,
decode_timeout_ms: optionalBrowserDeadlineMs(
"Source-map decode deadline in milliseconds (default 20000), for the owned codec process. Omitted means 20000 and applies on the next trace_web_source_location call. deadlineExpired returns no partial mapping. Does not raise the fixed 4 MiB map budget, 32 MiB reply budget, or 192 MiB codec heap. Those have no runtime override.",
),
},
);
export type WebSourceLocationInput = z.output<
@@ -121,6 +130,18 @@ export const WEB_SOURCE_MAP_LIMITS = {
/** V8 old-generation heap for the codec, and its Bun process budget. */
codecHeapMiB: 192,
} as const;
/** Decode deadline used when decode_timeout_ms is omitted. */
export const DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS =
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs;
/** Source-map decode deadlineExpired text for trace_web_source_location. */
export const webSourceMapDecodeTimeoutRecovery = (
timeoutMs: number,
): { readonly userMessage: string; readonly remediationAction: string } => ({
userMessage: `trace_web_source_location reached its ${String(timeoutMs)} ms source-map decode deadline (deadlineExpired) for decode_timeout_ms. No partial mapping is returned. This is not the MCP client deadline.`,
remediationAction: `${browserDeadlineIncrease("decode_timeout_ms", timeoutMs, DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS, "--decode-timeout-ms")} ${browserDeadlineRetry} ${noRuntimeOverrideSentence(`The ${String(WEB_SOURCE_MAP_LIMITS.mapBytes)}-byte map budget, ${String(WEB_SOURCE_MAP_LIMITS.outputBytes)}-byte reply budget, and ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB codec heap stay fixed.`, "decode_timeout_ms")}`,
});
export type WebSourceMapReport = z.output<typeof webSourceMapReportSchema>;
/** Selected capture/map byte identities and complete point attribution inline. */
@@ -276,3 +276,41 @@ it("keeps a timeout distinct from caller cancellation", async () => {
vi.useRealTimers();
}
});
it("applies a non-default decode deadline and releases its runtime", async () => {
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "performance"],
});
let runtimePath: string | undefined;
try {
const result = await runSourceMapCommand(
"{}",
"/probe/source.map",
undefined,
{
prepareOwnership: async () => {},
createRuntime: async () => {
const runtime = await PrivateRuntimeRoot.create({
prefix: "rea-source-map-selected-deadline-",
});
runtimePath = runtime.path;
return runtime;
},
launcher: async () => {
await vi.advanceTimersByTimeAsync(251);
throw new Error("launcher settled after deadline");
},
},
250,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected timeout");
expect(result.error).toBeInstanceOf(AnalysisTimeoutError);
expect(result.error.userMessage).toContain("250");
expect(result.error.userMessage).toContain("decode_timeout_ms");
if (runtimePath === undefined) throw new Error("runtime was not acquired");
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
vi.useRealTimers();
}
});
+19 -6
View File
@@ -15,7 +15,12 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js";
import { noRuntimeOverrideSentence } from "../../domain/browserDeadline.js";
import {
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
WEB_SOURCE_MAP_LIMITS,
webSourceMapDecodeTimeoutRecovery,
} from "../../domain/webSourceLocation.js";
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
import { PrivateRuntimeRootUnavailableError } from "../../process/PrivateRuntimeRootUnavailableError.js";
import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js";
@@ -95,6 +100,7 @@ export const runSourceMapCommand = async (
path: string,
options: ExecutionOptions | undefined,
dependencies: SourceMapDecoderDependencies,
decodeTimeoutMs: number = DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
): Promise<Result<string, AnalysisError>> => {
try {
await (dependencies.prepareOwnership ?? prepareProcessOwnershipInspection)(
@@ -119,10 +125,10 @@ export const runSourceMapCommand = async (
);
}
const deadline = new ProviderStartupDeadline(
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs,
decodeTimeoutMs,
options?.signal,
);
const command = new OwnedCodec(path, dependencies, deadline);
const command = new OwnedCodec(path, dependencies, deadline, decodeTimeoutMs);
let result: Result<string, AnalysisError>;
try {
await command.start(request);
@@ -153,6 +159,7 @@ class OwnedCodec implements SourceMapCleanupOwner {
readonly path: string,
readonly dependencies: SourceMapDecoderDependencies,
readonly deadline: ProviderStartupDeadline,
readonly decodeTimeoutMs: number,
) {
this.#onInterruption = () => this.#observeInterruption();
if (deadline.signal.aborted) this.#observeInterruption();
@@ -282,7 +289,8 @@ class OwnedCodec implements SourceMapCleanupOwner {
if (outcome?.kind === "timeout")
throw new AnalysisTimeoutError(
OPERATION,
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs,
this.decodeTimeoutMs,
webSourceMapDecodeTimeoutRecovery(this.decodeTimeoutMs),
);
}
@@ -298,7 +306,8 @@ class OwnedCodec implements SourceMapCleanupOwner {
if (outcome?.kind === "timeout")
return new AnalysisTimeoutError(
OPERATION,
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs,
this.decodeTimeoutMs,
webSourceMapDecodeTimeoutRecovery(this.decodeTimeoutMs),
);
if (cause instanceof AnalysisError) return cause;
return cause instanceof PrivateRuntimeRootUnavailableError
@@ -436,7 +445,7 @@ class OwnedCodec implements SourceMapCleanupOwner {
return new AnalysisResourceConstraintError(
OPERATION,
"memory",
`Source-map codec reached its ${SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES === null ? "" : `${String(SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES)}-byte `}process memory limit for ${this.path}; no partial evidence was returned.`,
`Source-map codec reached its ${SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES === null ? "" : `${String(SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES)}-byte `}process memory limit for ${this.path}; no partial evidence was returned. ${noRuntimeOverrideSentence(`The ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB codec heap stays fixed.`, "decode_timeout_ms")}`,
{
codec_process_memory_limit_bytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
},
@@ -448,6 +457,10 @@ class OwnedCodec implements SourceMapCleanupOwner {
snapshot.diagnosticTruncated === true ||
!(this.#supervisor?.observedOutputClose() ?? false),
},
remediationAction: noRuntimeOverrideSentence(
`The ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB codec heap, ${String(WEB_SOURCE_MAP_LIMITS.mapBytes)}-byte map budget, and ${String(WEB_SOURCE_MAP_LIMITS.outputBytes)}-byte reply budget stay fixed.`,
"decode_timeout_ms",
),
},
);
}
@@ -10,7 +10,9 @@ import {
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import { err, ok, type Result } from "../../domain/result.js";
import { javaScriptRuntime } from "../../domain/runtimeVersion.js";
import { noRuntimeOverrideSentence } from "../../domain/browserDeadline.js";
import {
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
WEB_SOURCE_MAP_LIMITS,
type WebSourceMapReport,
} from "../../domain/webSourceLocation.js";
@@ -94,7 +96,7 @@ export class SourceMapDecoder implements WebSourceMapPort {
OPERATION,
"limit",
undefined,
`${input.path}: Source-map input exceeds the 4 MiB map or 32 MiB encoded request budget.`,
`${input.path}: Source-map input exceeds the ${String(WEB_SOURCE_MAP_LIMITS.mapBytes)}-byte map or ${String(WEB_SOURCE_MAP_LIMITS.outputBytes)}-byte encoded request budget. ${noRuntimeOverrideSentence("Those budgets stay fixed.", "decode_timeout_ms")}`,
);
if (Buffer.byteLength(input.text) > WEB_SOURCE_MAP_LIMITS.mapBytes)
return err(capacityFailure());
@@ -117,6 +119,7 @@ export class SourceMapDecoder implements WebSourceMapPort {
input.path,
options,
this.dependencies,
input.decodeTimeoutMs ?? DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
);
if (!response.ok) {
if (response.error instanceof SourceMapCleanupFailure)
@@ -114,7 +114,7 @@ describe("browser process startup", () => {
startupTimeoutMs: 20,
});
expect(String(failure)).toContain("stderr=<empty>");
expect(String(failure)).toContain("startup_timeout_ms=20");
expect(String(failure)).toContain("timeoutMs=20");
} finally {
child.kill("SIGKILL");
expect(await waitForExit(child, 5_000)).toBe(true);
@@ -144,7 +144,7 @@ describe("browser process startup", () => {
startupTimeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
});
expect(String(failure)).toContain(
`startup_timeout_ms=${String(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS)}`,
`timeoutMs=${String(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS)}`,
);
});
@@ -4,11 +4,25 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { CdpConnection } from "../../../src/browser/CdpConnection.js";
import { openCdpEndpoint } from "../../../src/browser/CdpTargetSession.js";
import {
PlaywrightScenarioServiceWorkers,
SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS,
} 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 { BrowserObservationError } from "../../../src/domain/browserObservationError.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
import {
browserObservationEndpointSchema,
inspectWebPageInputSchema,
} from "../../../src/domain/browserObservation.js";
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
import { WEB_RUNTIME_LIMITS } from "../../../src/domain/webRuntime.js";
import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js";
// Keep real socket scheduling; expire only a timer whose owning phase is ready.
@@ -49,6 +63,15 @@ const expireTimer = (milliseconds: number): void => {
}
};
const expireEveryTimer = (milliseconds: number): void => {
const timers = activeTimers(milliseconds);
expect(timers.length).toBeGreaterThan(0);
for (const timer of timers) {
timer.callback();
clearTimeout(timer.timer);
}
};
it("bounds a stalled WebSocket handshake and releases its socket", async () => {
const sockets = new Set<Socket>();
const server = createServer((socket) => {
@@ -244,6 +267,12 @@ it.each(["Runtime.enable", "Debugger.enable"])(
events_observed: hangOnMethod === "Debugger.enable" ? 1 : 0,
},
});
expect(result.error.userMessage ?? "").not.toContain(
"command_timeout_ms",
);
expect(result.error.userMessage ?? "").not.toContain(
"--command-timeout-ms",
);
} finally {
await inspector.close();
}
@@ -275,3 +304,405 @@ it("keeps the chosen observation window independent of command deadlines", async
await browser.close();
}
});
it("keeps a short command deadline off HTTP discovery and names Page.getFrameTree", async () => {
const browser = await startFakeCdpBrowser({
hangOnMethod: "Page.getFrameTree",
discoveryDelayMs: 180,
});
const provider = new CdpWebRuntimeProvider();
const started = performance.now();
try {
const pending = provider.observeExecution(
observeWebExecutionInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 1,
command_timeout_ms: 40,
}),
{ signal: AbortSignal.timeout(10_000) },
);
await new Promise<void>((resolve) => setTimeout(resolve, 50));
expect(activeTimers(40)).toHaveLength(0);
const result = await pending;
expect(performance.now() - started).toBeGreaterThan(300);
if (result.ok) throw new Error("Expected a command deadline");
const text = `${result.error.userMessage ?? ""}\n${result.error.message}`;
expect(text).toContain("40 ms command_timeout_ms");
expect(text).toContain("Page.getFrameTree");
expect(text).not.toContain("WebSocket handshake");
expect(browser.httpRequests.map(({ url }) => url)).toEqual(
expect.arrayContaining(["/json/version", "/json/list"]),
);
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
} finally {
await provider.close();
await browser.close();
}
});
it("gives browser-socket attach its own command deadline after the handshake", async () => {
const commandTimeoutMs = 80;
const browser = await startFakeCdpBrowser({
handshakeDelayMs: 50,
hangOnMethod: "Target.attachToTarget",
});
const provider = new CdpWebRuntimeProvider();
try {
const pending = provider.observeExecution(
observeWebExecutionInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 1,
command_timeout_ms: commandTimeoutMs,
}),
{ signal: AbortSignal.timeout(10_000) },
);
await vi.waitFor(
() =>
expect(browser.commands.map(({ method }) => method)).toContain(
"Target.attachToTarget",
),
{ timeout: 5_000 },
);
expect(activeTimers(commandTimeoutMs)).toHaveLength(1);
expireTimer(commandTimeoutMs);
const result = await pending;
if (result.ok) throw new Error("Expected an attach deadline");
const text = `${result.error.userMessage ?? ""}\n${result.error.message}`;
expect(text).toContain(
`CDP command Target.attachToTarget exceeded its ${String(commandTimeoutMs)} ms command_timeout_ms`,
);
expect(text).not.toContain("WebSocket handshake exceeded");
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
} finally {
await provider.close();
await browser.close();
}
});
it("stops a stalled blocked-service-worker check before discovery", async () => {
const commandTimeoutMs = 25_000;
const browser = await startFakeCdpBrowser();
const frame = {
evaluate: () => new Promise<null>(() => undefined),
isDetached: () => false,
url: () => `${browser.allowedOrigin}/app`,
};
const workers = new PlaywrightScenarioServiceWorkers(
{
frames: () => [frame],
isClosed: () => false,
mainFrame: () => frame,
} as unknown as ConstructorParameters<
typeof PlaywrightScenarioServiceWorkers
>[0],
browser.endpoint,
"allowed-page",
commandTimeoutMs,
);
const pending = workers.initialize();
try {
await vi.waitFor(() =>
expect(activeTimers(SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS)).toHaveLength(
1,
),
);
expect(browser.httpRequests).toHaveLength(0);
expect(browser.commands).toHaveLength(0);
expect(activeTimers(commandTimeoutMs)).toHaveLength(0);
expireTimer(SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS);
const failure = await pending.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
if (!(failure instanceof AnalysisTimeoutError))
throw new Error("Expected a policy-check deadline");
const text = `${failure.userMessage ?? ""}\n${failure.message}`;
expect(text).toContain(
`${String(SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS)} ms deadline before CDP discovery`,
);
expect(text).toContain("command_timeout_ms does not apply");
expect(browser.httpRequests).toHaveLength(0);
expect(activeTimers(commandTimeoutMs)).toHaveLength(0);
} finally {
const closing = workers.close().catch(() => undefined);
await browser.close();
await closing;
await pending.catch(() => undefined);
}
});
it("keeps blocked service-worker attachment outside the discovery deadline", async () => {
const commandTimeoutMs = 25_000;
const browser = await startFakeCdpBrowser({
hangOnMethod: "Target.attachToTarget",
});
const frame = {
evaluate: async () => null,
isDetached: () => false,
url: () => `${browser.allowedOrigin}/app`,
};
const workers = new PlaywrightScenarioServiceWorkers(
{
frames: () => [frame],
isClosed: () => false,
mainFrame: () => frame,
} as unknown as ConstructorParameters<
typeof PlaywrightScenarioServiceWorkers
>[0],
browser.endpoint,
"allowed-page",
commandTimeoutMs,
);
const pending = workers.initialize();
try {
await vi.waitFor(
() =>
expect(browser.commands.map(({ method }) => method)).toContain(
"Target.attachToTarget",
),
{ timeout: 5_000 },
);
expect(activeTimers(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)).toHaveLength(0);
expect(activeTimers(commandTimeoutMs)).toHaveLength(1);
} finally {
const closing = workers.close().catch(() => undefined);
await browser.close();
await closing;
await pending.catch(() => undefined);
}
});
it("reports browser-socket discovery on its own deadline", async () => {
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");
const started = performance.now();
try {
const failure = await openCdpEndpoint(
browserObservationEndpointSchema.parse(
`ws://127.0.0.1:${String(address.port)}/devtools/browser/stalled`,
),
"list_browser_targets",
undefined,
80,
undefined,
30,
).catch((cause: unknown) => cause);
const elapsed = performance.now() - started;
expect(elapsed).toBeGreaterThan(60);
expect(elapsed).toBeLessThan(1_500);
expect(failure).toBeInstanceOf(BrowserObservationError);
if (!(failure instanceof BrowserObservationError))
throw new Error("Expected a browser-socket discovery failure");
const text = `${failure.userMessage ?? ""}\n${failure.message}`;
expect(text).toContain("80");
expect(text).not.toContain("30 ms");
expect(text).toContain("browser-socket discovery");
expect(text).toContain(
`${String(WEB_RUNTIME_LIMITS.protocolBytes)}-byte browser-socket message budget`,
);
expect(text).not.toContain("8388608-byte discovery document budget");
} finally {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("applies a non-default handshake deadline and releases its socket", async () => {
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 {
let captured: unknown;
const pending = CdpConnection.connect(
`ws://127.0.0.1:${String(address.port)}/target`,
"inspect_web_page",
AbortSignal.timeout(10_000),
{ commandTimeoutMs: 1_500 },
).then(
() => {
throw new Error("Expected a handshake deadline");
},
(cause: unknown) => {
captured = cause;
throw cause;
},
);
const rejection = expect(pending).rejects.toMatchObject({
reason: "timeout",
});
await vi.waitFor(() => expect(sockets.size).toBe(1), { timeout: 5_000 });
expireTimer(1_500);
await rejection;
expect(captured).toBeInstanceOf(BrowserObservationError);
if (!(captured instanceof BrowserObservationError))
throw new Error("Expected a handshake deadline");
expect(captured.userMessage).toContain("1500 ms command_timeout_ms");
await vi.waitFor(() => expect(sockets.size).toBe(0));
} finally {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("applies a non-default command deadline after HTTP discovery", async () => {
const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" });
try {
const pending = new CdpBrowserProvider().inspectPage(
inspectWebPageInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 0,
command_timeout_ms: 1_500,
}),
{ signal: AbortSignal.timeout(10_000) },
);
await vi.waitFor(
() =>
expect(browser.commands.map(({ method }) => method)).toContain(
"Page.enable",
),
{ timeout: 5_000 },
);
expireTimer(1_500);
const result = await pending;
if (result.ok) throw new Error("Expected a selected command timeout");
expect(result.error.userMessage ?? "").toContain(
"1500 ms command_timeout_ms",
);
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
} finally {
await browser.close();
}
});
it("reschedules a raised command deadline onto the fixed cleanup grace", async () => {
const browser = await startFakeCdpBrowser({ hangOnMethod: "Runtime.enable" });
const connection = await CdpConnection.connect(
browser.browserWebSocketUrl,
"inspect_web_page",
undefined,
{ commandTimeoutMs: 60_000 },
);
try {
const pending = connection.send("Runtime.enable");
await vi.waitFor(() => expect(browser.commands).toHaveLength(1), {
timeout: 5_000,
});
expect(activeTimers(60_000)).toHaveLength(1);
connection.beginCleanupGrace(5_000);
expect(activeTimers(60_000)).toHaveLength(0);
expireTimer(5_000);
const error = await pending.catch((cause: unknown) => cause);
expect(error).toMatchObject({ reason: "timeout" });
if (!(error instanceof BrowserObservationError))
throw new Error("Expected cleanup grace timeout");
expect(error.userMessage).toContain("5000 ms");
expect(error.userMessage).not.toContain("Increase command_timeout_ms");
expect(error.remediationAction).toContain("no runtime override");
} finally {
await connection.close();
await browser.close();
}
});
it("keeps runtime cleanup on the fixed grace when command_timeout_ms is shorter", async () => {
const commandTimeoutMs = 250;
const browser = await startRuntimeBrowser({
hangOnMethod: "Profiler.disable",
});
const provider = new CdpWebRuntimeProvider();
try {
const pending = provider.observeExecution(
observeWebExecutionInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 1,
command_timeout_ms: commandTimeoutMs,
}),
{ signal: AbortSignal.timeout(10_000) },
);
await vi.waitFor(
() =>
expect(browser.commands.map(({ method }) => method)).toContain(
"Profiler.disable",
),
{ timeout: 5_000 },
);
expect(activeTimers(commandTimeoutMs)).toHaveLength(0);
expect(
activeTimers(WEB_RUNTIME_LIMITS.cleanupTimeoutMs).length,
).toBeGreaterThan(0);
const hung = browser.commands.find(
({ method }) => method === "Profiler.disable",
);
if (hung === undefined) throw new Error("Expected Profiler.disable");
browser.emitRawMessage(
JSON.stringify({
id: hung.id,
...(hung.sessionId === undefined ? {} : { sessionId: hung.sessionId }),
result: {},
}),
);
const result = await pending;
expect(result.ok).toBe(true);
} finally {
await provider.close();
await browser.close();
}
});
it("names the fixed cleanup grace when runtime restoration stalls", async () => {
const commandTimeoutMs = 250;
const browser = await startRuntimeBrowser({
hangOnMethod: "Profiler.disable",
});
const provider = new CdpWebRuntimeProvider();
try {
const pending = provider.observeExecution(
observeWebExecutionInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 1,
command_timeout_ms: commandTimeoutMs,
}),
{ signal: AbortSignal.timeout(10_000) },
);
await vi.waitFor(
() =>
expect(browser.commands.map(({ method }) => method)).toContain(
"Profiler.disable",
),
{ timeout: 5_000 },
);
expect(activeTimers(commandTimeoutMs)).toHaveLength(0);
expireEveryTimer(WEB_RUNTIME_LIMITS.cleanupTimeoutMs);
const result = await pending;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cleanup grace failure");
const text = JSON.stringify(result.error.diagnostics);
expect(text).toContain("5000 ms");
expect(text).toContain("no runtime override");
expect(text).not.toContain(
`${String(commandTimeoutMs)} ms command_timeout_ms`,
);
expect(text).not.toContain("Increase command_timeout_ms");
} finally {
await provider.close();
await browser.close();
}
});
@@ -59,6 +59,49 @@ linux("allows a symlink to a regular mitmdump executable", async () => {
).resolves.toMatchObject({ command });
});
it("applies a non-default capture deadline and releases its runtime", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-capture-deadline-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const path = join(root, "capture.har");
await writeFile(path, JSON.stringify(historicalHar()));
let runtimePath: string | undefined;
const decoder = new HistoricalCaptureDecoder(
[
{
format: "har",
identity: HAR_CAPTURE_PROVIDER_IDENTITY,
command: () =>
Promise.resolve({
command: process.execPath,
arguments: ["-e", "setInterval(() => {}, 1_000_000)"],
}),
},
],
process.env,
async () => {
const runtime = await PrivateRuntimeRoot.create({
prefix: "rea-capture-deadline-",
});
runtimePath = runtime.path;
return runtime;
},
);
const result = await decoder.inspect(
inspectWebNetworkCaptureInputSchema.parse({
capture_path: path,
format: "har",
capture_timeout_ms: 250,
}),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected capture timeout");
expect(result.error._tag).toBe("AnalysisTimeoutError");
expect(result.error.userMessage).toContain("250");
expect(result.error.userMessage).toContain("capture_timeout_ms");
if (runtimePath === undefined) throw new Error("runtime was not acquired");
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
}, 8_000);
it.each([true, false])(
"binds decoder replies to the complete selected provider identity (matching name: %s)",
async (matching) => {
@@ -0,0 +1,121 @@
import { expect, it } from "vitest";
import { createBrowserEvidence } from "../../../src/application/BrowserEvidence.js";
import { CDP_BROWSER_PROVIDER_IDENTITY } from "../../../src/browser/providerIdentities.js";
import { listBrowserTargetsInputSchema } from "../../../src/domain/browserObservation.js";
import { browserTargetListSchema } from "../../../src/domain/browserObservationSchemas.js";
import {
analyzeWebBundleInputSchema,
webBundleAnalysisSchema,
} from "../../../src/domain/webBundleAnalysis.js";
const targetList = browserTargetListSchema.parse({
browser: {
product: "FakeChrome/1.0",
protocol_version: "1.3",
revision: "fake-revision",
user_agent: "FakeChrome",
js_version: "13.0",
},
targets: [],
excluded: { disallowed_origin: 0, unsupported_url: 0, non_page: 0 },
limitations: [],
});
const bundle = webBundleAnalysisSchema.parse({
capture: {
target_url: "https://example.test/",
scripts_observed: 0,
scripts_analyzed: 0,
source_artifacts: [],
},
observations: {
chunks: { nodes: [], edges: [] },
routes: [],
endpoints: [],
webmcp_declarations: [],
source_maps: {
status: "not_requested",
requested: 0,
processed: 0,
items: [],
},
},
inferences: [],
unknowns: [],
completeness: {
status: "complete",
parsed_scripts: 0,
parse_failures: 0,
visited_ast_nodes: 0,
},
limitations: [],
});
it("records an explicit command deadline and omits the default", () => {
const base = {
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page",
fetch_source_maps: false,
};
const explicit = createBrowserEvidence(
"analyze_web_bundle",
analyzeWebBundleInputSchema.parse({
...base,
command_timeout_ms: 1_500,
}),
bundle,
CDP_BROWSER_PROVIDER_IDENTITY,
);
expect(explicit.parameters.command_timeout_ms).toBe(1_500);
const omitted = createBrowserEvidence(
"analyze_web_bundle",
analyzeWebBundleInputSchema.parse(base),
bundle,
CDP_BROWSER_PROVIDER_IDENTITY,
);
expect(omitted.parameters).not.toHaveProperty("command_timeout_ms");
});
it("rejects a command deadline on target listing and leaves it out of Evidence", () => {
expect(
listBrowserTargetsInputSchema.safeParse({
cdp_endpoint: "http://127.0.0.1:9222",
command_timeout_ms: 1_500,
}).success,
).toBe(false);
const evidence = createBrowserEvidence(
"list_browser_targets",
listBrowserTargetsInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
}),
targetList,
CDP_BROWSER_PROVIDER_IDENTITY,
);
expect(evidence.parameters).not.toHaveProperty("command_timeout_ms");
});
it("records an explicit source-map fetch deadline and omits the default", () => {
const base = {
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page",
fetch_source_maps: true,
};
const explicit = createBrowserEvidence(
"analyze_web_bundle",
analyzeWebBundleInputSchema.parse({
...base,
source_map_fetch_timeout_ms: 1_500,
}),
bundle,
CDP_BROWSER_PROVIDER_IDENTITY,
);
expect(explicit.parameters.source_map_fetch_timeout_ms).toBe(1_500);
const omitted = createBrowserEvidence(
"analyze_web_bundle",
analyzeWebBundleInputSchema.parse(base),
bundle,
CDP_BROWSER_PROVIDER_IDENTITY,
);
expect(omitted.parameters).not.toHaveProperty("source_map_fetch_timeout_ms");
});
@@ -548,7 +548,7 @@ describe("web source-map fetching and validation: enforcing fetch limits and val
{
status: "fetch_failed",
artifact: null,
limitation: "Source-map fetching exceeded its operation deadline.",
limitation: expect.stringContaining("source_map_fetch_timeout_ms"),
},
],
});
@@ -611,8 +611,7 @@ describe("web source-map fetching and validation: enforcing fetch limits and val
{
status: "fetch_failed",
artifact: null,
limitation:
"Source-map response exceeded the retained-byte budget.",
limitation: expect.stringContaining("retained budget"),
},
],
});
@@ -668,6 +667,59 @@ describe("web source-map fetching and validation: enforcing fetch limits and val
});
});
describe("selected source-map fetch deadline", () => {
it("applies a non-default source_map_fetch_timeout_ms from the public input", async () => {
let calls = 0;
let closed = 0;
const server = createServer((incoming, response) => {
calls += 1;
incoming.on("close", () => {
closed += 1;
});
const next =
Number(
new URL(incoming.url ?? "/", "http://local").searchParams.get("n") ??
"0",
) + 1;
response.writeHead(302, { location: `/map?n=${String(next)}` }).end();
});
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (address === null || typeof address === "string")
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const url = `${localOrigin}/map?n=0`;
const result = await fetchWebSourceMaps(
[{ ...request, fetchUrl: url, declaredUrl: url }],
input({
source_map_fetch_timeout_ms: 150,
allowed_origins: [localOrigin],
}),
undefined,
{ fetch },
);
expect(calls).toBeGreaterThan(1);
expect(closed).toBeGreaterThan(0);
expect(result.items[0]?.limitation).toContain("150");
expect(result.items[0]?.limitation).toContain(
"source_map_fetch_timeout_ms",
);
} finally {
server.closeAllConnections();
if (server.listening)
await new Promise<void>((resolve, reject) => {
server.close((error) =>
error === undefined ? resolve() : reject(error),
);
});
}
}, 3_000);
});
describe("browser map decoded validation", () => {
it("keeps decoded-index and coordinate failures local to their map", async () => {
const maps = [
@@ -79,9 +79,9 @@ it("retains timeout Evidence and recovers on the same MCP server", async () => {
const timeout = parseMcpToolError(interrupted).error;
expect(timeout).toMatchObject({
code: "provider_timeout",
message: expect.stringContaining("server/provider deadline"),
message: expect.stringContaining("total_analysis_timeout_ms"),
remediation: {
action: expect.stringContaining("provider diagnostics"),
action: expect.stringContaining("total_analysis_timeout_ms"),
},
recovery: {
kind: "inspect_timeout_state",
+28 -12
View File
@@ -36,18 +36,27 @@ export const startFakeCdpBrowser = async (
);
const webSockets = new WebSocketServer({ noServer: true });
http.on("upgrade", (request, socket, head) => {
if (
request.url !== "/devtools/browser/fake" &&
request.url !== "/devtools/page/allowed-page" &&
request.url !== "/devtools/page/allowed-page-with-socket" &&
request.url !== "/devtools/page/electron-page"
) {
socket.destroy();
const accept = (): void => {
if (socket.destroyed) return;
if (
request.url !== "/devtools/browser/fake" &&
request.url !== "/devtools/page/allowed-page" &&
request.url !== "/devtools/page/allowed-page-with-socket" &&
request.url !== "/devtools/page/electron-page"
) {
socket.destroy();
return;
}
webSockets.handleUpgrade(request, socket, head, (webSocket) =>
webSockets.emit("connection", webSocket, request),
);
};
if (options.handshakeDelayMs === undefined) {
accept();
return;
}
webSockets.handleUpgrade(request, socket, head, (webSocket) =>
webSockets.emit("connection", webSocket, request),
);
const timer = setTimeout(accept, options.handshakeDelayMs);
socket.once("close", () => clearTimeout(timer));
});
webSockets.on("connection", (socket: WebSocket, request: IncomingMessage) => {
const directPage = request.url?.startsWith("/devtools/page/") === true;
@@ -146,8 +155,15 @@ const respondToHttpRequest = (
response.setHeader("content-type", "application/json");
const portNumber = port();
if (serveSourceMap(request, response, options)) return;
const finish = (body: string): void => {
if (options.discoveryDelayMs === undefined) {
response.end(body);
return;
}
setTimeout(() => response.end(body), options.discoveryDelayMs);
};
if (request.url === "/json/version") {
response.end(
finish(
options.oversizedDiscovery === true
? " ".repeat(65 * 1_024)
: options.malformedDiscovery === true
@@ -169,7 +185,7 @@ const respondToHttpRequest = (
return;
}
if (request.url === "/json/list") {
response.end(JSON.stringify(targets(portNumber, options)));
finish(JSON.stringify(targets(portNumber, options)));
return;
}
response.statusCode = 404;
+4
View File
@@ -46,6 +46,10 @@ export interface FakeOptions {
command: FakeCdpCommand,
origin: string,
) => readonly CdpEvent[] | undefined;
/** Delay each HTTP discovery document so a short command deadline can be observed separately. */
readonly discoveryDelayMs?: number;
/** Delay WebSocket upgrade acceptance so handshake and attach deadlines stay distinct. */
readonly handshakeDelayMs?: number;
readonly malformedDiscovery?: boolean;
readonly oversizedDiscovery?: boolean;
readonly invalidBrowserWebSocket?: boolean;