diff --git a/server/index.js b/server/index.js index 62d85130ba..59d2109aae 100755 --- a/server/index.js +++ b/server/index.js @@ -1396,6 +1396,20 @@ function handleChatConnection(ws, request) { // Add to connected clients for project updates connectedClients.add(ws); + // Heartbeat: detect dead connections (mobile backgrounding kills sockets silently) + let isAlive = true; + ws.on('pong', () => { isAlive = true; }); + const heartbeatInterval = setInterval(() => { + if (!isAlive) { + console.log('[INFO] Chat WebSocket heartbeat failed, terminating'); + clearInterval(heartbeatInterval); + ws.terminate(); + return; + } + isAlive = false; + try { ws.ping(); } catch (_) { /* socket already closing */ } + }, 30000); + // Wrap WebSocket with writer for consistent interface with SSEStreamWriter const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null); @@ -1403,6 +1417,14 @@ function handleChatConnection(ws, request) { try { const data = JSON.parse(message); + // Application-level ping for foreground-resume checks + if (data.type === 'ping') { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); + } + return; + } + if (data.type === 'claude-command') { console.log('[DEBUG] User message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.projectPath || 'Unknown'); @@ -1532,6 +1554,7 @@ function handleChatConnection(ws, request) { ws.on('close', () => { console.log('🔌 Chat client disconnected'); + clearInterval(heartbeatInterval); // Remove from connected clients connectedClients.delete(ws); }); @@ -1545,11 +1568,33 @@ function handleShellConnection(ws) { let urlDetectionBuffer = ''; const announcedAuthUrls = new Set(); + // Heartbeat: detect dead connections from mobile backgrounding + let isAlive = true; + ws.on('pong', () => { isAlive = true; }); + const heartbeatInterval = setInterval(() => { + if (!isAlive) { + console.log('[INFO] Shell WebSocket heartbeat failed, terminating'); + clearInterval(heartbeatInterval); + ws.terminate(); + return; + } + isAlive = false; + try { ws.ping(); } catch (_) { /* socket already closing */ } + }, 30000); + ws.on('message', async (message) => { try { const data = JSON.parse(message); console.log('📨 Shell message received:', data.type); + // Application-level ping for foreground-resume checks + if (data.type === 'ping') { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); + } + return; + } + if (data.type === 'init') { const projectPath = data.projectPath || process.cwd(); const sessionId = data.sessionId; @@ -1873,6 +1918,7 @@ function handleShellConnection(ws) { ws.on('close', () => { console.log('🔌 Shell client disconnected'); + clearInterval(heartbeatInterval); if (ptySessionKey) { const session = ptySessionsMap.get(ptySessionKey); diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 63c11df089..199428b441 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -7,6 +7,7 @@ import { useWebSocket } from '../../contexts/WebSocketContext'; import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useProjectsState } from '../../hooks/useProjectsState'; +import { useWakeLock } from '../../hooks/useWakeLock'; export default function AppContent() { const navigate = useNavigate(); @@ -26,6 +27,9 @@ export default function AppContent() { replaceTemporarySession, } = useSessionProtection(); + // Keep screen awake on mobile while an agent session is processing + useWakeLock(isMobile && processingSessions.size > 0); + const { selectedProject, selectedSession, diff --git a/src/components/shell/hooks/useShellConnection.ts b/src/components/shell/hooks/useShellConnection.ts index 7babed1593..39f406b6bc 100644 --- a/src/components/shell/hooks/useShellConnection.ts +++ b/src/components/shell/hooks/useShellConnection.ts @@ -5,10 +5,12 @@ import type { Terminal } from '@xterm/xterm'; import type { Project, ProjectSession } from '../../../types/app'; import { TERMINAL_INIT_DELAY_MS } from '../constants/constants'; import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket'; +import { useAppLifecycle } from '../../../hooks/useAppLifecycle'; const ANSI_ESCAPE_REGEX = /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u009B[0-?]*[ -/]*[@-~]|\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\u009D[^\u0007\u009C]*(?:\u0007|\u009C)|\u001B[PX^_][^\u001B]*\u001B\\|[\u0090\u0098\u009E\u009F][^\u009C]*\u009C|\u001B[@-Z\\-_])/g; const PROCESS_EXIT_REGEX = /Process exited with code (\d+)/; +const SHELL_RECONNECT_DELAY_MS = 5000; type UseShellConnectionOptions = { wsRef: MutableRefObject; @@ -54,6 +56,10 @@ export function useShellConnection({ const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const connectingRef = useRef(false); + const wasConnectedRef = useRef(false); + const shouldReconnectRef = useRef(true); + const reconnectTimeoutRef = useRef(null); + const { onForeground } = useAppLifecycle(); const handleProcessCompletion = useCallback( (output: string) => { @@ -165,7 +171,19 @@ export function useShellConnection({ setIsConnected(false); setIsConnecting(false); connectingRef.current = false; - clearTerminalScreen(); + + if (!shouldReconnectRef.current) return; + + // Don't clear terminal — server will replay buffered output on reconnect. + // Track that we were connected so foreground resume can auto-reconnect. + wasConnectedRef.current = true; + + // Auto-reconnect after delay (with jitter) + const jitter = Math.random() * 1000; + reconnectTimeoutRef.current = setTimeout(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + connectWebSocket(); + }, SHELL_RECONNECT_DELAY_MS + jitter); }; socket.onerror = () => { @@ -200,12 +218,19 @@ export function useShellConnection({ return; } + shouldReconnectRef.current = true; connectingRef.current = true; setIsConnecting(true); connectWebSocket(true); }, [connectWebSocket, isConnected, isConnecting, isInitialized]); const disconnectFromShell = useCallback(() => { + shouldReconnectRef.current = false; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + wasConnectedRef.current = false; closeSocket(); clearTerminalScreen(); setIsConnected(false); @@ -222,6 +247,34 @@ export function useShellConnection({ connectToShell(); }, [autoConnect, connectToShell, isConnected, isConnecting, isInitialized]); + // Foreground resume: auto-reconnect if we were previously connected + useEffect(() => { + const cleanup = onForeground(() => { + if (!wasConnectedRef.current) return; + const ws = wsRef.current; + if (ws && ws.readyState === WebSocket.OPEN) return; + + // Cancel any pending reconnect timer and connect immediately + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + console.log('[Shell] Foreground resume: reconnecting to shell'); + connectWebSocket(); + }); + + return cleanup; + }, [onForeground, connectWebSocket, wsRef]); + + // Clean up reconnect timer on unmount + useEffect(() => { + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + }; + }, []); + return { isConnected, isConnecting, diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx index 116da6b96a..04c0eed5ac 100644 --- a/src/contexts/WebSocketContext.tsx +++ b/src/contexts/WebSocketContext.tsx @@ -1,6 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useAuth } from '../components/auth/context/AuthContext'; import { IS_PLATFORM } from '../constants/config'; +import { useAppLifecycle } from '../hooks/useAppLifecycle'; type WebSocketContextType = { ws: WebSocket | null; @@ -26,52 +27,87 @@ const buildWebSocketUrl = (token: string | null) => { return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page }; +const HEARTBEAT_INTERVAL_MS = 25000; +const HEARTBEAT_TIMEOUT_MS = 10000; +const RECONNECT_DELAY_MS = 3000; +const STALE_THRESHOLD_MS = 5000; +const PING_PROBE_TIMEOUT_MS = 2000; + const useWebSocketProviderState = (): WebSocketContextType => { const wsRef = useRef(null); - const unmountedRef = useRef(false); // Track if component is unmounted - const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects) + const unmountedRef = useRef(false); + const hasConnectedRef = useRef(false); const [latestMessage, setLatestMessage] = useState(null); const [isConnected, setIsConnected] = useState(false); const reconnectTimeoutRef = useRef(null); + const heartbeatIntervalRef = useRef(null); + const lastPongRef = useRef(Date.now()); + const pingProbeTimeoutRef = useRef(null); const { token } = useAuth(); + const { onForeground, onBackground } = useAppLifecycle(); - useEffect(() => { - connect(); - - return () => { - unmountedRef.current = true; - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - if (wsRef.current) { - wsRef.current.close(); + const clearHeartbeat = useCallback(() => { + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } + if (pingProbeTimeoutRef.current) { + clearTimeout(pingProbeTimeoutRef.current); + pingProbeTimeoutRef.current = null; + } + }, []); + + const startHeartbeat = useCallback(() => { + clearHeartbeat(); + heartbeatIntervalRef.current = setInterval(() => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // If we haven't received a pong in too long, the connection is dead + if (Date.now() - lastPongRef.current > HEARTBEAT_INTERVAL_MS + HEARTBEAT_TIMEOUT_MS) { + console.warn('[WebSocket] Heartbeat timeout, forcing reconnect'); + clearHeartbeat(); + ws.close(); + return; } - }; - }, [token]); // everytime token changes, we reconnect + + try { + ws.send(JSON.stringify({ type: 'ping' })); + } catch (_) { /* socket closing */ } + }, HEARTBEAT_INTERVAL_MS); + }, [clearHeartbeat]); const connect = useCallback(() => { - if (unmountedRef.current) return; // Prevent connection if unmounted + if (unmountedRef.current) return; try { - // Construct WebSocket URL const wsUrl = buildWebSocketUrl(token); - if (!wsUrl) return console.warn('No authentication token found for WebSocket connection'); - + const websocket = new WebSocket(wsUrl); websocket.onopen = () => { setIsConnected(true); wsRef.current = websocket; + lastPongRef.current = Date.now(); if (hasConnectedRef.current) { - // This is a reconnect — signal so components can catch up on missed messages setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() }); } hasConnectedRef.current = true; + startHeartbeat(); }; websocket.onmessage = (event) => { try { const data = JSON.parse(event.data); + // Track application-level pongs for heartbeat + if (data.type === 'pong') { + lastPongRef.current = Date.now(); + if (pingProbeTimeoutRef.current) { + clearTimeout(pingProbeTimeoutRef.current); + pingProbeTimeoutRef.current = null; + } + return; + } setLatestMessage(data); } catch (error) { console.error('Error parsing WebSocket message:', error); @@ -81,12 +117,14 @@ const useWebSocketProviderState = (): WebSocketContextType => { websocket.onclose = () => { setIsConnected(false); wsRef.current = null; - - // Attempt to reconnect after 3 seconds + clearHeartbeat(); + + // Attempt to reconnect after delay (with jitter to avoid reconnect storms) + const jitter = Math.random() * 1000; reconnectTimeoutRef.current = setTimeout(() => { - if (unmountedRef.current) return; // Prevent reconnection if unmounted + if (unmountedRef.current) return; connect(); - }, 3000); + }, RECONNECT_DELAY_MS + jitter); }; websocket.onerror = (error) => { @@ -96,7 +134,78 @@ const useWebSocketProviderState = (): WebSocketContextType => { } catch (error) { console.error('Error creating WebSocket connection:', error); } - }, [token]); // everytime token changes, we reconnect + }, [token, startHeartbeat, clearHeartbeat]); + + // Initial connection + cleanup on token change + useEffect(() => { + unmountedRef.current = false; + connect(); + + return () => { + clearHeartbeat(); + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, [token]); + + // Mark as unmounted only on true component unmount + useEffect(() => { + return () => { + unmountedRef.current = true; + }; + }, []); + + // Foreground: immediately check connection health and reconnect if needed + useEffect(() => { + const cleanup = onForeground((backgroundDurationMs) => { + if (unmountedRef.current) return; + + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + // Socket is dead — cancel any pending reconnect and connect now + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + console.log('[WebSocket] Foreground resume: socket dead, reconnecting immediately'); + connect(); + return; + } + + // Socket reports OPEN but may be stale after long backgrounding + if (backgroundDurationMs > STALE_THRESHOLD_MS) { + try { + ws.send(JSON.stringify({ type: 'ping' })); + } catch (_) { + connect(); + return; + } + // If no pong within timeout, force reconnect + pingProbeTimeoutRef.current = setTimeout(() => { + console.log('[WebSocket] Foreground resume: stale connection detected, reconnecting'); + ws.close(); + }, PING_PROBE_TIMEOUT_MS); + return; // Don't restart heartbeat while probe is pending + } + + // Restart heartbeat (was paused during background) + startHeartbeat(); + }); + + return cleanup; + }, [onForeground, connect, startHeartbeat]); + + // Background: pause heartbeat (no point sending pings while frozen) + useEffect(() => { + const cleanup = onBackground(() => { + clearHeartbeat(); + }); + return cleanup; + }, [onBackground, clearHeartbeat]); const sendMessage = useCallback((message: any) => { const socket = wsRef.current; diff --git a/src/hooks/useAppLifecycle.ts b/src/hooks/useAppLifecycle.ts new file mode 100644 index 0000000000..79882373d1 --- /dev/null +++ b/src/hooks/useAppLifecycle.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Detects app foreground/background transitions via the Page Visibility API. + * Critical for mobile browsers where backgrounding kills WebSocket connections. + */ +export function useAppLifecycle() { + const [isVisible, setIsVisible] = useState(() => + typeof document !== 'undefined' ? document.visibilityState === 'visible' : true + ); + const backgroundTimestampRef = useRef(null); + const foregroundCallbacksRef = useRef void>>(new Set()); + const backgroundCallbacksRef = useRef void>>(new Set()); + + useEffect(() => { + if (typeof document === 'undefined') return; + + const handleVisibilityChange = () => { + const visible = document.visibilityState === 'visible'; + setIsVisible(visible); + + if (!visible) { + backgroundTimestampRef.current = Date.now(); + backgroundCallbacksRef.current.forEach((cb) => { + try { cb(); } catch (e) { console.error('[AppLifecycle] background callback error:', e); } + }); + } else { + const duration = backgroundTimestampRef.current + ? Date.now() - backgroundTimestampRef.current + : 0; + backgroundTimestampRef.current = null; + foregroundCallbacksRef.current.forEach((cb) => { + try { cb(duration); } catch (e) { console.error('[AppLifecycle] foreground callback error:', e); } + }); + } + }; + + // Handle bfcache restores (Safari/iOS) + const handlePageShow = (event: PageTransitionEvent) => { + if (event.persisted) { + const duration = backgroundTimestampRef.current + ? Date.now() - backgroundTimestampRef.current + : 0; + backgroundTimestampRef.current = null; + setIsVisible(true); + foregroundCallbacksRef.current.forEach((cb) => { + try { cb(duration); } catch (e) { console.error('[AppLifecycle] pageshow callback error:', e); } + }); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('pageshow', handlePageShow); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('pageshow', handlePageShow); + }; + }, []); + + const onForeground = useCallback((callback: (backgroundDurationMs: number) => void) => { + foregroundCallbacksRef.current.add(callback); + return () => { foregroundCallbacksRef.current.delete(callback); }; + }, []); + + const onBackground = useCallback((callback: () => void) => { + backgroundCallbacksRef.current.add(callback); + return () => { backgroundCallbacksRef.current.delete(callback); }; + }, []); + + return { isVisible, onForeground, onBackground }; +} diff --git a/src/hooks/useWakeLock.ts b/src/hooks/useWakeLock.ts new file mode 100644 index 0000000000..ee9c688e78 --- /dev/null +++ b/src/hooks/useWakeLock.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'react'; + +/** + * Requests a screen Wake Lock to keep WebSocket connections alive during brief + * app switches on mobile (notification shade, task switcher preview, etc.). + * + * Only activates when `shouldLock` is true (e.g., an agent session is processing). + * Automatically re-acquires the lock on foreground resume since the browser + * releases it when the page becomes hidden. + */ +export function useWakeLock(shouldLock: boolean) { + const wakeLockRef = useRef(null); + + useEffect(() => { + if (!shouldLock || !('wakeLock' in navigator)) return; + + let released = false; + + const requestLock = async () => { + try { + if (released) return; + const sentinel = await navigator.wakeLock.request('screen'); + // If cleanup ran while awaiting, release immediately + if (released) { + await sentinel.release().catch(() => {}); + return; + } + wakeLockRef.current = sentinel; + sentinel.addEventListener('release', () => { + // Only clear ref if it still holds this sentinel (not a newer one) + if (wakeLockRef.current === sentinel) { + wakeLockRef.current = null; + } + }); + } catch (_) { + // Wake Lock request can fail (low battery, permission denied, etc.) + } + }; + + // Re-acquire on foreground resume (browser releases lock on background) + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && !released) { + requestLock(); + } + }; + + requestLock(); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + released = true; + document.removeEventListener('visibilitychange', handleVisibilityChange); + if (wakeLockRef.current) { + wakeLockRef.current.release().catch(() => {}); + wakeLockRef.current = null; + } + }; + }, [shouldLock]); +}