ASCILINE/experiments/test_decode_order.js

112 lines
4.5 KiB
JavaScript
Raw Normal View History

fix: top-3 review findings (decoder ordering, public-by-default, per-session state) Frontend (app.js): - Serialize the stateful adaptive-codec decoder through a promise chain. decode() awaits a real async DecompressionStream, so the previous concurrent .then() let a small DELTA resolve before its keyframe and patch a stale/null prev -> corrupt frames. Adds .catch + stale-decoder guard so a re-INIT drops in-flight frames from the previous segment. - Flush frameBuffer on INIT so playlist transitions don't stall the reset master clock on the previous video's tail frames (or render them under the new renderer on a mode change). - Request /audio?v=<idx> using the new INIT queue-index field so audio is correct when multiple clients are connected. Server (stream_server.py): - Bind 127.0.0.1 by default (--host to opt into LAN); same-origin Origin check before streaming (CSWSH defense that still allows LAN same-origin). - Scope /static to an app.js/style.css/codec.js whitelist (was serving the whole repo: source, playlist, any local .env/notes). - Per-session audio: INIT carries the queue index; /audio?v= reads it (bounds-checked) instead of the shared global current_index. - Validate/clamp playlist+CLI mode/vol/pixel/cols/rows; guard malformed playlist JSON. ffmpeg gets -nostdin + terminate/kill-with-timeout. - Re-enable WS keepalive (reap dead clients); release VideoCapture on the isOpened()-false path. Adds experiments/test_decode_order.js: dependency-free regression proving serialized decode is bit-exact + in-order and that delta-before-keyframe throws (no video fixtures needed). Server fixes built by Codex from a Claude spec; Claude integrated + reviewed (tightened the Origin check to same-origin so --host 0.0.0.0 LAN mode works). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:01:27 -06:00
/**
* Codec decode-order regression test dependency-free (no video fixtures).
*
* Encodes a keyframe + DELTA stream in the exact ASCILINE wire format using
* Node's zlib (RFC1950, the same format Python's zlib.compress emits), then:
* 1. decodes it through a SERIALIZED chain (the shipped app.js pattern) and
* asserts every frame is byte-exact AND in order, and
* 2. shows a DELTA decoded before its keyframe throws i.e. arrival order is
* load-bearing, which is exactly why the decoder must be serialized.
*
* Usage: node experiments/test_decode_order.js
*/
const codec = require('../codec.js');
const zlib = require('zlib');
const ROWS = 16, COLS = 16, C = 4, CELLS = ROWS * COLS, FB = CELLS * C;
const N = 12, KEYFRAME_INTERVAL = 48;
function be32(n) { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0, 0); return b; }
function le32(n) { const b = Buffer.alloc(4); b.writeUInt32LE(n >>> 0, 0); return b; }
function ab(buf) { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); }
// Deterministic frames: a varied (poorly-compressible) frame 0, then a few
// changed cells per frame so DELTA decisively beats full-frame zlib.
function makeFrames() {
const frames = [];
const f0 = new Uint8Array(FB);
for (let i = 0; i < FB; i++) f0[i] = (i * 7 + (i >> 2) * 13) & 0xff;
frames.push(f0);
for (let i = 1; i < N; i++) {
const nf = frames[i - 1].slice();
for (let j = 0; j < 2; j++) {
const cell = (i * 5 + j * 37) % CELLS;
for (let b = 0; b < C; b++) nf[cell * C + b] = (i * 31 + cell * 17 + b * 11) & 0xff;
}
frames.push(nf);
}
return frames;
}
// Mirror of codec.py's encoder (lossless): smallest of RAW / ZLIB / DELTA.
function encode(frames) {
const msgs = []; let prev = null;
for (let i = 0; i < frames.length; i++) {
const raw = Buffer.from(frames[i]);
if (prev === null || i % KEYFRAME_INTERVAL === 0) {
const z = zlib.deflateSync(raw);
const tag = z.length < raw.length ? 1 : 0;
msgs.push(Buffer.concat([be32(i), Buffer.from([tag]), tag === 1 ? z : raw]));
prev = frames[i]; continue;
}
const idxs = [];
for (let c = 0; c < CELLS; c++) {
for (let b = 0; b < C; b++) {
if (frames[i][c * C + b] !== prev[c * C + b]) { idxs.push(c); break; }
}
}
const body = Buffer.concat([
...idxs.map(le32),
...idxs.map(c => Buffer.from(frames[i].slice(c * C, c * C + C))),
]);
const dz = zlib.deflateSync(body);
const fz = zlib.deflateSync(raw);
let tag, payload;
if (dz.length <= fz.length) { tag = 2; payload = dz; } else { tag = 1; payload = fz; }
if (raw.length < payload.length) { tag = 0; payload = raw; }
msgs.push(Buffer.concat([be32(i), Buffer.from([tag]), payload]));
prev = frames[i];
}
return msgs;
}
async function decodeSerial(msgs, cellBytes) {
const dec = codec.makeDecoder(cellBytes);
const out = [];
let chain = Promise.resolve();
for (const m of msgs) {
chain = chain.then(async () => {
const { frameIndex, frame } = await dec.decode(ab(m));
out.push({ frameIndex, frame });
});
}
await chain;
return out;
}
(async () => {
const frames = makeFrames();
const msgs = encode(frames);
const tags = msgs.map(m => m[4]).join('');
console.log(`frames: ${frames.length} tags: ${tags} (0=RAW 1=ZLIB 2=DELTA)`);
if (!tags.includes('2')) { console.error('FAIL: no DELTA frames produced — test is not exercising the stateful path'); process.exit(1); }
const out = await decodeSerial(msgs, C);
let bad = 0;
for (let i = 0; i < frames.length; i++) {
if (out[i].frameIndex !== i) { bad++; console.log(`order FAIL at ${i}: got index ${out[i].frameIndex}`); continue; }
const got = out[i].frame, want = frames[i];
if (got.length !== want.length) { bad++; console.log(`len FAIL frame ${i}`); continue; }
for (let j = 0; j < want.length; j++) if (got[j] !== want[j]) { bad++; console.log(`byte FAIL frame ${i} @ ${j}`); break; }
}
console.log(`serialized decode : ${bad === 0 ? 'PASS (bit-exact + in-order)' : 'FAIL (' + bad + ')'}`);
// A DELTA decoded with no prior keyframe must fail — proving order matters.
let threw = false;
try { await codec.makeDecoder(C).decode(ab(msgs.find(m => m[4] === 2))); }
catch { threw = true; }
console.log(`order load-bearing: ${threw ? 'YES — delta-before-keyframe throws (this is what the concurrency bug caused)' : 'NO'}`);
process.exit(bad === 0 && threw ? 0 : 1);
})().catch(e => { console.error('ERROR', e); process.exit(2); });