From 4e0fc60bb555b1e0cf26f039d4340a8ec4926a4f Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 31 Jul 2026 14:23:23 +0530 Subject: [PATCH 1/3] fix: keep background tabs from stealing focus --- .../local-cloak/session-manager.test.ts | 85 ++++++++++++++++++- .../runtime/local-cloak/session-manager.ts | 25 +++++- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index bcb6a801..2a19961b 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -5,7 +5,7 @@ import { CloakSessionManager } from './session-manager.js'; import { dispatchCloakAction } from './actions.js'; function fakeContext() { - const listeners = new Map void>>(); + const listeners = new Map void>>(); const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue('ok'), @@ -15,22 +15,35 @@ function fakeContext() { isClosed: vi.fn().mockReturnValue(false), close: vi.fn().mockResolvedValue(undefined), }; + const backgroundPage = { ...page }; + const emit = (event: string, ...args: unknown[]) => { + for (const listener of listeners.get(event) ?? []) listener(...args); + }; + const cdp = { + send: vi.fn(async (command: string) => { + if (command === 'Target.createTarget') queueMicrotask(() => emit('page', backgroundPage)); + }), + detach: vi.fn().mockResolvedValue(undefined), + }; return { context: { - on(event: string, listener: () => void) { + on(event: string, listener: (...args: unknown[]) => void) { const bucket = listeners.get(event) ?? new Set(); bucket.add(listener); listeners.set(event, bucket); }, - emit(event: string) { - for (const listener of listeners.get(event) ?? []) listener(); + emit, + waitForEvent(event: string) { + return new Promise((resolve) => this.on(event, resolve)); }, pages: vi.fn().mockReturnValue([page]), newPage: vi.fn().mockResolvedValue(page), + browser: vi.fn().mockReturnValue({ newBrowserCDPSession: vi.fn().mockResolvedValue(cdp) }), cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), close: vi.fn().mockResolvedValue(undefined), }, page, + cdp, }; } @@ -101,6 +114,70 @@ describe('CloakSessionManager', () => { expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); }); + it('creates a warm background lease tab without focusing Chromium', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + await manager.getPage({ profileId: 'default', session: 'first', surface: 'adapter' }); + await manager.getPage({ + profileId: 'default', + session: 'second', + surface: 'adapter', + windowMode: 'background', + }); + + expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { + url: 'about:blank', + background: true, + focus: false, + }); + expect(launched.context.newPage).not.toHaveBeenCalled(); + }); + + it('creates an explicit background tab without focusing Chromium', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' }); + await manager.newPage({ + profileId: 'default', + session: 'background', + surface: 'browser', + windowMode: 'background', + }); + + expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { + url: 'about:blank', + background: true, + focus: false, + }); + expect(launched.context.newPage).not.toHaveBeenCalled(); + }); + + it('creates an explicit foreground tab through Playwright', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + await manager.newPage({ + profileId: 'default', + session: 'foreground', + surface: 'browser', + windowMode: 'foreground', + }); + + expect(launched.context.newPage).toHaveBeenCalledOnce(); + expect(launched.cdp.send).not.toHaveBeenCalled(); + }); + it('coalesces concurrent persistent context launches for the same profile', async () => { const launched = fakeContext(); let resolveLaunch!: (context: BrowserContext) => void; diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 9cf0412a..12176dc4 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -161,7 +161,7 @@ export class CloakSessionManager { // freshPage must never adopt a leftover tab — its whole point is a clean DOM. return !freshPage && existingPages[0] && candidate.pages.size === 0 ? existingPages[0] - : candidate.context.newPage(); + : this.createPage(candidate.context, input.windowMode); }, (candidate, page) => { const pageId = nextPageId(); @@ -238,7 +238,7 @@ export class CloakSessionManager { const acquired = await this.createPageWithRecovery( profileId, input.windowMode, - (candidate) => candidate.context.newPage(), + (candidate) => this.createPage(candidate.context, input.windowMode), (runtime, page) => ({ runtime, page }), ); if (input.url) { @@ -441,6 +441,27 @@ export class CloakSessionManager { return commitPage(runtime, page); } + private async createPage(context: BrowserContext, windowMode?: BrowserWindowMode): Promise { + if (windowMode !== 'background') return context.newPage(); + + const browser = context.browser(); + if (!browser) throw new Error('Background page creation requires a Chromium browser connection.'); + const cdp = await browser.newBrowserCDPSession(); + try { + const [page] = await Promise.all([ + context.waitForEvent('page'), + cdp.send('Target.createTarget', { + url: 'about:blank', + background: true, + focus: false, + }), + ]); + return page; + } finally { + await cdp.detach().catch(() => {}); + } + } + private openEntries(runtime: ProfileRuntime): [string, PageEntry][] { return [...runtime.pages.entries()].filter(([, entry]) => !pageIsClosed(entry.page)); } From b1bf9775a5756a0aa2010716158b88be67d8ef84 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 31 Jul 2026 14:27:01 +0530 Subject: [PATCH 2/3] fix: serialize background page creation --- .../local-cloak/session-manager.test.ts | 40 +++++++++++++++++-- .../runtime/local-cloak/session-manager.ts | 34 +++++++++++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 2a19961b..312c762a 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -6,7 +6,7 @@ import { dispatchCloakAction } from './actions.js'; function fakeContext() { const listeners = new Map void>>(); - const page = { + const fakePage = () => ({ goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue('ok'), title: vi.fn().mockResolvedValue('Title'), @@ -14,14 +14,19 @@ function fakeContext() { screenshot: vi.fn().mockResolvedValue(Buffer.from('png')), isClosed: vi.fn().mockReturnValue(false), close: vi.fn().mockResolvedValue(undefined), - }; - const backgroundPage = { ...page }; + }); + const page = fakePage(); + const backgroundPages: ReturnType[] = []; const emit = (event: string, ...args: unknown[]) => { for (const listener of listeners.get(event) ?? []) listener(...args); }; const cdp = { send: vi.fn(async (command: string) => { - if (command === 'Target.createTarget') queueMicrotask(() => emit('page', backgroundPage)); + if (command === 'Target.createTarget') { + const backgroundPage = fakePage(); + backgroundPages.push(backgroundPage); + queueMicrotask(() => emit('page', backgroundPage)); + } }), detach: vi.fn().mockResolvedValue(undefined), }; @@ -43,6 +48,7 @@ function fakeContext() { close: vi.fn().mockResolvedValue(undefined), }, page, + backgroundPages, cdp, }; } @@ -178,6 +184,32 @@ describe('CloakSessionManager', () => { expect(launched.cdp.send).not.toHaveBeenCalled(); }); + it('gives concurrent background tabs distinct pages', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + await manager.getPage({ profileId: 'default', session: 'warm', surface: 'browser' }); + const firstRequest = manager.newPage({ + profileId: 'default', + session: 'first', + surface: 'browser', + windowMode: 'background', + }); + const secondRequest = manager.newPage({ + profileId: 'default', + session: 'second', + surface: 'browser', + windowMode: 'background', + }); + const [first, second] = await Promise.all([firstRequest, secondRequest]); + + expect(first.page).not.toBe(second.page); + expect(launched.backgroundPages).toEqual([first.page, second.page]); + }); + it('coalesces concurrent persistent context launches for the same profile', async () => { const launched = fakeContext(); let resolveLaunch!: (context: BrowserContext) => void; diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 12176dc4..3b6f698b 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -109,6 +109,7 @@ export class CloakSessionManager { private readonly recoverLockedProfile: RecoverLockedProfile; private readonly profiles = new Map(); private readonly profileLaunches = new Map>(); + private readonly pageCreationQueues = new Map>(); constructor(private readonly opts: CloakSessionManagerOptions = {}) { this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext; @@ -423,6 +424,20 @@ export class CloakSessionManager { windowMode: BrowserWindowMode | undefined, createPage: (runtime: ProfileRuntime) => PlaywrightPage | Promise, commitPage: (runtime: ProfileRuntime, page: PlaywrightPage) => T, + ): Promise { + return this.withPageCreationLock(profileId, () => this.createPageWithRecoveryAttempt( + profileId, + windowMode, + createPage, + commitPage, + )); + } + + private async createPageWithRecoveryAttempt( + profileId: string, + windowMode: BrowserWindowMode | undefined, + createPage: (runtime: ProfileRuntime) => PlaywrightPage | Promise, + commitPage: (runtime: ProfileRuntime, page: PlaywrightPage) => T, attempt = 0, ): Promise { const runtime = await this.getProfileRuntime(profileId, windowMode); @@ -432,7 +447,7 @@ export class CloakSessionManager { } catch (error) { if (attempt !== 0 || !isClosedContextError(error)) throw error; this.invalidateProfileRuntime(profileId, runtime); - return this.createPageWithRecovery(profileId, windowMode, createPage, commitPage, 1); + return this.createPageWithRecoveryAttempt(profileId, windowMode, createPage, commitPage, 1); } if (this.profiles.get(profileId) !== runtime) { if (!pageIsClosed(page)) await page.close().catch(() => {}); @@ -441,6 +456,23 @@ export class CloakSessionManager { return commitPage(runtime, page); } + private async withPageCreationLock(profileId: string, operation: () => Promise): Promise { + const previous = this.pageCreationQueues.get(profileId) ?? Promise.resolve(); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const queue = previous.then(() => released); + this.pageCreationQueues.set(profileId, queue); + await previous; + try { + return await operation(); + } finally { + release(); + if (this.pageCreationQueues.get(profileId) === queue) this.pageCreationQueues.delete(profileId); + } + } + private async createPage(context: BrowserContext, windowMode?: BrowserWindowMode): Promise { if (windowMode !== 'background') return context.newPage(); From e7e5e0416570307644a249bde8fd84af66e53de8 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 31 Jul 2026 14:36:16 +0530 Subject: [PATCH 3/3] fix: coalesce concurrent page leases --- .../local-cloak/session-manager.test.ts | 6 +- .../runtime/local-cloak/session-manager.ts | 70 ++++++++++--------- 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 312c762a..5b531871 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -210,8 +210,9 @@ describe('CloakSessionManager', () => { expect(launched.backgroundPages).toEqual([first.page, second.page]); }); - it('coalesces concurrent persistent context launches for the same profile', async () => { + it('coalesces concurrent same-lease page acquisition', async () => { const launched = fakeContext(); + launched.context.newPage.mockResolvedValue(fakeContext().page); let resolveLaunch!: (context: BrowserContext) => void; const launchPersistentContext = vi.fn(() => new Promise((resolve) => { resolveLaunch = resolve; @@ -232,6 +233,9 @@ describe('CloakSessionManager', () => { expect(first.context).toBe(launched.context); expect(second.context).toBe(launched.context); expect(first.page).toBe(second.page); + expect(first.pageId).toBe(second.pageId); + expect(launched.context.newPage).not.toHaveBeenCalled(); + expect(launched.cdp.send).not.toHaveBeenCalled(); }); it('evicts a closed runtime and clears every tracked page resource', async () => { diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 3b6f698b..1b8c3974 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -138,42 +138,44 @@ export class CloakSessionManager { const session = requireSession(input.session); const surface = normalizeSurface(input.surface); const leaseKey = resolveLeaseKey(input); - const runtime = await this.getProfileRuntime(profileId, input.windowMode); const freshPage = input.freshPage === true; - const existing = runtime.pages.get(leaseKey); - if (existing && !pageIsClosed(existing.page) && !freshPage) { - runtime.lastSeenAt = Date.now(); - existing.idleTimeout = input.idleTimeout; - this.refreshIdleTimer(runtime, leaseKey, existing); - return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId }; - } - if (existing && freshPage) { - runtime.pages.delete(leaseKey); - this.clearIdleTimer(existing); - if (runtime.selectedPageId === existing.pageId) runtime.selectedPageId = undefined; - if (!pageIsClosed(existing.page)) await existing.page.close().catch(() => {}); - } + return this.withPageCreationLock(profileId, async () => { + const runtime = await this.getProfileRuntime(profileId, input.windowMode); + const existing = runtime.pages.get(leaseKey); + if (existing && !pageIsClosed(existing.page) && !freshPage) { + runtime.lastSeenAt = Date.now(); + existing.idleTimeout = input.idleTimeout; + this.refreshIdleTimer(runtime, leaseKey, existing); + return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId }; + } + if (existing && freshPage) { + runtime.pages.delete(leaseKey); + this.clearIdleTimer(existing); + if (runtime.selectedPageId === existing.pageId) runtime.selectedPageId = undefined; + if (!pageIsClosed(existing.page)) await existing.page.close().catch(() => {}); + } - return this.createPageWithRecovery( - profileId, - input.windowMode, - (candidate) => { - const existingPages = candidate.context.pages(); - // freshPage must never adopt a leftover tab — its whole point is a clean DOM. - return !freshPage && existingPages[0] && candidate.pages.size === 0 - ? existingPages[0] - : this.createPage(candidate.context, input.windowMode); - }, - (candidate, page) => { - const pageId = nextPageId(); - const entry: PageEntry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout }; - candidate.pages.set(leaseKey, entry); - this.refreshIdleTimer(candidate, leaseKey, entry); - candidate.selectedPageId = pageId; - candidate.lastSeenAt = Date.now(); - return { profileId, leaseKey, context: candidate.context, page, pageId }; - }, - ); + return this.createPageWithRecoveryAttempt( + profileId, + input.windowMode, + (candidate) => { + const existingPages = candidate.context.pages(); + // freshPage must never adopt a leftover tab — its whole point is a clean DOM. + return !freshPage && existingPages[0] && candidate.pages.size === 0 + ? existingPages[0] + : this.createPage(candidate.context, input.windowMode); + }, + (candidate, page) => { + const pageId = nextPageId(); + const entry: PageEntry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout }; + candidate.pages.set(leaseKey, entry); + this.refreshIdleTimer(candidate, leaseKey, entry); + candidate.selectedPageId = pageId; + candidate.lastSeenAt = Date.now(); + return { profileId, leaseKey, context: candidate.context, page, pageId }; + }, + ); + }); } findPageById(pageId: string, opts: Pick = {}): CloakPageLease | null {