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:
co-authored by
Claude Opus 4.6
Max Stoel
parent
d4f7fbc718
commit
d019c09bbc
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user