fix(audio): serialize chain mutations + chainGeneration (phase 7a)

The single highest-value fix of the TLC pass (deep-read §1): one native
chain-mutation mutex (addon/ChainOps) held across the FULL Execute() of
every chain worker (LoadPreset/LoadVST/LoadNAM/LoadIR/ReplaceIR) and the
synchronous mutators (clearChain/removeProcessor/moveProcessor). Two
overlapping loadPreset calls can no longer interleave clear()/addProcessor()
into a merged-garbage chain — the plugin-vs-plugin fight becomes
last-writer-wins.

chainGeneration (monotonic, bumped under the mutex) is returned in loadPreset
results and exposed as getChainGeneration (new export, snapshot regenerated),
so the audio-effects executor can detect a foreign write invalidated its
stageSlots map and re-sync instead of flipping bypass/params on wrong slots —
the prerequisite for the single-chain-owner ownership track.

Also rides here: LoadPresetWorker's slot-state restore goes through
setSlotState() instead of const_cast (deep-read §9).

The phase-0 storm test flips from expected-fail to a hard gate: 50 iterations
of concurrent loadPreset now always end with exactly one caller's chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-14 01:45:01 +02:00
co-authored by Claude Fable 5
parent d4e0bfc272
commit db337eaf29
6 changed files with 120 additions and 4 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ function irPreset(irFile, slotCount) {
});
}
test('concurrent loadPreset calls end with exactly one caller\'s chain', { skip: !ENABLED && 'quarantined — set CHAIN_STORM=1 (expected-fail until ChainOps serializer)' }, async () => {
test('concurrent loadPreset calls end with exactly one caller\'s chain', { skip: !ENABLED && 'needs built addon — set CHAIN_STORM=1 (hard gate since the phase-7 serializer)' }, async () => {
assert.ok(fs.existsSync(ADDON), 'addon must be built (npm run build:audio)');
const audio = require(ADDON);
audio.init(); // returns undefined; loadPreset fails "No engine" if it didn't take