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]>
A display tried each of its network paths once:
- A registration that failed left it continuing from its persisted
state, behind the offline banner, with no realtime, heartbeat or
retry, until someone reloaded it. This is what the production kiosk
showed after a restart.
- A failed state refresh scheduled no further refresh, so a display
whose realtime was also down never fetched state again.
- A realtime stream the host reported closed was never subscribed
again.
Each path now retries on its own backoff: about 1 s, doubling to a
one-minute ceiling, with jitter so a building's screens do not return
in lockstep. There is no attempt limit, because a television has
nobody to press Retry. Each resets when its request works again.
- Registration retries until it works, then brings the display live
exactly as a first-time success does.
- A failed refresh schedules its own retry, with no realtime needed.
- A closed realtime subscription is replaced.
- Stopping clears every retry.
The recovery test's virtual window shrinks from an hour to five
minutes: eight failures back off for at most about 183 s, and an hour
of offline-continuation timers made the test slow without proving more.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Pins what a kiosk needs from each network path, on a virtual clock: a
registration that fails once is retried with backoff until it works, a
state refresh that fails is retried without a realtime nudge, and a
realtime stream the host reports closed is subscribed again. Nothing is
scheduled after stop.
Fails today on all three paths: each is tried once. The first matches
the production screen exactly: mounted, showing "Offline — continuing
from the last update", with no heartbeat and no retry until someone
reloads it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The display controller and the viewer routes each described the
heartbeat their own way and disagreed about one thing: the display sent
null for "nothing to report", and the route accepted only a missing
field. Every heartbeat from a screen showing a native image was refused
with a 400. The roster therefore marked working screens Offline 90 s
after they opened, and never recorded offlineReady or cachedAssetCount.
src/shared/display-protocol.ts now defines every message between a
display and the server:
- viewer session request and response;
- heartbeat request and response;
- channel state;
- the realtime display payload.
Both halves import it. Its rules: a nullable field accepts null
explicitly, an absent field means "not reported", and an unknown field
is ignored, so a newer display is never refused by an older server.
- The routes parse with the shared parsers, and a refusal names the
field with Signage's sanitized code.
- The server's channel state extends the wire type, so a field the
display relies on cannot drift without a compile error.
- The display builds its heartbeat as the shared type, and reads its
registration with the shared parser instead of casting it.
- Test stubs that described server responses more loosely than the
server sends them (no viewerName, no stale, partial media) now match
the real responses and are typed as the protocol.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every display test answered the controller with a hand-written stub, and
every viewer test called the service with a hand-written object, so
neither side was ever tested against the other. In production every
heartbeat was refused with a 400.
This suite answers the real controller's requests with the real viewer
route handlers over the in-memory store. The routes gain an optional
store factory for that; production still uses the module database.
Fails today: POST /viewers/:viewerId/heartbeat -> 400
SIGNAGE_INVALID_REQUEST, because the display sends
reportedContributionId: null and the route accepts only a missing field.
Co-Authored-By: Claude Opus 5.5 <[email protected]>