Skip to content

Commit ef4fda7

Browse files
authored
fix(browser): don't reuse a dead page/context lease in browser run (#324)
Thank you @Kaushik2003. Fixes #314. Dead CDP connections are detected on lease reuse and after a successful run whose snapshot sees a closed context. Also thank you @rohan911438 for #323 — same diagnosis; we landed this variant because it recovers at getPage time as well.
1 parent a740cbe commit ef4fda7

6 files changed

Lines changed: 117 additions & 10 deletions

File tree

src/browser/run/runner.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,40 @@ afterAll(async () => {
236236
}));
237237
});
238238

239+
it('signals a stale context when the post-snapshot fails with a closed-context error (webcmd#314)', async () => {
240+
const newCDPSession = context.newCDPSession.bind(context);
241+
let calls = 0;
242+
context.newCDPSession = ((...args: Parameters<BrowserContext['newCDPSession']>) => {
243+
calls += 1;
244+
if (calls === 2) return Promise.reject(new Error('Target page, context or browser has been closed'));
245+
return newCDPSession(...args);
246+
}) as BrowserContext['newCDPSession'];
247+
const onStaleContext = vi.fn();
248+
249+
const output = await run('return 7;', { snapshotDiff: true, onStaleContext });
250+
251+
expect(output.result).toBe(7);
252+
expect(output.warnings).toContainEqual(expect.objectContaining({
253+
code: 'BROWSER_RUN_SNAPSHOT_FAILED',
254+
}));
255+
expect(onStaleContext).toHaveBeenCalledTimes(1);
256+
});
257+
258+
it('does not signal a stale context for an unrelated post-snapshot failure', async () => {
259+
const newCDPSession = context.newCDPSession.bind(context);
260+
let calls = 0;
261+
context.newCDPSession = ((...args: Parameters<BrowserContext['newCDPSession']>) => {
262+
calls += 1;
263+
if (calls === 2) return Promise.reject(new Error('post snapshot failed'));
264+
return newCDPSession(...args);
265+
}) as BrowserContext['newCDPSession'];
266+
const onStaleContext = vi.fn();
267+
268+
await run('return 7;', { snapshotDiff: true, onStaleContext });
269+
270+
expect(onStaleContext).not.toHaveBeenCalled();
271+
});
272+
239273
it('does not expose page.snapshotForAI inside browser-run code', async () => {
240274
const output = await run('return typeof page.snapshotForAI;');
241275

src/browser/run/runner.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
type BrowserRunResult,
3434
type BrowserRunTimings,
3535
type BrowserRunWarning,
36+
isClosedContextError,
3637
} from './types.js';
3738

3839
export interface BrowserRunSessionScope {
@@ -650,6 +651,10 @@ export async function runBrowserProgram(
650651
code: 'BROWSER_RUN_SNAPSHOT_FAILED',
651652
message: normalizeExecutionError(snapshotError).message,
652653
});
654+
// The run itself already succeeded, so keep ok: true — but a closed-context
655+
// signature here means the connection is dying underneath it. Signal the
656+
// caller out-of-band so the dead Profile runtime doesn't get reused (#314).
657+
if (isClosedContextError(snapshotError)) options.onStaleContext?.();
653658
} finally {
654659
timings.snapshot_ms = (timings.snapshot_ms ?? 0) + Math.max(0, Date.now() - snapshotStartedAt);
655660
}

src/browser/run/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,21 @@ export interface BrowserRunOptions {
8888
snapshotMode?: SnapshotTreeMode;
8989
snapshotBaselineStore?: SnapshotBaselineStore;
9090
signal?: AbortSignal;
91+
/**
92+
* Called when a closed-context signature ("Target page, context or browser
93+
* has been closed") surfaces somewhere that doesn't itself fail the run —
94+
* currently, the post-run snapshot capture (webcmd#314). The run still
95+
* completes with `ok: true` (the program itself may have genuinely
96+
* succeeded), but the caller should treat the underlying Profile runtime as
97+
* dead so the next command on this Session isn't handed the same lease.
98+
*/
99+
onStaleContext?: () => void;
100+
}
101+
102+
/** Matches Playwright's "Target page, context or browser has been closed" error family. */
103+
export function isClosedContextError(error: unknown): boolean {
104+
const message = error instanceof Error ? error.message : String(error);
105+
return /Target page, context or browser has been closed/i.test(message);
91106
}
92107

