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

View file

@ -4,7 +4,7 @@
'coolify' => [
'version' => '4.0.0',
'helper_version' => '1.0.13',
'realtime_version' => '1.0.13',
'realtime_version' => '1.0.14',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),

View file

@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"

View file

@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always

View file

@ -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 }) => {

View file

@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"

View file

@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always

View file

@ -75,8 +75,6 @@ export function initializeTerminalComponent() {
focusWhenReady();
});
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
this.$watch('terminalActive', (active) => {
if (!active && this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
@ -150,8 +148,11 @@ export function initializeTerminalComponent() {
},
clearAllTimers() {
[this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
.forEach(timer => timer && clearInterval(timer));
if (this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
}
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
.forEach(timer => timer && clearTimeout(timer));
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
@ -282,6 +283,13 @@ export function initializeTerminalComponent() {
this.pendingCommand = null;
}
// (Re)start application-level keepalive on every successful connect.
// Server-side WebSocket protocol pings are the primary heartbeat; this
// adds a JSON-level ping in case the server-side is older or restarting.
if (!this.keepAliveInterval) {
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
}
// Start ping timeout monitoring
this.resetPingTimeout();
@ -494,11 +502,6 @@ export function initializeTerminalComponent() {
},
keepAlive() {
// Skip keepalive when document is hidden to prevent unnecessary disconnects
if (!this.isDocumentVisible) {
return;
}
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
@ -524,10 +527,23 @@ export function initializeTerminalComponent() {
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
// Send immediate ping to verify connection is still alive
// Connection may be half-open after Cloudflare/proxy idle drop while hidden.
// Probe with a short timeout (5s) instead of the default 35s — force a
// reconnect quickly if no pong arrives so the user is not stuck typing
// into a dead socket.
this.heartbeatMissed = 0;
this.sendMessage({ ping: true });
this.resetPingTimeout();
if (this.pingTimeoutId) {
clearTimeout(this.pingTimeoutId);
}
this.pingTimeoutId = setTimeout(() => {
logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.');
try {
this.socket.close(4000, 'Visibility-resume timeout');
} catch (_) {
// ignore — close handler will run on its own
}
}, 5000);
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
// Was connected before but now disconnected - attempt reconnection
this.reconnectAttempts = 0;

View file

@ -32,3 +32,28 @@
->toContain('if (!terminalDebugEnabled) {')
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
});
it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->toContain('ws.isAlive = true;')
->toContain("ws.on('pong'")
->toContain('ws.ping();')
->toContain('ws.terminate();')
->toContain('HEARTBEAT_INTERVAL_MS');
});
it('removes the keepalive short-circuit that fired when the tab was hidden', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects');
});
it('uses a fast probe timeout when the tab regains visibility', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain("'Visibility-resume timeout'");
});