fix(FN-958): fix terminal idle disconnect with heartbeat tolerance

- Add heartbeat tolerance window to prevent false idle disconnects on terminal connections
- Increase client heartbeat interval from 30s to 45s for better idle tolerance
- Add session staleness detection on reconnect with idle state logging
- Add tests for heartbeat tolerance and client interval configuration
- Add changeset for patch release
This commit is contained in:
gsxdsm
2026-04-04 23:43:20 -07:00
parent 2b857cd245
commit bf1cb5df5c
5 changed files with 292 additions and 5 deletions

View File

@@ -9,7 +9,7 @@ import { createApiRoutes } from "./routes.js";
import { createSSE } from "./sse.js";
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
import { getOrCreateProjectStore, evictAllProjectStores } from "./project-store-resolver.js";
import { getTerminalService, type TerminalSession } from "./terminal-service.js";
import { getTerminalService, type TerminalSession, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
import { WebSocketServer, type WebSocket } from "ws";
import { terminalSessionManager } from "./terminal.js";
import { getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
@@ -379,11 +379,22 @@ export function setupTerminalWebSocket(
return;
}
const MAX_MISSED_PONGS = 2; // Allow 2 missed pongs (~90s) before terminating
// Track if connection is alive
let isAlive = true;
let missedPongs = 0; // Track consecutive missed pongs
let dataUnsub: (() => void) | null = null;
let exitUnsub: (() => void) | null = null;
// Detect potentially stale sessions on reconnect
const idleMs = Date.now() - session.lastActivityAt.getTime();
if (idleMs > STALE_SESSION_THRESHOLD_MS) {
console.warn(
`[terminal] Session ${sessionId} reconnect after ${Math.round(idleMs / 1000)}s idle — PTY may be stale`
);
}
// Send scrollback buffer first
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
if (scrollback) {
@@ -413,6 +424,8 @@ export function setupTerminalWebSocket(
if (id === sessionId && isAlive) {
try {
ws.send(JSON.stringify({ type: "exit", exitCode }));
const idleSec = id ? Math.round((Date.now() - (terminalService.getSession(id)?.lastActivityAt?.getTime() ?? Date.now())) / 1000) : 0;
console.info(`[terminal] Session ${id} exited with code ${exitCode} (was ${idleSec}s idle)`);
} catch {
// WebSocket might be closing
}
@@ -422,7 +435,13 @@ export function setupTerminalWebSocket(
// Heartbeat ping/pong
const pingInterval = setInterval(() => {
if (!isAlive) {
ws.terminate();
missedPongs++;
if (missedPongs >= MAX_MISSED_PONGS) {
console.warn(`[terminal] Connection dead after ${missedPongs} missed pongs, terminating`);
ws.terminate();
return;
}
console.info(`[terminal] Missed pong #${missedPongs}, waiting for response...`);
return;
}
isAlive = false;
@@ -435,6 +454,7 @@ export function setupTerminalWebSocket(
ws.on("pong", () => {
isAlive = true;
missedPongs = 0; // Reset on successful pong
});
ws.on("message", (message: Buffer) => {
@@ -457,6 +477,7 @@ export function setupTerminalWebSocket(
break;
case "pong":
isAlive = true;
missedPongs = 0; // Reset on successful pong
break;
}
} catch {