Skip to content

fix: SSE startup + liveness (#189, #186) - #237

Merged
dzianisv merged 3 commits into
mainfrom
fix/sse-startup-recovery-189-186
Nov 13, 2026
Merged

dzianisv merged 3 commits into
mainfrom
fix/sse-startup-recovery-189-186

Conversation

@dzianisv

@dzianisv dzianisv commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Fixes #189 and #186.

#189 — SSE startup blocked behind metadata fetches

src/stores/connections.ts: loadConnections (and, for consistency, setActiveConnection, addConnection, updateConnection) now publishes the built client to the store immediately — isLoading: false included — and fetches project.current() + path.get() behind it via a shared fetchMetadataBehind() helper. A stale response can't clobber state after a server switch: metadata only lands if get().client === requestClient. The 8s resolveApi() probe stays before the publish — it chooses which client to build.

#186 — SSE liveness + prompt recovery

Tests

  • Mock server gains a v2 GET /api/event endpoint (tests/fixtures/mock-opencode-server.ts): same SSE framing/2s heartbeat as /global/event, v2-shaped {type, data} frames (server.connected on subscribe, session.execution.started/succeeded on prompt). The v1 endpoint shares the subscribe plumbing via a new openSse() helper (verified live: : connected + : ping).
  • Extended the existing mock-server integration test only (src/lib/session-list.integration.test.ts, +2 tests, no new test files, no unit/mock tests): v2 stream opens + server.connected + heartbeat bytes arrive; execution lifecycle broadcasts reach subscribers in order. The events store itself (zustand + react-native AppState) cannot run under node --test, so its first-event/dedupe logic is covered by typecheck + review, not by a test.

Local gate (worktree ~/workspace/opencode-mobile-sse-189-186, base e8444931)

  • npm run check:versions — pass (Version metadata aligned: 0.4.17 (44))
  • npm run typecheck — pass
  • npm test (full suite) — 382 pass, 0 fail (baseline on clean main: 380 pass — delta +2, the new v2 SSE tests)

Not changed

Residuals / not validated

Stage 4 fix pass (head 2ba4e92 — bug review FAIL → fixed, re-review requested)

I read the context pack (issue #189 comment 6855261016), the Stage 4 FAIL verdict (comment 6855661220), and the full PR diff before touching code. All three validated findings from the bug review are fixed in this pass; nothing outside them was changed.

1. HIGH — healthy but idle server never went live (fixed)

  • src/lib/sdk.ts (v2) and src/lib/sdk-v1.ts (v1) generators now yield a liveness-only event {type: "sse.liveness", properties: {}} whenever a chunk carries no session event — the v2 subscribe frame (server.connected, dropped by the translator), the v1 : connected comment, and : heartbeat comment frames. Constants live in src/lib/sse.ts (SSE_LIVENESS_EVENT_TYPE).
  • src/stores/events.ts: the loop treats any yielded event (liveness included) as proof of bytes — flips connected/transport to live, resets the backoff, and runs the once-per-reconnect busy resync + pending refresh on quiet reconnects too. Liveness events continue before the session switch, so nothing is dispatched for them. Backoff reset and resync no longer gate on the first session event.
  • Liveness is observable at the events-generator level (client.global.events), as required — see Tests.

2. HIGH — hung SSE dial parked attemptInFlight forever (fixed, both halves)

  • Both generators bound the dial with a 15s timeout controller (SSE_DIAL_TIMEOUT_MS = 15_000, src/lib/sse.ts): headers must arrive within 15s or the dial aborts with a clear SSE dial timed out error, the loop unwinds through its finally, and the backoff reconnect runs. Parent-signal abort still wins (a parent abort rethrows the original error, not the timeout). The 45s idle watchdog still covers reads after headers.
  • Belt-and-braces in src/stores/events.ts: reconnectNow() can now abort-and-restart an attempt parked past the dial bound with no byte received (attemptStartedAt + lastByteAt tracking); connect() aborts the old controller, unwinding the hung dial.

3. MEDIUM — reconnectNow() no-ops when live, so network-restore can't rescue a stale-live half-open stream (fixed)

  • src/stores/events.ts tracks lastByteAt (updated on every yielded event, liveness included). A live stream with no byte for 30s (SSE_STALE_LIVE_MS, two missed v2 heartbeats, under the 45s watchdog) is stale, and reconnectNow() rescues it. A fresh live stream still no-ops, so foreground + network-restore firing together still can't tear down a healthy stream.

LOW 5 — connect() re-entry left connected: true while transport: "connecting" (fixed)

  • src/stores/events.ts: connect() now sets connected: false alongside transport: "connecting".

LOW 6 — setActiveConnection breadcrumb delayed/losable (fixed)

  • src/stores/connections.ts: the breadcrumb now fires at publish time (unconditional, hasProject: false — unknown at publish), not inside the fetchMetadataBehind apply callback, so a slow/offline server no longer loses it.

Tests (+3, existing file only — no new test files)

  • Extended src/lib/session-list.integration.test.ts only: quiet v2 stream → generator yields only sse.liveness; quiet v1 stream → same; lifecycle events still flow through the generator (session.status + liveness both arrive). The generator tests drive the REAL client.global.events against the mock server.
  • Vehicle note: sdk.ts imports expo/fetch, which has no resolver outside Metro, so the SDK could not be imported under node --test at all. The test file registers a node:module resolve hook (inline data: URL, no new files, no test-script change) redirecting expo/fetch to the global fetch and resolving the SDKs' extensionless Metro imports to .ts. mock.module was tried first and is not available — it needs --experimental-test-module-mocks, which the test script does not pass.
  • Fail-before verified: with the old generators stashed (new tests + constants kept), all 3 new tests fail (generator never yields within 5s); with the fix they pass. The 5 pre-existing tests in the file pass in both runs.

Filed notes from the bug review (not expanded)

  • refreshProject's unconditional set and removeConnection's stale metadata are pre-existing; left untouched, as instructed.
  • Network-listener unsubscribe handle (events.ts:624): considered, not added — the expo-network and AppState listeners live for the app lifetime and there is no teardown site; capturing the handle would be dead code.
  • Abort unwinding a parked readWithIdleTimeout read: verified from code — aborting the controller rejects the pending read(), and the wrapper's .catch(() => ({done: true})) converts that to stream-end, so the loop breaks promptly; worst case the 45s idle timer still resolves the race, so the loop unwinds within 45s regardless.

Local gate, re-run after the fix pass (worktree ~/workspace/opencode-mobile-sse-189-186, base e8444931, main unmoved)

  • npm run check:versions — pass (Version metadata aligned: 0.4.17 (44))
  • npm run typecheck — pass
  • npm test (full suite) — 385 pass, 0 fail (was 382 — delta +3, the new generator-level tests; clean-main baseline 380)
  • Diff re-checked against current main blobs before pushing; head re-read immediately before push: 2ba4e92.

Commit the API client before the /project/current + /path metadata
fetches so the event stream starts immediately (#189), with a stale
guard so a late metadata response can't clobber state after a server
switch. Same split in setActiveConnection, addConnection and
updateConnection; the 8s resolveApi() probe stays before the publish.

Track transport (idle|connecting|live) in the events store: connected
flips only on the first received event, the 10s backoff-reset timer is
gone (backoff resets on demonstrated liveness instead), and
reconnectNow() is deduped — no-op when live or an attempt is in
flight. v1 reads get the same 45s idle-timeout wrapper as v2.
expo-network (SDK 54, ~8.0.8) wires offline->online to the same
deduped resume entry point; no netinfo dependency. The connection
dot is green only when transport is live, amber otherwise.

Mock server gains a v2 /api/event endpoint; the existing
mock-server integration test covers its stream, heartbeats and
execution-lifecycle broadcasts.
@dzianisv

Copy link
Copy Markdown
Owner Author

Stage 4 verdict: FAIL — do not merge

Compliance review: PASS (all 8 acceptance criteria verified against the diff; gates re-run 382 pass; sweep re-run zero unfixed hits). Bug review: FAIL — two validated correctness bugs in the new state machine. Fix pass + re-review follow. — Ember, dev-workflow lead

1. HIGH — A healthy but idle server never goes live

The only path to live is the first yielded event (src/stores/events.ts:287-290). On a quiet server nothing is ever yielded: v2 drops the subscribe frame in the translator (src/lib/v2-shape.ts:583-589, server.connected returns []), v1's subscribe frame is a : connected comment, and : heartbeat comment frames never surface as yielded events (src/lib/sse.ts ignores non-data: lines). Consequences: dot amber forever and connected stays false despite a live stream (a visible regression from the old optimistic connected: true); backoff never resets; the post-reconnect resync (events.ts:295-299) never runs on a quiet reconnect. AC 1 is never satisfiable on an idle server.
Fix shape: plumb byte-level liveness up (the SDKs' receivedFirstByte/heartbeat observation, or yield server.connected as a liveness-only event) instead of keying live off the first parsed session event. Liveness must be observable at the events-generator level so the integration test can assert it on a quiet stream.

2. HIGH — A hung SSE dial parks attemptInFlight forever, and reconnectNow() can no longer interrupt it

reconnectNow() early-returns while an attempt is in flight (events.ts:557). The dial has no timeout (src/lib/sdk.ts:366 and src/lib/sdk-v1.ts:305 both await expoFetch(url, { headers, signal }); the 45s watchdog only wraps reader.read() after headers arrive). If the dial black-holes, the loop never unwinds, the finally (events.ts:540) never runs, and the flag is never cleared. Foreground resume and the network listener both funnel through reconnectNow() and both no-op. The old reconnectNow() unconditionally aborted and restarted, so it could interrupt a hung dial — this PR removes that recovery path while adding the flag that blocks it.
Fix shape: bound the dial, or give reconnectNow() a way to abort-and-restart an attempt that has been connecting with no bytes for longer than a bound.

3. MEDIUM — reconnectNow() no-ops when live, so network-restore cannot rescue a stale-live half-open stream

live is latched at the first event with no last-event timestamp; a stream that went half-open seconds after its last event still reads live, so the network listener's reconnectNow() is swallowed and recovery waits for the 45s idle watchdog. Address in the same pass (last-event/last-byte timestamp so live can go stale).

4. MEDIUM — The +2 tests pass vacuously w.r.t. the liveness claim

The new tests subscribe with raw fetch + SSEParser and assert server.connected arrives as a raw frame — but the app path runs that frame through V2EventTranslator, which drops it. The fix pass adds a test that fails on Finding 1 (quiet stream → liveness observed at the generator/store level).

5. LOW — connect() re-entry leaves connected: true while transport: "connecting"

events.ts:224 sets only transport: "connecting". Reachable via the manual Retry button. No UI reads connected today (only the dot reads transport), so this is an invariant break, not a visible bug. Fix if cheap.

6. LOW — setActiveConnection breadcrumb is now delayed and losable

The breadcrumb moved inside the fetchMetadataBehind apply callback (connections.ts:296-303): it fires only after both metadata fetches resolve and the stale guard passes. Telemetry only; not disclosed in the PR body. Fix if cheap, otherwise record as residual.

Validated OK in the same review: stale guard, token-guarded flag, first-event handler (exactly once per attempt), backoff reset paths, v1 wrapper shape, network listener lifecycle, dot reactivity, subscription duplication, scope (all 8 files match the fix shape).

…#186)

Stage 4 bug review FAILED with two HIGH findings; this pass fixes them
plus MEDIUM 3 and LOWs 5-6:

1. HIGH — healthy idle server never went live: both SSE generators now
yield an sse.liveness event when a chunk carries no session event
(subscribe frame / heartbeat comments), so the events store observes
liveness, resets backoff, and runs the post-reconnect resync on quiet
streams. Observable at the client.global.events generator level.
2. HIGH — hung SSE dial parked attemptInFlight forever: the dial is now
bounded by a 15s timeout controller (both v1/v2); reconnectNow() can
also abort-and-restart an attempt parked past the dial bound with no
bytes.
3. MEDIUM — reconnectNow() rescues a stale-live stream (no byte for
30s) so network-restore recovers a half-open stream.
LOW 5 — connect() clears connected on re-entry. LOW 6 — setActiveConnection
breadcrumb fires at publish time instead of after metadata.

Tests: +3 generator-level integration tests in the existing file only
(quiet v2/v1 liveness + lifecycle still flows); all 3 fail on the old
generators (verified by stash) and pass after. No new test files.

Gate (worktree, sequential): check:versions pass, typecheck pass,
full suite 385 pass / 0 fail (was 382).
@dzianisv

Copy link
Copy Markdown
Owner Author

Stage 4 verdict: PASS (clean) — ready to merge once CI is green

Both re-reviews PASS on head 2ba4e92. Merge is held until CI is green on that head — see below. — Ember, dev-workflow lead

  • Bug re-review: PASS. All three FAIL findings plus LOW 5/6 verified fixed against the delta 9b42be60..2ba4e92: liveness yields on heartbeat-only chunks in both generators and flips live + resets backoff + resyncs on quiet reconnects; 15s dial bound in both generators with the parked-attempt rescue; 30s stale-live rescue coherent with the 45s watchdog. The +3 tests are sound (real generator against the mock server; fail-before verified). No new failing findings. Filed notes (not fails): parked-rescue keys off dial-start; 30s stale-live assumes ≤15s heartbeats (v1 interval unknown); a stale lastByteAt write race (worst case one missed rescue, self-healing); test fidelity (resolve hook swaps expo/fetch for Node fetch).
  • Compliance re-check: PASS. All 8 acceptance criteria 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 on every checkable claim.
  • CI on head 2ba4e92 at posting time: Typecheck and unit tests PASS, test PASS, gitleaks PASS; Unsigned iOS Simulator build queued, activation-e2e in progress, build in progress. Merge waits on those three.
  • Earlier FAIL verdict (comment 6855661220) is superseded by this clean verdict — the findings it named are fixed and re-verified; do not merge on the basis of the old comment alone.

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.

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

1 participant