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]>
Both failed on main before this PR: a display that failed its first
viewer-session request, or its first realtime connection, never
recovered without a reload. With the retries in place they pass, and
CI now holds them there.
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]>
Signage had no CI. Its tests passed while three defects shipped that
appear only inside Ekklesia, in a real browser. Every job now assembles
Signage into a pinned platform commit, as an instance does, and
installs that workspace with --frozen-lockfile:
- lint, typecheck, test: Signage's own checks;
- build: the platform's gates against exactly this module set (boundary
rules, module manifest, migrations), then the build;
- integration: the deployment image from the platform's Dockerfile,
then the platform's split-topology smoke. It pairs a kiosk display,
restarts it on the same profile, and runs the scenarios in
ci/display-scenarios.
The supporting files:
- ci/platform.env pins one platform commit. ci/assemble.sh fetches the
platform's main and refuses a pinned commit that is not on it, so CI
only runs reviewed platform code.
- ci/pnpm-lock.yaml is the assembled workspace's lockfile, regenerated
with ci/update-lock.sh.
Same trust rules as the platform: actions pinned by SHA,
persist-credentials false, no secrets, no pull_request_target.
Co-Authored-By: Claude Opus 5.5 <[email protected]>