From cc624d2575dd884c84657773c7c759cd68241c45 Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 14 Mar 2026 17:58:50 +0100 Subject: [PATCH] feat: add ping/pong heartbeat to WebSocket connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect stale connections that TCP keepalive won't catch for minutes, especially through tunnels and proxies. Pings every 30s with a 10s pong timeout — if the client doesn't respond, the socket is terminated and all timers cleaned up. Co-Authored-By: Claude Opus 4.6 --- src/web/routes/ws-routes.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index e31b4f29..fe490608 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -38,6 +38,13 @@ const WS_BATCH_INTERVAL_MS = 8; /** Flush immediately when batch exceeds this size (bytes) for responsiveness. */ const WS_BATCH_FLUSH_THRESHOLD = 16384; +/** How often to ping each WebSocket client (ms). Detects stale connections that + * TCP keepalive won't catch for minutes, especially through tunnels/proxies. */ +const WS_PING_INTERVAL_MS = 30_000; + +/** If pong isn't received within this window after a ping, terminate the socket. */ +const WS_PONG_TIMEOUT_MS = 10_000; + /** DEC 2026 synchronized update markers. Wrapping output in these tells xterm.js * to buffer all content and render atomically in a single frame — eliminates * flicker from cursor-up redraws that Ink sends without its own sync markers @@ -133,7 +140,35 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void { session.on('clearTerminal', onClearTerminal); session.on('needsRefresh', onNeedsRefresh); + // 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; + } + }); + + const pingInterval = setInterval(() => { + if (!alive) { + // Previous ping never got a pong — connection is dead + socket.terminate(); + return; + } + alive = false; + socket.ping(); + pongTimeout = setTimeout(() => { + socket.terminate(); + }, WS_PONG_TIMEOUT_MS); + }, WS_PING_INTERVAL_MS); + socket.on('close', () => { + clearInterval(pingInterval); + if (pongTimeout) clearTimeout(pongTimeout); if (batchTimer) clearTimeout(batchTimer); batchChunks = []; session.off('terminal', onTerminal);