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.