fix(vocal-cal): cancel fallback timer on Skip via _activeCleanup (Creed finding)

Root cause: clicking Calibrate in the vocalCalibration-absent path queued
an 1800ms setTimeout but never stored the timer id. If the user clicked
Skip before the timer fired, advance('vocals', false) resolved the wizard;
the stale timer then fired advance('vocals', true), mutating the completed
array after resolution and (with a multi-instrument queue) double-
incrementing idx so the next instrument was dropped from both lists.

Fix: capture the setTimeout return value as _timerId and assign:
  _activeCleanup = () => clearTimeout(_timerId)
advance() already drains _activeCleanup on every exit path (Skip,
Calibrate, and any future button), so no further call sites needed.
The _advancing flag + calBtn.disabled remain as the double-click guard;
this fix covers the orthogonal Skip-before-timer race.

Test 3c (new): Calibrate then Skip before timer — asserts clearTimeout
called, vocals in skipped only (not completed), stale timer no-op.
Fails without the fix. Harness updated to expose clearTimeout to vm ctx.

Gates: JS 1141/1141, pytest 60/60.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
This commit is contained in:
byrongamatos
2026-09-03 14:37:34 +02:00
co-authored by Claude Sonnet 4.6
parent 47e85d61c7
commit d2847ab153
2 changed files with 65 additions and 1 deletions
+6 -1
View File
@@ -200,7 +200,12 @@
calBtn.disabled = true;
const body = host.querySelector('[data-is-body]');
if (body) body.innerHTML = '<p class="text-sm text-fb-textDim">Vocal calibration will be available once the Vocal Highway plugin is enabled. Continuing…</p>';
setTimeout(() => advance(inst, true), 1800);
// Store timer id so advance() (via _activeCleanup) can cancel it
// if the user hits Skip before the 1800ms fires — prevents the
// stale timer from double-advancing the wizard and dropping the
// next instrument from both completed and skipped.
const _timerId = setTimeout(() => advance(inst, true), 1800);
_activeCleanup = () => clearTimeout(_timerId);
}
} else if (hasDetector) {
// Hide our own full-screen overlay while note_detect's