Skip to content

feat: mobile lifecycle handling for robust session persistence#724

Open
leighstillard wants to merge 3 commits intositeboon:mainfrom
leighstillard:feat/mobile-lifecycle-reconnect
Open

feat: mobile lifecycle handling for robust session persistence#724
leighstillard wants to merge 3 commits intositeboon:mainfrom
leighstillard:feat/mobile-lifecycle-reconnect

Conversation

@leighstillard
Copy link
Copy Markdown

@leighstillard leighstillard commented Apr 29, 2026

Summary

  • Adds Page Visibility API detection so WebSocket connections immediately reconnect when the app returns to foreground after being backgrounded on mobile (Android/iOS)
  • Adds server-side WebSocket heartbeat (ping every 30s) to detect dead connections promptly instead of waiting for TCP timeout
  • Adds client-side application-level ping/pong with stale connection probing after background periods >5s
  • Shell WebSocket now auto-reconnects on disconnect and on foreground resume, with server-side output buffer replay (terminal no longer clears on disconnect)
  • Adds Wake Lock API integration to keep connections alive during brief app switches when an agent session is actively processing on mobile
  • Adds reconnect jitter to prevent thundering herd on mass reconnection

Problem

On mobile browsers, backgrounding the app kills WebSocket connections silently. The browser freezes JS execution, so onclose timers don't fire until the user returns. This left sessions appearing frozen with no data flowing, requiring a manual page refresh.

Solution

New files

  • src/hooks/useAppLifecycle.ts — Reusable hook wrapping the Page Visibility API and pageshow event (for Safari bfcache). Provides onForeground(callback) and onBackground(callback) with background duration tracking.
  • src/hooks/useWakeLock.ts — Screen Wake Lock API hook that prevents the device from sleeping while an agent session is actively processing. Only activates on mobile. Auto-reacquires on foreground resume.

Modified files

  • server/index.js — WebSocket-level heartbeat (ping/pong every 30s with isAlive tracking) on both chat and shell connections. Application-level ping/pong message handler for client-initiated health checks.
  • src/contexts/WebSocketContext.tsx — Foreground-triggered reconnect (immediate if socket is dead, ping-probe if backgrounded >5s). Client heartbeat every 25s, paused during background. Reconnect delay includes random jitter.
  • src/components/shell/hooks/useShellConnection.ts — Auto-reconnect on close (5s + jitter) and on foreground resume. No longer clears terminal on disconnect so server can replay buffered output.
  • src/components/app/AppContent.tsx — Wires useWakeLock to activate when mobile + sessions are processing.

Test plan

  • Tested on Android (Chrome) — backgrounding and returning reconnects within ~1s
  • Verified shell sessions reconnect and replay buffered output
  • Verified agent continues processing server-side during background
  • Verified desktop behavior is unchanged (heartbeat runs, no visible difference)
  • TypeScript compiles clean, lint passes
  • Running in production for 24 hours with no issues

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Automatic WebSocket reconnection with heartbeat pings/pongs to detect and recover stale connections
    • Device wake-lock to keep the screen awake during active processing on mobile
    • Improved foreground/background handling to probe and restore connections immediately when the app resumes
    • Terminal reconnect behavior preserves state and attempts delayed reconnects when appropriate
  • Bug Fixes

    • More reliable reconnection cleanup to avoid orphaned timers or stale sockets

On mobile browsers (Android/iOS), backgrounding the app kills WebSocket
connections silently. Previously, sessions would appear frozen when
returning to the app, requiring a manual refresh.

This adds:
- Page Visibility API detection (useAppLifecycle hook) for immediate
  reconnect when the app returns to foreground
- Server-side WebSocket heartbeat (ping every 30s) to detect dead
  connections promptly instead of waiting for TCP timeout
- Client-side application-level ping/pong with stale connection probing
  after background periods >5s
- Shell WebSocket auto-reconnect with server-side output buffer replay
  (terminal no longer clears on disconnect)
- Wake Lock API integration to keep connections alive during brief app
  switches on mobile when an agent session is actively processing
- Reconnect jitter to prevent thundering herd on mass reconnection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 66d71465-6857-4289-8311-2a83d9dd22ff