93108
export interface BrowserRunLogEntry {

src/browser/runtime/local-cloak/actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command:
279279
snapshotDiff: command.noSnapshotDiff ? false : command.snapshotDiff,
280280
snapshotMode: command.snapshotMode === 'tree' ? 'tree' : 'act',
281281
snapshotBaselineStore: snapshotBaselineStore(manager),
282+
onStaleContext: () => manager.invalidateIfClosedContext(lease.profileId, scope.context),
282283
...(signal ? { signal } : {}),
283284
});
284285
return {

src/browser/runtime/local-cloak/session-manager.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,39 @@ describe('CloakSessionManager', () => {
850850
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
851851
});
852852

853+
it('invalidates a reused getPage() lease when the CDP liveness probe finds a dead context (webcmd#314)', async () => {
854+
const first = fakeContext();
855+
const replacement = fakeContext();
856+
replacement.context.pages.mockReturnValue([]);
857+
const launchPersistentContext = vi.fn()
858+
.mockResolvedValueOnce(first.context)
859+
.mockResolvedValueOnce(replacement.context);
860+
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
861+
862+
const firstLease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
863+
expect(firstLease.context).toBe(first.context);
864+
865+
// Simulate the issue's repro: isClosed() still reports false, but the
866+
// underlying CDP connection is dead, so the liveness probe used on reuse
867+
// (Browser.getWindowForTarget, via assertOwnedWindow) fails.
868+
first.cdp.send.mockImplementation(async (command: string) => {
869+
if (command === 'Browser.getWindowForTarget') {
870+
throw new Error('Target page, context or browser has been closed');
871+
}
872+
return {};
873+
});
874+
875+
// isClosed() still reports false right up to the reuse attempt — the fast
876+
// path's precondition holds; only the liveness probe catches the dead lease.
877+
expect(firstLease.page.isClosed()).toBe(false);
878+
const secondLease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
879+
880+
expect(secondLease.context).toBe(replacement.context);
881+
expect(secondLease.page).not.toBe(firstLease.page);
882+
expect((firstLease.page as unknown as { close: ReturnType<typeof vi.fn> }).close).toHaveBeenCalled();
883+
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
884+
});
885+
853886
it('retries explicit newPage page creation once after a closed-context failure', async () => {
854887
const closed = new Error('browserContext.newPage: Target page, context or browser has been closed');
855888
const first = fakeContext();

src/browser/runtime/local-cloak/session-manager.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { findPackageRoot } from '../../../package-paths.js';
1111
import { findExactCloakProfileProcesses } from './process-matcher.js';
1212
import { log } from '../../../logger.js';
1313
import { CliError, EXIT_CODES } from '../../../errors.js';
14+
import { isClosedContextError } from '../../run/types.js';
1415

1516
const UNRESOLVED = Symbol('unresolved');
1617
const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000;
@@ -180,11 +181,6 @@ function pageIsClosed(page: PlaywrightPage): boolean {
180181
return page.isClosed?.() === true;
181182
}
182183

183-
function isClosedContextError(error: unknown): boolean {
184-
const message = error instanceof Error ? error.message : String(error);
185-
return /Target page, context or browser has been closed/i.test(message);
186-
}
187-
188184
function errorMessage(error: unknown): string {
189185
return error instanceof Error ? error.message : String(error);
190186
}
@@ -282,11 +278,21 @@ export class CloakSessionManager {
282278
const sessionRuntime = this.getSessionRuntime(runtime, sessionId);
283279
const existing = sessionRuntime.pages.get(leaseKey);
284280
if (existing && !pageIsClosed(existing.page) && !freshPage) {
285-
await this.assertOwnedWindow(runtime, sessionId, existing);
286-
runtime.lastSeenAt = Date.now();
287-
existing.idleTimeout = input.idleTimeout;
288-
this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, existing);
289-
return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId };
281+
try {
282+
await this.assertOwnedWindow(runtime, sessionId, existing);
283+
runtime.lastSeenAt = Date.now();
284+
existing.idleTimeout = input.idleTimeout;
285+
this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, existing);
286+
return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId };
287+
} catch (error) {
288+
if (!isClosedContextError(error)) throw error;
289+
// isClosed() reported false, but the liveness probe above shows the
290+
// underlying CDP connection is actually dead. Invalidate the Profile
291+
// runtime and fall through to acquire a fresh page instead of handing
292+
// the same broken lease back out (webcmd#314).
293+
this.invalidateProfileRuntime(profileId, runtime);
294+
if (!pageIsClosed(existing.page)) await existing.page.close().catch(() => {});
295+
}
290296
}
291297
const acquired = await this.acquireSessionPage(profileId, sessionId, input.windowMode);
292298
const entry = await this.registerOwnedPage(acquired.runtime, acquired.session, acquired.page, {
@@ -667,6 +673,19 @@ export class CloakSessionManager {
667673
return entries.length;
668674
}
669675

676+
/**
677+
* Invalidates the Profile runtime backing `context`, if it's still the active
678+
* one, without evicting or retrying the command that observed it. Used by the
679+
* `run` action (webcmd#314) when a post-run snapshot capture surfaces a
680+
* closed-context signature: the run itself may have genuinely succeeded, but
681+
* the connection is dying, so the next command on this Session shouldn't be
682+
* handed the same lease.
683+
*/
684+
invalidateIfClosedContext(profileId: string, context: BrowserContext): void {
685+
const runtime = this.profiles.get(profileId);
686+
if (runtime?.context === context) this.invalidateProfileRuntime(profileId, runtime);
687+
}
688+
670689
async shutdown(): Promise<void> {
671690
this.shuttingDown = true;
672691
while (this.profileLaunches.size > 0) {

0 commit comments

Comments
 (0)