fix(browser): retain initialization through cancelled startup (#10045)

fix(browser-scenario): retain startup work through cancellation
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 17:14:37 +08:00
1 parent 36334402de
commit 979552aa5c
4 files changed
+166 -4

No files matched your search

@@ -187,6 +187,92 @@ const launchScenario = browserScenarioSchema.parse({
actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }],
});
it("does not launch a browser for an already-cancelled opening", async () => {
const controller = new AbortController();
const reason = { caller: "cancelled before browser acquisition" };
controller.abort(reason);
const launch = vi.fn(async () => {
throw new Error("unexpected launch after cancellation");
});
await expect(
openPlaywrightScenarioBrowser(
launchScenario,
{},
{
signal: controller.signal,
launcher: {
launchPersistentContext: launch,
connectOverCDP: async () => {
throw new Error("Unexpected connection");
},
},
},
),
).rejects.toMatchObject({ reason: "cancelled", cause: reason });
expect(launch).not.toHaveBeenCalled();
});
it.each([false, true])(
"admits a late launch only while its caller is active (cancelled: %s)",
async (cancelled) => {
const controller = new AbortController();
const reason = { caller: "cancelled during browser launch" };
const started = deferred<void>();
const launched = deferred<BrowserContext>();
let profile = "";
const close = vi.fn(async () => undefined);
const newPage = vi.fn(async () => ({}) as Page);
const opening = openPlaywrightScenarioBrowser(
launchScenario,
{},
{
signal: controller.signal,
launcher: {
launchPersistentContext: (directory) => {
profile = directory;
started.resolve(undefined);
return launched.promise;
},
connectOverCDP: async () => {
throw new Error("Unexpected connection");
},
},
},
).then(
(value) => ({ ok: true as const, value }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await started.promise;
if (cancelled) controller.abort(reason);
launched.resolve({
close,
browser: () => ({}) as Browser,
pages: () => [],
newPage,
} as unknown as BrowserContext);
const result = await opening;
if (result.ok) {
try {
expect(cancelled).toBe(false);
expect(newPage).toHaveBeenCalledOnce();
expect(close).not.toHaveBeenCalled();
expect(result.value.profilePath).toBe(profile);
} finally {
await result.value.cleanup.close();
}
} else {
expect(cancelled).toBe(true);
expect(result.cause).toMatchObject({ reason: "cancelled" });
if (!(result.cause instanceof AnalysisError))
throw new Error("Expected typed cancellation");
expect(result.cause.cause).toBe(reason);
expect(newPage).not.toHaveBeenCalled();
}
expect(close).toHaveBeenCalledOnce();
await expect(access(profile)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it.each([
{ environment: {}, sandbox: true },
{ environment: { REA_BROWSER_NO_SANDBOX: "false" }, sandbox: true },
+5
View File
@@ -855,6 +855,7 @@ export const openPlaywrightScenarioBrowser = async (
readonly retainCleanup?: (lease: AnalysisCleanupLease) => void;
} = {},
): Promise<OpenedScenarioBrowser> => {
throwIfScenarioCancelled(options.signal);
// Load Playwright only when a scenario opens; MCP startup stays off its module graph.
const launcher =
options.launcher ?? (await import("playwright-core")).chromium;
@@ -869,6 +870,7 @@ export const openPlaywrightScenarioBrowser = async (
commandTimeoutMs,
);
throwIfScenarioCancelled(options.signal);
const profilePath = await mkdtemp(join(tmpdir(), "rea-browser-scenario-"));
let context: BrowserContext;
const executablePath = scenario.browser.executable_path;
@@ -877,6 +879,7 @@ export const openPlaywrightScenarioBrowser = async (
let launchStarted = false;
try {
await chmod(profilePath, 0o700);
throwIfScenarioCancelled(options.signal);
launchStarted = true;
context = await withScenarioCommandDeadline(
() =>
@@ -950,10 +953,12 @@ export const openPlaywrightScenarioBrowser = async (
rm(profilePath, { recursive: true, force: true, maxRetries: 3 }),
});
try {
throwIfScenarioCancelled(options.signal);
const browser = context.browser();
if (browser === null)
throw new BrowserObservationError(OPERATION, "protocol_error");
const page = context.pages()[0] ?? (await context.newPage());
throwIfScenarioCancelled(options.signal);
return {
context,
page,
@@ -1185,6 +1185,64 @@ it("finishes initialization cleanup independently of request cancellation", asyn
expect(retainCleanup).not.toHaveBeenCalled();
});
it.each([false, true])(
"joins pending cookie setup before releasing an attached browser (late rejection: %s)",
async (rejectCookies) => {
const { context, cleanup, storage } = fixture();
const controller = new AbortController();
const reason = { stop: "during cookie setup" };
const started = deferred();
const cookies = deferred();
context.addCookies.mockImplementationOnce(async () => {
started.resolve();
await cookies.promise;
if (rejectCookies)
throw new Error("cookie setup rejected after cancellation");
});
const input = browserScenarioSchema.parse({
...connectedScenario("allow"),
storage: {
cookies: [
{
name: "session",
http_only: true,
secure: true,
same_site: "Lax",
value: { source: "literal", value: "selected" },
destination: { url: "https://storage.example.test/" },
},
],
},
});
const provider = new PlaywrightBrowserScenarioProvider(
(scenario, options) =>
PlaywrightScenarioSession.open(scenario, {}, options),
);
const result = provider.captureScenario(input, {
signal: controller.signal,
});
try {
await started.promise;
controller.abort(reason);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(cleanup.close).not.toHaveBeenCalled();
cookies.resolve();
const outcome = await result;
if (outcome.ok)
throw new Error("Expected cancelled cookie initialization");
expect(outcome.error._tag).toBe("AnalysisCancelledError");
expect(outcome.error.cause).toBe(reason);
expect(outcome.error.cleanupIncomplete).toBe(false);
expect(cleanup.close).toHaveBeenCalledOnce();
expect(storage?.initialize).not.toHaveBeenCalled();
} finally {
cookies.resolve();
await result;
await provider.close();
}
},
);
it("can retry cleanup of a browser that opened after request cancellation", async () => {
const { opened } = fixture();
const closeBrowser = vi
+17 -4
View File
@@ -329,6 +329,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
): Promise<PlaywrightScenarioSession> {
let events: PlaywrightScenarioEvents | undefined;
let storage: PlaywrightScenarioStorage | undefined;
let pageInitialization: Promise<void> | undefined;
const serviceWorkers =
scenario.browser.mode === "connect" &&
scenario.environment.service_workers === "block"
@@ -341,10 +342,19 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
: undefined;
try {
await withPlaywrightExecutionBoundary(
async () => {
await serviceWorkers?.initialize();
await initializePage(opened.context, scenario, secrets);
},
() => serviceWorkers?.initialize() ?? Promise.resolve(),
undefined,
options.signal,
);
throwIfScenarioCancelled(options.signal);
const initializingPage = initializePage(
opened.context,
scenario,
secrets,
);
pageInitialization = initializingPage;
await withPlaywrightExecutionBoundary(
() => initializingPage,
undefined,
options.signal,
);
@@ -386,6 +396,9 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
storage !== undefined && options.signal?.aborted === true;
let policyStopsLoading = !storageStopsLoading;
const cleanup = async () => {
// Cancelling the wait cannot revoke an already-sent cookie mutation.
// Join its acknowledgement before releasing the browser transport.
await pageInitialization?.catch(() => undefined);
let storageFailure: { readonly cause: unknown } | undefined;
let policyFailure: { readonly cause: unknown } | undefined;
let browserFailure: { readonly cause: unknown } | undefined;