zebra-spaces: SubscribeFSM — formalises renegotiation queue + reconnect

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.
This commit is contained in:
Russell Ballestrini 2026-06-02 11:03:48 -04:00
parent ebda460574
commit 57013ec9ad
No known key found for this signature in database
2 changed files with 224 additions and 5 deletions

View file

@ -821,6 +821,90 @@ const publishSpec = {
},
};
/* ==================================================================
* SubscribeFSM — one instance for the SFU subscribe leg.
*
* off ──START──▶ connecting ──CONNECTED──▶ subscribed
* ▲ │ FAILED │ RENEG (SSE offer arrives)
* │ ▼ ▼
* │ off renegotiating
* │ │ RENEG_DONE / RENEG_FAILED
* │ ▼
* │ subscribed
* │ │ LOST (SSE drop)
* │ ▼
* │ reconnecting ──CONNECTED──▶ subscribed
* │ │
* │ STOP
* │ ▼
* └────────────── DONE ──────────────── stopping
*
* Renegotiation lives in its own state so RENEG offers queued during an
* in-flight one can't race setRemoteDescription (the bug we hit in
* commit ae9721e). When RENEG fires while already renegotiating, the
* spec parks the SDP on ctx.pendingOffers; the runtime drains the queue
* on RENEG_DONE via an observer, so the FSM itself stays pure. */
const subscribeSpec = {
initial: 'off',
context: { pc: null, peerID: null, events: null, lastError: null, pendingOffers: [] },
states: {
off: {
entry: (ctx) => { ctx.pc = null; ctx.peerID = null; ctx.events = null; ctx.pendingOffers = []; },
on: { START: 'connecting' },
},
connecting: {
on: {
CONNECTED: {
target: 'subscribed',
action: (ctx, ev) => {
if (ev.payload){
ctx.pc = ev.payload.pc || ctx.pc;
ctx.peerID = ev.payload.peerID || ctx.peerID;
ctx.events = ev.payload.events || ctx.events;
}
},
},
FAILED: { target: 'off', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
STOP: 'off',
},
},
subscribed: {
on: {
RENEG: {
target: 'renegotiating',
action: (ctx, ev) => { const sdp = ev.payload && ev.payload.sdp; if (sdp) ctx.pendingOffers.push(sdp); },
},
LOST: 'reconnecting',
STOP: 'stopping',
},
},
renegotiating: {
on: {
/* extra offers queue up; we re-enter renegotiating so observers
* can see the transition but the existing run continues */
RENEG: {
target: 'renegotiating',
action: (ctx, ev) => { const sdp = ev.payload && ev.payload.sdp; if (sdp) ctx.pendingOffers.push(sdp); },
},
RENEG_DONE: 'subscribed',
RENEG_FAILED: { target: 'subscribed', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
LOST: 'reconnecting',
STOP: 'stopping',
},
},
reconnecting: {
on: {
CONNECTED: 'subscribed',
FAILED: { target: 'off', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
STOP: 'stopping',
},
},
stopping: {
on: { DONE: 'off' },
},
},
};
/* ==================================================================
* identity — ed25519 keypair, persisted in localStorage as JWK.
*
@ -2889,8 +2973,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-02</span><br>
md5 <span class="stamp-md5">41b1bef910d4b758a3c3170540b2c4ec</span><br>
sha256 <span class="stamp-sha">c89827a1cf4ddbc122642de2365f0fcf2dc60cae7725a10c64548b4e40c055ff</span><br>
md5 <span class="stamp-md5">fabb116d48d04e00c0d689f14e475ead</span><br>
sha256 <span class="stamp-sha">f449dab274cac4a6ecf36a7329eb759d1ea494789b054e1431408452168004aa</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>