fix(terminal): add idle timeout, reconnect replay, and scrollback preservation

- Kill PTY and notify client after 30 min of inactivity (IDLE_TIMEOUT_MS)
- Buffer client messages during async auth/IP fetch to prevent race-condition
  message loss on fast reconnects
- Replay last sent command after transient reconnect so PTY respawns without
  user interaction
- Preserve scrollback on disconnect/reconnect; write visible timestamp markers
  instead of wiping term state
- Handle idle-timeout sentinel on client with user-facing error message
This commit is contained in:
Andras Bacsai 2026-04-28 12:26:31 +02:00
parent 9408620d5f
commit cabcd8f699
3 changed files with 158 additions and 19 deletions

View file

@ -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');

View file

@ -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:'))

View file

@ -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();");
});