From e352f3d124cd01c0d24c9a1b754390d673e48d53 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 4 Jun 2026 20:05:13 -0700 Subject: [PATCH] fix(desktop): recover remote terminal after laptop sleep/wake The terminal WebSocket goes half-open on suspend: the TCP connection is dead but readyState stays OPEN and no close event fires. The transport only reconnects from its close handler, so it sat wedged on the dead pipe reporting connectionState "open" and the pane stayed frozen until killed. Add a liveness/wake path to the transport, set up in connect() and torn down in disconnect()/disposeTransport(): - A wall-clock-gap watchdog (setInterval) that recycles a presumed half-open socket when a tick gap shows the process was suspended. This is the dependable desktop signal; app-suspend doesn't reliably fire focus/visibility. - online/focus/visibilitychange listeners that reset backoff and reconnect a dead socket immediately, mirroring the web TerminalConnection. Re-attach is cheap: the host keeps the PTY alive, so reconnecting just re-subscribes and replays. Closes #5130 --- .../terminal/terminal-ws-transport.test.ts | 36 +++++- .../lib/terminal/terminal-ws-transport.ts | 104 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts index a133122d71b..470805522b8 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + jest, + setSystemTime, + test, +} from "bun:test"; import type { Terminal as XTerm } from "@xterm/xterm"; import { connect, createTransport } from "./terminal-ws-transport"; @@ -90,6 +98,8 @@ beforeEach(() => { afterEach(() => { globalThis.WebSocket = originalWebSocket; + setSystemTime(); + jest.useRealTimers(); }); describe("terminal-ws-transport", () => { @@ -156,4 +166,28 @@ describe("terminal-ws-transport", () => { { type: "input", data: "b" }, ]); }); + + test("recovers a half-open socket after the machine resumes from sleep", () => { + jest.useFakeTimers(); + setSystemTime(new Date("2026-01-01T00:00:00Z")); + + const transport = createTransport(); + connect(transport, createMockTerminal(), "ws://host/terminal/t1"); + + const socket = MockWebSocket.instances[0]; + if (!socket) throw new Error("expected websocket instance"); + socket.open(); + socket.message(JSON.stringify({ type: "attached", terminalId: "t1" })); + expect(transport.connectionState).toBe("open"); + + // Laptop sleeps: the socket dies but never observes it. readyState stays + // OPEN and no `close` is delivered — that silent death is the bug. Two + // minutes pass (clock jumps), then the watchdog tick runs on wake. + setSystemTime(new Date("2026-01-01T00:02:00Z")); + jest.advanceTimersByTime(120_000); + + // Recovery: the wall-clock-gap watchdog drops the wedged socket and dials + // a fresh one. Without it, only the original socket would ever exist. + expect(MockWebSocket.instances.length).toBe(2); + }); }); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index eee75f37edc..690ed58cf92 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -57,6 +57,13 @@ export interface TerminalTransport { * with no output still gets replay on the next connect. */ _hasReceivedBytes: boolean; + /** Internal: wall-clock-gap watchdog for laptop sleep/wake detection. */ + _livenessTimer: ReturnType | null; + /** Internal: Date.now() at the last watchdog tick. */ + _lastLivenessTick: number; + /** Internal: bound resume handler shared by the online/focus/visibility + * listeners, so they can be removed on teardown. */ + _resumeListener: (() => void) | null; } const MAX_LOG_ENTRIES = 200; @@ -153,9 +160,103 @@ export function createTransport(): TerminalTransport { _terminal: null, _hasReceivedBytes: false, _terminated: false, + _livenessTimer: null, + _lastLivenessTick: 0, + _resumeListener: null, }; } +// Wall-clock watchdog cadence and the gap that counts as a suspend. A tick gap +// far larger than the interval means the process was paused (laptop sleep), so +// any socket still reporting OPEN is almost certainly half-open — dead, but +// without a `close` event ever firing. This is the dependable desktop signal: +// app-suspend doesn't reliably fire focus/visibility when the window was +// focused both before and after sleep. +const LIVENESS_CHECK_INTERVAL_MS = 5_000; +const LIVENESS_SUSPEND_GAP_MS = 20_000; + +// Drop the current socket and immediately reconnect, without waiting for a +// `close` event that a half-open socket will never deliver. The host keeps the +// PTY alive, so this just re-attaches (and replays anything missed). +function reconnectNow(transport: TerminalTransport) { + if (transport._terminated) return; + if (!transport.currentUrl || !transport._terminal) return; + cancelReconnect(transport); + if (transport.socket) { + const dead = transport.socket; + transport.socket = null; + try { + dead.close(); + } catch { + // best-effort; the close handler is a no-op once socket is detached + } + } + transport._reconnectAttempt = 0; + // connect() is idempotent while "open"/"connecting"; force "closed" so it + // actually re-dials the now-detached socket. + setConnectionState(transport, "closed"); + connect(transport, transport._terminal, transport.currentUrl); +} + +// DOM resume signal (online/focus/visibilitychange). Reset backoff and +// reconnect only if the socket is actually dead — a healthy or still-connecting +// socket is left alone. Mirrors TerminalConnection.handleResume on web. +function handleResume(transport: TerminalTransport) { + if (transport._terminated) return; + if (!transport.currentUrl || !transport._terminal) return; + transport._reconnectAttempt = 0; + const socket = transport.socket; + if ( + socket && + (socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING) + ) { + return; + } + reconnectNow(transport); +} + +function setupLiveness(transport: TerminalTransport) { + if (transport._livenessTimer === null) { + transport._lastLivenessTick = Date.now(); + transport._livenessTimer = setInterval(() => { + const now = Date.now(); + const gap = now - transport._lastLivenessTick; + transport._lastLivenessTick = now; + if (gap > LIVENESS_SUSPEND_GAP_MS) reconnectNow(transport); + }, LIVENESS_CHECK_INTERVAL_MS); + } + if (!transport._resumeListener) { + const listener = () => handleResume(transport); + transport._resumeListener = listener; + if (typeof window !== "undefined") { + window.addEventListener("online", listener); + window.addEventListener("focus", listener); + } + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", listener); + } + } +} + +function teardownLiveness(transport: TerminalTransport) { + if (transport._livenessTimer !== null) { + clearInterval(transport._livenessTimer); + transport._livenessTimer = null; + } + const listener = transport._resumeListener; + if (listener) { + if (typeof window !== "undefined") { + window.removeEventListener("online", listener); + window.removeEventListener("focus", listener); + } + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", listener); + } + transport._resumeListener = null; + } +} + function scheduleReconnect(transport: TerminalTransport) { if (transport._reconnectTimer) return; if (transport._terminated) return; @@ -235,6 +336,7 @@ export function connect( transport.currentUrl = wsUrl; transport._terminal = terminal; transport._terminated = false; + setupLiveness(transport); setConnectionState(transport, "connecting"); const actualUrl = transport._hasReceivedBytes ? appendQueryParam(wsUrl, "replay", "0") @@ -391,6 +493,7 @@ function attachSocketListeners( export function disconnect(transport: TerminalTransport) { cancelReconnect(transport); + teardownLiveness(transport); if (transport.socket) { transport.socket.close(); transport.socket = null; @@ -430,6 +533,7 @@ export function sendDispose(transport: TerminalTransport) { export function disposeTransport(transport: TerminalTransport) { cancelReconnect(transport); + teardownLiveness(transport); if (transport.socket) { transport.socket.close(); transport.socket = null;