diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index ab1a9f21..82de0ec6 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -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); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 8778add2..44ac702f 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -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'); @@ -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; } } @@ -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); @@ -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 { const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir }); fs.mkdirSync(userDataDir, { recursive: true }); @@ -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); } @@ -1363,6 +1397,24 @@ function requireSessionId(input: Pick) 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')