Skip to content

[bug] Remote terminal freezes after laptop sleep/wake (half-open WebSocket is never recovered) #5130

Closed

Description

@yasyf

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

  1. Open a workspace terminal on a remote host and start something interactive (claude, or just top).
  2. Confirm it's live.
  3. Close the laptop lid and wait a minute or more.
  4. Reopen.
  5. 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.
  6. 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 main

Failing 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():

  1. A wall-clock-gap watchdog. A small setInterval compares Date.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-OPEN case.
  2. online, visibilitychange, and focus listeners that reset backoff and reconnect a known-dead socket right away, matching TerminalConnection.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

Activity

  1. yasyf commented on Jun 5, 2026

    @yasyf
    Author

    Full self-contained failing test, as offered above. Drop it in at apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.sleep-repro.test.ts and run:

    bun test apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.sleep-repro.test.ts
    

    It fails on main (Expected: 2 / Received: 1) and passes once the transport gains any time-based liveness recovery (I verified with a ~15-line setInterval watchdog in connect()).

    // Repro: a desktop workspace terminal freezes after laptop sleep/wake.
    //
    // On suspend the terminal WebSocket goes half-open: the connection is dead, but
    // readyState stays OPEN and no `close` event is ever delivered. The transport
    // only reconnects from its `close` handler, so nothing notices the dead pipe
    // and the pane stays frozen until it's killed and recreated.
    //
    // Fails on main (the dead socket is never replaced) and passes once the
    // transport gains any time-based liveness recovery.
    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";
    
    type Listener = (event: { data?: unknown; code?: number; reason?: string }) => void;
    
    class MockWebSocket {
    	static readonly CONNECTING = 0;
    	static readonly OPEN = 1;
    	static readonly CLOSING = 2;
    	static readonly CLOSED = 3;
    	static instances: MockWebSocket[] = [];
    
    	readyState = MockWebSocket.CONNECTING;
    	binaryType: BinaryType = "blob";
    	private readonly listeners = new Map<string, Set<Listener>>();
    
    	constructor(readonly url: string) {
    		MockWebSocket.instances.push(this);
    	}
    
    	addEventListener(type: string, cb: Listener) {
    		let set = this.listeners.get(type);
    		if (!set) this.listeners.set(type, (set = new Set()));
    		set.add(cb);
    	}
    	send() {}
    	open() {
    		this.readyState = MockWebSocket.OPEN;
    		this.emit("open", {});
    	}
    	close() {
    		this.readyState = MockWebSocket.CLOSED;
    		this.emit("close", { code: 1000 });
    	}
    	message(data: unknown) {
    		this.emit("message", { data });
    	}
    	private emit(type: string, event: Parameters<Listener>[0]) {
    		for (const cb of this.listeners.get(type) ?? []) cb(event);
    	}
    }
    
    function createMockTerminal(): XTerm {
    	return {
    		cols: 80,
    		rows: 24,
    		onData: () => ({ dispose() {} }),
    		write() {},
    		writeln() {},
    	} as unknown as XTerm;
    }
    
    const realWebSocket = globalThis.WebSocket;
    
    beforeEach(() => {
    	MockWebSocket.instances = [];
    	globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
    });
    
    afterEach(() => {
    	globalThis.WebSocket = realWebSocket;
    	setSystemTime();
    	jest.useRealTimers();
    });
    
    describe("terminal-ws-transport — laptop sleep/wake", () => {
    	test("recovers a half-open socket after the machine resumes", () => {
    		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 a websocket");
    		socket.open();
    		socket.message(JSON.stringify({ type: "attached", terminalId: "t1" }));
    		expect(transport.connectionState).toBe("open");
    
    		// Laptop sleeps. The connection dies, but the socket never observes it:
    		// readyState stays OPEN and no `close` event 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. On main
    		// there's no liveness check, so the transport stays wedged on the dead one.
    		expect(MockWebSocket.instances.length).toBe(2);
    	});
    });
  2. added a commit that references this issue on Jun 5, 2026
  3. saddlepaddle commented on Jun 5, 2026

    @saddlepaddle
    Collaborator

    Fix opened in #5135 — adds a wall-clock-gap watchdog plus online/focus/visibilitychange recovery to terminal-ws-transport.ts. Your repro test is folded into terminal-ws-transport.test.ts (fails on main, passes with the fix). Thanks for the thorough writeup and the self-contained failing test.

  4. saddlepaddle commented on Jun 5, 2026

    @saddlepaddle
    Collaborator

    Thanks for reporting this!

  5. added a commit that references this issue on Jun 5, 2026
    38e6a2c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions