Skip to content
Open
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
87 changes: 87 additions & 0 deletions src/browser/runtime/local-cloak/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,93 @@ describe('CloakSessionManager', () => {
expect(replacement.page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' });
});

it('names the CloakBrowser session cap when a launch exits 76', async () => {
const launchPersistentContext = vi.fn()
.mockRejectedValue(new Error('browserType.launchPersistentContext: Process exited with code 76'));
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });

const error = await manager.getPage({ profileId: 'actor', session: 'work', surface: 'browser' })
.catch((value: unknown) => value) as { code: string; kind: string; hint: string; message: string };

expect(error.code).toBe('BROWSER_CONNECT');
expect(error.kind).toBe('profile-disconnected');
expect(error.message).toContain('exited with code 76');
expect(error.hint).toContain('CLOAKBROWSER_LICENSE_KEY');
// the raw Playwright text is kept so the cause is not lost
expect(error.message).toContain('Process exited with code 76');
});

it('points at the other live profile when navigation keeps losing the browser', async () => {
const peer = fakeContext();
const first = fakeContext();
first.page.goto.mockRejectedValue(new Error('Target page, context or browser has been closed'));
first.context.newPage.mockResolvedValue(first.page);
const replacement = fakeContext();
replacement.page.goto.mockRejectedValue(new Error('Target page, context or browser has been closed'));
replacement.context.newPage.mockResolvedValue(replacement.page);
const launchPersistentContext = vi.fn()
.mockResolvedValueOnce(peer.context)
.mockResolvedValueOnce(first.context)
.mockResolvedValueOnce(replacement.context);
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });

// peer keeps a browser alive, exactly the concurrent-profile repro
await manager.getPage({ profileId: 'peer', session: 'work', surface: 'browser' });
const error = await manager.newPage({
profileId: 'actor',
session: 'work',
surface: 'browser',
url: 'https://example.com/',
}).catch((value: unknown) => value) as { code: string; hint: string; message: string };

expect(error.code).toBe('BROWSER_CONNECT');
expect(error.message).toContain('Browser profile actor disconnected during navigation');
expect(error.hint).toContain('peer');
expect(error.hint).toContain('one browser at a time');
expect(launchPersistentContext).toHaveBeenCalledTimes(3);
});

it('does not blame concurrency when no other profile is running', async () => {
const first = fakeContext();
first.page.goto.mockRejectedValue(new Error('Target page, context or browser has been closed'));
first.context.newPage.mockResolvedValue(first.page);
const replacement = fakeContext();
replacement.page.goto.mockRejectedValue(new Error('Target page, context or browser has been closed'));
replacement.context.newPage.mockResolvedValue(replacement.page);
const launchPersistentContext = vi.fn()
.mockResolvedValueOnce(first.context)
.mockResolvedValueOnce(replacement.context);
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });

const error = await manager.newPage({
profileId: 'default',
session: 'work',
surface: 'browser',
url: 'https://example.com/',
}).catch((value: unknown) => value) as { code: string; hint: string };

expect(error.code).toBe('BROWSER_CONNECT');
expect(error.hint).toContain('daemon restart');
expect(error.hint).not.toContain('one browser at a time');
});

it('leaves a non-closed navigation failure untouched', async () => {
const launched = fakeContext();
const failure = new Error('net::ERR_NAME_NOT_RESOLVED');
launched.page.goto.mockRejectedValue(failure);
launched.context.newPage.mockResolvedValue(launched.page);
const launchPersistentContext = vi.fn().mockResolvedValue(launched.context);
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });

await expect(manager.newPage({
profileId: 'default',
session: 'work',
surface: 'browser',
url: 'https://nowhere.example/',
})).rejects.toBe(failure);
expect(launchPersistentContext).toHaveBeenCalledTimes(1);
});

it('does not invalidate a replacement runtime when stale navigation fails', async () => {
const first = fakeContext();
first.context.newPage.mockResolvedValue(first.page);
Expand Down
56 changes: 54 additions & 2 deletions src/browser/runtime/local-cloak/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { CloakNetworkCapture } from './network.js';
import { findPackageRoot } from '../../../package-paths.js';
import { findExactCloakProfileProcesses } from './process-matcher.js';
import { log } from '../../../logger.js';
import { CliError, EXIT_CODES } from '../../../errors.js';
import { BrowserConnectError, CliError, EXIT_CODES } from '../../../errors.js';
import { isClosedContextError } from '../../run/types.js';

const UNRESOLVED = Symbol('unresolved');
Expand Down Expand Up @@ -457,6 +457,7 @@ export class CloakSessionManager {
return this.newPageAttempt(input, 1);
}
if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {});
if (isClosedContextError(error)) throw this.browserDisconnectedError(profileId, error);
throw error;
}
}
Expand Down Expand Up @@ -487,7 +488,8 @@ export class CloakSessionManager {
await lease.page.goto(url, { waitUntil });
return lease;
} catch (error) {
if (attempt !== 0 || !isClosedContextError(error)) throw error;
if (!isClosedContextError(error)) throw error;
if (attempt !== 0) throw this.browserDisconnectedError(profileId, error);
if (runtime?.context === lease.context) this.invalidateProfileRuntime(profileId, runtime);
if (!pageIsClosed(lease.page)) await lease.page.close().catch(() => {});
return this.navigatePageAttempt(input, url, waitUntil, 1);
Expand Down Expand Up @@ -721,6 +723,37 @@ export class CloakSessionManager {
});
}

/**
* Name why the browser went away instead of forwarding Playwright's
* "Target page, context or browser has been closed", which reads the same
* whether the browser crashed, was closed by hand, or was never allowed to
* start (webcmd#225). Another live profile is the one cause we can check, so
* it decides the hint rather than being asserted blindly.
*/
private browserDisconnectedError(profileId: string, cause: unknown): BrowserConnectError {
const detail = cause instanceof Error ? cause.message : String(cause);
const others = [...this.profiles.keys()].filter((id) => id !== profileId);
const hint = others.length > 0
? `Profile ${others.join(', ')} still has a browser open. CloakBrowser's free tier runs one browser at a time and exits a second launch with code ${CLOAK_SESSION_CAP_EXIT_CODE}. Close the other profile, or set CLOAKBROWSER_LICENSE_KEY for a tier with more sessions.`
: 'The browser closed on its own before the request finished. Run `webcmd daemon restart`; on macOS, WEBCMD_WINDOW=foreground avoids the background launch path.';
return new BrowserConnectError(
`Browser profile ${profileId} disconnected during navigation: ${detail}`,
hint,
'profile-disconnected',
);
}

private sessionCapError(profileId: string, cause: unknown): BrowserConnectError {
const detail = cause instanceof Error ? cause.message : String(cause);
const others = [...this.profiles.keys()].filter((id) => id !== profileId);
const running = others.length > 0 ? ` Profile ${others.join(', ')} is already running.` : '';
return new BrowserConnectError(
`CloakBrowser exited with code ${CLOAK_SESSION_CAP_EXIT_CODE} while launching profile ${profileId}: ${detail}`,
`That exit code means the CloakBrowser session limit was reached.${running} Close the other profile, or set CLOAKBROWSER_LICENSE_KEY for a tier with more sessions.`,
'profile-disconnected',
);
}

private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise<ProfileRuntime> {
const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir });
fs.mkdirSync(userDataDir, { recursive: true });
Expand All @@ -736,6 +769,7 @@ export class CloakSessionManager {
try {
context = await launchPersistentContext(launchOptions);
} catch (err) {
if (isCloakSessionCapError(err)) throw this.sessionCapError(profileId, err);
if (!isProfileAlreadyInUseError(err) || !(await this.recoverLockedProfile(userDataDir))) throw err;
context = await launchPersistentContext(launchOptions);
}
Expand Down Expand Up @@ -1363,6 +1397,24 @@ function requireSessionId(input: Pick<SessionKeyInput, 'session' | 'sessionId'>)
return input.sessionId?.trim() || requireSession(input.session);
}

/**
* CloakBrowser's free tier runs one browser at a time. A second launch exits
* with this code, which Playwright reports as a target that closed — the same
* message an externally closed or crashed browser produces (webcmd#225).
*/
const CLOAK_SESSION_CAP_EXIT_CODE = 76;

/**
* Matches both Playwright launch text ("exited with code 76") and process
* traces ("exitCode=76"). The trailing guard keeps 760 and 7600 out.
*/
const CLOAK_SESSION_CAP_PATTERN = /exit(?:ed|code)?[ =:]+(?:with )?(?:code[ =:]+)?76(?![0-9])/i;

function isCloakSessionCapError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return CLOAK_SESSION_CAP_PATTERN.test(message);
}

function isProfileAlreadyInUseError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return message.includes('Opening in existing browser session')
Expand Down
Loading