Seven fixes on top of the audio-engine TLC branch, each with the gate that
catches its regression.
Blocking:
- Monitor-mute suppression leaked its refcount. setMonitorMuteSuppressed()
became a refcounted acquire/release, but screen.js's callers are
deliberately unpaired: resolveChainRebuildGuard() leaves the suppression on
when a rebuild yields an empty chain, and returns early without releasing
while a provider route is still resolving. Harmless against the old latched
bool, a permanent +1 each against a refcount — after a failed tone rebuild
the count never returned to zero and monitor mute was silently dead for the
rest of the session. The renderer now holds at most one suppression.
- Slot ids are monotonic HANDLES (nextSlotId, never reset by clear()), not
bounded indices, so argSlotId's 4096 ceiling meant that once a session
created its 4096th processor EVERY guarded binding — setBypass,
setParameter, remove/moveProcessor, open/closePluginEditor — silently
no-opped for the rest of the run. Ceiling removed (same for
SetMultiBypass's hardcoded 4096); unknown ids are still rejected by
SignalChain::findSlotIndex.
- clearChain / removeProcessor / moveProcessor took chainMutationMutex with a
blocking lock_guard on the N-API thread — Electron's main thread, and on
macOS also the JUCE message thread. LoadPreset/LoadVST hold that mutex
across an unbounded plugin init (done->wait() has no timeout by design), so
a slow plugin froze the whole main process, every IPC channel with it. They
now queue on a libuv worker via queueChainMutation() and resolve a promise;
the bridge awaits them so callers still observe the mutation applied.
Also:
- getChainState() dereferenced raw ProcessorSlot* returned by getAllSlots()
after the lock was dropped — a concurrent clear() frees them under the
reader. Replaced with SignalChain::getSlotSummaries(), which copies under
the lock. getAllSlots() is gone (it had one caller).
- The device-settings migration removed the localStorage copy even when the
file-store save failed or was unavailable, losing the user's settings.
- SetSlotState and GetParameters kept the raw Int32Value() path: IsNumber()
is true for NaN, so setSlotState(NaN) wrote onto slot 0 — the same
coercion class the rest of the branch fixed.
- RendererBus flushed to the LIVE writeIndex, so a disable→re-enable with no
pull in between discarded the freshly pushed audio along with the stale
tail. It now snapshots the flush target at disable time.
- LoadPreset's rebuild barrier is now released by a scope guard, so a throw
between arming it and Queue() can't block editor opens forever.
Gates: new renderer-bus case (fails on the old flush), new slot-id-handle
case (fails on the old ceiling). ctest 9/9, npm test 79 pass / 0 fail,
chain-mutation storm green, addon export contract unchanged.
AddonContext (src/audio/addon/): engine/vstHost lifetime + snapshot rule,
the JUCE message thread with the macOS no-pump fork quarantined into ONE
file, the shutdown latch (exposed as isShuttingDown), doShutdown with a UI
teardown hook (NodeAddon points it at the editor-window nuke, #56), and the
pending-async-load registry. NodeAddon keeps using-declarations so the
binding bodies are unchanged. Also fixes SetBackingSpeed's bare `engine`
dereference — the one binding that dodged the file's own snapshot rule.
NapiHelpers (typed extractors argInt/argSlotId/argFiniteFloat/argBool/
argMidiChannel/argMidiByte) + rewrites of the unguarded bindings — the
deep-read §2 fix, done once: SetParameter/SetBypass/RemoveProcessor/
MoveProcessor/SetMultiBypass/SendMidiToSlot/SetGain plus the St-1 routing
quartet (SetPan/SetPostGain/SetBranch/SetBranchSrc). NaN slot ids no longer
coerce to slot 0; MIDI channel/program are range-checked before JUCE.
New gate: tests/napi-arg-fuzz.test.js — table-driven garbage (NaN/Inf/
negative/string/missing/object) against the real addon; chain state must be
byte-identical after the storm and a valid call must still apply.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>