diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 470f4af1e..11695173b 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -106,13 +106,24 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); const HEARTBEAT_INTERVAL_MS = 30000; +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; 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: [] }; + ws.userId = userId; + const userSession = { + ws, + userId, + ptyProcess: null, + isActive: false, + authorizedIPs: [], + lastActivityAt: Date.now(), + authReady: false, + pendingMessages: [], + }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const connectionContext = { userId, @@ -122,6 +133,26 @@ wss.on('connection', async (ws, req) => { hasLaravelSession: Boolean(laravelSession), }; + // Register socket handlers up front so messages sent immediately by the client + // (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch + // below is still pending. + ws.on('message', (message) => { + if (userSession.authReady) { + handleMessage(userSession, message); + } else { + userSession.pendingMessages.push(message); + } + }); + ws.on('error', (err) => handleError(err, userId)); + ws.on('close', (code, reason) => { + logTerminal('log', 'Terminal websocket connection closed.', { + userId, + code, + reason: reason?.toString(), + }); + handleClose(userId); + }); + // Verify presence of required tokens if (!laravelSession || !xsrfToken) { logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); @@ -153,23 +184,17 @@ wss.on('connection', async (ws, req) => { } userSessions.set(userId, userSession); + userSession.authReady = true; logTerminal('log', 'Terminal websocket connection established.', { ...connectionContext, authorizedHostCount: userSession.authorizedIPs.length, + bufferedMessages: userSession.pendingMessages.length, }); - ws.on('message', (message) => { - handleMessage(userSession, message); - }); - ws.on('error', (err) => handleError(err, userId)); - ws.on('close', (code, reason) => { - logTerminal('log', 'Terminal websocket connection closed.', { - userId, - code, - reason: reason?.toString(), - }); - handleClose(userId); - }); + // Drain any messages that arrived while we were waiting on the IP auth call. + while (userSession.pendingMessages.length > 0) { + handleMessage(userSession, userSession.pendingMessages.shift()); + } }); const heartbeat = setInterval(() => { @@ -184,14 +209,41 @@ const heartbeat = setInterval(() => { } catch (_) { // ignore — close handler will follow } + + const session = ws.userId ? userSessions.get(ws.userId) : null; + if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) { + const idleMs = Date.now() - session.lastActivityAt; + logTerminal('warn', 'Closing terminal session due to idle timeout.', { + userId: ws.userId, + idleMs, + idleTimeoutMs: IDLE_TIMEOUT_MS, + }); + try { + ws.send('idle-timeout'); + } catch (_) { + // ignore — close still attempted below + } + killPtyProcess(ws.userId); + setTimeout(() => { + try { + ws.close(1000, 'Idle timeout'); + } catch (_) { + // ignore — already closed + } + }, 100); + } }); }, HEARTBEAT_INTERVAL_MS); wss.on('close', () => clearInterval(heartbeat)); const messageHandlers = { - message: (session, data) => session.ptyProcess.write(data), + message: (session, data) => { + session.lastActivityAt = Date.now(); + session.ptyProcess.write(data); + }, resize: (session, { cols, rows }) => { + session.lastActivityAt = Date.now(); cols = cols > 0 ? cols : 80; rows = rows > 0 ? rows : 30; session.ptyProcess.resize(cols, rows) @@ -323,6 +375,7 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; + userSession.lastActivityAt = Date.now(); ws.send('pty-ready'); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 923d09d86..fe13c1b21 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -42,6 +42,10 @@ export function initializeTerminalComponent() { maxHeartbeatMisses: 3, // Command buffering for race condition prevention pendingCommand: null, + // Last successfully sent SSH command — replayed after a transient reconnect + // so the PTY respawns automatically. Cleared on intentional terminations + // (pty-exited, idle-timeout, unprocessable). + lastSentCommand: null, // Resize handling resizeObserver: null, resizeTimeout: null, @@ -162,9 +166,17 @@ export function initializeTerminalComponent() { resetTerminal() { if (this.term) { - this.$wire.dispatch('error', 'Terminal websocket connection lost.'); - this.term.reset(); - this.term.clear(); + this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...'); + // Preserve scrollback so the user keeps the context of their previous + // session. Print a visible marker so they know where the disconnect + // happened. Old PTY shell state cannot be restored — this is purely + // a visual carry-over. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`); + } catch (_) { + // ignore — terminal not ready to receive writes + } this.pendingWrites = 0; this.paused = false; this.commandBuffer = ''; @@ -277,10 +289,15 @@ export function initializeTerminalComponent() { this.connectionTimeoutId = null; } - // Flush any buffered command from before WebSocket was ready + // Flush any buffered command from before WebSocket was ready, otherwise + // replay the last command so a transient reconnect respawns the PTY + // automatically without requiring the user to click Connect again. if (this.pendingCommand) { this.sendMessage(this.pendingCommand); this.pendingCommand = null; + } else if (this.lastSentCommand) { + logTerminal('log', '[Terminal] Replaying last command after reconnect.'); + this.sendMessage(this.lastSentCommand); } // (Re)start application-level keepalive on every successful connect. @@ -362,6 +379,9 @@ export function initializeTerminalComponent() { sendMessage(message) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); + if (message && message.command) { + this.lastSentCommand = message; + } } else { logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message); } @@ -395,7 +415,15 @@ export function initializeTerminalComponent() { this.term.open(document.getElementById('terminal')); this.term._initialized = true; } else { - this.term.reset(); + // Already initialized — this is a reconnect or a follow-up command. + // Preserve scrollback so the user keeps context. Write a visible + // separator so the new shell prompt is easy to spot. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`); + } catch (_) { + // ignore — fall through; xterm will render the new prompt anyway + } } this.terminalActive = true; this.term.focus(); @@ -423,6 +451,7 @@ export function initializeTerminalComponent() { } else if (event.data === 'unprocessable') { if (this.term) this.term.reset(); this.terminalActive = false; + this.lastSentCommand = null; this.message = '(sorry, something went wrong, please try again)'; // Notify parent component that terminal connection failed @@ -431,9 +460,19 @@ export function initializeTerminalComponent() { this.terminalActive = false; this.term.reset(); this.commandBuffer = ''; + this.lastSentCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); + } else if (event.data === 'idle-timeout') { + this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.'); + this.terminalActive = false; + if (this.term) { + this.term.reset(); + } + this.commandBuffer = ''; + this.lastSentCommand = null; + this.$wire.dispatch('terminalDisconnected'); } else if ( typeof event.data === 'string' && (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index 8f4da3a62..ba01deca5 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -57,3 +57,50 @@ expect($terminalClient) ->toContain("'Visibility-resume timeout'"); }); + +it('closes idle terminal sessions after 30 minutes on the server', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000') + ->toContain('lastActivityAt') + ->toContain("ws.send('idle-timeout');") + ->toContain("ws.close(1000, 'Idle timeout');"); +}); + +it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain("event.data === 'idle-timeout'") + ->toContain('Terminal closed after 30 minutes of inactivity.'); +}); + +it('replays the last command on reconnect so the PTY respawns automatically', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('lastSentCommand') + ->toContain('Replaying last command after reconnect.') + ->toContain('this.lastSentCommand = null;'); +}); + +it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('authReady: false') + ->toContain('pendingMessages: []') + ->toContain('userSession.pendingMessages.push(message)') + ->toContain('userSession.authReady = true'); +}); + +it('preserves terminal scrollback across transient reconnects', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('── Connection lost at') + ->toContain('── Reconnected at') + // resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback. + ->not->toContain("this.term.reset();\n this.term.clear();"); +});