Repository navigation
Description
Describe the bug
A desktop workspace terminal connected to a remote host freezes after the laptop sleeps and wakes. No output, keystrokes do nothing, and the only way back is to kill the pane and open a new one.
The remote session itself is fine, and the PTY keeps running in pty-daemon on the host. The break is on the client side. After a suspend the terminal's WebSocket is half-open, meaning the TCP connection is dead but readyState stays OPEN and no close event fires. terminal-ws-transport.ts has no liveness or wake detection, so it never drops the dead socket or re-attaches.
Environment
- Superset desktop (Electron renderer), macOS client
- A workspace terminal on a remote host (the
/hosts/<routingKey>/terminal/<id>transport), not a local one - Trigger: close the lid for a minute or more, then reopen
Steps to reproduce
- Open a workspace terminal on a remote host and start something interactive (
claude, or justtop). - Confirm it's live.
- Close the laptop lid and wait a minute or more.
- Reopen.
- Observed: the pane is frozen. No output, keystrokes do nothing, and the transport still reports
connectionState === "open". Killing the pane and starting a new one is the only fix. - Expected: a few seconds after wake the transport notices the dead socket, reconnects, re-attaches to the still-running PTY, and the pane is interactive again.
Root cause
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts only ever reconnects in response to the socket's close event. scheduleReconnect() (L159) is called from two places: a WebSocket construction failure (L264) and the close handler (L372). There is no setInterval watchdog, no ping/pong timeout, and no visibilitychange, online, or focus listener in the file.
A socket that goes half-open during suspend never emits close, or emits it only minutes later once the OS TCP stack gives up. With no independent liveness check, the transport sits on the dead pipe reporting "open" and the pane stays frozen.
The web workspace terminal does not have this bug. apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/TerminalConnection.ts already reconnects on resume: it binds visibilitychange, resume, pageshow, and online (L63-68) and resets backoff in handleResume (L216). The desktop transport never got the equivalent. DOM resume events alone are not enough on desktop, since app-suspend does not reliably fire visibilitychange or focus when the window was focused both before and after sleep, so the dependable signal is a wall-clock gap.
Reproduction (failing test)
Confirmed with a self-contained bun test against the transport. It connects, attaches, simulates a half-open socket by never delivering a close, advances the system clock and timers by two minutes to model sleep and wake, then asserts the dead socket gets recycled. It fails on main because only one socket is ever created, and passes once a wall-clock-gap watchdog is added to connect() (verified locally with a ~15-line setInterval).
The core of the test, without the WebSocket-mock boilerplate:
connect(transport, createMockTerminal(), "ws://host/terminal/t1");
const socket = MockWebSocket.instances[0];
socket.open();
socket.message(JSON.stringify({ type: "attached", terminalId: "t1" }));
// Laptop sleeps: the socket dies but never observes it. readyState stays OPEN
// and no `close` is delivered. We never call socket.close(); that silent death
// is the bug. Two minutes pass, then the machine wakes.
setSystemTime(new Date("2026-01-01T00:02:00Z"));
jest.advanceTimersByTime(120_000);
// Recovery means dropping the dead socket and opening a fresh one.
expect(MockWebSocket.instances.length).toBe(2); // stays 1 on mainFailing output on main:
expect(received).toBe(expected)
Expected: 2
Received: 1
Happy to attach the full self-contained test file.
Proposed fix
Give the transport a liveness/wake path, set up in connect() and torn down in disconnect() and disposeTransport():
- A wall-clock-gap watchdog. A small
setIntervalcomparesDate.now()between ticks; a gap much larger than the interval means the process was suspended, so close the half-open socket and reconnect. This is what catches the dead-but-OPENcase. online,visibilitychange, andfocuslisteners that reset backoff and reconnect a known-dead socket right away, matchingTerminalConnection.ts.
Re-attach is cheap. The host keeps the PTY alive, so reconnecting just re-subscribes, and the protocol already supports sequence-numbered replay.
Related
- [minor bug] “ERR_NETWORK_CHANGED” toast appears after waking from sleep #771 and [bug] Login screen every time computer wakes from sleep #998 are also wake-from-sleep bugs, but on different surfaces (network-change toast, re-login), not the terminal transport.
Activity
yasyf commentedon Jun 5, 2026
Full self-contained failing test, as offered above. Drop it in at
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.sleep-repro.test.tsand run:It fails on
main(Expected: 2 / Received: 1) and passes once the transport gains any time-based liveness recovery (I verified with a ~15-linesetIntervalwatchdog inconnect()).fix: solve #5130 — recover remote terminal after sleep/wake half-open…
saddlepaddle commentedon Jun 5, 2026
Fix opened in #5135 — adds a wall-clock-gap watchdog plus
online/focus/visibilitychangerecovery toterminal-ws-transport.ts. Your repro test is folded intoterminal-ws-transport.test.ts(fails onmain, passes with the fix). Thanks for the thorough writeup and the self-contained failing test.saddlepaddle commentedon Jun 5, 2026
Thanks for reporting this!
fix(desktop): recover remote terminal after laptop sleep/wake (#5135)