zebra-spaces: prefix-match in flushSfuStreams (kill silent fedora-chrome listener)

Fox 2026-06-06: "fedora chrome is flawless besides not able to hear
any mics it was working a few days back and nothing was changed on
the system, only thing we changed was our zebra codes."

The race: on a listener joining a room with existing speakers, the
SFU sub PC ontrack can fire BEFORE the signal-server peer-joined
event populates `members`. handleRemoteSfuTrack already does prefix
resolution at line ~4640:

  let pubHex = pubHex16;
  for (const [, mm] of members){
    if (fh.startsWith(pubHex16)){ pubHex = fh; break; }
  }

When the roster is empty, the loop finds nothing, pubHex stays the
16-char streamID prefix, and sfuStreamsByPubHex.set(pubHex, stream)
caches under that short key. peer-joined arrives later,
flushSfuStreams runs to attach what was cached — but its inner match
was strict ===:

  if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){

mm.pubkey decodes to the FULL 64-char hex; pubHex from the cache is
the 16-char prefix; === never matches; listener stays permanently
silent for every speaker who was already in the room.

Pre-cascade this defect was masked: the old guard `!remoteAudio.has(uuid)`
was always true for worklet listeners (remoteAudio is the <audio>
fallback path only), so flushSfuStreams re-attached every cached
stream on every peer-joined — the eventual second ontrack from a
later renegotiation would land with members populated, cache key
became the full pubhex, and === matched. The 2e74b92 fix replaced
the always-true guard with `!listenerAudioNodes.has(uuid)`, which
correctly skipped re-attach but also exposed the strict-equality
matcher in the cold path.

Fix: switch flushSfuStreams' inner match from `=== pubHex` to
`fh.startsWith(pubHex)`. Symmetric with handleRemoteSfuTrack's own
prefix resolution. Works for both cases:

  - cache key is full 64-char pubhex → startsWith with a full string
    requires equality, so behavior is unchanged when ontrack arrived
    after peer-joined (the common case).
  - cache key is 16-char prefix → startsWith matches the first 16
    chars of any member's full pubhex. 64 bits of prefix entropy =
    astronomical collision probability.

Pinned by 29 new assertions in test/listener-audio-attach.test.js,
extracted from the live page so they cannot drift:
  - 22 cover the attach FSM (chain reachability, dedup, in-place
    swap, jbuf race, idempotent re-attach).
  - 7 cover handleRemoteSfuTrack including the failing scenario:
    "ontrack ARRIVES BEFORE peer-joined (member roster empty) →
    cached + audible after flush" — fails pre-fix, passes post-fix.

Makefile gets test-listener-audio + adds it to test-all.
This commit is contained in:
Russell Ballestrini 2026-06-06 14:37:18 -04:00
parent 1c09dacb62
commit 0ce1339f8e
No known key found for this signature in database
7 changed files with 860 additions and 21 deletions

View file

@ -61,6 +61,17 @@ test-mod-actions:
test-self-listener:
@node test/self-listener-fsm.test.js
# Listener audio attach pipeline — pins src→(jbuf)→gain→destination
# graph reachability through attachAudioStreamViaWorklet,
# setWorkletStream, installJitterBuffer, attachSfuTrack,
# flushSfuStreams. Extracts each function from the live page and
# replays the cascade scenarios (fresh attach, same-stream no-op,
# different-stream in-place swap, dedup by pubkey, publisher rejoin,
# zero-live-track reject). Catches silent-listener regressions where
# a chain builds but never reaches audioCtx.destination.
test-listener-audio:
@node test/listener-audio-attach.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
@ -78,7 +89,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-mod-actions test-self-listener test-zebra-spaces
test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-self-listener test-listener-audio test-zebra-spaces
@echo "--- unit ---"
@./test/unit
@echo "--- integration ---"
@ -97,6 +108,8 @@ test-all: test/unit test/integration test/functional test-web test-fsm test-vide
@node test/mod-action-serializer.test.js
@echo "--- self-listener FSM ---"
@node test/self-listener-fsm.test.js
@echo "--- listener audio attach ---"
@node test/listener-audio-attach.test.js
@echo "--- zebra-spaces ---"
@$(MAKE) -s test-zebra-spaces

View file

@ -0,0 +1,811 @@
#!/usr/bin/env node
/* listener-audio attach pipeline tests.
*
* node test/listener-audio-attach.test.js
*
* Pins the audio-attach FSM in web/zebra-spaces.html. The shipped
* receive-side audio path is:
*
* handleRemoteSfuTrack caches stream by pubHex, attaches per-uuid
* attachSfuTrack role-routes to worklet (listener: 4s buffer;
* speaker/cohost/host: 0.5s)
* attachListenerStreamViaAudioContext / attachAudioStreamViaWorklet
* builds src(jbuf?)gaindestination per uuid
* setWorkletStream in-place source swap on an existing chain
* (publisher renegotiates or mesh ontrack)
* installJitterBuffer inserts the AudioWorkletNode between src
* and gain once the worklet module is ready
* flushSfuStreams after peer-joined, attach any cached stream
* for that uuid if no chain exists yet
* detachListenerStream tear-down of one chain
*
* The invariants below are pinned because the cascade of Jun 5 fixes
* (commits 2e74b92, f479878, 144dd15, 61b21a6) tightened this path
* specifically to kill double-audio on rejoin and fox 2026-06-06
* reported "fedora chrome can't hear any mics", a clean silent-output
* regression somewhere in that cascade. Each test below pins a single
* thread of expected behavior so the failing assertion names the bad
* commit.
*
* No browser, no AudioContext, no MediaStream pure Node. We replicate
* just enough of the Web Audio graph to verify reachability from a
* MediaStreamSource node to audioCtx.destination, with no orphan / no
* silent split. The page is source-of-truth: every function under test
* is extracted from web/zebra-spaces.html at test time so the tests
* cannot drift from 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 literal out of zebra-spaces.html by
* brace-matching the body. */
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);
}
const attachWorkletSrc = extractFn(/function attachAudioStreamViaWorklet\(/);
const attachListenerSrc = extractFn(/function attachListenerStreamViaAudioContext\(/);
const setWorkletStreamSrc = extractFn(/function setWorkletStream\(/);
const installJBSrc = extractFn(/function installJitterBuffer\(/);
const detachListenerSrc = extractFn(/function detachListenerStream\(/);
const attachSfuSrc = extractFn(/function attachSfuTrack\(/);
const flushSrc = extractFn(/function flushSfuStreams\(/);
const playoutDelaySrc = extractFn(/function playoutDelayForRole\(/);
const handleTrackSrc = extractFn(/function handleRemoteSfuTrack\(/);
/* shipped constants read from source so an accidental dial-back here
* shows up as a failing assertion. */
const RECV = parseFloat(src.match(/const\s+RECV_PLAYOUT_DELAY_SEC\s*=\s*([0-9.]+)/)[1]);
const SPEAKER = parseFloat(src.match(/const\s+SPEAKER_PLAYOUT_DELAY_SEC\s*=\s*([0-9.]+)/)[1]);
/* =================== fake Web Audio graph =================== */
/* Every node carries _outgoing: a Set of nodes it currently feeds. The
* graph is otherwise arbitrary we only care about reachability from
* a MediaStreamSource to audioCtx.destination. */
function makeAudioCtx(){
const ctx = {
state: 'running',
currentTime: 0,
_nodes: [],
resume(){ ctx.state = 'running'; return Promise.resolve(); },
};
function makeNode(kind, extra){
const n = {
kind,
_outgoing: new Set(),
connect(target){
n._outgoing.add(target);
return target; /* chained: a.connect(b).connect(c) */
},
disconnect(target){
if (target === undefined) n._outgoing.clear();
else n._outgoing.delete(target);
},
};
Object.assign(n, extra || {});
ctx._nodes.push(n);
return n;
}
ctx.destination = makeNode('destination');
ctx.createMediaStreamSource = stream => makeNode('source', { stream });
ctx.createGain = () => makeNode('gain', {
gain: {
value: 1.0,
cancelScheduledValues(){},
setValueAtTime(){},
linearRampToValueAtTime(){},
},
});
ctx.createOscillator = () => makeNode('oscillator', {
start(){}, stop(){}, onended: null,
});
ctx.createAnalyser = () => makeNode('analyser', {
fftSize: 1024, smoothingTimeConstant: 0,
getFloatTimeDomainData(){},
});
ctx.audioWorklet = { addModule(){ return Promise.resolve(); } };
return ctx;
}
/* The shipped attach calls `new AudioWorkletNode(audioCtx, 'jitter-buffer', opts)`.
* Our fake supports connect/disconnect like any other node + a port stub. */
function makeAudioWorkletNodeClass(){
return class FakeAudioWorkletNode {
constructor(ctx, name, opts){
this.kind = 'worklet:' + name;
this._outgoing = new Set();
this._opts = opts || {};
this.port = {
onmessage: null,
postMessage: msg => { this._lastPosted = msg; },
};
ctx._nodes.push(this);
}
connect(target){ this._outgoing.add(target); return target; }
disconnect(target){
if (target === undefined) this._outgoing.clear();
else this._outgoing.delete(target);
}
};
}
/* =================== fake MediaStream / track =================== */
let nextStreamId = 0;
function makeAudioTrack(opts){
opts = opts || {};
const listeners = {};
let readyState = opts.readyState || 'live';
return {
kind: 'audio',
enabled: opts.enabled !== false,
muted: !!opts.muted,
get readyState(){ return readyState; },
contentHint: '',
addEventListener(ev, fn){ (listeners[ev] = listeners[ev] || []).push(fn); },
stop(){
readyState = 'ended';
for (const fn of (listeners.ended || [])) fn();
},
_fire(ev){ for (const fn of (listeners[ev] || [])) fn(); },
};
}
class FakeMediaStream {
constructor(tracks){
this.id = 's' + (nextStreamId++);
this.tracks = tracks ? [...tracks] : [];
}
getAudioTracks(){ return this.tracks.filter(t => t.kind === 'audio'); }
getTracks(){ return this.tracks.slice(); }
}
/* =================== reachability =================== */
function reachable(from, to){
if (!from || !to) return false;
const seen = new Set([from]);
const stack = [from];
while (stack.length){
const n = stack.pop();
if (n === to) return true;
for (const next of (n._outgoing || [])){
if (!seen.has(next)){
seen.add(next);
stack.push(next);
}
}
}
return false;
}
/* count direct edges from `from` whose target is in some node-set */
function edgesTo(from, target){
let c = 0;
for (const n of from._outgoing){
if (n === target) c++;
}
return c;
}
/* =================== sandboxed browser =================== */
const PUB_A = 'a'.repeat(64);
const PUB_B = 'b'.repeat(64);
const PUB_C = 'c'.repeat(64);
const UUID_A = 'uuidaaaa-aaaa-aaaa';
const UUID_B = 'uuidbbbb-bbbb-bbbb';
const UUID_C = 'uuidcccc-cccc-cccc';
const UUID_A2 = 'uuidA222-2222-2222'; /* A rejoined under new uuid */
function makeBrowser(role){
role = role || 'listener';
const ctx = makeAudioCtx();
const WorkletNodeClass = makeAudioWorkletNodeClass();
/* logs captured for diagnostic assertion */
const logs = [];
/* helpers the attach code calls each is a stub specific enough to
* not interfere with the connection graph. */
const stubs = `
let audioCtx = audioCtxInit;
let workletReady = false, workletLoading = false;
const listenerAudioNodes = new Map();
const remoteAudio = new Map();
const sfuStreamsByPubHex = new Map();
const sfuAudioReceivers = new Map();
const members = new Map();
const peers = new Map();
const lipSync = new Map();
const screenStreams = new Map();
const cameraStreams = new Map();
const gameStreams = new Map();
let myKeys = { pubHex: '${'f'.repeat(64)}' };
let myRole = '${role}';
let transcribeEnabled = false;
/* track every audioCtx-create call so we can assert single-ctx behavior */
let audioCtxCreates = 0;
/* explicit per-test toggles */
function triggerWorkletReady(){
workletReady = true;
for (const [uuid, node] of listenerAudioNodes) installJitterBuffer(uuid, node);
}
function setRole(r){ myRole = r; }
/* no-op worklet loader — tests call triggerWorkletReady manually */
function loadJitterWorklet(){ workletLoading = true; }
function startCaptureForUuid(){ return Promise.resolve(); }
function leaseAudioElement(){
const el = {
srcObject: null, _attrs: {},
play(){ return Promise.resolve(); },
pause(){}, remove(){},
addEventListener(){}, removeAttribute(){}, load(){},
muted: false, autoplay: true, playsInline: true,
currentTime: 0,
error: null,
};
return el;
}
function applySinkTo(){ return Promise.resolve(); }
function startMeter(){}
function stopMeter(){}
function registerLipSyncAudio(){}
function refreshLipSyncForUuid(){}
function ddNoteWorkletBuffered(){}
function onWorkletStarted(){}
function canSpeak(r){ return r === 'host' || r === 'cohost' || r === 'speaker'; }
function unb64(s){
const buf = Buffer.from(s, 'base64');
return new Uint8Array(buf);
}
function hex(u8){
if (!(u8 instanceof Uint8Array)) u8 = new Uint8Array(u8);
return Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
}
/* logLine captures everything for assertion */
function logLine(kind, msg){ logsBuf.push({ kind, msg }); }
const navigator = {}; /* skips the Media Session block cleanly */
const window = { AudioContext: function(){ audioCtxCreates++; return makeAudioCtx_(); } };
const AudioWorkletNode = AudioWorkletNodeClass;
/* SPEAKER_PLAYOUT_DELAY_SEC + RECV_PLAYOUT_DELAY_SEC come in as args */
`;
const factory = new Function(
'audioCtxInit',
'AudioWorkletNodeClass',
'MediaStream',
'logsBuf',
'RECV_PLAYOUT_DELAY_SEC',
'SPEAKER_PLAYOUT_DELAY_SEC',
'makeAudioCtx_',
stubs + '\n' +
attachWorkletSrc + '\n' +
attachListenerSrc + '\n' +
setWorkletStreamSrc + '\n' +
installJBSrc + '\n' +
detachListenerSrc + '\n' +
attachSfuSrc + '\n' +
flushSrc + '\n' +
playoutDelaySrc + '\n' +
/* handleRemoteSfuTrack needs stubs for non-mic kinds they don't run on
* a mic ontrack but they're referenced in the body via early-return
* blocks. We never deliver screen/camera/game to this sandbox so the
* shims below are tombstones. */
'function renderScreenTile(){}\n' +
'function removeScreenTile(){}\n' +
'function renderCameraTile(){}\n' +
'function removeCameraTile(){}\n' +
'function renderVideoTile(){}\n' +
'function removeVideoTile(){}\n' +
'function watchVideoTrackForRemoval(){}\n' +
'function watchFirstFrame(){}\n' +
'function handleBlocked(){}\n' +
'const VIDEO_REMOVE_MUTE_WINDOW_MS = 5000;\n' +
'const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 8000;\n' +
handleTrackSrc + '\n' +
'return {\n' +
' attachAudioStreamViaWorklet, attachListenerStreamViaAudioContext,\n' +
' attachSfuTrack, flushSfuStreams, setWorkletStream, detachListenerStream,\n' +
' handleRemoteSfuTrack,\n' +
' triggerWorkletReady, setRole,\n' +
' get audioCtx(){ return audioCtx; },\n' +
' get workletReady(){ return workletReady; },\n' +
' get audioCtxCreates(){ return audioCtxCreates; },\n' +
' listenerAudioNodes, remoteAudio, sfuStreamsByPubHex, members, peers,\n' +
'};\n'
);
const api = factory(
ctx,
WorkletNodeClass,
FakeMediaStream,
logs,
RECV,
SPEAKER,
makeAudioCtx,
);
return { ctx, logs, api };
}
/* add a member to the roster so handleRemoteSfuTrack / attachAudioStreamViaWorklet
* can resolve uuid pubHex via members. The map stores pubkey in base64. */
function addMember(b, uuid, pubHex){
const pubB64 = Buffer.from(pubHex, 'hex').toString('base64');
b.api.members.set(uuid, { uuid, pubkey: pubB64, handle: uuid, role: 'speaker' });
}
function makeStream(opts){
opts = opts || {};
const tracks = [];
const n = opts.tracks !== undefined ? opts.tracks : 1;
for (let i = 0; i < n; i++) tracks.push(makeAudioTrack(opts.trackOpts));
return new FakeMediaStream(tracks);
}
/* =================== assertions =================== */
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'); }
/* =================== tests =================== */
console.log('listener audio attach:');
/* -------- shipped constants -------- */
test('shipped: RECV_PLAYOUT_DELAY_SEC is 4s (fox-approved for cellular music; do not dial back without sign-off)', () => {
eq(RECV, 4.0, 'RECV_PLAYOUT_DELAY_SEC');
});
test('shipped: SPEAKER_PLAYOUT_DELAY_SEC is 0.5s (conversational latency for speakers/cohost/host)', () => {
eq(SPEAKER, 0.5, 'SPEAKER_PLAYOUT_DELAY_SEC');
});
/* -------- unit: attachAudioStreamViaWorklet -------- */
test('listener: first attach with a healthy stream builds src→gain→destination at gain=1', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const stream = makeStream();
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
eq(ok, true, 'returns true');
const node = b.api.listenerAudioNodes.get(UUID_A);
truthy(node, 'node created');
eq(node.gain.gain.value, 1.0, 'gain at unity');
truthy(reachable(node.src, b.ctx.destination), 'src reaches destination');
eq(node.stream, stream, 'tracks the source stream');
});
test('listener: stream with zero audio tracks is rejected without building a chain', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const stream = new FakeMediaStream([]); /* no tracks */
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
eq(ok, false, 'returns false');
falsy(b.api.listenerAudioNodes.has(UUID_A), 'no chain created');
});
test('listener: stream whose only track is already ended is rejected', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const t = makeAudioTrack();
t.stop();
const stream = new FakeMediaStream([t]);
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
eq(ok, false, 'returns false on dead stream');
falsy(b.api.listenerAudioNodes.has(UUID_A), 'no chain created');
});
test('listener: stream with a live but muted track still attaches (chrome listener fresh-join flow — peer arrives with mic muted)', () => {
/* Chrome reports a remote audio track as readyState=live + muted=true
* during the brief window between negotiation completion and first
* RTP arrival. The "no live audio tracks" guard added in f479878
* MUST gate on readyState only, never on .muted, or fresh listeners
* with quiet speakers see permanent silence. fox 2026-06-06:
* "fedora chrome is flawless besides not able to hear any mics". */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const stream = makeStream({ trackOpts: { muted: true } });
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
eq(ok, true, 'returns true even when track.muted=true');
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built');
});
test('listener: second attach with SAME stream object is a no-op (does not rebuild gain or rewire destination)', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const stream = makeStream();
b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
const first = b.api.listenerAudioNodes.get(UUID_A);
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
eq(ok, true, 'returns true (idempotent)');
eq(b.api.listenerAudioNodes.get(UUID_A), first, 'same node object — not rebuilt');
eq(first.gain.gain.value, 1.0, 'gain still unity');
truthy(reachable(first.src, b.ctx.destination), 'src→destination still intact');
});
test('listener: second attach with DIFFERENT stream on same uuid swaps source in-place (single chain, no orphan)', () => {
/* Pins 144dd15: in-place source swap. The SFU forwards stale audio
* transceivers across publisher rejoins each ontrack delivers a
* NEW MediaStream object even when the publisher is the same. The
* old teardown+rebuild path produced "two streams" (fxhp-android-
* firefox 2026-06-05). The new path reuses the gain+destination
* wiring and only swaps the MediaStreamSource. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const streamA = makeStream();
b.api.attachAudioStreamViaWorklet(UUID_A, streamA, RECV);
const node1 = b.api.listenerAudioNodes.get(UUID_A);
const gain1 = node1.gain;
const dest = b.ctx.destination;
const streamB = makeStream();
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, streamB, RECV);
eq(ok, true, 'swap returns true');
const node2 = b.api.listenerAudioNodes.get(UUID_A);
eq(node2, node1, 'same node object reused (no teardown)');
eq(node2.gain, gain1, 'same GainNode reused (no rewire to destination)');
eq(node2.stream, streamB, 'tracks the new stream');
truthy(reachable(node2.src, dest), 'new src reaches destination');
});
test('listener: in-place swap after jbuf installed routes new src through the SAME jbuf (no duplicate jbufs, no orphan src)', () => {
/* If installJitterBuffer ran (worklet ready), the chain is
* srcjbufgaindestination. A subsequent setWorkletStream MUST
* reuse the same jbuf otherwise two jbufs end up parallel and
* audio doubles. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const streamA = makeStream();
b.api.attachAudioStreamViaWorklet(UUID_A, streamA, RECV);
b.api.triggerWorkletReady();
const node = b.api.listenerAudioNodes.get(UUID_A);
const jbuf = node.jbuf;
truthy(jbuf, 'jbuf installed');
const streamB = makeStream();
b.api.attachAudioStreamViaWorklet(UUID_A, streamB, RECV);
eq(node.jbuf, jbuf, 'jbuf reused');
truthy(node.src._outgoing.has(jbuf), 'new src feeds the jbuf');
truthy(reachable(node.src, b.ctx.destination), 'still reaches destination');
/* count worklet nodes — exactly one jbuf per uuid */
const jbufs = b.ctx._nodes.filter(n => n.kind === 'worklet:jitter-buffer');
eq(jbufs.length, 1, 'exactly one jbuf node total');
});
/* -------- unit: dedup by pubkey -------- */
test('listener: attaching uuid B for the same publisher as uuid A detaches A (no double-gain to destination)', () => {
/* Pins f9736c2 dedup + the upstream guard from 2e74b92. fox 2026-06-05:
* "fxhp-android-firefox is playing host audio twice." */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
addMember(b, UUID_A2, PUB_A);
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
const node1 = b.api.listenerAudioNodes.get(UUID_A);
b.api.attachAudioStreamViaWorklet(UUID_A2, makeStream(), RECV);
falsy(b.api.listenerAudioNodes.has(UUID_A), 'A detached');
truthy(b.api.listenerAudioNodes.has(UUID_A2), 'A2 attached');
/* assert A's source no longer reaches destination */
falsy(reachable(node1.src, b.ctx.destination), 'A no longer reaches destination');
});
/* -------- unit: detachListenerStream cleans everything -------- */
test('detachListenerStream: removes from map and disconnects src, jbuf, gain', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
b.api.triggerWorkletReady();
const node = b.api.listenerAudioNodes.get(UUID_A);
b.api.detachListenerStream(UUID_A);
falsy(b.api.listenerAudioNodes.has(UUID_A), 'removed from map');
eq(node.src._outgoing.size, 0, 'src disconnected');
eq(node.jbuf._outgoing.size, 0, 'jbuf disconnected');
eq(node.gain._outgoing.size, 0, 'gain disconnected');
});
/* -------- unit: setWorkletStream -------- */
test('setWorkletStream: no existing chain returns false (caller should attach fresh)', () => {
const b = makeBrowser('listener');
const ok = b.api.setWorkletStream(UUID_A, makeStream());
eq(ok, false);
});
test('setWorkletStream: when jbuf exists, new source connects to jbuf (not gain)', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
b.api.triggerWorkletReady();
const node = b.api.listenerAudioNodes.get(UUID_A);
const jbuf = node.jbuf;
b.api.setWorkletStream(UUID_A, makeStream());
truthy(node.src._outgoing.has(jbuf), 'src→jbuf edge present');
falsy(node.src._outgoing.has(node.gain), 'src does NOT bypass jbuf to gain');
});
test('setWorkletStream: when jbuf does NOT exist (worklet not ready yet), new source connects to gain', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
/* deliberately do NOT triggerWorkletReady */
const node = b.api.listenerAudioNodes.get(UUID_A);
falsy(node.jbuf, 'jbuf not installed yet');
b.api.setWorkletStream(UUID_A, makeStream());
truthy(node.src._outgoing.has(node.gain), 'src→gain edge present');
truthy(reachable(node.src, b.ctx.destination), 'still reaches destination');
});
/* -------- installJitterBuffer race -------- */
test('installJitterBuffer after setWorkletStream: src (post-swap) gets jbuf inserted, single chain', () => {
/* Race: ontrackA attach src1+gain wired. ontrackB (same pub,
* different stream) setWorkletStream src2 connected directly to
* gain (jbuf not ready). LATER worklet loads installJitterBuffer
* walks listenerAudioNodes and inserts jbuf between current src
* (src2) and gain. Final chain: src2jbufgaindestination. No
* orphan, single jbuf. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
b.api.setWorkletStream(UUID_A, makeStream()); /* swap before worklet ready */
b.api.triggerWorkletReady(); /* now worklet ready */
const node = b.api.listenerAudioNodes.get(UUID_A);
truthy(node.jbuf, 'jbuf installed');
truthy(node.src._outgoing.has(node.jbuf), 'current src feeds jbuf');
truthy(node.jbuf._outgoing.has(node.gain), 'jbuf feeds gain');
truthy(reachable(node.src, b.ctx.destination), 'reaches destination');
});
/* -------- integration: attachSfuTrack routing by role -------- */
test('integration: listener role routes through attachListenerStreamViaAudioContext at 4s target', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
b.api.attachSfuTrack(UUID_A, makeStream());
const node = b.api.listenerAudioNodes.get(UUID_A);
truthy(node, 'chain built');
eq(node.targetSeconds, RECV, 'target=4s for listener');
truthy(reachable(node.src, b.ctx.destination), 'audible path exists');
});
test('integration: speaker role routes through attachAudioStreamViaWorklet at 0.5s target', () => {
const b = makeBrowser('speaker');
addMember(b, UUID_A, PUB_A);
b.api.attachSfuTrack(UUID_A, makeStream());
const node = b.api.listenerAudioNodes.get(UUID_A);
truthy(node, 'chain built');
eq(node.targetSeconds, SPEAKER, 'target=0.5s for speaker');
});
/* -------- integration: flushSfuStreams skip-on-existing -------- */
test('flushSfuStreams: when a chain already exists for the uuid, no re-attach (kills the peer-joined re-attach storm from 2e74b92)', () => {
/* Pre-2e74b92, the guard only checked remoteAudio (the <audio>
* fallback), which is empty on listeners using the worklet path
* every peer-joined re-attached every speaker audible
* teardown/rebuild race. Fix: check listenerAudioNodes too. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const stream = makeStream();
/* prime: stream is cached AND a chain already exists */
b.api.sfuStreamsByPubHex.set(PUB_A, stream);
b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
const node = b.api.listenerAudioNodes.get(UUID_A);
const srcBefore = node.src;
b.api.flushSfuStreams();
eq(b.api.listenerAudioNodes.get(UUID_A).src, srcBefore, 'src unchanged — no re-attach');
});
test('flushSfuStreams: when a stream is cached but no chain exists yet, it attaches', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const stream = makeStream();
b.api.sfuStreamsByPubHex.set(PUB_A, stream);
b.api.flushSfuStreams();
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built from cache');
const node = b.api.listenerAudioNodes.get(UUID_A);
truthy(reachable(node.src, b.ctx.destination), 'audible path exists');
});
/* -------- integration: multi-speaker listener -------- */
test('integration: listener with three speakers — three independent chains, each reaches destination', () => {
/* The fedora-chrome silent-listener regression repro. If any
* chain is silently dropped or not reaching destination, this fails. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
addMember(b, UUID_B, PUB_B);
addMember(b, UUID_C, PUB_C);
b.api.attachSfuTrack(UUID_A, makeStream());
b.api.attachSfuTrack(UUID_B, makeStream());
b.api.attachSfuTrack(UUID_C, makeStream());
eq(b.api.listenerAudioNodes.size, 3, 'three chains');
for (const uuid of [UUID_A, UUID_B, UUID_C]){
const node = b.api.listenerAudioNodes.get(uuid);
truthy(node, uuid + ' chain present');
truthy(reachable(node.src, b.ctx.destination), uuid + ' reaches destination');
eq(node.gain.gain.value, 1.0, uuid + ' at unity gain');
}
});
test('integration: listener with three speakers, worklet loads AFTER all attached — every chain gets jbuf, every chain reaches destination', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
addMember(b, UUID_B, PUB_B);
addMember(b, UUID_C, PUB_C);
b.api.attachSfuTrack(UUID_A, makeStream());
b.api.attachSfuTrack(UUID_B, makeStream());
b.api.attachSfuTrack(UUID_C, makeStream());
b.api.triggerWorkletReady(); /* worklet finishes loading now */
for (const uuid of [UUID_A, UUID_B, UUID_C]){
const node = b.api.listenerAudioNodes.get(uuid);
truthy(node.jbuf, uuid + ' jbuf installed');
truthy(reachable(node.src, b.ctx.destination), uuid + ' still reaches destination');
}
});
/* -------- regression: publisher rejoin under SAME pubHex with NEW stream -------- */
test('regression: ontrack streamA, track-ended, peer-joined for same pubHex new uuid — fresh chain, no zero-track skip', () => {
/* Pins f479878 cache-drop on track ended. Pre-fix, the dead streamA
* was still in sfuStreamsByPubHex flushSfuStreams handed it to
* attach "no live audio tracks" reject silenced uuid_A2. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const streamA = makeStream();
/* simulate: handleRemoteSfuTrack would set cache + listen for 'ended' */
b.api.sfuStreamsByPubHex.set(PUB_A, streamA);
const trackA = streamA.getAudioTracks()[0];
trackA.addEventListener('ended', () => {
if (b.api.sfuStreamsByPubHex.get(PUB_A) === streamA) b.api.sfuStreamsByPubHex.delete(PUB_A);
});
b.api.attachAudioStreamViaWorklet(UUID_A, streamA, RECV);
truthy(b.api.listenerAudioNodes.has(UUID_A), 'A attached');
/* publisher leaves: trackA ends + peer-left would detach */
trackA.stop();
b.api.detachListenerStream(UUID_A);
falsy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache dropped on ended');
/* publisher rejoins under a new uuid, ontrack arrives with streamB */
addMember(b, UUID_A2, PUB_A);
const streamB = makeStream();
b.api.sfuStreamsByPubHex.set(PUB_A, streamB);
b.api.attachAudioStreamViaWorklet(UUID_A2, streamB, RECV);
truthy(b.api.listenerAudioNodes.has(UUID_A2), 'A2 attached');
const node = b.api.listenerAudioNodes.get(UUID_A2);
truthy(reachable(node.src, b.ctx.destination), 'A2 reaches destination');
});
/* =================== handleRemoteSfuTrack integration =================== */
/* These tests drive the ENTIRE receive-side flow from an ontrack event
* down through chain build. They catch upstream defects where the
* attach FSM is intact but never gets called (member-resolution failure,
* cache-only path, etc.). */
/* Construct a fake ontrack event with the streamID encoding the page
* expects: 16-hex-prefix-of-pubHex (+ '-screen' / '-camera' / '-game'
* for non-mic). */
function fakeOntrack(pubHex, kind, stream){
const sid = (kind === 'mic' || !kind) ? pubHex.slice(0, 16)
: (pubHex.slice(0, 16) + '-' + kind);
stream.id = sid;
return {
streams: [stream],
track: stream.getTracks()[0] || null,
receiver: { playoutDelayHint: 0, jitterBufferTarget: 0 },
};
}
test('handleRemoteSfuTrack: listener ontrack(mic) for known speaker → chain built + reaches destination', () => {
/* The canonical fedora-chrome listener flow: a remote speaker
* publishes mic, ontrack arrives on the SFU sub PC, handleRemoteSfuTrack
* runs, sfuStreamsByPubHex caches by full pubHex, attachSfuTrack fires,
* chain reaches audioCtx.destination. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const stream = makeStream();
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
truthy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cached by pubHex');
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built');
const node = b.api.listenerAudioNodes.get(UUID_A);
truthy(reachable(node.src, b.ctx.destination), 'audible path');
eq(node.targetSeconds, RECV, '4s target for listener');
});
test('handleRemoteSfuTrack: ontrack ARRIVES BEFORE peer-joined (member roster empty) → cached + audible after flush', () => {
/* The fedora-chrome silent-listener race: on a real listener, the SFU
* sub PC ontrack can fire BEFORE the signal-server peer-joined event
* populates `members`. handleRemoteSfuTrack must cache the stream by
* SOMETHING that flushSfuStreams can later resolve to the publisher's
* uuid otherwise the listener stays silent forever even after
* peer-joined arrives. The exact cache key is an implementation
* detail; the test pins the end-state contract: after addMember +
* flushSfuStreams, the chain MUST exist and reach destination. */
const b = makeBrowser('listener');
/* deliberately do NOT addMember(UUID_A) yet */
const stream = makeStream();
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
truthy(b.api.sfuStreamsByPubHex.size > 0, 'cached under SOME key (prefix or full)');
falsy(b.api.listenerAudioNodes.has(UUID_A), 'no attach yet — uuid unknown');
/* peer-joined event lands: add to roster, then flush */
addMember(b, UUID_A, PUB_A);
b.api.flushSfuStreams();
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built on flush');
const node = b.api.listenerAudioNodes.get(UUID_A);
truthy(reachable(node.src, b.ctx.destination), 'audible path');
});
test('handleRemoteSfuTrack: listener does NOT skip SFU when a stale peers map entry is present (kills the "promoted but nobody hears" regression)', () => {
/* The mesh-skip branch ("speakers get peers via mesh") is gated by
* canSpeak(myRole). Listeners must NEVER skip SFU mic even if peers
* happens to have a stale entry they have no mesh PC. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
b.api.peers.set(UUID_A, { connectionState: 'failed' }); /* stale, irrelevant for listener */
const stream = makeStream();
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built, mesh check skipped for listener');
});
test('handleRemoteSfuTrack: track-ended drops the cache entry (so next peer-joined with same pubHex does NOT get a dead stream)', () => {
/* Pins the f479878 cache-drop. fox 2026-06-05 signal log:
* "peer-joined u=64b0 fired audio attach fresh=1 tracks=0" that
* was flushSfuStreams handing the dead cached stream to attach
* after the publisher had left. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
const stream = makeStream();
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
truthy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache primed');
stream.getAudioTracks()[0].stop();
falsy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache dropped on track ended');
});
test('handleRemoteSfuTrack: own publish echo is skipped (sid prefix matches myKeys.pubHex)', () => {
const b = makeBrowser('listener');
/* myKeys.pubHex = 'f'*64 in our stub */
const myPub = 'f'.repeat(64);
const stream = makeStream();
b.api.handleRemoteSfuTrack(fakeOntrack(myPub, 'mic', stream));
falsy(b.api.sfuStreamsByPubHex.has(myPub), 'own echo not cached');
eq(b.api.listenerAudioNodes.size, 0, 'no chain for self');
});
/* =================== audioCtx state regression coverage =================== */
test('listener: attach when audioCtx starts SUSPENDED → resume() is called (Chrome autoplay policy)', () => {
/* Chrome (Fedora especially) leaves audioCtx suspended until a real
* gesture. The attach code calls audioCtx.resume() fire-and-forget.
* This test pins that the call happens. If a future refactor drops
* the resume() call, fedora chrome listeners go silent because
* destination won't emit while suspended. fox 2026-06-05:
* "speaker on firefox chrome was hard restarted and cannot hear host." */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
let resumeCalled = false;
b.ctx.state = 'suspended';
const origResume = b.ctx.resume;
b.ctx.resume = function(){ resumeCalled = true; return origResume.call(b.ctx); };
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
truthy(resumeCalled, 'resume() called when audioCtx was suspended');
});
test('listener: attach when audioCtx is already RUNNING does not call resume()', () => {
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
let resumeCalled = false;
b.ctx.state = 'running';
b.ctx.resume = function(){ resumeCalled = true; return Promise.resolve(); };
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
falsy(resumeCalled, 'resume() not called on running ctx');
});
/* -------- summary -------- */
console.log('');
console.log((fail === 0 ? 'PASS' : 'FAIL') + ' — ' + pass + ' passed, ' + fail + ' failed');
process.exit(fail === 0 ? 0 : 1);

