Skip to content

R5 + R6 + zombie-tone fix — everything left from the review, one PR#50

Open
adyz wants to merge 3 commits into
masterfrom
r5-recheck-memoizare
Open

R5 + R6 + zombie-tone fix — everything left from the review, one PR#50
adyz wants to merge 3 commits into
masterfrom
r5-recheck-memoizare

Conversation

@adyz

@adyz adyz commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Per Adrian: one PR for everything remaining from the post-review refactor plan. Three things, three commits:

1. R5 — offline rechecks re-run no effects; presentation memoized

  • The error state's recovery wait loop moves into substates (backoffofflineRecheck): entry fx and the sound supervisor run once per error cycle; an offline recheck only re-arms its own child timer. Before: a phone sitting offline re-entered error every 10s and re-ran the whole entry pipeline (MediaMetadata rebuild → artwork refetch + widget flicker, 6 handlers re-registered, poster img.src reassigned, supervisor torn down/recreated) — all night. Cadence/backoff values unchanged; the offlineRecheck context flag is gone.
  • updateMediaSession memoizes the presentation (metadata, poster, title) on (state, station title); handler re-registration and playbackState stay unmemoized (iOS resets them — d798cc9).
  • New unit test: 50 offline rechecks → zero new side-effect calls; first online check still recovers.

2. Zombie-tone fix (device-observed on master, born in R4b)

Sometimes the loading tone played under the live radio. carrySound()'s never-trade-audible-for-silent revert ran in a .catch with no generation guard: a late-settling swap rejection after stop() reverted the src and restarted the element — unstoppable, since isPlaying was already false and later stops skipped it. The revert now runs only while still wanted (same generation, not AbortError). Regression test included.

3. R6 — startup off the critical path

  • The ~22 status/station image fetches defer to requestIdleCallback (Safari fallback: 3s) — no longer competing with the stream connect + sound preloads. The precache list stays complete, deliberately: the untouched e2e (and the product) pin all-station posters working offline.
  • sw.js: app-shell cache.put leaves the response path (event.waitUntil, like the cloudinary branch); /downloads/ (2.4MB APK) streams straight through instead of landing in the app cache; APP_CACHE → v4 so existing installs evict the APK.

Verification

80/80 unit, 37/37 e2e untouched, clean typecheck.

Device smoke: (a) phone locked offline in error 5+ min → error tone keeps playing, Now Playing widget no longer flickers every 10s; wifi on → recovers alone; (b) a few wifi off/on cycles during playback → no tone ever left under the stream (the zombie); (c) quick lock-screen prev/next sanity.

🤖 Generated with Claude Code

…te, title)

From the post-review refactor plan (plan.md, R5) — the battery-drain
finding. Old e2e untouched and green (37/37); 79/79 unit.

radioMachine: the error state's wait-for-recovery loop moves into
substates (backoff -> offlineRecheck). Entry fx and the sound supervisor
run ONCE per error cycle; an offline recheck only re-arms its own child
timer (reenter on the atomic child leaves the parent alone). Before, every
10s tick re-entered 'error' and re-ran the whole entry pipeline — new
MediaMetadata (lock-screen artwork refetch + widget flicker), 6 action
handlers re-registered, poster img.src reassigned, supervisor interval
torn down and recreated — all night, for as long as the phone sat offline.
The offlineRecheck context flag and its two actions are gone; the cadence
and backoff values are unchanged (the old cadence unit tests pass as-is).

mediaSession: updateMediaSession memoizes the presentation (MediaMetadata,
poster img.src, document.title, loadingMsg) on (state, station title) —
identical consecutive renders are skipped and cloudinaryImageUrl is
computed once instead of twice. Deliberately NOT memoized: action-handler
re-registration and playbackState — iOS resets those out from under us
(d798cc9), so they re-assert on every call, exactly as before.

New unit test pins the phase's point: 50 offline rechecks produce zero new
side-effect calls (mediaSession/sounds/buttons/supervisor), and the loop
still recovers on the first online check.

NEEDS DEVICE SMOKE before merge: phone offline in error 5+ min, locked —
error tone keeps playing, Now Playing widget no longer flickers every 10s;
wifi back on -> recovers to playing by itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
radio Ready Ready Preview, Comment Jul 4, 2026 7:20am

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

CI Summary

Check Status Result
Typecheck PASS tsc --noEmit clean
Unit tests PASS 80/80 passed; Coverage: Lines 100%, Branches 96.10%, Functions 97.14%, Statements 100%
Build PASS Build completed
Playwright browser PASS Chromium installed
E2E tests PASS 37/37 passed

…ter stop

Device-observed on master (R4b): sometimes the loading tone played UNDER
the live radio — the exact overlapping-sounds class the state machine
exists to forbid. Root cause: carrySound()'s never-trade-audible-for-
silent revert ran in a .catch with NO generation guard. When the swap's
play() settled late (iOS latency) and the machine had meanwhile recovered
to 'playing' (stop already ran: paused, src cleared, isPlaying=false),
the catch reverted the src and RESTARTED the element — and nothing could
stop it again, because isPlaying was already false so every later stop()
skipped it.

The revert now runs only while still wanted: same generation and not an
AbortError (our own stop/reclaim interrupting the pending play). This was
the only unguarded resurrect path in the file — every other catch already
checks the generation.

New unit test pins the repro: stop lands while the swap's play() is in
flight; the late rejection must leave the element dead (no revert, no
restart). 80/80 unit, 37/37 e2e.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adyz

adyz commented Jul 4, 2026

Copy link
Copy Markdown
Owner Author

Also carries the zombie-tone fix (c5e6bc8): a late-settling carry swap rejection used to revert-and-restart the element after stop() — the device-observed "loading tone playing under the live radio" (born in R4b, so it reproduces on master too). The catch is now generation-guarded and ignores AbortError; regression test included. 80/80 unit, 37/37 e2e.

…-blocking SW writes

Same PR as R5 + the zombie fix, per Adrian (one PR for everything left).

- main.ts: the ~22 status/station image fetches no longer compete with the
  stream connection and sound-blob preloads at startup — deferred to
  requestIdleCallback (setTimeout fallback for Safari). The precache LIST
  stays complete, deliberately: the untouched e2e (and the product) pin
  all-station posters working offline; only the timing moves.
- sw.js: the app-shell cache.put leaves the response path (event.waitUntil,
  same pattern as the cloudinary branch) — pages get first bytes without
  waiting for a body download + disk write; /downloads/ (the 2.4MB APK) is
  streamed straight through instead of landing in the app cache; APP_CACHE
  bumped to v4 so existing installs evict the cached APK.
- plan.md: R5/R6 statuses + the deliberate deviations from the initial R6
  sketch (full list kept, page-level put kept — first-load SW claim race).

80/80 unit, 37/37 e2e (image tests untouched and green), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adyz adyz changed the title R5: offline rechecks re-run no effects; presentation memoized R5 + R6 + zombie-tone fix — everything left from the review, one PR Jul 4, 2026
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.

1 participant