Skip to content

Keep daemon reconnect running while the app is backgrounded - #4444

Merged
boudra merged 5 commits into
mainfrom
fix/background-reconnect
Sep 7, 2026
Merged

boudra merged 5 commits into
mainfrom
fix/background-reconnect

Conversation

@boudra

@boudra boudra commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

Refs #4160, which introduced this regression.

Type of change

  • Bug fix
  • New feature
  • Enhancement
  • Refactor
  • Docs

Reasoning

After hiding a browser tab, minimizing a desktop window, or leaving the mobile app, a dropped daemon connection could remain offline because app visibility disabled automatic reconnect. Keep normal reconnect backoff enabled whenever the OS lets the process run, and retain #4160's immediate foreground reconnect and healthy-socket preservation.

The fix removes visibility-controlled reconnect state from the host runtime. Visibility still flushes the replica cache on departure and calls ensureConnectedAll() on return. No new state machine or platform branch is needed. getIsAppVisible() also returns false for native inactive, so substituting that helper would not fix the retry policy.

Goals

  • Keep retries enabled for existing hosts and hosts created while hidden or inactive.
  • Reconnect immediately on foreground and preserve healthy sockets.
  • Lock the regression with failing-first lifecycle tests and a real browser/daemon restart journey.
  • Verify web, Android, and feasible Electron behavior with inline QA evidence.

Non-goals

  • Change backoff intervals, protocol, relay transport, or notification delivery.
  • Keep mobile JavaScript executing after OS suspension.
  • Add liveness probes for sockets that still report connected.

QA

Verified on 2026-09-07 against the worktree app and isolated development daemon (both report 0.7.2). No wire changes. The baseline was c46aee4, including #4160.

Regression

The lifecycle regression fails before the fix: inactive changes the client's reconnect flag to false. With the fix, 100 tests pass across the runtime and bootstrap files, covering all three initial states, two hosts, healthy sockets, and immediate foreground reconnect.

cd packages/app
npx vitest run src/runtime/host-runtime.test.ts src/navigation/host-runtime-bootstrap.test.ts --bail=1

Key failing-first output:

FAIL keeps reconnect enabled through inactive/background and resumes immediately (mounted active)
AssertionError: expected false to be true
changeAppState("inactive");
expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true);

After fix:
Test Files 2 passed; Tests 100 passed

Web

The committed Playwright journey starts its own daemon, seeds only that host, dispatches hidden visibility, terminates/restarts its own daemon process tree, waits for a new socket and the Online badge while still hidden, then refocuses.

npm run test:e2e --workspace=@getpaseo/app -- background-reconnect.spec.ts --project=browser --workers=1
npm run test:e2e --workspace=@getpaseo/app -- background-reconnect.spec.ts --project=browser --workers=1 --repeat-each=2

The same journey failed against the baseline: no new WebSocket for 30 seconds after daemon restart. It passed once during development and twice consecutively after restoring the fix. A further run passed after moving setup and cleanup into a test fixture (1 passed, 34.3s). Key command output:

Before: TimeoutError: page.waitForEvent: Timeout 30000ms exceeded while waiting for event "websocket"
After: 2 passed (1.3m); individual journeys 17.5s and 16.6s
After fixture cleanup: 1 passed (34.3s)

Android

Built the dev client from this worktree. The already-running emulator was reserved, so this run used a second existing API 35 x86_64 AVD with fresh app data. The daemon used startIsolatedHostDaemon with its own temporary home; the OS assigned daemon port 42049 and Metro port 33799. Restart called the helper's restart(), which only terminates its captured child process tree. These port values belong to this run; allocate fresh ports when repeating it.

# packages/app
CI=1 APP_VARIANT=development npx expo prebuild --platform android --no-install
cd android
./gradlew assembleDebug -PreactNativeArchitectures=x86_64 --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
# BUILD SUCCESSFUL in 2m 44s; 959 actionable tasks: 959 executed

# packages/app, separate shell
CI=1 APP_VARIANT=development REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 \
  EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:42049 npx expo start --port 33799 --clear

# repository root
adb -s emulator-5556 install -r packages/app/android/app/build/outputs/apk/debug/app-debug.apk
agent-device --session background-reconnect --platform android --device paseo-api35-2 \
  open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A33799' \
  --metro-host 10.0.2.2 --metro-port 33799

Dismissed the dev menu and first-launch permission prompts. Opened Settings → Overview and verified Online, 10.0.2.2:42049.

agent-device --session background-reconnect wait text Online 3000
agent-device --session background-reconnect home
agent-device --session background-reconnect appstate
# Foreground app: com.google.android.apps.nexuslauncher
# Restart isolated daemon here, using its owned handle.
agent-device --session background-reconnect open sh.paseo.debug
agent-device --session background-reconnect wait text Online 3000
agent-device --session background-reconnect screenshot android-resumed.png
agent-device --session background-reconnect app-switcher
agent-device --session background-reconnect screenshot android-switcher.png
agent-device --session background-reconnect open sh.paseo.debug
agent-device --session background-reconnect wait text Online 3000
agent-device --session background-reconnect screenshot android-switcher-return.png

Observed resume handshake: 471 ms from Android's resume callback to the daemon accepting the connection. The first post-return assertion passed; there was no persistent disconnected screen. The backgrounded Android process did not complete a reconnect before return in this run. The same socket remained attached across the subsequent app-switcher round trip (no additional hello).

Raw event/log excerpts (same machine clock; opaque identifiers omitted):

09-07 20:19:42.413 I wm_on_paused_called: [...,sh.paseo.debug.MainActivity,performPause,3]
RESTARTED 317095 2026-09-07T18:19:56.518Z
09-07 20:20:16.744 I wm_on_resume_called: [...,sh.paseo.debug.MainActivity,RESUME_ACTIVITY,2]
[20:20:17.215] INFO: Client connected via hello
  host=10.0.2.2:42049 userAgent=okhttp/4.12.0 appVersion=0.7.2 resumed=false
09-07 20:20:47.095 I wm_on_paused_called: [...,sh.paseo.debug.MainActivity,performPause,17]
09-07 20:20:47.117 I wm_on_resume_called: [...,sh.paseo.debug.MainActivity,LIFECYCLER_RESUME_ACTIVITY,23]

Android's installed React Native AppStateModule.kt emits background/active, not literal inactive. The unit regression exercises inactive; real iOS interruption behavior remains unverified on this Linux host.

Electron Linux

Built production main with npm run build:main --workspace=@getpaseo/desktop. A temporary Playwright _electron.launch harness launched packages/desktop under xvfb-run -a, with separate Electron user data, daemon management disabled, PASEO_HOME pointing at the owned isolated daemon, and EXPO_DEV_URL=http://localhost:33799.

Used app.browserWindow(page) to hide the actual app window, restarted the isolated daemon, then read the host's Online badge before showing the window. Electron 44.2.0 reconnected at 20:18:03.817 after the restart completed at 20:18:01.050. Captured the screenshot after showing the window again.

WINDOW { visible: false, minimized: false, url: '.../settings/hosts/background-reconnect-qa/host' }
STATUS { visibility: 'visible', text: '...Overview\nReconnect QA\nOnline...' }
VISIBLE visible
SCREENSHOT SAVED

Under Xvfb, Chromium continued reporting document visibility as visible even though the native window was hidden. This verifies actual window hiding and reconnect; the browser test separately proves the hidden document lifecycle. This used the development wrapper and shared Metro renderer, not a packaged release or macOS/Windows window manager.

Checks and limits

npm run typecheck, npm run lint, npm run format, and git diff --check pass. Lint: 0 warnings, 0 errors.

Platform Coverage
Web Real browser, real restarted daemon, hidden lifecycle, refocus; failing baseline + passing regression
Android Built native dev client, API 35 emulator, Home/restart/return, app-switcher round trip
Desktop Linux Real Electron dev wrapper under Xvfb, native window hide/restart/show; visibility caveat above
iOS Unavailable on Linux; literal inactive covered by unit tests only
Desktop macOS / Windows Unavailable on this host

This does not verify silent half-open sockets, mobile execution after OS suspension, relay outages, or notification delivery. Healthy sockets and the existing foreground fast path remain unchanged.

CI follow-up

The first complete CI run passed the new reconnect test on its first attempt (19.0s), but failed the existing delayed-history scroll journey on both attempts. Its trace showed temporary reasoning rows satisfying the scroll-height prerequisite, then collapsing before the wheel gesture. The test now waits for the mock's next assistant response before measuring scrollable height. Scroll-distance and viewport-preservation assertions are unchanged.

npm run test:e2e --workspace=@getpaseo/app -- agent-stream-ui.spec.ts --grep 'delayed authoritative history' --workers=1 --repeat-each=5
# 5 passed (1.8m)
npm run test:e2e --workspace=@getpaseo/app -- agent-stream-ui.spec.ts background-reconnect.spec.ts --workers=1
# 9 passed (2.4m)
npm run typecheck
npm run lint
npm run format
git diff --check

Evidence cleanup is complete in one commit and one push: 6e56bc85db6902b89fc8916913a4d2f0e81d99d1. QA artifacts and their links are removed; the unit tests and real Playwright spec are unchanged. The commands, key output, timing evidence, and platform limits are inline above.

The cleanup head is green: CI, Nix Linux/macOS builds, Docker, and Greptile review all passed. gh pr checks 4444 --watch exited 0; no unresolved review threads. No workflow reruns or additional pushes were needed. The reconnect journey passed on its first attempt (18.8s), and the corrected delayed-history journey passed on its first attempt (20.0s). Two existing tests passed on retry: command-center workspace actions and sidebar row shape.

