feat: add idle timeout to daemon to prevent orphaned Chrome processes (#722)

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Max Stoel <maxalerator@hotmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CommerceMax
2026-03-12 14:39:25 -05:00
committed by GitHub
co-authored by Claude Opus 4.6 Max Stoel
parent d4f7fbc718
commit d019c09bbc
+66
View File
@@ -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<typeof setTimeout> | 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<void>) | 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);