From a649c91b68fb54981d086c5f41159a5d5dbd8302 Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 14 Mar 2026 18:37:10 +0100 Subject: [PATCH] fix: WS session lifecycle, reconnection, and CJK session-switch cleanup - Close WebSocket when session exits (exit event listener) to prevent orphaned listeners and stale writes to dead PTY - Add readyState guard in onTerminal to stop buffering after socket closes - Simplify heartbeat: remove redundant alive flag, use pongTimeout only - Add exponential backoff reconnection on unexpected WS close (skip for server rejections 4004/4008/4009) - Clear CJK textarea on session switch to prevent wrong-session input Co-Authored-By: Claude Opus 4.6 --- src/web/public/app.js | 33 ++++++++++++++++++++++++++++----- src/web/routes/ws-routes.ts | 18 ++++++++++-------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index dec17f68..126cd4bb 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2904,6 +2904,7 @@ class CodemanApp { // Only mark ready if this is still the intended session if (this._ws === ws) { this._wsReady = true; + this._wsReconnectAttempts = 0; } }; @@ -2924,11 +2925,24 @@ class CodemanApp { } }; - ws.onclose = () => { - if (this._ws === ws) { - this._ws = null; - this._wsSessionId = null; - this._wsReady = false; + ws.onclose = (event) => { + if (this._ws !== ws) return; + this._ws = null; + this._wsSessionId = null; + this._wsReady = false; + + // Reconnect on unexpected close (server restart, network blip, ping timeout). + // Don't reconnect if we intentionally disconnected (_disconnectWs nulls onclose) + // or if the server rejected the session (4004=not found, 4008=too many, 4009=terminated). + if (event.code < 4004 && this.activeSessionId === sessionId) { + const delay = Math.min(1000 * Math.pow(2, this._wsReconnectAttempts || 0), 10000); + this._wsReconnectAttempts = (this._wsReconnectAttempts || 0) + 1; + this._wsReconnectTimer = setTimeout(() => { + this._wsReconnectTimer = null; + if (this.activeSessionId === sessionId) { + this._connectWs(sessionId); + } + }, delay); } }; @@ -2939,6 +2953,11 @@ class CodemanApp { /** Close the active WebSocket connection (if any). */ _disconnectWs() { + if (this._wsReconnectTimer) { + clearTimeout(this._wsReconnectTimer); + this._wsReconnectTimer = null; + } + this._wsReconnectAttempts = 0; if (this._ws) { this._ws.onclose = null; // Prevent re-entrant cleanup this._ws.close(); @@ -3763,6 +3782,10 @@ class CodemanApp { // Close WebSocket for previous session (new one opens after buffer load) this._disconnectWs(); + // Clear CJK textarea to prevent sending stale text to the wrong session + const cjkEl = document.getElementById('cjkInput'); + if (cjkEl) cjkEl.value = ''; + // Clean up flicker filter state when switching sessions if (this.flickerFilterTimeout) { clearTimeout(this.flickerFilterTimeout); diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index 79c55e3d..5d9b4115 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -123,6 +123,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void { // Terminal output -> micro-batched WS send const onTerminal = (data: string) => { + if (socket.readyState !== 1) return; batchChunks.push(data); batchSize += data.length; @@ -153,17 +154,22 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void { } }; + // Close WS when session exits (deleted, respawned, or crashed) — prevents + // orphaned listeners and stale writes to a dead PTY. + const onSessionExit = () => { + socket.close(4009, 'Session terminated'); + }; + session.on('terminal', onTerminal); session.on('clearTerminal', onClearTerminal); session.on('needsRefresh', onNeedsRefresh); + session.on('exit', onSessionExit); // Heartbeat: detect stale connections (especially through tunnels where // TCP RST can take minutes to propagate). let pongTimeout: ReturnType | null = null; - let alive = true; socket.on('pong', () => { - alive = true; if (pongTimeout) { clearTimeout(pongTimeout); pongTimeout = null; @@ -171,12 +177,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void { }); const pingInterval = setInterval(() => { - if (!alive) { - // Previous ping never got a pong — connection is dead - socket.terminate(); - return; - } - alive = false; + if (socket.readyState !== 1) return; socket.ping(); pongTimeout = setTimeout(() => { socket.terminate(); @@ -191,6 +192,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void { session.off('terminal', onTerminal); session.off('clearTerminal', onClearTerminal); session.off('needsRefresh', onNeedsRefresh); + session.off('exit', onSessionExit); // Decrement per-session connection count const count = sessionWsCount.get(id) ?? 1;