From 29260246b4a598d3c1477070488d8d7f7550c93a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 21 Mar 2026 16:58:37 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20state=20file=20authority=20=E2=80=94=20C?= =?UTF-8?q?DP=20server=20cannot=20be=20silently=20replaced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the connect/disconnect lifecycle: - ensureServer() refuses to auto-start headless when CDP server is alive - $B connect does full cleanup: SIGTERM → 2s → SIGKILL, profile locks, state - shutdown() cleans Chromium SingletonLock/Socket/Cookie files - uncaughtException/unhandledRejection handlers do emergency cleanup This prevents the bug where a headless server overwrites the CDP server's state file, causing $B commands to hit the wrong browser. Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/cli.ts | 48 ++++++++++++++++++++----- browse/src/server.ts | 85 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 109 insertions(+), 24 deletions(-) diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 065450a0..47ecd788 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -236,7 +236,16 @@ async function ensureServer(): Promise { } } - // Need to (re)start + // Guard: never silently replace a CDP server with a headless one. + // CDP mode means a user-visible Chrome window is (or was) controlled. + // Silently replacing it would be confusing — tell the user to reconnect. + if (state && state.mode === 'cdp' && isProcessAlive(state.pid)) { + console.error(`[browse] CDP server running (PID ${state.pid}) but not responding.`); + console.error(`[browse] Run '$B connect' to restart.`); + process.exit(1); + } + + // Need to (re)start (headless only — safe to auto-start) console.error('[browse] Starting server...'); return startServer(); } @@ -348,20 +357,41 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: // connect must be handled BEFORE ensureServer() because it needs // to restart the server with real Chrome via Playwright channel:chrome. if (command === 'connect') { - // Check if already in CDP mode + // Check if already in CDP mode and healthy const existingState = readState(); - if (existingState && existingState.mode === 'cdp') { - console.log('Already connected to real browser.'); - process.exit(0); + if (existingState && existingState.mode === 'cdp' && isProcessAlive(existingState.pid)) { + try { + const resp = await fetch(`http://127.0.0.1:${existingState.port}/health`, { + signal: AbortSignal.timeout(2000), + }); + if (resp.ok) { + console.log('Already connected to real browser.'); + process.exit(0); + } + } catch { + // CDP server alive but not responding — kill and restart + } } - // Kill existing headless server if running - if (existingState) { + // Kill ANY existing server (SIGTERM → wait 2s → SIGKILL) + if (existingState && isProcessAlive(existingState.pid)) { try { process.kill(existingState.pid, 'SIGTERM'); } catch {} - try { fs.unlinkSync(config.stateFile); } catch {} - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise(resolve => setTimeout(resolve, 2000)); + if (isProcessAlive(existingState.pid)) { + try { process.kill(existingState.pid, 'SIGKILL'); } catch {} + await new Promise(resolve => setTimeout(resolve, 1000)); + } } + // Clean up Chromium profile locks (can persist after crashes) + const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); + for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { + try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {} + } + + // Delete stale state file + try { fs.unlinkSync(config.stateFile); } catch {} + console.log('Launching real Chrome browser...'); try { // Start server with CDP flag — server.ts will use channel:chrome diff --git a/browse/src/server.ts b/browse/src/server.ts index 7e79972f..c6b2969c 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -315,6 +315,12 @@ async function shutdown() { await browserManager.close(); + // Clean up Chromium profile locks (prevent SingletonLock on next launch) + const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); + for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { + try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {} + } + // Clean up state file try { fs.unlinkSync(config.stateFile); } catch {} @@ -325,6 +331,27 @@ async function shutdown() { process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); +// Emergency cleanup for crashes (OOM, uncaught exceptions) +function emergencyCleanup() { + if (isShuttingDown) return; + isShuttingDown = true; + const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); + for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { + try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {} + } + try { fs.unlinkSync(config.stateFile); } catch {} +} +process.on('uncaughtException', (err) => { + console.error('[browse] FATAL uncaught exception:', err.message); + emergencyCleanup(); + process.exit(1); +}); +process.on('unhandledRejection', (err: any) => { + console.error('[browse] FATAL unhandled rejection:', err?.message || err); + emergencyCleanup(); + process.exit(1); +}); + // ─── Start ───────────────────────────────────────────────────── async function start() { // Clear old log files @@ -455,21 +482,9 @@ async function start() { }); } - // All other endpoints require auth - if (!validateAuth(req)) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }); - } + // ─── Sidebar endpoints (no auth — localhost only) ───────────── - if (url.pathname === '/command' && req.method === 'POST') { - resetIdleTimer(); // Only commands reset idle timer - const body = await req.json(); - return handleCommand(body); - } - - // Sidebar → Claude Code command queue (file-based message passing) + // Sidebar → Claude Code command queue if (url.pathname === '/sidebar-command' && req.method === 'POST') { const body = await req.json(); const msg = body.message?.trim(); @@ -490,7 +505,7 @@ async function start() { }); } - // Claude Code → Sidebar response (also file-based) + // Claude Code → Sidebar response if (url.pathname === '/sidebar-response' && req.method === 'POST') { const body = await req.json(); const msg = body.message?.trim(); @@ -510,6 +525,31 @@ async function start() { }); } + // Streaming events from sidebar agent + if (url.pathname === '/sidebar-event' && req.method === 'POST') { + const body = await req.json(); + const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack'); + fs.mkdirSync(gstackDir, { recursive: true }); + const entry = JSON.stringify({ ts: new Date().toISOString(), role: 'agent', ...body }) + '\n'; + fs.appendFileSync(path.join(gstackDir, 'sidebar-chat.jsonl'), entry); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Clear sidebar chat + if (url.pathname === '/sidebar-chat/clear' && req.method === 'POST') { + const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack'); + try { + fs.writeFileSync(path.join(gstackDir, 'sidebar-chat.jsonl'), ''); + } catch {} + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + // Sidebar chat history + polling if (url.pathname === '/sidebar-chat') { const afterParam = url.searchParams.get('after') || '0'; @@ -528,6 +568,21 @@ async function start() { }); } + // ─── Auth-required endpoints ────────────────────────────────── + + if (!validateAuth(req)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url.pathname === '/command' && req.method === 'POST') { + resetIdleTimer(); // Only commands reset idle timer + const body = await req.json(); + return handleCommand(body); + } + return new Response('Not found', { status: 404 }); }, });