fix: state file authority — CDP server cannot be silently replaced

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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-21 16:58:37 -07:00
parent 2b02e9612d
commit 29260246b4
2 changed files with 109 additions and 24 deletions
+39 -9
View File
@@ -236,7 +236,16 @@ async function ensureServer(): Promise<ServerState> {
}
}
// 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
+70 -15
View File
@@ -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 });
},
});