web: state goes stale after iOS PWA resume — resync on SSE reconnect (liveResource wrapper + always-fresh wake) #54

Closed
opened 2026-07-08 21:01:30 +02:00 by dominik.polakovics · 1 comment

Problem

The UI often stops updating until a manual reload — painful as an iOS PWA where reload is awkward. Root cause (traced, not speculative):

  1. Reconnect ≠ resync. The event bus is in-process, fire-and-forget: no event IDs, no replay (internal/events/bus.go, internal/httpapi/sse.go). When iOS backgrounds the PWA the socket dies and every event in that window is lost forever. On foreground wake() reconnects the stream (web/src/sse.ts) and the live dot goes green — but nothing refetches: the connected signal only drives the dot. A run that finished while the phone was locked stays "working" on screen until some future event happens to touch it; for an ended run that can be never.
  2. Dead-but-green window. Backgrounded under 65s, wake() declines to force-cycle (sse.ts STALE_MS gate), so a socket iOS already killed can sit "green" for up to ~65s after resume until the watchdog fires — and then still no refetch (see 1).

Not in scope: changes made directly on the Forgejo UI produce no lab event at all (only mutations through lab/labctl publish). That is a separate feature (webhooks/poller) and explicitly out of scope here. No server changes in this issue.

Design (resolved with operator)

  • Resync on reconnect: sse.ts dispatches a client-synthesized resync pseudo-event on every onopen except the first of the app session. It never comes over the wire — do not add it to EVENT_TYPES / the EventSource listener loop; type it as LabEventType | 'resync' on the subscribe surface.

  • Always-fresh wake: on visibilitychange→visible (plus window pageshow and focus listeners as iOS belt-and-braces, removed in close()), force-cycle the stream via fail(source, true) unless an event (heartbeats count) arrived within the last ~10s (FRESH_MS = 10_000 — replaces the STALE_MS comparison in wake() only; the 65s silent-death watchdog stays as backstop). The 10s guard is the anti-flap: a fresh open sets lastEventAt, so rapid app-switcher peeks don't thrash. Net effect: returning to the app always yields a fresh stream + resync burst (~6 GETs — rate limiting is login-only, negligible for a single operator).

  • createLiveResource wrapper (new web/src/lib/liveResource.ts): owns createResource + event subscriptions + resync so future views cannot forget resync. Signature mirrors createResource (same return tuple, so call sites keep [data, { refetch }]), plus specs:

    interface LiveEventSpec {
      type: LabEventType;
      match?: (event: LabEvent) => boolean; // absent = always
      debounceMs?: number;                   // trailing-edge coalesce
    }
    

    Behavior: matching event → (debounced) refetch; resync → immediate unconditional refetch that cancels any pending debounce; all subscriptions and timers cleaned up via onCleanup. Uses useEvents() internally (must be called in a component root). Centralize the solid/reactivity eslint escape the raw handlers currently carry — match predicates may read params/props fresh per event.

Migration (hybrid, all in one PR)

Move every plain resource+subscribe site to the wrapper:

  • components/AppShell.tsx (ShellFrame instances): run.changed unscoped; run.messages.changed unscoped with debounceMs: MESSAGES_DEBOUNCE_MS.
  • routes/Repos.tsx: repos ← repo.changed; instances ← run.changed; RepoCard CRs ← cr.changed scoped event.repoID === props.repoID.
  • routes/Credentials.tsx: repo.changed → repos; run.changed → instances.
  • routes/NewRun.tsx: repo.changed → repos; claude.auth.changed → auth.
  • components/ClaudeAuthCard.tsx: claude.auth.changed.
  • routes/History.tsx: run.changed unscoped.
  • routes/RepoIssues.tsx: three resources (issues, ready, labels) each on issue.changed scoped to params.id; the labels one additionally gated on builtin binding in its match.
  • routes/IssueDetail.tsx, routes/RepoLabels.tsx: same pattern as above.
  • routes/RepoCRs.tsx, routes/CRDetail.tsx: cr.changed scoped.
  • routes/RepoSettings.tsx: repo.changed scoped.
  • components/ParkedSection.tsx: parked.changed scoped.
  • components/AFKStrip.tsx: one resource, specs = issue.changed (scoped) + run.changed (unscoped) + parked.changed (scoped).

Deliberately bespoke (keep raw subscriptions, add an explicit subscribe('resync', …) line and a comment pointing at the wrapper):

  • routes/RunChat.tsx stays raw wholesale — the message accumulator is not a resource (cursor/seq-merge + fetch tokens, ADR-0016). Add resyncvoid refetchRun(); void refetchMessages(); alongside the existing run.changed / run.messages.changed handlers. Do not restructure the accumulator or the transcript-rotation logic.
  • stores/cloneProgress.ts untouched — data-bearing events, never fetches. A stale progress entry after an outage is covered because Repos refetches repo.changed state on resync.

