fix(maestro): force-replace pane sockets on mobile resume

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 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-09-12 20:50:48 +01:00
co-authored by Claude Opus 4.6
parent 2512b8be42
commit 35a6ff9d0f
3 changed files with 25 additions and 11 deletions
+11 -3
View File
@@ -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);
};
@@ -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();
};
+10 -5
View File
@@ -303,17 +303,22 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
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();