Reconnect terminals on visibility restore

When a tab is hidden, buffer inbound output and stop the heartbeat.
On visibility restore, drop the hidden buffer and reconnect to replay a clean
server state, while guarding against stale socket events.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-18 15:24:13 +00:00
parent 247c1a3340
commit 52c52f8435
2 changed files with 87 additions and 28 deletions
File diff suppressed because one or more lines are too long
+81 -22
View File
@@ -12,6 +12,8 @@ import { Terminal, FitAddon, Ghostty, type ITerminalOptions, type ITheme } from
const MAX_MESSAGE_QUEUE_SIZE = 1000; const MAX_MESSAGE_QUEUE_SIZE = 1000;
/** How often to run periodic resource cleanup (ms) */ /** How often to run periodic resource cleanup (ms) */
const RESOURCE_CLEANUP_INTERVAL_MS = 30_000; const RESOURCE_CLEANUP_INTERVAL_MS = 30_000;
/** Maximum bytes to buffer while the tab is hidden (256 KB) */
const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024;
/** Shared Ghostty WASM instance (loaded once, reused across all terminals) */ /** Shared Ghostty WASM instance (loaded once, reused across all terminals) */
let sharedGhostty: Ghostty | null = null; let sharedGhostty: Ghostty | null = null;
@@ -648,6 +650,7 @@ class WebTerminal {
private terminal: Terminal; private terminal: Terminal;
private fitAddon: FitAddon; private fitAddon: FitAddon;
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private socketGeneration = 0;
private element: HTMLElement; private element: HTMLElement;
private wsUrl: string; private wsUrl: string;
private reconnectAttempts = 0; private reconnectAttempts = 0;
@@ -676,6 +679,10 @@ class WebTerminal {
private resizeObserver: ResizeObserver | null = null; private resizeObserver: ResizeObserver | null = null;
private mobileKeybarStyle: HTMLStyleElement | null = null; private mobileKeybarStyle: HTMLStyleElement | null = null;
private boundHandlers: { target: EventTarget; type: string; handler: EventListener; options?: boolean | AddEventListenerOptions }[] = []; private boundHandlers: { target: EventTarget; type: string; handler: EventListener; options?: boolean | AddEventListenerOptions }[] = [];
private isTabHidden = false;
private hiddenBuffer: Uint8Array[] = [];
private hiddenBufferBytes = 0;
private static sharedTextEncoder = new TextEncoder();
private constructor( private constructor(
container: HTMLElement, container: HTMLElement,
@@ -793,6 +800,8 @@ class WebTerminal {
this.setupMobileKeybar(); this.setupMobileKeybar();
} }
this.isTabHidden = document.hidden;
// Start periodic resource cleanup // Start periodic resource cleanup
this.startResourceCleanup(); this.startResourceCleanup();
@@ -810,11 +819,11 @@ class WebTerminal {
// Focus terminal when returning to the tab // Focus terminal when returning to the tab
this.addTrackedListener(document, "visibilitychange", () => { this.addTrackedListener(document, "visibilitychange", () => {
if (document.hidden) { if (document.hidden) {
this.isTabHidden = true;
this.stopHeartbeatWatchdog(); this.stopHeartbeatWatchdog();
} else { } else {
if (this.socket?.readyState === WebSocket.OPEN) { this.isTabHidden = false;
this.startHeartbeatWatchdog(); this.refreshConnection();
}
restoreFocus(); restoreFocus();
} }
}); });
@@ -1095,34 +1104,37 @@ class WebTerminal {
canvas.dispatchEvent(event); canvas.dispatchEvent(event);
}; };
canvas.addEventListener( this.addTrackedListener(
canvas,
"touchstart", "touchstart",
(e) => { ((e: TouchEvent) => {
if (e.touches.length !== 1) return; if (e.touches.length !== 1) return;
dispatchMouse("mousedown", e.touches[0]); dispatchMouse("mousedown", e.touches[0]);
e.preventDefault(); e.preventDefault();
}, }) as EventListener,
{ passive: false } { passive: false }
); );
canvas.addEventListener( this.addTrackedListener(
canvas,
"touchmove", "touchmove",
(e) => { ((e: TouchEvent) => {
if (e.touches.length !== 1) return; if (e.touches.length !== 1) return;
dispatchMouse("mousemove", e.touches[0]); dispatchMouse("mousemove", e.touches[0]);
e.preventDefault(); e.preventDefault();
}, }) as EventListener,
{ passive: false } { passive: false }
); );
canvas.addEventListener( this.addTrackedListener(
canvas,
"touchend", "touchend",
(e) => { ((e: TouchEvent) => {
const touch = e.changedTouches[0]; const touch = e.changedTouches[0];
if (!touch) return; if (!touch) return;
dispatchMouse("mouseup", touch); dispatchMouse("mouseup", touch);
e.preventDefault(); e.preventDefault();
}, }) as EventListener,
{ passive: false } { passive: false }
); );
} }
@@ -1330,9 +1342,9 @@ class WebTerminal {
isDragging = false; isDragging = false;
}; };
dragHandle.addEventListener("touchstart", onTouchStart, { passive: false }); this.addTrackedListener(dragHandle, "touchstart", onTouchStart as EventListener, { passive: false });
document.addEventListener("touchmove", onTouchMove, { passive: false }); this.addTrackedListener(document, "touchmove", onTouchMove as EventListener, { passive: false });
document.addEventListener("touchend", onTouchEnd); this.addTrackedListener(document, "touchend", onTouchEnd as EventListener);
} }
/** Deactivate all modifiers */ /** Deactivate all modifiers */
@@ -1476,12 +1488,16 @@ class WebTerminal {
return; return;
} }
const gen = ++this.socketGeneration;
this.socket = new WebSocket(this.wsUrl); this.socket = new WebSocket(this.wsUrl);
this.socket.binaryType = "arraybuffer"; this.socket.binaryType = "arraybuffer";
this.socket.addEventListener("open", () => { this.socket.addEventListener("open", () => {
if (gen !== this.socketGeneration) return;
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.startHeartbeatWatchdog(); if (!this.isTabHidden) {
this.startHeartbeatWatchdog();
}
this.element.classList.add("-connected"); this.element.classList.add("-connected");
this.element.classList.remove("-disconnected"); this.element.classList.remove("-disconnected");
@@ -1501,6 +1517,7 @@ class WebTerminal {
}); });
this.socket.addEventListener("close", () => { this.socket.addEventListener("close", () => {
if (gen !== this.socketGeneration) return;
this.stopHeartbeatWatchdog(); this.stopHeartbeatWatchdog();
this.element.classList.remove("-connected"); this.element.classList.remove("-connected");
this.element.classList.add("-disconnected"); this.element.classList.add("-disconnected");
@@ -1512,20 +1529,24 @@ class WebTerminal {
}); });
this.socket.addEventListener("message", (event) => { this.socket.addEventListener("message", (event) => {
if (gen !== this.socketGeneration) return;
this.handleMessage(event.data); this.handleMessage(event.data);
}); });
} }
/** Handle incoming WebSocket message */ /** Handle incoming WebSocket message */
private handleTextMessage(data: string): void { private handleTextMessage(data: string): void {
// JSON message
try { try {
const envelope = JSON.parse(data) as [string, unknown]; const envelope = JSON.parse(data) as [string, unknown];
const [type, payload] = envelope; const [type, payload] = envelope;
switch (type) { switch (type) {
case "stdout": case "stdout":
this.terminal.write(payload as string); if (this.isTabHidden) {
this.bufferWhileHidden(payload as string);
} else {
this.terminal.write(payload as string);
}
break; break;
case "pong": case "pong":
this.lastPongAt = Date.now(); this.lastPongAt = Date.now();
@@ -1534,8 +1555,11 @@ class WebTerminal {
console.debug("Unknown message type:", type); console.debug("Unknown message type:", type);
} }
} catch { } catch {
// Not JSON - treat as raw text if (this.isTabHidden) {
this.terminal.write(data); this.bufferWhileHidden(data);
} else {
this.terminal.write(data);
}
} }
} }
@@ -1543,8 +1567,12 @@ class WebTerminal {
private handleMessage(data: string | ArrayBuffer | Blob): void { private handleMessage(data: string | ArrayBuffer | Blob): void {
this.lastMessageAt = Date.now(); this.lastMessageAt = Date.now();
if (data instanceof ArrayBuffer) { if (data instanceof ArrayBuffer) {
// Binary data - write directly to terminal as Uint8Array (avoids string allocation) const bytes = new Uint8Array(data);
this.terminal.write(new Uint8Array(data)); if (this.isTabHidden) {
this.bufferWhileHidden(bytes);
} else {
this.terminal.write(bytes);
}
return; return;
} }
if (data instanceof Blob) { if (data instanceof Blob) {
@@ -1610,6 +1638,35 @@ class WebTerminal {
} }
} }
/** Buffer terminal data while the tab is hidden instead of writing to WASM */
private bufferWhileHidden(data: string | Uint8Array): void {
const chunk = typeof data === "string"
? WebTerminal.sharedTextEncoder.encode(data)
: data;
while (
this.hiddenBufferBytes + chunk.byteLength > MAX_HIDDEN_BUFFER_BYTES &&
this.hiddenBuffer.length > 0
) {
const evicted = this.hiddenBuffer.shift()!;
this.hiddenBufferBytes -= evicted.byteLength;
}
this.hiddenBuffer.push(chunk);
this.hiddenBufferBytes += chunk.byteLength;
}
/** Discard hidden buffer and reconnect to get clean state from server replay */
private refreshConnection(): void {
this.hiddenBuffer.length = 0;
this.hiddenBufferBytes = 0;
this.reconnectAttempts = 0;
this.stopHeartbeatWatchdog();
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.connect();
}
/** Send message to server with queueing support */ /** Send message to server with queueing support */
private send(message: [string, unknown]): void { private send(message: [string, unknown]): void {
if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) { if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) {
@@ -1669,6 +1726,8 @@ class WebTerminal {
this.socket?.close(); this.socket?.close();
this.socket = null; this.socket = null;
this.messageQueue.length = 0; this.messageQueue.length = 0;
this.hiddenBuffer.length = 0;
this.hiddenBufferBytes = 0;
// Remove all tracked event listeners // Remove all tracked event listeners
for (const { target, type, handler, options } of this.boundHandlers) { for (const { target, type, handler, options } of this.boundHandlers) {
target.removeEventListener(type, handler, options); target.removeEventListener(type, handler, options);