chat: heartbeat the chat WebSocket so long/idle turns don't drop the connection

Bun closes a WS idle for idleTimeout (60s) and only resets its timer on frames
received from the client — but during a chat turn the client only receives, so a
long tool call or a gap between turns tripped the timeout (clean close 1000,
mid-stream cuts, reconnects). Now each chat connection is pinged every 25s in the
WS open handler (cleared in close); the client auto-pongs at the protocol level,
resetting Bun's timer. Covers every client (web, mobile) with one server change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 14:43:42 +00:00
co-authored by Claude Opus 4.8
parent 6f7bf8df42
commit f36b98db30
+20 -2
View File
@@ -54,6 +54,13 @@ export const resolveBaseCwd = (email: string, role: string, cwd?: string) => {
const wsToSessionMap = new WeakMap<any, string>();
// Per-connection heartbeat. Bun closes a WS idle for `idleTimeout` (60s), and its timer only resets
// on frames *received* from the client — but during a chat turn the client only receives. So we ping
// each connection every 25s; the client auto-pongs at the protocol level, which resets Bun's timer
// (and keeps reverse proxies happy). Genuinely dead sockets still time out (no pong).
const pingTimers = new WeakMap<ServerWebSocket<WSData>, ReturnType<typeof setInterval>>();
const PING_INTERVAL_MS = 25_000;
function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage): void {
if (ws?.readyState === 1) {
ws.send(JSON.stringify(msg));
@@ -61,7 +68,14 @@ function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage): v
}
export async function open(ws: ServerWebSocket<WSData>): Promise<void> {
// logger.info('WebSocket connection opened', { email: ws.data.email });
const timer = setInterval(() => {
try {
ws.ping(); // client auto-pongs → resets Bun's idleTimeout
} catch {
/* socket already gone */
}
}, PING_INTERVAL_MS);
pingTimers.set(ws, timer);
}
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void {
@@ -86,7 +100,11 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
}
export function close(ws: ServerWebSocket<WSData>): void {
// logger.info('WebSocket connection closed', { email: ws.data.email });
const timer = pingTimers.get(ws);
if (timer) {
clearInterval(timer);
pingTimers.delete(ws);
}
const sessionId = wsToSessionMap.get(ws);
if (sessionId) {