F1 (dead DI wiring): remove 7 fn-refs absent from V-section pre-carve:
xFret, pbBeg, pbEnd, arpeggioChordIdForNoteWithInferCache,
arpHsBoundsForNote, chordWireHighDensity, muteXMat
Wiring count 137→130 in both factory signature and screen.js call.
F2 (dead imports): trim geometry.js import to the 3 live symbols:
dZ, slideTrailEnd, renderOrderForLayerAtZ (drop 8 unused).
F3 (counts coherence): header 136/30→130/24, test name and
assertion 137→130, all coherent after F1.
F4 (behavioral kill): add two execution-path tests for drawNote via
new Function sandbox with full DI stubs.
- Negative: dt=-999 → early exit at line 452 → pNote.get()=0
- Positive: dt=0 → gem body executes → pNote.get()≥2 (outline+core)
Kill proof documented in test comments.
Suite: 1377 pass / 2 fail (same 2 pre-existing as c58c40f baseline).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Adds a structural source-scan test that verifies every entry in the
createCamera({...}) argument object satisfies its naming class:
- Shorthand entries (plain consts, sY): key===value by definition, skipped.
- Getter arrows (getXxx: () => [_]var): var stem (stripped of optional _)
must equal key minus 'get' prefix (getCurX → curX, getProbe → probe).
- Setter arrows (setXxx: (v) => { [_]var = v; }): same rule for set.
- Fn-ref renames (freeCamFor: _freeCamFor, etc.): must appear in the
exhaustive PINNED_RENAMES map; any unregistered key:value entry fails.
Kills the entire param-swap class (not just the CAM_H_BASE/CAM_DIST_BASE
instance Creed probed). Cut-13 additions inherit the guard automatically;
new fn-ref renames only need a one-line PINNED_RENAMES entry.
Mutation proofs (all RED before revert):
(a) CAM_H_BASE: CAM_DIST_BASE, CAM_DIST_BASE: CAM_H_BASE → not ok 14
(b) getCurX: () => curDist (wrong var stem) → not ok 14
(c) bogusKey: _someOtherFn (unregistered rename) → not ok 14
Gut-audit: severing the violation check (violations.push suppressed) → the
assert.deepEqual(violations, []) always passes — so the push lines are the
live part; removing any one of the three class branches lets its mutation
class through silently.
Suite: 1272/1274 pass (same 2 pre-existing failures, +1 new test).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
test 11 now extracts the real nutColor from screen.js via regex and
pins it against a literal ('#f5f3f0'). Three-layer guard:
1. guard: BG_DEFAULTS literal is present in screen.js
2. guard: nutColor key is extractable
3. literal-pin: extracted value == '#f5f3f0' → fail loudly if
production drifts (verified: mutating screen.js to '#deadf5'
makes the test fail on the pin assertion; revert confirmed clean)
Fixture and fallback assertion both use PROD_NUT_COLOR (the extracted
value), so they track production automatically.
panel_controls stub updated: added _applyBloom to the createFx stub
(Toby r1 added _applyBloom to the return set; stub was one short).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Creed gap fix: two functions had no test that went RED when gutted.
_applyCinematic: tests both paths (cinematic=true and false) against
fake ambLight/dirLight objects with intensity fields. Asserts all 4
expected intensity values. Mutation `return` at entry → RED (4 fail).
_h3dHexOrDefault: tests valid hex parse ('#a1b2c3' → 0xa1b2c3), no-#
fallback (regex requires #), gibberish fallback, and explicit defHex
override. Mutation (return default always) → RED (valid-hex assertion fails).
Suite: 1269/1271 (same 2 pre-existing failures unchanged).
Note: from cut 9 onward, self-audit step added before reporting — every
moved function names which test kills it; no uncovered function ships.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
F1 (MED — declared gap confirmed and widened):
Extract _bloomEnsure's .then() body into named _applyBloom([EC, RP, UB, OP])
at factory scope. _applyBloom added to return set and screen.js destructure.
Test 9 calls _applyBloom directly with mock module objects (no import() needed).
All 4 write-back mutations now RED:
sever setBloomPass(bp) → RED
sever setBloomW(w) → RED
sever setBloomH(h) → RED
sever setComposer(comp) → RED
F2 (MED — vacuous OR clause in test 7):
Old:
The arm is always true (no living sparks) → NO-OP passes.
Fix: replaced with independent AND assertions on BOTH needsUpdate flags.
NO-OP mutation (return; at top of _sparkUpdate) now goes RED.
DI beyond-subst count +5: _applyBloom reads T/ren/scene/cam/highwayCanvas
from live-accessors (instead of inheriting _bloomEnsure's locals).
fx.js return set: 6 → 7 symbols (added _applyBloom).
Suite: 1267/1269 (2 pre-existing failures unchanged).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Creed gap: both functions only tested with empty/null state — a return 0 at
the body entry point kept the suite green (Creed's exact injection at
overlay.js:631 for drawToneHud; equivalent non-empty branch gut for drawSectionHud).
Fix: two new recording-ctx tests (tests 9–10) that drive real rendering:
• drawToneHud: toneBase='Clean', next change 'Lead' at t=10, currentTime=5
→ assert boxH>0, fillText contains 'Clean' and 'Lead', ctx.fill() called
• drawSectionHud: sections=[Intro@0, Verse@10], currentTime=5
→ assert boxH>0, fillText contains 'Intro' and 'Verse', ctx.fill() called
Named mutations verified RED:
Mut 1: return 0 at drawToneHud body start (overlay.js:631) → test 10 RED
Mut 2: return 0 after drawSectionHud empty-guard → test 11 RED
Shipped code: zero changes. Test files only.
Suite: 262/262 on 0a91f4f (base) → 264/264 (+2 tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Move longestConsecutiveRun, drawChordDiagram, _drawDiagramCached,
drawSectionHud, drawToneHud, drawLyrics out of screen.js IIFE into
plugins/highway_3d/src/overlay.js as export function createOverlay({ diagRenderCache }).
Surprises vs approved contract:
• longestConsecutiveRun (lines 4338–4352) co-moved — called only by drawChordDiagram
• DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX DELETED from screen.js (lines 573–575);
only users were inside the O-section; moved not copied to avoid drift
• _DIAG_CACHE_MAX DELETED from screen.js factory scope (line 3256); moved not copied
Beyond-subst rewires (2):
1. Factory wrapper createOverlay({ diagRenderCache })
2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached
Base run stated: 253/253 on a993d2b
Post-cut: 262/262 (+9 tests — 8 overlay class-killers + 1 panel-controls stub)
Named mutations verified RED:
• createOverlay({ diagRenderCache: new Map() }) → test 6 RED (ref-severance)
• void _DIAG_CACHE_MAX in screen.js → test 4 RED (bare private)
plugin.json: 3.42.0 → 3.43.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Replace hardcoded ['_pmXSpriteMat','_fhXSpriteMat'] list in test 4 with
a full factory-depth-1 scan of materials.js (4-space indent const/let
declarations NOT in the return set). This catches TXT_STYLES and any
future factory-private additions automatically.
Mutation-verified: void TXT_STYLES injected into screen.js → test 4 RED;
reverted → 16/16 GREEN. Full h3d suite: 253/253, 0 fail.
Command: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Creed finding: DOM/style literals in the moved factory were never asserted;
a single-value change (e.g. idle '#181830'→'#181831') shipped silently.
Add test 15: table-driven source scan pinning all 6 _PC_C color entries,
4 _PC_PILL CSS fragments, all 8 _PC_LABELS display strings, and all 9
_PC_USES intensity/reactive boolean pairs.
Mutation-verified RED:
idle '#181830'→'#181831' → not ok 15, fail 1 (Creed's mutant)
particles intensity true→false → not ok 15, fail 1
Suite: 236/236 → 237/237.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Toby r2 finding: the declaration-only regex only captured the first
variable from multi-var let statements. bg-control.js lines 81-88 have
a single let with 8 state symbols (_pcEl, _pcSel, _pcReactive, _pcIntensity,
_pcIntensityWrap, _pcReason, _pcRetry, _pcRetryTimer); 7 of 8 were
invisible to the guard, so a bare _pcEl ref in screen.js escaped all tests.
Fix: replace the declaration-pattern matchAll with a full identifier
scan (/\b(_pc\w+)\b/g). Every _pc* word in bg-control.js lands in
`defined`; the privateSymbols filter (excluding destructured public API)
is unchanged.
Mutation-verified RED:
_pcEl.remove() injected in screen.js → not ok 14, fail 1 (Toby's mutant)
_pcSync() injected in screen.js → not ok 14, fail 1 (Mut B, still works)
Suite: 236/236.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Toby r1 finding: test 13's body-ref filter let the stated mutation escape
('add _pcNewFn to return only → RED' was actually GREEN), and the cut-4
stale-private-reference class (_pcSync bare ref in screen.js) was also
unguarded.
Fix test 13: drop the && screen.js body-ref condition from the leaked
filter — pure 'returned ⊆ destructure' check. Mutation-verified RED:
add _pcNewFn to return but not destructure → leaked = ['_pcNewFn'] → fail 1.
Add test 14: extract every _pc* symbol defined in bg-control.js (function/
let/const declarations), filter to private (not in the destructure), assert
none appears bare in screen.js IIFE body after stripping imports, block
comments, line comments, and the destructure statement. Mutation-verified RED:
inject _pcSync() in screen.js body → stale = ['_pcSync'] → fail 1.
Suite: 235/235 (c4eebe1 base) → 236/236 tip.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Extracted _pc* subsystem (420 lines) from screen.js IIFE into
src/bg-control.js using a factory DI pattern (createBgControl({...})).
Exports: createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe,
_bgUnsubscribe, getVenueSceneOverride })
→ { _pcAcquire, _pcRelease }
screen.js: const { _pcAcquire, _pcRelease } = createBgControl({...})
Beyond-subst (2):
1. Factory wrapper (IIFE-scope closure → DI params) — factory export pattern
required because DI values are IIFE-scope, not ES module imports.
2. _venueSceneOverride → getVenueSceneOverride() (live accessor, 1 call site
in _pcSync — mutable let at screen.js:1523 must be read per-call).
DI values all defined before the createBgControl call (screen.js):
- BG_STYLE_IDS: line 1435 | _bgReadGlobal: 1825
- _bgSubscribe/_bgUnsubscribe: 1917-18 | _venueSceneOverride: 1523
First _pcAcquire caller: init() in createFactory() (~line 14850 post-cut).
Construction order correct: createBgControl call before createFactory.
Surprise declared to god before commit (outbox/h3d-cut5-surprise.json):
No bc-panel.js dependency — §8's anticipation was wrong. The
_bcCreateController call at what was ~line 8082 is in _bcSyncMode
(P-section / factory scope), not the H-section. bg-control.js has ZERO
dependency on bc-panel.js.
Tests:
- tests/js/highway_3d_bg_control.test.js (new, 13 class-killers):
stranded-caller (test 13, factory-adapted from bc-panel test 12),
construction-order (tests 11-12), DI completeness (test 2),
live-accessor enforcement (test 3), lifecycle (tests 4-7).
- plugins/highway_3d/tests/background_control.test.js: retargeted from
screen.js slice → bg-control.js factory eval; all 20 existing behaviour
tests preserved (load() uses vm.createContext + augmented return getters).
- tests/js/highway_3d_panel_controls.test.js: createBgControl stub added.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
base 222/222 (f69c544) → tip 235/235 (+13: 13 new class-killers)
plugin.json: 3.40.0 → 3.41.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
screen.js H/P-section render path at lines 15396-15402 calls _bcLoadSettings()
and _bcFfIdx() (3×) — both moved to bc-panel.js in cut 4 but omitted from the
export list. Browser: first render() after bcCtrl creation → ReferenceError;
seek/loop fast-forward index also dead. Suite was green because no test
executed the butterchurn render path.
Fix:
- export _bcLoadSettings and _bcFfIdx from bc-panel.js
- add both to the tagged import in screen.js
Class-killer (test 12 — generic, not instance-specific):
Extracts all exports from bc-panel.js, all imports in screen.js's
bc-panel.js import clause, then asserts no exported symbol appears as a
bare reference in the screen.js IIFE body without being imported.
Generic: adding a new export + new caller without updating the import → RED.
Own grep (audit):
grep (non-comment lines, all private _bc* names from bc-panel.js):
_bcLoadSettings 1 hit (line 15396)
_bcFfIdx 3 hits (lines 15400-15402)
all others: 0 hits
Only the two symbols Creed found.
Mutation-verify:
remove _bcLoadSettings from screen.js import →
node --test tests/js/highway_3d_bc_panel.test.js
tests 12 pass 11 fail 1 (test 12 RED) ✓
restore →
node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
tests 222 pass 222 fail 0 ✓ GREEN
Plan §8 amended: exports 2→4 with dated note.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Test 2 used s.indexOf which matched the JSDoc comment at offset 133, not
the real assignment at line 183 — so it passed even with the assignment
indented inside a function.
Fix: s.search(/^window\.h3dBcApplySettings\s*=/m) anchored to line-start.
An indented assignment (inside a function) does not match /^window\./m and
returns -1. Also removed the erroneous firstFnIdx comparison: the assignment
is legitimately after _bcIsDesktop in the file and still at module scope.
Mutation-verify:
indent assignment → node --test tests/js/highway_3d_bc_panel.test.js
not ok 2 (fail 1/11) ✓ RED
restore → node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
tests 221 pass 221 fail 0 ✓ GREEN
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Move all _bc* constants, mutable state, and functions (~670 lines) from
screen.js B-section (lines 27-695) to src/bc-panel.js.
Public API: _bcCreateController, _bcIsDesktop (2 named exports).
window.h3dBcApplySettings assigned at bc-panel.js module scope (1
beyond-subst; body verbatim — scope moves from IIFE to ES module top-level).
THREE_URL / THREE_CDN dead-code tombstoned (unused since three-loader.js).
Vendor files untouched (R2); asset URL constants verbatim (R3).
Delta note: original survey estimated ~1,333 lines for B-section. Actual is
~670 because (a) prior cuts 1b/2/3 moved material that was interleaved in the
B-section range, and (b) the original survey section boundaries were wrong —
the B-section ends where the H-section factory begins, not at line 695 of the
pre-cut file.
Completeness grep (post-cut):
grep -nP '^\s*(function|const|let|var)\s+_bc' screen.js
→ 2 hits: _bcActive and _bcSyncMode at line 8081-8082 (factory-scope
H/P-section helpers that orchestrate the imported controller; not B-section
symbols — confirmed F-section in the survey table).
B-section _bc* definitions in IIFE: 0.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
tests 221 pass 221 fail 0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Toby r1 on c7f7c88: MAX_RENDER_STRINGS=6 hardcoded in utils.js is a
stale compile-time copy — if S_COL grows to 7 entries, resolveStringCount
silently clamps a 7-string chart to 6 and no test fails.
Fix: delegator-param pattern (mirrors fretX / geoFretX):
- Remove const MAX_RENDER_STRINGS from utils.js
- resolveStringCount(bundle, maxStrings) — param replaces the copy
- _openStringPitchLabelsForTuning(bundle, songInfo, n, maxStrings) — same
- screen.js import aliases (_resolveStringCountBase, _openStringPitchLabelsForTuningBase)
- 1-line delegators in IIFE supply MAX_RENDER_STRINGS (= S_COL.length); zero call sites change
- NSTR=6 kept (it is a fixed semantic fact about standard guitar, not a palette ceiling)
New test: 'maxStrings param is authoritative, not a hardcoded 6'
— resolveStringCount({stringCount:7}, 7)=7; re-hardcode mutation → RED.
Wiring tests: delegator lines asserting _resolveStringCountBase/
_openStringPitchLabelsForTuningBase each receive MAX_RENDER_STRINGS.
Mutation-verified: re-hardcode 6 → 1 RED; original → 210/210 GREEN.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
207→210/210 pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Moves 12 pure-function / compile-time-constant exports from the screen.js
IIFE into a new src/utils.js ES module:
Color utils: _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt
String-count: resolveStringCount
Tuning/pitch: _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4/5, _BASE_OPEN_MIDI_GUITAR6/7/8,
_baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning
Splitscreen: _ssActive, _ssIsCanvasFocused
Free-identifier audit: all clean. resolveStringCount uses MAX_RENDER_STRINGS
and NSTR — copied as compile-time constants (both = 6) matching IIFE values.
_ssActive/_ssIsCanvasFocused read window.feedBackSplitscreen live per call.
Survey discrepancy declared: the 12 functions span two non-contiguous regions
in current screen.js (lines 741–882 and 1490–1503) rather than the plan's
original 1703–2193 range; function list from the plan is exact.
New test file highway_3d_utils.test.js: 19 class-killer tests.
Mutation-verified (3-char shorthand removal → RED, original → GREEN).
Panel-controls sandbox: 16 stubs added for the new imports.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
188→207/207 pass.
Plugin: 3.38.0 → 3.39.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Toby r1 finding on 8ea123d: the count-based check /T\s*=\s*mod\s*;/g
matches even when the .then bodies declare 'const T = mod' — a local
shadow that leaves the module-level live-binding T permanently null.
Fix: add doesNotMatch(/(?:const|let|var)\s+T\s*=\s*mod/) to reject
any declaration form.
Mutation-verified: 7/8 RED under const-shadow, 188/188 GREEN on original.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Move the D-section (~21 lines) from screen.js factory scope to its own module.
Exports:
- loadThree() — memoised import() with local-vendor→CDN fallback; same body verbatim
- T (live let-binding) — updated to the Three.js namespace on first resolution
screen.js gains: import { loadThree, T } from './src/three-loader.js'
screen.js loses: let T = null; let threeLoadPromise = null; function loadThree()
T and loadThree remain accessible to the IIFE via module-scope closure. The live
let-binding means the IIFE reads the populated T after loadThree() resolves
without any call-site changes.
Panel-controls vm test: strip regex extended to consume all consecutive import
lines; T: null + loadThree stubs added to sandbox context.
Class-killer tests (8 new — highway_3d_three_loader.test.js):
- loadThree exported (mutation: rename/remove → import fails)
- T exported as mutable let (mutation: const → T=mod throws TypeError)
- T=mod in both .then handlers (mutation: remove both → T stays null)
- memoisation guard !threeLoadPromise (mutation: remove → race + duplicate loads)
- CDN fallback .catch chain (mutation: remove → deploy failures unrecoverable)
- threeLoadPromise reset on failure (mutation: remove → no retry possible)
- screen.js imports loadThree+T (wiring confirmed)
- IIFE no longer declares local T or threeLoadPromise (shadow defeated)
All class-killer mutations confirmed distinguishable before commit.
Base run (5e401af): 180/180 pass.
Post-cut run: 188/188 pass.
Command: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Toby r1 finding: three exports in src/geometry.js had no class-killer tests.
Named mutations that were undetected before this commit:
- camBaseDistU: Math.max(span,4)→span leaves camBaseDistU(0)=65 vs 77
- camLowFretPullbackU: drop Max(0,..) leaves (10)=-20 vs 0
- _makeGaussTex: default sigma=0 leaves centre-pixel alpha=0 vs 255
Each test is written to call without the value that makes the mutation
transparent (sigma passed without default; span=0 for the floor case;
fret=10 for the clamp case). Mutation verification run before commit:
all three mutations confirmed caught, all three pass on real code.
Suite (cmd): node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
Base (84d3376): 169/169
Tip: 172/172 (+3 discriminating tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Moves 7 exports from screen.js to a new ES module src/geometry.js:
geoFretX (was fretX, now 2-arg), dZ, slideTrailEnd, camBaseDistU,
camLowFretPullbackU, computeBPM, _makeGaussTex.
Internal helpers _fretXLog / _fretXUniStep / _fretXUni are module-private
inside geometry.js. Module-level constants (SCALE, K, FRET_SCALE, NFRETS,
FRET_SPACING_*, TS) are duplicated at geometry.js module scope — values
are compile-time and never vary at runtime.
screen.js keeps a 1-arg delegator (1 beyond-subst):
const fretX = f => geoFretX(f, _h3dFretUniform);
No call site changes; fretMid / fretColumnWorldW / slideOffsetWorldX /
_recomputeFretSpacingDerived stay in screen.js and use the delegator.
Tests:
- highway_3d_geometry.test.js: class-killer via import() with 9
known-answer assertions (geoFretX uniform/log invariants, dZ linearity,
slideTrailEnd, computeBPM BPM estimate).
- highway_3d_fret_spacing: test updated to match delegator pattern + adds
GEOMETRY_JS read to verify geoFretX export.
- highway_3d_sustain_bloom: retargeted _makeGaussTex check to GEOMETRY_JS
(definition moved); call-site check stays on SCREEN_JS.
- highway_3d_panel_controls: vm loader strips ES import line and injects
geometry stubs; all factory-statics tests unaffected.
Suite: 169/169 pass
node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
Version bump: 3.35.0 -> 3.36.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
Zero logic changes. screen.js content byte-identical — an IIFE is valid
inside a <script type="module">. The loader consumes scriptType at
plugin-loader.js:666 (if plugin.script_type === "module" -> script.type =
"module"), fed from plugins/__init__.py:1429 (manifest.get("scriptType")).
Isolates the R7 module-flip risk as its own verifiable cut before any
function extraction begins.
Version bump: 3.34.1 → 3.35.0
Suite: 160/160 pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
The v1 zero-listing guard (f6e9727) was bypassed by a partial degraded
mount: if even one song was visible, current_files was non-empty and
delete_missing ran freely, pruning every invisible DB row (Creed r1 HIGH).
Guard contract (god ruling):
- Auto scans (startup, periodic, /api/rescan): refuse when
would_remove >= max(_PRUNE_MAX_ABS=1, _PRUNE_MAX_FRAC=0.5 * existing).
Zero-listing also refused (would_remove == existing).
Scan sets stage='error', leaves DB intact.
- /api/rescan/full: allow_mass_prune=True → guard logs a warning and
proceeds; delete_missing runs (user-authorised explicit intent).
Changes:
- lib/scan.py: _PRUNE_MAX_ABS/FRAC constants; combined guard (zero +
partial) before delete_missing; allow_mass_prune param on
background_scan(); _scan_mass_prune_next global threaded through
kick_scan() and _scan_runner()
- server.py: trigger_full_rescan calls kick_scan(allow_mass_prune=True)
- tests/test_scan_prune_guard.py: Case B (Creed partial, RED f6e9727→
GREEN here) + Case C (full-rescan bypass)
Gates: pytest 2816/2816, JS 1155/1155
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
When background_scan() discovers zero songs in the DLC directory but the
songs table is non-empty, skip delete_missing and return with stage='error'.
An empty listing on a non-empty library almost certainly means the DLC mount
was temporarily inaccessible (FUSE remount, NTFS dirty-flag RO fallback, brief
unmount mid-scan) rather than every song being genuinely deleted.
delete_missing({}) on a non-empty DB deleted ALL rows — the #P1-libpurge
incident that wiped 50,943 songs was caused by exactly this path.
The guard fires only when current_files is empty AND the DB has at least one
row, so a genuinely empty new library is unaffected.
Test: test_scan_prune_guard.py::test_empty_listing_refuses_prune_when_db_nonempty
Failing input: dlc dir with no feedpak/sloppak/wem + 1 DB row.
Before: delete_missing({}) fires, row gone.
After: stage='error', row survives.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
The previous test for closeBook() invalidating in-flight requests bumped
_ppBookGen directly via setBookGen instead of calling the production closeBook().
Deleting ++_ppBookGen from closeBook() left the test green — decoration.
Fix: expose closeBook via the __careerPassportTest seam and call it directly
in the test. Mutation proof (run before commit):
- delete ++_ppBookGen from closeBook() → 1 fail (test RED) ✓
- restore → 14/14 pass (GREEN) ✓
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
F1b (MEDIUM): the error path (non-ok response) lacked a gen check after
res.json() completed. A stale 404's json() could finish after a newer booking
had incremented _ppBookGen, and the error handler would still revert the newer
pref to 'any'. Fix: `if (gen !== _ppBookGen) return;` immediately after the
error-branch `await res.json().catch(...)`. Test: json() side-effect bumps
_ppBookGen (simulating a new booking racing in), verifies pref stays 'standard'.
F2b (LOW): closeBook() dismissed the poster but left _ppBookGen unchanged,
so a still-pending successful bookGig response could land after dismissal and
reopen the overlay / repopulate _ppGigProposal. Fix: `++_ppBookGen` in
closeBook(). Test: pending request fires, gen bumped (simulating closeBook),
response resolves, asserts _ppGigProposal stays null.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
F1 (MEDIUM): bookGig had no request-generation guard. Rapid pref changes could
let a stale response overwrite _ppGigProposal → user sees the wrong song set.
Fix: _ppBookGen counter incremented on each request; response discarded unless
gen === _ppBookGen at both the res.ok check and after json(). Stale-response
driver test fails without the guard.
F2 (LOW): every non-ok response reverted _ppGigTuningPref to 'any' and
persisted it. A transient 500 would silently blow away the user's pref.
Fix: pref reverted only on 404 (no-match case). Other errors notify but keep
the pref. 500 driver test asserts pref stays 'drop' — fails without the fix.
404 driver still asserts revert to 'any'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
F1 (HIGH): onGigSongLoading used `pref !== 'specific'` but production pref
is always 'specific:<name>' not bare 'specific' — guard was always true,
interstitial fired even when every gig song shared one tuning.
Fix: !pref.startsWith('specific:')
F2 (MEDIUM): JS test for specific-exemption used bare 'specific' (impossible
in production), giving false confidence. Updated to 'specific:E Standard',
which is the real production shape and correctly exercises the fixed guard.
F3 (MEDIUM): On 404-revert (_ppGigTuningPref → 'any'), poster was not
re-rendered so the stale pill from the previous successful booking stayed
highlighted while internal pref was already 'any'.
Fix: re-render overlay with gigPosterHTML(_ppGigProposal) before returning.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
Users can now pick a tuning preference before booking a gig:
- Any (default), Standard only, Drop only, or a specific tuning
- Backend filters the song pool (stubs + filler) by that preference
- Empty-filter case returns a 404 with a descriptive message; frontend
reverts the pref to 'any' and shows a notification
- Interstitial pause before first song and on tuning changes (all prefs
except 'specific') via window.feedBack.holdAutoplay(); opens the tuner
panel in auto mode while the user retunes
- 'Specific' gigs skip interstitials (every song already shares one tuning)
- Graceful degradation: no holdAutoplay → interstitial silently skipped
New backend:
- _tuning_ok_fn helper for standard/drop/specific classification
- _fill_genre_songs accepts optional tuning_ok filter
- propose_gig batch-fetches tuning_name for played stubs, applies filter
- GET /gigs/tunings endpoint for the specific-tuning picker
Tests:
- tests/test_career_gig_tuning.py — 17 Python tests (classification, filter)
- tests/js/career_gig_tuning.test.js — 9 JS tests (interstitial logic)
- tests/plugins/career/conftest.py — songs table schema gets tuning_name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
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
F1 (HIGH): add double-click guard to fallback path in renderAudioPanel.
- Declare `_advancing` flag before click handler; fallback else branch
returns early if already advancing, then sets flag + calBtn.disabled.
- Prevents two setTimeout advances from queuing when the button is
clicked twice before the 1800ms timer fires, which would silently
drop subsequent instruments from both completed and skipped.
- Test 4 (new): double-click with two instruments queued, asserts only
one timer is scheduled -- fails without the guard.
F2 (MEDIUM): add 'vocals' to _inputSetupRelaunch fallback list.
- Was ['guitar','bass','keys','drums']; now includes 'vocals' so the
Settings re-calibration wizard runs the vocals panel even when
/api/progression fails to respond.
- Test 6 (new): reads fallback literal from source, asserts 'vocals'
present -- fails without the fix.
F3 (LOW): key button label and notice off vocalCalibration presence for
vocals, not hasDetector (noteDetect).
- New `hasVocalCal` and `canCalibrate` vars; vocals shows 'Calibrate'
iff vocalCalibration facade present, 'Continue' otherwise.
- notLoadedNotice selects the correct per-instrument message.
- Test 7 (new): facade present + noteDetect absent => label 'Calibrate'.
Also: fix curly-quote string delimiters introduced by editor autocorrect
in prior commit -- replaced with straight ASCII quotes; restored original
curly apostrophes in prose content (isn’t, it’s).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
1. Add data/progression/paths/vocals.json (5 levels, instrument:vocals
challenges) so the Vocals tile appears on wizard step 4.
2. Add vocals to INSTRUMENTS in plugins/input_setup/screen.js (mode:audio)
so the wizard renders a Vocals panel when that path is selected.
3. In renderAudioPanel, branch vocals away from noteDetect.launchCalibration
to window.feedBack.vocalCalibration.launch({requester,onDone,onCancel}).
Guard: facade absent (vocal-highway plugin disabled) → shows a notice and
auto-advances via setTimeout; never hangs or throws.
4. Update test_bundled_content_loads_clean to expect the 'vocals' path id.
5. Add tests/js/vocal_calibration_handoff.test.js — 4 tests covering:
- vocals.json shape (id/icon/5 levels, namespaced challenge ids)
- INSTRUMENTS includes vocals
- fallback when facade absent (no hang/throw, auto-advance)
- facade.launch called with correct args, onDone resolves wizard
Facade contract: window.feedBack.vocalCalibration frozen {version:1,
launch({requester,onDone,onCancel})} — built by Dwight (vocal-highway).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
* Carry rig bindings through the sloppak tone payload
`sloppak_tone_changes` emitted `{t, name}` only, so a chart's declared
sound never reached the client: `base_rig` was never read and each
change's `rig` was dropped at the wire boundary. Both survive load
intact (`Arrangement.tones` is an opaque passthrough) — the strip
happened here, at the last step before send.
That left the rig model (feedpak-spec 1.18.0 §6.9/§7.9) unreachable
from core: a pack could declare which rig voices a part, and nothing
downstream could ever see it. First step of the core reader for source
rigs; the rig library itself and the manifest precedence cascade follow.
Return `(base, base_rig, changes)` and keep `rig` on each change. Both
ids are validated as non-blank strings and stripped — anything else is
dropped rather than forwarded, so presence of the key means the change
binds a rig. Resolution against `rigs.json` deliberately does NOT happen
here: this builder preserves the declared binding, while realization
selection and the `intent.gm` fallback belong to whatever voices the
part.
On the wire `base_rig` is omitted entirely when empty, so packs that
bind no rig produce the byte-identical `tone_changes` message they
always did.
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
* Load the pack's rig library from the manifest
feedpak 1.18.0 lets a chart declare what a MIDI part should sound like
by binding a rig id, but core had nothing to bind to: `rigs`, `base_rig`
and `drum_tones` appeared nowhere in lib/, server.py or static/. The
preceding commit carries the reference onto the wire; this adds the
library it references.
Read the manifest `rigs:` key into a new `LoadedSloppak.rigs`, alongside
the other side-files rather than on Song — every side-file (drum_tab,
song_timeline, keys, notation) hangs off the load result, and rigs is
pack-level, not per-arrangement. Same permissive posture as its
neighbours: missing, unreadable, malformed or traversing disables rigs
with a warning and never fails the pack, which §7.9 requires outright.
Rig objects pass through VERBATIM. §7.9 obliges a Reader to preserve
unknown role/engine/kind values and `ext` namespaces, so validating
block structure here would be wrong as well as premature — realization
selection and the `intent.gm` floor belong to whatever voices the part.
The only entries dropped are ones unreachable by construction: a rig is
addressable solely by `id`, so a non-dict entry or one without a usable
string id can never be referenced. Ids are stripped to match the
reference side, and a duplicate id resolves first-wins with a warning,
since ambiguity there would surface as the wrong sound rather than an
error.
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
* Resolve which tones block binds a part
feedpak 1.18.0 lets a sound binding arrive from three places, and core
honoured none of them: the manifest arrangement entry, the arrangement
JSON, and the top-level drum_tones. Reading them needs a precedence
rule, because two of the three can be present at once.
Arrangement entries: the entry's `tones` replaces the arrangement JSON's
WHOLESALE (spec 5.2), unlike name/tuning/capo/centOffset beside it,
which override field by field. A merge would produce a sound nobody
authored -- one source's base under the other's changes -- which is
worse than either block alone. This is also what makes a notation-only
keys entry bindable at all, since it has no arrangement JSON to carry
tones in the first place.
Drums: the top-level drum_tones binds the song-level primary part, and
a `type: drums` entry's own tones takes precedence, with a Reader
forbidden from applying both to the same part (5.1). That is the same
shape as the drum_tab alias rule, so it lives inside
_resolve_drum_parts next to it rather than beside it -- one precedence
resolver, not two that drift. drum_tones is the PRIMARY's fallback
only: a second drummer with no binding gets None, never the primary's
kit.
An empty `tones: {}` reads as absent rather than as an override to
silence, matching how arrangement_from_wire already normalizes the
in-JSON empty dict, so a stray empty object cannot quietly unbind a
part.
Spec-conformance gate passes with drum_tones added to the keys core
reads (22 of the spec's 32, all declared).
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
* Document the rig bindings on the tone_changes wire message
CHANGELOG entry for the core rig reader, plus the WS protocol table in
CLAUDE.md, which described `tone_changes` as carrying only base + name.
While in that row: its time key was documented as `time`, but every
producer emits `t` — both the sloppak builder and the legacy XML path.
The 3D highway already carries a comment warning readers about exactly
this discrepancy. Corrected here rather than left sitting next to the
newly-added keys, where a reader would reasonably assume both were
equally reliable.
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
---------
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
Every manifest key that names a file carried its own copy of the same
traversal guard: resolve, prove containment under source_dir, warn and
skip on ValueError, warn and skip on OSError. Seven copies —
original_audio, drum_tab, arrangement, notation, song_timeline, lyrics,
keys — which is seven chances for the next side-file to get a security
check subtly wrong by copying the wrong neighbour.
Route them all through `_resolve_pack_path(source_dir, rel, label)`.
Deliberately preserved, because each was load-bearing:
- Both exception branches, with their different messages. ValueError
means the path resolved outside the pack (a crafted or broken
manifest); OSError means it could not be resolved at all (symlink
loop, permissions). They send an operator to different places.
- Per-call-site control flow. The helper returns `Path | None` and says
nothing about what to do next, so the two sites that return, the one
that continues, and the four that fall through to an `is not None`
test each keep the shape they had.
- The existence-check asymmetry. Some sites test `.exists()` (or
`.is_file()`) after resolving and some do not, which is intentional —
a missing optional side-file is silent, a missing arrangement skips an
entry — so existence stays out of the helper entirely.
Pure refactor: no behaviour change and no new validation. Log output is
byte-identical (the hardcoded labels become a `%s` argument rendering to
the same text). Full suite is unchanged at 2774 passed / 4 skipped
before and after, and the five loader-level traversal tests that used to
cover five separate copies of the guard now all exercise the same
function.
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
The count-in always clicked exactly four beats, so a 3/4 song was counted
in 4/4, and a song opening with a pickup (anacrusis) had the pickup enter
where the downbeat belonged — putting the player a beat ahead all song.
Bar length now comes from the song_timeline beats already on the highway
(measure >= 0 marks downbeats), so no new plumbing: the time_signatures
map is streamed to plugins rather than stored in the frontend. A first bar
shorter than that meter shortens the count by its length — a 1-beat pickup
in 4/4 counts "1 2 3" and the music enters on 4.
Bar length is the mode of the downbeat gaps, not the first gap, so a
pickup's own short gap can't be read as the meter; the beats trailing the
last downbeat count as a candidate too, or a song of pickup + one bar
offers only the pickup's gap. Pickup shortening is scoped to the song's
first bar — a short bar elsewhere is a meter change, and is counted by its
own length instead. Songs without beats (pre-chart, minigames, synthetic
highways) still get four.
Applies to both count-in paths: loop wrap / section practice, and the
start-of-song 'Countdown before song' setting.
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Rebased onto merged main (was stacked on #1023/#1024, whose venue work is
now in main) so it no longer carries a stale content_packs.py that would
revert 1023's build_pack fixes.
- build_vst_pack: slice a fat .vst3 tree to one platform (keep its binary
dir + shared bundle files, drop the two foreign platform dirs and src/
build trees). Pins create_system=3 like build_pack — without it the same
tree hashes differently on a Windows runner (native .vst3 are built there),
breaking the precomputable-hash guarantee exactly where it matters.
- Publish wiring: 'python tools/content_packs.py <vst-root> --vst --version N
--publish' builds+uploads vst-<plat>-vN releases for mac/win/linux and emits
a platform-keyed {url,sha256,bytes} manifest — the shape rig_builder's
data/vst_packs.json consumes. publish() refactored onto a shared
_publish_release helper (venue behaviour unchanged).
- Tests: slice keeps target+shared/drops foreign, per-platform binary,
reproducibility, unknown-platform reject, and a simulated-win32 guard that
fails if the create_system pin is dropped. selfcheck covers the VST path.
Original build_vst_pack by Matthew Harris Glover; reworked for the create_system
fix, publish wiring, and rebase.
Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(career): host higher venues as opt-in content packs
Move the club and arena venue packs (~678 MB of crowd MP4s) out of the
bundle and download them on demand, keeping the bar starter bundled so
career still works offline. Leans on career's existing pack pipeline
(_download_pack: stream -> sha256 -> extract -> validate -> swap), which
already degrades gracefully when a pack is absent.
- venues.json: club/arena gain `pack` URLs pointing at per-pack, versioned,
immutable releases (venue-<id>-v<N>, matching the existing venue-arena-v1).
Arena's sha256/bytes are the real published asset (verified end-to-end);
club is a placeholder until its release is published.
- tools/content_packs.py: reusable, reproducible pack build/publish/manifest
tool. Byte-identical output for identical media (fixed order/mtime/perms,
STORED) so a pack's hash can be known before upload. --local (file://) for
offline tests, --publish for the per-pack release. Has a --selfcheck.
- .github/workflows/content-packs.yml: workflow_dispatch automation that
builds/publishes packs and opens the venues.json manifest-bump PR, so
publishing is never a manual checklist.
- test: round-trips a tool-built pack through career's real _download_pack.
Part of the nightly-slimming effort (feedBack-desktop#122). The desktop
bundle change (stop shipping club/arena) is a companion PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(career): don't offer a venue pack until its release is published
A committed venues.json entry carries a 0-byte placeholder (and all-zero sha)
until its release exists. Previously has_pack was true as soon as a `pack`
object was present, so the UI showed a "Download" button that could only fail
(the placeholder URL 404s). Gate on a real, publish-stamped size via
_pack_published(): the card shows "coming soon" and the download endpoint 404s
until the pack is actually published. Caught by a real bundle+runtime smoke.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(content-packs): address CodeRabbit review on #1023
- workflow: stop interpolating dispatch inputs into Bash (template
injection flagged by zizmor). Pass venues/version via env, validate
formats, use an argument array.
- content_packs: reject top-level files the career downloader would
refuse (PACK_FILENAME_RE) before publishing — a stray .DS_Store would
otherwise ship and fail _validate_pack_dir for every client. + test.
- content_packs: pin ZipInfo.create_system=3 so packs hash identically
across Windows/Unix runners (was the documented reproducibility caveat).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(changelog): note opt-in career venue packs (#122)
Signed-off-by: Matthew Harris Glover <matthew@harrisglover.com>
* docs(content_packs): correct --publish usage in module docstring
--publish is a flag (no tag arg) and publish() deliberately omits
--clobber; the docstring said otherwise.
Signed-off-by: byrongamatos <xasiklas@gmail.com>
---------
Signed-off-by: Matthew Harris Glover <matthew@harrisglover.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
* feat(server): add session-sync relay WebSocket /ws/sync/{session_id}
Cross-device followers (splitscreen's upcoming LAN pop-out mode,
feedBack-plugin-splitscreen#21) need a machine-crossing replacement for
BroadcastChannel — the one link in the follower architecture that cannot
leave the host browser. Chart data already streams per-client over
/ws/highway, so all that's missing is a dumb live-state channel.
Add a fan-out room endpoint: a JSON text frame from one client is relayed
verbatim to every other client on the same session id. No schema, no
history, no persistence — rooms are created on first join and GC'd when
the last socket leaves. The statelessness is deliberate: an idle room is
indistinguishable from a nonexistent one, and a host that crashes and
rejoins the same id resumes publishing to reconnecting subscribers with
no server-side coordination.
Caps for a LAN-exposable port: 16 KB frames (1009), 16 sockets/room and
32 rooms (1013), 120 msg/s sustained / 240 burst per socket (1008),
text-only (1003), session id validated against [A-Za-z0-9_-]{4,64}. An
over-limit socket is closed individually; a peer that dies mid-fan-out
is dropped without wedging delivery to the rest.
Closes#1030
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
* fix(ws_sync): bound stalled peer sends; cap ws frames at the transport
Review feedback (CodeRabbit on #1032):
- A peer that stops draining its socket left send_text() pending forever;
since publishers await the fan-out gather, one stalled peer stalled
every publisher's receive loop behind it. Fan-out sends are now bounded
by SEND_TIMEOUT_SECONDS (5 s) so a stall becomes an eviction through
the existing failed-send drop path.
- uvicorn buffers inbound WS frames up to its 16 MB default before the
handler's 16 KB check ever runs, so the DoS bound wasn't enforced at
the transport. main.py now passes ws_max_size=64 KB (no client sends
large frames: the highway WS receives only small control messages, and
the relay keeps its tighter application cap as the primary limit).
Regression tests for both; the desktop's own uvicorn spawn gets the
matching --ws-max-size flag with the feedBack-desktop follow-up work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
---------
Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>