mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
Starting a gig dropped the player onto the fallback 2D highway with no venue.
startGig() calls setViz('venue'), which installs the 3D renderer — whose init is
async — and then immediately starts its play queue. playSong() re-initialises
that same renderer a tick later. A renderer mints a fresh readyPromise per
init() and rejects the previous one with "superseded"; highway.js only checked
that the RENDERER OBJECT was unchanged, which it is. So it treated a healthy,
re-initialising renderer as a failed one, tore it down, and reverted to 2D:
renderer async init failure: Error: superseded
viz picker: reverted to default renderer (async-init-failure)
The guard now also checks the PROMISE identity: a rejection from an init cycle
the renderer has already moved on from is ignored. The renderer-identity guard
stays (a rejection for a renderer since REPLACED is also not ours), and a
genuine failure of the CURRENT cycle still reverts — both init() call sites go
through _setRenderer, which re-wires the handler every time, so the new cycle is
always watched.
Reproduced and fixed against the real build:
before: vizSelection=default viz-picker=default venue=inactive viz:reverted
after: vizSelection=venue viz-picker=venue venue=ACTIVE (no revert)
Also widens the paused-frame throttle's opt-out. The throttle fires whenever the
CHART CLOCK is stalled — not only on a pause, but through a count-in and the
credits/author overlay too. Its opt-out only asked "is a crowd video rolling",
but the venue scene animates on a clock of its own with no pack at all (backdrop
breathe, parallax, haze drift, warmth pulse — Math.sin(t) in the draw loop), so
that motion was still being throttled. It now claims frames for both sources; a
plain 3D highway with no venue reads motion mode 'off' and keeps the #654 GPU
saving.
HONEST CAVEAT on that second part: I could not get the throttle to fire in a
reproduction. A control run on the shipped code showed 100 draws/sec while
paused, not the ~10/sec a firing throttle would give — so the change is
defensible on its own terms (a stalled clock is genuinely not a static picture)
but it does NOT have a demonstrated symptom behind it. The viz fix above does.
Tests: the superseded guard, and that the throttle opt-out covers both motion
sources. All fail against the pre-fix source. eslint 0 errors; JS 1207/1207.
166 lines
8.9 KiB
JavaScript
166 lines
8.9 KiB
JavaScript
// Source-level guards for the playback-aware paused-render throttle
|
|
// (feedBack#654). The createHighway closure owns the rAF loop + WebGL
|
|
// context lifecycle that's too heavy to reproduce in a vm sandbox, so —
|
|
// like highway_visibility.test.js — these checks lock in the wiring.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
|
|
|
// Brace-balanced extraction (shared shape with highway_visibility.test.js)
|
|
// so a future edit that grows the loop body doesn't get truncated.
|
|
function extractBlock(src, signature) {
|
|
const start = src.indexOf(signature);
|
|
assert.ok(start !== -1, `signature '${signature}' not found`);
|
|
const openBrace = src.indexOf('{', start);
|
|
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
|
|
let depth = 1;
|
|
let i = openBrace + 1;
|
|
while (i < src.length && depth > 0) {
|
|
const ch = src[i];
|
|
if (ch === '{') depth++;
|
|
else if (ch === '}') depth--;
|
|
i++;
|
|
}
|
|
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
|
|
return src.slice(start, i);
|
|
}
|
|
|
|
|
|
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
|
|
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
|
|
// means they break again on the next carve — and worse, a source-shape assertion that silently
|
|
// stops finding its target is indistinguishable from one that passes.
|
|
function highwaySources() {
|
|
const root = path.join(__dirname, '..', '..');
|
|
const jsDir = path.join(root, 'static', 'js');
|
|
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
|
|
for (const f of fs.readdirSync(jsDir).sort()) {
|
|
if (f.startsWith('highway-') && f.endsWith('.js')) {
|
|
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
|
}
|
|
}
|
|
return parts.join('\n');
|
|
}
|
|
|
|
test('highway declares the paused-render throttle state', () => {
|
|
const src = highwaySources();
|
|
assert.match(src, /(?:export\s+)?const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
|
|
assert.match(src, /hwState\._lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
|
});
|
|
|
|
test('draw() throttles full renders while the audio clock is stalled', () => {
|
|
const src = highwaySources();
|
|
const fn = extractBlock(src, 'function draw()');
|
|
// Reuse getTime()'s pause signal rather than inventing a parallel one.
|
|
assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)');
|
|
assert.match(fn, /_CHART_MAX_INTERP_MS/, 'throttle must reuse the _CHART_MAX_INTERP_MS pause threshold');
|
|
assert.match(fn, /_PAUSED_FRAME_INTERVAL_MS/, 'throttle must cap paused draws to _PAUSED_FRAME_INTERVAL_MS');
|
|
assert.match(fn, /hwState\._lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
|
});
|
|
|
|
test('throttle runs after the ready gate, before bundle/draw', () => {
|
|
const src = highwaySources();
|
|
const fn = extractBlock(src, 'function draw()');
|
|
// Regex landmarks (not exact-string indexOf) so harmless spacing /
|
|
// semicolon changes don't break the ordering guard — matches the
|
|
// search-based style of the other highway source-guard tests.
|
|
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return;/);
|
|
const throttleIdx = fn.search(/_PAUSED_FRAME_INTERVAL_MS/);
|
|
const drawIdx = fn.search(/_renderer\.draw\s*\(/);
|
|
assert.ok(readyIdx !== -1, 'ready gate not found');
|
|
assert.ok(throttleIdx !== -1, 'throttle not found');
|
|
assert.ok(drawIdx !== -1, '_renderer.draw call not found');
|
|
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
|
|
assert.ok(throttleIdx < drawIdx, 'throttle must come before the renderer draw');
|
|
});
|
|
|
|
// ── The throttle must not starve a renderer that animates on its own clock ──
|
|
//
|
|
// The throttle assumes a paused chart is a still picture, so re-rendering it is
|
|
// waste. That stopped being true when the venue landed: the 3D highway draws the
|
|
// venue's VIDEO backdrop and its reactive crowd into the same canvas as the
|
|
// notes, so capping paused frames capped the whole room — pausing the song
|
|
// dropped the venue to ~10 fps ("everything around the highway drops fps").
|
|
//
|
|
// Renderers now opt out via an optional needsContinuousFrames(). Absent or
|
|
// throwing must mean false, so every other renderer keeps the throttle.
|
|
|
|
test('paused throttle defers to a renderer that needs continuous frames', () => {
|
|
const src = highwaySources();
|
|
const fn = extractBlock(src, 'function draw()');
|
|
assert.match(fn, /_rendererNeedsContinuousFrames\s*\(\s*\)/,
|
|
'the paused throttle must consult the renderer capability');
|
|
// The capability must GATE the early-return, not merely be called near it:
|
|
// the throttle only applies when the renderer does NOT need every frame.
|
|
assert.match(
|
|
fn,
|
|
/!\s*_rendererNeedsContinuousFrames\s*\(\s*\)[\s\S]{0,160}_PAUSED_FRAME_INTERVAL_MS[\s\S]{0,40}return;/,
|
|
'throttle must be skipped when the renderer needs continuous frames',
|
|
);
|
|
});
|
|
|
|
test('the capability probe fails closed (absent / non-function / throwing)', () => {
|
|
const src = highwaySources();
|
|
const fn = extractBlock(src, 'function _rendererNeedsContinuousFrames()');
|
|
assert.match(fn, /typeof\s+r\.needsContinuousFrames\s*!==\s*'function'[\s\S]{0,40}return false/,
|
|
'a renderer without the method must keep the throttle');
|
|
assert.match(fn, /catch[\s\S]{0,40}return false/,
|
|
'a throwing renderer must keep the throttle, not crash the draw loop');
|
|
assert.match(fn, /===\s*true/,
|
|
'only an explicit true opts out — a truthy accident must not disable the throttle');
|
|
});
|
|
|
|
test('3D highway claims continuous frames for BOTH sources of venue motion', () => {
|
|
const h3d = fs.readFileSync(
|
|
path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
|
|
const fn = extractBlock(h3d, 'needsContinuousFrames()');
|
|
// (1) a crowd video rolling on its own clock (career venue pack)
|
|
assert.match(fn, /_venueCrowdVideos/, 'must key off the actual crowd video elements');
|
|
assert.match(fn, /\.paused/, 'a paused video is a still frame');
|
|
// (2) the venue scene's OWN fake-depth motion — backdrop breathe, haze drift,
|
|
// warmth pulse, shimmer. Math.sin(t) in the draw loop, so it only moves while
|
|
// we get frames, and it runs with NO pack at all. Missing this meant the venue
|
|
// still stuttered on pause / count-in / credits whenever no video was rolling.
|
|
assert.match(fn, /_venueEffectiveMotionMode\s*\(\s*\)\s*!==\s*'off'/,
|
|
'the venue scene animates without any video — it must claim frames too');
|
|
// ...and with no venue at all the paused scene IS static: the #654 GPU saving
|
|
// must survive, so the method has to be able to return false.
|
|
assert.match(fn, /return false;/, 'must fall through to false on a plain 3D highway');
|
|
});
|
|
|
|
// ── a SUPERSEDED init is not a FAILED init ──────────────────────────────────
|
|
//
|
|
// Starting a gig dropped the player onto the fallback 2D highway with no venue.
|
|
//
|
|
// setViz('venue') installs the 3D renderer, whose init is async; the gig then
|
|
// immediately starts its play queue, and playSong() re-initialises that same
|
|
// renderer a tick later. A renderer mints a fresh readyPromise per init() and
|
|
// rejects the previous one with "superseded" — but highway.js only checked that
|
|
// the RENDERER object was unchanged, which it is. So it treated a healthy
|
|
// re-initialising renderer as a failed one, tore it down, and reverted to 2D:
|
|
//
|
|
// renderer async init failure: Error: superseded
|
|
// viz picker: reverted to default renderer (async-init-failure)
|
|
//
|
|
// Reproduced and fixed against the real build (venue stays selected, scene
|
|
// active, no viz:reverted).
|
|
|
|
test('a superseded readyPromise must not revert the viz to 2D', () => {
|
|
const src = highwaySources();
|
|
const fn = extractBlock(src, 'function _handleAsyncInitFailure(e)');
|
|
assert.match(fn, /readyPromise\s*!==\s*rp[\s\S]{0,40}return/,
|
|
'a rejection from a STALE readyPromise (the renderer has since re-init\'d) must be ' +
|
|
'ignored — otherwise a re-initialising renderer is torn down as if it had failed');
|
|
// The renderer-identity check must survive too: a rejection belonging to a
|
|
// renderer that has since been REPLACED is also not our problem.
|
|
assert.match(fn, /hwState\._renderer\s*!==\s*_installedRenderer[\s\S]{0,20}return/,
|
|
'the renderer-identity guard must remain');
|
|
// ...and a genuine failure of the CURRENT init cycle must still revert.
|
|
assert.match(fn, /_emitVizReverted\s*\(\s*'async-init-failure'\s*\)/,
|
|
'a real async-init failure must still fall back to the default renderer');
|
|
});
|