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:
parent
9a58e0fea2
commit
9408620d5f
8 changed files with 79 additions and 16 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue