From 3e449c03181dd051d0de129173102cdf9e4cf1c0 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:28:34 +0200 Subject: [PATCH] test(audio): quarantined chain-mutation storm test (expected-fail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents TLC deep-read §1: concurrent loadPreset workers interleave clear()/addProcessor() and merge both presets — reproduces first iteration ([storm-ir-1-0, storm-ir-2-0, storm-ir-2-1]). Quarantined behind CHAIN_STORM=1; flips to a hard gate when ChainOps lands the serializer (plan phase 7). Co-Authored-By: Claude Fable 5 --- tests/chain-mutation-storm.test.js | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/chain-mutation-storm.test.js diff --git a/tests/chain-mutation-storm.test.js b/tests/chain-mutation-storm.test.js new file mode 100644 index 0000000..cb6c547 --- /dev/null +++ b/tests/chain-mutation-storm.test.js @@ -0,0 +1,83 @@ +// Phase 0.b storm test (docs/audio-engine-tlc.md §4, deep-read §1): chain- +// mutating async workers (loadPreset/loadVST/loadNAM/loadIR) queue on the +// libuv threadpool with no mutual exclusion, so two overlapping loadPreset +// calls can interleave clear()/addProcessor() and merge both presets into +// garbage. This test documents that corruption today and flips to a hard gate +// once ChainOps lands the chain-mutation serializer (plan phase 7). +// +// EXPECTED-FAIL / QUARANTINED: needs the built addon, drives a known race +// repeatedly (a single clean run proves nothing for a race), and initializes +// JUCE in-process. Run explicitly with: +// CHAIN_STORM=1 node --test tests/chain-mutation-storm.test.js +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ADDON = path.join(__dirname, '..', 'build', 'Release', 'slopsmith_audio.node'); +const ENABLED = process.env.CHAIN_STORM === '1'; +const ITERATIONS = 50; + +// Minimal valid 16-bit PCM mono WAV (a short impulse) — enough for IRLoader, +// so the presets need no real cab/amp assets. IR slots (type 2) are the only +// asset-cheap distinguishable payload loadPreset accepts. +function writeImpulseWav(file, numSamples) { + const dataBytes = numSamples * 2; + const buf = Buffer.alloc(44 + dataBytes); + buf.write('RIFF', 0); buf.writeUInt32LE(36 + dataBytes, 4); buf.write('WAVE', 8); + buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(1, 22); buf.writeUInt32LE(48000, 24); buf.writeUInt32LE(96000, 28); + buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); + buf.write('data', 36); buf.writeUInt32LE(dataBytes, 40); + buf.writeInt16LE(32767, 44); // unit impulse, remaining samples zero + fs.writeFileSync(file, buf); +} + +const IR_TYPE = 2; // ProcessorSlot::Type::IR + +function irPreset(irFile, slotCount) { + return JSON.stringify({ + chain: Array.from({ length: slotCount }, (_, i) => ({ + type: IR_TYPE, name: `storm-ir-${slotCount}-${i}`, path: irFile, bypassed: false, + })), + }); +} + +test('concurrent loadPreset calls end with exactly one caller\'s chain', { skip: !ENABLED && 'quarantined — set CHAIN_STORM=1 (expected-fail until ChainOps 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 + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chain-storm-')); + const irFile = path.join(tmp, 'impulse.wav'); + writeImpulseWav(irFile, 64); + + // Preset A loads 1 IR slot, preset B loads 2 — after both settle the chain + // must be exactly one of those (1 or 2 slots of the SAME preset's names). + // An interleaved clear/add merge shows up as 3 slots or mixed names. + const presetA = irPreset(irFile, 1); + const presetB = irPreset(irFile, 2); + + try { + for (let i = 0; i < ITERATIONS; i++) { + const [ra, rb] = await Promise.all([ + audio.loadPreset(presetA), + audio.loadPreset(presetB), + ]); + assert.ok(ra?.success && rb?.success, `iteration ${i}: a load reported failure`); + + const slots = audio.getChainState(); + const names = slots.map((s) => s.name); + const isA = names.length === 1 && names[0] === 'storm-ir-1-0'; + const isB = names.length === 2 && names[0] === 'storm-ir-2-0' && names[1] === 'storm-ir-2-1'; + assert.ok(isA || isB, `iteration ${i}: merged/corrupt chain: ${JSON.stringify(names)}`); + } + } finally { + await audio.clearChain?.(); + audio.shutdown?.(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +});