Skip to content

SSE event stream startup is blocked behind /project/current and /path metadata fetches #189

Closed

Description

@omgoshjosh

Symptom

On a slow or flaky network, the app can sit with no live events — no session status, no streaming, stale list — for up to ~30 seconds after launch, even though the server is reachable. In the worst case (a metadata request that hangs until timeout), the event stream doesn't start until two unrelated requests have given up.

Cause

loadConnections() in src/stores/connections.ts builds the API client, then awaits project.current() and path.get() before committing the client to the store:

client = built.client
// ...
const [proj, paths] = await Promise.all([
  client.project.current().catch(() => null),
  client.path.get().catch(() => null),
])
// ...
set({ ..., client, currentProject: proj, serverHome: home, ... })

Everything downstream of startup keys off client appearing in the store — app/_layout.tsx starts the SSE stream and the catalog load in an effect watching client. So the entire live pipeline is serialized behind two metadata requests that it doesn't need: neither the project name nor the server's home path is required to stream events. Each request is capped at REQUEST_TIMEOUT_MS (30s), so a hanging response delays live events by up to that long — on exactly the networks where prompt (re)connection matters most.

This is separate from #186 (dead-stream detection): #186 covers a stream that dies after starting; this covers the stream never being asked to start.

Fix

Commit the client to the store immediately after building it, then let the metadata fetch fill in currentProject/serverHome behind it — guarded so a stale response can't clobber state after the user switches servers mid-fetch. Fix included in #182.

