From d019c09bbcc5b82ec3da14c7d949862c002dc5f4 Mon Sep 17 00:00:00 2001 From: CommerceMax <87635598+CommerceMax@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:39:25 +0100 Subject: [PATCH] feat: add idle timeout to daemon to prevent orphaned Chrome processes (#722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add idle timeout to daemon to prevent orphaned Chrome processes The daemon persists indefinitely after browser sessions are used, leaving orphaned Chromium processes consuming memory and CPU. Add a configurable idle timeout (default 15 minutes) that shuts down the daemon when no commands arrive. Resets on every incoming command, so active sessions are unaffected. Set AGENT_BROWSER_IDLE_TIMEOUT_MS=0 to disable (preserves old behavior). Fixes #721 * fix: save session state before shutdown to prevent silent data loss The shutdown() function (used by idle timeout, SIGINT, SIGTERM, SIGHUP) previously closed the browser without saving state, unlike the explicit `close` command which calls saveStateToFile(). This meant idle timeouts silently destroyed cookies, localStorage, and login sessions. Now shutdown() mirrors the close command's auto-save behavior: it calls saveStateToFile() before manager.close(), preserving session state to disk. This makes idle timeout functionally equivalent to an explicit close — users returning after an idle shutdown get their state restored. Addresses review feedback on #722 by @ctate. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Max Stoel Co-authored-by: Claude Opus 4.6 --- src/daemon.ts | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/daemon.ts b/src/daemon.ts index cfa6cc1..ed14735 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -71,6 +71,19 @@ let currentSession = process.env.AGENT_BROWSER_SESSION || 'default'; // Stream server for browser preview let streamServer: StreamServer | null = null; +// Idle timeout - shut down daemon after period of inactivity +// Configurable via AGENT_BROWSER_IDLE_TIMEOUT_MS env var (default: 15 minutes, 0 to disable) +const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000; +const IDLE_TIMEOUT_MS = (() => { + const env = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + if (env !== undefined) { + const val = parseInt(env, 10); + return isNaN(val) ? DEFAULT_IDLE_TIMEOUT_MS : val; + } + return DEFAULT_IDLE_TIMEOUT_MS; +})(); +let idleTimer: ReturnType | null = null; + // Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT) const DEFAULT_STREAM_PORT = 9223; @@ -366,6 +379,28 @@ export async function startDaemon(options?: { fs.writeFileSync(streamPortFile, streamPort.toString()); } + // Idle timeout: shut down daemon if no commands arrive within the timeout period. + // Reset on every incoming command. Set AGENT_BROWSER_IDLE_TIMEOUT_MS=0 to disable. + let shutdownRef: (() => Promise) | null = null; + + function resetIdleTimer(): void { + if (IDLE_TIMEOUT_MS <= 0) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error(`[DEBUG] Idle timeout reached (${IDLE_TIMEOUT_MS}ms), shutting down daemon`); + } + if (shutdownRef) shutdownRef(); + }, IDLE_TIMEOUT_MS); + // Don't let the idle timer keep the process alive on its own + if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) { + idleTimer.unref(); + } + } + + // Start the idle timer immediately + resetIdleTimer(); + const server = net.createServer((socket) => { let buffer = ''; let httpChecked = false; @@ -382,6 +417,8 @@ export async function startDaemon(options?: { while (commandQueue.length > 0) { const line = commandQueue.shift()!; + // Reset idle timer on every command + resetIdleTimer(); try { const parseResult = parseCommand(line); @@ -653,6 +690,32 @@ export async function startDaemon(options?: { if (shuttingDown) return; shuttingDown = true; + // Clear idle timer + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + + // Auto-save session state before closing (same as the explicit `close` command path) + if (manager instanceof BrowserManager && manager.isLaunched()) { + const savePath = getSessionSaveStatePath(); + if (savePath) { + try { + const { encrypted } = await saveStateToFile(manager, savePath); + fs.chmodSync(savePath, 0o600); + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error( + `Auto-saved session state before shutdown: ${savePath}${encrypted ? ' (encrypted)' : ''}` + ); + } + } catch (err) { + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error(`Failed to auto-save session state before shutdown:`, err); + } + } + } + } + // Stop stream server if running if (streamServer) { await streamServer.stop(); @@ -672,6 +735,9 @@ export async function startDaemon(options?: { process.exit(0); }; + // Wire up idle timeout to shutdown + shutdownRef = shutdown; + process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); process.on('SIGHUP', shutdown);