fix(browser): stop late navigation before releasing attached sessions

This commit is contained in:
morluto committed 2026-10-20 17:18:17 +08:00
1 parent 696832caa3
commit 599fdac029
6 files changed
+1024 -47

No files matched your search

@@ -821,7 +821,8 @@ it("shares concurrent close and retries browser cleanup after failure", async ()
.mockResolvedValue(undefined);
const cleanupSettlements: boolean[] = [];
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: browserClose,
closeBrowser: (disconnectAtBoundary) =>
disconnectAtBoundary(() => browserClose()),
removeProfile,
onSettled: (browserClosed) => cleanupSettlements.push(browserClosed),
});
@@ -851,7 +852,8 @@ it("keeps failed event finalization failed across cleanup retries", async () =>
.fn<() => Promise<void>>()
.mockResolvedValue(undefined);
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser,
closeBrowser: (disconnectAtBoundary) =>
disconnectAtBoundary(() => closeBrowser()),
removeProfile: undefined,
});
@@ -924,7 +926,8 @@ it.each([false, true])(
);
let denied = true;
const owner = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: async () => {},
closeBrowser: (disconnectAtBoundary) =>
disconnectAtBoundary(async () => undefined),
removeProfile: async () => {
if (denied) throw new Error("profile removal denied");
},
@@ -1099,7 +1102,8 @@ it.each([false, true])(
const caller = new AbortController();
const reason = { caller: "during final browser close" };
const owner = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: () => acknowledgement,
closeBrowser: (disconnectAtBoundary) =>
disconnectAtBoundary(() => acknowledgement),
removeProfile: undefined,
});
const session = new FakeSession("launch");
+190 -4
View File
@@ -135,9 +135,9 @@ it("keeps attached-target ownership after the cleanup response timeout", async (
const closeResult = deferred<void>();
const settled: boolean[] = [];
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: () => {
closeBrowser: async (disconnectAtBoundary) => {
closeStarted.resolve(undefined);
return closeResult.promise;
await disconnectAtBoundary(() => closeResult.promise);
},
removeProfile: undefined,
onSettled: (browserClosed) => settled.push(browserClosed),
@@ -399,6 +399,50 @@ it("restores its environment overrides before releasing an attached browser", as
]);
});
it("checks a late stop after attached restoration and before transport disconnect", async () => {
const restorationStarted = deferred<void>();
const finishRestoration = deferred<void>();
const order: string[] = [];
const fixture = attachedBrowserFixture({
send: async (method, parameters) => {
if (
method === "Emulation.setTimezoneOverride" &&
(parameters as { timezoneId?: string }).timezoneId === ""
) {
order.push("restore-started");
restorationStarted.resolve(undefined);
await finishRestoration.promise;
order.push("restore-finished");
}
},
closeBrowser: async () => {
order.push("browser-disconnected");
},
});
const opened = await fixture.open();
let revision = 0;
const release = opened.cleanup.release(undefined, {
get revision() {
return revision;
},
async run() {
order.push(`gate:${revision}`);
},
});
await restorationStarted.promise;
revision += 1;
finishRestoration.resolve(undefined);
await release;
expect(order).toEqual([
"restore-started",
"restore-finished",
"gate:1",
"browser-disconnected",
]);
});
it("retains attached environment ownership until a failed reset can be retried", async () => {
let localeResetAttempts = 0;
const fixture = attachedBrowserFixture({
@@ -701,9 +745,12 @@ it("tracks only pending browser cleanup resources and resolves prior errors afte
let profileDenied = true;
let browserCloses = 0;
const owner = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: async () => {
closeBrowser: async (disconnectAtBoundary) => {
browserCloses++;
if (browserDenied) throw new Error("controlled transport close failure");
await disconnectAtBoundary(async () => {
if (browserDenied)
throw new Error("controlled transport close failure");
});
},
removeProfile: async () => {
if (profileDenied) throw new Error("controlled profile removal failure");
@@ -743,6 +790,145 @@ it("tracks only pending browser cleanup resources and resolves prior errors afte
}
});
it("rechecks the browser release gate after finalization changes its revision", async () => {
const eventsStarted = deferred<void>();
const finishEvents = deferred<void>();
const gateStarted = deferred<void>();
const finishGate = deferred<void>();
const closeBrowser = vi.fn(
async (
disconnectAtBoundary: (disconnect: () => Promise<void>) => Promise<void>,
) => disconnectAtBoundary(async () => undefined),
);
const owner = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser,
removeProfile: undefined,
});
let revision = 0;
const gateRuns: number[] = [];
const release = owner.release(
async () => {
eventsStarted.resolve(undefined);
await finishEvents.promise;
},
{
get revision() {
return revision;
},
async run() {
gateRuns.push(revision);
if (gateRuns.length === 1) {
gateStarted.resolve(undefined);
await finishGate.promise;
}
},
},
);
await eventsStarted.promise;
finishEvents.resolve(undefined);
await gateStarted.promise;
revision += 1;
finishGate.resolve(undefined);
await release;
expect(gateRuns).toEqual([0, 1]);
expect(closeBrowser).toHaveBeenCalledTimes(1);
expect(owner.browserClosed).toBe(true);
});
it("runs the latest stop gate after attached restoration and before disconnect", async () => {
const restorationStarted = deferred<void>();
const finishRestoration = deferred<void>();
const gateRevisions: number[] = [];
let revision = 0;
let disconnected = false;
const owner = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: async (disconnectAtBoundary) => {
restorationStarted.resolve(undefined);
await finishRestoration.promise;
await disconnectAtBoundary(async () => {
disconnected = true;
});
},
removeProfile: undefined,
});
const release = owner.release(undefined, {
get revision() {
return revision;
},
async run() {
gateRevisions.push(revision);
},
});
await restorationStarted.promise;
revision += 1;
finishRestoration.resolve(undefined);
await release;
expect(gateRevisions).toEqual([1]);
expect(disconnected).toBe(true);
expect(owner.browserClosed).toBe(true);
});
it.each([false, true])(
"retains a failed release gate when its revision changes: %s",
async (changeRevision) => {
let transportClosed = false;
const closeBrowser = vi.fn(
async (
disconnectAtBoundary: (
disconnect: () => Promise<void>,
) => Promise<void>,
) =>
disconnectAtBoundary(async () => {
transportClosed = true;
}),
);
const owner = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser,
removeProfile: undefined,
});
const failure = new Error("navigation stop was not confirmed");
const gateStarted = deferred<void>();
const finishGate = deferred<void>();
let revision = 0;
let failGate = true;
const gate = {
get revision() {
return revision;
},
async run() {
if (failGate) {
failGate = false;
gateStarted.resolve(undefined);
await finishGate.promise;
throw failure;
}
},
};
const release = owner.release(undefined, gate).then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await gateStarted.promise;
if (changeRevision) revision += 1;
finishGate.resolve(undefined);
await expect(release).resolves.toEqual({ ok: false, cause: failure });
expect(closeBrowser).toHaveBeenCalledTimes(1);
expect(transportClosed).toBe(false);
expect(owner.browserClosed).toBe(false);
expect(owner.cleanupResources).toContain("browser_transport");
await owner.release(undefined, gate);
expect(closeBrowser).toHaveBeenCalledTimes(2);
expect(transportClosed).toBe(true);
expect(owner.browserClosed).toBe(true);
},
);
it("bounds an opening failure response while retaining the same late cleanup lease", async () => {
vi.useFakeTimers();
let acknowledge: () => void = () => {};
+47 -8
View File
@@ -43,9 +43,17 @@ export interface OpenedScenarioBrowser {
readonly cleanup: PlaywrightScenarioBrowserCleanupOwner;
}
/** A retryable obligation that must hold before the browser transport closes. */
export interface PlaywrightScenarioBrowserReleaseGate {
readonly revision: number;
run(): Promise<void>;
}
/** Provider actions retained until the owned browser and profile are released. */
interface PlaywrightScenarioCleanupResources {
readonly closeBrowser: () => Promise<void>;
readonly closeBrowser: (
disconnectAtBoundary: (disconnect: () => Promise<void>) => Promise<void>,
) => Promise<void>;
readonly removeProfile: (() => Promise<void>) | undefined;
readonly onSettled?: (browserClosed: boolean) => void;
}
@@ -56,6 +64,7 @@ export class PlaywrightScenarioBrowserCleanupOwner {
#profileRemoved: boolean;
#eventFinalizationPromise: Promise<void> | undefined;
#eventsFinalized = false;
#releaseGate: PlaywrightScenarioBrowserReleaseGate | undefined;
readonly cleanupLease: AnalysisCleanupLease;
/** Keep the narrow provider actions needed to release one opened browser. */
@@ -83,13 +92,20 @@ export class PlaywrightScenarioBrowserCleanupOwner {
}
/** Await actual resource release, including a late browser acknowledgement. */
release(finishEvents?: () => Promise<void>): Promise<void> {
release(
finishEvents?: () => Promise<void>,
releaseGate?: PlaywrightScenarioBrowserReleaseGate,
): Promise<void> {
this.#prepareEvents(finishEvents);
if (releaseGate !== undefined) this.#releaseGate = releaseGate;
return this.cleanupLease.release();
}
close(finishEvents?: () => Promise<void>): Promise<void> {
return this.#awaitCleanup(this.release(finishEvents));
close(
finishEvents?: () => Promise<void>,
releaseGate?: PlaywrightScenarioBrowserReleaseGate,
): Promise<void> {
return this.#awaitCleanup(this.release(finishEvents, releaseGate));
}
#prepareEvents(finishEvents?: () => Promise<void>): void {
@@ -131,7 +147,9 @@ export class PlaywrightScenarioBrowserCleanupOwner {
}
if (!this.#browserClosed) {
try {
await this.resources.closeBrowser();
await this.resources.closeBrowser((disconnect) =>
this.#runReleaseGate(disconnect),
);
this.#browserClosed = true;
} catch (cause: unknown) {
failures.push(cause);
@@ -153,6 +171,25 @@ export class PlaywrightScenarioBrowserCleanupOwner {
if (failures.length > 1)
throw new AggregateError(failures, "Browser session cleanup failed");
}
async #runReleaseGate(disconnect: () => Promise<void>): Promise<void> {
for (;;) {
const gate = this.#releaseGate;
if (gate === undefined) {
await disconnect();
return;
}
const revision = gate.revision;
await gate.run();
if (this.#releaseGate === gate && gate.revision === revision) {
// Invoke the physical disconnect in the same continuation that
// observes a stable stop revision; no await separates the check and
// the operation start.
await disconnect();
return;
}
}
}
}
interface AttachedScenarioWaiter {
@@ -566,7 +603,7 @@ const openAttachedScenarioBrowser = async (
const browser = await launcher.connectOverCDP(browserRequest.cdp_endpoint);
const cleanup: PlaywrightScenarioBrowserCleanupOwner =
new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: async () => {
closeBrowser: async (disconnectAtBoundary) => {
let environmentFailure: unknown;
const ownedEnvironment = environmentOwner;
if (ownedEnvironment !== undefined) {
@@ -590,7 +627,7 @@ const openAttachedScenarioBrowser = async (
}
}
try {
await browser.close();
await disconnectAtBoundary(() => browser.close());
// Once target state is restored, disconnecting the transport is a
// definitive fallback for an explicit session detach failure.
environmentOwner = undefined;
@@ -719,7 +756,9 @@ export const openPlaywrightScenarioBrowser = async (
}
}
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: () => context.close(),
closeBrowser: async (disconnectAtBoundary) => {
await disconnectAtBoundary(() => context.close());
},
removeProfile: () =>
rm(profilePath, { recursive: true, force: true, maxRetries: 3 }),
});
+46 -13
View File
@@ -51,6 +51,8 @@ export class PlaywrightScenarioServiceWorkers {
#released = false;
#detached = false;
#loadingStopped = false;
#stopRequested = false;
#stopPromise: Promise<void> | undefined;
#removeListener: (() => void) | undefined;
#setup = new AbortController();
@@ -276,9 +278,11 @@ export class PlaywrightScenarioServiceWorkers {
private async stopMainFrame(frame: Frame): Promise<void> {
try {
await frame.evaluate("globalThis.stop()");
this.#loadingStopped = true;
} catch (cause: unknown) {
if (!this.page.isClosed() && !frame.isDetached()) throw cause;
// A closed page no longer has an active navigation to stop. On a live
// page, including a detached frame, preserve failure so retry can use a
// fresh main-frame reference.
if (!this.page.isClosed()) throw cause;
}
}
@@ -313,9 +317,10 @@ export class PlaywrightScenarioServiceWorkers {
close(stopLoading = false): Promise<void> {
this.#closed = true;
this.#stopRequested ||= stopLoading;
// Abort discovery/handshakes and reads, never an already-sent page mutation.
this.#setup.abort();
const restoration = (this.#restoration ??= this.restore(stopLoading));
const restoration = (this.#restoration ??= this.restore());
void restoration.then(
() => {
if (this.#restoration === restoration) this.#restoration = undefined;
@@ -324,13 +329,48 @@ export class PlaywrightScenarioServiceWorkers {
if (this.#restoration === restoration) this.#restoration = undefined;
},
);
// Restoration completion does not discharge a stop request that arrived
// after that restoration began. Both paths share the owner's stop latch.
const completion = restoration.then(() => this.fulfillStopRequest());
return withPlaywrightExecutionBoundary(
() => restoration,
() => completion,
POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS,
);
}
private async restore(stopLoading: boolean): Promise<void> {
private fulfillStopRequest(): Promise<void> {
if (!this.#stopRequested || this.#loadingStopped) return Promise.resolve();
if (this.#stopPromise !== undefined) return this.#stopPromise;
const stopping = (async () => {
if (this.page.isClosed()) {
this.#loadingStopped = true;
return;
}
const session = this.#session;
if (
!this.#released &&
!this.#detached &&
session?.sessionId !== undefined
) {
await this.send(session.sessionId, "Page.stopLoading");
} else {
await this.stopMainFrame(this.page.mainFrame());
}
this.#loadingStopped = true;
})();
this.#stopPromise = stopping;
void stopping.then(
() => {
if (this.#stopPromise === stopping) this.#stopPromise = undefined;
},
() => {
if (this.#stopPromise === stopping) this.#stopPromise = undefined;
},
);
return stopping;
}
private async restore(): Promise<void> {
await this.#initialization?.catch(() => undefined);
if (this.#released) return;
const session = this.#session;
@@ -339,14 +379,7 @@ export class PlaywrightScenarioServiceWorkers {
const mainFrame = this.page.mainFrame();
// This owner exists even with empty storage seeds. Stopped navigation is
// independent of storage and restoration never waits for a load event.
if (stopLoading && !this.#loadingStopped) {
if (!this.#detached && session.sessionId !== undefined) {
await this.send(session.sessionId, "Page.stopLoading");
this.#loadingStopped = true;
} else {
await this.stopMainFrame(mainFrame);
}
}
await this.fulfillStopRequest();
await Promise.all(
[...this.#targets.values()].map((target) =>
this.settleTargetForRestore(target),
+661 -6
View File
@@ -65,7 +65,7 @@ vi.mock("./PlaywrightScenarioStorage.js", () => ({
vi.mock("./PlaywrightScenarioEvents.js", () => ({
PlaywrightScenarioEvents: class {
finish() {
mocks.finishEvents();
return mocks.finishEvents();
}
result() {
@@ -137,10 +137,22 @@ const fixture = () => {
version: () => "Chrome 154",
});
let browserClosed = false;
const close = vi.fn(async (finishEvents?: () => Promise<void>) => {
await finishEvents?.();
browserClosed = true;
});
const close = vi.fn(
async (
finishEvents?: () => Promise<void>,
releaseGate?: { readonly revision: number; run(): Promise<void> },
) => {
await finishEvents?.();
if (releaseGate !== undefined) {
for (;;) {
const revision = releaseGate.revision;
await releaseGate.run();
if (releaseGate.revision === revision) break;
}
}
browserClosed = true;
},
);
const cleanup = {
close,
release: close,
@@ -226,6 +238,41 @@ it("stops attached navigation without storage and retries rejected policy cleanu
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("stops attached navigation when cancellation precedes the first close", async () => {
const { cleanup, page } = fixture();
mocks.storage = undefined;
const cancellation = new AbortController();
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
storage: {},
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
cancellation.abort();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(1);
expect(
page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
),
).toHaveLength(0);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("assigns attached navigation stopping to the storage owner once", async () => {
const { cleanup, storage } = fixture();
const attached = browserScenarioSchema.parse({
@@ -318,7 +365,7 @@ it("transfers a late stop request after storage has already released", async ()
expect(cleanup.close).not.toHaveBeenCalled();
cancellation.abort();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(storage?.close.mock.calls).toEqual([[false], [true]]);
expect(storage?.close.mock.calls).toEqual([[false]]);
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
@@ -327,6 +374,614 @@ it("transfers a late stop request after storage has already released", async ()
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it.each(["pending", "settled"] as const)(
"honors a late stop after policy restoration is %s",
async (phase) => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
let finishRestoration: () => void = () => undefined;
let restorationStarted: () => void = () => undefined;
const held = new Promise<void>((resolve) => {
finishRestoration = resolve;
});
const started = new Promise<void>((resolve) => {
restorationStarted = resolve;
});
let signalTransportClosed: () => void = () => undefined;
const transportClosed = new Promise<void>((resolve) => {
signalTransportClosed = resolve;
});
mocks.policyTransportClose.mockImplementation(async () => {
signalTransportClosed();
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
vi.useFakeTimers();
try {
const firstClose = session.close(false).then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await started;
await vi.advanceTimersByTimeAsync(5_001);
await expect(firstClose).resolves.toMatchObject({
ok: false,
cause: { cleanupIncomplete: true },
});
expect(cleanup.close).not.toHaveBeenCalled();
if (phase === "settled") {
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
}
cancellation.abort();
const retry = session.close();
if (phase === "pending") {
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
}
await expect(retry).resolves.toBe("disconnected-external");
} finally {
vi.useRealTimers();
}
expect(storage?.close.mock.calls).toEqual([[false]]);
const policyStops = mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
).length;
const frameStops = page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
).length;
expect(policyStops + frameStops).toBe(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
expect(mocks.policyTransportClose).toHaveBeenCalledTimes(1);
},
);
it("transfers cancellation to a no-stop cleanup already in progress", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
let finishRestoration: () => void = () => undefined;
let restorationStarted: () => void = () => undefined;
const held = new Promise<void>((resolve) => {
finishRestoration = resolve;
});
const started = new Promise<void>((resolve) => {
restorationStarted = resolve;
});
let browserWasOpenAtStop = false;
page.evaluate.mockImplementation(async (script: unknown) => {
if (script === "globalThis.stop()")
browserWasOpenAtStop = cleanup.browserClosed === false;
return null;
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
if (method === "Page.stopLoading")
browserWasOpenAtStop = cleanup.browserClosed === false;
return policyResponse(method);
});
const firstClose = session.close(false);
await started;
cancellation.abort();
const retry = session.close();
finishRestoration();
await expect(Promise.all([firstClose, retry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
const policyStops = mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
).length;
const frameStops = page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
).length;
expect(policyStops + frameStops).toBe(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
expect(browserWasOpenAtStop).toBe(true);
});
it("does not repeat a stop confirmed by storage during a late retry", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: true });
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
let finishRestoration: () => void = () => undefined;
let restorationStarted: () => void = () => undefined;
const held = new Promise<void>((resolve) => {
finishRestoration = resolve;
});
const started = new Promise<void>((resolve) => {
restorationStarted = resolve;
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
const firstClose = session.close(false);
await started;
cancellation.abort();
const retry = session.close();
finishRestoration();
await expect(Promise.all([firstClose, retry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
expect(storage?.close).toHaveBeenCalledWith(false);
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(0);
expect(
page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
),
).toHaveLength(0);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("transfers cancellation arriving during final event cleanup", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
let finishEvents: () => void = () => undefined;
let finishStarted: () => void = () => undefined;
const heldEvents = new Promise<void>((resolve) => {
finishEvents = resolve;
});
const eventsStarted = new Promise<void>((resolve) => {
finishStarted = resolve;
});
mocks.finishEvents.mockImplementation(async () => {
finishStarted();
await heldEvents;
});
let browserWasOpenAtStop = false;
page.evaluate.mockImplementation(async (script: unknown) => {
if (script === "globalThis.stop()")
browserWasOpenAtStop = cleanup.browserClosed === false;
return null;
});
const firstClose = session.close(false);
await eventsStarted;
cancellation.abort();
const retry = session.close();
finishEvents();
await expect(Promise.all([firstClose, retry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
expect(page.evaluate).toHaveBeenCalledWith("globalThis.stop()");
expect(browserWasOpenAtStop).toBe(true);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("stops a connected page when service workers are allowed", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "allow" },
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
let finishEvents: () => void = () => undefined;
let finishStarted: () => void = () => undefined;
const heldEvents = new Promise<void>((resolve) => {
finishEvents = resolve;
});
const eventsStarted = new Promise<void>((resolve) => {
finishStarted = resolve;
});
mocks.finishEvents.mockImplementation(async () => {
finishStarted();
await heldEvents;
});
let browserWasOpenAtStop = false;
page.evaluate.mockImplementation(async (script: unknown) => {
if (script === "globalThis.stop()")
browserWasOpenAtStop = cleanup.browserClosed === false;
return null;
});
const firstClose = session.close(false);
await eventsStarted;
cancellation.abort();
const retry = session.close();
finishEvents();
await expect(Promise.all([firstClose, retry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
expect(page.evaluate).toHaveBeenCalledTimes(1);
expect(browserWasOpenAtStop).toBe(true);
expect(storage?.close).toHaveBeenCalledWith(false);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("stops a connected page without storage or a service-worker policy owner", async () => {
const { cleanup, page } = fixture();
mocks.storage = undefined;
const cancellation = new AbortController();
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "allow" },
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
let finishEvents: () => void = () => undefined;
let finishStarted: () => void = () => undefined;
const heldEvents = new Promise<void>((resolve) => {
finishEvents = resolve;
});
const eventsStarted = new Promise<void>((resolve) => {
finishStarted = resolve;
});
mocks.finishEvents.mockImplementation(async () => {
finishStarted();
await heldEvents;
});
const firstClose = session.close(false);
await eventsStarted;
cancellation.abort();
const retry = session.close();
finishEvents();
await expect(Promise.all([firstClose, retry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
expect(page.evaluate).toHaveBeenCalledWith("globalThis.stop()");
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("retries a failed connected-page stop when service workers are allowed", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "allow" },
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
let finishEvents: () => void = () => undefined;
let finishStarted: () => void = () => undefined;
const heldEvents = new Promise<void>((resolve) => {
finishEvents = resolve;
});
const eventsStarted = new Promise<void>((resolve) => {
finishStarted = resolve;
});
mocks.finishEvents.mockImplementation(async () => {
finishStarted();
await heldEvents;
});
const failedFrame = {
isDetached: () => false,
evaluate: vi.fn(async () => {
throw new Error("main frame stop failed");
}),
} as unknown as ReturnType<typeof page.mainFrame>;
const freshFrame = {
isDetached: () => false,
evaluate: vi.fn(async () => undefined),
} as unknown as ReturnType<typeof page.mainFrame>;
vi.spyOn(page, "mainFrame")
.mockReturnValueOnce(failedFrame)
.mockReturnValue(freshFrame);
const firstClose = session.close(false);
await eventsStarted;
cancellation.abort();
const concurrentClose = session.close();
finishEvents();
await expect(
Promise.allSettled([firstClose, concurrentClose]),
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ status: "rejected" }),
expect.objectContaining({ status: "rejected" }),
]),
);
expect(cleanup.browserClosed).toBe(false);
await expect(session.close()).resolves.toBe("disconnected-external");
expect(failedFrame.evaluate).toHaveBeenCalledTimes(1);
expect(freshFrame.evaluate).toHaveBeenCalledTimes(1);
expect(storage?.close).toHaveBeenCalledTimes(1);
expect(cleanup.close).toHaveBeenCalledTimes(2);
});
it("retains a late fallback stop failure for retry", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
let finishRestoration: () => void = () => undefined;
let restorationStarted: () => void = () => undefined;
const held = new Promise<void>((resolve) => {
finishRestoration = resolve;
});
const started = new Promise<void>((resolve) => {
restorationStarted = resolve;
});
let signalTransportClosed: () => void = () => undefined;
const transportClosed = new Promise<void>((resolve) => {
signalTransportClosed = resolve;
});
mocks.policyTransportClose.mockImplementation(async () => {
signalTransportClosed();
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
vi.useFakeTimers();
try {
const firstClose = session.close(false).then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await started;
await vi.advanceTimersByTimeAsync(5_001);
await expect(firstClose).resolves.toMatchObject({ ok: false });
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
cancellation.abort();
const stopFailure = new Error("late main-frame stop failed");
const detachedFrame = {
evaluate: vi.fn(async () => {
throw stopFailure;
}),
isDetached: () => true,
} as unknown as ReturnType<typeof page.mainFrame>;
const freshFrame = {
evaluate: vi.fn(async () => null),
isDetached: () => false,
} as unknown as ReturnType<typeof page.mainFrame>;
vi.spyOn(page, "mainFrame")
.mockReturnValueOnce(detachedFrame)
.mockReturnValue(freshFrame);
const failedRetry = session.close().then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await expect(failedRetry).resolves.toMatchObject({
ok: false,
cause: { cleanupIncomplete: true },
});
expect(cleanup.close).not.toHaveBeenCalled();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(detachedFrame.evaluate).toHaveBeenCalledWith("globalThis.stop()");
expect(freshFrame.evaluate).toHaveBeenCalledWith("globalThis.stop()");
} finally {
vi.useRealTimers();
}
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("coalesces concurrent late-stop close retries", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
let finishRestoration: () => void = () => undefined;
let restorationStarted: () => void = () => undefined;
const held = new Promise<void>((resolve) => {
finishRestoration = resolve;
});
const started = new Promise<void>((resolve) => {
restorationStarted = resolve;
});
let signalTransportClosed: () => void = () => undefined;
const transportClosed = new Promise<void>((resolve) => {
signalTransportClosed = resolve;
});
mocks.policyTransportClose.mockImplementation(async () => {
signalTransportClosed();
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
vi.useFakeTimers();
try {
const firstClose = session.close(false).then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await started;
await vi.advanceTimersByTimeAsync(5_001);
await expect(firstClose).resolves.toMatchObject({ ok: false });
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
cancellation.abort();
let finishStop: () => void = () => undefined;
let stopStarted: () => void = () => undefined;
const heldStop = new Promise<void>((resolve) => {
finishStop = resolve;
});
const startedStop = new Promise<void>((resolve) => {
stopStarted = resolve;
});
page.evaluate.mockImplementation(async (script: unknown) => {
if (script === "globalThis.stop()") {
stopStarted();
await heldStop;
}
return null;
});
const firstRetry = session.close();
await startedStop;
const secondRetry = session.close();
expect(
page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
),
).toHaveLength(1);
finishStop();
await expect(Promise.all([firstRetry, secondRetry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
} finally {
vi.useRealTimers();
}
expect(
page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
),
).toHaveLength(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("retains transferred navigation stopping after setup cleanup fails", async () => {
const { cleanup, storage } = fixture();
const cancellation = new AbortController();
+72 -12
View File
@@ -13,6 +13,7 @@ import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import {
failBrowserScenarioOperation,
openPlaywrightScenarioBrowser,
type PlaywrightScenarioBrowserReleaseGate,
type OpenedScenarioBrowser,
} from "./PlaywrightScenarioBrowser.js";
import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js";
@@ -83,9 +84,14 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
private readonly serviceWorkers: PlaywrightScenarioServiceWorkers | undefined;
private readonly signal: AbortSignal | undefined;
#storageReleased = false;
#storageCloseCompleted = false;
#storageStopConfirmed = false;
#stopLoading = false;
#stopRevision = 0;
readonly cleanupLease: AnalysisCleanupLease;
#policyStopsLoading = false;
#navigationStopConfirmed = false;
readonly #browserReleaseGate: PlaywrightScenarioBrowserReleaseGate;
readonly #onAbort = (): void => this.#recordStopRequest();
private constructor(
private readonly opened: OpenedScenarioBrowser,
@@ -106,6 +112,15 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
this.storage = options.storage;
this.serviceWorkers = options.serviceWorkers;
this.signal = options.signal;
const session = this;
this.#browserReleaseGate = {
get revision() {
return session.#stopRevision;
},
run: () => this.#fulfillNavigationStopRequest(),
};
if (this.signal?.aborted === true) this.#recordStopRequest();
else this.signal?.addEventListener("abort", this.#onAbort, { once: true });
this.version = opened.browser.version();
this.initialUrl = opened.page.url();
this.cleanupLease = new AnalysisCleanupLease(
@@ -457,7 +472,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
}
async close(stopLoading = this.signal?.aborted === true) {
this.#stopLoading ||= stopLoading;
if (stopLoading) this.#recordStopRequest();
try {
await withPlaywrightExecutionBoundary(
() => this.cleanupLease.release(),
@@ -480,31 +495,46 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
}
async #closeResources(): Promise<void> {
const stopLoading = this.#stopLoading || this.signal?.aborted === true;
const stopLoading = this.#stopLoading;
let storageFailure: { readonly cause: unknown } | undefined;
let policyFailure: { readonly cause: unknown } | undefined;
let browserFailure: { readonly cause: unknown } | undefined;
if (stopLoading && this.storage === undefined)
this.#policyStopsLoading = true;
if (!this.#storageReleased) {
if (!this.#storageCloseCompleted) {
try {
const result = await this.storage?.close(stopLoading);
if (stopLoading && result?.loadingStopped !== true)
this.#policyStopsLoading = true;
this.#storageStopConfirmed = result?.loadingStopped === true;
this.#storageCloseCompleted = true;
} catch (failure: unknown) {
storageFailure = { cause: failure };
if (stopLoading) this.#policyStopsLoading = true;
}
}
try {
if (!this.opened.cleanup.browserClosed)
await this.serviceWorkers?.close(this.#policyStopsLoading);
await this.serviceWorkers?.close(
stopLoading && !this.#storageStopConfirmed,
);
if (
stopLoading &&
!this.#storageStopConfirmed &&
this.serviceWorkers !== undefined
)
this.#navigationStopConfirmed = true;
} catch (failure: unknown) {
policyFailure = { cause: failure };
}
if (policyFailure === undefined) {
try {
await this.opened.cleanup.release(() => this.eventCapture.finish());
await this.#fulfillNavigationStopRequest();
} catch (failure: unknown) {
policyFailure = { cause: failure };
}
}
if (policyFailure === undefined) {
try {
await this.opened.cleanup.release(
() => this.eventCapture.finish(),
this.#browserReleaseGate,
);
} catch (failure: unknown) {
browserFailure = { cause: failure };
}
@@ -515,7 +545,10 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
browserFailure = { cause: failure };
}
}
if (this.opened.cleanup.browserClosed) this.#storageReleased = true;
if (this.opened.cleanup.browserClosed) {
this.#storageReleased = true;
this.signal?.removeEventListener("abort", this.#onAbort);
}
throwCleanupFailures([
...(policyFailure === undefined ? [] : [policyFailure.cause]),
...(this.#storageReleased || storageFailure === undefined
@@ -525,6 +558,33 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
]);
}
#recordStopRequest(): void {
if (!this.#stopLoading) this.#stopRevision += 1;
this.#stopLoading = true;
}
async #fulfillNavigationStopRequest(): Promise<void> {
if (
!this.#stopLoading ||
this.#storageStopConfirmed ||
this.#navigationStopConfirmed ||
this.mode === "launch"
)
return;
if (this.serviceWorkers !== undefined) {
await this.serviceWorkers.close(true);
} else if (this.opened.page.isClosed()) {
this.#navigationStopConfirmed = true;
return;
} else {
const frame = this.opened.page.mainFrame();
if (frame.isDetached())
throw new Error("Cannot stop navigation through a detached main frame");
await frame.evaluate("globalThis.stop()");
}
this.#navigationStopConfirmed = true;
}
redactError(error: unknown): string {
return this.secrets.redact(
error instanceof Error ? error.message : "browser action failed",