📥 Commits

Reviewing files that changed from the base of the PR and between 439a9f7 and 652c103.

📒 Files selected for processing (1)
  • src/contexts/WebSocketContext.tsx
✅ Files skipped from review due to trivial changes (1)
  • src/contexts/WebSocketContext.tsx

📝 Walkthrough

Walkthrough

Adds heartbeat-based WebSocket liveness (app-level ping/pong), background-aware probes and reconnection logic, automatic shell WebSocket reconnection with jitter and foreground fast-path, a page-visibility lifecycle hook, and a wake-lock hook used while processing sessions are active.

Changes

Cohort / File(s) Summary
Server WebSocket liveness
server/index.js
Adds application-level {type:'ping'}/{type:'pong'} handling and server-side ping/pong-based liveness with force-termination on missed pongs; clears heartbeat on close.
WebSocket Context & heartbeat
src/contexts/WebSocketContext.tsx
Adds periodic ping sender, tracks last-pong timestamp, closes stale sockets, pauses heartbeat when backgrounded, foreground probe with timeout, and jittered reconnect scheduling with proper teardown.
Shell connection reconnection
src/components/shell/hooks/useShellConnection.ts
Implements automatic reconnect with jitter on close, avoids clearing terminal for transient closes, tracks prior connection state, cancels pending reconnects on explicit disconnect/unmount, and triggers immediate reconnect on foreground.
App lifecycle hook
src/hooks/useAppLifecycle.ts
New hook exposing isVisible, onForeground, and onBackground; tracks background duration, handles pageshow/bfcache restores, runs callbacks with error handling and cleanup.
Wake lock hook
src/hooks/useWakeLock.ts
New useWakeLock(shouldLock) that requests/re-acquires screen wake lock when visible, handles sentinel.release and visibility changes, and cleans up safely.
App integration
src/components/app/AppContent.tsx
Calls useWakeLock when isMobile and there is at least one processingSessions entry to hold the screen awake during processing.

Sequence Diagrams

sequenceDiagram
    participant Client as Client<br/>(WebSocketContext)
    participant Timer as Heartbeat<br/>Timer
    participant Server as Server<br/>(WS)
    participant App as App<br/>(Visibility)

    rect rgba(100, 150, 200, 0.5)
    Note over Client,Server: Normal Heartbeat Loop
    Timer->>Client: heartbeat interval
    Client->>Server: {type:'ping'}
    Server->>Client: {type:'pong'}
    Client->>Client: update lastPong timestamp
    end

    rect rgba(200, 100, 100, 0.5)
    Note over Client,Server: Stale Connection Detection
    Timer->>Client: heartbeat interval
    Client->>Client: check lastPong age
    alt lastPong too old
        Client->>Client: force close & schedule reconnect
    end
    end

    rect rgba(150, 200, 100, 0.5)
    Note over App,Client: Foreground Resume Probe
    App->>Client: onForeground(backgroundDuration)
    Client->>Server: probe {type:'ping'}
    Timer->>Client: wait PING_PROBE_TIMEOUT
    alt no pong
        Client->>Client: trigger reconnect
    end
    end
Loading
sequenceDiagram
    participant Shell as Shell Hook<br/>(useShellConnection)
    participant WS as WebSocket
    participant App as App Lifecycle
    participant Timer as Reconnect<br/>Timer

    rect rgba(100, 150, 200, 0.5)
    Note over Shell,WS: Connection Lost
    WS->>Shell: onclose
    Shell->>Shell: mark wasConnectedRef=true
    Shell->>Timer: schedule reconnect with jitter
    end

    rect rgba(150, 100, 200, 0.5)
    Note over App,Shell: Foreground Fast-Path
    App->>Shell: onForeground
    Shell->>Timer: cancel pending reconnect
    Shell->>WS: immediate reconnect attempt
    end

    rect rgba(200, 100, 100, 0.5)
    Note over Shell,WS: Explicit Disconnect
    Shell->>Shell: disconnectFromShell()
    Shell->>Timer: cancel pending reconnect
    Shell->>Shell: reset wasConnectedRef=false
    Shell->>WS: close socket
    end
