Add Firefox WebSocket stall recovery watchdog

Address issue #3 by hardening the terminal WebSocket client against silent receive stalls seen in Firefox.

Changes:
- Added a client heartbeat watchdog that sends periodic ping messages.
- Detects stale inbound streams and forces reconnect when no inbound data/pong is seen within the timeout window.
- Added Blob WebSocket message handling for improved cross-browser receive compatibility.
- Ensures heartbeat timers are started/stopped with socket lifecycle and terminal disposal.

Validation:
- Rebuilt frontend bundle via make build-fast.
- Ran make check successfully.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-16 14:48:53 +00:00
parent 5e375edf01
commit c905a66d9b
2 changed files with 65 additions and 15 deletions
File diff suppressed because one or more lines are too long
+59 -9
View File
@@ -542,11 +542,17 @@ class WebTerminal {
private terminal: Terminal;
private fitAddon: FitAddon;
private socket: WebSocket | null = null;
private readonly textDecoder = new TextDecoder();
private element: HTMLElement;
private wsUrl: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
private heartbeatIntervalMs = 15000;
private stallTimeoutMs = 45000;
private heartbeatTimer: number | undefined;
private lastMessageAt = 0;
private lastPongAt = 0;
private messageQueue: [string, unknown][] = [];
private lastValidSize: { cols: number; rows: number } | null = null;
private mobileInput: HTMLTextAreaElement | null = null;
@@ -1428,6 +1434,7 @@ class WebTerminal {
this.socket.addEventListener("open", () => {
this.reconnectAttempts = 0;
this.startHeartbeatWatchdog();
this.element.classList.add("-connected");
this.element.classList.remove("-disconnected");
@@ -1447,6 +1454,7 @@ class WebTerminal {
});
this.socket.addEventListener("close", () => {
this.stopHeartbeatWatchdog();
this.element.classList.remove("-connected");
this.element.classList.add("-disconnected");
this.scheduleReconnect();
@@ -1462,14 +1470,7 @@ class WebTerminal {
}
/** Handle incoming WebSocket message */
private handleMessage(data: string | ArrayBuffer): void {
if (data instanceof ArrayBuffer) {
// Binary data - write directly to terminal
const text = new TextDecoder().decode(data);
this.terminal.write(text);
return;
}
private handleTextMessage(data: string): void {
// JSON message
try {
const envelope = JSON.parse(data) as [string, unknown];
@@ -1480,7 +1481,7 @@ class WebTerminal {
this.terminal.write(payload as string);
break;
case "pong":
// Keep-alive response - nothing to do
this.lastPongAt = Date.now();
break;
default:
console.debug("Unknown message type:", type);
@@ -1491,6 +1492,54 @@ class WebTerminal {
}
}
/** Handle incoming WebSocket message */
private handleMessage(data: string | ArrayBuffer | Blob): void {
this.lastMessageAt = Date.now();
if (data instanceof ArrayBuffer) {
// Binary data - write directly to terminal
const text = this.textDecoder.decode(data);
this.terminal.write(text);
return;
}
if (data instanceof Blob) {
void data.text().then((text) => {
this.lastMessageAt = Date.now();
this.handleTextMessage(text);
}).catch(() => {
// Ignore blob decode failures; reconnect watchdog will recover if needed.
});
return;
}
this.handleTextMessage(data);
}
private startHeartbeatWatchdog(): void {
this.stopHeartbeatWatchdog();
const now = Date.now();
this.lastMessageAt = now;
this.lastPongAt = now;
this.heartbeatTimer = window.setInterval(() => {
if (this.socket?.readyState !== WebSocket.OPEN) {
return;
}
const now = Date.now();
const lastInbound = Math.max(this.lastMessageAt, this.lastPongAt);
if (now - lastInbound > this.stallTimeoutMs) {
console.warn("WebSocket inbound stream stalled; reconnecting");
this.socket.close();
return;
}
this.send(["ping", String(now)]);
}, this.heartbeatIntervalMs);
}
private stopHeartbeatWatchdog(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
}
/** Send message to server with queueing support */
private send(message: [string, unknown]): void {
this.messageQueue.push(message);
@@ -1537,6 +1586,7 @@ class WebTerminal {
/** Clean up resources */
dispose(): void {
this.stopHeartbeatWatchdog();
this.socket?.close();
if (this.mobileInput) {
this.mobileInput.remove();