zebra-spaces: RemoteTileFSM — formalises frozen-thumb fix from f71e9e7

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

View file

@ -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);

View file

@ -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');
<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">fabb116d48d04e00c0d689f14e475ead</span><br>
sha256 <span class="stamp-sha">f449dab274cac4a6ecf36a7329eb759d1ea494789b054e1431408452168004aa</span><br>
md5 <span class="stamp-md5">a3266c98f3e438ced8d92ede2203211b</span><br>
sha256 <span class="stamp-sha">8b48fd39877e1a3a2d0dd155998489a09ca62f72eb0a59889ce9a0de4affe26a</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>