zebra-spaces: refactor sub PC ontrack into handleRemoteSfuTrack + multi-peer mesh tests
Receive-side mesh state machine (the bit that decides which peer gets
which tile) was buried inside an anonymous pc.ontrack callback inside
sfuSubscribe(). Extracted into a named top-level function
handleRemoteSfuTrack so tests can drive it directly with synthetic
RTCTrackEvents — no real RTCPeerConnection, no real SFU.
test/multi-peer-mesh.test.js pins fox's stated invariant:
'whatever one device shares all should see, and when unshared none
should see.'
Eight scenarios across 2-3 fake browser sandboxes, each holding the
shipped handleRemoteSfuTrack + renderVideoTile + removeVideoTile +
watchVideoTrackForRemoval + the maps they own:
- one peer publishes camera -> every other peer ends with that pubHex
in cameraStreams + a tile entry
- one peer unshares (track ended) -> every other peer drops that pubHex
- one peer unshares mid-flow (mute past window) -> drops correctly
- hiccup supplant (same pubkey, new track) -> tile preserved AND
pointed at the new stream object (this is the MSID-supplant fix from
d5e9e4e — fresh MediaStream per track means the video element binds
to the new RTP cleanly)
- hiccup supplant + the OLD track's stream-identity guard prevents the
NEW tile from being reaped
- supplant + sustained mute past window on the NEW track -> reaped
correctly
- echo guard: a peer's own publish never enters their own cameraStreams
- three publishers fan-out: A B C all publish, every peer ends with
exactly the other two
Wired into Makefile as test-mesh + added to test-all. Pure Node, no
browser or proxy server needed. Will catch the regressions where one
peer's publish/unpublish silently desyncs another peer's view.
This commit is contained in:
parent
27fea30680
commit
07fcec7775
3 changed files with 595 additions and 97 deletions
14
Makefile
14
Makefile
|
|
@ -34,6 +34,16 @@ test-fsm:
|
||||||
test-video-removal:
|
test-video-removal:
|
||||||
@node test/video-track-removal.test.js
|
@node test/video-track-removal.test.js
|
||||||
|
|
||||||
|
# Mesh state-sync invariant tests — pins the contract fox stated as
|
||||||
|
# "whatever one device shares all should see, and when unshared none
|
||||||
|
# should see." Runs the shipped handleRemoteSfuTrack + renderVideoTile +
|
||||||
|
# removeVideoTile + watchVideoTrackForRemoval against multi-peer
|
||||||
|
# scenarios with synthetic ontrack/mute/unmute/ended events. Catches
|
||||||
|
# regressions where one peer's publish/unpublish leaves another peer
|
||||||
|
# out of sync.
|
||||||
|
test-mesh:
|
||||||
|
@node test/multi-peer-mesh.test.js
|
||||||
|
|
||||||
# zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live
|
# zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live
|
||||||
# server flow. The live-server tier auto-runs when proxy.unturf.com sits
|
# 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
|
# alongside this checkout AND has a Go toolchain — we build the relay binary
|
||||||
|
|
@ -51,7 +61,7 @@ test-zebra-spaces:
|
||||||
ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \
|
ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \
|
||||||
rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc
|
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-zebra-spaces
|
test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-zebra-spaces
|
||||||
@echo "--- unit ---"
|
@echo "--- unit ---"
|
||||||
@./test/unit
|
@./test/unit
|
||||||
@echo "--- integration ---"
|
@echo "--- integration ---"
|
||||||
|
|
@ -64,6 +74,8 @@ test-all: test/unit test/integration test/functional test-web test-fsm test-vide
|
||||||
@node test/zebra-fsm.test.js
|
@node test/zebra-fsm.test.js
|
||||||
@echo "--- video-track-removal ---"
|
@echo "--- video-track-removal ---"
|
||||||
@node test/video-track-removal.test.js
|
@node test/video-track-removal.test.js
|
||||||
|
@echo "--- multi-peer-mesh ---"
|
||||||
|
@node test/multi-peer-mesh.test.js
|
||||||
@echo "--- zebra-spaces ---"
|
@echo "--- zebra-spaces ---"
|
||||||
@$(MAKE) -s test-zebra-spaces
|
@$(MAKE) -s test-zebra-spaces
|
||||||
|
|
||||||
|
|
|
||||||
477
test/multi-peer-mesh.test.js
Normal file
477
test/multi-peer-mesh.test.js
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/* multi-peer mesh state-sync tests.
|
||||||
|
*
|
||||||
|
* node test/multi-peer-mesh.test.js
|
||||||
|
*
|
||||||
|
* Pins the invariant fox stated bluntly:
|
||||||
|
* "whatever one device shares all should see, and when unshared none
|
||||||
|
* should see."
|
||||||
|
*
|
||||||
|
* Each test instantiates 2-3 fake "browser" sandboxes containing the
|
||||||
|
* actual shipped receive-side state machine from web/zebra-spaces.html
|
||||||
|
* (handleRemoteSfuTrack + renderVideoTile + removeVideoTile +
|
||||||
|
* watchVideoTrackForRemoval + the streams/maps they own). We then
|
||||||
|
* synthesize ontrack/mute/unmute/ended events that mirror what the
|
||||||
|
* SFU would push to each subscriber's PC, and assert on the resulting
|
||||||
|
* state of every browser's cameraStreams / screenStreams / gameStreams
|
||||||
|
* maps + tile DOM.
|
||||||
|
*
|
||||||
|
* No browser, no real WebRTC stack, no proxy server — pure Node. The
|
||||||
|
* page is the source of truth: extract the functions from HTML and
|
||||||
|
* sandbox them so the tests can never drift from the shipped code. */
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8');
|
||||||
|
|
||||||
|
/* lift a top-level function / const literal out of zebra-spaces.html by
|
||||||
|
* locating its head, then brace-matching. */
|
||||||
|
function extractFn(re){
|
||||||
|
const m = src.match(re);
|
||||||
|
if (!m) throw new Error('could not find ' + re);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
function extractConst(name){
|
||||||
|
const re = new RegExp('const\\s+' + name + '\\s*=\\s*\\{');
|
||||||
|
const m = src.match(re);
|
||||||
|
if (!m) throw new Error('could not find const ' + name);
|
||||||
|
let i = src.indexOf('{', m.index), depth = 0, j = i;
|
||||||
|
for (; j < src.length; j++){
|
||||||
|
if (src[j] === '{') depth++;
|
||||||
|
else if (src[j] === '}') { if (--depth === 0) { j++; break; } }
|
||||||
|
}
|
||||||
|
if (src[j] === ';') j++;
|
||||||
|
return src.slice(m.index, j + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchSrc = extractFn(/function watchVideoTrackForRemoval\(/);
|
||||||
|
const renderTileSrc = extractFn(/function renderVideoTile\(/);
|
||||||
|
const removeTileSrc = extractFn(/function removeVideoTile\(/);
|
||||||
|
const renderScreenShim = extractFn(/function renderScreenTile\(/);
|
||||||
|
const removeScreenShim = extractFn(/function removeScreenTile\(/);
|
||||||
|
const renderCameraShim = extractFn(/function renderCameraTile\(/);
|
||||||
|
const removeCameraShim = extractFn(/function removeCameraTile\(/);
|
||||||
|
const handleTrackSrc = extractFn(/function handleRemoteSfuTrack\(/);
|
||||||
|
const tileKindsSrc = extractConst('TILE_KINDS');
|
||||||
|
|
||||||
|
/* shipped constants */
|
||||||
|
const camWinMS = parseInt(src.match(/VIDEO_REMOVE_MUTE_WINDOW_MS\s*=\s*(\d+)/)[1], 10);
|
||||||
|
const screenWinMS = parseInt(src.match(/VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS\s*=\s*(\d+)/)[1], 10);
|
||||||
|
|
||||||
|
/* ============================ fake DOM ============================ */
|
||||||
|
function makeDom(){
|
||||||
|
let nextId = 0;
|
||||||
|
function mkEl(tag){
|
||||||
|
const el = {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
id: '',
|
||||||
|
className: '',
|
||||||
|
_classes: new Set(),
|
||||||
|
children: [],
|
||||||
|
parent: null,
|
||||||
|
_listeners: {},
|
||||||
|
addEventListener(ev, fn){ (this._listeners[ev] = this._listeners[ev] || []).push(fn); },
|
||||||
|
appendChild(c){ c.parent = this; this.children.push(c); return c; },
|
||||||
|
remove(){
|
||||||
|
if (!this.parent) return;
|
||||||
|
const i = this.parent.children.indexOf(this);
|
||||||
|
if (i >= 0) this.parent.children.splice(i, 1);
|
||||||
|
this.parent = null;
|
||||||
|
},
|
||||||
|
classList: null,
|
||||||
|
_attrs: {},
|
||||||
|
setAttribute(k, v){ this._attrs[k] = v; },
|
||||||
|
_gen: nextId++,
|
||||||
|
srcObject: null,
|
||||||
|
play(){ return Promise.resolve(); },
|
||||||
|
};
|
||||||
|
el.classList = {
|
||||||
|
add(c){ el._classes.add(c); },
|
||||||
|
remove(c){ el._classes.delete(c); },
|
||||||
|
contains(c){ return el._classes.has(c); },
|
||||||
|
toggle(c, on){
|
||||||
|
if (on === undefined) on = !el._classes.has(c);
|
||||||
|
if (on) el._classes.add(c); else el._classes.delete(c);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Object.defineProperty(el, 'innerHTML', { get(){ return ''; }, set(){} });
|
||||||
|
Object.defineProperty(el, 'textContent', { get(){ return ''; }, set(){} });
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
const containers = {};
|
||||||
|
function ensureContainer(id){
|
||||||
|
if (!containers[id]) containers[id] = mkEl('div');
|
||||||
|
return containers[id];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
$(id){ return ensureContainer(id); },
|
||||||
|
createElement(tag){ return mkEl(tag); },
|
||||||
|
containers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ fake MediaStream / MediaStreamTrack ============================ */
|
||||||
|
let nextStreamId = 0;
|
||||||
|
function makeTrack(){
|
||||||
|
const listeners = {};
|
||||||
|
let muted = true;
|
||||||
|
let readyState = 'live';
|
||||||
|
return {
|
||||||
|
kind: 'video',
|
||||||
|
muted,
|
||||||
|
readyState,
|
||||||
|
get muted(){ return muted; },
|
||||||
|
set muted(v){ muted = v; },
|
||||||
|
setMuted(v){ muted = v; },
|
||||||
|
setReadyState(s){ readyState = s; },
|
||||||
|
contentHint: '',
|
||||||
|
addEventListener(ev, fn){ (listeners[ev] = listeners[ev] || []).push(fn); },
|
||||||
|
fire(ev){ for (const fn of (listeners[ev] || [])) fn(); },
|
||||||
|
stop(){
|
||||||
|
readyState = 'ended';
|
||||||
|
muted = true;
|
||||||
|
// 'ended' fires when the track stops
|
||||||
|
for (const fn of (listeners.ended || [])) fn();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
class FakeMediaStream {
|
||||||
|
constructor(tracks){
|
||||||
|
this.id = 's' + (nextStreamId++);
|
||||||
|
this.tracks = tracks ? [...tracks] : [];
|
||||||
|
}
|
||||||
|
getTracks(){ return this.tracks.slice(); }
|
||||||
|
getVideoTracks(){ return this.tracks.filter(t => t.kind === 'video'); }
|
||||||
|
addTrack(t){ this.tracks.push(t); }
|
||||||
|
removeTrack(t){
|
||||||
|
const i = this.tracks.indexOf(t);
|
||||||
|
if (i >= 0) this.tracks.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ helpers (mirror the page) ============================ */
|
||||||
|
function unb64(s){ return Buffer.from(s, 'base64').toString('binary'); }
|
||||||
|
function hexFromStr(s){
|
||||||
|
let h = '';
|
||||||
|
for (let i = 0; i < s.length; i++) h += s.charCodeAt(i).toString(16).padStart(2, '0');
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
function shortHexFn(h){ return h.slice(0, 4) + '…' + h.slice(-4); }
|
||||||
|
|
||||||
|
/* ============================ fake clock ============================ */
|
||||||
|
function makeClock(){
|
||||||
|
let now = 0;
|
||||||
|
let nextId = 1;
|
||||||
|
let pending = new Map();
|
||||||
|
return {
|
||||||
|
setTimeout(fn, ms){
|
||||||
|
const id = nextId++;
|
||||||
|
pending.set(id, { fireAt: now + ms, fn });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimeout(id){ pending.delete(id); },
|
||||||
|
advance(ms){
|
||||||
|
now += ms;
|
||||||
|
const ready = [...pending.entries()]
|
||||||
|
.filter(([, t]) => t.fireAt <= now)
|
||||||
|
.sort((a, b) => a[1].fireAt - b[1].fireAt);
|
||||||
|
for (const [id, t] of ready){
|
||||||
|
pending.delete(id);
|
||||||
|
t.fn();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pending(){ return pending.size; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ Browser sandbox ============================ */
|
||||||
|
function makeBrowser(name, pubKeyHex){
|
||||||
|
const dom = makeDom();
|
||||||
|
const clock = makeClock();
|
||||||
|
/* Build the function scope. Inject:
|
||||||
|
* - shipped constants
|
||||||
|
* - storage maps the receive-side touches
|
||||||
|
* - DOM helpers ($)
|
||||||
|
* - the MediaStream + hex/unb64 helpers
|
||||||
|
* - logLine stub
|
||||||
|
* - spotlight + setSpotlight stubs (we don't assert on them here) */
|
||||||
|
const fnSrc =
|
||||||
|
'const cameraVideos = new Map();\n' +
|
||||||
|
'const cameraStreams = new Map();\n' +
|
||||||
|
'const screenVideos = new Map();\n' +
|
||||||
|
'const screenStreams = new Map();\n' +
|
||||||
|
'const gameVideos = new Map();\n' +
|
||||||
|
'const gameStreams = new Map();\n' +
|
||||||
|
'const sfuStreamsByPubHex = new Map();\n' +
|
||||||
|
'const remoteAudio = new Map();\n' +
|
||||||
|
'const peers = new Map();\n' +
|
||||||
|
'let spotlight = null;\n' +
|
||||||
|
'function setSpotlight(){}\n' +
|
||||||
|
'function clearSpotlightDOM(){}\n' +
|
||||||
|
'function pickNextSpotlight(){}\n' +
|
||||||
|
'function updateContainerVisibility(){}\n' +
|
||||||
|
'function buildTile(kind, pubHex, label, opts){\n' +
|
||||||
|
' const tile = document.createElement("div");\n' +
|
||||||
|
' const video = document.createElement("video");\n' +
|
||||||
|
' tile.appendChild(video);\n' +
|
||||||
|
' return { tile, video };\n' +
|
||||||
|
'}\n' +
|
||||||
|
'function attachSfuTrack(){}\n' +
|
||||||
|
'function canSpeak(role){ return role === "host" || role === "cohost" || role === "speaker"; }\n' +
|
||||||
|
'function shortHex(h){ return shortHexFn(h); }\n' +
|
||||||
|
'const myKeys = { pubHex: "' + pubKeyHex + '" };\n' +
|
||||||
|
'let myRole = "speaker";\n' +
|
||||||
|
'const members = new Map();\n' +
|
||||||
|
tileKindsSrc + '\n' +
|
||||||
|
watchSrc + '\n' +
|
||||||
|
renderTileSrc + '\n' +
|
||||||
|
removeTileSrc + '\n' +
|
||||||
|
renderScreenShim + '\n' +
|
||||||
|
removeScreenShim + '\n' +
|
||||||
|
renderCameraShim + '\n' +
|
||||||
|
removeCameraShim + '\n' +
|
||||||
|
handleTrackSrc + '\n' +
|
||||||
|
'return {\n' +
|
||||||
|
' handleRemoteSfuTrack,\n' +
|
||||||
|
' members,\n' +
|
||||||
|
' cameraStreams, screenStreams, gameStreams,\n' +
|
||||||
|
' cameraVideos, screenVideos, gameVideos,\n' +
|
||||||
|
' myKeys,\n' +
|
||||||
|
'};';
|
||||||
|
|
||||||
|
const factory = new Function(
|
||||||
|
'$', 'document', 'logLine', 'unb64', 'hex', 'shortHexFn',
|
||||||
|
'MediaStream', 'setTimeout', 'clearTimeout',
|
||||||
|
'VIDEO_REMOVE_MUTE_WINDOW_MS', 'VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS',
|
||||||
|
fnSrc,
|
||||||
|
);
|
||||||
|
const ctx = factory(
|
||||||
|
dom.$.bind(dom), // $ — returns the container by id
|
||||||
|
{ createElement: tag => dom.createElement(tag) }, // document.createElement
|
||||||
|
() => {}, // logLine stub (silent for tests)
|
||||||
|
unb64, hexFromStr, shortHexFn,
|
||||||
|
FakeMediaStream,
|
||||||
|
clock.setTimeout.bind(clock), clock.clearTimeout.bind(clock),
|
||||||
|
camWinMS, screenWinMS,
|
||||||
|
);
|
||||||
|
return { name, pubHex: pubKeyHex, ctx, clock, dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* short prefix of pubHex for streamID building */
|
||||||
|
function shortPub(h){ return h.slice(0, 16); }
|
||||||
|
|
||||||
|
/* register a member roster in browser b so handleRemoteSfuTrack can
|
||||||
|
* resolve a 16-char prefix back to a full pubHex. */
|
||||||
|
function addMember(b, uuid, pubHex){
|
||||||
|
/* members map uses pubkey in base64 form (the wire format). Encode
|
||||||
|
* our hex back to base64 for the resolver to find. */
|
||||||
|
const pubB64 = Buffer.from(pubHex, 'hex').toString('base64');
|
||||||
|
b.ctx.members.set(uuid, { uuid, pubkey: pubB64, handle: uuid, role: 'speaker' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* simulate an ontrack delivery from the SFU to one subscriber browser. */
|
||||||
|
function deliverTrack(toBrowser, fromPubHex, kind, track){
|
||||||
|
const sid = (kind === 'mic')
|
||||||
|
? shortPub(fromPubHex)
|
||||||
|
: shortPub(fromPubHex) + '-' + kind;
|
||||||
|
const stream = new FakeMediaStream([track]);
|
||||||
|
stream.id = sid;
|
||||||
|
toBrowser.ctx.handleRemoteSfuTrack({
|
||||||
|
streams: [stream],
|
||||||
|
track,
|
||||||
|
receiver: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ test harness ============================ */
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function test(name, fn){
|
||||||
|
try { fn(); console.log(' ✓ ' + name); pass++; }
|
||||||
|
catch (e){ console.log(' ✗ ' + name + ' — ' + (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 falsy(v, msg){ if (v) throw new Error(msg || 'expected falsy'); }
|
||||||
|
|
||||||
|
/* fixed pubkeys for the three peers */
|
||||||
|
const PUB_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
|
const PUB_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||||
|
const PUB_C = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
|
||||||
|
|
||||||
|
/* ============================ tests ============================ */
|
||||||
|
|
||||||
|
console.log('multi-peer mesh:');
|
||||||
|
|
||||||
|
test('invariant: when one peer shares camera, every other peer ends up with their pubHex in cameraStreams', () => {
|
||||||
|
const A = makeBrowser('A', PUB_A);
|
||||||
|
const B = makeBrowser('B', PUB_B);
|
||||||
|
const C = makeBrowser('C', PUB_C);
|
||||||
|
for (const [self, peer1, peer2] of [[A, B, C], [B, A, C], [C, A, B]]){
|
||||||
|
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
|
||||||
|
addMember(self, 'uuid-' + peer2.name, peer2.pubHex);
|
||||||
|
}
|
||||||
|
/* A publishes camera. Simulate: SFU delivers ontrack to B and C. */
|
||||||
|
const trackToB = makeTrack(); trackToB.setMuted(false);
|
||||||
|
const trackToC = makeTrack(); trackToC.setMuted(false);
|
||||||
|
deliverTrack(B, PUB_A, 'camera', trackToB);
|
||||||
|
deliverTrack(C, PUB_A, 'camera', trackToC);
|
||||||
|
/* Every OTHER peer has A's pubHex in cameraStreams. A doesn't (own publish). */
|
||||||
|
truthy(B.ctx.cameraStreams.has(PUB_A), "B should have A's camera");
|
||||||
|
truthy(C.ctx.cameraStreams.has(PUB_A), "C should have A's camera");
|
||||||
|
falsy(A.ctx.cameraStreams.has(PUB_A), "A should NOT have its own camera through SFU sub");
|
||||||
|
/* Tile rendered on B and C */
|
||||||
|
truthy(B.ctx.cameraVideos.has(PUB_A), "B should have a camera tile entry for A");
|
||||||
|
truthy(C.ctx.cameraVideos.has(PUB_A), "C should have a camera tile entry for A");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invariant: when A unshares (track ends), every other peer drops A from cameraStreams', () => {
|
||||||
|
const A = makeBrowser('A', PUB_A);
|
||||||
|
const B = makeBrowser('B', PUB_B);
|
||||||
|
const C = makeBrowser('C', PUB_C);
|
||||||
|
for (const [self, peer1, peer2] of [[A, B, C], [B, A, C], [C, A, B]]){
|
||||||
|
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
|
||||||
|
addMember(self, 'uuid-' + peer2.name, peer2.pubHex);
|
||||||
|
}
|
||||||
|
const trackToB = makeTrack(); trackToB.setMuted(false);
|
||||||
|
const trackToC = makeTrack(); trackToC.setMuted(false);
|
||||||
|
deliverTrack(B, PUB_A, 'camera', trackToB);
|
||||||
|
deliverTrack(C, PUB_A, 'camera', trackToC);
|
||||||
|
/* A unpublishes — SFU stops the transceiver, browsers see 'ended' */
|
||||||
|
trackToB.fire('ended');
|
||||||
|
trackToC.fire('ended');
|
||||||
|
falsy(B.ctx.cameraStreams.has(PUB_A), "B should NOT have A's camera after unshare");
|
||||||
|
falsy(C.ctx.cameraStreams.has(PUB_A), "C should NOT have A's camera after unshare");
|
||||||
|
falsy(B.ctx.cameraVideos.has(PUB_A), "B should have no camera tile entry for A");
|
||||||
|
falsy(C.ctx.cameraVideos.has(PUB_A), "C should have no camera tile entry for A");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invariant: A unshares mid-flow (mute timeout) — every other peer drops A from cameraStreams', () => {
|
||||||
|
const A = makeBrowser('A', PUB_A);
|
||||||
|
const B = makeBrowser('B', PUB_B);
|
||||||
|
for (const [self, peer1] of [[A, B], [B, A]]){
|
||||||
|
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
|
||||||
|
}
|
||||||
|
const track = makeTrack();
|
||||||
|
track.setMuted(false); /* flowing */
|
||||||
|
deliverTrack(B, PUB_A, 'camera', track);
|
||||||
|
track.fire('unmute');
|
||||||
|
truthy(B.ctx.cameraStreams.has(PUB_A));
|
||||||
|
/* RTP stops (publisher killed, ICE failure on SFU side, etc.) */
|
||||||
|
track.setMuted(true); track.fire('mute');
|
||||||
|
B.clock.advance(camWinMS + 1000);
|
||||||
|
falsy(B.ctx.cameraStreams.has(PUB_A), "B should drop A's camera after mute timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hiccup supplant: when A republishes (same pubkey, new stream), B keeps the camera tile alive and points it at the new stream', () => {
|
||||||
|
const A = makeBrowser('A', PUB_A);
|
||||||
|
const B = makeBrowser('B', PUB_B);
|
||||||
|
for (const [self, peer1] of [[A, B], [B, A]]){
|
||||||
|
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
|
||||||
|
}
|
||||||
|
/* first publish */
|
||||||
|
const t1 = makeTrack(); t1.setMuted(false);
|
||||||
|
deliverTrack(B, PUB_A, 'camera', t1);
|
||||||
|
const stream1 = B.ctx.cameraStreams.get(PUB_A);
|
||||||
|
truthy(stream1, "B got A's first camera");
|
||||||
|
/* second publish (hiccup rejoin) — same pubkey, new track. Per MSID-
|
||||||
|
* supplant safety the page builds a fresh MediaStream rather than
|
||||||
|
* trusting ev.streams[0]. */
|
||||||
|
const t2 = makeTrack(); t2.setMuted(false);
|
||||||
|
deliverTrack(B, PUB_A, 'camera', t2);
|
||||||
|
const stream2 = B.ctx.cameraStreams.get(PUB_A);
|
||||||
|
truthy(stream2, "B should still have A's camera entry");
|
||||||
|
truthy(stream1 !== stream2, "stream object should have been REPLACED with the new track's stream");
|
||||||
|
/* now the OLD track fires ended (SFU stopped the old transceiver
|
||||||
|
* during the supplant). The stream-identity guard on the OLD
|
||||||
|
* watcher must NOT reap the tile that the NEW track installed. */
|
||||||
|
t1.fire('ended');
|
||||||
|
truthy(B.ctx.cameraStreams.has(PUB_A), "old-track 'ended' must NOT reap the new tile");
|
||||||
|
truthy(B.ctx.cameraStreams.get(PUB_A) === stream2, "stream is still the new one after old ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hiccup supplant followed by new track ALSO muting past window removes correctly', () => {
|
||||||
|
const A = makeBrowser('A', PUB_A);
|
||||||
|
const B = makeBrowser('B', PUB_B);
|
||||||
|
for (const [self, peer1] of [[A, B], [B, A]]){
|
||||||
|
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
|
||||||
|
}
|
||||||
|
const t1 = makeTrack(); t1.setMuted(false);
|
||||||
|
deliverTrack(B, PUB_A, 'camera', t1);
|
||||||
|
const t2 = makeTrack(); t2.setMuted(false);
|
||||||
|
deliverTrack(B, PUB_A, 'camera', t2);
|
||||||
|
/* old goes ended — guard saves the new tile */
|
||||||
|
t1.fire('ended');
|
||||||
|
truthy(B.ctx.cameraStreams.has(PUB_A));
|
||||||
|
/* new track flowed (unmute already implicit). Then RTP stops for
|
||||||
|
* real — should reap at window. */
|
||||||
|
t2.fire('unmute');
|
||||||
|
t2.setMuted(true); t2.fire('mute');
|
||||||
|
B.clock.advance(camWinMS + 1000);
|
||||||
|
falsy(B.ctx.cameraStreams.has(PUB_A), "new tile reaped after sustained mute past window");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('echo guard: A does NOT add their OWN ontrack to cameraStreams', () => {
|
||||||
|
const A = makeBrowser('A', PUB_A);
|
||||||
|
const B = makeBrowser('B', PUB_B);
|
||||||
|
addMember(A, 'uuid-B', PUB_B);
|
||||||
|
const t = makeTrack(); t.setMuted(false);
|
||||||
|
/* SFU shouldn't deliver A's own publish to A, but defend regardless */
|
||||||
|
deliverTrack(A, PUB_A, 'camera', t);
|
||||||
|
falsy(A.ctx.cameraStreams.has(PUB_A), "self-publish must be ignored by ontrack");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('screen and camera are independent: A publishes BOTH, B sees both, A unshares ONE, the other stays', () => {
|
||||||
|
const A = makeBrowser('A', PUB_A);
|
||||||
|
const B = makeBrowser('B', PUB_B);
|
||||||
|
addMember(A, 'uuid-B', PUB_B);
|
||||||
|
addMember(B, 'uuid-A', PUB_A);
|
||||||
|
const cam = makeTrack(); cam.setMuted(false);
|
||||||
|
const scr = makeTrack(); scr.setMuted(false);
|
||||||
|
deliverTrack(B, PUB_A, 'camera', cam);
|
||||||
|
deliverTrack(B, PUB_A, 'screen', scr);
|
||||||
|
truthy(B.ctx.cameraStreams.has(PUB_A));
|
||||||
|
truthy(B.ctx.screenStreams.has(PUB_A));
|
||||||
|
cam.fire('ended');
|
||||||
|
falsy(B.ctx.cameraStreams.has(PUB_A), "camera should be gone");
|
||||||
|
truthy(B.ctx.screenStreams.has(PUB_A), "screen should still be there");
|
||||||
|
scr.fire('ended');
|
||||||
|
falsy(B.ctx.screenStreams.has(PUB_A));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('three publishers fan-out: A B C all publish camera, every peer ends with exactly the other two', () => {
|
||||||
|
const A = makeBrowser('A', PUB_A);
|
||||||
|
const B = makeBrowser('B', PUB_B);
|
||||||
|
const C = makeBrowser('C', PUB_C);
|
||||||
|
for (const [self, peer1, peer2] of [[A, B, C], [B, A, C], [C, A, B]]){
|
||||||
|
addMember(self, 'uuid-' + peer1.name, peer1.pubHex);
|
||||||
|
addMember(self, 'uuid-' + peer2.name, peer2.pubHex);
|
||||||
|
}
|
||||||
|
/* every peer publishes; every other peer receives */
|
||||||
|
for (const [pub, others] of [[A, [B, C]], [B, [A, C]], [C, [A, B]]]){
|
||||||
|
for (const o of others){
|
||||||
|
const t = makeTrack(); t.setMuted(false);
|
||||||
|
deliverTrack(o, pub.pubHex, 'camera', t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* invariants */
|
||||||
|
eq(A.ctx.cameraStreams.size, 2, "A should see exactly 2 cameras");
|
||||||
|
truthy(A.ctx.cameraStreams.has(PUB_B));
|
||||||
|
truthy(A.ctx.cameraStreams.has(PUB_C));
|
||||||
|
eq(B.ctx.cameraStreams.size, 2);
|
||||||
|
truthy(B.ctx.cameraStreams.has(PUB_A));
|
||||||
|
truthy(B.ctx.cameraStreams.has(PUB_C));
|
||||||
|
eq(C.ctx.cameraStreams.size, 2);
|
||||||
|
truthy(C.ctx.cameraStreams.has(PUB_A));
|
||||||
|
truthy(C.ctx.cameraStreams.has(PUB_B));
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ============================== summary ============================== */
|
||||||
|
console.log('\n' + pass + ' passed, ' + fail + ' failed');
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
|
@ -2415,103 +2415,112 @@ async function sfuUnpublish(){
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* handleRemoteSfuTrack — receive-side mesh state machine. Called once
|
||||||
|
* per ontrack on the SFU sub PC. Extracted into a named function so
|
||||||
|
* test/multi-peer-mesh.test.js can drive it directly with synthetic
|
||||||
|
* events and verify the mesh invariants without needing a real
|
||||||
|
* RTCPeerConnection or SFU. The signature mirrors a real RTCTrackEvent:
|
||||||
|
* .streams[0] (MediaStream), .track (MediaStreamTrack), .receiver
|
||||||
|
* (RTCRtpReceiver — optional, only used for playoutDelayHint). */
|
||||||
|
function handleRemoteSfuTrack(ev){
|
||||||
|
const sid = ev.streams[0] ? ev.streams[0].id : '';
|
||||||
|
if (!sid) return;
|
||||||
|
/* streamID format (RFC 7941 compliant — Firefox enforces 1*64 token-
|
||||||
|
* chars and rejects ':'): SHORT16HEX (mic) | SHORT16HEX-screen |
|
||||||
|
* SHORT16HEX-camera. Resolve the 16-char prefix back to a member's
|
||||||
|
* full pubkey via lookup so the rest of the code keeps using full
|
||||||
|
* pubhex as identity. */
|
||||||
|
const dash = sid.indexOf('-');
|
||||||
|
let pubHex16, kind;
|
||||||
|
if (dash > 0){
|
||||||
|
pubHex16 = sid.slice(0, dash);
|
||||||
|
kind = sid.slice(dash + 1);
|
||||||
|
} else {
|
||||||
|
pubHex16 = sid;
|
||||||
|
kind = 'mic';
|
||||||
|
}
|
||||||
|
/* skip echo of our own publish — match by prefix */
|
||||||
|
if (myKeys && myKeys.pubHex.startsWith(pubHex16)) return;
|
||||||
|
/* resolve short prefix → full pubhex via member roster */
|
||||||
|
let pubHex = pubHex16;
|
||||||
|
for (const [, mm] of members){
|
||||||
|
try {
|
||||||
|
if (mm.pubkey){
|
||||||
|
const fh = hex(unb64(mm.pubkey));
|
||||||
|
if (fh.startsWith(pubHex16)){ pubHex = fh; break; }
|
||||||
|
}
|
||||||
|
} catch(_){}
|
||||||
|
}
|
||||||
|
if (kind === 'screen' || kind === 'camera' || kind === 'game'){
|
||||||
|
logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex +
|
||||||
|
' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState);
|
||||||
|
}
|
||||||
|
/* MSID-supplant safety: the SFU re-uses the same streamID
|
||||||
|
* (`shortPub-kind`) when a publisher supplants themselves. WebRTC
|
||||||
|
* merges the new track into the EXISTING MediaStream — ev.streams[0]
|
||||||
|
* is literally the same instance as before, containing both the
|
||||||
|
* dead old track AND the new live one. Setting srcObject to that
|
||||||
|
* stream doesn't switch the playing track; the video element keeps
|
||||||
|
* showing the (now-ended) old track's last frame and reports muted.
|
||||||
|
* Construct a fresh MediaStream containing only the new track so
|
||||||
|
* the video element binds to the new RTP flow cleanly.
|
||||||
|
*
|
||||||
|
* Stream-identity guard on removeFn: when the OLD track's mute →
|
||||||
|
* ended → removeFn would tear down the tile that the NEW track
|
||||||
|
* just installed. Only remove if the stream we registered against
|
||||||
|
* is still the one in the store for this pub. */
|
||||||
|
if (kind === 'screen'){
|
||||||
|
const s = new MediaStream([ev.track]);
|
||||||
|
screenStreams.set(pubHex, s);
|
||||||
|
renderScreenTile(pubHex, s);
|
||||||
|
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'camera'){
|
||||||
|
const s = new MediaStream([ev.track]);
|
||||||
|
cameraStreams.set(pubHex, s);
|
||||||
|
renderCameraTile(pubHex, s);
|
||||||
|
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'game'){
|
||||||
|
/* a publisher is sharing their gameplay (Region-Capture cropped
|
||||||
|
* iframe). Route to its own TILE_KIND so it coexists with a
|
||||||
|
* normal screen-share from the same person. */
|
||||||
|
const s = new MediaStream([ev.track]);
|
||||||
|
gameStreams.set(pubHex, s);
|
||||||
|
renderVideoTile('gameshare', pubHex, s);
|
||||||
|
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind !== 'mic'){
|
||||||
|
logLine('', 'sfu: unknown kind '+kind+' from '+pubHex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* mic audio — 400ms jitter-buffer target absorbs Wi-Fi peak jitter */
|
||||||
|
try { if (ev.receiver) ev.receiver.playoutDelayHint = 0.4; } catch(_){}
|
||||||
|
/* cache by full pubkey (already resolved above) so it survives the
|
||||||
|
* member's session uuid changing across leave/rejoin */
|
||||||
|
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
|
||||||
|
for (const [uuid, mm] of members){
|
||||||
|
try {
|
||||||
|
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
|
||||||
|
/* speakers get their peers' audio via mesh (lower latency) —
|
||||||
|
* skip the duplicate SFU mic. screens + cameras still came
|
||||||
|
* through above. */
|
||||||
|
if (canSpeak(myRole) && peers.has(uuid)) return;
|
||||||
|
attachSfuTrack(uuid, ev.streams[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch(_){}
|
||||||
|
}
|
||||||
|
/* no matching member yet — flushSfuStreams will attach on peer-joined */
|
||||||
|
}
|
||||||
|
|
||||||
async function sfuSubscribe(){
|
async function sfuSubscribe(){
|
||||||
if (sfuSubPC || !roomID) return;
|
if (sfuSubPC || !roomID) return;
|
||||||
const pc = new RTCPeerConnection(rtcConfig);
|
const pc = new RTCPeerConnection(rtcConfig);
|
||||||
pc.ontrack = (ev) => {
|
pc.ontrack = handleRemoteSfuTrack;
|
||||||
const sid = ev.streams[0] ? ev.streams[0].id : '';
|
|
||||||
if (!sid) return;
|
|
||||||
/* streamID format (RFC 7941 compliant — Firefox enforces 1*64 token-
|
|
||||||
* chars and rejects ':'): SHORT16HEX (mic) | SHORT16HEX-screen |
|
|
||||||
* SHORT16HEX-camera. Resolve the 16-char prefix back to a member's
|
|
||||||
* full pubkey via lookup so the rest of the code keeps using full
|
|
||||||
* pubhex as identity. */
|
|
||||||
const dash = sid.indexOf('-');
|
|
||||||
let pubHex16, kind;
|
|
||||||
if (dash > 0){
|
|
||||||
pubHex16 = sid.slice(0, dash);
|
|
||||||
kind = sid.slice(dash + 1);
|
|
||||||
} else {
|
|
||||||
pubHex16 = sid;
|
|
||||||
kind = 'mic';
|
|
||||||
}
|
|
||||||
/* skip echo of our own publish — match by prefix */
|
|
||||||
if (myKeys && myKeys.pubHex.startsWith(pubHex16)) return;
|
|
||||||
/* resolve short prefix → full pubhex via member roster */
|
|
||||||
let pubHex = pubHex16;
|
|
||||||
for (const [, mm] of members){
|
|
||||||
try {
|
|
||||||
if (mm.pubkey){
|
|
||||||
const fh = hex(unb64(mm.pubkey));
|
|
||||||
if (fh.startsWith(pubHex16)){ pubHex = fh; break; }
|
|
||||||
}
|
|
||||||
} catch(_){}
|
|
||||||
}
|
|
||||||
if (kind === 'screen' || kind === 'camera' || kind === 'game'){
|
|
||||||
logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex +
|
|
||||||
' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState);
|
|
||||||
}
|
|
||||||
/* MSID-supplant safety: the SFU re-uses the same streamID
|
|
||||||
* (`shortPub-kind`) when a publisher supplants themselves. WebRTC
|
|
||||||
* merges the new track into the EXISTING MediaStream — ev.streams[0]
|
|
||||||
* is literally the same instance as before, containing both the
|
|
||||||
* dead old track AND the new live one. Setting srcObject to that
|
|
||||||
* stream doesn't switch the playing track; the video element keeps
|
|
||||||
* showing the (now-ended) old track's last frame and reports muted.
|
|
||||||
* Construct a fresh MediaStream containing only the new track so
|
|
||||||
* the video element binds to the new RTP flow cleanly.
|
|
||||||
*
|
|
||||||
* Stream-identity guard on removeFn: when the OLD track's mute →
|
|
||||||
* ended → removeFn would tear down the tile that the NEW track
|
|
||||||
* just installed. Only remove if the stream we registered against
|
|
||||||
* is still the one in the store for this pub. */
|
|
||||||
if (kind === 'screen'){
|
|
||||||
const s = new MediaStream([ev.track]);
|
|
||||||
screenStreams.set(pubHex, s);
|
|
||||||
renderScreenTile(pubHex, s);
|
|
||||||
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === 'camera'){
|
|
||||||
const s = new MediaStream([ev.track]);
|
|
||||||
cameraStreams.set(pubHex, s);
|
|
||||||
renderCameraTile(pubHex, s);
|
|
||||||
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === 'game'){
|
|
||||||
/* a publisher is sharing their gameplay (Region-Capture cropped
|
|
||||||
* iframe). Route to its own TILE_KIND so it coexists with a
|
|
||||||
* normal screen-share from the same person. */
|
|
||||||
const s = new MediaStream([ev.track]);
|
|
||||||
gameStreams.set(pubHex, s);
|
|
||||||
renderVideoTile('gameshare', pubHex, s);
|
|
||||||
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind !== 'mic'){
|
|
||||||
logLine('', 'sfu: unknown kind '+kind+' from '+pubHex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
/* mic audio — 400ms jitter-buffer target absorbs Wi-Fi peak jitter */
|
|
||||||
try { ev.receiver.playoutDelayHint = 0.4; } catch(_){}
|
|
||||||
/* cache by full pubkey (already resolved above) so it survives the
|
|
||||||
* member's session uuid changing across leave/rejoin */
|
|
||||||
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
|
|
||||||
for (const [uuid, mm] of members){
|
|
||||||
try {
|
|
||||||
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
|
|
||||||
/* speakers get their peers' audio via mesh (lower latency) —
|
|
||||||
* skip the duplicate SFU mic. screens + cameras still came
|
|
||||||
* through above. */
|
|
||||||
if (canSpeak(myRole) && peers.has(uuid)) return;
|
|
||||||
attachSfuTrack(uuid, ev.streams[0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch(_){}
|
|
||||||
}
|
|
||||||
/* no matching member yet — flushSfuStreams will attach on peer-joined */
|
|
||||||
};
|
|
||||||
/* server-initiated offer: POST /subscribe (empty body) — SFU answers with
|
/* server-initiated offer: POST /subscribe (empty body) — SFU answers with
|
||||||
* an SDP offer containing one m-line per current publisher. We answer it
|
* an SDP offer containing one m-line per current publisher. We answer it
|
||||||
* and POST the answer back, which completes the initial handshake. */
|
* and POST the answer back, which completes the initial handshake. */
|
||||||
|
|
@ -3889,8 +3898,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">
|
<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> · built <span class="stamp-date">2026-06-03</span><br>
|
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-03</span><br>
|
||||||
md5 <span class="stamp-md5">e5d7d386ae1ed988094498aa9e352e29</span><br>
|
md5 <span class="stamp-md5">60524e74b32d941769b3ea31eb9d067c</span><br>
|
||||||
sha256 <span class="stamp-sha">260d3562b39b72d508f00041f6e3dc9772a2fc747fc3e585c302c89c79140d9d</span><br>
|
sha256 <span class="stamp-sha">c16114b2737d13d6bb8699e3daa59d6702092fa789f1066241b51b1fea9c1dc6</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">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>
|
<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>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue