zebra-spaces: CallFSM — top-level join/leave/reconnect/boot lifecycle

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.
This commit is contained in:
Russell Ballestrini 2026-06-02 11:07:55 -04:00
parent 2d75cd7547
commit a29baeee7e
No known key found for this signature in database
2 changed files with 267 additions and 5 deletions

View file

@ -33,14 +33,16 @@ const createFSMSrc = extract(/function createFSM\(/);
const publishSpecSrc = extract(/const publishSpec = /);
const subscribeSpecSrc = extract(/const subscribeSpec = /);
const remoteTileSpecSrc = extract(/const remoteTileSpec = /);
const callSpecSrc = extract(/const callSpec = /);
/* Function-constructor scope so `const` declarations are visible at the
* harness's `return` — they would NOT leak through a bare `eval()`. */
const harness = new Function(
createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' + remoteTileSpecSrc +
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec };'
createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' +
remoteTileSpecSrc + '\n' + callSpecSrc +
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec };'
);
const { createFSM, publishSpec, subscribeSpec, remoteTileSpec } = harness();
const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec } = harness();
let pass = 0, fail = 0;
function test(name, fn){
@ -521,5 +523,180 @@ test('entry into removed nulls the stream so the runtime can drop refs', () => {
eq(m.context.stream, null);
});
console.log('callSpec — join / leave happy path:');
test('starts in idle', () => {
const m = createFSM(callSpec);
eq(m.state, 'idle');
});
test('idle + ENTER → connecting, code+handle stored', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'test-room', handle: 'fox' });
eq(m.state, 'connecting');
eq(m.context.code, 'test-room');
eq(m.context.handle, 'fox');
});
test('connecting + WELCOME → joined, uuid + role stored', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'test', handle: 'fox' });
m.send('WELCOME', { uuid: 'u-1', role: 'host' });
eq(m.state, 'joined');
eq(m.context.uuid, 'u-1');
eq(m.context.role, 'host');
});
test('joined + LEAVE → leaving → idle on DONE', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'host' });
m.send('LEAVE');
eq(m.state, 'leaving');
m.send('DONE');
eq(m.state, 'idle');
});
test('idle entry clears uuid + role + bootedBy', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'host' });
m.send('LEAVE'); m.send('DONE');
eq(m.context.uuid, '');
eq(m.context.role, '');
eq(m.context.bootedBy, null);
});
console.log('callSpec — role transitions stay in joined:');
test('joined + ROLE_CHANGE updates role and stays in joined', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'listener' });
m.send('ROLE_CHANGE', { role: 'speaker' });
eq(m.state, 'joined');
eq(m.context.role, 'speaker');
});
test('observers see ROLE_CHANGE as a transition even though state stays joined', () => {
const m = createFSM(callSpec);
let seenRole = '';
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'listener' });
m.observe(({ state, ev }) => { if (ev && ev.type === 'ROLE_CHANGE') seenRole = m.context.role; });
m.send('ROLE_CHANGE', { role: 'host' });
eq(seenRole, 'host');
});
console.log('callSpec — reconnect path:');
test('joined + WS_DROPPED → reconnecting', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'speaker' });
m.send('WS_DROPPED');
eq(m.state, 'reconnecting');
});
test('reconnecting + WELCOME → joined (role may have changed during drop)', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'host' });
m.send('WS_DROPPED');
m.send('WELCOME', { role: 'cohost' });
eq(m.state, 'joined');
eq(m.context.role, 'cohost');
});
test('reconnecting + LEAVE → leaving (user gives up during outage)', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'speaker' });
m.send('WS_DROPPED');
m.send('LEAVE');
eq(m.state, 'leaving');
});
console.log('callSpec — boot path:');
test('joined + BOOTED → booted, bootedBy captured', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'listener' });
m.send('BOOTED', { by: 'host-uuid' });
eq(m.state, 'booted');
eq(m.context.bootedBy, 'host-uuid');
});
test('connecting + BOOTED → booted (block-listed before welcome lands)', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('BOOTED', { by: 'host' });
eq(m.state, 'booted');
});
test('booted + ACK → idle (acknowledge the notice, return to entry)', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'listener' });
m.send('BOOTED', { by: 'host' });
m.send('ACK');
eq(m.state, 'idle');
});
test('reconnecting + BOOTED → booted (boot can fire during outage)', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'speaker' });
m.send('WS_DROPPED');
m.send('BOOTED', { by: 'host' });
eq(m.state, 'booted');
});
console.log('callSpec — connect cancel + failure:');
test('connecting + LEAVE → idle (user backed out before welcome)', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('LEAVE');
eq(m.state, 'idle');
});
test('connecting + FAILED → idle, error stored', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('FAILED', { error: 'ws upgrade 502' });
eq(m.state, 'idle');
eq(m.context.lastError, 'ws upgrade 502');
});
console.log('callSpec — illegal transitions are no-ops:');
test('idle + WELCOME is a no-op (must ENTER first)', () => {
const m = createFSM(callSpec);
eq(m.send('WELCOME', { uuid: 'u', role: 'host' }), false);
eq(m.state, 'idle');
});
test('joined + WELCOME is a no-op (already joined)', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'host' });
eq(m.send('WELCOME', { uuid: 'other', role: 'listener' }), false);
eq(m.state, 'joined');
eq(m.context.uuid, 'u');
});
test('leaving + every other event is a no-op', () => {
const m = createFSM(callSpec);
m.send('ENTER', { code: 'r', handle: 'h' });
m.send('WELCOME', { uuid: 'u', role: 'host' });
m.send('LEAVE');
eq(m.send('ENTER', { code: 'x' }), false);
eq(m.send('WELCOME', { uuid: 'y', role: 'speaker' }), false);
eq(m.send('BOOTED', { by: 'host' }), false);
eq(m.state, 'leaving');
});
console.log('\n' + pass + ' passed, ' + fail + ' failed');
process.exit(fail === 0 ? 0 : 1);

