// 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 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; // 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', 'aheadLimit', 'evictBehind', 'flushQueue', 'pump'] .sort((a, b) => app.indexOf(`const ${a} = useCallback(`) - app.indexOf(`const ${b} = useCallback(`)) .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. // Arithmetic is allowed because some of them are written as one (`48 * 1024 * // 1024`), and refusing anything that is not arithmetic keeps this a reader // rather than an evaluator of whatever happens to be on the line. const constOf = (name) => { const m = app.match(new RegExp(`const ${name} = ([^;]+);`)); if (!m) throw new Error(`${name} not found`); if (!/^[\d\s.*/+-]+$/.test(m[1])) throw new Error(`${name} is not a number`); return Number(new Function(`return (${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 BUFFER_AHEAD_STEP_BYTES = constOf('BUFFER_AHEAD_STEP_BYTES'); const BUFFER_AHEAD_MAX_BYTES = constOf('BUFFER_AHEAD_MAX_BYTES'); const BUFFER_AHEAD_MAX_S = constOf('BUFFER_AHEAD_MAX_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; 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; 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. pendingRemoveEvents++; }, }; let pendingRemoveEvents = 0; // `paused` is part of the element the player reads, not decoration: the // read-ahead budget only grows while the film is running, and a fake video // that never reports being paused would exercise the growing branch in a run // whose whole point is that nobody pressed play. const video = { currentTime: 0, paused: !playing }; 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 awaitingInitRef = { current: false }; // The film's own average bitrate, as `stream_init` gives the player, and the // budget state the read-ahead walks up from. Both start where the component // starts them. const bitrateRef = { current: BITRATE }; const aheadBytesRef = { current: 0 }; const aheadCapRef = { current: BUFFER_AHEAD_MAX_BYTES }; const quotaHoldRef = { 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,awaitingInitRef,' + 'bitrateRef,aheadBytesRef,aheadCapRef,quotaHoldRef,' + 'BUFFER_AHEAD_STEP_BYTES,' + 'BUFFER_AHEAD_MAX_BYTES,BUFFER_AHEAD_MAX_S,console,useCallback', src + '\n return {currentRange, bufferedAhead, aheadLimit, 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, awaitingInitRef, bitrateRef, aheadBytesRef, aheadCapRef, quotaHoldRef, BUFFER_AHEAD_STEP_BYTES, BUFFER_AHEAD_MAX_BYTES, BUFFER_AHEAD_MAX_S, 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; // Bytes the link has carried but not yet spent on a whole segment. This used // to be re-created inside the loop and thrown away at the end of every tick, // so a link slower than one segment per tick — 5 MB/s at this resolution, // which is every mobile network there is — delivered *nothing at all* and the // run reported a player that had simply never been fed. Anything below about // forty megabits was unmeasurable here, which is most of the cases worth // measuring. Carried over instead, and capped at one window's worth, because // a link cannot bank a burst larger than what the node may have in flight. let netBudget = 0; 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(); } netBudget = Math.min(netBudget + netMBs * 1048576 * TICK, SEG * STREAM_WINDOW); while (credit > 0 && netBudget >= SEG) { credit--; netBudget -= 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), aheadLimitS: +fns.aheadLimit().toFixed(1), budgetMB: +(aheadBytesRef.current / 1048576).toFixed(1), budgetCapMB: +(aheadCapRef.current / 1048576).toFixed(1), bitrateMbits: +(BITRATE * 8 / 1e6).toFixed(2), watchedS: +video.currentTime.toFixed(1), grants: granted, keepalives, removes, quotaRefusals: quotaRef.current, hitCeiling: bytes >= CAP * 0.99, }));