fix(terminal): add WS heartbeat and fix proxy idle disconnects

Proxies (Cloudflare, nginx) drop idle WebSocket connections before the
application notices, leaving clients typing into dead sockets.

- Add server-side ping/pong heartbeat (30s) in terminal-server.js;
  terminate unresponsive clients instead of letting connections go stale
- Move client keepAlive interval start to the connect event so it
  restarts correctly after reconnects
- Remove hidden-tab keepalive short-circuit — server pings now own
  liveness; suppressing client pings while hidden masked proxy drops
- Fix clearAllTimers to use clearTimeout for one-shot timers
- On visibility resume, probe with a 5s timeout instead of the default
  35s so half-open sockets are detected quickly
- Bump coolify-realtime to 1.0.14 across all compose files
This commit is contained in:
Andras Bacsai
2026-04-28 10:35:32 +02:00
parent 9a58e0fea2
commit 9408620d5f
8 changed files with 79 additions and 16 deletions
@@ -105,7 +105,12 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
const HEARTBEAT_INTERVAL_MS = 30000;
wss.on('connection', async (ws, req) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
const userId = generateUserId();
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
@@ -167,6 +172,23 @@ wss.on('connection', async (ws, req) => {
});
});
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
logTerminal('warn', 'Terminating WS due to missed protocol pong.');
return ws.terminate();
}
ws.isAlive = false;
try {
ws.ping();
} catch (_) {
// ignore — close handler will follow
}
});
}, HEARTBEAT_INTERVAL_MS);
wss.on('close', () => clearInterval(heartbeat));
const messageHandlers = {
message: (session, data) => session.ptyProcess.write(data),
resize: (session, { cols, rows }) => {