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:
co-authored by
Claude Opus 4.6
parent
2512b8be42
commit
35a6ff9d0f
+11
-3
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user