From 2edfef2e3f4271c29ae8670df9dd382a9eef7c3c Mon Sep 17 00:00:00 2001 From: Vojtech <119944107+cvrysanek@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:07:59 +0400 Subject: [PATCH] fix(websocket): add 30s server-side heartbeat to prevent proxy idle disconnects (#770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebSocket gateway never sent ping frames, so any reverse proxy with an idle timeout (Cloudflare Tunnel ~100s, AWS ALB 60s, nginx 60s, etc.) would silently tear down /shell, /ws and /plugin-ws/* connections after the idle window. The UI reconnects automatically but users see a "Connecting to shell" toast every 1–3 minutes during normal use and any in-flight PTY/chat traffic can race the reconnect. Schedule a 30s ws.ping() per connection at the gateway level, cleared on close/error. ping/pong counts as protocol activity for all proxies that implement WebSocket correctly, so this single change covers every deployment topology without per-proxy tuning. Fixes #769 Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com> --- .../services/websocket-server.service.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server/modules/websocket/services/websocket-server.service.ts b/server/modules/websocket/services/websocket-server.service.ts index 7e5c12e4..2ba2ec6e 100644 --- a/server/modules/websocket/services/websocket-server.service.ts +++ b/server/modules/websocket/services/websocket-server.service.ts @@ -31,6 +31,24 @@ export function createWebSocketServer( }); wss.on('connection', (ws, request) => { + // Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s, + // AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections + // are silently torn down even when the UI is active, causing repeated + // reconnect cycles. ws library heartbeat is opt-in. + const HEARTBEAT_INTERVAL_MS = 30_000; + const heartbeat = setInterval(() => { + if (ws.readyState === ws.OPEN) { + try { + ws.ping(); + } catch { + // socket may have been closed concurrently — interval will be cleared below + } + } + }, HEARTBEAT_INTERVAL_MS); + const stopHeartbeat = () => clearInterval(heartbeat); + ws.on('close', stopHeartbeat); + ws.on('error', stopHeartbeat); + const incomingRequest = request as AuthenticatedWebSocketRequest; const url = incomingRequest.url ?? '/'; const pathname = new URL(url, 'http://localhost').pathname;