From 53c3c4c27af675826b4b717a09b40a419aa93753 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Fri, 19 Jun 2026 13:07:08 +0000 Subject: [PATCH] Fix long-running desktop resource leaks --- electron/localServer.js | 1 + .../computer-use/computer-use.service.ts | 52 +++++++++++++++---- .../helpers/macos/CloudCLISemantics.swift | 12 +++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/electron/localServer.js b/electron/localServer.js index 3761a299..b0aa0675 100644 --- a/electron/localServer.js +++ b/electron/localServer.js @@ -484,6 +484,7 @@ export class LocalServerController { const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS); if (!ready) { const recentLogs = this.getStartupLogs().slice(-20).join('\n'); + await this.shutdownOwnedServer(); this.localServerPort = null; throw new Error([ `Bundled backend did not become ready at ${displayUrl}.`, diff --git a/server/modules/computer-use/computer-use.service.ts b/server/modules/computer-use/computer-use.service.ts index 536c3dc8..4ffc61eb 100644 --- a/server/modules/computer-use/computer-use.service.ts +++ b/server/modules/computer-use/computer-use.service.ts @@ -22,6 +22,8 @@ const __dirname = getModuleDir(import.meta.url); const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true'; const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_MAX_SESSIONS_PER_OWNER || '1', 10); const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10); +const STOPPED_SESSION_RETENTION_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_STOPPED_SESSION_RETENTION_MS || String(30 * 60 * 1000), 10); +const MAX_STORED_SESSIONS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_MAX_STORED_SESSIONS || '100', 10); const COMPUTER_USE_SETTINGS_KEY = 'computer_use_settings'; const COMPUTER_USE_MCP_TOKEN_KEY = 'computer_use_mcp_token'; type ComputerUseRuntime = 'cloud' | 'local'; @@ -283,22 +285,52 @@ function findActiveAgentSession(): ComputerUseSession | null { .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0] || null; } +function positiveDuration(value: number, fallback: number): number { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + async function expireStaleSessions(now = Date.now()): Promise { - for (const session of sessions.values()) { - if (session.status !== 'ready') { - continue; - } + const sessionTtl = positiveDuration(SESSION_TTL_MS, 30 * 60 * 1000); + const stoppedRetention = positiveDuration(STOPPED_SESSION_RETENTION_MS, sessionTtl); + for (const [sessionId, session] of sessions.entries()) { const updatedAt = Date.parse(session.updatedAt); - if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) { + if (!Number.isFinite(updatedAt)) { continue; } - session.status = 'stopped'; - session.agentAccessEnabled = false; - session.updatedAt = new Date(now).toISOString(); - session.lastAction = 'expire'; - session.message = 'Computer Use session expired after inactivity.'; + if (session.status === 'ready') { + if (now - updatedAt <= sessionTtl) { + continue; + } + session.status = 'stopped'; + session.agentAccessEnabled = false; + session.updatedAt = new Date(now).toISOString(); + session.lastAction = 'expire'; + session.message = 'Computer Use session expired after inactivity.'; + continue; + } + + if (now - updatedAt > stoppedRetention) { + sessions.delete(sessionId); + } + } + + const maxStoredSessions = Number.isFinite(MAX_STORED_SESSIONS) && MAX_STORED_SESSIONS > 0 + ? MAX_STORED_SESSIONS + : 100; + if (sessions.size <= maxStoredSessions) { + return; + } + + const removable = [...sessions.values()] + .filter((session) => session.status !== 'ready') + .sort((a, b) => Date.parse(a.updatedAt) - Date.parse(b.updatedAt)); + for (const session of removable) { + if (sessions.size <= maxStoredSessions) { + break; + } + sessions.delete(session.id); } } diff --git a/server/modules/computer-use/semantics/helpers/macos/CloudCLISemantics.swift b/server/modules/computer-use/semantics/helpers/macos/CloudCLISemantics.swift index 9c91e624..be55aabb 100644 --- a/server/modules/computer-use/semantics/helpers/macos/CloudCLISemantics.swift +++ b/server/modules/computer-use/semantics/helpers/macos/CloudCLISemantics.swift @@ -15,6 +15,8 @@ struct ElementRecord { var stateElements: [String: [ElementRecord]] = [:] var stateAxElements: [String: [String: AXUIElement]] = [:] +var stateOrder: [String] = [] +let maxStoredStates = 100 func jsonLine(_ object: Any) { guard JSONSerialization.isValidJSONObject(object), @@ -116,6 +118,14 @@ func dictionary(_ record: ElementRecord) -> JSON { return output } +func pruneStoredStates() { + while stateOrder.count > maxStoredStates { + let evicted = stateOrder.removeFirst() + stateElements.removeValue(forKey: evicted) + stateAxElements.removeValue(forKey: evicted) + } +} + func resolveApp(_ query: String) throws -> NSRunningApplication { let normalized = query.lowercased() let apps = NSWorkspace.shared.runningApplications.filter { app in @@ -189,6 +199,8 @@ func getAppState(_ params: JSON) throws -> JSON { let stateId = "state_\(UUID().uuidString)" stateElements[stateId] = records stateAxElements[stateId] = axRecords + stateOrder.append(stateId) + pruneStoredStates() let elements = records.map(dictionary) return [