refactor(audio): move chain workers into ChainOps.cpp (phase 7b)

Moves the five chain-mutating async workers (LoadPreset/LoadVST/LoadNAM/
LoadIR/ReplaceIR), their N-API handlers, loadVstSandboxAware, and the shared
load helpers (decodeStateBlob, loadSafeSampleRate/BlockSize) verbatim into
src/audio/addon/ChainOps.cpp — joining the phase-7a serialization primitives
in their planned home (§3.3). NodeAddon keeps using-declarations; the export
table is unchanged. Storm and arg-fuzz gates stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-14 02:29:16 +02:00
co-authored by Claude Fable 5
parent 95b32ba160
commit f473aad920
3 changed files with 869 additions and 799 deletions
+6 -799
View File
@@ -35,6 +35,12 @@ using slopsmith::addon::closeAllPluginEditorWindows;
using slopsmith::addon::destroyAllPluginEditorWindowsOnMessageThread; using slopsmith::addon::destroyAllPluginEditorWindowsOnMessageThread;
using slopsmith::addon::OpenPluginEditor; using slopsmith::addon::OpenPluginEditor;
using slopsmith::addon::ClosePluginEditor; using slopsmith::addon::ClosePluginEditor;
using slopsmith::addon::decodeStateBlob;
using slopsmith::addon::LoadVST;
using slopsmith::addon::LoadNAMModel;
using slopsmith::addon::LoadIR;
using slopsmith::addon::ReplaceIR;
using slopsmith::addon::LoadPreset;
// Lifetime/threading moved to addon/AddonContext (TLC phase 6); the usings // Lifetime/threading moved to addon/AddonContext (TLC phase 6); the usings
// keep the 100+ existing binding bodies unchanged. // keep the 100+ existing binding bodies unchanged.
@@ -46,31 +52,6 @@ using slopsmith::addon::unregisterPendingLoad;
using slopsmith::addon::cancelAllPendingLoads; using slopsmith::addon::cancelAllPendingLoads;
using slopsmith::addon::doShutdown; using slopsmith::addon::doShutdown;
// Decode a state blob that may be in EITHER base64 flavour. JUCE's
// MemoryBlock::fromBase64Encoding only understands JUCE's own proprietary
// format ("<size>.<juce-alphabet>") and returns false for standard RFC-4648
// base64 — which is what the Python-side plugins (rig_builder et al.) emit
// for per-slot state. That silent false meant setState() was never called
// for those slots: IR stages lost their per-stage `gain` (the cab loudness
// makeup and amp trims never reached the engine). Try the JUCE format first
// (engine-native saves), then fall back to standard b64.
//
// `allowStandard` is only set for IR/NAM slots: their processors take a JSON
// state ({"irPath","gain"} / model path), which is exactly what the plugins
// emit. VST slots keep the JUCE-only decode — their plugin-emitted blobs are
// metadata wrappers, not real setStateInformation() chunks, and feeding those
// to a VST3 for the first time would be an unasked-for behaviour change.
static bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb,
bool allowStandard)
{
if (mb.fromBase64Encoding(s) && mb.getSize() > 0)
return true;
if (!allowStandard)
return false;
mb.reset();
juce::MemoryOutputStream mo(mb, false);
return juce::Base64::convertFromBase64(mo, s) && mb.getSize() > 0;
}
// Validate a JS source-id argument and return the live source, or nullptr if it is // Validate a JS source-id argument and return the live source, or nullptr if it is
// missing / not a Number / not a FINITE INTEGER / out of range. The TS bridge already // missing / not a Number / not a FINITE INTEGER / out of range. The TS bridge already
@@ -88,17 +69,7 @@ static SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInf
return eng->getSource((int) raw); return eng->getSource((int) raw);
} }
static double loadSafeSampleRate(const AudioEngine& eng)
{
const double sr = eng.getCurrentSampleRate();
return (std::isfinite(sr) && sr > 0.0) ? sr : 48000.0;
}
static int loadSafeBlockSize(const AudioEngine& eng)
{
const int bs = eng.getCurrentBlockSize();
return bs > 0 ? bs : 256;
}
// Destroys every in-process plugin editor window. MUST be called on the message // Destroys every in-process plugin editor window. MUST be called on the message
// thread — lives in addon/EditorWindows now (TLC phase 7). // thread — lives in addon/EditorWindows now (TLC phase 7).
@@ -1842,586 +1813,6 @@ static Napi::Value SetVstCrashSentinelPath(const Napi::CallbackInfo& info)
// signals them all so the workers unblock and return a clean "cancelled" // signals them all so the workers unblock and return a clean "cancelled"
// error instead of hanging forever when the JUCE message thread is about // error instead of hanging forever when the JUCE message thread is about
// to be stopped (and any unfired callback would never arrive). // to be stopped (and any unfired callback would never arrive).
// Load a VST3, routing it through the out-of-process sandbox when
// shouldSandbox() says so (the filename pre-seed or the runtime crash
// blocklist), otherwise loading it in-process. The in-process load uses
// VSTHost::loadPluginAsync so the JUCE message thread keeps pumping during
// the plugin's init — critical for plugins like AmpliTube that post WM_USER
// / WM_TIMER messages to themselves while initialising. The sync
// createPluginInstance would block the pump, those self-messages would
// queue forever, and the plugin would end up half-wired (a pointer that
// only gets written by a queued message stays null, and the editor crashes
// on its first WindowProc dispatch — the AmpliTube failure signature).
//
// Threading: on !JUCE_MAC must be called from a libuv worker thread (NOT
// the JS main thread, NOT the JUCE message thread) — the done->wait below
// has to be on a thread that *isn't* the one running JUCE's pump or the
// load can't complete. On JUCE_MAC the inline sync fallback is used and
// the caller can be the Node/main thread (which is also JUCE's message
// thread there); LoadVST does exactly that, while LoadPresetWorker still
// hits this from a worker (a pre-existing macOS limitation).
//
// On a *required*-sandbox failure (the plugin matched shouldSandbox but the
// sandbox couldn't spawn) this returns nullptr with `error` set and
// `sandboxRequired` true, so the caller can choose how to surface it —
// LoadVSTWorker throws to JS, LoadPresetWorker just skips the slot.
static std::unique_ptr<juce::AudioProcessor> loadVstSandboxAware(
const juce::String& pluginPath, double sr, int bs,
juce::String& error, bool& sandboxRequired)
{
sandboxRequired = false;
// A plugin persisted in a signal-chain preset can be uninstalled or
// deleted between runs. Instantiating a VST3 whose module is gone from
// disk faults deep inside the format loader (a stack-buffer-overrun /
// 0xC0000409 on Windows) and takes the whole app down on startup — before
// the crash blocklist or sandbox can ever intervene, because the preset is
// restored independently of those guards. A native access violation also
// can't be caught by the renderer's JS try/catch around loadPreset. So
// pre-flight a cheap existence check here, the single choke point shared by
// every load path (direct LoadVST and preset restore, in-process and
// sandboxed, all platforms), and fail soft when the file is missing.
//
// Only filesystem paths are judged: VST3/LV2 fileOrIdentifiers are absolute
// paths (File::exists covers both a .vst3 file and a bundle directory),
// whereas macOS AudioUnit identifiers ("AudioUnit:...") are not absolute
// paths and must not be rejected here.
if (juce::File::isAbsolutePath(pluginPath) && ! juce::File(pluginPath).exists())
{
error = "Plugin file not found: " + pluginPath;
VST_TRACE("loadVstSandboxAware: missing plugin file '%s' — skipping load",
pluginPath.toRawUTF8());
return nullptr;
}
juce::PluginDescription probeDesc;
probeDesc.fileOrIdentifier = pluginPath;
probeDesc.name = juce::File(pluginPath).getFileNameWithoutExtension();
if (slopsmith::sandbox::shouldSandbox(probeDesc))
{
sandboxRequired = true;
juce::String sandboxErr;
auto processor = slopsmith::sandbox::tryLoadSandboxed(
probeDesc, sr, bs, sandboxErr);
if (!processor)
{
error = "sandbox load failed: "
+ (sandboxErr.isEmpty() ? juce::String("unknown error")
: sandboxErr);
VST_TRACE("loadVstSandboxAware: sandbox path declined/failed: %s",
sandboxErr.toRawUTF8());
}
return processor;
}
#if JUCE_MAC
// macOS has no separate JUCE message thread (see startJuceMessageThread /
// dispatchOnMessageThread): the JUCE MessageManager is bound to the
// Node/main thread, and dispatchOnMessageThread historically ran inline
// on the caller. A callAsync + done->wait pattern would queue a callback
// to a pump that may never run in this calling context.
//
// Fall back to the sync loadPlugin, executed on whichever thread called
// in — the Node/main thread for LoadVST's JUCE_MAC branch (correct: that
// *is* the MessageManager thread on macOS), or a libuv worker thread for
// LoadPresetWorker (the pre-existing macOS constraint). Caveat: the
// existing dispatchOnMessageThread block on macOS already documents
// that "VST/AU plugin instantiation (which genuinely requires a message
// thread on macOS) is the one capability we give up until a proper
// libuv-based pump lands." LoadPresetWorker has called loadVstSandbox-
// Aware on a worker thread for ages under exactly the same constraint;
// moving LoadVST to AsyncWorker brings direct loads under the same
// (pre-existing) limitation. The AmpliTube-class self-message problem
// this PR targets is Windows-specific (Electron owns the OS main
// thread, forcing JUCE's MessageManager onto a background thread that
// createPluginInstance then blocks); macOS doesn't have that mismatch.
auto host = snapshotVstHost();
if (! host) { error = "vstHost not initialised"; return nullptr; }
juce::String err;
auto instance = host->loadPlugin(pluginPath, sr, bs, err);
if (! instance) error = err.isNotEmpty() ? err : juce::String("load failed");
return instance;
#else
// In-process: kick off createPluginInstanceAsync on the message thread,
// block *this* (libuv worker) thread on a WaitableEvent until the load
// callback fires. The message thread keeps pumping during the wait so
// the plugin's self-posted init messages dispatch and its state finishes
// wiring up before the editor is ever opened.
//
// All state passed across the thread hop is held by shared_ptr so it
// outlives the lambda even on an unexpected destructor / scope exit.
auto instance = std::make_shared<std::unique_ptr<juce::AudioPluginInstance>>();
auto loadError = std::make_shared<juce::String>();
auto done = std::make_shared<juce::WaitableEvent>();
// Register BEFORE scheduling so a shutdown that lands between callAsync
// and the wait below can't miss us — cancelAllPendingLoads would
// otherwise see an empty set and the worker would block forever.
registerPendingLoad(done);
// Check alreadyShutDown after registering to catch the inverse race
// (shutdown ran before we registered): if it's already set, the
// shutdown won't see this event and we must bail ourselves.
if (slopsmith::addon::isShuttingDown())
{
unregisterPendingLoad(done);
error = "shutdown in flight";
return nullptr;
}
// Snapshot a shared_ptr to vstHost so the async load and its inner
// continuation can keep VSTHost (and thus formatManager) alive even if
// shutdown resets the global mid-load. The inner callback captures the
// same hostKeeper, so JUCE retains it until createPluginInstanceAsync
// completes; once the callback destructs, the keeper drops, and if the
// global has been reset by then the VSTHost destructor runs safely
// (no work in flight). The snapshot itself goes through vstHostMutex
// so the shared_ptr copy can't race with shutdown's vstHost.reset().
auto hostKeeper = snapshotVstHost();
const bool scheduled = juce::MessageManager::callAsync(
[hostKeeper, pluginPath, sr, bs, instance, loadError, done]()
{
// Shutdown may have fired between callAsync queueing this
// lambda and the message thread picking it up. Bail before
// kicking off another in-flight createPluginInstanceAsync
// that the shutdown would otherwise have to wait on.
if (slopsmith::addon::isShuttingDown())
{
*loadError = "shutdown in flight";
done->signal();
return;
}
if (! hostKeeper)
{
*loadError = "vstHost not initialised";
done->signal();
return;
}
hostKeeper->loadPluginAsync(
pluginPath, sr, bs,
[hostKeeper, instance, loadError, done]
(std::unique_ptr<juce::AudioPluginInstance> inst, juce::String err)
{
*instance = std::move(inst);
*loadError = std::move(err);
done->signal();
});
});
if (! scheduled)
{
// The message queue is gone (typically: shutdown in flight). The
// lambda will never run, so done would never signal — surface the
// failure rather than hanging the worker forever.
unregisterPendingLoad(done);
error = "message manager unavailable (shutdown?)";
return nullptr;
}
// No timeout: createPluginInstanceAsync is genuinely async (the message
// thread keeps pumping), so a slow first-run plugin (e.g. one doing a
// license check that exceeds 15 s) is allowed to take however long it
// takes. The old 15-second timeout in dispatchOnMessageThread could
// return early while the lambda was still running, then the lambda
// would construct a fully-initialised plugin only for it to immediately
// destruct because no one held a reference — running VST teardown on
// the message thread while the user had already moved on. That race is
// gone with this design.
//
// Tradeoff: this call holds a libuv threadpool worker for the duration
// of the plugin's init. Multiple concurrent hung loads could in theory
// starve other AsyncWorkers (fs / crypto). In practice plugin loads are
// user-driven and serialised (LoadPresetWorker loads slots one at a
// time), and a truly stuck load is bounded by app shutdown via
// cancelAllPendingLoads. A proper "fire-and-forget with a TSFN
// completion callback" model would eliminate the block entirely but
// requires a bigger API restructure than this PR's scope.
done->wait();
unregisterPendingLoad(done);
// Distinguish "shutdown cancelled us before the callback fired"
// (instance null AND error empty) from a normal load failure (instance
// null with error set) and a normal success.
if (! *instance && loadError->isEmpty())
{
error = "load cancelled (shutdown)";
return nullptr;
}
error = *loadError;
return std::move(*instance);
#endif
}
// AsyncWorker wrapper for LoadVST. Execute() runs on a libuv worker thread,
// so loadVstSandboxAware can block-wait on the async load without freezing
// the JS main thread or deadlocking the JUCE message thread.
class LoadVSTWorker : public Napi::AsyncWorker
{
public:
LoadVSTWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path)
: Napi::AsyncWorker(env)
, deferred_(deferred)
, pluginPath_(std::move(path)) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
// Snapshot engine + vstHost through their mutex-protected helpers so
// shutdown's reset on the message thread can't race the worker's
// dereferences below. The shared_ptr locals keep both objects alive
// for the duration of this worker even if the globals get reset
// mid-load. The atomic alreadyShutDown gate is the early-out: once
// it's set, the dispatched reset is on its way and there's no point
// continuing.
if (slopsmith::addon::isShuttingDown())
{
error_ = "shutdown in flight";
return;
}
auto engineKeeper = snapshotEngine();
auto hostSnap = snapshotVstHost();
if (!engineKeeper || !hostSnap)
{
error_ = "engine not initialised";
return;
}
const auto sr = loadSafeSampleRate(*engineKeeper);
const auto bs = loadSafeBlockSize(*engineKeeper);
const auto path = juce::String(pluginPath_);
VST_TRACE("LoadVSTWorker: path='%s' sr=%.0f bs=%d",
pluginPath_.c_str(), sr, bs);
bool sandboxRequired = false;
juce::String err;
auto processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired);
if (sandboxRequired && !processor)
{
// The plugin's on the denylist and the sandbox couldn't spawn —
// falling back to in-process is what crashed the addon to begin
// with. Surface as a JS exception (handled in OnOK).
fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8());
error_ = err;
sandboxFailed_ = true;
return;
}
if (!processor)
{
fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8());
error_ = err;
return;
}
// Engine may have been torn down while we were waiting on the async
// load. The shared_ptr captures keep `processor` alive; just don't
// touch a freed engine. The processor destructs cleanly when this
// scope exits.
//
// Gate on alreadyShutDown (atomic, properly synchronised) before the
// raw engine/vstHost pointer reads — once that flag is set, the
// dispatched reset of engine/vstHost is on its way and any use of
// the pointers from this worker thread is racy. The atomic check is
// the authoritative "should I still be touching engine?" signal.
if (slopsmith::addon::isShuttingDown())
{
error_ = "engine torn down during load";
return;
}
// Re-snapshot the engine — the original engineKeeper might have
// outlived a reset on the message thread, but the AudioEngine
// we're about to mutate must be the still-installed one. If the
// global has been reset, the local keeps the old engine alive but
// we shouldn't be adding slots to it any more.
auto liveEngine = snapshotEngine();
if (!liveEngine || !snapshotVstHost())
{
error_ = "engine torn down during load";
return;
}
auto name = processor->getName();
slotId_ = liveEngine->getSignalChain().addProcessor(
std::move(processor),
ProcessorSlot::Type::VST,
name,
path);
}
void OnOK() override
{
if (sandboxFailed_)
{
// Match the prior LoadVST throw-on-required-sandbox-failure
// behaviour so renderers' try/catch keeps working.
deferred_.Reject(
Napi::Error::New(Env(), error_.toStdString()).Value());
return;
}
deferred_.Resolve(Napi::Number::New(Env(), slotId_));
}
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
std::string pluginPath_;
int slotId_ = -1;
bool sandboxFailed_ = false;
juce::String error_;
};
static Napi::Value LoadVST(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
if (!snapshotEngine() || !snapshotVstHost() || info.Length() < 1)
{
deferred.Resolve(Napi::Number::New(env, -1));
return deferred.Promise();
}
auto pluginPath = info[0].As<Napi::String>().Utf8Value();
#if JUCE_MAC
// On macOS the JUCE MessageManager is bound to the Node/main thread.
// Running this as an AsyncWorker would call vstHost->loadPlugin on a
// libuv worker thread, which JUCE documents as unsupported for VST/AU
// instantiation. Do the load synchronously on the Node/main thread
// (same as the pre-PR LoadVST) and return a resolved Promise to match
// the new signature. Pays the foreground-block cost the AsyncWorker
// path was supposed to avoid, but that's the existing macOS reality —
// dispatchOnMessageThread already runs inline there. The async-load
// motivation (AmpliTube blocking the background JUCE message thread
// under Electron) is a Windows-only problem.
// Snapshot once for the whole load so the same AudioEngine is used for
// the sr/bs reads and the addProcessor mutation, even if shutdown
// resets the global mid-call.
auto liveEngine = snapshotEngine();
if (! liveEngine)
{
deferred.Resolve(Napi::Number::New(env, -1));
return deferred.Promise();
}
juce::String error;
bool sandboxRequired = false;
auto processor = loadVstSandboxAware(
juce::String(pluginPath),
loadSafeSampleRate(*liveEngine),
loadSafeBlockSize(*liveEngine),
error, sandboxRequired);
if (sandboxRequired && !processor)
{
fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8());
deferred.Reject(
Napi::Error::New(env, error.toStdString()).Value());
return deferred.Promise();
}
int slotId = -1;
if (processor)
{
auto name = processor->getName();
slotId = liveEngine->getSignalChain().addProcessor(
std::move(processor),
ProcessorSlot::Type::VST,
name,
juce::String(pluginPath));
}
else
{
fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8());
}
deferred.Resolve(Napi::Number::New(env, slotId));
return deferred.Promise();
#else
auto* worker = new LoadVSTWorker(env, deferred, std::move(pluginPath));
worker->Queue();
return deferred.Promise();
#endif
}
class LoadNAMWorker : public Napi::AsyncWorker
{
public:
LoadNAMWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path)
: Napi::AsyncWorker(env), deferred_(deferred), modelPath_(std::move(path)) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
auto liveEngine = snapshotEngine();
if (!liveEngine) { slotId_ = -1; return; }
auto processor = std::make_unique<NAMProcessor>();
if (processor->loadModel(juce::File(juce::String(modelPath_))))
{
auto name = processor->getModelName();
slotId_ = liveEngine->getSignalChain().addProcessor(
std::move(processor),
ProcessorSlot::Type::NAM,
"NAM: " + name,
juce::String(modelPath_));
}
}
void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); }
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
std::string modelPath_;
int slotId_ = -1;
};
static Napi::Value LoadNAMModel(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
if (!snapshotEngine() || info.Length() < 1) {
deferred.Resolve(Napi::Number::New(env, -1));
return deferred.Promise();
}
auto modelPath = info[0].As<Napi::String>().Utf8Value();
auto worker = new LoadNAMWorker(env, deferred, modelPath);
worker->Queue();
return deferred.Promise();
}
class LoadIRWorker : public Napi::AsyncWorker
{
public:
LoadIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path)
: Napi::AsyncWorker(env), deferred_(deferred), irPath_(std::move(path)) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
auto liveEngine = snapshotEngine();
if (!liveEngine) { slotId_ = -1; return; }
const auto sr = loadSafeSampleRate(*liveEngine);
const auto bs = loadSafeBlockSize(*liveEngine);
auto processor = std::make_unique<IRLoader>();
processor->setPlayConfigDetails(2, 2, sr, bs);
processor->prepareToPlay(sr, bs);
if (processor->loadIR(juce::File(juce::String(irPath_))))
{
auto name = processor->getIRName();
slotId_ = liveEngine->getSignalChain().addProcessor(
std::move(processor),
ProcessorSlot::Type::IR,
"IR: " + name,
juce::String(irPath_));
}
}
void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); }
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
std::string irPath_;
int slotId_ = -1;
};
static Napi::Value LoadIR(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
if (!snapshotEngine() || info.Length() < 1) {
deferred.Resolve(Napi::Number::New(env, -1));
return deferred.Promise();
}
auto irPath = info[0].As<Napi::String>().Utf8Value();
auto worker = new LoadIRWorker(env, deferred, irPath);
worker->Queue();
return deferred.Promise();
}
// Replace the IR of an EXISTING convolution slot in place (cab swap / mic move),
// so the rest of the chain — the amp VST above all — is NOT torn down and rebuilt.
// Mirrors LoadIRWorker but calls SignalChain::replaceProcessor(slotId, …) instead
// of addProcessor. Optional `gain` (>=0) updates the slot's post-gain (the cab
// makeup); a negative gain leaves the existing post-gain untouched.
class ReplaceIRWorker : public Napi::AsyncWorker
{
public:
ReplaceIRWorker(Napi::Env env, Napi::Promise::Deferred deferred,
int slotId, std::string path, float gain)
: Napi::AsyncWorker(env), deferred_(deferred),
slotId_(slotId), irPath_(std::move(path)), gain_(gain) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
auto liveEngine = snapshotEngine();
if (!liveEngine) { ok_ = false; return; }
const auto sr = loadSafeSampleRate(*liveEngine);
const auto bs = loadSafeBlockSize(*liveEngine);
auto processor = std::make_unique<IRLoader>();
processor->setPlayConfigDetails(2, 2, sr, bs);
processor->prepareToPlay(sr, bs);
if (! processor->loadIR(juce::File(juce::String(irPath_)))) { ok_ = false; return; }
auto name = processor->getIRName();
ok_ = liveEngine->getSignalChain().replaceProcessor(
slotId_, std::move(processor),
"IR: " + name, juce::String(irPath_));
if (ok_ && gain_ >= 0.0f)
liveEngine->getSignalChain().setPostGain(slotId_, gain_);
}
void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); }
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
int slotId_;
std::string irPath_;
float gain_;
bool ok_ = false;
};
static Napi::Value ReplaceIR(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
if (!snapshotEngine() || info.Length() < 2
|| !info[0].IsNumber() || !info[1].IsString()) {
deferred.Resolve(Napi::Boolean::New(env, false));
return deferred.Promise();
}
const int slotId = info[0].As<Napi::Number>().Int32Value();
const auto irPath = info[1].As<Napi::String>().Utf8Value();
const float gain = (info.Length() >= 3 && info[2].IsNumber())
? info[2].As<Napi::Number>().FloatValue() : -1.0f;
auto worker = new ReplaceIRWorker(env, deferred, slotId, irPath, gain);
worker->Queue();
return deferred.Promise();
}
static Napi::Value RemoveProcessor(const Napi::CallbackInfo& info) static Napi::Value RemoveProcessor(const Napi::CallbackInfo& info)
{ {
// Typed extractors (addon/NapiHelpers.h): NaN/Inf slot ids used to coerce // Typed extractors (addon/NapiHelpers.h): NaN/Inf slot ids used to coerce
@@ -2751,190 +2142,6 @@ static Napi::Value SavePreset(const Napi::CallbackInfo& info)
return Napi::String::New(env, json.toStdString()); return Napi::String::New(env, json.toStdString());
} }
class LoadPresetWorker : public Napi::AsyncWorker
{
public:
LoadPresetWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string json)
: Napi::AsyncWorker(env), deferred_(deferred), presetJson_(std::move(json)) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
auto liveEngine = snapshotEngine();
if (!liveEngine) { success_ = false; error_ = "No engine"; return; }
auto parsed = juce::JSON::parse(juce::String(presetJson_));
if (!parsed.isObject()) { success_ = false; error_ = "Invalid JSON"; return; }
auto* root = parsed.getDynamicObject();
if (!root) { success_ = false; error_ = "Invalid preset"; return; }
auto chainVar = root->getProperty("chain");
auto* chainArray = chainVar.getArray();
if (!chainArray) { success_ = false; error_ = "No chain array"; return; }
// NB: any open in-process editor windows were already torn down on the
// message thread by LoadPreset() before this AsyncWorker was queued (see
// there) — so clearing the chain here can't leave an editor pointing at
// a freed processor (use-after-free; #56). We deliberately do NOT tear
// editors down from this worker thread: JUCE GUI objects must only be
// destroyed on the message thread, and macOS has no pump to marshal to
// from here.
// Clear existing chain
liveEngine->getSignalChain().clear();
double sr = loadSafeSampleRate(*liveEngine);
int bs = loadSafeBlockSize(*liveEngine);
for (auto& slotVar : *chainArray)
{
auto* slotObj = slotVar.getDynamicObject();
if (!slotObj) continue;
int type = (int)slotObj->getProperty("type");
auto name = slotObj->getProperty("name").toString();
auto path = slotObj->getProperty("path").toString();
bool bypassed = (bool)slotObj->getProperty("bypassed");
auto stateB64 = slotObj->getProperty("state").toString();
std::unique_ptr<juce::AudioProcessor> processor;
if (type == (int)ProcessorSlot::Type::VST && snapshotVstHost())
{
// Sandbox-aware load: a crash-blocklisted plugin restored
// from a preset must still go out-of-process, otherwise the
// "one crash, then always sandbox" contract is defeated.
juce::String err;
bool sandboxRequired = false;
processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired);
if (!processor)
{
fprintf(stderr, "[LoadPreset] VST load failed: %s (%s)\n",
name.toRawUTF8(), err.toRawUTF8());
continue;
}
}
else if (type == (int)ProcessorSlot::Type::NAM)
{
auto nam = std::make_unique<NAMProcessor>();
if (!nam->loadModel(juce::File(path)))
{
fprintf(stderr, "[LoadPreset] NAM load failed: %s\n", path.toRawUTF8());
continue;
}
processor = std::move(nam);
}
else if (type == (int)ProcessorSlot::Type::IR)
{
auto ir = std::make_unique<IRLoader>();
ir->setPlayConfigDetails(2, 2, sr, bs);
ir->prepareToPlay(sr, bs);
if (!ir->loadIR(juce::File(path)))
{
fprintf(stderr, "[LoadPreset] IR load failed: %s\n", path.toRawUTF8());
continue;
}
processor = std::move(ir);
}
else continue;
int slotId = liveEngine->getSignalChain().addProcessor(
std::move(processor),
(ProcessorSlot::Type)type,
name, path);
if (bypassed && slotId >= 0)
liveEngine->getSignalChain().setBypass(slotId, true);
// Stereo routing (St-1). Absent keys read back as 0 (= default), so
// mono presets restore exactly as before.
if (slotId >= 0)
{
if (slotObj->hasProperty("pan"))
liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan"));
if (slotObj->hasProperty("branch"))
liveEngine->getSignalChain().setBranch(slotId, (int)slotObj->getProperty("branch"));
if (slotObj->hasProperty("postGain"))
liveEngine->getSignalChain().setPostGain(slotId, (float)(double)slotObj->getProperty("postGain"));
if (slotObj->hasProperty("branchSrc"))
liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("branchSrc"));
}
// Restore processor state (JUCE-format base64; IR/NAM slots also
// accept standard base64 — see decodeStateBlob: their plugin-
// emitted JSON states were silently dropped before, so IR stages
// never got their per-stage gain).
if (stateB64.isNotEmpty() && slotId >= 0)
{
const bool allowStandard = type == (int)ProcessorSlot::Type::IR
|| type == (int)ProcessorSlot::Type::NAM;
juce::MemoryBlock state;
if (decodeStateBlob(stateB64, state, allowStandard))
{
// Through the class's own synchronized API (deep-read 9) --
// no more const_cast around setSlotState's locking.
liveEngine->getSignalChain().setSlotState(slotId, state);
}
}
slotsLoaded_++;
}
success_ = true;
generation_ = slopsmith::addon::bumpChainGeneration(); // still under chainLock
}
void OnOK() override
{
auto obj = Napi::Object::New(Env());
obj.Set("success", success_);
obj.Set("slotsLoaded", slotsLoaded_);
obj.Set("chainGeneration", (double) generation_);
if (!success_) obj.Set("error", error_);
deferred_.Resolve(obj);
}
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
std::string presetJson_;
uint64_t generation_ = 0;
bool success_ = false;
std::string error_;
int slotsLoaded_ = 0;
};
static Napi::Value LoadPreset(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
auto liveEngine = snapshotEngine();
if (!liveEngine || info.Length() < 1) {
auto obj = Napi::Object::New(env);
obj.Set("success", false);
obj.Set("error", "No engine or missing argument");
deferred.Resolve(obj);
return deferred.Promise();
}
// Tear down any open in-process editor windows NOW, on the N-API/main
// thread, before the AsyncWorker frees the chain's processors on a libuv
// worker (#56). Doing it here — not inside LoadPresetWorker::Execute — keeps
// JUCE GUI teardown off the worker thread: on macOS this thread IS the
// message thread (inline teardown); on Linux/Windows closeAllPluginEditor-
// Windows() posts to the dedicated JUCE message thread and blocks. Either
// way editors are destroyed before Execute() clears the chain.
closeAllPluginEditorWindows();
auto json = info[0].As<Napi::String>().Utf8Value();
auto worker = new LoadPresetWorker(env, deferred, json);
worker->Queue();
return deferred.Promise();
}
static Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) static Napi::Value SetMultiBypass(const Napi::CallbackInfo& info)
{ {
auto env = info.Env(); auto env = info.Env();
+835
View File
@@ -1,6 +1,28 @@
// ChainOps implementation — the chain-mutating async workers, their N-API
// handlers, and loadVstSandboxAware, moved verbatim from NodeAddon.cpp (TLC
// plan phase 7b / 3.3). The serialization primitives (chainMutationMutex /
// chainGeneration) landed in phase 7a; every worker Execute() below holds
// the mutex for its full body.
#include "ChainOps.h" #include "ChainOps.h"
#include "AddonContext.h"
#include "NapiHelpers.h"
#include "EditorWindows.h"
#include "../AudioEngine.h"
#include "../VSTHost.h"
#include "../VSTTrace.h"
#include "../NAMProcessor.h"
#include "../IRLoader.h"
#include "../Sandbox/SandboxedProcessor.h"
#include <juce_events/juce_events.h>
#include <atomic> #include <atomic>
#include <cmath>
#include <cstdio>
#include <memory>
#include <string>
namespace slopsmith::addon { namespace slopsmith::addon {
@@ -22,4 +44,817 @@ uint64_t currentChainGeneration()
return chainGeneration.load(std::memory_order_acquire); return chainGeneration.load(std::memory_order_acquire);
} }
// ── decodeStateBlob (moved verbatim) ────────────────────
// Decode a state blob that may be in EITHER base64 flavour. JUCE's
// MemoryBlock::fromBase64Encoding only understands JUCE's own proprietary
// format ("<size>.<juce-alphabet>") and returns false for standard RFC-4648
// base64 — which is what the Python-side plugins (rig_builder et al.) emit
// for per-slot state. That silent false meant setState() was never called
// for those slots: IR stages lost their per-stage `gain` (the cab loudness
// makeup and amp trims never reached the engine). Try the JUCE format first
// (engine-native saves), then fall back to standard b64.
//
// `allowStandard` is only set for IR/NAM slots: their processors take a JSON
// state ({"irPath","gain"} / model path), which is exactly what the plugins
// emit. VST slots keep the JUCE-only decode — their plugin-emitted blobs are
// metadata wrappers, not real setStateInformation() chunks, and feeding those
// to a VST3 for the first time would be an unasked-for behaviour change.
bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb,
bool allowStandard)
{
if (mb.fromBase64Encoding(s) && mb.getSize() > 0)
return true;
if (!allowStandard)
return false;
mb.reset();
juce::MemoryOutputStream mo(mb, false);
return juce::Base64::convertFromBase64(mo, s) && mb.getSize() > 0;
}
double loadSafeSampleRate(const AudioEngine& eng)
{
const double sr = eng.getCurrentSampleRate();
return (std::isfinite(sr) && sr > 0.0) ? sr : 48000.0;
}
int loadSafeBlockSize(const AudioEngine& eng)
{
const int bs = eng.getCurrentBlockSize();
return bs > 0 ? bs : 256;
}
// ── loadVstSandboxAware (moved verbatim from NodeAddon.cpp) ────────────────────
// Load a VST3, routing it through the out-of-process sandbox when
// shouldSandbox() says so (the filename pre-seed or the runtime crash
// blocklist), otherwise loading it in-process. The in-process load uses
// VSTHost::loadPluginAsync so the JUCE message thread keeps pumping during
// the plugin's init — critical for plugins like AmpliTube that post WM_USER
// / WM_TIMER messages to themselves while initialising. The sync
// createPluginInstance would block the pump, those self-messages would
// queue forever, and the plugin would end up half-wired (a pointer that
// only gets written by a queued message stays null, and the editor crashes
// on its first WindowProc dispatch — the AmpliTube failure signature).
//
// Threading: on !JUCE_MAC must be called from a libuv worker thread (NOT
// the JS main thread, NOT the JUCE message thread) — the done->wait below
// has to be on a thread that *isn't* the one running JUCE's pump or the
// load can't complete. On JUCE_MAC the inline sync fallback is used and
// the caller can be the Node/main thread (which is also JUCE's message
// thread there); LoadVST does exactly that, while LoadPresetWorker still
// hits this from a worker (a pre-existing macOS limitation).
//
// On a *required*-sandbox failure (the plugin matched shouldSandbox but the
// sandbox couldn't spawn) this returns nullptr with `error` set and
// `sandboxRequired` true, so the caller can choose how to surface it —
// LoadVSTWorker throws to JS, LoadPresetWorker just skips the slot.
std::unique_ptr<juce::AudioProcessor> loadVstSandboxAware(
const juce::String& pluginPath, double sr, int bs,
juce::String& error, bool& sandboxRequired)
{
sandboxRequired = false;
// A plugin persisted in a signal-chain preset can be uninstalled or
// deleted between runs. Instantiating a VST3 whose module is gone from
// disk faults deep inside the format loader (a stack-buffer-overrun /
// 0xC0000409 on Windows) and takes the whole app down on startup — before
// the crash blocklist or sandbox can ever intervene, because the preset is
// restored independently of those guards. A native access violation also
// can't be caught by the renderer's JS try/catch around loadPreset. So
// pre-flight a cheap existence check here, the single choke point shared by
// every load path (direct LoadVST and preset restore, in-process and
// sandboxed, all platforms), and fail soft when the file is missing.
//
// Only filesystem paths are judged: VST3/LV2 fileOrIdentifiers are absolute
// paths (File::exists covers both a .vst3 file and a bundle directory),
// whereas macOS AudioUnit identifiers ("AudioUnit:...") are not absolute
// paths and must not be rejected here.
if (juce::File::isAbsolutePath(pluginPath) && ! juce::File(pluginPath).exists())
{
error = "Plugin file not found: " + pluginPath;
VST_TRACE("loadVstSandboxAware: missing plugin file '%s' — skipping load",
pluginPath.toRawUTF8());
return nullptr;
}
juce::PluginDescription probeDesc;
probeDesc.fileOrIdentifier = pluginPath;
probeDesc.name = juce::File(pluginPath).getFileNameWithoutExtension();
if (slopsmith::sandbox::shouldSandbox(probeDesc))
{
sandboxRequired = true;
juce::String sandboxErr;
auto processor = slopsmith::sandbox::tryLoadSandboxed(
probeDesc, sr, bs, sandboxErr);
if (!processor)
{
error = "sandbox load failed: "
+ (sandboxErr.isEmpty() ? juce::String("unknown error")
: sandboxErr);
VST_TRACE("loadVstSandboxAware: sandbox path declined/failed: %s",
sandboxErr.toRawUTF8());
}
return processor;
}
#if JUCE_MAC
// macOS has no separate JUCE message thread (see startJuceMessageThread /
// dispatchOnMessageThread): the JUCE MessageManager is bound to the
// Node/main thread, and dispatchOnMessageThread historically ran inline
// on the caller. A callAsync + done->wait pattern would queue a callback
// to a pump that may never run in this calling context.
//
// Fall back to the sync loadPlugin, executed on whichever thread called
// in — the Node/main thread for LoadVST's JUCE_MAC branch (correct: that
// *is* the MessageManager thread on macOS), or a libuv worker thread for
// LoadPresetWorker (the pre-existing macOS constraint). Caveat: the
// existing dispatchOnMessageThread block on macOS already documents
// that "VST/AU plugin instantiation (which genuinely requires a message
// thread on macOS) is the one capability we give up until a proper
// libuv-based pump lands." LoadPresetWorker has called loadVstSandbox-
// Aware on a worker thread for ages under exactly the same constraint;
// moving LoadVST to AsyncWorker brings direct loads under the same
// (pre-existing) limitation. The AmpliTube-class self-message problem
// this PR targets is Windows-specific (Electron owns the OS main
// thread, forcing JUCE's MessageManager onto a background thread that
// createPluginInstance then blocks); macOS doesn't have that mismatch.
auto host = snapshotVstHost();
if (! host) { error = "vstHost not initialised"; return nullptr; }
juce::String err;
auto instance = host->loadPlugin(pluginPath, sr, bs, err);
if (! instance) error = err.isNotEmpty() ? err : juce::String("load failed");
return instance;
#else
// In-process: kick off createPluginInstanceAsync on the message thread,
// block *this* (libuv worker) thread on a WaitableEvent until the load
// callback fires. The message thread keeps pumping during the wait so
// the plugin's self-posted init messages dispatch and its state finishes
// wiring up before the editor is ever opened.
//
// All state passed across the thread hop is held by shared_ptr so it
// outlives the lambda even on an unexpected destructor / scope exit.
auto instance = std::make_shared<std::unique_ptr<juce::AudioPluginInstance>>();
auto loadError = std::make_shared<juce::String>();
auto done = std::make_shared<juce::WaitableEvent>();
// Register BEFORE scheduling so a shutdown that lands between callAsync
// and the wait below can't miss us — cancelAllPendingLoads would
// otherwise see an empty set and the worker would block forever.
registerPendingLoad(done);
// Check alreadyShutDown after registering to catch the inverse race
// (shutdown ran before we registered): if it's already set, the
// shutdown won't see this event and we must bail ourselves.
if (slopsmith::addon::isShuttingDown())
{
unregisterPendingLoad(done);
error = "shutdown in flight";
return nullptr;
}
// Snapshot a shared_ptr to vstHost so the async load and its inner
// continuation can keep VSTHost (and thus formatManager) alive even if
// shutdown resets the global mid-load. The inner callback captures the
// same hostKeeper, so JUCE retains it until createPluginInstanceAsync
// completes; once the callback destructs, the keeper drops, and if the
// global has been reset by then the VSTHost destructor runs safely
// (no work in flight). The snapshot itself goes through vstHostMutex
// so the shared_ptr copy can't race with shutdown's vstHost.reset().
auto hostKeeper = snapshotVstHost();
const bool scheduled = juce::MessageManager::callAsync(
[hostKeeper, pluginPath, sr, bs, instance, loadError, done]()
{
// Shutdown may have fired between callAsync queueing this
// lambda and the message thread picking it up. Bail before
// kicking off another in-flight createPluginInstanceAsync
// that the shutdown would otherwise have to wait on.
if (slopsmith::addon::isShuttingDown())
{
*loadError = "shutdown in flight";
done->signal();
return;
}
if (! hostKeeper)
{
*loadError = "vstHost not initialised";
done->signal();
return;
}
hostKeeper->loadPluginAsync(
pluginPath, sr, bs,
[hostKeeper, instance, loadError, done]
(std::unique_ptr<juce::AudioPluginInstance> inst, juce::String err)
{
*instance = std::move(inst);
*loadError = std::move(err);
done->signal();
});
});
if (! scheduled)
{
// The message queue is gone (typically: shutdown in flight). The
// lambda will never run, so done would never signal — surface the
// failure rather than hanging the worker forever.
unregisterPendingLoad(done);
error = "message manager unavailable (shutdown?)";
return nullptr;
}
// No timeout: createPluginInstanceAsync is genuinely async (the message
// thread keeps pumping), so a slow first-run plugin (e.g. one doing a
// license check that exceeds 15 s) is allowed to take however long it
// takes. The old 15-second timeout in dispatchOnMessageThread could
// return early while the lambda was still running, then the lambda
// would construct a fully-initialised plugin only for it to immediately
// destruct because no one held a reference — running VST teardown on
// the message thread while the user had already moved on. That race is
// gone with this design.
//
// Tradeoff: this call holds a libuv threadpool worker for the duration
// of the plugin's init. Multiple concurrent hung loads could in theory
// starve other AsyncWorkers (fs / crypto). In practice plugin loads are
// user-driven and serialised (LoadPresetWorker loads slots one at a
// time), and a truly stuck load is bounded by app shutdown via
// cancelAllPendingLoads. A proper "fire-and-forget with a TSFN
// completion callback" model would eliminate the block entirely but
// requires a bigger API restructure than this PR's scope.
done->wait();
unregisterPendingLoad(done);
// Distinguish "shutdown cancelled us before the callback fired"
// (instance null AND error empty) from a normal load failure (instance
// null with error set) and a normal success.
if (! *instance && loadError->isEmpty())
{
error = "load cancelled (shutdown)";
return nullptr;
}
error = *loadError;
return std::move(*instance);
#endif
}
// AsyncWorker wrapper for LoadVST. Execute() runs on a libuv worker thread,
// so loadVstSandboxAware can block-wait on the async load without freezing
// the JS main thread or deadlocking the JUCE message thread.
// ── VST/NAM/IR workers + handlers (moved verbatim from NodeAddon.cpp) ────────────────────
class LoadVSTWorker : public Napi::AsyncWorker
{
public:
LoadVSTWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path)
: Napi::AsyncWorker(env)
, deferred_(deferred)
, pluginPath_(std::move(path)) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
// Snapshot engine + vstHost through their mutex-protected helpers so
// shutdown's reset on the message thread can't race the worker's
// dereferences below. The shared_ptr locals keep both objects alive
// for the duration of this worker even if the globals get reset
// mid-load. The atomic alreadyShutDown gate is the early-out: once
// it's set, the dispatched reset is on its way and there's no point
// continuing.
if (slopsmith::addon::isShuttingDown())
{
error_ = "shutdown in flight";
return;
}
auto engineKeeper = snapshotEngine();
auto hostSnap = snapshotVstHost();
if (!engineKeeper || !hostSnap)
{
error_ = "engine not initialised";
return;
}
const auto sr = loadSafeSampleRate(*engineKeeper);
const auto bs = loadSafeBlockSize(*engineKeeper);
const auto path = juce::String(pluginPath_);
VST_TRACE("LoadVSTWorker: path='%s' sr=%.0f bs=%d",
pluginPath_.c_str(), sr, bs);
bool sandboxRequired = false;
juce::String err;
auto processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired);
if (sandboxRequired && !processor)
{
// The plugin's on the denylist and the sandbox couldn't spawn —
// falling back to in-process is what crashed the addon to begin
// with. Surface as a JS exception (handled in OnOK).
fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8());
error_ = err;
sandboxFailed_ = true;
return;
}
if (!processor)
{
fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8());
error_ = err;
return;
}
// Engine may have been torn down while we were waiting on the async
// load. The shared_ptr captures keep `processor` alive; just don't
// touch a freed engine. The processor destructs cleanly when this
// scope exits.
//
// Gate on alreadyShutDown (atomic, properly synchronised) before the
// raw engine/vstHost pointer reads — once that flag is set, the
// dispatched reset of engine/vstHost is on its way and any use of
// the pointers from this worker thread is racy. The atomic check is
// the authoritative "should I still be touching engine?" signal.
if (slopsmith::addon::isShuttingDown())
{
error_ = "engine torn down during load";
return;
}
// Re-snapshot the engine — the original engineKeeper might have
// outlived a reset on the message thread, but the AudioEngine
// we're about to mutate must be the still-installed one. If the
// global has been reset, the local keeps the old engine alive but
// we shouldn't be adding slots to it any more.
auto liveEngine = snapshotEngine();
if (!liveEngine || !snapshotVstHost())
{
error_ = "engine torn down during load";
return;
}
auto name = processor->getName();
slotId_ = liveEngine->getSignalChain().addProcessor(
std::move(processor),
ProcessorSlot::Type::VST,
name,
path);
}
void OnOK() override
{
if (sandboxFailed_)
{
// Match the prior LoadVST throw-on-required-sandbox-failure
// behaviour so renderers' try/catch keeps working.
deferred_.Reject(
Napi::Error::New(Env(), error_.toStdString()).Value());
return;
}
deferred_.Resolve(Napi::Number::New(Env(), slotId_));
}
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
std::string pluginPath_;
int slotId_ = -1;
bool sandboxFailed_ = false;
juce::String error_;
};
Napi::Value LoadVST(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
if (!snapshotEngine() || !snapshotVstHost() || info.Length() < 1)
{
deferred.Resolve(Napi::Number::New(env, -1));
return deferred.Promise();
}
auto pluginPath = info[0].As<Napi::String>().Utf8Value();
#if JUCE_MAC
// On macOS the JUCE MessageManager is bound to the Node/main thread.
// Running this as an AsyncWorker would call vstHost->loadPlugin on a
// libuv worker thread, which JUCE documents as unsupported for VST/AU
// instantiation. Do the load synchronously on the Node/main thread
// (same as the pre-PR LoadVST) and return a resolved Promise to match
// the new signature. Pays the foreground-block cost the AsyncWorker
// path was supposed to avoid, but that's the existing macOS reality —
// dispatchOnMessageThread already runs inline there. The async-load
// motivation (AmpliTube blocking the background JUCE message thread
// under Electron) is a Windows-only problem.
// Snapshot once for the whole load so the same AudioEngine is used for
// the sr/bs reads and the addProcessor mutation, even if shutdown
// resets the global mid-call.
auto liveEngine = snapshotEngine();
if (! liveEngine)
{
deferred.Resolve(Napi::Number::New(env, -1));
return deferred.Promise();
}
juce::String error;
bool sandboxRequired = false;
auto processor = loadVstSandboxAware(
juce::String(pluginPath),
loadSafeSampleRate(*liveEngine),
loadSafeBlockSize(*liveEngine),
error, sandboxRequired);
if (sandboxRequired && !processor)
{
fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8());
deferred.Reject(
Napi::Error::New(env, error.toStdString()).Value());
return deferred.Promise();
}
int slotId = -1;
if (processor)
{
auto name = processor->getName();
slotId = liveEngine->getSignalChain().addProcessor(
std::move(processor),
ProcessorSlot::Type::VST,
name,
juce::String(pluginPath));
}
else
{
fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8());
}
deferred.Resolve(Napi::Number::New(env, slotId));
return deferred.Promise();
#else
auto* worker = new LoadVSTWorker(env, deferred, std::move(pluginPath));
worker->Queue();
return deferred.Promise();
#endif
}
class LoadNAMWorker : public Napi::AsyncWorker
{
public:
LoadNAMWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path)
: Napi::AsyncWorker(env), deferred_(deferred), modelPath_(std::move(path)) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
auto liveEngine = snapshotEngine();
if (!liveEngine) { slotId_ = -1; return; }
auto processor = std::make_unique<NAMProcessor>();
if (processor->loadModel(juce::File(juce::String(modelPath_))))
{
auto name = processor->getModelName();
slotId_ = liveEngine->getSignalChain().addProcessor(
std::move(processor),
ProcessorSlot::Type::NAM,
"NAM: " + name,
juce::String(modelPath_));
}
}
void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); }
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
std::string modelPath_;
int slotId_ = -1;
};
Napi::Value LoadNAMModel(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
if (!snapshotEngine() || info.Length() < 1) {
deferred.Resolve(Napi::Number::New(env, -1));
return deferred.Promise();
}
auto modelPath = info[0].As<Napi::String>().Utf8Value();
auto worker = new LoadNAMWorker(env, deferred, modelPath);
worker->Queue();
return deferred.Promise();
}
class LoadIRWorker : public Napi::AsyncWorker
{
public:
LoadIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path)
: Napi::AsyncWorker(env), deferred_(deferred), irPath_(std::move(path)) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
auto liveEngine = snapshotEngine();
if (!liveEngine) { slotId_ = -1; return; }
const auto sr = loadSafeSampleRate(*liveEngine);
const auto bs = loadSafeBlockSize(*liveEngine);
auto processor = std::make_unique<IRLoader>();
processor->setPlayConfigDetails(2, 2, sr, bs);
processor->prepareToPlay(sr, bs);
if (processor->loadIR(juce::File(juce::String(irPath_))))
{
auto name = processor->getIRName();
slotId_ = liveEngine->getSignalChain().addProcessor(
std::move(processor),
ProcessorSlot::Type::IR,
"IR: " + name,
juce::String(irPath_));
}
}
void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); }
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
std::string irPath_;
int slotId_ = -1;
};
Napi::Value LoadIR(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
if (!snapshotEngine() || info.Length() < 1) {
deferred.Resolve(Napi::Number::New(env, -1));
return deferred.Promise();
}
auto irPath = info[0].As<Napi::String>().Utf8Value();
auto worker = new LoadIRWorker(env, deferred, irPath);
worker->Queue();
return deferred.Promise();
}
// Replace the IR of an EXISTING convolution slot in place (cab swap / mic move),
// so the rest of the chain — the amp VST above all — is NOT torn down and rebuilt.
// Mirrors LoadIRWorker but calls SignalChain::replaceProcessor(slotId, …) instead
// of addProcessor. Optional `gain` (>=0) updates the slot's post-gain (the cab
// makeup); a negative gain leaves the existing post-gain untouched.
class ReplaceIRWorker : public Napi::AsyncWorker
{
public:
ReplaceIRWorker(Napi::Env env, Napi::Promise::Deferred deferred,
int slotId, std::string path, float gain)
: Napi::AsyncWorker(env), deferred_(deferred),
slotId_(slotId), irPath_(std::move(path)), gain_(gain) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
auto liveEngine = snapshotEngine();
if (!liveEngine) { ok_ = false; return; }
const auto sr = loadSafeSampleRate(*liveEngine);
const auto bs = loadSafeBlockSize(*liveEngine);
auto processor = std::make_unique<IRLoader>();
processor->setPlayConfigDetails(2, 2, sr, bs);
processor->prepareToPlay(sr, bs);
if (! processor->loadIR(juce::File(juce::String(irPath_)))) { ok_ = false; return; }
auto name = processor->getIRName();
ok_ = liveEngine->getSignalChain().replaceProcessor(
slotId_, std::move(processor),
"IR: " + name, juce::String(irPath_));
if (ok_ && gain_ >= 0.0f)
liveEngine->getSignalChain().setPostGain(slotId_, gain_);
}
void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); }
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
int slotId_;
std::string irPath_;
float gain_;
bool ok_ = false;
};
Napi::Value ReplaceIR(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
if (!snapshotEngine() || info.Length() < 2
|| !info[0].IsNumber() || !info[1].IsString()) {
deferred.Resolve(Napi::Boolean::New(env, false));
return deferred.Promise();
}
const int slotId = info[0].As<Napi::Number>().Int32Value();
const auto irPath = info[1].As<Napi::String>().Utf8Value();
const float gain = (info.Length() >= 3 && info[2].IsNumber())
? info[2].As<Napi::Number>().FloatValue() : -1.0f;
auto worker = new ReplaceIRWorker(env, deferred, slotId, irPath, gain);
worker->Queue();
return deferred.Promise();
}
// ── LoadPresetWorker + handler (moved verbatim from NodeAddon.cpp) ────────────────────
class LoadPresetWorker : public Napi::AsyncWorker
{
public:
LoadPresetWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string json)
: Napi::AsyncWorker(env), deferred_(deferred), presetJson_(std::move(json)) {}
void Execute() override
{
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
// workers on the libuv pool must not interleave clear()/addProcessor().
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
auto liveEngine = snapshotEngine();
if (!liveEngine) { success_ = false; error_ = "No engine"; return; }
auto parsed = juce::JSON::parse(juce::String(presetJson_));
if (!parsed.isObject()) { success_ = false; error_ = "Invalid JSON"; return; }
auto* root = parsed.getDynamicObject();
if (!root) { success_ = false; error_ = "Invalid preset"; return; }
auto chainVar = root->getProperty("chain");
auto* chainArray = chainVar.getArray();
if (!chainArray) { success_ = false; error_ = "No chain array"; return; }
// NB: any open in-process editor windows were already torn down on the
// message thread by LoadPreset() before this AsyncWorker was queued (see
// there) — so clearing the chain here can't leave an editor pointing at
// a freed processor (use-after-free; #56). We deliberately do NOT tear
// editors down from this worker thread: JUCE GUI objects must only be
// destroyed on the message thread, and macOS has no pump to marshal to
// from here.
// Clear existing chain
liveEngine->getSignalChain().clear();
double sr = loadSafeSampleRate(*liveEngine);
int bs = loadSafeBlockSize(*liveEngine);
for (auto& slotVar : *chainArray)
{
auto* slotObj = slotVar.getDynamicObject();
if (!slotObj) continue;
int type = (int)slotObj->getProperty("type");
auto name = slotObj->getProperty("name").toString();
auto path = slotObj->getProperty("path").toString();
bool bypassed = (bool)slotObj->getProperty("bypassed");
auto stateB64 = slotObj->getProperty("state").toString();
std::unique_ptr<juce::AudioProcessor> processor;
if (type == (int)ProcessorSlot::Type::VST && snapshotVstHost())
{
// Sandbox-aware load: a crash-blocklisted plugin restored
// from a preset must still go out-of-process, otherwise the
// "one crash, then always sandbox" contract is defeated.
juce::String err;
bool sandboxRequired = false;
processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired);
if (!processor)
{
fprintf(stderr, "[LoadPreset] VST load failed: %s (%s)\n",
name.toRawUTF8(), err.toRawUTF8());
continue;
}
}
else if (type == (int)ProcessorSlot::Type::NAM)
{
auto nam = std::make_unique<NAMProcessor>();
if (!nam->loadModel(juce::File(path)))
{
fprintf(stderr, "[LoadPreset] NAM load failed: %s\n", path.toRawUTF8());
continue;
}
processor = std::move(nam);
}
else if (type == (int)ProcessorSlot::Type::IR)
{
auto ir = std::make_unique<IRLoader>();
ir->setPlayConfigDetails(2, 2, sr, bs);
ir->prepareToPlay(sr, bs);
if (!ir->loadIR(juce::File(path)))
{
fprintf(stderr, "[LoadPreset] IR load failed: %s\n", path.toRawUTF8());
continue;
}
processor = std::move(ir);
}
else continue;
int slotId = liveEngine->getSignalChain().addProcessor(
std::move(processor),
(ProcessorSlot::Type)type,
name, path);
if (bypassed && slotId >= 0)
liveEngine->getSignalChain().setBypass(slotId, true);
// Stereo routing (St-1). Absent keys read back as 0 (= default), so
// mono presets restore exactly as before.
if (slotId >= 0)
{
if (slotObj->hasProperty("pan"))
liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan"));
if (slotObj->hasProperty("branch"))
liveEngine->getSignalChain().setBranch(slotId, (int)slotObj->getProperty("branch"));
if (slotObj->hasProperty("postGain"))
liveEngine->getSignalChain().setPostGain(slotId, (float)(double)slotObj->getProperty("postGain"));
if (slotObj->hasProperty("branchSrc"))
liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("branchSrc"));
}
// Restore processor state (JUCE-format base64; IR/NAM slots also
// accept standard base64 — see decodeStateBlob: their plugin-
// emitted JSON states were silently dropped before, so IR stages
// never got their per-stage gain).
if (stateB64.isNotEmpty() && slotId >= 0)
{
const bool allowStandard = type == (int)ProcessorSlot::Type::IR
|| type == (int)ProcessorSlot::Type::NAM;
juce::MemoryBlock state;
if (decodeStateBlob(stateB64, state, allowStandard))
{
// Through the class's own synchronized API (deep-read 9) --
// no more const_cast around setSlotState's locking.
liveEngine->getSignalChain().setSlotState(slotId, state);
}
}
slotsLoaded_++;
}
success_ = true;
generation_ = slopsmith::addon::bumpChainGeneration(); // still under chainLock
}
void OnOK() override
{
auto obj = Napi::Object::New(Env());
obj.Set("success", success_);
obj.Set("slotsLoaded", slotsLoaded_);
obj.Set("chainGeneration", (double) generation_);
if (!success_) obj.Set("error", error_);
deferred_.Resolve(obj);
}
void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
private:
Napi::Promise::Deferred deferred_;
std::string presetJson_;
uint64_t generation_ = 0;
bool success_ = false;
std::string error_;
int slotsLoaded_ = 0;
};
Napi::Value LoadPreset(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto deferred = Napi::Promise::Deferred::New(env);
auto liveEngine = snapshotEngine();
if (!liveEngine || info.Length() < 1) {
auto obj = Napi::Object::New(env);
obj.Set("success", false);
obj.Set("error", "No engine or missing argument");
deferred.Resolve(obj);
return deferred.Promise();
}
// Tear down any open in-process editor windows NOW, on the N-API/main
// thread, before the AsyncWorker frees the chain's processors on a libuv
// worker (#56). Doing it here — not inside LoadPresetWorker::Execute — keeps
// JUCE GUI teardown off the worker thread: on macOS this thread IS the
// message thread (inline teardown); on Linux/Windows closeAllPluginEditor-
// Windows() posts to the dedicated JUCE message thread and blocks. Either
// way editors are destroyed before Execute() clears the chain.
closeAllPluginEditorWindows();
auto json = info[0].As<Napi::String>().Utf8Value();
auto worker = new LoadPresetWorker(env, deferred, json);
worker->Queue();
return deferred.Promise();
}
} // namespace slopsmith::addon } // namespace slopsmith::addon
+28
View File
@@ -21,9 +21,17 @@
// The full worker bodies migrate into this unit with the phase-7 binding // The full worker bodies migrate into this unit with the phase-7 binding
// split; the serializer lands first so the storm gate flips. // split; the serializer lands first so the storm gate flips.
#include <napi.h>
#include <juce_core/juce_core.h>
#include <cstdint> #include <cstdint>
#include <memory>
#include <mutex> #include <mutex>
class AudioEngine;
namespace juce { class AudioProcessor; }
namespace slopsmith::addon { namespace slopsmith::addon {
// Held for the FULL clear+rebuild (or single-slot mutation). Control/worker // Held for the FULL clear+rebuild (or single-slot mutation). Control/worker
@@ -41,4 +49,24 @@ uint64_t currentChainGeneration();
// const uint64_t gen = bumpChainGeneration(); // still under the lock // const uint64_t gen = bumpChainGeneration(); // still under the lock
// (return gen in the result object) // (return gen in the result object)
// ── Shared load helpers (used by the workers here and SetSlotState) ──────
// Decode a state blob in EITHER base64 flavour (JUCE-proprietary first,
// standard RFC-4648 fallback when `allowStandard` — IR/NAM slots only).
bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb, bool allowStandard);
double loadSafeSampleRate(const AudioEngine& eng);
int loadSafeBlockSize(const AudioEngine& eng);
// Load a VST3 through the out-of-process sandbox when shouldSandbox() says
// so, else in-process via the async message-pumping path. See the .cpp for
// the threading contract.
std::unique_ptr<juce::AudioProcessor> loadVstSandboxAware(
const juce::String& pluginPath, double sr, int bs,
juce::String& error, bool& sandboxRequired);
// ── N-API handlers (registered by NodeAddon's export table) ──────────────
Napi::Value LoadVST(const Napi::CallbackInfo& info);
Napi::Value LoadNAMModel(const Napi::CallbackInfo& info);
Napi::Value LoadIR(const Napi::CallbackInfo& info);
Napi::Value ReplaceIR(const Napi::CallbackInfo& info);
Napi::Value LoadPreset(const Napi::CallbackInfo& info);
} // namespace slopsmith::addon } // namespace slopsmith::addon