Skip to content

Commit 6e3c14b

Browse files
shrey150claude
andauthored
STG-1669: fix(cli): clear cached state when browser connection dies (#1887)
## Summary - When the browse CLI daemon outlives its Chrome browser (e.g., between Claude Code sessions), `ensureBrowserInitialized()` returns the cached `stagehand`/`context` without checking if the browser is still alive - This causes `"No Page found for awaitActivePage: no page available"` errors requiring a manual `browse stop` + retry - Fix: register an `onTransportClosed` handler on the CDP connection that clears the cached state, so the next command triggers a full re-initialization ## Root cause The daemon is spawned as a detached process (`detached: true`, `child.unref()`) and persists across CLI sessions. When Chrome dies, `_onCdpClosed` fires and calls `stagehand.close()`, but the daemon's closure variables `stagehand` and `context` are never cleared. The next command hits the early return at `if (stagehand && context) { return ... }` and tries to use dead objects. ## Test plan **Regression test added to PR is illustrative** - [x] Start a `browse` session, close Chrome manually, run another `browse open` — should auto-recover instead of erroring - [x] Normal `browse` workflow (open, snapshot, click, stop) still works - [x] Daemon restart across mode switches (local ↔ remote) still works - [x] Regression test added: kills Chrome under daemon, verifies auto-recovery (fails on main, passes with fix) ## Verified test results **On `main` (without fix):** Test fails — after killing Chrome, the retry returns exit code 1 with `"Error: No Page found for awaitActivePage: no page available"`. The daemon is alive but returns stale cached Stagehand/context objects. **On this branch (with fix):** Test passes — after killing Chrome, the `onTransportClosed` handler nulls the cached state, and the retry triggers a full browser re-initialization. All 37 CLI tests pass (full suite, 67s). > **Note:** When building locally, use `npx tsup src/index.ts --format cjs --out-dir dist --no-splitting` inside `packages/cli/` rather than `pnpm run build:cli`, since turbo may serve a stale cached build that doesn't include the fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6c89565 commit 6e3c14b

3 files changed

Lines changed: 84 additions & 0 deletions

File tree

.changeset/fix-stale-daemon.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/browse-cli": patch
3+
---
4+
5+
fix: clear cached browser state when CDP connection dies, preventing "awaitActivePage: no page available" errors when daemon outlives its browser

packages/cli/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,15 @@ async function runDaemon(session: string, headless: boolean): Promise<void> {
372372

373373
context = stagehand.context;
374374

375+
// Clear cached state when the browser connection dies so the next
376+
// command triggers a full re-initialization instead of reusing a
377+
// dead Stagehand/context pair (fixes "awaitActivePage: no page
378+
// available" when a stale daemon outlives its browser).
379+
context.conn.onTransportClosed(() => {
380+
stagehand = null;
381+
context = null;
382+
});
383+
375384
// Try to save Chrome info for reference (best effort)
376385
try {
377386
const wsUrl = stagehand.connectURL();

packages/cli/tests/cli.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,4 +572,74 @@ describe("Browse CLI", () => {
572572
expect(result.exitCode).not.toBe(0);
573573
});
574574
});
575+
576+
describe("Stale daemon recovery", () => {
577+
const staleSession = `${TEST_SESSION}-stale`;
578+
579+
afterEach(async () => {
580+
await browse("stop --force", { session: staleSession });
581+
await cleanupSession(staleSession);
582+
});
583+
584+
it("should recover when Chrome dies under a running daemon", async () => {
585+
// Force local mode (Browserbase env vars may be set)
586+
const tmpDir = os.tmpdir();
587+
await fs.writeFile(
588+
path.join(tmpDir, `browse-${staleSession}.mode-override`),
589+
"local",
590+
);
591+
592+
// 1. Start daemon and initialize browser by opening a page
593+
const openResult = await browse("open https://example.com", {
594+
session: staleSession,
595+
timeout: 30000,
596+
});
597+
expect(openResult.exitCode).toBe(0);
598+
const openData = parseJson(openResult.stdout);
599+
expect(openData.url).toContain("example.com");
600+
601+
// 2. Kill the Chrome process tree owned by THIS session's daemon.
602+
// Read the daemon PID, then find its child processes to avoid
603+
// killing Chrome instances from other concurrent sessions.
604+
const daemonPid = (
605+
await fs.readFile(
606+
path.join(tmpDir, `browse-${staleSession}.pid`),
607+
"utf-8",
608+
)
609+
).trim();
610+
611+
const { stdout: psOut } = await new Promise<{
612+
stdout: string;
613+
stderr: string;
614+
}>((resolve) => {
615+
exec(`pgrep -P ${daemonPid}`, (_, stdout, stderr) =>
616+
resolve({ stdout: stdout?.trim() ?? "", stderr: stderr ?? "" }),
617+
);
618+
});
619+
620+
const childPids = psOut.split("\n").filter(Boolean);
621+
expect(childPids.length).toBeGreaterThan(0);
622+
623+
// Kill the daemon's child processes (Chrome)
624+
for (const pid of childPids) {
625+
try {
626+
process.kill(parseInt(pid), "SIGKILL");
627+
} catch {}
628+
}
629+
630+
// 3. Wait for the WebSocket close to propagate to the daemon
631+
await new Promise((r) => setTimeout(r, 3000));
632+
633+
// 4. Daemon is still running (socket alive), but browser is dead.
634+
// Without the fix, this would fail with:
635+
// "No Page found for awaitActivePage: no page available"
636+
const retryResult = await browse("open https://example.com", {
637+
session: staleSession,
638+
timeout: 30000,
639+
});
640+
expect(retryResult.exitCode).toBe(0);
641+
const retryData = parseJson(retryResult.stdout);
642+
expect(retryData.url).toContain("example.com");
643+
}, 60000);
644+
});
575645
});

0 commit comments

Comments
 (0)