diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/mse_harness.mjs | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/mse_harness.mjs b/packages/meshbay-hub/tests/harness/mse_harness.mjs new file mode 100644 index 0000000..0daacfb --- /dev/null +++ b/packages/meshbay-hub/tests/harness/mse_harness.mjs @@ -0,0 +1,159 @@ +// Run the SHIPPED player functions against a fake SourceBuffer. +// +// The point is that nothing here is a paraphrase of app.js: `bufferedAhead`, +// `evictBehind`, `flushQueue` and `pump` are lifted out of the file as text and +// executed. A model of a fix, written by whoever wrote the fix, agrees with it +// by construction — which is how a passing test sat next to a player that still +// hung. What is modelled here is the *browser*: a SourceBuffer with a ceiling, +// and `updateend` firing for removals as well as appends. +// +// Usage: node mse_harness.mjs <path to app.js> <json config> +import { readFileSync } from 'fs'; + +const app = readFileSync(process.argv[2], 'utf8'); +const cfg = JSON.parse(process.argv[3] || '{}'); + +const { + playing = false, // does the viewer actually press play + capMB = 100, // where the browser refuses the append + fileMB = 493.5, // the film, from a real upload + durationS = 3936, + netMBs = 35, // measured node throughput + wallS = 600, +} = cfg; + +const grab = (name) => { + const start = app.indexOf(`const ${name} = useCallback(`); + if (start < 0) throw new Error(`${name} not found in app.js`); + const deps = app.indexOf('\n }, [', start); + const end = app.indexOf(');', deps) + 2; + return app.slice(start, end); +}; + +const useCallback = (fn) => fn; +const src = ['bufferedAhead', 'evictBehind', 'flushQueue', 'pump'] + .map(grab).join('\n'); + +const SEG = 256 * 1024; +const CAP = capMB * 1048576; +const BITRATE = fileMB * 1048576 / durationS; + +// Read from app.js too, so a change to the constants is a change to the test. +const constOf = (name) => { + const m = app.match(new RegExp(`const ${name} = (\\d+)`)); + if (!m) throw new Error(`${name} not found`); + return Number(m[1]); +}; +const BUFFER_BEHIND_S = constOf('BUFFER_BEHIND_S'); +const STREAM_WINDOW = constOf('STREAM_WINDOW'); +const BUFFER_AHEAD_S = constOf('BUFFER_AHEAD_S'); +const QUEUE_HIGH_WATER = constOf('QUEUE_HIGH_WATER'); +const CREDIT_KEEPALIVE_MS = constOf('CREDIT_KEEPALIVE_MS'); + +let bytes = 0, ranges = [], appended = 0, removes = 0; +let granted = 0, keepalives = 0, sent = 0, credit = 0, quotaRefusals = 0; + +const sb = { + updating: false, + get buffered() { + return { + get length() { return ranges.length; }, + start: (i) => ranges[i][0], + end: (i) => ranges[i][1], + }; + }, + appendBuffer(chunk) { + if (bytes + chunk.byteLength > CAP) { + quotaRefusals++; + 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]); + bytes += chunk.byteLength; + appended++; + }, + remove(a, b) { + removes++; + let dropped = 0; + ranges = ranges.filter(([s, e]) => { + if (e <= b && s >= a) { dropped += (e - s) * BITRATE; return false; } + return true; + }); + 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. + pendingRemoveEvents++; + }, +}; +let pendingRemoveEvents = 0; + +const video = { currentTime: 0 }; +const sbRef = { current: sb }, videoRef = { current: video }; +const msRef = { current: { readyState: 'open', endOfStream() {} } }; +const queueRef = { current: [] }; +const appendingRef = { current: false }, endedRef = { current: false }; +const outstandingRef = { current: 0 }, lastPokeRef = { current: 0 }; +const quotaRef = { current: 0 }, stalledRef = { current: false }; +const transportRef = { + current: { + connected: true, + grantStreamCredit(n) { + if (n === 0) { keepalives++; return; } + granted += n; credit += n; + }, + }, +}; + +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};' +)(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); + +// The player's own `updateend` listener, transcribed — the one part of the +// component that is a listener rather than a callback, and the place the +// remove/append distinction lives. +const updateend = () => { + appendingRef.current = false; + fns.pump(); +}; + +credit = STREAM_WINDOW; +outstandingRef.current = STREAM_WINDOW; +let wall = 0; +const TICK = 0.05; +while (wall < wallS) { + wall += TICK; + if (playing) { + const end = ranges.length ? ranges[ranges.length - 1][1] : 0; + video.currentTime = Math.min(video.currentTime + TICK, end); + } + fns.pump(); // the 1 s timer and timeupdate + while (pendingRemoveEvents > 0) { pendingRemoveEvents--; updateend(); } + + let budget = netMBs * 1048576 * TICK; + while (credit > 0 && budget >= SEG) { + credit--; budget -= SEG; sent++; + outstandingRef.current = Math.max(0, outstandingRef.current - 1); + queueRef.current.push({ byteLength: SEG }); + fns.flushQueue(); + if (appendingRef.current) updateend(); + while (pendingRemoveEvents > 0) { pendingRemoveEvents--; updateend(); } + } +} + +console.log(JSON.stringify({ + sentMB: +(sent * SEG / 1048576).toFixed(1), + heldInBufferMB: +(bytes / 1048576).toFixed(1), + queueDepth: queueRef.current.length, + bufferedAheadS: +fns.bufferedAhead().toFixed(1), + watchedS: +video.currentTime.toFixed(1), + grants: granted, + keepalives, + removes, + quotaRefusals: quotaRef.current, + hitCeiling: bytes >= CAP * 0.99, +})); |