diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-17 02:16:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-17 02:16:28 +0200 |
| commit | 42047dac4041e72e09499e3adf145f1c0f83b284 (patch) | |
| tree | b816f20fd8b3b190faa69473b7a2892f3bfc9706 /packages/meshbay-hub/tests/harness | |
| parent | f5c4c058aa7e91fbdbbdd8cf34042535a05df433 (diff) | |
| download | meshbay-42047dac4041e72e09499e3adf145f1c0f83b284.tar.gz | |
test(hub): a hook that depends on one declared below it never runs
`const a = useCallback(fn, [b])` evaluates `[b]` where it is written, so a `b`
further down the component is still in its temporal dead zone. ReferenceError on
every render, before anything the component does can run — and the symptom is
the component simply not appearing. Clicking a video did nothing at all: no
picture, no error on screen, nothing in the node's log because nothing was ever
requested. It reached production.
Nothing caught it. `node --check` passes, the code is well-formed. Worse, the
MSE harness extracts the player functions into an order of its own and therefore
*reordered* them before running — quietly repairing the one class of defect it
was best placed to catch. It sorts by position in the file now, and
test_hook_ordering.py checks the property directly across the whole SPA. Both
the rule and the harness are checked against the layout that actually shipped.
test_video_seek.py covers the rest of seeking, and window_leak.mjs forces the
race that made the third seek hang: the whole in-flight window arriving while
`reinitAt` is still awaiting. Before, the player is left believing eight
segments are in flight and grants nothing; after, the window comes back. A run
that happens to work proves nothing about a race, which is the point of forcing
the worst case rather than trusting a longer session.
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/mse_harness.mjs | 37 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/harness/window_leak.mjs | 73 |
2 files changed, 103 insertions, 7 deletions
diff --git a/packages/meshbay-hub/tests/harness/mse_harness.mjs b/packages/meshbay-hub/tests/harness/mse_harness.mjs index 0daacfb..437a542 100644 --- a/packages/meshbay-hub/tests/harness/mse_harness.mjs +++ b/packages/meshbay-hub/tests/harness/mse_harness.mjs @@ -31,7 +31,13 @@ const grab = (name) => { }; const useCallback = (fn) => fn; -const src = ['bufferedAhead', 'evictBehind', 'flushQueue', 'pump'] +// Sorted by where they appear in app.js, not by the order this list happens to +// be written in. Extracting into an order of our own would quietly repair a +// hook declared before its own dependency — a real fault, which reached +// production once, and which the harness is otherwise well placed to catch. +const src = ['currentRange', 'bufferedAhead', 'evictBehind', 'flushQueue', 'pump'] + .sort((a, b) => app.indexOf(`const ${a} = useCallback(`) + - app.indexOf(`const ${b} = useCallback(`)) .map(grab).join('\n'); const SEG = 256 * 1024; @@ -68,17 +74,34 @@ const sb = { const e = new Error('quota'); e.name = 'QuotaExceededError'; throw e; } const at = ranges.length ? ranges[ranges.length - 1][1] : 0; - ranges.push([at, at + chunk.byteLength / BITRATE]); + const end = at + chunk.byteLength / BITRATE; + // A real SourceBuffer coalesces contiguous ranges: `buffered` reports the + // spans of media it holds, not the appends that built them. Pushing one + // range per segment made every range a couple of seconds long, which is + // invisible to code that reads `end(length - 1)` and fatal to code that + // looks for the range around the playhead. + if (ranges.length && Math.abs(ranges[ranges.length - 1][1] - at) < 0.001) { + ranges[ranges.length - 1][1] = end; + } else { + ranges.push([at, end]); + } bytes += chunk.byteLength; appended++; }, remove(a, b) { removes++; + // remove(a, b) takes a span out of whatever it overlaps, trimming a range + // rather than only dropping whole ones — otherwise a coalesced range is + // never evicted at all and the buffer grows without limit. let dropped = 0; - ranges = ranges.filter(([s, e]) => { - if (e <= b && s >= a) { dropped += (e - s) * BITRATE; return false; } - return true; - }); + const kept = []; + for (const [s, e] of ranges) { + if (e <= a || s >= b) { kept.push([s, e]); continue; } + if (s < a) kept.push([s, a]); + if (e > b) kept.push([b, e]); + dropped += (Math.min(e, b) - Math.max(s, a)) * BITRATE; + } + ranges = kept; bytes -= dropped; // A real remove() is asynchronous and fires updateend when it lands. That // event is indistinguishable from an append's unless the player kept track. @@ -108,7 +131,7 @@ const fns = new Function( 'sbRef,videoRef,msRef,queueRef,appendingRef,endedRef,outstandingRef,' + 'lastPokeRef,transportRef,BUFFER_BEHIND_S,BUFFER_AHEAD_S,QUEUE_HIGH_WATER,' + 'CREDIT_KEEPALIVE_MS,STREAM_WINDOW,quotaRef,stalledRef,console,useCallback', - src + '\n return {bufferedAhead, evictBehind, flushQueue, pump};' + src + '\n return {currentRange, bufferedAhead, evictBehind, flushQueue, pump};' )(sbRef, videoRef, msRef, queueRef, appendingRef, endedRef, outstandingRef, lastPokeRef, transportRef, BUFFER_BEHIND_S, BUFFER_AHEAD_S, QUEUE_HIGH_WATER, CREDIT_KEEPALIVE_MS, STREAM_WINDOW, quotaRef, stalledRef, console, useCallback); diff --git a/packages/meshbay-hub/tests/harness/window_leak.mjs b/packages/meshbay-hub/tests/harness/window_leak.mjs new file mode 100644 index 0000000..ae6d46e --- /dev/null +++ b/packages/meshbay-hub/tests/harness/window_leak.mjs @@ -0,0 +1,73 @@ +// Run the shipped onStreamData against a seek that lands badly. +// +// The defect was a race: `reinitAt` waits for two `updateend` events, and the +// segments the node sends in that gap are discarded. Whether the player +// survived depended on how many arrived before the gap closed — which is why +// the same seek worked twice and hung on the third, and why "it works now" is +// not on its own evidence that it is fixed. +// +// So force the worst case. Every in-flight segment arrives during the gap. The +// question is only whether the window comes back. +// +// The handler is lifted out of app.js as text, like the rest of the harness. +// What is modelled is the transport and the clock. +import { readFileSync } from 'fs'; + +const app = readFileSync(process.argv[2], 'utf8'); +const cfg = JSON.parse(process.argv[3] || '{}'); +const { decrementFirst = true } = cfg; // false reproduces the shipped defect + +const STREAM_WINDOW = Number(app.match(/const STREAM_WINDOW = (\d+)/)[1]); + +// The body of `transport.onStreamData = async (msg) => { ... }`. +const start = app.indexOf('transport.onStreamData = async (msg) => {'); +const body = app.slice(app.indexOf('{', start) + 1, + app.indexOf('\n };', start)); + +// The A/B: put the decrement back after the guards, which is where it was. +const DECREMENT = 'outstandingRef.current = Math.max(0, outstandingRef.current - 1);'; +let source = body; +if (!decrementFirst) { + source = source.replace(DECREMENT, ''); + source = source.replace('if (msg.file_id && msg.file_id !== entry.id) return;', + 'if (msg.file_id && msg.file_id !== entry.id) return;\n' + DECREMENT); +} + +const outstandingRef = { current: STREAM_WINDOW }; +const awaitingInitRef = { current: true }; // mid-reinit, as after a seek +const queueRef = { current: [] }; +const entry = { id: 'abc' }; +let cancelled = false; +const pump = () => {}; +const flushQueue = () => {}; +const gekRef = { current: null }; +const window_ = { MeshBayCrypto: { decryptChunkBin: async () => new Uint8Array(4) } }; + +const handler = new Function( + 'msg', 'cancelled', 'awaitingInitRef', 'outstandingRef', 'queueRef', + 'entry', 'gekRef', 'pump', 'flushQueue', 'console', 'window', + `return (async () => {${source}})();`); + +const deliver = (n) => Promise.all( + Array.from({ length: n }, (_, i) => handler( + { file_id: entry.id, segment_index: i, nonce: 'n', ct: 'c' }, + cancelled, awaitingInitRef, outstandingRef, queueRef, entry, gekRef, + pump, flushQueue, console, window_))); + +const run = async () => { + // The whole window arrives while reinitAt is still awaiting its updateends. + await deliver(STREAM_WINDOW); + const duringGap = outstandingRef.current; + // reinitAt finishes and lowers the flag. + awaitingInitRef.current = false; + // pump() would now top the window up to STREAM_WINDOW - outstanding. + const roomAfterwards = STREAM_WINDOW - outstandingRef.current; + console.log(JSON.stringify({ + decrementFirst, + windowAfterDiscards: duringGap, + creditPumpWouldGrant: roomAfterwards, + deadlocked: roomAfterwards <= 0, + })); +}; + +run(); |