mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
ship-ci / ci (push) Waiting to run
* feat(career): extract the whole setlist before the gig starts
A feedpak is a zip, and the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs: the player finished
a number and then sat there waiting for the next one to unpack, mid-gig.
A setlist is a known list up front, so unpack it all while the poster is still on
screen. New POST /gigs/prepare walks the set through resolve_source_dir; the
poster's Play button shows "Preparing set…" while it runs.
Best-effort by design, at every level:
- a corrupt pak in the set does not sink the prepare (it is reported in
`failed`; the play itself surfaces the error exactly as it does outside a
gig — slow beats blocked)
- a host without the library resolvers degrades to a no-op rather than 500
- a failed request just falls through to the old lazy extraction
Ordering matters and is pinned: the set is unpacked BEFORE the stage is borrowed
(venue/viz overwritten) and before the queue starts, so a proposal cancelled
while unpacking leaves nothing half-applied to unwind.
Tests unpack REAL zips rather than mocking the extractor: every song of the set
lands on disk before the first note, a re-prepare does not duplicate the unpack,
one bad pak still leaves the good one prepared, and no-library / empty-setlist
degrade cleanly. 18/18.
NB the other half of the gig report — the per-song results popup interrupting
the set (and worse, claimAutoExit'ing so the queue would not advance until it was
dismissed) — is fixed in the note_detect plugin repo, which is not part of this
checkout.
* fix(career): bound the prepare request; validate the setlist (PR #971 review)
Both CodeRabbit findings were right.
1. A HUNG PREPARE COULD BLOCK THE GIG FOREVER.
`await fetch(...)` only rejects on a network ERROR. A server that accepts the
connection and then never answers hangs indefinitely — and the gig would never
start. That makes this optimisation the exact thing the PR promises it can
never be: the reason you cannot play.
The request is now bounded by an AbortController (PREPARE_TIMEOUT_MS, generous
because unpacking a setlist is real work — but a CEILING, not a wait). Past it
we start the gig and let the first play extract lazily, as it always did. The
Play button is restored in a `finally`, so a timeout cannot strand the poster
on "Preparing set…" with Play disabled — which would have been the same bug
wearing a different hat.
2. THE `songs` BODY WAS UNVALIDATED.
A str is iterable: "abc" would have prepared three one-character "songs". And
the endpoint unpacks zips, so an arbitrary caller could ask for unbounded work.
Now list-only, string entries, blanks dropped, capped at MAX_GIG_SONGS.
Tests: the fetch is abortable and the button is re-enabled on EVERY path
including the abort; non-list bodies, non-string/blank entries, and an
oversized setlist. 50 career tests, JS 5/5, eslint clean.
* fix(career): path-traversal guard on prepare; a cap test that actually tests the cap
CodeRabbit again, and the first one is a real hole I put there.
1. PATH TRAVERSAL. sloppak.resolve_source_dir() does a bare `dlc_root / filename`
with NO containment guard — so `../../x` walks straight out of the library, and
my new endpoint handed it attacker-supplied filenames. Every filename now goes
through _resolve_dlc_path first, the same check every other filename-bound
handler applies. Pinned: `..`, backslash traversal, an absolute POSIX path and
a Windows drive path are all refused, and nothing outside the library is
unpacked.
2. THE CAP TEST WAS VACUOUS. It asserted `prepared == 0` against a fixture with no
library — where the endpoint exits before extraction — so it passed whether or
not MAX_GIG_SONGS existed. It now runs against a real library and asserts the
endpoint CONSIDERED at most MAX_GIG_SONGS of the 82 it was handed. Verified to
fail when the cap is removed.
Same class of mistake as the notedetect gigBlock: a test that passes for the
wrong reason. Worth saying out loud since it is twice in one day.
3. E702 — semicolon-joined statements in the new tests, split.
51 career tests; full suite green.
99 lines
4.8 KiB
JavaScript
99 lines
4.8 KiB
JavaScript
// A gig is a SET, not a run of unrelated songs.
|
|
//
|
|
// Reported from a live gig: the player finished the first song and had to sit
|
|
// through the per-song results popup before the next one would start, and then
|
|
// wait again while that song was extracted from its feedpak zip.
|
|
//
|
|
// This file covers the CORE half — career pre-extracts the whole setlist before
|
|
// the first note. The other half (note_detect must not show its per-song summary
|
|
// inside a gig) lives in the note_detect plugin repo, which is not part of this
|
|
// checkout: plugins/*/ is gitignored here and note_detect ships from
|
|
// feedBack-plugin-notedetect. A test reading it from core would pass on a dev
|
|
// box (where the plugin happens to be bundled) and fail in CI, which is worse
|
|
// than no test.
|
|
//
|
|
// The pre-extraction is tested for REAL behaviour — actually unpacking zips — in
|
|
// tests/plugins/career/test_routes.py. These are the wiring guards around it.
|
|
|
|
'use strict';
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..', '..');
|
|
const CAREER = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8');
|
|
const CAREER_ROUTES = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'routes.py'), 'utf8');
|
|
|
|
function extractBlock(src, signature) {
|
|
const start = src.indexOf(signature);
|
|
assert.ok(start !== -1, `signature '${signature}' not found`);
|
|
const openBrace = src.indexOf('{', start);
|
|
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);
|
|
}
|
|
|
|
test('startGig extracts the whole setlist before starting the queue', () => {
|
|
const fn = extractBlock(CAREER, 'async function startGig(');
|
|
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
|
|
const startIdx = fn.search(/q\.start\s*\(/);
|
|
assert.ok(prepIdx !== -1, 'startGig must pre-extract the set');
|
|
assert.ok(startIdx !== -1, 'q.start not found');
|
|
assert.ok(prepIdx < startIdx,
|
|
'the set must be unpacked BEFORE the queue starts — otherwise the player ' +
|
|
'waits between songs, which is the bug');
|
|
});
|
|
|
|
test('the stage is only borrowed once the set is ready', () => {
|
|
const fn = extractBlock(CAREER, 'async function startGig(');
|
|
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
|
|
const stageIdx = fn.search(/VENUE_OVERRIDE_KEY/);
|
|
assert.ok(prepIdx < stageIdx,
|
|
'a gig cancelled while unpacking must not leave the venue/viz overwritten');
|
|
assert.match(fn, /_ppGigProposal\s*!==\s*prop/,
|
|
'a proposal dismissed while unpacking must not then start a gig');
|
|
});
|
|
|
|
test('pre-extraction never blocks the gig from starting', () => {
|
|
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
|
|
assert.match(fn, /catch\s*\(/,
|
|
'a failed prepare must fall through to the old lazy extraction, not abort the gig');
|
|
});
|
|
|
|
test('the prepare route degrades instead of failing', () => {
|
|
assert.match(CAREER_ROUTES, /def prepare_gig/, 'prepare route missing');
|
|
assert.match(CAREER_ROUTES, /context\.get\(\s*["']get_dlc_dir["']\s*\)/,
|
|
'a host without the library resolvers must degrade, not 500 — pre-extraction ' +
|
|
'is an optimisation and can never be why a gig will not start');
|
|
});
|
|
|
|
// ── the prepare must never be able to BLOCK the gig (CodeRabbit, #971) ──────
|
|
//
|
|
// A bare `await fetch(...)` only rejects on a network error. A server that
|
|
// accepts the connection and then never answers hangs forever — and the gig
|
|
// would never start. That would make this optimisation the exact thing it
|
|
// promises never to be: the reason you cannot play.
|
|
|
|
test('the prepare fetch is bounded — a hung server cannot block the gig', () => {
|
|
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
|
|
assert.match(fn, /AbortController/, 'the request must be abortable');
|
|
assert.match(fn, /setTimeout\([\s\S]{0,40}abort\s*\(\s*\)/,
|
|
'a hung request must be aborted, not awaited forever');
|
|
assert.match(fn, /signal:\s*ctrl\.signal/, 'the signal must actually be passed to fetch');
|
|
assert.match(fn, /clearTimeout/, 'the timer must be cleared on the happy path');
|
|
assert.match(CAREER, /const\s+PREPARE_TIMEOUT_MS\s*=\s*\d+/, 'the ceiling must be named');
|
|
// The button must be restored however we leave — otherwise a timeout strands
|
|
// the poster on "Preparing set…" with Play disabled: unplayable.
|
|
assert.match(fn, /finally\s*\{[\s\S]{0,220}btn\.disabled\s*=\s*false/,
|
|
'the Play button must be re-enabled on EVERY path, including the abort');
|
|
});
|