Repository navigation
fix: SSE startup + liveness (#189, #186) - #237
Conversation
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.
Stage 4 verdict: FAIL — do not mergeCompliance 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
|
…#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).
Stage 4 verdict: PASS (clean) — ready to merge once CI is greenBoth re-reviews PASS on head
|
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: falseincluded — and fetchesproject.current()+path.get()behind it via a sharedfetchMetadataBehind()helper. A stale response can't clobber state after a server switch: metadata only lands ifget().client === requestClient. The 8sresolveApi()probe stays before the publish — it chooses which client to build.#186 — SSE liveness + prompt recovery
src/stores/events.ts: newtransport: idle | connecting | live.connectedflips only on the first received event (was: set at dial time); the 10sSTABLE_CONNECTION_MSbackoff-reset timer is gone — backoff now resets only on demonstrated liveness (first event), per the policy shape of 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'ssse-liveness.ts(reference only; 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 not merged, nothing copied from its test files).reconnectNow()is deduplicated — no-op when live or when an attempt is in flight (token-guarded flag, so a stale loop'sfinallycan't clear the flag out from under a new attempt). Foreground resume inapp/_layout.tsxis unchanged and now actually idempotent, as its comment claims.src/lib/sdk-v1.ts: v1 reads get the same 45s idle-timeout wrapper as v2 (SSE_IDLE_TIMEOUT_MS = 45_000, same bound; lead judgment call, v1 heartbeat interval unknown).expo-network(SDK 54,~8.0.8, added vianpx expo install) — it was not a dependency, but it is an Expo SDK module and covers the listener, so no@react-native-community/netinfowas added. The listener lives inevents.tsnext to the existing AppState hook and fires only on the offline → online transition, into the same dedupedreconnectNow(). It consultsisConnectedonly —isInternetReachablewould call LAN/Tailscale servers offline.app/(tabs)/index.tsx:530: the connection dot readstransport— green only when live, amber otherwise (was hardcoded green).Tests
GET /api/eventendpoint (tests/fixtures/mock-opencode-server.ts): same SSE framing/2s heartbeat as/global/event, v2-shaped{type, data}frames (server.connectedon subscribe,session.execution.started/succeededon prompt). The v1 endpoint shares the subscribe plumbing via a newopenSse()helper (verified live:: connected+: ping).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 undernode --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, basee8444931)npm run check:versions— pass (Version metadata aligned: 0.4.17 (44))npm run typecheck— passnpm test(full suite) — 382 pass, 0 fail (baseline on clean main: 380 pass — delta +2, the new v2 SSE tests)Not changed
applicationIdcc.agentlabs.opencodeuntouched (app.json:17,37; android/app/build.gradle:92,94).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) andsrc/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: connectedcomment, and: heartbeatcomment frames. Constants live insrc/lib/sse.ts(SSE_LIVENESS_EVENT_TYPE).src/stores/events.ts: the loop treats any yielded event (liveness included) as proof of bytes — flipsconnected/transportto live, resets the backoff, and runs the once-per-reconnect busy resync + pending refresh on quiet reconnects too. Liveness eventscontinuebefore the session switch, so nothing is dispatched for them. Backoff reset and resync no longer gate on the first session event.client.global.events), as required — see Tests.2. HIGH — hung SSE dial parked
attemptInFlightforever (fixed, both halves)SSE_DIAL_TIMEOUT_MS = 15_000,src/lib/sse.ts): headers must arrive within 15s or the dial aborts with a clearSSE dial timed outerror, the loop unwinds through itsfinally, 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.src/stores/events.ts:reconnectNow()can now abort-and-restart an attempt parked past the dial bound with no byte received (attemptStartedAt+lastByteAttracking);connect()aborts the old controller, unwinding the hung dial.3. MEDIUM —
reconnectNow()no-ops whenlive, so network-restore can't rescue a stale-live half-open stream (fixed)src/stores/events.tstrackslastByteAt(updated on every yielded event, liveness included). Alivestream with no byte for 30s (SSE_STALE_LIVE_MS, two missed v2 heartbeats, under the 45s watchdog) is stale, andreconnectNow()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 leftconnected: truewhiletransport: "connecting"(fixed)src/stores/events.ts:connect()now setsconnected: falsealongsidetransport: "connecting".LOW 6 —
setActiveConnectionbreadcrumb delayed/losable (fixed)src/stores/connections.ts: the breadcrumb now fires at publish time (unconditional,hasProject: false— unknown at publish), not inside thefetchMetadataBehindapply callback, so a slow/offline server no longer loses it.Tests (+3, existing file only — no new test files)
src/lib/session-list.integration.test.tsonly: quiet v2 stream → generator yields onlysse.liveness; quiet v1 stream → same; lifecycle events still flow through the generator (session.status+ liveness both arrive). The generator tests drive the REALclient.global.eventsagainst the mock server.sdk.tsimportsexpo/fetch, which has no resolver outside Metro, so the SDK could not be imported undernode --testat all. The test file registers anode:moduleresolve hook (inlinedata:URL, no new files, no test-script change) redirectingexpo/fetchto the global fetch and resolving the SDKs' extensionless Metro imports to.ts.mock.modulewas tried first and is not available — it needs--experimental-test-module-mocks, which the test script does not pass.Filed notes from the bug review (not expanded)
refreshProject's unconditional set andremoveConnection's stale metadata are pre-existing; left untouched, as instructed.expo-networkand AppState listeners live for the app lifetime and there is no teardown site; capturing the handle would be dead code.readWithIdleTimeoutread: verified from code — aborting the controller rejects the pendingread(), 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, basee8444931, main unmoved)npm run check:versions— pass (Version metadata aligned: 0.4.17 (44))npm run typecheck— passnpm test(full suite) — 385 pass, 0 fail (was 382 — delta +3, the new generator-level tests; clean-main baseline 380)2ba4e92.