From 35a6ff9d0f608ebdb749210f2aac3c6f4ff602c2 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 12 Sep 2026 20:50:48 +0100 Subject: [PATCH] fix(maestro): force-replace pane sockets on mobile resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile browsers report readyState === OPEN after background suspension even though the OS killed the TCP connection. The per-pane visibility handlers skipped reconnection in that case, leaving the pane frozen until TCP keepalive fired (30–120 s on cellular). Remove the readyState guard and unconditionally replace the socket on visibility return — same pattern the events socket already uses. Also let pageshow/online fire recovery without the visibilityState gate, since some mobile browsers fire pageshow before updating that property. Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 14 +++++++++++--- .../components/structured/useStructuredSession.ts | 7 ++++--- web/src/components/terminal/TerminalPane.tsx | 15 ++++++++++----- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index d880949..8f732e2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -113,15 +113,23 @@ function Shell() { // a transient lost connection has already recovered. useEffect(() => { const recover = () => { - if (document.visibilityState !== "visible") return; void linkStore.getState().probe(); catalogStore.getState().reconnect(); }; - document.addEventListener("visibilitychange", recover); + // visibilitychange fires reliably on tab/app switch; guard it so we don't + // probe while the page is being hidden. + const onVisibility = () => { + if (document.visibilityState !== "visible") return; + recover(); + }; + // pageshow (bfcache restore) and online (network back) indicate the page + // is active — fire unconditionally since visibilityState may lag behind on + // some mobile browsers. + document.addEventListener("visibilitychange", onVisibility); window.addEventListener("pageshow", recover); window.addEventListener("online", recover); return () => { - document.removeEventListener("visibilitychange", recover); + document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("pageshow", recover); window.removeEventListener("online", recover); }; diff --git a/web/src/components/structured/useStructuredSession.ts b/web/src/components/structured/useStructuredSession.ts index bba2ced..91bbfc2 100644 --- a/web/src/components/structured/useStructuredSession.ts +++ b/web/src/components/structured/useStructuredSession.ts @@ -197,11 +197,12 @@ export function useStructuredSession( }; // Mobile browsers suspend timers and silently kill WebSockets in the - // background. Kick a fresh connect when the page becomes visible again. + // background. The browser often still reports readyState === OPEN because + // TCP keepalive hasn't noticed the dead connection yet. Force-replace + // unconditionally — connect() closes the old socket first, and the server + // replays the full transcript on every reconnect so nothing is lost. const onVisibility = () => { if (document.visibilityState !== "visible" || disposed) return; - if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return; - cancelTimer(); setStatus("reconnecting"); connect(); }; diff --git a/web/src/components/terminal/TerminalPane.tsx b/web/src/components/terminal/TerminalPane.tsx index da03014..636ea5c 100644 --- a/web/src/components/terminal/TerminalPane.tsx +++ b/web/src/components/terminal/TerminalPane.tsx @@ -303,17 +303,22 @@ export const TerminalPane = forwardRef( ta?.addEventListener("blur", onTaBlur); // Mobile browsers (Android especially) suspend timers and silently kill - // WebSockets when the app goes to background. The backoff timer set by - // onclose is frozen too, so recovery stalls until visibility returns. Kick - // a fresh connect immediately when the page becomes visible again. + // WebSockets when the app goes to background. The browser often still + // reports readyState === OPEN because TCP keepalive hasn't noticed the + // dead connection yet. Force-replace the socket unconditionally — + // a genuinely alive server socket gets a clean close, and the reconnect + // replays the scrollback so nothing is lost. const onVisibility = () => { if (document.visibilityState !== "visible" || disposed) return; - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) return; - // Cancel any pending backoff — we're reconnecting right now. if (reconnectTimer !== null) { window.clearTimeout(reconnectTimer); reconnectTimer = null; } + // Detach the old socket's handlers so its close event doesn't race + // with the fresh connection (same pattern as catalogStore.reconnect). + ws.onclose = null; + ws.onerror = null; + ws.close(); reconnectMs = INITIAL_RECONNECT_MS; onStatusChange?.("reconnecting"); connect();