diff --git a/backend/server.js b/backend/server.js index 62b6ffc..87e0fed 100644 --- a/backend/server.js +++ b/backend/server.js @@ -117,8 +117,73 @@ function loadConfig() { } loadConfig(); -// Store active Claude sessions -const sessions = new Map(); +// LRU Map with size limit for sessions +class LRUMap extends Map { + constructor(maxSize = 100) { + super(); + this.maxSize = maxSize; + } + + set(key, value) { + // If key exists, delete it first to update its position + if (this.has(key)) { + this.delete(key); + } + // If at capacity, delete oldest entry + if (this.size >= this.maxSize) { + const oldestKey = this.keys().next().value; + const oldestSession = this.get(oldestKey); + console.log(`[LRU] Evicting oldest session: ${oldestKey}`); + // Kill the process if it exists + if (oldestSession?.process) { + try { + oldestSession.process.kill(); + } catch (e) { + // Ignore kill errors + } + } + this.delete(oldestKey); + } + super.set(key, value); + return this; + } + + // Touch a key to mark it as recently used + touch(key) { + if (this.has(key)) { + const value = this.get(key); + this.delete(key); + super.set(key, value); + } + } +} + +// Store active Claude sessions with LRU eviction (max 100 sessions) +const sessions = new LRUMap(100); + +// Process lifetime limits +const PROCESS_STARTUP_TIMEOUT = 30000; // 30s to start +const PROCESS_MAX_LIFETIME = 24 * 60 * 60 * 1000; // 24h max session + +// Periodic cleanup of idle sessions (every hour) +const SESSION_MAX_IDLE = 4 * 60 * 60 * 1000; // 4 hours idle = cleanup +setInterval(() => { + const now = Date.now(); + for (const [id, session] of sessions.entries()) { + const idle = now - (session.lastActivity || session.createdAt || now); + if (idle > SESSION_MAX_IDLE) { + console.log(`[Cleanup] Removing idle session: ${id} (idle: ${Math.round(idle/1000/60)}min)`); + if (session.process) { + try { + session.process.kill(); + } catch (e) { + // Ignore + } + } + sessions.delete(id); + } + } +}, 60 * 60 * 1000); // Check hourly // Control request counter for unique IDs let controlRequestCounter = 0; @@ -566,11 +631,20 @@ wss.on('connection', async (ws, req) => { const sessionId = uuidv4(); console.log(`[${sessionId}] New WebSocket connection`); - // Track connection health + // Track connection health with timestamp ws.isAlive = true; + ws.lastPong = Date.now(); - // Heartbeat to keep connection alive through proxies + // Heartbeat to keep connection alive through proxies + zombie detection + const HEARTBEAT_TIMEOUT = 60000; // 60s without pong = dead const heartbeatInterval = setInterval(() => { + // Check for zombie connections + if (Date.now() - ws.lastPong > HEARTBEAT_TIMEOUT) { + console.log(`[${sessionId}] Heartbeat timeout - terminating zombie connection`); + ws.terminate(); + return; + } + if (ws.readyState === ws.OPEN) { ws.ping(); } @@ -578,6 +652,7 @@ wss.on('connection', async (ws, req) => { ws.on('pong', () => { ws.isAlive = true; + ws.lastPong = Date.now(); }); // Authenticate WebSocket connection @@ -639,14 +714,61 @@ wss.on('connection', async (ws, req) => { pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode, createdAt: Date.now() }); }; + // Message queue with backpressure handling + const messageQueue = []; + const MAX_QUEUE_SIZE = 500; + let isFlushing = false; + + const flushMessageQueue = () => { + if (isFlushing || messageQueue.length === 0) return; + if (ws.readyState !== ws.OPEN) { + messageQueue.length = 0; // Clear queue if connection closed + return; + } + + isFlushing = true; + const msg = messageQueue.shift(); + + try { + ws.send(JSON.stringify(msg), (err) => { + isFlushing = false; + if (err) { + console.error(`[${sessionId}] WebSocket send failed:`, err.message); + // Don't re-queue on error - message is lost but prevents infinite loops + } + // Continue flushing if more messages + if (messageQueue.length > 0) { + setImmediate(flushMessageQueue); + } + }); + } catch (err) { + isFlushing = false; + console.error(`[${sessionId}] WebSocket send exception:`, err.message); + } + }; + const sendToClient = (type, data) => { - if (ws.readyState === ws.OPEN) { - try { - ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() })); - } catch (err) { - console.error(`[${sessionId}] WebSocket send failed:`, err.message); + if (ws.readyState !== ws.OPEN) return; + + const message = { type, ...data, timestamp: Date.now() }; + + // Backpressure: if queue is full, drop oldest non-critical messages + if (messageQueue.length >= MAX_QUEUE_SIZE) { + // Find oldest non-critical message to drop (keep errors, session_ended, etc) + const criticalTypes = ['error', 'session_ended', 'auth_error', 'permission_request']; + const dropIndex = messageQueue.findIndex(m => !criticalTypes.includes(m.type)); + if (dropIndex !== -1) { + messageQueue.splice(dropIndex, 1); + console.warn(`[${sessionId}] Queue full - dropped oldest non-critical message`); + } else { + // All critical, drop oldest anyway + messageQueue.shift(); + console.warn(`[${sessionId}] Queue full - dropped oldest message`); } } + + messageQueue.push(message); + flushMessageQueue(); }; const startClaudeSession = (projectPath, resume = true, hostId = null, silent = false) => { @@ -711,7 +833,48 @@ wss.on('connection', async (ws, req) => { }); } - sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId, user: wsUser }); + const sessionData = { + process: claudeProcess, + project: projectPath, + host: host, + hostId: hostId, + user: wsUser, + createdAt: Date.now(), + lastActivity: Date.now() + }; + sessions.set(sessionId, sessionData); + + // Process startup timeout - kill if not responsive within 30s + const startupTimeout = setTimeout(() => { + if (!isInitialized && claudeProcess) { + console.error(`[${sessionId}] Process startup timeout - killing`); + sendToClient('error', { message: 'Claude startup timeout - please try again' }); + try { + claudeProcess.kill('SIGKILL'); + } catch (e) { + // Ignore + } + } + }, PROCESS_STARTUP_TIMEOUT); + + // Process max lifetime - kill after 24h to prevent runaway sessions + const maxLifetimeTimeout = setTimeout(() => { + console.log(`[${sessionId}] Max lifetime reached (24h) - terminating session`); + sendToClient('session_ended', { reason: 'max_lifetime', message: 'Session expired after 24 hours' }); + if (claudeProcess) { + try { + claudeProcess.kill('SIGTERM'); + } catch (e) { + // Ignore + } + } + }, PROCESS_MAX_LIFETIME); + + // Clear timeouts on process exit + claudeProcess.once('exit', () => { + clearTimeout(startupTimeout); + clearTimeout(maxLifetimeTimeout); + }); // Only send session_started if not a silent restart (e.g., after interrupt) if (!silent) { @@ -963,6 +1126,13 @@ wss.on('connection', async (ws, req) => { const data = JSON.parse(message.toString()); if (DEBUG) console.log(`[${sessionId}] Received:`, data.type); + // Update session activity timestamp + const session = sessions.get(sessionId); + if (session) { + session.lastActivity = Date.now(); + sessions.touch(sessionId); // Move to end of LRU + } + switch (data.type) { case 'start_session': startClaudeSession(data.project || '/projects', data.resume !== false, data.host || null); @@ -997,42 +1167,65 @@ wss.on('connection', async (ws, req) => { // Interrupt Claude and restart with --continue // In JSON mode, SIGINT causes Claude to exit (unlike TUI mode where it just stops output) // So we need to restart the session automatically - if (claudeProcess) { - console.log(`[${sessionId}] Stop generation: sending SIGINT and will restart`); - - // Set flag to prevent session_ended from being sent - isRestarting = true; - - // Save current state for restart - const restartProject = currentProject; - const restartHost = currentHostId; - const restartPermissionMode = currentPermissionMode; - - // Notify frontend (no message - silent interrupt) - sendToClient('generation_stopped', { - timestamp: Date.now() - }); - - // Listen for exit and restart - claudeProcess.once('exit', (code) => { - console.log(`[${sessionId}] Claude exited with code ${code}, restarting with --continue`); - isInitialized = false; - - // Restart with --continue to resume conversation (silent = no session_started message) - setTimeout(() => { - startClaudeSession(restartProject, true, restartHost, true); // silent=true - savedPermissionMode = restartPermissionMode; - isRestarting = false; // Clear flag after restart - }, 200); - }); - - // Send SIGINT (graceful interrupt) - claudeProcess.kill('SIGINT'); - } else { + if (!claudeProcess) { sendToClient('generation_stopped', { message: 'No active process', timestamp: Date.now() }); + break; + } + + // Prevent multiple simultaneous restart attempts + if (isRestarting) { + sendToClient('error', { message: 'Already restarting, please wait' }); + break; + } + + console.log(`[${sessionId}] Stop generation: sending SIGINT and will restart`); + + // Set flag to prevent session_ended from being sent + isRestarting = true; + + // Save current state for restart + const restartProject = currentProject; + const restartHost = currentHostId; + const restartPermissionMode = currentPermissionMode; + + // Notify frontend (no message - silent interrupt) + sendToClient('generation_stopped', { + timestamp: Date.now() + }); + + // Timeout to prevent stuck isRestarting state + const restartTimeout = setTimeout(() => { + console.error(`[${sessionId}] Restart timeout - forcing new session`); + isRestarting = false; + isInitialized = false; + startClaudeSession(restartProject, true, restartHost, true); + savedPermissionMode = restartPermissionMode; + }, 10000); // 10s timeout + + // Listen for exit and restart + claudeProcess.once('exit', (code) => { + clearTimeout(restartTimeout); + console.log(`[${sessionId}] Claude exited with code ${code}, restarting with --continue`); + isInitialized = false; + + // Restart with --continue to resume conversation (silent = no session_started message) + setTimeout(() => { + startClaudeSession(restartProject, true, restartHost, true); // silent=true + savedPermissionMode = restartPermissionMode; + isRestarting = false; // Clear flag after restart + }, 200); + }); + + // Send SIGINT (graceful interrupt) + try { + claudeProcess.kill('SIGINT'); + } catch (killErr) { + console.error(`[${sessionId}] Kill failed:`, killErr.message); + clearTimeout(restartTimeout); + isRestarting = false; } break; diff --git a/frontend/src/components/MessageList.jsx b/frontend/src/components/MessageList.jsx index 2304484..0d1d8f9 100644 --- a/frontend/src/components/MessageList.jsx +++ b/frontend/src/components/MessageList.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo, memo, useCallback } from 'react'; +import { useEffect, useRef, useState, useMemo, memo, useCallback, lazy, Suspense } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; import { User, Bot, Terminal, CheckCircle, AlertCircle, Info, @@ -10,9 +10,42 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useAuth } from '../contexts/AuthContext'; import { useHosts } from '../contexts/HostContext'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; + +// Lazy load SyntaxHighlighter - saves ~500KB from initial bundle +const SyntaxHighlighter = lazy(() => + import('react-syntax-highlighter').then(mod => ({ + default: mod.Prism + })) +); + +// Import style separately (small JSON, OK to load eagerly for consistency) import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; +// Fallback component for code blocks while SyntaxHighlighter loads +const CodeFallback = memo(function CodeFallback({ children }) { + return ( +
+      {children}
+    
+ ); +}); + +// Wrapper for lazy-loaded SyntaxHighlighter with Suspense +const LazyCodeBlock = memo(function LazyCodeBlock({ language, style, customStyle, children, ...props }) { + return ( + {children}}> + + {children} + + + ); +}); + // Helper to extract and filter system-reminder tags function parseSystemReminders(text) { if (!text || typeof text !== 'string') return { content: text || '', reminders: [] }; @@ -415,7 +448,7 @@ const Message = memo(function Message({ message, onSendMessage, hostConfig }) { code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || ''); return !inline && match ? ( - {String(children).replace(/\n$/, '')} - + ) : ( {children} @@ -854,13 +887,13 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa if (tool === 'Bash' && input.command) { return (
- {input.command} - + {input.description && (
Description: {input.description}
)} @@ -1055,7 +1088,7 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa {lines} lines
{input.content && ( - {input.content} - + )} ); @@ -1221,13 +1254,13 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa // Default JSON view return ( - {formatInput()} - + ); }; @@ -1323,14 +1356,14 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa {/* Result Content */}
{resultLooksLikeCode ? ( - {resultData.contentStr} - + ) : (
{resultData.contentStr}
)} @@ -1433,14 +1466,14 @@ const ToolResultCard = memo(function ToolResultCard({ content, isSuccess = true {contentStr && (
{looksLikeCode ? ( - {contentStr} - + ) : (
{contentStr}
)}