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
+67 -14
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');