Loading

Possibly Related PRs

Suggested Reviewers

  • blackmammoth
  • viper151

Poem

🐰 A rabbit’s nibble on the new link:
I ping the sky to keep things true,
I pong right back — the wires renew.
When apps wake from a gentle nap,
We hop, reconnect, and bridge the gap. 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: mobile lifecycle handling for robust session persistence' directly aligns with the main objective of adding mobile lifecycle detection and connection robustness features to prevent session loss on backgrounded mobile browsers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@leighstillard leighstillard force-pushed the feat/mobile-lifecycle-reconnect branch from c8d8925 to 1fcb9a2 Compare April 29, 2026 00:07
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/shell/hooks/useShellConnection.ts (1)

169-182: ⚠️ Potential issue | 🟠 Major

Don't auto-reconnect intentional closes.

This onclose path always flips wasConnectedRef back to true and arms a retry, so disconnectFromShell() can still reconnect a few seconds later. It also leaves the existing auto-connect effect at Lines 236-242 free to reconnect immediately, which bypasses the new backoff. This needs an explicit shouldReconnect / reconnectScheduled guard.

♻️ One way to gate reconnects
+  const shouldReconnectRef = useRef(true);
+  const reconnectScheduledRef = useRef(false);
+
         socket.onclose = () => {
           setIsConnected(false);
           setIsConnecting(false);
           connectingRef.current = false;
-          // Don't clear terminal — server will replay buffered output on reconnect.
-          // Track that we were connected so foreground resume can auto-reconnect.
-          wasConnectedRef.current = true;
+          if (!shouldReconnectRef.current) return;
+          // Don't clear terminal — server will replay buffered output on reconnect.
+          // Track that we were connected so foreground resume can auto-reconnect.
+          wasConnectedRef.current = true;
+          reconnectScheduledRef.current = true;
 
           // Auto-reconnect after delay (with jitter)
           const jitter = Math.random() * 1000;
-          reconnectTimeoutRef.current = setTimeout(() => {
+          reconnectTimeoutRef.current = window.setTimeout(() => {
+            reconnectScheduledRef.current = false;
             if (wsRef.current?.readyState === WebSocket.OPEN) return;
             connectWebSocket();
           }, SHELL_RECONNECT_DELAY_MS + jitter);
         };
// Also set `shouldReconnectRef.current = false` before `closeSocket()` in
// `disconnectFromShell()`, and have the auto-connect effect return early while
// `reconnectScheduledRef.current` is true.

Also applies to: 223-227

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shell/hooks/useShellConnection.ts` around lines 169 - 182, The
onclose handler currently always sets wasConnectedRef.current = true and
schedules a reconnect; add a guard using a new shouldReconnectRef and
reconnectScheduledRef so intentional closes don't auto-reconnect: in the
socket.onclose callback (the block that calls
setIsConnected/setIsConnecting/wasConnectedRef and sets reconnectTimeoutRef)
only set wasConnectedRef.current = true and schedule reconnect if
shouldReconnectRef.current is true, and set reconnectScheduledRef.current = true
when you arm reconnectTimeoutRef and clear it when the timeout runs or
connection succeeds; in disconnectFromShell(), set shouldReconnectRef.current =
false (and clear any reconnectScheduledRef/current timeout) before calling
closeSocket(); and update the auto-connect effect to return early while
reconnectScheduledRef.current is true so the existing effect does not bypass
backoff.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/contexts/WebSocketContext.tsx`:
- Around line 139-153: The cleanup in the token-dependent useEffect incorrectly
sets unmountedRef.current = true on every token change causing connect() to bail
forever; remove setting unmountedRef.current from that effect and instead set it
only in a separate effect that runs on real unmount (e.g., useEffect(() => () =>
{ unmountedRef.current = true }, [])). Keep the existing clearing logic
(clearHeartbeat, clearTimeout, wsRef.current.close) in the token effect cleanup
so reconnection on token refresh still happens, and ensure connect() continues
to check unmountedRef as before.

In `@src/hooks/useWakeLock.ts`:
- Around line 19-25: The requestLock async function can race with cleanup and
other requests: change requestLock so it stores the result of
navigator.wakeLock.request('screen') in a local sentinel variable, then re-check
the released flag after the await and if released immediately call
sentinel.release() and return; assign wakeLockRef.current = sentinel only if
still appropriate; when adding the 'release' listener, have it clear
wakeLockRef.current only if wakeLockRef.current === sentinel to avoid clearing a
newer sentinel; ensure cleanup logic releases the current sentinel
(wakeLockRef.current) if present.

---

Outside diff comments:
In `@src/components/shell/hooks/useShellConnection.ts`:
- Around line 169-182: The onclose handler currently always sets
wasConnectedRef.current = true and schedules a reconnect; add a guard using a
new shouldReconnectRef and reconnectScheduledRef so intentional closes don't
auto-reconnect: in the socket.onclose callback (the block that calls
setIsConnected/setIsConnecting/wasConnectedRef and sets reconnectTimeoutRef)
only set wasConnectedRef.current = true and schedule reconnect if
shouldReconnectRef.current is true, and set reconnectScheduledRef.current = true
when you arm reconnectTimeoutRef and clear it when the timeout runs or
connection succeeds; in disconnectFromShell(), set shouldReconnectRef.current =
false (and clear any reconnectScheduledRef/current timeout) before calling
closeSocket(); and update the auto-connect effect to return early while
reconnectScheduledRef.current is true so the existing effect does not bypass
backoff.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6ec7b3da-bdf5-457f-891d-049ded33d5b9

📥 Commits

Reviewing files that changed from the base of the PR and between f6200e3 and 1fcb9a2.

📒 Files selected for processing (6)
  • server/index.js
  • src/components/app/AppContent.tsx
  • src/components/shell/hooks/useShellConnection.ts
  • src/contexts/WebSocketContext.tsx
  • src/hooks/useAppLifecycle.ts
  • src/hooks/useWakeLock.ts

Comment thread src/contexts/WebSocketContext.tsx Outdated
Comment thread src/hooks/useWakeLock.ts
- Fix unmountedRef in WebSocketContext: token changes no longer mark
  the provider as unmounted, which was preventing reconnection after
  login/logout cycles. Unmount flag now only set on true component
  unmount via a separate effect.
- Fix Wake Lock async race: store sentinel in local variable and
  re-check released flag after await. Guard release listener to only
  clear ref if it still holds the same sentinel instance.
- Fix shell auto-reconnect on intentional disconnect: add
  shouldReconnectRef guard so disconnectFromShell() prevents the
  onclose handler from scheduling a reconnect. Guard is reset when
  connectToShell() is called intentionally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/contexts/WebSocketContext.tsx`:
- Around line 188-196: The stale-probe timeout set via pingProbeTimeoutRef and
PING_PROBE_TIMEOUT_MS is being cleared immediately because startHeartbeat()
calls clearHeartbeat() (which clears pingProbeTimeoutRef), so the 2s probe never
fires; fix by only restarting the heartbeat when no probe is active: after
scheduling the probe (pingProbeTimeoutRef.current = setTimeout(...)), avoid
calling startHeartbeat() unconditionally—instead call startHeartbeat() only when
pingProbeTimeoutRef.current is falsy (or move startHeartbeat() into an else
branch), so the fast stale-check can run and ws.close() can trigger before the
regular heartbeat timeout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c61a15aa-218c-4eb9-b649-8354b94d8ab5

📥 Commits

Reviewing files that changed from the base of the PR and between 1fcb9a2 and 439a9f7.

📒 Files selected for processing (3)
  • src/components/shell/hooks/useShellConnection.ts
  • src/contexts/WebSocketContext.tsx
  • src/hooks/useWakeLock.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/hooks/useWakeLock.ts
  • src/components/shell/hooks/useShellConnection.ts

Comment thread src/contexts/WebSocketContext.tsx
startHeartbeat() calls clearHeartbeat() which was clearing the
ping probe timeout before it could fire, defeating the fast 2s
stale-check on foreground resume. Now returns early when a probe
is pending so it isn't immediately cancelled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant