Live-room defect (2026-06-03): Will refreshed his browser; the
supplant fired a SECOND ontrack for kind=camera pub=Will at +16s.
The page swapped srcObject on the existing <video>, called play(),
got 'fetching process for the media resource was aborted by the
user agent at the user's request' — browser refused to start a new
playback session on the same element after its autoplay grant had
already been consumed. Tile sat black on the moderator's screen.
Fix: swapFreshVideoElement() — on supplant, replace the <video>
with a freshly-built one carrying the same attrs. A brand-new
<video> is eligible for muted-autoplay even when the prior one had
its play() rejected, so the supplant lands cleanly without needing
a tap. Applied to both the thumbnail and the spotlight tile.
Mesh test harness extracts the helper so renderVideoTile still
runs end-to-end under the sandbox.
- moderation: 'boot' button onclick now .catch'es and logs server
errors. Server now returns 'boot target not found (stale uuid?)'
instead of silently no-op'ing when the page's member roster lagged
the room — the moderator was clicking 'boot' and seeing nothing
happen because their page held a stale uuid.
- diagnostic: watchFirstFrame logs 'still black after 2500ms — no
keyframe?' on any fresh SFU video track (screen/camera/game) whose
decoder never unmutes. Pairs with the SFU's extended kfBurst so we
can tell next session which path actually broke when a camera tile
renders black.
multi-peer-mesh test harness extracts the new fn alongside
watchVideoTrackForRemoval so handleRemoteSfuTrack still runs end-to-end
in the headless sandbox.
Stamp date refresh on the other pages (no behavior change).
Receive-side mesh state machine (the bit that decides which peer gets
which tile) was buried inside an anonymous pc.ontrack callback inside
sfuSubscribe(). Extracted into a named top-level function
handleRemoteSfuTrack so tests can drive it directly with synthetic
RTCTrackEvents — no real RTCPeerConnection, no real SFU.
test/multi-peer-mesh.test.js pins fox's stated invariant:
'whatever one device shares all should see, and when unshared none
should see.'
Eight scenarios across 2-3 fake browser sandboxes, each holding the
shipped handleRemoteSfuTrack + renderVideoTile + removeVideoTile +
watchVideoTrackForRemoval + the maps they own:
- one peer publishes camera -> every other peer ends with that pubHex
in cameraStreams + a tile entry
- one peer unshares (track ended) -> every other peer drops that pubHex
- one peer unshares mid-flow (mute past window) -> drops correctly
- hiccup supplant (same pubkey, new track) -> tile preserved AND
pointed at the new stream object (this is the MSID-supplant fix from
d5e9e4e — fresh MediaStream per track means the video element binds
to the new RTP cleanly)
- hiccup supplant + the OLD track's stream-identity guard prevents the
NEW tile from being reaped
- supplant + sustained mute past window on the NEW track -> reaped
correctly
- echo guard: a peer's own publish never enters their own cameraStreams
- three publishers fan-out: A B C all publish, every peer ends with
exactly the other two
Wired into Makefile as test-mesh + added to test-all. Pure Node, no
browser or proxy server needed. Will catch the regressions where one
peer's publish/unpublish silently desyncs another peer's view.
Screen shares and game shares can sit static for long stretches — a still
desktop, a paused video, a code editor with no caret movement. The
encoder genuinely stops emitting RTP, the subscriber's track goes muted,
and the 15s camera window would falsely reap the live tile.
watchVideoTrackForRemoval now takes a per-call windowMs; the sub-PC
ontrack handler passes VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS (120s) for
screen + game and VIDEO_REMOVE_MUTE_WINDOW_MS (15s) for camera. A
genuine unshare still resolves through the 'ended' path within a
frame, so the longer window only affects the slow-failure case.
Tests bumped to 18: new screen-window assertions + invalid-windowMs
fallback to the default rather than disabling removal entirely.
15 unit + integration tests that extract the function and the shipped
VIDEO_REMOVE_MUTE_WINDOW_MS constant from web/zebra-spaces.html so the
assertions track exactly what's deployed. Drives synthetic mute/
unmute/ended event sequences against a fake EventTarget track with a
fake clock injected through a Function-constructor harness.
Unit coverage:
- shipped window must be >= 10s (catches accidental shorten)
- initial mute (never flowed) never removes
- flowing + sustained mute past window removes
- mute + unmute within window cancels removal
- ended event removes immediately + cancels pending timer
- removeFn is idempotent (no double-call across mute, ended, or later
events)
- redundant mute events do not stack timers
- rescue and removal both emit logLine telemetry
- rapid mute/unmute oscillation never removes while unmute lands in time
Integration coverage (realistic lifecycles):
- fresh track -> flow -> publisher unshares -> tile removed
- mobile network handoff (long mute) recovers without removal
- peer leaves abruptly (ended fires) -> tile removed once
- hard refresh of publisher (SFU supplant renegotiation gap) -> tile
survives — this is the cascade fox flagged where a phone reconnect
was killing the host's view of its camera
- publisher process crashes (mute holds indefinitely) -> removed at
window
Wired into Makefile as test-video-removal + added to test-all. Pure
Node, no browser or proxy server needed.
Step five — composition layer. wireZebraMachines() returns a coherent
room:
- one CallFSM
- one SubscribeFSM
- three PublishFSMs (mic / screen / camera)
- lazy Map of RemoteTileFSMs created on first tileFor(kind, pubHex)
- tileLeft(pubHex) fans LEFT to every tile keyed by that publisher
Observers wire transitions between machines but the orchestrator
itself stays pure — no WebRTC, no DOM, no fetch. The page's runtime
layers its OWN observers on top to drive real side effects, and the
test extracts the orchestrator directly.
Cascades modelled:
- CallFSM joined (from anything except reconnecting) ── starts the sub
- CallFSM reconnecting → joined does NOT re-START (sub stayed alive)
- CallFSM leaving / booted ── stops sub AND every live publish
- RemoteTileFSMs lazy: tileFor returns the same instance per key
- tileLeft sends LEFT to every kind for that pubHex
+ 11 integration tests + 1 full end-to-end scenario walking through
host publishes mic+screen / listener joins late / listener sees the
screen / host unshares / mute+prune cycle removes the tile / listener
leaves and sub stops.
Total: 83 tests passing. The pure-FSM layer + orchestrator are now
ready to be wired into the imperative call sites in the live runtime.
That's the next step — gradually replace the firefighting code paths
(sfuPublishCamera, sfuSubscribe, role transitions) by feeding events
into these machines from the existing handlers, then observing
state changes to invoke the side effects. Tests catch regressions
on the pure layer while the QA loop catches what touches the wire.
Fourth state machine. Orchestrates the per-leg FSMs:
idle ──ENTER──▶ connecting ──WELCOME──▶ joined ──LEAVE──▶ leaving ──DONE──▶ idle
▲ │ FAILED │ ▲
│ ▼ │ WS_DROPPED │
│ idle ▼ │
│ reconnecting ──WELCOME──▶ joined │
│ │ LEAVE / FAILED │
│ ▼ │
│ leaving ────────────────────────────── ┘
│ ▲
│ │ ACK
└─────────────────────────────────── booted ◀── BOOTED ── (any live state)
Role lives in ctx (host / cohost / speaker / listener). ROLE_CHANGE
re-enters joined so observers fire on every promotion / demotion —
that's how the runtime decides whether to start mic+publish or stop
them, without needing a state per role permutation.
reconnecting handles signal-WS drops without tearing down the
SubscribeFSM or PublishFSMs (WebRTC PCs are independent of the WS).
booted is the explicit terminal for being kicked + ACK returns to
idle so the entry screen comes back.
+ 19 unit tests. test-fsm now 72 passed.
Next step: the integration layer — observers on each FSM that drive
the actual side effects, plus integration tests that compose multiple
FSMs (a CallFSM with SubscribeFSM + RemoteTileFSMs) to assert the
multi-machine interactions match what the live code does.
Third state machine. One instance per incoming screen/camera track,
keyed by kind+pubHex. Codifies the lifecycle:
inactive ──TRACK_ARRIVED──▶ receiving ──MUTED──▶ muted
▲ │
│ UNMUTED │ PRUNE / ENDED
└────────────────────┤
▼
removed
{receiving, muted} + ENDED → removed
* + LEFT → removed
MUTED is a debounce gate, not a deletion: UNMUTED within the runtime's
~1.5s window cancels the prune and stays receiving (transient network
blip). PRUNE fires from the runtime's setTimeout if still muted.
ENDED skips the debounce. LEFT (peer-left) wipes the tile from any
live state. TRACK_ARRIVED in receiving/muted swaps to the new stream
(publisher re-shared before our prune fired).
Removed is terminal — a re-share spins up a fresh FSM. entry into
removed nulls ctx.stream so the runtime can drop refs.
+ 15 unit tests covering happy path, debounce semantics, ENDED
short-circuit, LEFT from every state, re-share refresh, removed
terminality. test-fsm now reports 53 passed.
Second state machine. Models the SFU subscribe leg explicitly:
off ──START──▶ connecting ──CONNECTED──▶ subscribed
│ FAILED │ RENEG
▼ ▼
off renegotiating
│ RENEG_DONE / RENEG_FAILED
▼
subscribed
│ LOST
▼
reconnecting ──CONNECTED──▶ subscribed
│ STOP │ FAILED
▼ ▼
stopping off
│ DONE
▼
off
Renegotiation is its own state so concurrent SSE offers can't race
setRemoteDescription (the bug ae9721e patched imperatively with a
promise queue). A RENEG event during renegotiating parks the SDP on
ctx.pendingOffers; the runtime will drain that queue from an observer
when RENEG_DONE fires. RENEG_FAILED returns to subscribed without
killing the PC — the negotiation attempt is what failed, the channel
itself is still up.
LOST during renegotiating jumps straight to reconnecting (drops the
in-flight reneg cleanly; when the connection comes back the runtime
will re-deliver any still-relevant SDP via fresh RENEGs).
+ 15 new unit tests covering connect, queue, drops, teardown, illegal
transitions. test-fsm now reports 38 passed.
First step of the state-machine refactor. Same self-contained pattern
as the rest of the page — FSMs live inline in web/zebra-spaces.html so
the page-integrity stamp keeps working, and the tests extract them with
the same regex/brace-match technique web-protocol.test.js already uses
(page = source of truth, tests track the page).
Added:
- createFSM(spec): minimal state machine. spec.states[name] has optional
entry/exit hooks and an .on table mapping events → target (string) or
{ target, action }. Observers fire after each transition with
{ state, prev, ev, ctx }. No async in transitions; effects belong in
observers (which can call send() to advance the machine).
- publishSpec: pure transition table for the publish flow.
off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
│ FAILED │ FAILED │ STOP/LOST
▼ ▼ ▼
off stopping ◀──── stopping ──┘
│ DONE
▼
off
One instance per kind (mic / screen / camera). FAILED in negotiating
goes to stopping (not off) so any acquired stream/pc gets torn down.
- test/zebra-fsm.test.js: 23 unit tests covering framework semantics +
publishSpec happy path + error/cancel paths + illegal-transition
no-ops. Function-constructor scope handles const-leak; bare eval()
doesn't expose const declarations to the harness.
- Makefile: test-fsm target + included in test-all.
Next: SubscribeFSM, CallFSM, RemoteTileFSM. Then wire each into the
imperative call sites progressively, replacing the firefighting code.
test/zebra-spaces.test.js — pure Node, four tiers:
1. pure protocol parity: extracts sigJoin/sigAction directly from
web/zebra-spaces.html (so the test tracks the shipped page),
compares produced bytes against fixtures pinned to the Go-side
unit tests in proxy.unturf.com/cmd/zebra-spaces-signal/main_test.go.
If JS drifts from Go by one byte the test fails — exactly the
silent break that would kill promotions in production.
2. ed25519 sign/verify: WebCrypto Ed25519 round-trip + tamper detection,
the same crypto stack the page uses for signed role transitions.
3. vault round-trip: PBKDF2 600k + AES-GCM, mirrors vaultExport/Import
in the page. Verifies wrong-password rejection.
4. live server (optional): if ZEBRA_SPACES_BINARY is set, launches the
relay, dials over real WebSocket, drives full join -> mic-invite ->
accept flow using browser APIs end to end.
Makefile: 'test-zebra-spaces' target auto-builds the relay binary
from ../proxy.unturf.com when present so the live tier runs without
manual setup. 'test-all' now includes it.
Add test/web-protocol.test.js — runs the real protocol code from chat.html in
Node (unit: crc/frame/ACK/HELLO codec, Hamming, Gray; integration: multi-level
modem roundtrip + FEC recovery of off-by-one symbol errors; functional: full
frame -> modem -> FEC -> assembler -> parse + ACK roundtrip). 3348 assertions.
Wired as `make test-web` (also in test-all). Lets us QA the modem without two
devices. Also: retransmit now uses exponential backoff so a lost ACK spaces out
retries instead of hammering the channel.