Skip to content

Commit a649c91

Browse files
Ark0Nclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 3383c23 commit a649c91

2 files changed

Lines changed: 38 additions & 13 deletions

File tree

src/web/public/app.js

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2904,6 +2904,7 @@ class CodemanApp {
29042904
// Only mark ready if this is still the intended session
29052905
if (this._ws === ws) {
29062906
this._wsReady = true;
2907+
this._wsReconnectAttempts = 0;
29072908
}
29082909
};
29092910

@@ -2924,11 +2925,24 @@ class CodemanApp {
29242925
}
29252926
};
29262927

2927-
ws.onclose = () => {
2928-
if (this._ws === ws) {
2929-
this._ws = null;
2930-
this._wsSessionId = null;
2931-
this._wsReady = false;
2928+
ws.onclose = (event) => {
2929+
if (this._ws !== ws) return;
2930+
this._ws = null;
2931+
this._wsSessionId = null;
2932+
this._wsReady = false;
2933+
2934+
// Reconnect on unexpected close (server restart, network blip, ping timeout).
2935+
// Don't reconnect if we intentionally disconnected (_disconnectWs nulls onclose)
2936+
// or if the server rejected the session (4004=not found, 4008=too many, 4009=terminated).
2937+
if (event.code < 4004 && this.activeSessionId === sessionId) {
2938+
const delay = Math.min(1000 * Math.pow(2, this._wsReconnectAttempts || 0), 10000);
2939+
this._wsReconnectAttempts = (this._wsReconnectAttempts || 0) + 1;
2940+
this._wsReconnectTimer = setTimeout(() => {
2941+
this._wsReconnectTimer = null;
2942+
if (this.activeSessionId === sessionId) {
2943+
this._connectWs(sessionId);
2944+
}
2945+
}, delay);
29322946
}
29332947
};
29342948

@@ -2939,6 +2953,11 @@ class CodemanApp {
29392953

29402954
/** Close the active WebSocket connection (if any). */
29412955
_disconnectWs() {
2956+
if (this._wsReconnectTimer) {
2957+
clearTimeout(this._wsReconnectTimer);
2958+
this._wsReconnectTimer = null;
2959+
}
2960+
this._wsReconnectAttempts = 0;
29422961
if (this._ws) {
29432962
this._ws.onclose = null; // Prevent re-entrant cleanup
29442963
this._ws.close();
@@ -3763,6 +3782,10 @@ class CodemanApp {
37633782
// Close WebSocket for previous session (new one opens after buffer load)
37643783
this._disconnectWs();
37653784

3785+
// Clear CJK textarea to prevent sending stale text to the wrong session
3786+
const cjkEl = document.getElementById('cjkInput');
3787+
if (cjkEl) cjkEl.value = '';
3788+
37663789
// Clean up flicker filter state when switching sessions
37673790
if (this.flickerFilterTimeout) {
37683791
clearTimeout(this.flickerFilterTimeout);

src/web/routes/ws-routes.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
123123

124124
// Terminal output -> micro-batched WS send
125125
const onTerminal = (data: string) => {
126+
if (socket.readyState !== 1) return;
126127
batchChunks.push(data);
127128
batchSize += data.length;
128129

@@ -153,30 +154,30 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
153154
}
154155
};
155156

157+
// Close WS when session exits (deleted, respawned, or crashed) — prevents
158+
// orphaned listeners and stale writes to a dead PTY.
159+
const onSessionExit = () => {
160+
socket.close(4009, 'Session terminated');
161+
};
162+
156163
session.on('terminal', onTerminal);
157164
session.on('clearTerminal', onClearTerminal);
158165
session.on('needsRefresh', onNeedsRefresh);
166+
session.on('exit', onSessionExit);
159167

160168
// Heartbeat: detect stale connections (especially through tunnels where
161169
// TCP RST can take minutes to propagate).
162170
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
163-
let alive = true;
164171

165172
socket.on('pong', () => {
166-
alive = true;
167173
if (pongTimeout) {
168174
clearTimeout(pongTimeout);
169175
pongTimeout = null;
170176
}
171177
});
172178

173179
const pingInterval = setInterval(() => {
174-
if (!alive) {
175-
// Previous ping never got a pong — connection is dead
176-
socket.terminate();
177-
return;
178-
}
179-
alive = false;
180+
if (socket.readyState !== 1) return;
180181
socket.ping();
181182
pongTimeout = setTimeout(() => {
182183
socket.terminate();
@@ -191,6 +192,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
191192
session.off('terminal', onTerminal);
192193
session.off('clearTerminal', onClearTerminal);
193194
session.off('needsRefresh', onNeedsRefresh);
195+
session.off('exit', onSessionExit);
194196

195197
// Decrement per-session connection count
196198
const count = sessionWsCount.get(id) ?? 1;

0 commit comments

Comments
 (0)