Skip to content

fix: recover SSE promptly after mobile network transitions #186

Closed

Description

@omgoshjosh

Problem

The app can show a selected connection as connected while its global SSE transport is not live, then take several seconds or indefinitely to recover after foreground or network transitions.

Investigation findings

  • Initial client publication waits for both /project/current and /path, delaying SSE startup behind metadata requests.
  • Reconnect backoff reaches roughly 3-5 seconds by attempt three.
  • events.ts sets connected: true before transport liveness is established.
  • Retry state resets after 10 seconds without requiring an SSE event.
  • There is no AppState or network-change recovery hook.
  • The SSE reader has no read/liveness timeout.
  • Green connection UI reflects the selected connection, not verified SSE health.

Relevant files: src/stores/connections.ts, src/stores/events.ts, and src/lib/sdk.ts.

Agent pickup protocol

  • Claim this work by commenting CLAIMED: <agent/session> <branch> before editing.
  • Work in a dedicated branch/worktree; do not use /Users/josh/agents/worktrees/mobile-upstream-2, which has separate ownership.
  • Post progress and blockers as issue comments.
  • Finish with a comment containing the commit/PR, test commands and results, and any residual risks.
  • Leave the issue open until device/emulator validation is reported.

Acceptance criteria

  • Connection state distinguishes selected/configured from verified SSE-live.
  • Foreground and network restoration trigger prompt, deduplicated reconnect.
  • A silent or half-open stream is detected within a bounded interval and reconnects.
  • Retry backoff resets only after demonstrated transport stability, preferably receipt of an event or heartbeat.
  • SSE startup is not unnecessarily blocked by unrelated metadata fetches.
  • Reconnects do not duplicate subscriptions or event handling.
  • Unit coverage exercises lifecycle transitions, liveness timeout, retry reset, and cancellation.
  • npm run lint and npm run typecheck pass.
  • Required CUA/device smoke validation is reported before merge.

