fix(display): keep one object URL per cached slide, and give them back
Likely cause of the "two tabs flap" and the flaky laptop test.
The Cache Storage media cache made a new URL.createObjectURL on every lookup and never revoked any.
Each live object URL pins its blob in the browser.
Every state refresh looks up every cached slide (24 selections per refresh), so a running display leaked one blob per slide per refresh.
In a real browser (Ekklesia draft PR #56, scenario stream-recycle), after about four minutes the display's own display-state requests failed with net::ERR_INSUFFICIENT_RESOURCES. It then showed its offline banner on a working network, with no remount involved. Two tabs leak twice as fast, and real photographs fill blob storage far sooner than the test's small slides.
This fixes the leak. Whether it fully explains the production flap is for the kiosk run to confirm. The harness also counts live object URLs now, so a rerun will show it directly.
Commits
Failing test first:tests/browser/media-cache.test.ts, with an injected Cache Storage and fetch, counting real create/revoke calls. On main:
303 live URLs for 3 slides after 100 refreshes;
the same bytes get a new URL each time;
nothing is revoked on prune or replace;
there is no release.
The fix:
each cache entry has exactly one object URL, and every lookup reuses it;
pruning or replacing the entry revokes it;
release(), new on SignageMediaCache, revokes all of them, and the controller calls it on stop() after the stage is destroyed;
two racing lookups of one slide keep the first URL and make no second;
the in-memory cache's release() is a no-op.
Revoking a URL does not blank an image already decoded on screen. Prune still runs only after a new state is usable, so it removes only slides that state no longer contains.
Verification
Signage has no CI. Run locally in an Ekklesia main (= v0.1.1) workspace: test 203 passed, plus typecheck and lint, all clean.
No conflicts. stop() now clears #5's retry timers and then releases this PR's object URLs, after the stage is destroyed.
A third commit adds stream-recycle to ci/display-scenarios, so CI holds the bound on live object URLs (it failed on main). Its 120 s watch moves to the nightly suite in the coming CI-speed PR. Until then it adds about 2 minutes to each Signage PR run.
Local checks, in the CI layout: test 233, typecheck, lint, all pass.
## fix(display): keep one object URL per cached slide, and give them back
**Likely cause of the "two tabs flap" and the flaky laptop test.**
- The Cache Storage media cache made a new `URL.createObjectURL` on every lookup and never revoked any.
- Each live object URL pins its blob in the browser.
- Every state refresh looks up every cached slide (24 selections per refresh), so a running display leaked one blob per slide per refresh.
In a real browser (Ekklesia draft PR #56, scenario `stream-recycle`), after about four minutes the display's own `display-state` requests failed with `net::ERR_INSUFFICIENT_RESOURCES`. It then showed its offline banner on a working network, with no remount involved. Two tabs leak twice as fast, and real photographs fill blob storage far sooner than the test's small slides.
This fixes the leak. Whether it fully explains the production flap is for the kiosk run to confirm. The harness also counts live object URLs now, so a rerun will show it directly.
### Commits
1. **Failing test first:** `tests/browser/media-cache.test.ts`, with an injected Cache Storage and `fetch`, counting real create/revoke calls. On `main`:
- **303** live URLs for 3 slides after 100 refreshes;
- the same bytes get a new URL each time;
- nothing is revoked on prune or replace;
- there is no release.
2. **The fix:**
- each cache entry has exactly **one** object URL, and every lookup reuses it;
- pruning or replacing the entry revokes it;
- `release()`, new on `SignageMediaCache`, revokes all of them, and the controller calls it on `stop()` after the stage is destroyed;
- two racing lookups of one slide keep the first URL and make no second;
- the in-memory cache's `release()` is a no-op.
Revoking a URL does not blank an image already decoded on screen. Prune still runs only after a new state is usable, so it removes only slides that state no longer contains.
### Verification
Signage has no CI. Run locally in an Ekklesia `main` (= v0.1.1) workspace: `test` 203 passed, plus `typecheck` and `lint`, all clean.
### Rebased onto `main` after #5
No conflicts. `stop()` now clears #5's retry timers and then releases this PR's object URLs, after the stage is destroyed.
A third commit adds `stream-recycle` to `ci/display-scenarios`, so CI holds the bound on live object URLs (it failed on `main`). Its 120 s watch moves to the nightly suite in the coming CI-speed PR. Until then it adds about 2 minutes to each Signage PR run.
Local checks, in the CI layout: test 233, typecheck, lint, all pass.
The media cache minted a new object URL on every lookup and never
revoked one. Each state refresh looks up every cached slide, so a
display leaked one pinned blob per slide per refresh. In a real browser
(Ekklesia draft PR #56, scenario stream-recycle) its own state requests
then failed with net::ERR_INSUFFICIENT_RESOURCES within about four
minutes, and it showed its offline banner on a working network.
Fails today: 303 live URLs for 3 slides after 100 refreshes. The same
bytes get a new URL each time, nothing is revoked on prune or replace,
and there is no release.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Cache Storage media cache minted a new object URL on every lookup
and never revoked any. Every state refresh looks up every cached slide,
so a running display pinned one more blob per slide per refresh until
the browser refused its requests with net::ERR_INSUFFICIENT_RESOURCES.
It then showed its offline banner on a working network. Two tabs leaked
twice as fast, and real photographs fill blob storage far sooner than
test slides.
Each cache entry now has exactly one object URL:
- every lookup reuses it;
- pruning or replacing the entry revokes it;
- release(), new on SignageMediaCache, revokes all of them, and the
controller calls it on stop once the stage is gone.
Two lookups of one slide racing each other keep the first URL and make
no second one. The in-memory cache has nothing to give back.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
It failed on main before this PR: the media cache leaked one blob per
slide per refresh until the browser refused the display's own requests.
With one object URL per slide it holds three live URLs for three slides,
and CI now keeps it there, together with "stays live for the whole watch".
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
fix(display): keep one object URL per cached slide, and give them back
Likely cause of the "two tabs flap" and the flaky laptop test.
URL.createObjectURLon every lookup and never revoked any.In a real browser (Ekklesia draft PR #56, scenario
stream-recycle), after about four minutes the display's owndisplay-staterequests failed withnet::ERR_INSUFFICIENT_RESOURCES. It then showed its offline banner on a working network, with no remount involved. Two tabs leak twice as fast, and real photographs fill blob storage far sooner than the test's small slides.This fixes the leak. Whether it fully explains the production flap is for the kiosk run to confirm. The harness also counts live object URLs now, so a rerun will show it directly.
Commits
tests/browser/media-cache.test.ts, with an injected Cache Storage andfetch, counting real create/revoke calls. Onmain:release(), new onSignageMediaCache, revokes all of them, and the controller calls it onstop()after the stage is destroyed;release()is a no-op.Revoking a URL does not blank an image already decoded on screen. Prune still runs only after a new state is usable, so it removes only slides that state no longer contains.
Verification
Signage has no CI. Run locally in an Ekklesia
main(= v0.1.1) workspace:test203 passed, plustypecheckandlint, all clean.Rebased onto
mainafter #5No conflicts.
stop()now clears #5's retry timers and then releases this PR's object URLs, after the stage is destroyed.A third commit adds
stream-recycletoci/display-scenarios, so CI holds the bound on live object URLs (it failed onmain). Its 120 s watch moves to the nightly suite in the coming CI-speed PR. Until then it adds about 2 minutes to each Signage PR run.Local checks, in the CI layout: test 233, typecheck, lint, all pass.
83ad66fd9atoe3c5e546eb