Activity

  1. 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.

  2. dzianisv commented on Nov 13, 2026

    @dzianisv
    Owner

    Claimed: Ember — dev-workflow lead for the #189/#186 SSE fix. Branch: fix/sse-startup-recovery-189-186.

    Scope: (1) #189 — commit the API client to the store before the /project/current and /path metadata fetches so the SSE stream can start immediately; (2) #186 — bounded SSE liveness detection and prompt reconnect after network/foreground transitions.

    Noted: PR #182 claims this fix but is unmergeable (dirty, 49 files, last updated Aug 23) and spans other leads' work, so this task ships a scoped fix against current main, reusing #182's verified approach where it applies. Investigating first; fix lands via PR with typecheck + full test suite before merge.

  3. dzianisv commented on Nov 13, 2026

    @dzianisv
    Owner

    Context pack — SSE #189 / #186 (maintained by the lead; artifacts win over this pack)

    Stage: 5 + 5.5 DONE (merged 3d7728c, TDD updated). Lead: Ember — replacement dev-workflow lead (continuation claim 6858676393 on #189, pointer 6858676876 on #186), branch fix/sse-startup-recovery-189-186, worktree ~/workspace/opencode-mobile-sse-189-186. Claim: comment 6855126991. Work Log: https://app.notion.com/p/417ac25eb49f815aa751fea1b7345222 (Done 2026-11-14). Covers #186 too — see the pointer comment there. Project TDD/PRD exist (TDD §5 updated at Stage 5.5); the design gap below is the bug-lane design record.

    Stage 1 verdict (verified against main @ e844493)

    Sweep (pattern-expressible, bounded to three files)

    Method: code search rate-limited mid-sweep, redone against a full tarball of e8444931 (541 blobs) with local grep -rn. Hits: project.current() — connections.ts:141, 203, 269, 350, 380 (only :380 refreshProject() is a standalone refresh; the other four await metadata before a set() that publishes the client: loadConnections, addConnection, setActiveConnection, updateConnection). path.get() — connections.ts:142, 204, 270, 351 (same four sites). connected: true — single site events.ts:198. reader.read() — sdk.ts:326 (guarded) and sdk-v1.ts:291 (unbounded). NetInfo — zero hits repo-wide. transport — zero hits. reconnectNow — events.ts:56,512 + _layout.tsx:133 only.

    Design gap (recorded before the fix starts)

    There is no single owner of "SSE liveness" as a state machine. connected is a boolean set at dial time, not derived from transport evidence; the idle watchdog (sdk), the backoff timer (events), and the foreground resume (_layout) were added at different times with no shared transport state; the UI reads a different store (or nothing) than the transport; and metadata fetch and client publication share one set(), so unrelated data gates the transport. The v1/v2 split duplicated the SSE loop, so a fix applied to one silently missed the other. Any fix that patches one site without a shared transport state and a single resume entry point will re-create this drift.

    Lead decisions (recorded 2026-11-13)

    1. Keep the 8s resolveApi() probe before publish — it chooses which client to build; SSE event stream startup is blocked behind /project/current and /path metadata fetches #189 is about the metadata fetch, not the probe. Split only the metadata behind the publish.
    2. v1 gets the same 45s idle-timeout wrapper as v2 (judgment call; v1 heartbeat interval unknown — a silent stream still reconnects within 45s).
    3. Network recovery: prefer an Expo-SDK dependency already available (expo-network) if it covers the listener; add @react-native-community/netinfo only if it cannot, with the cause recorded (AC 2 requires prompt reconnect on network restoration).
    4. fix: recover SSE promptly after mobile network transitions #186's "unit coverage" criterion conflicts with the standing no-unit-tests rule — the standing rule wins. Extend existing integration/e2e only; the mock server needs a v2 /api/event endpoint first (part of this task). No *.test.ts today imports src/stores/events; src/lib/sse.test.ts covers only the SSEParser.
    5. npm run lint does not exist (package.json scripts: start, ios, android, typecheck, check:versions, test) — that criterion is void. Gate is check:versions + typecheck + FULL test suite.
    6. CUA/device-smoke criterion is MOOT (CUA test removed 2026-11-13 by owner order). Merge validation = CI green + full suite + typecheck; emulator airplane-mode check if an emulator is available, else a recorded residual.
    7. reconnectNow() tearing down a live stream on every foreground is a bug — fix with dedupe (no-op when live or in flight).
    8. Generation route: Google AI Studio (browser) is not available to a background subagent — the implementer authors the patch directly from this brief + file excerpts; verification is the gate + Stage 4 review.
    9. PR fix: keyboard/composer (#156), auto-scroll (#155), dead SSE (#186), blocked SSE startup (#189), stale transcripts, uncopyable text, axis-perfect table scrolling, 50-session list cap, transcript flash, dark-mode contrast #182 is NOT the fix vehicle (unmergeable, dirty, 49 files, spans other leads' work); its SSE approach (sse-liveness.ts policy shape, 35s read timeout, transport states, resume() dedupe, commit-client-first with stale guard) is reference only.

    Consolidated acceptance criteria

    1. Selected/configured distinguished from verified SSE-live. 2. Foreground AND network restoration trigger prompt, deduplicated reconnect. 3. Silent/half-open stream detected within a bounded interval and reconnected. 4. Backoff resets only on demonstrated transport stability (event/heartbeat). 5. SSE startup not blocked by metadata fetches. 6. Reconnects do not duplicate subscriptions/event handling. 7. check:versions + typecheck + FULL test suite pass. 8. No new unit/mock tests; integration/e2e extended only if coverage is missing.

    Open residuals

    Stage 3 verdict (PR #237, verified by lead readback 2026-11-13)

    • PR: fix: SSE startup + liveness (#189, #186) #237 — "fix: SSE startup + liveness (SSE event stream startup is blocked behind /project/current and /path metadata fetches #189, fix: recover SSE promptly after mobile network transitions #186)", open, mergeable, head 9b42be60fd543144b84fa61fecb9922254ea90fa, base main e8444931, 8 files, +382/−113. Never merged (implementer never merges).
    • Diff: connections.ts — all four actions publish the client immediately, metadata behind via shared fetchMetadataBehind() with the get().client === requestClient stale guard; 8s probe kept. events.ts — transport: idle|connecting|live, connected only on first event, 10s timer gone, backoff resets on first event, reconnectNow() deduped (token-guarded flag). sdk-v1.ts — same 45s idle-timeout wrapper as v2. Network listener via expo-network ~8.0.8 (SDK 54, npx expo install; no NetInfo added; listener consults isConnected only). Dot reads transport (green only when live, amber otherwise). Mock server gains v2 GET /api/event; existing mock-server integration test extended (+2 tests, no new test files).
    • Gate (worktree, sequential): check:versions pass, typecheck pass, full suite 382 pass, 0 fail. Test-count delta +2 (380 → 382, verified against a stashed clean-main baseline that also passed 380/380).
    • Files touched: app/(tabs)/index.tsx, package-lock.json, package.json, src/lib/sdk-v1.ts, src/lib/session-list.integration.test.ts, src/stores/connections.ts, src/stores/events.ts, tests/fixtures/mock-opencode-server.ts. No app.json/build.gradle change — applicationId untouched. No keyboard/auto-scroll scope.
    • CI on the head (lead readback): test pass, gitleaks pass; Typecheck and unit tests, build, activation-e2e still in progress at review time. Merge waits on CI green.
    • Residuals: no emulator was available to the implementer, so no device/airplane-mode check ran; real Wi-Fi↔cellular handover still unvalidated. First-event/dedupe logic has no direct test (cannot run under node --test — zustand + react-native AppState); coverage is the transport-level integration test + typecheck + review. v1 heartbeat interval and v1 prod usage still unknown.
    • Process note: the claude-code-review skill file referenced by dev-workflow (~/workspace/custom-skills/claude-code-review/SKILL.md) does not exist on this VM — Stage 4 runs on the method as specified inline in dev-workflow §Stage 4 (parallel compliance + bug agents, high-signal only, independent validation per finding).
    • Leftover: an unapproved permission request from the implementer (Expo dev server connection to cdp.expo.dev, requested 14:10 UTC) — moot, the implementer finished without it; no action.

    Stage 4 — compliance review: PASS (2026-11-13)

    All 8 acceptance criteria verified against the diff; gates re-run standalone in the worktree (check:versions pass, typecheck pass, full suite 382 pass / 0 fail — matches the implementer exactly). Sweep re-run: zero unfixed hits (project.current()/path.get() only inside fetchMetadataBehind + standalone refreshProject(); connected:true single site inside the first-event branch; both reader.read() sites inside idle-timeout wrappers; NetInfo zero hits; STABLE_CONNECTION_MS zero hits). Standing rules verified: applicationId untouched, no keyboard/auto-scroll scope, test delta +2 AC-mapped, no new test files. Lead decisions 1–9 all honored. PR body accurate on every checkable claim. Filed notes (not fails): first-event/dedupe logic untested directly; real handover unvalidated; v1 heartbeat/usage unknown; 380 baseline not re-run by the reviewer (delta established from the diff + 382 count).

    • CI on head 9b42be6 (lead readback 2026-11-13): Typecheck and unit tests PASS, test PASS, gitleaks PASS; Unsigned iOS Simulator build, activation-e2e, build still pending. Merge waits on CI green.
    • Bug review still running at this write; Stage 4 closes only when both verdicts are in.

    Stage 4 — bug review: FAIL (2026-11-13), fix pass running

    Two validated HIGH findings (both verified by the lead against the code in the worktree before acting):

    1. Healthy but idle server never goes live: the only path to live is the first yielded event (events.ts:287-290), but on a quiet server nothing is yielded — v2 drops server.connected in the translator (v2-shape.ts:583-589), v1'"'"'s subscribe frame is a comment, heartbeats never surface as yielded events. Dot amber forever, connected false, backoff never resets, resync never runs. Fix: plumb byte-level liveness up; liveness must be observable at the events-generator level so the integration test can assert it on a quiet stream.
    2. Hung SSE dial parks attemptInFlight forever: dial (sdk.ts:366, sdk-v1.ts:305) has no timeout; 45s watchdog only wraps reader.read() after headers. A black-holed dial never unwinds, the finally never runs, reconnectNow() no-ops forever. Fix: bound the dial (preferred) or let reconnectNow() abort-and-restart a stale connecting attempt.
      Also in the fix pass: MEDIUM 3 (last-event timestamp so live can go stale; network-restore must rescue stale-live), MEDIUM 4 (the +2 tests pass vacuously re liveness — add a test that fails on Finding 1), LOW 5 (connected:false on connect start) and LOW 6 (breadcrumb timing) if cheap. Filed notes (not fails, no scope expansion): v1 idle-loop risk if real v1 servers send no heartbeats; refreshProject unconditional set and removeConnection stale metadata are pre-existing.
    • Stage 4 verdict comment on PR fix: SSE startup + liveness (#189, #186) #237: 6855661220 (FAIL, do not merge). Compliance PASS stands.
    • CI on head 9b42be6 (lead readback 2026-11-13): Typecheck and unit tests PASS, test PASS, gitleaks PASS; Unsigned iOS Simulator build, activation-e2e, build still pending at that time.

    Stage 4 fix pass — done, new head 2ba4e92 (verified by lead readback 2026-11-13)

    • PR fix: SSE startup + liveness (#189, #186) #237 head now 2ba4e92150b1c301fd19f693013333cd8d57afcf, commit "fix: SSE quiet-server liveness + bounded dial", 10 files, +632/−118. Base main unmoved at e8444931. CI on the new head: Typecheck and unit tests PASS, test PASS, gitleaks PASS; iOS sim build, activation-e2e, build pending.
    • Shared bounds in src/lib/sse.ts:14,24,33: SSE_LIVENESS_EVENT_TYPE = "sse.liveness", SSE_DIAL_TIMEOUT_MS = 15_000, SSE_STALE_LIVE_MS = 30_000.
    • Finding 1: both generators yield {type: "sse.liveness", properties: {}} when a chunk carries no session event (sdk.ts:439, sdk-v1.ts:370); events.ts:302-324 records lastByteAt on any yielded event, flips live + resets backoff + runs the resync on quiet reconnects too. Liveness observable at the client.global.events generator level.
    • Finding 2: dial bounded by a 15s timeout controller in both generators (sdk.ts:371-388, sdk-v1.ts:308-325, throws SSE dial timed out); reconnectNow() aborts-and-restarts an attempt parked past the dial bound with no byte (events.ts:591-592, attemptStartedAt/lastByteAt at :230-231).
    • Finding 3: live with no byte for 30s is stale and gets rescued (events.ts:587-590); a fresh live stream still no-ops. LOW 5 fixed (connected: false on re-entry, :236). LOW 6 fixed (breadcrumb at publish time, connections.ts:298). refreshProject/removeConnection left untouched as instructed; network-listener unsubscribe handle considered and not added (no teardown site); abort-unwind answer recorded in the PR body.
    • Tests: +3 in the existing integration test file only (quiet v2 → only sse.liveness; quiet v1 → same; lifecycle still flows through the generator). Fail-before proven: with old generators stashed, all 3 new tests fail, 5 old pass; after the fix all 8 pass in the file. Note: the test file registers an inline node:module resolve hook redirecting expo/fetch to global fetch (expo/fetch has no resolver outside Metro); mock.module was tried first and is unavailable without --experimental-test-module-mocks.
    • Gate (worktree, sequential): check:versions pass, typecheck pass, full suite 385 pass, 0 fail (was 382; clean-main baseline 380). Test-count delta +3.
    • Residuals unchanged: real Wi-Fi↔cellular handover unvalidated; v1 heartbeat interval / v1 prod usage unknown.

    Stage 4 bug re-review: PASS (2026-11-13)

    All three FAIL findings plus LOW 5 and LOW 6 verified fixed against the delta 9b42be60..2ba4e92. Finding 1: both generators yield liveness on heartbeat-only chunks; store flips live + resets backoff + resyncs on liveness, before the session switch; sawLiveness is per-attempt; no real event type collides with "sse.liveness"; only consumer of client.global.events is events.ts:294. Finding 2: 15s dial timeout in both generators, timer cleared + listener removed in finally on all paths; timeout throws only when the parent signal did not abort; the error unwinds the loop → finally clears the flag → backoff reconnect runs; parked-attempt rescue fires only when in flight + no byte + parked past the dial bound, and cannot fire spuriously on a healthy dialling attempt. Finding 3: 30s stale-live rescue is coherent with the 45s watchdog (rescue first in the 30–45s window when a resume event arrives; watchdog is the backstop). LOW 5/6 fixed. The +3 tests are sound (real generator against the mock server; fail-before mechanism verified by reading — old code has zero liveness references). No new failing findings. Filed notes (not fails): parked-rescue keys off dial-start not dial-completion (a server violating 15s heartbeat cadence could be restarted by a resume event — cost is a restart, not data loss); 30s stale-live assumes ≤15s heartbeats, v1 interval still unknown; a stale lastByteAt write race (worst case one missed rescue, self-healing); test fidelity (resolve hook swaps expo/fetch for Node fetch); liveness yielded on garbage/partial chunks (benign — bytes did arrive).

    • CI on head 2ba4e92 (lead readback 2026-11-13): Typecheck and unit tests PASS, test PASS, gitleaks PASS; Unsigned iOS Simulator build, activation-e2e, build still pending.

    Stage 4 re-review — both PASS; Stage 4 clean (2026-11-13)

    • Bug re-review: PASS (all findings + LOW 5/6 verified fixed; no new failings; filed notes only — see the earlier section).
    • Compliance re-check: PASS (all 8 AC re-verified; gates re-run standalone — check:versions pass, typecheck pass, full suite 385 pass / 0 fail, delta +3 AC-mapped; sweep re-run zero unfixed hits; applicationId untouched; no scope creep; lead decisions 1–9 honored; PR body accurate).
    • Clean Stage 4 verdict comment on PR fix: SSE startup + liveness (#189, #186) #237: 6856047162, read back by the lead (names head 2ba4e92). The earlier FAIL verdict (6855661220) is superseded.
    • CI on head 2ba4e92 (lead readback 2026-11-13): Typecheck and unit tests PASS, test PASS, gitleaks PASS; Unsigned iOS Simulator build QUEUED, activation-e2e IN PROGRESS, build IN PROGRESS. Merge HELD on those three — queue delays grant no exception.

    Stage 5 — MERGED (2026-11-14, replacement lead)

    Stage 5.5 — Design/PRD sync (2026-11-14)

    Next action

    Close #189 + #186, mark the Work Log row Done. No release tag (Den's go only). Residuals unchanged: real Wi-Fi↔cellular handover unvalidated; v1 heartbeat interval / v1 prod usage unknown.

  4. dzianisv commented on Nov 13, 2026

    @dzianisv
    Owner

    Continuation claim: Ember — replacement dev-workflow lead inheriting the claim (comment 6855126991), branch fix/sse-startup-recovery-189-186, PR #237. Original lead ended after Stage 4 clean verdict (comment 6856047162 on PR #237, head 2ba4e92). Proceeding at Stage 5: update branch from main (strict branch protection, PR is behind), re-verify CI on the new head, merge, then Stage 5.5 Design/PRD sync.

  5. added a commit that references this issue on Nov 13, 2026
  6. 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): gained only main-side changes (keyboard #194/#156, speech #197, auto-scroll #155, docs #239/#240) — no SSE file touched, so the Stage 4 clean verdict (comment 6856047162) still covered the tree. CI green on b4e124a: Typecheck and unit tests, test, build, activation-e2e, Unsigned iOS Simulator build, Secret scan all success; release + build-fdroid skipped.

  7. 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 (SSE real-time streaming), §3.7 (reconnect with exponential backoff + jitter) 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