fix(display): keep one object URL per cached slide, and give them back #6

Merged
Zayin_Admin merged 3 commits from fix/display-media-object-urls into main 2026-10-06 02:56:01 +00:00
Member

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.

## 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.
Zayin_Admin added 3 commits 2026-10-06 02:45:54 +00:00
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]>
ci: run stream-recycle on every change
CI / lint (pull_request) Successful in 13s
CI / typecheck (pull_request) Successful in 15s
CI / test (pull_request) Successful in 20s
CI / build (pull_request) Successful in 19s
CI / integration (pull_request) Successful in 5m41s
e3c5e546eb
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]>
Zayin_Admin force-pushed fix/display-media-object-urls from 83ad66fd9a to e3c5e546eb 2026-10-06 02:45:54 +00:00 Compare
Zayin_Admin scheduled this pull request to auto merge when all checks succeed 2026-10-06 02:54:53 +00:00
Zayin_Admin merged commit 525335e6dd into main 2026-10-06 02:56:01 +00:00
Zayin_Admin deleted branch fix/display-media-object-urls 2026-10-06 02:56:01 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Signage#6