Tests (units only — agreed scope; no e2e, no new UI)

  • sse.test.ts: resync fires on 2nd open, not 1st; visible-wake force-cycles when lastEventAt > FRESH_MS even while nominally connected; no cycle under FRESH_MS; pageshow/focus trigger wake; close() removes the new listeners; watchdog behavior unchanged.
  • lib/liveResource.test.ts: refetch on matching event; skip on non-matching; debounce coalesces; resync refetches unconditionally and cancels a pending debounce; dispose unsubscribes and clears timers.
  • Existing route tests must keep passing (preserve their event-injection seams).

Acceptance

  • Background/lock the PWA across a run-state change; on foreground the rail and an open chat reflect reality within ~1–2s without reload.
  • The green dot is never shown alongside data staler than ~10s after foregrounding.
  • npm test, lint, and npm run build green in web/. No Go changes.
## Problem The UI often stops updating until a manual reload — painful as an iOS PWA where reload is awkward. Root cause (traced, not speculative): 1. **Reconnect ≠ resync.** The event bus is in-process, fire-and-forget: no event IDs, no replay (`internal/events/bus.go`, `internal/httpapi/sse.go`). When iOS backgrounds the PWA the socket dies and every event in that window is lost forever. On foreground `wake()` reconnects the stream (`web/src/sse.ts`) and the live dot goes green — but **nothing refetches**: the `connected` signal only drives the dot. A run that finished while the phone was locked stays "working" on screen until some *future* event happens to touch it; for an ended run that can be never. 2. **Dead-but-green window.** Backgrounded under 65s, `wake()` declines to force-cycle (`sse.ts` STALE_MS gate), so a socket iOS already killed can sit "green" for up to ~65s after resume until the watchdog fires — and then still no refetch (see 1). Not in scope: changes made directly on the Forgejo UI produce no lab event at all (only mutations through lab/labctl publish). That is a separate feature (webhooks/poller) and explicitly **out of scope** here. No server changes in this issue. ## Design (resolved with operator) - **Resync on reconnect**: `sse.ts` dispatches a client-synthesized `resync` pseudo-event on every `onopen` **except the first** of the app session. It never comes over the wire — do not add it to `EVENT_TYPES` / the EventSource listener loop; type it as `LabEventType | 'resync'` on the subscribe surface. - **Always-fresh wake**: on `visibilitychange→visible` (plus `window` `pageshow` and `focus` listeners as iOS belt-and-braces, removed in `close()`), force-cycle the stream via `fail(source, true)` unless an event (heartbeats count) arrived within the last ~10s (`FRESH_MS = 10_000` — replaces the STALE_MS comparison in `wake()` only; the 65s silent-death watchdog stays as backstop). The 10s guard is the anti-flap: a fresh open sets `lastEventAt`, so rapid app-switcher peeks don't thrash. Net effect: returning to the app always yields a fresh stream + resync burst (~6 GETs — rate limiting is login-only, negligible for a single operator). - **`createLiveResource` wrapper** (new `web/src/lib/liveResource.ts`): owns `createResource` + event subscriptions + resync so future views cannot forget resync. Signature mirrors `createResource` (same return tuple, so call sites keep `[data, { refetch }]`), plus specs: interface LiveEventSpec { type: LabEventType; match?: (event: LabEvent) => boolean; // absent = always debounceMs?: number; // trailing-edge coalesce } Behavior: matching event → (debounced) refetch; `resync` → immediate unconditional refetch that cancels any pending debounce; all subscriptions and timers cleaned up via `onCleanup`. Uses `useEvents()` internally (must be called in a component root). Centralize the `solid/reactivity` eslint escape the raw handlers currently carry — `match` predicates may read params/props fresh per event. ## Migration (hybrid, all in one PR) Move every plain resource+subscribe site to the wrapper: - `components/AppShell.tsx` (ShellFrame instances): `run.changed` unscoped; `run.messages.changed` unscoped with `debounceMs: MESSAGES_DEBOUNCE_MS`. - `routes/Repos.tsx`: repos ← `repo.changed`; instances ← `run.changed`; RepoCard CRs ← `cr.changed` scoped `event.repoID === props.repoID`. - `routes/Credentials.tsx`: `repo.changed` → repos; `run.changed` → instances. - `routes/NewRun.tsx`: `repo.changed` → repos; `claude.auth.changed` → auth. - `components/ClaudeAuthCard.tsx`: `claude.auth.changed`. - `routes/History.tsx`: `run.changed` unscoped. - `routes/RepoIssues.tsx`: three resources (issues, ready, labels) each on `issue.changed` scoped to `params.id`; the labels one additionally gated on builtin binding in its `match`. - `routes/IssueDetail.tsx`, `routes/RepoLabels.tsx`: same pattern as above. - `routes/RepoCRs.tsx`, `routes/CRDetail.tsx`: `cr.changed` scoped. - `routes/RepoSettings.tsx`: `repo.changed` scoped. - `components/ParkedSection.tsx`: `parked.changed` scoped. - `components/AFKStrip.tsx`: one resource, specs = `issue.changed` (scoped) + `run.changed` (unscoped) + `parked.changed` (scoped). **Deliberately bespoke** (keep raw subscriptions, add an explicit `subscribe('resync', …)` line and a comment pointing at the wrapper): - `routes/RunChat.tsx` stays raw wholesale — the message accumulator is not a resource (cursor/seq-merge + fetch tokens, ADR-0016). Add `resync` → `void refetchRun(); void refetchMessages();` alongside the existing `run.changed` / `run.messages.changed` handlers. Do not restructure the accumulator or the transcript-rotation logic. - `stores/cloneProgress.ts` untouched — data-bearing events, never fetches. A stale progress entry after an outage is covered because Repos refetches `repo.changed` state on resync. ## Tests (units only — agreed scope; no e2e, no new UI) - `sse.test.ts`: resync fires on 2nd open, not 1st; visible-wake force-cycles when lastEventAt > FRESH_MS even while nominally connected; no cycle under FRESH_MS; `pageshow`/`focus` trigger wake; `close()` removes the new listeners; watchdog behavior unchanged. - `lib/liveResource.test.ts`: refetch on matching event; skip on non-matching; debounce coalesces; resync refetches unconditionally and cancels a pending debounce; dispose unsubscribes and clears timers. - Existing route tests must keep passing (preserve their event-injection seams). ## Acceptance - Background/lock the PWA across a run-state change; on foreground the rail and an open chat reflect reality within ~1–2s without reload. - The green dot is never shown alongside data staler than ~10s after foregrounding. - `npm test`, lint, and `npm run build` green in `web/`. No Go changes.
Author
Owner

This was generated by AI while landing a PR.

Land-PR audit for PR #59 (afk/54main, head 553ff8f)

Verdict: PASS

Checked:

  • CI (relied-on signal): required check ci / native (pull_request) is green on the head SHA (6m10s) — covers web/ vitest, eslint, prettier, and vite build, exactly the touched surface. ci-nix correctly did not run: the diff touches no *.nix/go.mod/go.sum, and per ADR-0023 web-only diffs cannot rot the nix build. Nothing was re-run.
  • Mergeability: merges clean into main (verified via git merge-tree), single commit.
  • Conventions: Conventional Commits title ✓; AFK contract ✓ (head afk/54, working Closes #54).
  • Diff review vs issue #54: sse.ts resync-on-reconnect (skips first open, not in EVENT_TYPES, no backoff reset) and FRESH_MS=10s always-fresh wake with pageshow/focus listeners removed in close() — matches the resolved design verbatim, 65s watchdog retained. createLiveResource implements both arities with tuple passthrough (incl. mutate), per-spec debounce timers, and unconditional resync refetch that cancels pending debounces; all teardown via onCleanup. All 15 migration sites preserve their original scoping predicates (incl. ProviderAuthCard's defensive no-provider match, RepoIssues' builtin-gated labels resource, AppShell's messages debounce). RunChat stays raw with hand-wired resync; cloneProgress.ts untouched — both as the issue prescribes. One benign divergence: the issue's ClaudeAuthCard/claude.auth.changed references predate the provider-seam generalization (#53); the PR correctly targets the renamed ProviderAuthCard/provider.auth.changed.
  • Tests: +18 units covering resync-on-2nd-open, FRESH_MS cycling/anti-flap, listener removal, backoff preservation, match/debounce/resync/dispose/arity/tuple behavior.

No blockers found. Awaiting explicit human confirmation to merge.

> *This was generated by AI while landing a PR.* **Land-PR audit for PR #59** (`afk/54` → `main`, head `553ff8f`) **Verdict: PASS** Checked: - **CI (relied-on signal):** required check `ci / native (pull_request)` is green on the head SHA (6m10s) — covers web/ vitest, eslint, prettier, and vite build, exactly the touched surface. `ci-nix` correctly did not run: the diff touches no `*.nix`/`go.mod`/`go.sum`, and per ADR-0023 web-only diffs cannot rot the nix build. Nothing was re-run. - **Mergeability:** merges clean into `main` (verified via `git merge-tree`), single commit. - **Conventions:** Conventional Commits title ✓; AFK contract ✓ (head `afk/54`, working `Closes #54`). - **Diff review vs issue #54:** `sse.ts` resync-on-reconnect (skips first open, not in `EVENT_TYPES`, no backoff reset) and FRESH_MS=10s always-fresh wake with `pageshow`/`focus` listeners removed in `close()` — matches the resolved design verbatim, 65s watchdog retained. `createLiveResource` implements both arities with tuple passthrough (incl. `mutate`), per-spec debounce timers, and unconditional resync refetch that cancels pending debounces; all teardown via `onCleanup`. All 15 migration sites preserve their original scoping predicates (incl. ProviderAuthCard's defensive no-provider match, RepoIssues' builtin-gated labels resource, AppShell's messages debounce). RunChat stays raw with hand-wired resync; `cloneProgress.ts` untouched — both as the issue prescribes. One benign divergence: the issue's `ClaudeAuthCard`/`claude.auth.changed` references predate the provider-seam generalization (#53); the PR correctly targets the renamed `ProviderAuthCard`/`provider.auth.changed`. - **Tests:** +18 units covering resync-on-2nd-open, FRESH_MS cycling/anti-flap, listener removal, backoff preservation, match/debounce/resync/dispose/arity/tuple behavior. No blockers found. Awaiting explicit human confirmation to merge.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#54
No description provided.