Files
rea/src/browser/PlaywrightElectronActiveProjection.ts
T
0155b89953 fix(browser): name web-bundle budgets and recover web deadlines (#6036)
* fix(browser): name web-bundle budgets and recover web deadlines

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

Refs #5910

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 05:47:38 +08:00

218 lines
8.2 KiB
TypeScript

import {
electronActiveObservationResultSchema,
type ElectronActiveObservationResult,
} from "../domain/javascript/electronActiveObservation.js";
import type {
ElectronHookEvent,
ElectronHookSnapshot,
ElectronMetrics,
} from "./PlaywrightElectronActiveActions.js";
type ElectronCapturePaths = {
readonly executable: string;
readonly application: string;
};
type ElectronCaptureState = {
readonly windows: ReadonlyArray<
ElectronActiveObservationResult["windows"][number]
>;
readonly metrics: ElectronMetrics;
readonly electronVersion: string;
readonly hookSnapshot: ElectronHookSnapshot;
};
const observableEventFamilies = [
"app-lifecycle",
"window-lifecycle",
"web-contents-lifecycle",
"navigation",
"shell-attempt",
"process-lifecycle",
"permission",
"popup-attempt",
"download",
"protocol",
"preload",
"preload-configuration",
"renderer-ipc",
"native-addon",
"updater",
"error",
"ipc",
] as const;
const ipcEventKinds = new Set<ElectronHookEvent["kind"]>([
"main-handler-invocation",
"main-event-invocation",
"utility-process-fork",
"utility-process-message",
"ipc-main-to-renderer",
"ipc-utility-to-main",
"ipc-renderer-send",
"ipc-renderer-invoke",
"ipc-renderer-post-message",
]);
const eventFamily = (
kind: ElectronHookEvent["kind"],
processType: string | null,
): string => {
if (
kind === "preload" &&
(processType === "main" || processType === "browser")
)
return "preload-configuration";
if (kind.startsWith("ipc-renderer"))
return processType === "renderer" ? "renderer-ipc" : "ipc";
return ipcEventKinds.has(kind) ? "ipc" : kind;
};
const normalizeObservations = (hookSnapshot: ElectronHookSnapshot) => {
const observedEventFamilies = [
...new Set([
...hookSnapshot.events.map(({ kind, process_type }) =>
eventFamily(kind, process_type),
),
...hookSnapshot.dropped_event_families.map(({ family }) => family),
]),
].sort();
const observedRoles = [
...new Set([
...hookSnapshot.events.flatMap(({ process_type, kind }) => [
...(process_type === null ? [] : [process_type]),
...(kind === "window-lifecycle" ? ["window"] : []),
...(kind === "web-contents-lifecycle" || kind === "navigation"
? ["web_contents"]
: []),
...(kind === "preload" && process_type === "preload"
? ["preload"]
: []),
...(kind.startsWith("ipc-renderer") && process_type === "renderer"
? ["renderer"]
: []),
]),
...hookSnapshot.dropped_event_roles.map(({ role }) => role),
]),
].sort();
return { observedEventFamilies, observedRoles };
};
const createCoverage = (
hookSnapshot: ElectronHookSnapshot,
observations: ReturnType<typeof normalizeObservations>,
) => {
const { observedEventFamilies, observedRoles } = observations;
const unavailableEventFamilies = new Set<string>(
observableEventFamilies.filter(
(family) => !observedEventFamilies.includes(family),
),
);
if (!observedRoles.includes("preload"))
unavailableEventFamilies.add("preload");
if (!observedRoles.includes("renderer"))
unavailableEventFamilies.add("renderer-ipc");
return {
status: hookSnapshot.hook_error ? "hook_conflict" : "partial_attach",
observed_event_families: observedEventFamilies,
unavailable_event_families: [...unavailableEventFamilies].sort(),
observed_roles: observedRoles,
pre_capture_activity: "unavailable",
} as const;
};
const hasUnattributedDroppedPreloadFamily = (
hookSnapshot: ElectronHookSnapshot,
): boolean =>
hookSnapshot.dropped_event_families.some(
({ family, count }) => family === "preload" && count > 0,
);
const hasObservedPreloadConfiguration = (
observations: ReturnType<typeof normalizeObservations>,
): boolean =>
observations.observedEventFamilies.includes("preload-configuration");
/** Project one provider hook snapshot into the canonical public result. */
export const projectElectronActiveCapture = (
paths: ElectronCapturePaths,
actions: ElectronActiveObservationResult["actions"],
state: ElectronCaptureState,
): ElectronActiveObservationResult => {
const { hookSnapshot } = state;
const observations = normalizeObservations(hookSnapshot);
const ipcEvents = hookSnapshot.events.filter(({ kind }) =>
ipcEventKinds.has(kind),
);
return electronActiveObservationResultSchema.parse({
application: {
executable_path: paths.executable,
application_path: paths.application,
electron_version: state.electronVersion,
process_ownership: "provider-owned",
cleanup: "terminated-owned-process",
},
actions,
windows: state.windows,
processes: {
items: state.metrics,
},
ipc: {
events: ipcEvents,
observed: hookSnapshot.observed_ipc,
dropped: hookSnapshot.dropped_ipc,
},
timeline: {
events: hookSnapshot.events,
observed: hookSnapshot.observed,
dropped: hookSnapshot.dropped,
},
retention: {
budget_bytes: hookSnapshot.retention_budget_bytes,
estimated_retained_bytes: hookSnapshot.estimated_retained_bytes,
event_serialized_byte_upper_bound:
hookSnapshot.event_serialized_byte_upper_bound,
retained: hookSnapshot.retained,
dropped: hookSnapshot.dropped,
dropped_event_families: hookSnapshot.dropped_event_families,
dropped_event_roles: hookSnapshot.dropped_event_roles,
observed_ipc: hookSnapshot.observed_ipc,
dropped_ipc: hookSnapshot.dropped_ipc,
observed_runtime: hookSnapshot.observed_runtime,
dropped_runtime: hookSnapshot.dropped_runtime,
},
coverage: createCoverage(hookSnapshot, observations),
limitations: [
"IPC payloads are represented by value shapes; payload values are never retained.",
...(observations.observedEventFamilies.includes("ipc-observation-gap")
? [
"IPC observation gaps have unobserved callback arguments and execution. Available ipc-observation-gap timeline records identify the callable, registration or removal boundary and channel where known; dropped gap records are counted by retention. Registration gaps do not prove IPC delivery, and removal gaps do not confirm which registration was removed.",
]
: []),
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
"The runtime timeline records lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.",
"The preload and renderer process contexts are not instrumented by the main-process -r hook; preload configuration and contextBridge API-shape events are main-boundary observations, not proof of renderer-side execution.",
...(!hasObservedPreloadConfiguration(observations)
? [
"Preload configuration was not exposed by the observed Electron window metadata. Preload paths and execution remain unavailable.",
]
: []),
...(hasUnattributedDroppedPreloadFamily(hookSnapshot)
? [
"Dropped preload-family events were not attributed to preload configuration; independently aggregated event families and roles cannot establish that relationship.",
]
: []),
"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 (${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.",
...(hookSnapshot.hook_error
? ["The active IPC hook could not be installed."]
: []),
],
});
};