Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 119 additions & 6 deletions src/browser/runtime/local-cloak/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,51 @@ import { CloakSessionManager } from './session-manager.js';
import { dispatchCloakAction } from './actions.js';

function fakeContext() {
const listeners = new Map<string, Set<() => void>>();
const page = {
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
const fakePage = () => ({
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue('ok'),
title: vi.fn().mockResolvedValue('Title'),
url: vi.fn().mockReturnValue('https://example.com/'),
screenshot: vi.fn().mockResolvedValue(Buffer.from('png')),
isClosed: vi.fn().mockReturnValue(false),
close: vi.fn().mockResolvedValue(undefined),
});
const page = fakePage();
const backgroundPages: ReturnType<typeof fakePage>[] = [];
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') {
const backgroundPage = fakePage();
backgroundPages.push(backgroundPage);
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,
backgroundPages,
cdp,
};
}

Expand Down Expand Up @@ -101,8 +120,99 @@ describe('CloakSessionManager', () => {
expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context);
});

it('coalesces concurrent persistent context launches for the same profile', async () => {
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('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 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<BrowserContext>((resolve) => {
resolveLaunch = resolve;
Expand All @@ -123,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 () => {
Expand Down
127 changes: 91 additions & 36 deletions src/browser/runtime/local-cloak/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class CloakSessionManager {
private readonly recoverLockedProfile: RecoverLockedProfile;
private readonly profiles = new Map<string, ProfileRuntime>();
private readonly profileLaunches = new Map<string, Promise<ProfileRuntime>>();
private readonly pageCreationQueues = new Map<string, Promise<void>>();

constructor(private readonly opts: CloakSessionManagerOptions = {}) {
this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext;
Expand Down Expand Up @@ -137,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]
: candidate.context.newPage();
},
(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<SessionKeyInput, 'idleTimeout'> = {}): CloakPageLease | null {
Expand Down Expand Up @@ -238,7 +241,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) {
Expand Down Expand Up @@ -423,6 +426,20 @@ export class CloakSessionManager {
windowMode: BrowserWindowMode | undefined,
createPage: (runtime: ProfileRuntime) => PlaywrightPage | Promise<PlaywrightPage>,
commitPage: (runtime: ProfileRuntime, page: PlaywrightPage) => T,
): Promise<T> {
return this.withPageCreationLock(profileId, () => this.createPageWithRecoveryAttempt(
profileId,
windowMode,
createPage,
commitPage,
));
}

private async createPageWithRecoveryAttempt<T>(
profileId: string,
windowMode: BrowserWindowMode | undefined,
createPage: (runtime: ProfileRuntime) => PlaywrightPage | Promise<PlaywrightPage>,
commitPage: (runtime: ProfileRuntime, page: PlaywrightPage) => T,
attempt = 0,
): Promise<T> {
const runtime = await this.getProfileRuntime(profileId, windowMode);
Expand All @@ -432,7 +449,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(() => {});
Expand All @@ -441,6 +458,44 @@ export class CloakSessionManager {
return commitPage(runtime, page);
}

private async withPageCreationLock<T>(profileId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.pageCreationQueues.get(profileId) ?? Promise.resolve();
let release!: () => void;
const released = new Promise<void>((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<PlaywrightPage> {
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));
}
Expand Down