From 2d75cd754717bfe43e7bfec73dba837a8faaf0fd Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 2 Jun 2026 11:05:52 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20RemoteTileFSM=20=E2=80=94=20for?= =?UTF-8?q?malises=20frozen-thumb=20fix=20from=20f71e9e7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/zebra-fsm.test.js | 144 +++++++++++++++++++++++++++++++++++++++-- web/zebra-spaces.html | 70 +++++++++++++++++++- 2 files changed, 206 insertions(+), 8 deletions(-) diff --git a/test/zebra-fsm.test.js b/test/zebra-fsm.test.js index fe13780..9f0c6a5 100644 --- a/test/zebra-fsm.test.js +++ b/test/zebra-fsm.test.js @@ -29,17 +29,18 @@ function extract(re){ return src.slice(m.index, j); } -const createFSMSrc = extract(/function createFSM\(/); -const publishSpecSrc = extract(/const publishSpec = /); -const subscribeSpecSrc = extract(/const subscribeSpec = /); +const createFSMSrc = extract(/function createFSM\(/); +const publishSpecSrc = extract(/const publishSpec = /); +const subscribeSpecSrc = extract(/const subscribeSpec = /); +const remoteTileSpecSrc = extract(/const remoteTileSpec = /); /* 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 + - '\nreturn { createFSM, publishSpec, subscribeSpec };' + createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' + remoteTileSpecSrc + + '\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec };' ); -const { createFSM, publishSpec, subscribeSpec } = harness(); +const { createFSM, publishSpec, subscribeSpec, remoteTileSpec } = harness(); let pass = 0, fail = 0; function test(name, fn){ @@ -389,5 +390,136 @@ test('stopping + RENEG / LOST / CONNECTED are all no-ops', () => { eq(m.state, 'stopping'); }); +console.log('remoteTileSpec — happy path:'); + +test('starts in inactive', () => { + const m = createFSM(remoteTileSpec); + eq(m.state, 'inactive'); +}); + +test('inactive + TRACK_ARRIVED → receiving, stream stored', () => { + const m = createFSM(remoteTileSpec); + const stream = { id: 'remote-stream' }; + m.send('TRACK_ARRIVED', { stream }); + eq(m.state, 'receiving'); + eq(m.context.stream, stream); +}); + +test('receiving + MUTED → muted', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: {} }); + m.send('MUTED'); + eq(m.state, 'muted'); +}); + +console.log('remoteTileSpec — mute is a debounce, not a kill:'); + +test('muted + UNMUTED → receiving (transient network blip recovers)', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: {} }); + m.send('MUTED'); + m.send('UNMUTED'); + eq(m.state, 'receiving'); +}); + +test('muted + PRUNE → removed (debounce window expired, still muted)', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: {} }); + m.send('MUTED'); + m.send('PRUNE'); + eq(m.state, 'removed'); +}); + +test('after recovery, mute → unmute again keeps the tile alive', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: {} }); + m.send('MUTED'); m.send('UNMUTED'); + m.send('MUTED'); m.send('UNMUTED'); + eq(m.state, 'receiving'); +}); + +console.log('remoteTileSpec — ENDED skips debounce:'); + +test('receiving + ENDED → removed directly', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: {} }); + m.send('ENDED'); + eq(m.state, 'removed'); +}); + +test('muted + ENDED → removed directly', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: {} }); + m.send('MUTED'); + m.send('ENDED'); + eq(m.state, 'removed'); +}); + +console.log('remoteTileSpec — LEFT wipes from any live state:'); + +test('inactive + LEFT → removed', () => { + const m = createFSM(remoteTileSpec); + m.send('LEFT'); + eq(m.state, 'removed'); +}); + +test('receiving + LEFT → removed', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: {} }); + m.send('LEFT'); + eq(m.state, 'removed'); +}); + +test('muted + LEFT → removed', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: {} }); + m.send('MUTED'); + m.send('LEFT'); + eq(m.state, 'removed'); +}); + +console.log('remoteTileSpec — publisher re-share refreshes stream:'); + +test('receiving + TRACK_ARRIVED swaps to new stream', () => { + const m = createFSM(remoteTileSpec); + const a = { id: 'a' }, b = { id: 'b' }; + m.send('TRACK_ARRIVED', { stream: a }); + m.send('TRACK_ARRIVED', { stream: b }); + eq(m.state, 'receiving'); + eq(m.context.stream, b); +}); + +test('muted + TRACK_ARRIVED → receiving with new stream', () => { + const m = createFSM(remoteTileSpec); + const a = { id: 'a' }, b = { id: 'b' }; + m.send('TRACK_ARRIVED', { stream: a }); + m.send('MUTED'); + m.send('TRACK_ARRIVED', { stream: b }); + eq(m.state, 'receiving'); + eq(m.context.stream, b); +}); + +console.log('remoteTileSpec — removed is terminal:'); + +test('removed + every event is a no-op (need a fresh FSM)', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: {} }); + m.send('ENDED'); + eq(m.state, 'removed'); + eq(m.send('TRACK_ARRIVED', { stream: {} }), false); + eq(m.send('MUTED'), false); + eq(m.send('UNMUTED'), false); + eq(m.send('PRUNE'), false); + eq(m.send('LEFT'), false); + eq(m.state, 'removed'); +}); + +test('entry into removed nulls the stream so the runtime can drop refs', () => { + const m = createFSM(remoteTileSpec); + m.send('TRACK_ARRIVED', { stream: { id: 's' } }); + m.send('ENDED'); + eq(m.context.stream, null); +}); + console.log('\n' + pass + ' passed, ' + fail + ' failed'); process.exit(fail === 0 ? 0 : 1); diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index f9a568b..c455da8 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -905,6 +905,72 @@ const subscribeSpec = { }, }; +/* ================================================================== + * RemoteTileFSM — one instance per incoming screen / camera track from + * a remote publisher. Formalises the lifecycle that f71e9e7 patched + * imperatively (frozen-thumb after unshare): + * + * inactive ──TRACK_ARRIVED──▶ receiving ──MUTED──▶ muted + * ▲ │ + * │ UNMUTED │ PRUNE / ENDED + * └────────────────────┤ + * ▼ + * removed + * + * {receiving, muted} + ENDED → removed + * * + LEFT → removed (peer-left wipes the tile from every state) + * + * MUTED is a debounce gate, not a deletion: if UNMUTED arrives within + * the runtime's debounce window (~1.5s) we stay receiving — the mute + * was a transient network blip. If PRUNE fires (debounce expired and + * still muted) the publisher really unshared and the tile dies. + * ENDED skips the debounce. removed is terminal — a new tile gets + * a fresh FSM. */ +const remoteTileSpec = { + initial: 'inactive', + context: { kind: '', pubHex: '', stream: null, lastError: null }, + states: { + inactive: { + on: { + TRACK_ARRIVED: { + target: 'receiving', + action: (ctx, ev) => { if (ev.payload) ctx.stream = ev.payload.stream || ctx.stream; }, + }, + LEFT: 'removed', + }, + }, + receiving: { + on: { + MUTED: 'muted', + ENDED: 'removed', + LEFT: 'removed', + /* a fresh ontrack for the same pubHex+kind — publisher re-shared + * before our prune fired; keep the new stream and stay receiving */ + TRACK_ARRIVED: { + target: 'receiving', + action: (ctx, ev) => { if (ev.payload && ev.payload.stream) ctx.stream = ev.payload.stream; }, + }, + }, + }, + muted: { + on: { + UNMUTED: 'receiving', + PRUNE: 'removed', + ENDED: 'removed', + LEFT: 'removed', + TRACK_ARRIVED: { + target: 'receiving', + action: (ctx, ev) => { if (ev.payload && ev.payload.stream) ctx.stream = ev.payload.stream; }, + }, + }, + }, + removed: { + entry: (ctx) => { ctx.stream = null; }, + /* terminal — no outbound transitions */ + }, + }, +}; + /* ================================================================== * identity — ed25519 keypair, persisted in localStorage as JWK. * @@ -2973,8 +3039,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');