diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index d662815..f9a568b 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -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');