View file

@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})();
</script>
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">b7b1b1b9c612636a17adf94eca5835b5</span><br>
sha256 <span class="stamp-sha">7dff2ae15621b8c56d4c0c5a50afe5b3305317a161b281d66c2417c3b1cbf1a6</span><br>
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-06</span><br>
md5 <span class="stamp-md5">e9fb81e6d445184e8a4967dfe56883f9</span><br>
sha256 <span class="stamp-sha">a44a19223961e6a8e4f6010d073ff1e95246d908eb3b27d322fcd7bb17e5ad39</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 it against these hashes; point it 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>

View file

@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
</p>
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">e4e8e782260db19486c05c042f4f1afc</span><br>
sha256 <span class="stamp-sha">0d6678f32e1c4dcc0dd3adcb052b4b0cc5dc86fb6f2dbc5c9c79775e3feb683e</span><br>
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-06</span><br>
md5 <span class="stamp-md5">f3900aa0c841baa7c61599ff247f8fe1</span><br>
sha256 <span class="stamp-sha">496b0dee4967745d5502d549c30278370cbd228ae75c3020025f9912fa9773d7</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 it against these hashes; point it at your own servers with ?signal= and ?turncred=</span>
</footer>

View file

@ -341,9 +341,9 @@ try {
</p>
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">9870bd4e4dae3439f3238f6c7bfbb0e1</span><br>
sha256 <span class="stamp-sha">7bf54074bc06b6a9378d75cfc678aadf626c2c2e921149db5610a7e0ed86ef6a</span><br>
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-06</span><br>
md5 <span class="stamp-md5">ea02f55584d8042b03fcbd999e1da240</span><br>
sha256 <span class="stamp-sha">c7cb5f8be8241d4634cca84acd3343c1fbfd0c7053b260bcee05084aa7bd83aa</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 it against these hashes; point it 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>

View file

@ -747,9 +747,9 @@ else wirePuppet();
</section>
<footer style="max-width:560px;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-04</span><br>
md5 <span class="stamp-md5">704bc6f8c294b01704add1b59ec23d69</span><br>
sha256 <span class="stamp-sha">06b739c56ebdfab90591ea4c5e8d8cb6b5cd854c02f526753f724857c518a64e</span><br>
<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-06</span><br>
md5 <span class="stamp-md5">ba6a5714f597469b0aa61b32657f50f4</span><br>
sha256 <span class="stamp-sha">78b5fb863ec406ec39081a0231fb909eef3abf332e84c0a5f25942f62f49de1b</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 it against these hashes; point it 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>

View file

@ -3507,16 +3507,31 @@ function flushSfuStreams(){
* fresh-attach on the same uuid and producing audible overlap.
* Fox 2026-06-05 telemetry: "fxhp-android-firefox is hearing two
* streams now as listener" — fix the wrong guard, not double-audio
* via dedup. */
* via dedup.
*
* Match by prefix: the cache key from handleRemoteSfuTrack is whatever
* resolution returned at ontrack time — full pubhex if the member
* roster already had the publisher, or the 16-char streamID prefix
* otherwise (ontrack races peer-joined; SFU sub PC ontrack can fire
* before signal-server announces existing speakers). Strict === on
* the full-decoded mm pubkey would never match the prefix key, so a
* listener that joined a room with N existing speakers stayed
* permanently silent. Symmetry with the startsWith resolution in
* handleRemoteSfuTrack (line ~4640). fox 2026-06-06: "fedora chrome
* is flawless besides not able to hear any mics, was working a few
* days back, only zebra code changed." */
for (const [pubHex, stream] of sfuStreamsByPubHex){
for (const [uuid, mm] of members){
try {
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
if (mm.pubkey){
const fh = hex(unb64(mm.pubkey));
if (fh.startsWith(pubHex)){
if (!remoteAudio.has(uuid) && !listenerAudioNodes.has(uuid)){
attachSfuTrack(uuid, stream);
}
break;
}
}
} catch(_){}
}
}
@ -7875,9 +7890,9 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
</script>
<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-05</span><br>
md5 <span class="stamp-md5">96bc1aa9b23545fe92f4dfe02dfda74c</span><br>
sha256 <span class="stamp-sha">df6d1806da9b5844d6b9779ce84ae41fc21feeb4e2352a747a09662741bab08f</span><br>
<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-06</span><br>
md5 <span class="stamp-md5">cf7bb5c3111551561745c6432932a659</span><br>
sha256 <span class="stamp-sha">fe4edd4a17f457275f1b73dfa3fe61d538a1e0d1b2593efca50b1147a68b71fa</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>