Repository navigation
fix(desktop): recover remote terminal after laptop sleep/wake #5135
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<typeof setInterval> | 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); | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Add a guard before calling if (transport.connectionState === "connecting") return;Prompt for AI agents
Suggested change
|
||||||||
| } | ||||||||
|
|
||||||||
| 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; | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
handleResumedoesn't guard againstconnectionState === "connecting", so it callsreconnectNoweven while a pre-flight HTTP request is already in progress. For remote/hosts/URLs, the waketime sequence is: watchdog fires →reconnectNow→connect()starts asyncprimeRelayAffinity→ socket is stillnull, state is"connecting"→onlineevent fires →handleResumeseesnullsocket → skips the early-return → callsreconnectNowagain. Both pre-flights resolve and each callsopenSocket(), passing thecurrentUrl/connectionStateguards, creating two simultaneous WebSocket connections. The first socket is overwritten intransport.socketwithout being closed, leaving it orphaned on both the client and server.Prompt To Fix With AI