Activity

  1. changed the title [-]fix: recover SSE promptly after mobile network transitions[/-] [+][AGENT BACKLOG] fix: recover SSE promptly after mobile network transitions[/+] on Aug 16, 2026
  2. changed the title [-][AGENT BACKLOG] fix: recover SSE promptly after mobile network transitions[/-] [+]fix: recover SSE promptly after mobile network transitions[/+] on Aug 16, 2026
  3. omgoshjosh commented on Aug 16, 2026

    @omgoshjosh
    Author

    CLAIMED: claude-opus-5 / OPENCODEX mobile dev session — branch fix/sse-liveness-recovery in worktree /Users/josh/agents/worktrees/mobile-sse (not mobile-upstream-2).

    Starting with the diagnosis before touching code. Will post findings, then the commit/PR with test results and residual risks.

    Context that may matter: a related client-side gap was fixed recently in this fork — resyncBusySessions() was the only reconnect-time reconciliation and no-ops when no session is marked busy, so a transcript could stay stale after reconnect even once SSE was live again. That is complementary to this issue (data vs transport) and already carries a reconcileOpenSession() fix.

  4. omgoshjosh commented on Aug 16, 2026

    @omgoshjosh
    Author

    Implemented on fix/sse-liveness-recovery (commit d2dabfb). Every claim in the issue was verified in the code before changing anything; one needed correcting.

    Root causes, confirmed

    1. The read was unbounded — this is the "indefinitely" case. sdk.ts awaited reader.read() with no timeout. A half-open socket yields no bytes, no done and no error, so the loop parks forever and nothing triggers a reconnect. Reads are now raced against a 35s liveness timeout (three missed ~10s server heartbeats). It rejects rather than returning a sentinel, so the failure lands on the same path as a genuine transport error and the existing reconnect logic needs no special case.

    2. connected meant "we tried". Set at the moment connecting began, before a byte arrived. Now flips only on the first received event, with a transport field (idle | connecting | live) for the finer distinction.

    3. Backoff reset on a timer. A 10s setTimeout cleared reconnectAttempts regardless of whether anything arrived, so a silently-failing connection kept resetting its own backoff. Reset now requires evidence.

    4. Recovery hook — the issue is right in substance, slightly off in detail. An AppState listener does exist in app/_layout.tsx, but it only handles biometric lock; nothing re-checked the stream on foreground. Added resume(), wired to AppState "active", deduplicated so a simultaneous foreground + network-restore cannot open two streams.

    Also found, not in the issue: the connection dot was backgroundColor: "#22c55e" — literally hardcoded green. It claimed health regardless of transport state. Now reflects verified liveness (amber when not live). This is probably the most visible half of "green UI reflects the selected connection, not verified SSE health".

    Acceptance criteria

    • Selected vs verified-live distinguished — transport state ✅
    • Foreground/network restore triggers deduplicated reconnect ✅
    • Half-open stream detected within a bounded interval ✅ (35s)
    • Backoff resets only on demonstrated liveness ✅
    • Reconnects do not duplicate subscriptions ✅ (resume no-ops when live or in flight)
    • Unit coverage of lifecycle, liveness timeout, retry reset, cancellation ✅ (13 tests)
    • npm run typecheck ✅ · full suite 315 pass ✅
    • SSE startup not blocked by metadata fetches — NOT addressed. /project/current and /path gating still stands. It is a latency optimisation on a different code path (connections.ts) and I did not want to fold an unrelated refactor into a transport-correctness fix. Worth its own issue.

    Device validation

    Android 12 emulator against a live server: airplane mode on, then off. Backoff climbed (9.4s → 14.5s) and the stream recovered 16 seconds after the network returned — automatic, no navigation required.

    Not yet validated: a real Wi-Fi↔cellular handover on hardware, which is the case most likely to produce a genuinely half-open socket rather than a clean disconnect. The emulator's airplane mode gives clean ConnectExceptions, so the 35s timeout path is covered by unit tests rather than observed in the wild.

    Leaving open per the protocol pending that hardware validation.

  5. omgoshjosh commented on Aug 16, 2026

    @omgoshjosh
    Author

    Now in #182 (commit 767315b), rather than a separate PR — the repo owner asked to keep this work to a single PR.

    Recap of what landed, since the diagnosis differs slightly from the issue as filed:

    1. Unbounded read. The SSE loop awaited reader.read() with no deadline. A half-open socket produces no bytes, no done, and no error, so the loop parked indefinitely — this is the actual reason recovery never happened, and no amount of reconnect-logic tuning would have helped. readWithTimeout races the read against 35s (three missed ~10s heartbeats) and rejects, which routes the failure through the existing reconnect path unchanged.
    2. connected meant "we tried" — set at the moment a connect began. Now flips only once bytes arrive, with a transport state (idle/connecting/live) so the UI can't show green over a stream that never delivered anything.
    3. Backoff reset on a 10s timer regardless of whether anything was received, so a silently-failing connection kept declaring itself stable.

    Measured on an emulator with an airplane-mode toggle: ~16s to recover, versus never before.

    Still outstanding, and I think it deserves its own issue: SSE startup is blocked behind the /project/current and /path metadata fetches in connections.ts. If either is slow the stream doesn't start at all, which is a different failure from the ones above and isn't addressed here.

    Not yet validated: real Wi-Fi↔cellular handover on physical hardware. The emulator airplane-mode toggle exercises the timeout and reconnect, but not a genuine interface migration where the socket survives in a half-open state.

  6. Qiiks commented on Aug 21, 2026

    @Qiiks

    Fixed by the merge of #182 into the Qiiks/opencode-mobile maintenance branch (maint/v0.4.16) — shipping in release v0.4.16-maint.1 (APK will be attached there). Upstream review of #182 is still recommended; this merge keeps the fork usable while it waits.

  7. dzianisv commented on Nov 13, 2026

    @dzianisv
    Owner

    This issue is being fixed together with #189 by the same task (dev-workflow lead Ember, branch fix/sse-startup-recovery-189-186, claim comment 6855126991 on #189). The maintained context pack — Stage 1 findings, sweep, design gap, lead decisions, consolidated acceptance criteria — lives on #189 as comment 6855261016 and will be updated at every stage. Stage 1 found #186 partly fixed on main already (v2 idle-timeout exists; foreground hook exists but reconnectNow() is not deduplicated), with the v1 unbounded read, optimistic connected:true, 10s timer reset, missing network hook, and hardcoded-green dot still open. Fix lands via PR with typecheck + full test suite before merge.

  8. dzianisv commented on Nov 13, 2026

    @dzianisv
    Owner

    Continuation claim pointer: Ember — replacement dev-workflow lead inheriting the claim for #189/#186 (claim comment 6855126991 on #189). PR #237, branch fix/sse-startup-recovery-189-186. Stage 4 clean verdict comment 6856047162; proceeding at Stage 5 (branch update from main, then merge).

  9. added a commit that references this issue on Nov 13, 2026
  10. dzianisv commented on Nov 13, 2026

    @dzianisv
    Owner

    Stage 5 — MERGED: PR #237 merged as 3d7728c (main HEAD verified by readback). Branch updated from main first (merge commit b4e124a); CI green on the new head (Typecheck and unit tests, test, build, activation-e2e, iOS simulator build, Secret scan all success). Stage 4 clean verdict comment 6856047162 on PR #237.

  11. dzianisv commented on Nov 13, 2026

    @dzianisv
    Owner

    Stage 5.5 — Design/PRD sync: Design UPDATED — TDD §5 “SSE & Reconnect” and §4 step 3 rewritten to the merged mechanism (transport idle|connecting|live, connected only on first event/liveness, 15s dial bound, 30s stale-live rescue, deduped reconnectNow() + expo-network listener, dot reads transport; client published before metadata fetches). https://app.notion.com/p/40bac25eb49f8132908ce6f9bcc2dff9 — verified by re-fetch. PRD checked — no update needed: §3.4, §3.7 and the 15s backoff cap all still hold.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions