mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 21:25:27 +02:00
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:
+39
-9
@@ -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
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user