test: pin the mod-action serializer race fix
Six tests covering the kick-race regression fox hit 2026-06-04
("kicked two phones, only one was kicked") and the fix in commit
8d14873:
- single action signs with current epoch and resolves
- second action waits for state-update from the first (the key one
— fires both actions back-to-back, asserts only the first runs
before the simulated state-update, then asserts the second's fn
signs against the FRESH epoch)
- third action queues behind first two and signs cumulatively
- queue does not wedge when state update never arrives (1.5s timeout)
- early state-update releases the gate immediately
- failure inside fn does not poison the queue
Extracts runModSerial / awaitStateUpdate / resolvePendingStateUpdate
from the live web/zebra-spaces.html so the assertions track shipped
code (same pattern as the other web test files). Each test gets a
fresh sandbox so lastModSettled doesn't leak between cases.
Makefile gets a test-mod-actions target plus a slot in test-all.
This commit is contained in:
parent
8d14873ed9
commit
84f591834f
2 changed files with 233 additions and 1 deletions
13
Makefile
13
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
|
||||
|
||||
|
|
|
|||
221
test/mod-action-serializer.test.js
Normal file
221
test/mod-action-serializer.test.js
Normal file
|
|
@ -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);
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue