perf(harness): close page on error + MD040 fence + CHANGELOG (CodeRabbit)

- frameTimeOnce now closes its page in a finally, so a failing run doesn't leak
  the page until the final browser.close() (CodeRabbit).
- fenced code block gets a bash language hint (MD040).
- CHANGELOG mentions the new --song frame-time mode.
This commit is contained in:
byrongamatos
2026-07-10 23:07:38 +02:00
parent 3b862ba117
commit 4b8ec441b6
3 changed files with 35 additions and 28 deletions
+1
View File
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
next root-level module can't ship broken. next root-level module can't ship broken.
### Added ### Added
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. - **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping - **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
+1 -1
View File
@@ -46,7 +46,7 @@ highway actually painted count (the other ~half are cheap no-op loops that would
otherwise mask a regression). Any `highway.js` change must re-run this on the otherwise mask a regression). Any `highway.js` change must re-run this on the
same machine and stay within noise of these numbers. same machine and stay within noise of these numbers.
``` ```bash
node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \ node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \
--song "Arcturus - The Sham Mirrors - Kinetic.feedpak" --song "Arcturus - The Sham Mirrors - Kinetic.feedpak"
``` ```
+33 -27
View File
@@ -85,35 +85,41 @@ async function clientMetrics() {
// ── 2D highway per-frame draw cost (needs --song + a seeded library) ────────── // ── 2D highway per-frame draw cost (needs --song + a seeded library) ──────────
async function frameTimeOnce(browser) { async function frameTimeOnce(browser) {
const page = await browser.newPage(); const page = await browser.newPage();
// Wrap rAF before any page script; mark frames the highway actually drew. let f, t2, notes;
await page.addInitScript(() => { try {
window.__f = []; // Wrap rAF before any page script; mark frames the highway actually drew.
window.__drew = false; await page.addInitScript(() => {
const raf = window.requestAnimationFrame.bind(window); window.__f = [];
window.requestAnimationFrame = (cb) => raf((t) => {
window.__drew = false; window.__drew = false;
const t0 = performance.now(); const raf = window.requestAnimationFrame.bind(window);
try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); } window.requestAnimationFrame = (cb) => raf((t) => {
window.__drew = false;
const t0 = performance.now();
try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); }
});
}); });
}); await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 }); await page.evaluate(() => document.getElementById('v3-onboarding')?.remove());
await page.evaluate(() => document.getElementById('v3-onboarding')?.remove()); await page.evaluate((s) => window.playSong(s), SONG);
await page.evaluate((s) => window.playSong(s), SONG); await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(),
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(), null, { timeout: 45000 }).catch(() => {});
null, { timeout: 45000 }).catch(() => {}); await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 });
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 }); await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; }));
await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; })); // Start playback so draw() leaves its paused-throttle path; confirm the clock advances.
// Start playback so draw() leaves its paused-throttle path; confirm the clock advances. await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); });
await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); }); await page.waitForTimeout(1500);
await page.waitForTimeout(1500); const t1 = await page.evaluate(() => window.highway.getTime());
const t1 = await page.evaluate(() => window.highway.getTime()); await page.evaluate(() => { window.__f.length = 0; });
await page.evaluate(() => { window.__f.length = 0; }); await page.waitForTimeout(FRAME_S * 1000);
await page.waitForTimeout(FRAME_S * 1000); ({ f, t2, notes } = await page.evaluate(() => ({
const { f, t2, notes } = await page.evaluate(() => ({ f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length,
f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length, })));
})); if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}${t2}) — measured the paused path`);
await page.close(); } finally {
if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}${t2}) — measured the paused path`); // Always close, even when an await above throws — otherwise a failing
// run leaks its page until the final browser.close().
await page.close();
}
const drew = f.filter((x) => x.drew).map((x) => x.ms); const drew = f.filter((x) => x.drew).map((x) => x.ms);
return { drew, total: f.length, notes }; return { drew, total: f.length, notes };
} }