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:
File diff suppressed because one or more lines are too long
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user