View file

@ -971,6 +971,91 @@ const remoteTileSpec = {
},
};
/* ==================================================================
* CallFSM — the user's overall lifecycle in a space. Orchestrates
* (but doesn't replace) the per-leg FSMs above: a Joined call has
* exactly one SubscribeFSM and zero-to-three PublishFSMs running
* underneath; the runtime starts/stops them on entry/exit of joined.
*
* 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
* fires re-entry of joined so observers can swap mesh/sub/publish
* topology without inventing a new state per role permutation. */
const callSpec = {
initial: 'idle',
context: { code: '', handle: '', uuid: '', role: '', bootedBy: null, lastError: null },
states: {
idle: {
entry: (ctx) => { ctx.uuid = ''; ctx.role = ''; ctx.bootedBy = null; },
on: {
ENTER: {
target: 'connecting',
action: (ctx, ev) => { if (ev.payload){ ctx.code = ev.payload.code || ''; ctx.handle = ev.payload.handle || ''; } },
},
},
},
connecting: {
on: {
WELCOME: {
target: 'joined',
action: (ctx, ev) => { if (ev.payload){ ctx.uuid = ev.payload.uuid || ''; ctx.role = ev.payload.role || ''; } },
},
FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
LEAVE: 'idle',
},
},
joined: {
on: {
/* ROLE_CHANGE re-enters joined so observers get an event to act
* on (swap mesh peers, start/stop publish, etc.) */
ROLE_CHANGE: {
target: 'joined',
action: (ctx, ev) => { if (ev.payload && ev.payload.role) ctx.role = ev.payload.role; },
},
WS_DROPPED: 'reconnecting',
LEAVE: 'leaving',
BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
},
},
reconnecting: {
/* signal-server WebSocket dropped; the SubscribeFSM + publish
* legs stay alive because WebRTC PCs are independent of the WS.
* On WELCOME (a re-issued one after the WS comes back) we land
* back in joined and ROLE_CHANGE may follow. */
on: {
WELCOME: {
target: 'joined',
action: (ctx, ev) => { if (ev.payload && ev.payload.role) ctx.role = ev.payload.role; },
},
FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
LEAVE: 'leaving',
BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
},
},
leaving: {
on: { DONE: 'idle' },
},
booted: {
/* user has been removed from the room. ACK transitions back to
* idle so the entry screen comes back; the room's auto-rejoin
* sessionStorage key is cleared by the runtime when entering booted. */
on: { ACK: 'idle' },
},
},
};
/* ==================================================================
* identity — ed25519 keypair, persisted in localStorage as JWK.
*
@ -3039,8 +3124,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">a3266c98f3e438ced8d92ede2203211b</span><br>
sha256 <span class="stamp-sha">8b48fd39877e1a3a2d0dd155998489a09ca62f72eb0a59889ce9a0de4affe26a</span><br>
md5 <span class="stamp-md5">a871b9e9438f857dfd47cbaa578c676d</span><br>
sha256 <span class="stamp-sha">bdfaeba6af0fd5765508ab816cf3bde5ab405a0864368320ba26d952e0218a72</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>