diff --git a/Makefile b/Makefile index e036190..cf6053f 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,15 @@ test-video-removal: test-mesh: @node test/multi-peer-mesh.test.js +# Mod-action serializer — pins the kick race fix fox hit 2026-06-04 +# ("kicked two phones, only one was kicked"). Extracts runModSerial + +# awaitStateUpdate + resolvePendingStateUpdate from the live page so +# the assertions track the shipped code, then drives synthetic mod +# actions through them with a stubbed roomEpoch counter to prove the +# second action signs against the FRESH epoch. +test-mod-actions: + @node test/mod-action-serializer.test.js + # zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live # server flow. The live-server tier auto-runs when proxy.unturf.com sits # alongside this checkout AND has a Go toolchain — we build the relay binary @@ -61,7 +70,7 @@ test-zebra-spaces: ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \ rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc -test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-zebra-spaces +test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-zebra-spaces @echo "--- unit ---" @./test/unit @echo "--- integration ---" @@ -76,6 +85,8 @@ test-all: test/unit test/integration test/functional test-web test-fsm test-vide @node test/video-track-removal.test.js @echo "--- multi-peer-mesh ---" @node test/multi-peer-mesh.test.js + @echo "--- mod-action serializer ---" + @node test/mod-action-serializer.test.js @echo "--- zebra-spaces ---" @$(MAKE) -s test-zebra-spaces diff --git a/test/mod-action-serializer.test.js b/test/mod-action-serializer.test.js new file mode 100644 index 0000000..039bf8b --- /dev/null +++ b/test/mod-action-serializer.test.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/* zebra-spaces mod-action serializer — repros the kick race that + * fox hit 2026-06-04 ("kicked two phones, only one was kicked"), + * then proves the serializer fix prevents it. + * + * node test/mod-action-serializer.test.js + * + * Race recap: server increments roomEpoch on every successful mod + * action and broadcasts the new value in 'state'. Two rapid mod + * actions signed before the state-update round-trip both carry the + * same stale epoch — the second is rejected. The fix is a client- + * side serializer that holds the next action until either a 'state' + * arrives (resolvePendingStateUpdate fires) or a 1.5s fallback. + * + * The tests below extract the live serializer from zebra-spaces.html + * so they track the shipped code, then drive synthetic mod actions + * through it under a fake-clock timer + stubbed send/sign helpers. */ + +const fs = require('fs'); +const path = require('path'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8'); + +function extractFn(name){ + const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\('); + const m = src.match(re); + if (!m) throw new Error('could not find function ' + name); + let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i; + for (; j < src.length; j++){ + if (src[j] === '{') depth++; + else if (src[j] === '}') { if (--depth === 0) { j++; break; } } + } + return src.slice(m.index, j); +} + +/* extract the four serializer pieces */ +const runModSerialSrc = extractFn('runModSerial'); +const awaitStateUpdateSrc = extractFn('awaitStateUpdate'); +const resolvePendingStateUpdateSrc = extractFn('resolvePendingStateUpdate'); + +/* the let-bindings live just above the helpers — extract them as a single + * literal block. They reference globals (logLine) we'll stub below. */ +function extractLet(re){ + const m = src.match(re); + if (!m) throw new Error('could not find ' + re); + const eol = src.indexOf('\n', m.index); + return src.slice(m.index, eol); +} +const lastModSettledSrc = extractLet(/let lastModSettled = Promise\.resolve\(\);/); +const pendingStateResolverSrc = extractLet(/let pendingStateResolver = null;/); + +/* harness — drop the four pieces into a sandbox along with stubs for + * logLine + roomEpoch (a counter we can bump like the server would on + * each mod action). */ +let pass = 0, fail = 0; +function test(name, fn){ + return Promise.resolve() + .then(() => fn()) + .then(() => { console.log(' ✓ ' + name); pass++; }) + .catch(e => { console.log(' ✗ ' + name + ' — ' + (e && e.message || e)); fail++; }); +} +function eq(a, b, msg){ + if (a !== b) throw new Error((msg || 'expected') + ' — got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b)); +} +function truthy(v, msg){ if (!v) throw new Error(msg || 'expected truthy'); } + +function buildSerializer(){ + /* fresh sandbox per test so lastModSettled doesn't leak between cases */ + const code = ` + let roomEpoch = 0; + const logLines = []; + function logLine(){ logLines.push(Array.from(arguments).join(' ')); } + ${lastModSettledSrc} + ${pendingStateResolverSrc} + ${awaitStateUpdateSrc} + ${resolvePendingStateUpdateSrc} + ${runModSerialSrc} + return { + runModSerial, + resolvePendingStateUpdate, + getEpoch: () => roomEpoch, + bumpEpoch: () => { roomEpoch++; }, + setEpoch: (v) => { roomEpoch = v; }, + logLines, + }; + `; + return new Function(code)(); +} + +(async () => { + console.log('mod-action serializer:'); + + await test('single action signs with current epoch and resolves', async () => { + const s = buildSerializer(); + s.setEpoch(7); + const seen = []; + const p = s.runModSerial('kick', async () => { seen.push(s.getEpoch()); }); + /* nothing pending yet — let the queue body execute */ + await new Promise(r => setImmediate(r)); + /* server-side bump + state push back */ + s.bumpEpoch(); + s.resolvePendingStateUpdate(); + await p; + eq(seen.length, 1, 'fn fired once'); + eq(seen[0], 7, 'fn signed with epoch=7'); + eq(s.getEpoch(), 8, 'epoch advanced after state update'); + }); + + await test('second action waits for state-update from the first', async () => { + const s = buildSerializer(); + s.setEpoch(10); + const seen = []; + const p1 = s.runModSerial('kick', async () => { seen.push({ phase:'p1', epoch: s.getEpoch() }); }); + /* fire p2 immediately — must NOT execute its fn until p1 settles */ + const p2 = s.runModSerial('kick', async () => { seen.push({ phase:'p2', epoch: s.getEpoch() }); }); + /* let the event loop tick to allow p1's fn to run */ + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + /* p1 has signed and sent, queue is waiting for state */ + eq(seen.length, 1, 'only p1 fired before state update'); + eq(seen[0].phase, 'p1'); + eq(seen[0].epoch, 10, 'p1 signed against the original epoch'); + /* simulate server: state update arrives with new epoch */ + s.bumpEpoch(); + s.resolvePendingStateUpdate(); + /* wait for p1 to settle and p2's fn to fire */ + await p1; + await new Promise(r => setImmediate(r)); + eq(seen.length, 2, 'p2 fired after p1 settled'); + eq(seen[1].phase, 'p2'); + eq(seen[1].epoch, 11, 'p2 signed against the FRESH epoch — race fixed'); + /* drain p2 */ + s.bumpEpoch(); + s.resolvePendingStateUpdate(); + await p2; + eq(s.getEpoch(), 12); + }); + + await test('third action queues behind first two and signs with their cumulative epoch', async () => { + const s = buildSerializer(); + s.setEpoch(100); + const seen = []; + const p1 = s.runModSerial('kick', async () => { seen.push(s.getEpoch()); }); + const p2 = s.runModSerial('kick', async () => { seen.push(s.getEpoch()); }); + const p3 = s.runModSerial('kick', async () => { seen.push(s.getEpoch()); }); + await new Promise(r => setImmediate(r)); + eq(seen.length, 1); + eq(seen[0], 100); + s.bumpEpoch(); s.resolvePendingStateUpdate(); + await p1; await new Promise(r => setImmediate(r)); + eq(seen.length, 2); + eq(seen[1], 101); + s.bumpEpoch(); s.resolvePendingStateUpdate(); + await p2; await new Promise(r => setImmediate(r)); + eq(seen.length, 3); + eq(seen[2], 102, 'third action signed against epoch 102, not stale 100'); + s.bumpEpoch(); s.resolvePendingStateUpdate(); + await p3; + }); + + await test('queue does NOT wedge when state update never arrives (1.5s timeout)', async () => { + /* simulate a server that lost the state broadcast — the queue must + * still drain so the user can keep using mod actions. The fallback + * is a 1500ms setTimeout inside awaitStateUpdate. */ + const s = buildSerializer(); + s.setEpoch(0); + const seen = []; + const start = Date.now(); + const p1 = s.runModSerial('kick', async () => { seen.push(1); }); + const p2 = s.runModSerial('kick', async () => { seen.push(2); }); + await p1; /* will wait the full 1500ms because resolvePendingStateUpdate never fires */ + const elapsed = Date.now() - start; + truthy(elapsed >= 1400, 'p1 settled near the 1.5s fallback (got ' + elapsed + 'ms)'); + truthy(elapsed < 2500, 'p1 did not wait longer than the fallback'); + await p2; /* another 1500ms */ + eq(seen.length, 2, 'both fired even without state updates'); + }); + + await test('an early state-update releases the gate immediately', async () => { + /* state update arrives BEFORE the action fn is even called. + * resolvePendingStateUpdate() should be idempotent — the gate gets + * armed only once awaitStateUpdate is called. The early call is a + * no-op (no resolver registered), and the action still completes + * via the fallback timeout. Documents the contract. */ + const s = buildSerializer(); + s.resolvePendingStateUpdate(); /* no-op before any awaiter exists */ + const seen = []; + const start = Date.now(); + const p = s.runModSerial('kick', async () => { seen.push('ran'); }); + /* let the fn fire */ + await new Promise(r => setImmediate(r)); + eq(seen.length, 1); + /* now arm the gate properly */ + s.resolvePendingStateUpdate(); + await p; + truthy(Date.now() - start < 200, 'fast-path under 200ms with timely state update'); + }); + + await test('failure inside fn does not poison the queue', async () => { + const s = buildSerializer(); + s.setEpoch(0); + const seen = []; + const p1 = s.runModSerial('kick', async () => { throw new Error('signbytes-blew-up'); }).catch(e => seen.push('p1-rejected')); + const p2 = s.runModSerial('kick', async () => { seen.push('p2-ran'); }); + await new Promise(r => setImmediate(r)); + /* p1 rejected — but its `finally { settle(); }` should have released + * the gate so p2 can proceed. */ + s.resolvePendingStateUpdate(); /* state didn't arrive (action failed), but allow fast-path */ + await p1; + await new Promise(r => setImmediate(r)); + /* p2 still needs its own gate to settle */ + s.resolvePendingStateUpdate(); + await p2; + eq(JSON.stringify(seen), JSON.stringify(['p1-rejected', 'p2-ran']), + 'p2 still ran even though p1 rejected'); + }); + + console.log(''); + console.log('passed: ' + pass + ' failed: ' + fail); + process.exit(fail ? 1 : 0); +})();