Review follow-up

  • The browser journey now uses a daemon fixture and seven named actions. The final journey passed again (1 passed, 34.3s), followed by clean typecheck, lint, and formatting.
  • Visibility-emulation finding: retained because the requested QA explicitly calls for emulating document.visibilityState and dispatching visibilitychange. The test exercises the real app, WebSocket, and restarted daemon and fails on the original runtime. It proves the application retry policy; it does not claim to simulate Chromium timer throttling or OS suspension. Actual native window hiding is separately documented above.
  • Injected-client finding: retained because these unit assertions verify the runtime's policy at the injected client interface (retry enabled across inactive/background and immediate foreground command). They also verify connected snapshots and healthy-socket preservation. Actual autonomous reconnect is proved by the separate real-browser/daemon test. Reimplementing backoff inside the fake would duplicate the client rather than strengthen that proof.

Checklist

  • One focused change
  • npm run typecheck passes
  • npm run lint passes
  • npm run format passes
  • QA evidence
  • Tests added or updated where it made sense

Visibility should flush cached state and accelerate foreground recovery without disabling normal reconnect backoff. Preserve healthy sockets and retry whenever the OS permits execution.
@greptile-apps

greptile-apps Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR keeps daemon-client retry backoff enabled across inactive and background application states while preserving immediate foreground reconnection and replica-cache flushing.

  • Removes visibility-controlled reconnect state from the host runtime.
  • Adds lifecycle coverage for active, inactive, and background initial states.
  • Adds a browser journey that restarts an isolated daemon while the tab is hidden.
  • Stabilizes the delayed-history E2E setup by waiting past temporary reasoning-row height.

Confidence Score: 5/5

The PR appears safe to merge; no outstanding correctness, security, or repository-rule issue remains.

The runtime consistently enables client retry behavior, retains cache flushing on departure, and preserves the foreground fast path without closing healthy sockets. All previous review threads were resolved, and the changes since the previous review only remove committed QA artifacts and stabilize an existing E2E precondition.

Important Files Changed

Filename Overview
packages/app/src/runtime/host-runtime.ts Removes visibility-gated reconnect state so all clients retain normal retry behavior while foregrounding still requests an immediate connection.
packages/app/src/runtime/host-runtime.test.ts Expands lifecycle coverage across all initial app states, multiple hosts, healthy sockets, and foreground reconnection.
packages/app/src/navigation/host-runtime-bootstrap.test.ts Verifies initial visibility and forwarding of active, inactive, and background lifecycle transitions.
packages/app/e2e/browser/background-reconnect.spec.ts Exercises hidden-state reconnection against a restarted isolated daemon and verifies the connection survives refocus.
packages/app/e2e/browser/agent-stream-ui.spec.ts Waits for stable assistant output before measuring scrollability, avoiding temporary reasoning-row height.

Sequence Diagram

sequenceDiagram
  participant OS as App lifecycle
  participant Store as HostRuntimeStore
  participant Client as Daemon client
  participant Daemon as Daemon
  OS->>Store: inactive/background
  Store->>Store: flush replica cache
  Note over Client: Normal reconnect backoff remains enabled
  Daemon--xClient: Connection drops
  Client->>Daemon: Retry while process can execute
  OS->>Store: active
  Store->>Client: ensureConnected()
  Client->>Daemon: Immediate reconnect if needed
Loading

Reviews (5): Last reviewed commit: "chore(app): keep reconnect QA evidence i..." | Re-trigger Greptile

Comment thread packages/app/e2e/browser/background-reconnect.spec.ts
Comment thread packages/app/e2e/browser/background-reconnect.spec.ts Outdated
Comment thread packages/app/src/runtime/host-runtime.test.ts
Comment thread packages/app/e2e/browser/background-reconnect.spec.ts Outdated
@boudra
boudra merged commit d779514 into main Sep 7, 2026
24 checks passed
@boudra
boudra deleted the fix/background-reconnect branch September 7, 2026 21:34
omorsi45 pushed a commit to omorsi45/paseo that referenced this pull request Sep 8, 2026
…#4444)

* fix(app): keep reconnect enabled while backgrounded

Visibility should flush cached state and accelerate foreground recovery without disabling normal reconnect backoff. Preserve healthy sockets and retry whenever the OS permits execution.

* test(app): clarify the background reconnect journey

* test(app): name reconnect screenshot helper inputs

* test(app): wait for stable content before delayed-history scroll

* chore(app): keep reconnect QA evidence in the PR body
Legoless added a commit to Legoless/paseo that referenced this pull request Sep 9, 2026
Port of getpaseo#4160 and getpaseo#4444. Flush the replica cache when hidden, reconnect
every host immediately on resume, and keep daemon reconnect backoff
running while backgrounded. Disconnect no longer clears turn liveness
so queued messages still drain after a backgrounded turn finishes.
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