From 532cec9258eddfbda8170890e1fbec6e32c24931 Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:34:43 +0530 Subject: [PATCH] fix(browser): name the CloakBrowser session cap instead of "target closed" (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second concurrent profile exits with code 76 on CloakBrowser's free tier, and webcmd reported it as `page.goto: Target page, context or browser has been closed` — the same text an externally closed or crashed browser produces. The reporter traced the daemon and profile multiplexing end to end before finding the licensing cap, because the error pointed nowhere. Two mappings, both onto the existing BrowserConnectError / 'profile-disconnected': - A launch that fails with exit code 76 now says so and names the license key. - A navigation that still fails with a closed-context error after the existing one-shot recovery retry now reports which profile disconnected, and keeps the Playwright text as the cause rather than dropping it. Another live profile is the one cause we can actually check, so it decides the hint: with a second profile up, the cap is named; alone, the hint points at `daemon restart` and the macOS foreground window mode. Non-closed navigation failures and the page-creation paths are untouched. This is the error-mapping half of #225. The macOS background `open -g` launch that self-terminates is a CloakBrowser/LaunchServices issue and is not fixed here. --- .../local-cloak/session-manager.test.ts | 87 +++++++++++++++++++ .../runtime/local-cloak/session-manager.ts | 56 +++++++++++- 2 files changed, 141 insertions(+), 2 deletions(-) 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')