From a3d9287ea4425deea544c24b2ffba9407c83d3d4 Mon Sep 17 00:00:00 2001 From: Erik M Jacobs Date: Tue, 20 Jan 2026 09:52:41 -0500 Subject: [PATCH] fix: Prevent JavaScript heap out of memory crash Add automatic cleanup mechanisms to prevent unbounded memory growth: - Session cleanup: Remove inactive sessions (24h+, no connections, no active agent) every 5 minutes - WebSocket cleanup: Detect and terminate orphaned connections (no heartbeat for 60s) every 30 seconds - UsageAnalytics: Add hourly auto-cleanup interval, limit sessionHistory to 100 entries, add destroy() method - UsageReader: Add caching for expensive methods (getCurrentSessionStats 5s, detectOverlappingSessions 30s) - Memory monitoring: Log heap usage every 5 minutes in dev mode Also removes unused historicalData array from UsageAnalytics. Co-Authored-By: Claude Opus 4.5 --- src/server.js | 142 +++++++++++++++++++++++++++++++++++++++-- src/usage-analytics.js | 36 +++++++++-- src/usage-reader.js | 39 +++++++++-- 3 files changed, 201 insertions(+), 16 deletions(-) diff --git a/src/server.js b/src/server.js index de88bf1..a9674bd 100644 --- a/src/server.js +++ b/src/server.js @@ -24,7 +24,7 @@ class ClaudeCodeWebServer { this.keyFile = options.key; this.folderMode = options.folderMode !== false; // Default to true this.selectedWorkingDir = null; - this.baseFolder = process.cwd(); // The folder where the app runs from + this.baseFolder = options.cwd || process.cwd(); // The folder where the app runs from (or specified via --cwd) // Session duration in hours (default to 5 hours from first message) this.sessionDurationHours = parseFloat(process.env.CLAUDE_SESSION_HOURS || options.sessionHours || 5); @@ -42,6 +42,9 @@ class ClaudeCodeWebServer { customCostLimit: parseFloat(process.env.CLAUDE_COST_LIMIT || options.customCostLimit || 50.00) }); this.autoSaveInterval = null; + this.sessionCleanupInterval = null; + this.wsCleanupInterval = null; + this.memoryMonitorInterval = null; this.startTime = Date.now(); // Track server start time this.isShuttingDown = false; // Flag to prevent duplicate shutdown // Commands dropdown removed @@ -55,6 +58,9 @@ class ClaudeCodeWebServer { this.setupExpress(); this.loadPersistedSessions(); this.setupAutoSave(); + this.setupSessionCleanup(); + this.setupWebSocketCleanup(); + this.setupMemoryMonitoring(); } async loadPersistedSessions() { @@ -74,13 +80,115 @@ class ClaudeCodeWebServer { this.autoSaveInterval = setInterval(() => { this.saveSessionsToDisk(); }, 30000); - + // Also save on process exit process.on('SIGINT', () => this.handleShutdown()); process.on('SIGTERM', () => this.handleShutdown()); process.on('beforeExit', () => this.saveSessionsToDisk()); } - + + setupSessionCleanup() { + // Clean up inactive sessions every 5 minutes + this.sessionCleanupInterval = setInterval(() => { + this.cleanupInactiveSessions(); + }, 5 * 60 * 1000); + } + + cleanupInactiveSessions() { + const now = Date.now(); + const maxInactiveMs = 24 * 60 * 60 * 1000; // 24 hours + let cleanedCount = 0; + + for (const [sessionId, session] of this.claudeSessions.entries()) { + const lastActivity = session.lastActivity instanceof Date + ? session.lastActivity.getTime() + : new Date(session.lastActivity).getTime(); + + const isInactive = (now - lastActivity) > maxInactiveMs; + const hasNoConnections = session.connections.size === 0; + const isNotActive = !session.active; + + // Remove session if it's been inactive for 24+ hours, has no connections, and no active agent + if (isInactive && hasNoConnections && isNotActive) { + // Stop any running processes just in case + if (session.agent === 'codex') { + this.codexBridge.stopSession(sessionId); + } else if (session.agent === 'agent') { + this.agentBridge.stopSession(sessionId); + } else if (session.agent === 'claude') { + this.claudeBridge.stopSession(sessionId); + } + + this.claudeSessions.delete(sessionId); + cleanedCount++; + + if (this.dev) { + console.log(`Cleaned up inactive session: ${sessionId} (inactive for ${Math.round((now - lastActivity) / (1000 * 60 * 60))}h)`); + } + } + } + + if (cleanedCount > 0) { + this.saveSessionsToDisk(); + if (this.dev) { + console.log(`Session cleanup: removed ${cleanedCount} inactive sessions, ${this.claudeSessions.size} remaining`); + } + } + } + + setupWebSocketCleanup() { + // Clean up orphaned WebSocket connections every 30 seconds + this.wsCleanupInterval = setInterval(() => { + this.cleanupOrphanedWebSockets(); + }, 30 * 1000); + } + + cleanupOrphanedWebSockets() { + const now = Date.now(); + const maxSilenceMs = 60 * 1000; // 60 seconds without pong + let cleanedCount = 0; + + for (const [wsId, wsInfo] of this.webSocketConnections.entries()) { + // Check if we have a lastPong timestamp + if (wsInfo.lastPong) { + const silenceMs = now - wsInfo.lastPong; + + if (silenceMs > maxSilenceMs) { + if (this.dev) { + console.log(`Terminating orphaned WebSocket ${wsId} (no pong for ${Math.round(silenceMs / 1000)}s)`); + } + + // Terminate the connection + if (wsInfo.ws && wsInfo.ws.readyState !== WebSocket.CLOSED) { + wsInfo.ws.terminate(); + } + + this.cleanupWebSocketConnection(wsId); + cleanedCount++; + } + } + } + + if (cleanedCount > 0 && this.dev) { + console.log(`WebSocket cleanup: removed ${cleanedCount} orphaned connections, ${this.webSocketConnections.size} remaining`); + } + } + + setupMemoryMonitoring() { + // Only monitor memory in dev mode + if (!this.dev) return; + + // Log memory usage every 5 minutes in dev mode + this.memoryMonitorInterval = setInterval(() => { + const memUsage = process.memoryUsage(); + const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024); + const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024); + const rssMB = Math.round(memUsage.rss / 1024 / 1024); + + console.log(`[Memory] Heap: ${heapUsedMB}/${heapTotalMB} MB | RSS: ${rssMB} MB | Sessions: ${this.claudeSessions.size} | WS: ${this.webSocketConnections.size}`); + }, 5 * 60 * 1000); + } + async saveSessionsToDisk() { if (this.claudeSessions.size > 0) { await this.sessionStore.saveSessions(this.claudeSessions); @@ -596,7 +704,8 @@ class ClaudeCodeWebServer { id: wsId, ws, claudeSessionId: null, - created: new Date() + created: new Date(), + lastPong: Date.now() // Track last heartbeat response for orphan detection }; this.webSocketConnections.set(wsId, wsInfo); @@ -741,6 +850,7 @@ class ClaudeCodeWebServer { break; case 'ping': + wsInfo.lastPong = Date.now(); // Update heartbeat timestamp this.sendToWebSocket(wsInfo.ws, { type: 'pong' }); break; @@ -1191,12 +1301,32 @@ class ClaudeCodeWebServer { close() { // Save sessions before closing this.saveSessionsToDisk(); - + // Clear auto-save interval if (this.autoSaveInterval) { clearInterval(this.autoSaveInterval); } - + + // Clear session cleanup interval + if (this.sessionCleanupInterval) { + clearInterval(this.sessionCleanupInterval); + } + + // Clear WebSocket cleanup interval + if (this.wsCleanupInterval) { + clearInterval(this.wsCleanupInterval); + } + + // Clear memory monitoring interval + if (this.memoryMonitorInterval) { + clearInterval(this.memoryMonitorInterval); + } + + // Destroy usage analytics (clears its internal intervals) + if (this.usageAnalytics && this.usageAnalytics.destroy) { + this.usageAnalytics.destroy(); + } + if (this.wss) { this.wss.close(); } diff --git a/src/usage-analytics.js b/src/usage-analytics.js index f78cbbc..35b354e 100644 --- a/src/usage-analytics.js +++ b/src/usage-analytics.js @@ -63,20 +63,22 @@ class UsageAnalytics extends EventEmitter { this.activeSessions = new Map(); // sessionId -> session data this.sessionHistory = []; this.rollingWindows = new Map(); // Track multiple overlapping windows - + // Usage data this.recentUsage = []; // Array of {timestamp, tokens, cost, model} - this.historicalData = []; this.p90Limit = null; - + // Burn rate tracking this.burnRateHistory = []; this.currentBurnRate = 0; this.velocityTrend = 'stable'; // 'increasing', 'decreasing', 'stable' - + // Predictions this.depletionTime = null; this.depletionConfidence = 0; + + // Auto-cleanup every hour + this.cleanupInterval = setInterval(() => this.cleanup(), 60 * 60 * 1000); } /** @@ -476,7 +478,7 @@ class UsageAnalytics extends EventEmitter { */ cleanup() { const now = new Date(); - + // Remove expired sessions for (const [id, session] of this.activeSessions) { if (session.endTime < now) { @@ -484,10 +486,32 @@ class UsageAnalytics extends EventEmitter { this.activeSessions.delete(id); } } - + // Keep only last 24 hours of history const cutoff = new Date(now - 24 * 60 * 60 * 1000); this.sessionHistory = this.sessionHistory.filter(s => s.endTime > cutoff); + + // Limit sessionHistory array size to prevent unbounded growth + if (this.sessionHistory.length > 100) { + this.sessionHistory = this.sessionHistory.slice(-100); + } + } + + /** + * Destroy and clean up resources + */ + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + // Clear all data structures + this.activeSessions.clear(); + this.sessionHistory = []; + this.rollingWindows.clear(); + this.recentUsage = []; + this.burnRateHistory = []; } } diff --git a/src/usage-reader.js b/src/usage-reader.js index 9ae9190..b7317ce 100644 --- a/src/usage-reader.js +++ b/src/usage-reader.js @@ -11,6 +11,15 @@ class UsageReader { this.cacheTimeout = 5000; // Cache for 5 seconds for more real-time updates this.sessionDurationHours = sessionDurationHours; // Default 5 hours from first message this.overlappingSessions = []; // Track overlapping sessions + + // Caching for expensive methods to prevent repeated disk reads + this.currentSessionStatsCache = null; + this.currentSessionStatsCacheTime = null; + this.currentSessionStatsCacheTimeout = 5000; // 5 second cache + + this.overlappingSessionsCache = null; + this.overlappingSessionsCacheTime = null; + this.overlappingSessionsCacheTimeout = 30000; // 30 second cache } /** @@ -84,8 +93,14 @@ class UsageReader { } async getCurrentSessionStats() { + // Use cache if fresh + if (this.currentSessionStatsCache && + this.currentSessionStatsCacheTime && + (Date.now() - this.currentSessionStatsCacheTime < this.currentSessionStatsCacheTimeout)) { + return this.currentSessionStatsCache; + } + try { - // Use new session logic based on daily boundaries and cascading 5-hour sessions const currentSession = await this.getCurrentSession(); @@ -159,7 +174,11 @@ class UsageReader { stats.cacheTokens = stats.cacheCreationTokens + stats.cacheReadTokens; // Total tokens only includes input and output (matching claude-monitor behavior) stats.totalTokens = stats.inputTokens + stats.outputTokens; - + + // Cache the result + this.currentSessionStatsCache = stats; + this.currentSessionStatsCacheTime = Date.now(); + return stats; } catch (error) { console.error('Error reading current session stats:', error); @@ -638,12 +657,19 @@ class UsageReader { // Detect overlapping sessions within rolling windows async detectOverlappingSessions() { + // Use cache if fresh + if (this.overlappingSessionsCache && + this.overlappingSessionsCacheTime && + (Date.now() - this.overlappingSessionsCacheTime < this.overlappingSessionsCacheTimeout)) { + return this.overlappingSessionsCache; + } + try { const now = new Date(); const lookbackHours = this.sessionDurationHours * 2; // Look back twice the session duration const cutoff = new Date(now - lookbackHours * 60 * 60 * 1000); const entries = await this.readAllEntries(cutoff); - + if (entries.length === 0) return []; // Group entries into sessions based on time gaps @@ -707,13 +733,18 @@ class UsageReader { } this.overlappingSessions = overlapping; + + // Cache the result + this.overlappingSessionsCache = sessions; + this.overlappingSessionsCacheTime = Date.now(); + return sessions; } catch (error) { console.error('Error detecting overlapping sessions:', error); return []; } } - + // Generate a session ID from timestamp generateSessionId(timestamp) { return `session_${new Date(timestamp).getTime()}`;