Files
rea/src/browser/PlaywrightScenarioStorage.ts
T
morluto 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

763 lines
24 KiB
TypeScript

import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import type { BrowserContext, CDPSession, Page } from "playwright-core";
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() });
const storageKeyResponse = z.object({ storageKey: z.string() });
const targetInfoResponse = z.object({
targetInfo: z.object({ targetId: z.string() }),
});
type StorageEventMethod =
| "Runtime.executionContextCreated"
| "Runtime.executionContextDestroyed"
| "Runtime.executionContextsCleared"
| "Debugger.scriptParsed"
| "Debugger.paused"
| "Target.attachedToTarget"
| "Target.detachedFromTarget"
| "Target.receivedMessageFromTarget";
const STORAGE_EVENT_METHODS = new Set<string>([
"Runtime.executionContextCreated",
"Runtime.executionContextDestroyed",
"Runtime.executionContextsCleared",
"Debugger.scriptParsed",
"Debugger.paused",
"Target.attachedToTarget",
"Target.detachedFromTarget",
"Target.receivedMessageFromTarget",
]);
const attachedTargetEvent = z.object({
sessionId: z.string(),
targetInfo: z.object({ type: z.string(), targetId: z.string() }),
});
const targetSessionEvent = z.object({ sessionId: z.string() });
const nestedMessageEvent = z.object({
sessionId: z.string(),
message: z.string(),
});
const nestedProtocolMessage = z.object({
id: z.number().optional(),
method: z.string().optional(),
params: z.unknown().optional(),
result: z.unknown().optional(),
error: z.object({ message: z.string() }).optional(),
});
const executionContextEvent = z.object({
context: z.object({
id: z.number(),
origin: z.string(),
name: z.string(),
auxData: z.object({ frameId: z.string().optional() }).optional(),
}),
});
const scriptParsedEvent = z.object({
scriptId: z.string(),
executionContextId: z.number(),
url: z.string(),
});
const pausedEvent = z.object({
callFrames: z.array(
z.object({
callFrameId: z.string(),
location: z.object({ scriptId: z.string() }),
}),
),
});
const evaluationResponse = z.object({
exceptionDetails: z.unknown().optional(),
});
const STORAGE_CLEANUP_TIMEOUT_MS = 1_000;
type StorageSeed = {
local: [string, string][];
session: [string, string][];
};
interface StorageTarget {
readonly parentId: string | undefined;
readonly topLevelTargetId: string;
readonly send: (
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
) => Promise<unknown>;
readonly contexts: Map<
number,
z.infer<typeof executionContextEvent>["context"]
>;
readonly scripts: Map<string, number>;
ready: Promise<void>;
waitingForDebugger: boolean;
}
type PendingReply = {
readonly sessionId: string;
readonly resolve: (value: unknown) => void;
readonly reject: (error: Error) => void;
};
type StorageInstallationOptions = {
readonly signal?: AbortSignal;
readonly retainCleanup?: (lease: AnalysisCleanupLease) => void;
};
type StoragePage = { readonly context: BrowserContext; readonly page: Page };
export type PlaywrightScenarioStorageCleanup = {
readonly loadingStopped: boolean;
};
/** Owns pre-script storage seeding and its CDP instrumentation for one scenario. */
export class PlaywrightScenarioStorage {
readonly #seeds = new Map<string, StorageSeed>();
readonly #initializedLocal = new Set<string>();
readonly #initializedSession = new Set<string>();
readonly #targets = new Map<string, StorageTarget>();
readonly #replies = new Map<number, PendingReply>();
readonly #sourceUrl = `rea-storage-${randomUUID()}`;
readonly #listeners: {
readonly method: StorageEventMethod;
readonly listener: (params: unknown) => void;
}[] = [];
#sequence = 0;
#generation = 0;
#closed = false;
#cleanupCompleted = false;
#loadingStopped = false;
#failure: BrowserObservationError | undefined;
#closePromise: Promise<PlaywrightScenarioStorageCleanup> | undefined;
#detachPromise: Promise<void> | undefined;
#rootInitialization: Promise<void> | undefined;
#work: Promise<void> = Promise.resolve();
#removeListeners: () => void = () => undefined;
private constructor(
private readonly cdp: CDPSession,
private readonly signal: AbortSignal | undefined,
private readonly commandTimeoutMs: number,
) {}
static async create(
storagePage: StoragePage,
scenario: BrowserScenario,
secrets: BrowserScenarioSecrets,
options: StorageInstallationOptions = {},
): Promise<PlaywrightScenarioStorage | undefined> {
const { signal, retainCleanup } = options;
const { context, page } = storagePage;
if (
scenario.storage.local_storage.length === 0 &&
scenario.storage.session_storage.length === 0
)
return undefined;
const commandTimeoutMs = scenarioCommandTimeoutMs(scenario);
const connection = context.newCDPSession(page);
let cdp: CDPSession;
try {
cdp = await withPlaywrightExecutionBoundary(
() =>
withScenarioCommandDeadline(
() => connection,
commandTimeoutMs,
"command",
"Target.attachToTarget",
),
undefined,
signal,
);
} catch (cause: unknown) {
if (signal?.aborted === true) {
let detached = false;
const lateCleanup = async (): Promise<void> => {
let session: CDPSession;
try {
session = await connection;
} catch {
return;
}
if (!detached) {
await session.detach();
detached = true;
}
};
const cleanup = new AnalysisCleanupLease(lateCleanup, [
"browser_storage_transport",
]);
retainCleanup?.(cleanup);
void withPlaywrightExecutionBoundary(
() => 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, commandTimeoutMs);
owner.addSeeds(scenario.storage.local_storage, "local", secrets);
owner.addSeeds(scenario.storage.session_storage, "session", secrets);
return owner;
}
async initialize(page: Page, context: BrowserContext): Promise<void> {
const initialization = this.initializeRoot(page, context);
this.#rootInitialization = initialization;
await withPlaywrightExecutionBoundary(
() => initialization,
undefined,
this.signal,
);
}
private async initializeRoot(
page: Page,
context: BrowserContext,
): Promise<void> {
const rootInfo = targetInfoResponse.parse(
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, 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);
const ready = this.initializeTarget(root);
root.ready = ready;
await ready;
}
private addSeeds(
blocks: BrowserScenario["storage"]["local_storage"],
kind: "local" | "session",
secrets: BrowserScenarioSecrets,
): void {
for (const { origin, entries } of blocks) {
const seed = this.#seeds.get(origin) ?? { local: [], session: [] };
seed[kind].push(
...entries.map(({ name, value }): [string, string] => [
name,
secrets.value(value),
]),
);
this.#seeds.set(origin, seed);
}
}
private listen(page: Page, context: BrowserContext): void {
const browser = context.browser();
const detached = (): void => this.targetDetached("");
page.on("close", detached);
context.on("close", detached);
browser?.on("disconnected", detached);
this.#removeListeners = () => {
page.off("close", detached);
context.off("close", detached);
browser?.off("disconnected", detached);
};
for (const method of [
"Runtime.executionContextCreated",
"Runtime.executionContextDestroyed",
"Runtime.executionContextsCleared",
"Debugger.scriptParsed",
"Debugger.paused",
"Target.attachedToTarget",
"Target.detachedFromTarget",
"Target.receivedMessageFromTarget",
] as const) {
const listener = (params: unknown): void =>
this.event("", method, params);
this.#listeners.push({ method, listener });
this.cdp.on(method, listener);
}
}
private createTarget(
parentId: string | undefined,
topLevelTargetId: string,
send: StorageTarget["send"],
): StorageTarget {
return {
parentId,
topLevelTargetId,
send,
contexts: new Map(),
scripts: new Map(),
ready: Promise.resolve(),
waitingForDebugger: false,
};
}
private async initializeTarget(target: StorageTarget): Promise<void> {
if (this.#closed) return;
await target.send("Page.enable");
if (this.#closed) return;
await target.send("Runtime.enable");
if (this.#closed) return;
await target.send("Debugger.enable");
if (this.#closed) return;
breakpointResponse.parse(
await target.send("Debugger.setBreakpointByUrl", {
url: this.#sourceUrl,
lineNumber: 1,
}),
);
if (this.#closed) return;
scriptIdentifierResponse.parse(
await target.send("Page.addScriptToEvaluateOnNewDocument", {
worldName: this.#sourceUrl,
source: `(() => {\n void 0;\n})()\n//# sourceURL=${this.#sourceUrl}`,
}),
);
if (this.#closed) return;
await target.send("Target.setAutoAttach", {
autoAttach: true,
waitForDebuggerOnStart: true,
flatten: false,
});
}
private sendObserved(
target: StorageTarget,
method: string,
params?: Record<string, unknown>,
): Promise<unknown> {
return withPlaywrightExecutionBoundary(
() => target.send(method, params),
undefined,
this.signal,
);
}
private event(
sessionId: string,
method: StorageEventMethod,
params: unknown,
): void {
if (this.#closed) return;
const target = this.#targets.get(sessionId);
if (target === undefined) return;
try {
if (method === "Runtime.executionContextCreated") {
const { context } = executionContextEvent.parse(params);
if (context.name === this.#sourceUrl)
target.contexts.set(context.id, context);
} else if (method === "Runtime.executionContextDestroyed") {
const { executionContextId } = z
.object({ executionContextId: z.number() })
.parse(params);
target.contexts.delete(executionContextId);
} else if (method === "Runtime.executionContextsCleared") {
target.contexts.clear();
target.scripts.clear();
} else if (method === "Debugger.scriptParsed") {
const script = scriptParsedEvent.parse(params);
if (script.url === this.#sourceUrl)
target.scripts.set(script.scriptId, script.executionContextId);
} else if (method === "Debugger.paused") {
const pause = pausedEvent.parse(params);
const frame = pause.callFrames[0];
// CDP resume is target-wide, so identify our private seed frame before
// doing any work that will resume the paused target.
if (frame === undefined || !target.scripts.has(frame.location.scriptId))
return;
this.#generation += 1;
this.#work = this.#work
.then(() => this.seed(target, pause))
.catch((cause: unknown) => {
if (sessionId === "" || this.#targets.get(sessionId) === target)
this.fail(cause);
});
} else if (method === "Target.attachedToTarget") {
this.#generation += 1;
this.attach(sessionId, attachedTargetEvent.parse(params));
} else if (method === "Target.detachedFromTarget") {
this.#generation += 1;
this.targetDetached(targetSessionEvent.parse(params).sessionId);
} else if (method === "Target.receivedMessageFromTarget") {
this.nestedMessage(nestedMessageEvent.parse(params));
}
} catch (cause: unknown) {
this.fail(cause);
}
}
private nestedMessage(event: z.infer<typeof nestedMessageEvent>): void {
const reply = nestedProtocolMessage.parse(JSON.parse(event.message));
if (reply.id !== undefined) {
const waiter = this.#replies.get(reply.id);
if (waiter?.sessionId !== event.sessionId) return;
this.#replies.delete(reply.id);
if (reply.error !== undefined)
waiter.reject(new Error(reply.error.message));
else waiter.resolve(reply.result);
return;
}
if (reply.method !== undefined && STORAGE_EVENT_METHODS.has(reply.method))
this.event(
event.sessionId,
reply.method as StorageEventMethod,
reply.params,
);
}
private fail(cause: unknown): void {
this.#generation += 1;
// 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(
target: StorageTarget,
frame: z.infer<typeof pausedEvent>["callFrames"][number],
): Promise<void> {
const contextId = target.scripts.get(frame.location.scriptId);
const context =
contextId === undefined ? undefined : target.contexts.get(contextId);
const seed =
context === undefined ? undefined : this.#seeds.get(context.origin);
if (
this.#closed ||
seed === undefined ||
context?.auxData?.frameId === undefined
)
return;
const storageKey = storageKeyResponse.parse(
await this.sendObserved(target, "Storage.getStorageKey", {
frameId: context.auxData.frameId,
}),
).storageKey;
const sessionKey = JSON.stringify([target.topLevelTargetId, storageKey]);
const local =
this.#initializedLocal.has(storageKey) || seed.local.length === 0
? []
: seed.local;
const session =
this.#initializedSession.has(sessionKey) || seed.session.length === 0
? []
: seed.session;
if (local.length === 0 && session.length === 0) return;
const payload = JSON.stringify({ local, session }).replaceAll(
"<",
"\\u003c",
);
const response = evaluationResponse.parse(
await this.sendObserved(target, "Debugger.evaluateOnCallFrame", {
callFrameId: frame.callFrameId,
expression: `(() => { const seed = ${payload}; for (const [name, value] of seed.local) localStorage.setItem(name, value); for (const [name, value] of seed.session) sessionStorage.setItem(name, value); })()`,
returnByValue: true,
}),
);
if (response.exceptionDetails !== undefined)
throw new BrowserObservationError(
"capture_browser_scenario",
"protocol_error",
{ detail: "The browser denied initial storage initialization." },
);
if (local.length > 0) this.#initializedLocal.add(storageKey);
if (session.length > 0) this.#initializedSession.add(sessionKey);
}
private async seed(
target: StorageTarget,
pause: z.infer<typeof pausedEvent>,
): Promise<void> {
const frame = pause.callFrames[0];
if (frame === undefined) return;
let primaryFailure: unknown;
let hasPrimaryFailure = false;
try {
await this.seedPausedFrame(target, frame);
} catch (cause: unknown) {
primaryFailure = cause;
hasPrimaryFailure = true;
}
let resumeFailure: unknown;
try {
await withPlaywrightExecutionBoundary(
() =>
target.send("Debugger.resume", undefined, STORAGE_CLEANUP_TIMEOUT_MS),
STORAGE_CLEANUP_TIMEOUT_MS,
);
} catch (cause: unknown) {
resumeFailure = cause;
}
if (hasPrimaryFailure && resumeFailure !== undefined)
throw new AggregateError(
[primaryFailure, resumeFailure],
"Storage initialization and debugger resume both failed",
{ cause: primaryFailure },
);
if (this.signal?.aborted === true && resumeFailure === undefined) return;
if (hasPrimaryFailure) throw primaryFailure;
if (resumeFailure !== undefined) throw resumeFailure;
}
private attach(
parentId: string,
event: z.infer<typeof attachedTargetEvent>,
): void {
const parent = this.#targets.get(parentId);
if (parent === undefined) return;
const { sessionId, targetInfo } = event;
const target = this.createTarget(
parentId,
event.targetInfo.type === "iframe"
? parent.topLevelTargetId
: event.targetInfo.targetId,
async (method, params = {}, timeoutMs = this.commandTimeoutMs) => {
const id = ++this.#sequence;
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 }),
},
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);
}
},
);
target.waitingForDebugger = true;
this.#targets.set(sessionId, target);
target.ready = (async () => {
try {
if (
!this.#closed &&
(targetInfo.type === "iframe" || targetInfo.type === "page")
)
await this.initializeTarget(target);
} catch (cause: unknown) {
if (
this.signal?.aborted !== true &&
!this.#closed &&
this.#targets.get(sessionId) === target
)
this.fail(cause);
}
if (this.#targets.get(sessionId) !== target) return;
try {
await withPlaywrightExecutionBoundary(
() =>
target.send(
"Runtime.runIfWaitingForDebugger",
undefined,
STORAGE_CLEANUP_TIMEOUT_MS,
),
STORAGE_CLEANUP_TIMEOUT_MS,
);
target.waitingForDebugger = false;
} catch (cause: unknown) {
if (
this.signal?.aborted !== true &&
!this.#closed &&
this.#targets.get(sessionId) === target
)
this.fail(cause);
}
})();
}
private targetDetached(sessionId: string): void {
for (const [id, target] of this.#targets)
if (target.parentId === sessionId) this.targetDetached(id);
this.#targets.delete(sessionId);
for (const [id, reply] of this.#replies)
if (reply.sessionId === sessionId) {
this.#replies.delete(id);
reply.reject(
new BrowserObservationError(
"capture_browser_scenario",
"disconnected",
),
);
}
this.#generation += 1;
}
async settle(): Promise<void> {
for (;;) {
const generation = this.#generation;
const work = this.#work;
const targets = [...this.#targets.entries()];
await withPlaywrightExecutionBoundary(
() => Promise.all(targets.map(([, target]) => target.ready)),
undefined,
this.signal,
);
await withPlaywrightExecutionBoundary(() => work, undefined, this.signal);
await Promise.resolve();
if (this.#failure !== undefined) throw this.#failure;
if (
generation === this.#generation &&
work === this.#work &&
targets.every(([id, target]) => this.#targets.get(id) === target)
)
return;
}
}
close(stopLoading = false): Promise<PlaywrightScenarioStorageCleanup> {
if (this.#cleanupCompleted)
return Promise.resolve({ loadingStopped: this.#loadingStopped });
if (this.#closePromise !== undefined) return this.#closePromise;
const closing = this.closeResources(stopLoading).catch((cause: unknown) => {
throw new BrowserObservationError(
"capture_browser_scenario",
"cleanup_failed",
{
cause,
cleanup: {
reason: cause instanceof Error ? cause.message : String(cause),
resources: ["browser_transport", "browser_storage_bootstrap"],
},
},
);
});
this.#closePromise = closing;
void closing.catch(() => {
if (this.#closePromise === closing) this.#closePromise = undefined;
});
return closing;
}
private async closeResources(
stopLoading: boolean,
): Promise<PlaywrightScenarioStorageCleanup> {
this.#closed = true;
let stopLoadingFailure: { readonly cause: unknown } | undefined;
if (stopLoading && !this.#loadingStopped) {
try {
const root = this.#targets.get("");
if (root !== undefined) {
await withPlaywrightExecutionBoundary(
() => this.cdp.send("Page.stopLoading"),
STORAGE_CLEANUP_TIMEOUT_MS,
);
this.#loadingStopped = true;
}
} catch (cause: unknown) {
stopLoadingFailure = { cause };
}
}
const work = this.#work;
const rootInitialization = this.#rootInitialization;
const targets = [...this.#targets.values()];
try {
await withPlaywrightExecutionBoundary(
() =>
Promise.all([
work.catch(() => undefined),
...(rootInitialization === undefined
? []
: [rootInitialization.catch(() => undefined)]),
...targets.map((target) => target.ready.catch(() => undefined)),
]),
STORAGE_CLEANUP_TIMEOUT_MS,
);
} catch {
// Detaching the owning CDP session below is the terminal release for
// scripts, breakpoints, debugger state, and attached child sessions.
}
let detach = this.#detachPromise;
if (detach === undefined) {
detach = Promise.resolve().then(() => this.cdp.detach());
this.#detachPromise = detach;
void detach.catch(() => {
if (this.#detachPromise === detach) this.#detachPromise = undefined;
});
}
await withPlaywrightExecutionBoundary(
() => detach,
STORAGE_CLEANUP_TIMEOUT_MS,
);
for (const { method, listener } of this.#listeners)
this.cdp.off(method, listener);
this.#removeListeners();
for (const reply of this.#replies.values())
reply.reject(
new BrowserObservationError("capture_browser_scenario", "disconnected"),
);
this.#targets.clear();
this.#replies.clear();
this.#cleanupCompleted = true;
if (stopLoadingFailure !== undefined) throw stopLoadingFailure.cause;
return { loadingStopped: this.#loadingStopped };
}
}