Streaming UI wedges after suspend/resume
Symptom (Jonas): start a long agent task on mobile Safari, close Safari, come back a bit later — the stream feed is stuck. Reloading the page "resumes" it correctly. Happens "quite often".
Theories under investigation#
T1 — Dead-transport capture: the stream runtime can never reach a fresh socket (PRIMARY)#
Chain, from code reading (2026-07-11):
apps/os/src/components/project-stream-view.tsx:114-124— the browser stream client factory closes over theuseItx()handle captured at mount:(path) => itx.streams.get(path).apps/os/src/itx/itx-react.tsx— the/apiWebSocket is memoized in a module-level map; the entry is only evicted on acloseevent orreconnectAllItx()(which onlyuseItxSubscriptionwatchdogs call — the feed stack doesn't use that hook).apps/os/src/domains/streams/client-libraries/browser/stream-browser-store.ts:206—acquireStreamRuntimededupes by(projectId, streamPath, slug)and ignores the newcreateStreamClientfactory for an existing runtime.
So when the socket dies (mobile Safari kills TCP during suspend):
- Close event delivered (typical on resume): capnweb breaks the session,
the itx socket map evicts and React gets a fresh itx handle — but the
already-registered stream runtime keeps its mount-time factory, whose stub
rides the broken session. Its liveness probe fails (~2 probe intervals,
~20s),
scheduleReconnect→connect()→createStreamClient→ rejects ("RPC session broken") → reconnect in 1s → rejects → loops forever. - No close event (half-open): every RPC hangs; probe timeouts (2 strikes)
force reconnect, but
connect()awaitsitx.streams.get()on the half-open socket with no deadline (stream-browser-store.ts:583) → parked forever, or falls into the same loop when the close finally lands.
Reload (or SPA navigation away+back, which disposes the runtime at listener refcount 0) creates a fresh runtime with a fresh factory → works. Matches the symptom exactly.
T2 — No proactive resume signal#
stream-browser-store.ts has no visibilitychange/pageshow/online
handlers; detection waits for the 10s-interval probe (which mobile browsers
freeze while backgrounded). Even with T1 fixed, resume-to-recovery latency is
one-to-two probe intervals. (useItxSubscription in itx-react does have
these handlers — the feed stack doesn't.)
T3 — UI-only stall: data lands in SQLite but the feed doesn't repaint/pin#
use-stick-to-bottom.ts re-pins via ResizeObserver only; reactive queries
notify via coalesced 16ms timers. If everything data-side recovers but the
observer chain was frozen, the feed could look stuck while the mirror is
current. Secondary — reload wouldn't be required for data, but user-visible.
T4 — OPFS worker / Web Lock casualties of suspension#
The SQLite mirror lives in a Worker over OPFS; the writer role is a Web Lock. iOS may kill workers or release locks on suspended pages. If the writer-lock election or db worker wedges on resume, ingest fails → reconnect loop interacts with T1.
Plan#
- Failing Playwright repro in
specs/(fixture project + admin itx appends; kill/suspend transport variants: forced WS close, offline/online, CDPPage.setWebLifecycleStatefrozen→active). Prove the wedge in CI-shaped tests, not just on a phone. - Confirm mechanism via
__streamRuntimeDebug()instrumentation. - Fix (likely: per-call fresh transport resolution + runtime factory refresh on re-acquire + transport-reset escalation on repeated connect failures + visibility fast-probe), keeping the repro specs as regression tests.
- Verify against a preview deployment, including a real phone-shaped run.
Journey log#
- 2026-07-11: Theories drafted from code reading; T1 confirmed statically (factory capture + registry dedupe + eviction rules). Repro specs next.
- 2026-07-11 (later): T1 confirmed empirically on local dev via
specs/stream-resume-after-suspend.spec.ts:- control (healthy append → delivery): PASS, 6.5s.
- clean WS close: FAIL as predicted — probe notices at ~20s
(
connection failed its liveness probe; reconnecting Error: Peer closed WebSocket: 1005), then both runtimes wedge forever inconnectionStatus: "reconnecting",connectionError: "connect failed: Peer closed WebSocket: 1005",hasConnection: false; server-appended marker never delivered in 60s. - freeze (CDP
Page.setWebLifecycleState) + offline + thaw: FAIL with the identical wedge signature. The itx socket map re-dials fine (page chrome recovers); only the stream runtimes stay dead — precisely the reported UX.
- 2026-07-11 (fix): adversarial audit CONFIRMED T1 on all five axes (capnweb
abort is terminal + no auto-reconnect; nothing remounts the view; React
keeps uSES subscriptions across Suspense so no accidental dispose-heal;
composer sends SUCCEED via fresh
connectItxBrowserwhile the feed stays wedged — the crueler UX;unsubscribe()on a broken session rejects async, no sync-throw hazard). T3 REFUTED (repaint chain sound; multi-tab follower-of-a-frozen-writer is a real but separate lane). T4 PARTIAL (db worker death is undetected — no onerror — separate latent hazard). Fix shipped: per-call transport resolution (view + admin source), factory refresh on re-acquire, 15s dial deadline, StepTimeoutError-classified transport eviction viaresetTransport(wired toreconnectItx(address)), probe rejections reconnect on first hit, resume listeners (visibilitychange/online/pageshow → immediate nudge/reconnect), escalating connect backoff. All four specs green locally (clean close ~30s recovery, freeze ~56s, half-open ~40s — the mute-the-socket trick simulates a suspend-killed TCP connection with no close frame). - 2026-07-12 (thermo hardening): two thermo-nuclear reviews (structure +
edge-case lenses), both NOT APPROVED first pass; all blockers fixed:
- Election-chain timeouts now evict (a cached corpse "dials" instantly, so
the election is where a not-subscribed half-open death manifests —
previously an eviction-free 16s reconnect loop forever). One
reconnectAfterErrordecision point replaces the scatteredinstanceof StepTimeoutErrorchecks. reconnectItxnow CLOSES the WebSocket (it disposed only the derived stub; capnweb tears a session down on transport close only) — pending composer sends/queries reject instead of hanging on a ghost session, and evicted sockets no longer leak.- New
evictItxSocket(young-socket guard, <15s can't be the corpse) prevents the two runtimes' staggered strikes from killing each other's fresh dials. - Followers (Web-Lock losers, no probe) now recover:
callWhenReadywraps calls in a 20s deadline + broken-session classification that clears the corpse, andonResumegives followers a two-strike transport check. - Nudge held to the probe's two-strike standard (single 5s timeout no
longer rips the shared socket);
pageshowno longer restarts a young in-flight dial; stream stubs get their real dispose (cap-table leak). - Transport = ONE value ({createStreamClient, resetTransport}) refreshed wholesale on re-acquire via the registry (off the public store type). All specs green incl. new composer-send-after-recovery assertion.
- Election-chain timeouts now evict (a cached corpse "dials" instantly, so
the election is where a not-subscribed half-open death manifests —
previously an eviction-free 16s reconnect loop forever). One