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.
All 8 CodeRabbit findings verified against the code and fixed:
- ChainOps: macOS LoadVST routes its addProcessor through chainMutationMutex
(macOS is a first-class platform; deadlock-safe — a worker holding the
mutex never waits on the Node/main thread there). All four single-slot
workers (LoadVST/NAM/IR/ReplaceIR) now bump chainGeneration so the
executor's foreign-write detection sees direct loads, not just presets.
- Rebuild barrier (beginChainRebuild/endChainRebuild): LoadPreset and
ClearChain arm it before editor teardown; OpenPluginEditor refuses to
open while a teardown+clear/rebuild is pending (#56 window between
closeAllPluginEditorWindows returning and the worker taking the mutex).
- EditorWindows: all slot/processor resolution in editor lambdas runs under
a try_lock of chainMutationMutex (try_lock, never blocking — workers
holding the mutex block-wait on the message thread). Sandbox promotion
bumps chainGeneration. editorWindows map is now message-thread-only
(duplicate-window check and close-erase moved into the queued lambdas).
Null slot->processor recheck after a faulted promotion capture.
- closeAllPluginEditorWindows returns false on refused post / 15s timeout;
ClearChain skips the clear and LoadPreset resolves {success:false}
instead of freeing processors under a live editor.
- AddonContext: dispatchOnMessageThread reports refused-post/timeout;
doShutdown leaves the message thread running when teardown didn't
complete instead of unloading mid-destruction.
- RendererBus::push rejects NaN/Inf/non-positive rates and a step that
underflows to zero; new testRejectsUnusableRates unit case.
Verified: addon builds clean, all 78 JS tests pass (storm, contracts,
executor, N-API fuzz), all 5 engine_units native tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two deep-read fixes now homed in their phase-1/2 units:
RendererBus (§4): setEnabled(false) no longer writes readIndex from the
control thread — the ring's designated consumer-side writer is pull(). The
drop-on-disable is now a flushRequested atomic the consumer honors at its
next pull, closing the last SPSC-discipline hole (a concurrent pull
mid-drain could overwrite the control thread's store and replay a stale
tail after re-enable). New unit test pins flush-then-fresh-audio.
setAudioDevices (§3): the restart decision reads state.userWantsAudio
(intent, written only by start/stopAudio) instead of the racy device-state
flag that transient audioDeviceStopped() fires clear — a reconfigure landing
inside a transient-stop window no longer leaves the engine configured but
stopped ('no audio until Start/Apply is pressed again').
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the WebAudio→engine bus — ring, producer-side linear resampler,
prefill gate, fill clamp, metrics — verbatim into
src/audio/engine/RendererBus.h. AudioEngine keeps thin facades
(setRendererBus/pushRendererAudio/pullRendererBus/getRendererBusMetrics) so
the NodeAddon surface is unchanged. JUCE-free: pull() takes raw channel
pointers, which is what lets tests/engine_units drive the resampler
continuity, prime/underflow/clamp, and metrics cases without a device.
The control-thread readIndex write on disable (deep-read §4) is preserved
verbatim and marked; its flush-flag fix lands as the phase-8 commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>