feat: add heartbeat/ping to terminal WebSocket

- Backend: Send ping every 30s from WebSocket endpoint
- Frontend: Respond to pings with pongs, detect missed pings (60s timeout)
- Update type definitions to include 'resetting' status

Refs: persistent-terminal-sessions task 6.4
This commit is contained in:
2026-05-24 12:45:50 +00:00
parent d117047711
commit 073013bc61
13 changed files with 468 additions and 24 deletions
@@ -8,7 +8,7 @@ interface MobileTerminalHeaderProps {
onClose?: () => void;
onFontSizeChange?: (delta: number) => void;
isVisible: boolean;
connectionStatus?: "connecting" | "connected" | "disconnected" | "error";
connectionStatus?: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
@@ -30,7 +30,7 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected" | "error";
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
focusInput: () => void;
changeFontSize: (delta: number) => void;
} | null>(null);
@@ -42,7 +42,7 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
}, [headerAutoHide]);
const handleTerminalReady = useCallback(
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error", focusInput: () => void, changeFontSize: (delta: number) => void) => {
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
},
[]
+30 -1
View File
@@ -14,7 +14,7 @@ interface TerminalProps {
onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: (
sendData: (data: string) => void,
connectionStatus: "connecting" | "connected" | "disconnected" | "error",
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void
) => void;
@@ -54,6 +54,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
const stored = localStorage.getItem(FONT_SIZE_KEY);
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
});
const lastPingRef = useRef<number>(0);
const heartbeatCheckRef = useRef<number | null>(null);
const calculateFontSize = useCallback(() => {
if (!isMobile) return fontSize;
@@ -75,6 +77,20 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
lastPingRef.current = Date.now();
// Start heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
}
heartbeatCheckRef.current = window.setInterval(() => {
const elapsed = Date.now() - lastPingRef.current;
if (elapsed > 60000) {
// No ping for 60 seconds, connection may be dead
console.warn("Terminal heartbeat timeout, reconnecting...");
ws.close(4000, "Heartbeat timeout");
}
}, 30000);
};
ws.onmessage = (event) => {
@@ -95,6 +111,12 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
} else if (msg.status === "resetting") {
setStatus("resetting");
}
} else if (msg.type === "ping") {
// Respond with pong and update last ping time
lastPingRef.current = Date.now();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
}
} catch {
termRef.current?.write(event.data);
@@ -104,6 +126,13 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
ws.onclose = (event) => {
setStatus("disconnected");
// Clean up heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
if (event.code !== 1000) {
setError(`Connection closed (code: ${event.code})`);