mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-11 05:44:10 +00:00
fix(audio): address PR #107 review — close serializer gaps, editor lifetime races, dispatch failures
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d887c68014
commit
ea8c6a9ccd
@@ -81,7 +81,7 @@ static void stopJuceMessageThread()
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispatchOnMessageThreadImpl(std::function<void()> func)
|
bool dispatchOnMessageThreadImpl(std::function<void()> func)
|
||||||
{
|
{
|
||||||
#if JUCE_MAC
|
#if JUCE_MAC
|
||||||
// No background message thread on macOS — execute inline on caller thread.
|
// No background message thread on macOS — execute inline on caller thread.
|
||||||
@@ -89,17 +89,38 @@ void dispatchOnMessageThreadImpl(std::function<void()> func)
|
|||||||
// instantiation (which genuinely requires a message thread on macOS) is
|
// instantiation (which genuinely requires a message thread on macOS) is
|
||||||
// the one capability we give up until a proper libuv-based pump lands.
|
// the one capability we give up until a proper libuv-based pump lands.
|
||||||
func();
|
func();
|
||||||
|
return true;
|
||||||
#else
|
#else
|
||||||
// Heap-allocate the WaitableEvent and capture by value so the queued
|
// Heap-allocate the WaitableEvent and capture by value so the queued
|
||||||
// callAsync closure can outlive this stack frame. Without this, a 15 s
|
// callAsync closure can outlive this stack frame. Without this, a 15 s
|
||||||
// timeout (rare, but possible during shutdown when the message thread is
|
// timeout (rare, but possible during shutdown when the message thread is
|
||||||
// busy) leaves the lambda running on freed `done` storage — a real UAF.
|
// busy) leaves the lambda running on freed `done` storage — a real UAF.
|
||||||
|
//
|
||||||
|
// Both failure modes are reported to the caller: a refused post means
|
||||||
|
// `func` will NEVER run (message queue already gone); a wait timeout
|
||||||
|
// means it hasn't run YET (it may still run later while the dispatch
|
||||||
|
// loop drains). Lifecycle callers must not proceed as if the work
|
||||||
|
// completed — doShutdown in particular used to unload the addon while
|
||||||
|
// editor teardown / stopAudio / engine destruction were still pending.
|
||||||
auto done = std::make_shared<juce::WaitableEvent>();
|
auto done = std::make_shared<juce::WaitableEvent>();
|
||||||
juce::MessageManager::callAsync([func = std::move(func), done]() mutable {
|
const bool posted = juce::MessageManager::callAsync(
|
||||||
|
[func = std::move(func), done]() mutable {
|
||||||
func();
|
func();
|
||||||
done->signal();
|
done->signal();
|
||||||
});
|
});
|
||||||
done->wait(15000);
|
if (!posted)
|
||||||
|
{
|
||||||
|
fprintf(stderr, "[audio-native] dispatchOnMessageThread: message queue "
|
||||||
|
"refused the post; dispatched work will not run\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!done->wait(15000))
|
||||||
|
{
|
||||||
|
fprintf(stderr, "[audio-native] dispatchOnMessageThread: dispatched work "
|
||||||
|
"did not complete within 15s\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +173,7 @@ void initialize(std::function<void()> uiTeardownHook)
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Create engine on the JUCE message thread (or inline on macOS)
|
// Create engine on the JUCE message thread (or inline on macOS)
|
||||||
dispatchOnMessageThread([]() {
|
const bool initialized = dispatchOnMessageThread([]() {
|
||||||
std::shared_ptr<AudioEngine> liveEngine;
|
std::shared_ptr<AudioEngine> liveEngine;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(engineMutex);
|
std::lock_guard<std::mutex> lock(engineMutex);
|
||||||
@@ -172,6 +193,9 @@ void initialize(std::function<void()> uiTeardownHook)
|
|||||||
types[i].inputDevices.size(),
|
types[i].inputDevices.size(),
|
||||||
types[i].outputDevices.size());
|
types[i].outputDevices.size());
|
||||||
});
|
});
|
||||||
|
if (!initialized)
|
||||||
|
fprintf(stderr, "[audio-native] initialize: engine creation did not complete "
|
||||||
|
"on the message thread; audio bindings will no-op until re-init\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
void doShutdown()
|
void doShutdown()
|
||||||
@@ -201,7 +225,7 @@ void doShutdown()
|
|||||||
|
|
||||||
if (juceRunning.load() || snapshotEngine() || snapshotVstHost())
|
if (juceRunning.load() || snapshotEngine() || snapshotVstHost())
|
||||||
{
|
{
|
||||||
dispatchOnMessageThread([]() {
|
const bool toreDown = dispatchOnMessageThread([]() {
|
||||||
// Editors reference their slot's processor; engine.reset() below
|
// Editors reference their slot's processor; engine.reset() below
|
||||||
// frees the whole chain, so destroy the editor windows first (#56).
|
// frees the whole chain, so destroy the editor windows first (#56).
|
||||||
if (shutdownUiTeardown) shutdownUiTeardown();
|
if (shutdownUiTeardown) shutdownUiTeardown();
|
||||||
@@ -216,6 +240,20 @@ void doShutdown()
|
|||||||
vstHost.reset();
|
vstHost.reset();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (!toreDown)
|
||||||
|
{
|
||||||
|
// Editor teardown / stopAudio / engine destruction have NOT
|
||||||
|
// completed. Do not stop the message thread underneath them: a
|
||||||
|
// timed-out teardown lambda is still queued and can only finish
|
||||||
|
// if the pump keeps running. Leaking the pump thread at process
|
||||||
|
// exit beats unloading the addon mid-destruction (the exact
|
||||||
|
// shutdown UAF this path exists to prevent). The latch stays
|
||||||
|
// set, so a re-entrant shutdown call no-ops.
|
||||||
|
fprintf(stderr, "[audio-native] doShutdown: engine teardown did not "
|
||||||
|
"complete; leaving message thread running\n");
|
||||||
|
slopsmith::sandbox::uninstallVstCrashAttribution();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopJuceMessageThread();
|
stopJuceMessageThread();
|
||||||
|
|||||||
@@ -43,11 +43,15 @@ void doShutdown();
|
|||||||
// Dispatch `func` on the JUCE message thread and wait (bounded 15 s).
|
// Dispatch `func` on the JUCE message thread and wait (bounded 15 s).
|
||||||
// macOS: executes inline on the caller thread — no background pump exists
|
// macOS: executes inline on the caller thread — no background pump exists
|
||||||
// (AppKit owns the real main thread; see the fork note in the .cpp).
|
// (AppKit owns the real main thread; see the fork note in the .cpp).
|
||||||
void dispatchOnMessageThreadImpl(std::function<void()> func);
|
// Returns false when the work did not complete: the post was refused
|
||||||
|
// (message queue gone — `func` will never run) or the wait timed out
|
||||||
|
// (`func` may still run later). Lifecycle callers must treat false as
|
||||||
|
// "teardown/init did not happen" rather than continuing.
|
||||||
|
bool dispatchOnMessageThreadImpl(std::function<void()> func);
|
||||||
template <typename Func>
|
template <typename Func>
|
||||||
inline void dispatchOnMessageThread(Func&& func)
|
inline bool dispatchOnMessageThread(Func&& func)
|
||||||
{
|
{
|
||||||
dispatchOnMessageThreadImpl(std::function<void()>(std::forward<Func>(func)));
|
return dispatchOnMessageThreadImpl(std::function<void()>(std::forward<Func>(func)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending-async-load registry: LoadVSTWorker / LoadPresetWorker block on a
|
// Pending-async-load registry: LoadVSTWorker / LoadPresetWorker block on a
|
||||||
|
|||||||
@@ -74,8 +74,25 @@ Napi::Value SetBypass(const Napi::CallbackInfo& info)
|
|||||||
|
|
||||||
Napi::Value ClearChain(const Napi::CallbackInfo& info)
|
Napi::Value ClearChain(const Napi::CallbackInfo& info)
|
||||||
{
|
{
|
||||||
|
// Gate editor opens for the whole teardown+clear window (see the rebuild
|
||||||
|
// barrier in ChainOps.h): without it, an editor opened between the
|
||||||
|
// teardown below and the clear acquiring the mutex would point at a
|
||||||
|
// processor the clear is about to free.
|
||||||
|
slopsmith::addon::beginChainRebuild();
|
||||||
|
struct BarrierRelease {
|
||||||
|
~BarrierRelease() { slopsmith::addon::endChainRebuild(); }
|
||||||
|
} barrierRelease;
|
||||||
|
|
||||||
// Tear editors down before their processors are freed just below (#56).
|
// Tear editors down before their processors are freed just below (#56).
|
||||||
closeAllPluginEditorWindows();
|
if (!closeAllPluginEditorWindows())
|
||||||
|
{
|
||||||
|
// Teardown refused/timed out: an editor may still be bound to a chain
|
||||||
|
// processor. Clearing now would free it under the live editor — the
|
||||||
|
// documented UAF. Skip the clear; the caller can retry.
|
||||||
|
fprintf(stderr, "[audio-native] clearChain: editor teardown did not complete; "
|
||||||
|
"chain left untouched\n");
|
||||||
|
return info.Env().Undefined();
|
||||||
|
}
|
||||||
if (auto liveEngine = snapshotEngine())
|
if (auto liveEngine = snapshotEngine())
|
||||||
{
|
{
|
||||||
// Serialized with the async chain workers (deep-read 1). May block
|
// Serialized with the async chain workers (deep-read 1). May block
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ uint64_t currentChainGeneration()
|
|||||||
return chainGeneration.load(std::memory_order_acquire);
|
return chainGeneration.load(std::memory_order_acquire);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rebuild barrier (see ChainOps.h) ────────────────────────────────────────
|
||||||
|
|
||||||
|
static std::atomic<int> chainRebuildsPending{0};
|
||||||
|
|
||||||
|
void beginChainRebuild()
|
||||||
|
{
|
||||||
|
chainRebuildsPending.fetch_add(1, std::memory_order_acq_rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
void endChainRebuild()
|
||||||
|
{
|
||||||
|
chainRebuildsPending.fetch_sub(1, std::memory_order_acq_rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isChainRebuildPending()
|
||||||
|
{
|
||||||
|
return chainRebuildsPending.load(std::memory_order_acquire) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ── decodeStateBlob (moved verbatim) ────────────────────
|
// ── decodeStateBlob (moved verbatim) ────────────────────
|
||||||
|
|
||||||
// Decode a state blob that may be in EITHER base64 flavour. JUCE's
|
// Decode a state blob that may be in EITHER base64 flavour. JUCE's
|
||||||
@@ -398,6 +417,8 @@ public:
|
|||||||
ProcessorSlot::Type::VST,
|
ProcessorSlot::Type::VST,
|
||||||
name,
|
name,
|
||||||
path);
|
path);
|
||||||
|
if (slotId_ >= 0)
|
||||||
|
slopsmith::addon::bumpChainGeneration(); // still under chainLock
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnOK() override
|
void OnOK() override
|
||||||
@@ -476,11 +497,22 @@ Napi::Value LoadVST(const Napi::CallbackInfo& info)
|
|||||||
if (processor)
|
if (processor)
|
||||||
{
|
{
|
||||||
auto name = processor->getName();
|
auto name = processor->getName();
|
||||||
|
// Serialize with the async chain workers (deep-read 1): an unguarded
|
||||||
|
// addProcessor here could land a slot inside a LoadPresetWorker's
|
||||||
|
// clear()+rebuild running on a libuv thread. Deadlock-safe on macOS:
|
||||||
|
// a worker holding this mutex never waits on THIS (Node/main) thread —
|
||||||
|
// loadVstSandboxAware's JUCE_MAC branch is a synchronous load on the
|
||||||
|
// worker itself, and dispatchOnMessageThread runs inline there. Only
|
||||||
|
// the mutation is guarded; the slow plugin load above stays outside
|
||||||
|
// the lock.
|
||||||
|
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
|
||||||
slotId = liveEngine->getSignalChain().addProcessor(
|
slotId = liveEngine->getSignalChain().addProcessor(
|
||||||
std::move(processor),
|
std::move(processor),
|
||||||
ProcessorSlot::Type::VST,
|
ProcessorSlot::Type::VST,
|
||||||
name,
|
name,
|
||||||
juce::String(pluginPath));
|
juce::String(pluginPath));
|
||||||
|
if (slotId >= 0)
|
||||||
|
slopsmith::addon::bumpChainGeneration(); // still under chainLock
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -518,6 +550,8 @@ public:
|
|||||||
ProcessorSlot::Type::NAM,
|
ProcessorSlot::Type::NAM,
|
||||||
"NAM: " + name,
|
"NAM: " + name,
|
||||||
juce::String(modelPath_));
|
juce::String(modelPath_));
|
||||||
|
if (slotId_ >= 0)
|
||||||
|
slopsmith::addon::bumpChainGeneration(); // still under chainLock
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +607,8 @@ public:
|
|||||||
ProcessorSlot::Type::IR,
|
ProcessorSlot::Type::IR,
|
||||||
"IR: " + name,
|
"IR: " + name,
|
||||||
juce::String(irPath_));
|
juce::String(irPath_));
|
||||||
|
if (slotId_ >= 0)
|
||||||
|
slopsmith::addon::bumpChainGeneration(); // still under chainLock
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +671,8 @@ public:
|
|||||||
"IR: " + name, juce::String(irPath_));
|
"IR: " + name, juce::String(irPath_));
|
||||||
if (ok_ && gain_ >= 0.0f)
|
if (ok_ && gain_ >= 0.0f)
|
||||||
liveEngine->getSignalChain().setPostGain(slotId_, gain_);
|
liveEngine->getSignalChain().setPostGain(slotId_, gain_);
|
||||||
|
if (ok_)
|
||||||
|
slopsmith::addon::bumpChainGeneration(); // still under chainLock
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); }
|
void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); }
|
||||||
@@ -680,6 +718,13 @@ public:
|
|||||||
|
|
||||||
void Execute() override
|
void Execute() override
|
||||||
{
|
{
|
||||||
|
// Release the rebuild barrier LoadPreset() armed before editor
|
||||||
|
// teardown, on every exit path — editors may open again once the
|
||||||
|
// rebuild below has completed (or bailed).
|
||||||
|
struct BarrierRelease {
|
||||||
|
~BarrierRelease() { slopsmith::addon::endChainRebuild(); }
|
||||||
|
} barrierRelease;
|
||||||
|
|
||||||
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
|
// Serialize the FULL mutation (TLC deep-read 1): overlapping chain
|
||||||
// workers on the libuv pool must not interleave clear()/addProcessor().
|
// workers on the libuv pool must not interleave clear()/addProcessor().
|
||||||
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
|
std::lock_guard<std::mutex> chainLock(slopsmith::addon::chainMutationMutex());
|
||||||
@@ -841,6 +886,14 @@ Napi::Value LoadPreset(const Napi::CallbackInfo& info)
|
|||||||
return deferred.Promise();
|
return deferred.Promise();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arm the rebuild barrier BEFORE editor teardown: between closeAll…()
|
||||||
|
// returning and the queued worker acquiring chainMutationMutex, nothing
|
||||||
|
// else stops OpenPluginEditor from opening a fresh editor whose processor
|
||||||
|
// the worker is about to free (#56). The barrier gates editor opens for
|
||||||
|
// the whole teardown+rebuild window; the worker releases it on every
|
||||||
|
// Execute() exit path.
|
||||||
|
slopsmith::addon::beginChainRebuild();
|
||||||
|
|
||||||
// Tear down any open in-process editor windows NOW, on the N-API/main
|
// 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
|
// thread, before the AsyncWorker frees the chain's processors on a libuv
|
||||||
// worker (#56). Doing it here — not inside LoadPresetWorker::Execute — keeps
|
// worker (#56). Doing it here — not inside LoadPresetWorker::Execute — keeps
|
||||||
@@ -848,7 +901,19 @@ Napi::Value LoadPreset(const Napi::CallbackInfo& info)
|
|||||||
// message thread (inline teardown); on Linux/Windows closeAllPluginEditor-
|
// message thread (inline teardown); on Linux/Windows closeAllPluginEditor-
|
||||||
// Windows() posts to the dedicated JUCE message thread and blocks. Either
|
// Windows() posts to the dedicated JUCE message thread and blocks. Either
|
||||||
// way editors are destroyed before Execute() clears the chain.
|
// way editors are destroyed before Execute() clears the chain.
|
||||||
closeAllPluginEditorWindows();
|
if (!closeAllPluginEditorWindows())
|
||||||
|
{
|
||||||
|
// Teardown refused or timed out: an editor may still be alive and
|
||||||
|
// bound to a chain processor. Clearing/rebuilding now would free that
|
||||||
|
// processor under the live editor — the documented UAF. Abort the
|
||||||
|
// load instead of proceeding.
|
||||||
|
slopsmith::addon::endChainRebuild();
|
||||||
|
auto obj = Napi::Object::New(env);
|
||||||
|
obj.Set("success", false);
|
||||||
|
obj.Set("error", "editor teardown did not complete; preset load aborted");
|
||||||
|
deferred.Resolve(obj);
|
||||||
|
return deferred.Promise();
|
||||||
|
}
|
||||||
|
|
||||||
auto json = info[0].As<Napi::String>().Utf8Value();
|
auto json = info[0].As<Napi::String>().Utf8Value();
|
||||||
auto worker = new LoadPresetWorker(env, deferred, json);
|
auto worker = new LoadPresetWorker(env, deferred, json);
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ 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)
|
||||||
|
|
||||||
|
// ── Rebuild barrier (editor-open gate) ────────────────────────────────────
|
||||||
|
// A chain clear/rebuild is a two-step dance: editors are torn down on the
|
||||||
|
// message thread FIRST, then the mutation runs (synchronously for ClearChain,
|
||||||
|
// on a queued AsyncWorker for LoadPreset). Between those steps the mutation
|
||||||
|
// mutex is NOT yet held, so an editor opened in that window would point at a
|
||||||
|
// processor the imminent clear is about to free (#56). Callers bracket the
|
||||||
|
// whole teardown+mutation with begin/end; OpenPluginEditor refuses to open
|
||||||
|
// while any rebuild is pending. Counter (not bool): overlapping LoadPreset +
|
||||||
|
// ClearChain must not un-gate each other early.
|
||||||
|
void beginChainRebuild();
|
||||||
|
void endChainRebuild();
|
||||||
|
bool isChainRebuildPending();
|
||||||
|
|
||||||
// ── Shared load helpers (used by the workers here and SetSlotState) ──────
|
// ── Shared load helpers (used by the workers here and SetSlotState) ──────
|
||||||
// Decode a state blob in EITHER base64 flavour (JUCE-proprietary first,
|
// Decode a state blob in EITHER base64 flavour (JUCE-proprietary first,
|
||||||
// standard RFC-4648 fallback when `allowStandard` — IR/NAM slots only).
|
// standard RFC-4648 fallback when `allowStandard` — IR/NAM slots only).
|
||||||
|
|||||||
@@ -8,12 +8,14 @@
|
|||||||
|
|
||||||
#include "AddonContext.h"
|
#include "AddonContext.h"
|
||||||
#include "NapiHelpers.h"
|
#include "NapiHelpers.h"
|
||||||
|
#include "ChainOps.h"
|
||||||
#include "../Sandbox/SandboxedProcessor.h"
|
#include "../Sandbox/SandboxedProcessor.h"
|
||||||
#include "../Sandbox/CrashAttribution.h"
|
#include "../Sandbox/CrashAttribution.h"
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
namespace slopsmith::addon {
|
namespace slopsmith::addon {
|
||||||
|
|
||||||
@@ -82,13 +84,13 @@ void destroyAllPluginEditorWindowsOnMessageThread()
|
|||||||
// until the editors are gone. Its 50ms dispatch loop drains this promptly,
|
// until the editors are gone. Its 50ms dispatch loop drains this promptly,
|
||||||
// so there is no macOS-style stall here. Report a refused post / wait
|
// so there is no macOS-style stall here. Report a refused post / wait
|
||||||
// timeout so a lingering-editor UAF stays diagnosable.
|
// timeout so a lingering-editor UAF stays diagnosable.
|
||||||
void closeAllPluginEditorWindows()
|
bool closeAllPluginEditorWindows()
|
||||||
{
|
{
|
||||||
auto* mm = juce::MessageManager::getInstanceWithoutCreating();
|
auto* mm = juce::MessageManager::getInstanceWithoutCreating();
|
||||||
if (mm != nullptr && mm->isThisTheMessageThread())
|
if (mm != nullptr && mm->isThisTheMessageThread())
|
||||||
{
|
{
|
||||||
destroyAllPluginEditorWindowsOnMessageThread();
|
destroyAllPluginEditorWindowsOnMessageThread();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto done = std::make_shared<juce::WaitableEvent>();
|
auto done = std::make_shared<juce::WaitableEvent>();
|
||||||
@@ -100,12 +102,19 @@ void closeAllPluginEditorWindows()
|
|||||||
if (!posted)
|
if (!posted)
|
||||||
{
|
{
|
||||||
fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: message queue refused the post; "
|
fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: message queue refused the post; "
|
||||||
"editors may briefly outlive their processors\n");
|
"editors may still be alive\n");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (!done->wait(15000))
|
if (!done->wait(15000))
|
||||||
|
{
|
||||||
|
// The queued teardown hasn't run: editors may still hold pointers into
|
||||||
|
// the chain. Callers must NOT free slot processors on a false return —
|
||||||
|
// proceeding here is exactly the #56 use-after-free, just delayed.
|
||||||
fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: editor teardown did not complete "
|
fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: editor teardown did not complete "
|
||||||
"within 15s; proceeding\n");
|
"within 15s; caller must not free chain processors\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info)
|
Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info)
|
||||||
@@ -117,6 +126,22 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info)
|
|||||||
return Napi::Boolean::New(env, false);
|
return Napi::Boolean::New(env, false);
|
||||||
const int slotId = *slotIdOpt;
|
const int slotId = *slotIdOpt;
|
||||||
|
|
||||||
|
// Rebuild barrier (ChainOps.h): a chain clear/rebuild is between its
|
||||||
|
// editor teardown and the mutation itself — the processor this editor
|
||||||
|
// would bind to is about to be freed (#56). Refuse to open.
|
||||||
|
if (slopsmith::addon::isChainRebuildPending())
|
||||||
|
return Napi::Boolean::New(env, false);
|
||||||
|
|
||||||
|
// Resolve the slot under the chain-mutation mutex: getSlot returns a raw
|
||||||
|
// pointer a concurrent worker's clear()/rebuild would free under us.
|
||||||
|
// try_lock, never a blocking lock — a preset load can hold the mutex for
|
||||||
|
// seconds (VST init) and this is V8's thread; if a mutation is in flight
|
||||||
|
// the slot we'd open is about to be replaced anyway.
|
||||||
|
std::unique_lock<std::mutex> chainLock(
|
||||||
|
slopsmith::addon::chainMutationMutex(), std::try_to_lock);
|
||||||
|
if (!chainLock.owns_lock())
|
||||||
|
return Napi::Boolean::New(env, false);
|
||||||
|
|
||||||
auto slot = liveEngine->getSignalChain().getSlot(slotId);
|
auto slot = liveEngine->getSignalChain().getSlot(slotId);
|
||||||
if (!slot || !slot->processor || !slot->processor->hasEditor())
|
if (!slot || !slot->processor || !slot->processor->hasEditor())
|
||||||
return Napi::Boolean::New(env, false);
|
return Napi::Boolean::New(env, false);
|
||||||
@@ -154,10 +179,22 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info)
|
|||||||
// crash between then and now is possible — re-check here.
|
// crash between then and now is possible — re-check here.
|
||||||
if (!sb->isAlive())
|
if (!sb->isAlive())
|
||||||
return Napi::Boolean::New(env, false);
|
return Napi::Boolean::New(env, false);
|
||||||
|
// Validation is done — release before queueing, so the lambda's own
|
||||||
|
// try_lock on the message thread can't collide with THIS thread still
|
||||||
|
// holding the mutex and drop the open as a false conflict.
|
||||||
|
chainLock.unlock();
|
||||||
const bool queued = juce::MessageManager::callAsync([slotId]()
|
const bool queued = juce::MessageManager::callAsync([slotId]()
|
||||||
{
|
{
|
||||||
auto liveEngine = snapshotEngine();
|
auto liveEngine = snapshotEngine();
|
||||||
if (!liveEngine) return;
|
if (!liveEngine) return;
|
||||||
|
// try_lock, NEVER a blocking lock on the message thread: chain
|
||||||
|
// workers holding the mutex block-wait on this very thread
|
||||||
|
// (loadVstSandboxAware's callAsync+wait) — blocking here would
|
||||||
|
// deadlock. Contention means a mutation is rebuilding the slot;
|
||||||
|
// skip the open.
|
||||||
|
std::unique_lock<std::mutex> chainLock(
|
||||||
|
slopsmith::addon::chainMutationMutex(), std::try_to_lock);
|
||||||
|
if (!chainLock.owns_lock()) return;
|
||||||
if (auto* slot = liveEngine->getSignalChain().getSlot(slotId))
|
if (auto* slot = liveEngine->getSignalChain().getSlot(slotId))
|
||||||
if (auto* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()))
|
if (auto* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()))
|
||||||
sb->requestOpenEditor();
|
sb->requestOpenEditor();
|
||||||
@@ -173,30 +210,46 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info)
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// In-process plugin — host-side PluginEditorWindow flow. If a window
|
// In-process plugin — host-side PluginEditorWindow flow. Everything —
|
||||||
// already exists for this slot, bring it to front rather than creating
|
// including the duplicate-window check — runs on the message thread:
|
||||||
// a duplicate.
|
// editorWindows is a plain std::map owned by that thread, and reading or
|
||||||
|
// erasing it from this (N-API) thread raced the message-thread inserts/
|
||||||
|
// erases.
|
||||||
|
//
|
||||||
|
// Capture slotId only — re-resolve the slot via snapshotEngine() +
|
||||||
|
// getSlot(slotId) inside the lambda so a SignalChain::removeProcessor()
|
||||||
|
// between this call returning and the async firing can't leave us calling
|
||||||
|
// createEditorAndMakeActive() on a dangling juce::AudioProcessor*.
|
||||||
|
//
|
||||||
|
// Validation is done — release before queueing, so the lambda's own
|
||||||
|
// try_lock on the message thread can't collide with THIS thread still
|
||||||
|
// holding the mutex and drop the open as a false conflict.
|
||||||
|
chainLock.unlock();
|
||||||
|
const bool queued = juce::MessageManager::callAsync([slotId]()
|
||||||
|
{
|
||||||
|
// If a window already exists for this slot, bring it to front rather
|
||||||
|
// than creating a duplicate.
|
||||||
auto it = editorWindows.find(slotId);
|
auto it = editorWindows.find(slotId);
|
||||||
if (it != editorWindows.end() && it->second)
|
if (it != editorWindows.end() && it->second)
|
||||||
{
|
{
|
||||||
if (it->second->isVisible())
|
if (it->second->isVisible())
|
||||||
{
|
{
|
||||||
it->second->toFront(true);
|
it->second->toFront(true);
|
||||||
return Napi::Boolean::New(env, true);
|
return;
|
||||||
}
|
}
|
||||||
// Window was hidden/closed, remove stale entry
|
// Window was hidden/closed, remove stale entry
|
||||||
editorWindows.erase(it);
|
editorWindows.erase(it);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create editor on the message thread. Capture slotId only — re-resolve
|
|
||||||
// the slot via snapshotEngine() + getSlot(slotId) inside the lambda so a
|
|
||||||
// SignalChain::removeProcessor() between this call returning and the
|
|
||||||
// async firing can't leave us calling createEditorAndMakeActive() on a
|
|
||||||
// dangling juce::AudioProcessor*. Mirrors the sandbox branch's pattern.
|
|
||||||
const bool queued = juce::MessageManager::callAsync([slotId]()
|
|
||||||
{
|
|
||||||
auto liveEngine = snapshotEngine();
|
auto liveEngine = snapshotEngine();
|
||||||
if (!liveEngine) return;
|
if (!liveEngine) return;
|
||||||
|
// try_lock, NEVER a blocking lock on the message thread: chain
|
||||||
|
// workers holding the mutex block-wait on this very thread
|
||||||
|
// (loadVstSandboxAware's callAsync+wait) — blocking here would
|
||||||
|
// deadlock. Contention means the slot is being rebuilt; skip.
|
||||||
|
std::unique_lock<std::mutex> chainLock(
|
||||||
|
slopsmith::addon::chainMutationMutex(), std::try_to_lock);
|
||||||
|
if (!chainLock.owns_lock()) return;
|
||||||
auto& chain = liveEngine->getSignalChain();
|
auto& chain = liveEngine->getSignalChain();
|
||||||
auto* slot = chain.getSlot(slotId);
|
auto* slot = chain.getSlot(slotId);
|
||||||
if (!slot || !slot->processor) return;
|
if (!slot || !slot->processor) return;
|
||||||
@@ -259,6 +312,11 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info)
|
|||||||
if (chain.replaceProcessor(slotId, std::move(sandboxed)))
|
if (chain.replaceProcessor(slotId, std::move(sandboxed)))
|
||||||
{
|
{
|
||||||
promoted = true;
|
promoted = true;
|
||||||
|
// A promotion swaps the slot's processor: bump the
|
||||||
|
// generation (we hold chainMutationMutex via the
|
||||||
|
// try_lock above) so JS-side chain owners re-sync
|
||||||
|
// instead of driving the replaced slot blind.
|
||||||
|
slopsmith::addon::bumpChainGeneration();
|
||||||
bool editorOpened = false;
|
bool editorOpened = false;
|
||||||
if (auto* slot2 = chain.getSlot(slotId))
|
if (auto* slot2 = chain.getSlot(slotId))
|
||||||
if (auto* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot2->processor.get()))
|
if (auto* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot2->processor.get()))
|
||||||
@@ -296,7 +354,14 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info)
|
|||||||
|
|
||||||
// In-process editor: non-VST3, editor-less, already-sandboxed, or POSIX
|
// In-process editor: non-VST3, editor-less, already-sandboxed, or POSIX
|
||||||
// (where the in-process editor is safe).
|
// (where the in-process editor is safe).
|
||||||
|
//
|
||||||
|
// Re-check the processor: the promotion branch above documents that a
|
||||||
|
// faulted captureVstStateForPromotion() can RELEASE the slot's
|
||||||
|
// processor before returning false — falling through here with a null
|
||||||
|
// processor would crash on createEditorAndMakeActive().
|
||||||
auto* processor = slot->processor.get();
|
auto* processor = slot->processor.get();
|
||||||
|
if (processor == nullptr)
|
||||||
|
return;
|
||||||
auto name = slot->name;
|
auto name = slot->name;
|
||||||
juce::AudioProcessorEditor* editor = nullptr;
|
juce::AudioProcessorEditor* editor = nullptr;
|
||||||
try {
|
try {
|
||||||
@@ -324,53 +389,38 @@ Napi::Value ClosePluginEditor(const Napi::CallbackInfo& info)
|
|||||||
if (!slotIdOpt) return Napi::Boolean::New(env, false);
|
if (!slotIdOpt) return Napi::Boolean::New(env, false);
|
||||||
const int slotId = *slotIdOpt;
|
const int slotId = *slotIdOpt;
|
||||||
|
|
||||||
// Sandboxed plugins: route the close to the sandbox child via IPC.
|
// One queued lambda handles both the sandbox and in-process paths, for
|
||||||
// No host-side PluginEditorWindow exists for these.
|
// two reasons:
|
||||||
//
|
// - editorWindows is message-thread-owned; the old synchronous
|
||||||
// Same shape as the open path: dispatch off the N-API thread and
|
// find() here raced the message-thread inserts/erases.
|
||||||
// re-resolve the slot inside the lambda. requestCloseEditor()
|
// - getSlot() from this (N-API) thread dereferenced a slot a chain
|
||||||
// ultimately writes to the control pipe (writeFrame can block up
|
// worker could free mid-call; the slot is now resolved inside the
|
||||||
// to ~5s on a stalled reader), so running it synchronously here
|
// lambda under a try_lock on the chain-mutation mutex.
|
||||||
// would freeze JS / the renderer UI on a slow sandbox; the
|
// requestCloseEditor() ultimately writes to the control pipe (writeFrame
|
||||||
// re-resolve guards against slot-removal UAF between the napi call
|
// can block up to ~5s on a stalled reader), so dispatching also keeps a
|
||||||
// and the async firing.
|
// slow sandbox from freezing JS / the renderer UI.
|
||||||
//
|
|
||||||
// All desktop platforms: route the close to the sandbox child via IPC
|
|
||||||
// (SandboxedProcessor is compiled everywhere now). In-process plugins fall
|
|
||||||
// through to the host-side editor-window teardown below.
|
|
||||||
#if defined(SLOPSMITH_AUDIO_ADDON)
|
|
||||||
if (auto liveEngine = snapshotEngine())
|
|
||||||
{
|
|
||||||
if (auto* slot = liveEngine->getSignalChain().getSlot(slotId))
|
|
||||||
{
|
|
||||||
if (slot->processor
|
|
||||||
&& dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()))
|
|
||||||
{
|
|
||||||
const bool queued = juce::MessageManager::callAsync([slotId]()
|
const bool queued = juce::MessageManager::callAsync([slotId]()
|
||||||
{
|
{
|
||||||
|
// Host-side window (in-process plugins). Erasing a missing key is a
|
||||||
|
// no-op; sandbox slots never have an entry here.
|
||||||
|
editorWindows.erase(slotId);
|
||||||
|
|
||||||
|
#if defined(SLOPSMITH_AUDIO_ADDON)
|
||||||
auto liveEngine = snapshotEngine();
|
auto liveEngine = snapshotEngine();
|
||||||
if (!liveEngine) return;
|
if (!liveEngine) return;
|
||||||
|
// try_lock, NEVER a blocking lock on the message thread: chain
|
||||||
|
// workers holding the mutex block-wait on this very thread —
|
||||||
|
// blocking here would deadlock. Contention means the chain is being
|
||||||
|
// rebuilt, which tears editors down anyway.
|
||||||
|
std::unique_lock<std::mutex> chainLock(
|
||||||
|
slopsmith::addon::chainMutationMutex(), std::try_to_lock);
|
||||||
|
if (!chainLock.owns_lock()) return;
|
||||||
if (auto* slot = liveEngine->getSignalChain().getSlot(slotId))
|
if (auto* slot = liveEngine->getSignalChain().getSlot(slotId))
|
||||||
if (auto* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()))
|
if (auto* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()))
|
||||||
sb->requestCloseEditor();
|
sb->requestCloseEditor();
|
||||||
|
#endif
|
||||||
});
|
});
|
||||||
return Napi::Boolean::New(env, queued);
|
return Napi::Boolean::New(env, queued);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// In-process plugin — tear down the host-side editor window.
|
|
||||||
auto it = editorWindows.find(slotId);
|
|
||||||
if (it != editorWindows.end())
|
|
||||||
{
|
|
||||||
juce::MessageManager::callAsync([slotId]()
|
|
||||||
{
|
|
||||||
editorWindows.erase(slotId);
|
|
||||||
});
|
|
||||||
return Napi::Boolean::New(env, true);
|
|
||||||
}
|
|
||||||
return Napi::Boolean::New(env, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ void destroyAllPluginEditorWindowsOnMessageThread();
|
|||||||
// caller frees the processors those editors point at. Safe from the Node
|
// caller frees the processors those editors point at. Safe from the Node
|
||||||
// thread (posts to the message thread and blocks, bounded) or the message
|
// thread (posts to the message thread and blocks, bounded) or the message
|
||||||
// thread itself (inline). Clearing an empty map is cheap.
|
// thread itself (inline). Clearing an empty map is cheap.
|
||||||
void closeAllPluginEditorWindows();
|
// Returns false when teardown did NOT complete (post refused or the bounded
|
||||||
|
// wait timed out) — the caller must not free chain processors in that case
|
||||||
|
// (#56 use-after-free).
|
||||||
|
bool closeAllPluginEditorWindows();
|
||||||
|
|
||||||
// N-API bindings (registered by NodeAddon's export table).
|
// N-API bindings (registered by NodeAddon's export table).
|
||||||
Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info);
|
Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info);
|
||||||
|
|||||||
@@ -62,8 +62,12 @@ public:
|
|||||||
{
|
{
|
||||||
if (!busEnabled.load(std::memory_order_acquire)) return false;
|
if (!busEnabled.load(std::memory_order_acquire)) return false;
|
||||||
if (interleavedLR == nullptr || frames <= 0) return false;
|
if (interleavedLR == nullptr || frames <= 0) return false;
|
||||||
if (deviceRate <= 0.0) return false;
|
// Both rates cross the JS/IPC boundary: reject NaN/Inf (a NaN
|
||||||
if (!(sourceRate > 0.0)) sourceRate = deviceRate;
|
// deviceRate passes a plain `<= 0.0` check) and a step that
|
||||||
|
// underflowed to zero (subnormal source rate), either of which would
|
||||||
|
// make the resample loop index garbage or never advance.
|
||||||
|
if (!std::isfinite(deviceRate) || deviceRate <= 0.0) return false;
|
||||||
|
if (!std::isfinite(sourceRate) || sourceRate <= 0.0) sourceRate = deviceRate;
|
||||||
|
|
||||||
uint64_t w = ring.beginWrite();
|
uint64_t w = ring.beginWrite();
|
||||||
|
|
||||||
@@ -73,6 +77,7 @@ public:
|
|||||||
// interpolation is continuous across pushes. Equal rates degenerate
|
// interpolation is continuous across pushes. Equal rates degenerate
|
||||||
// to step == 1.0 (still exact: pos stays integral, frac == 0).
|
// to step == 1.0 (still exact: pos stays integral, frac == 0).
|
||||||
const double step = sourceRate / deviceRate;
|
const double step = sourceRate / deviceRate;
|
||||||
|
if (!std::isfinite(step) || step <= 0.0) return false;
|
||||||
double pos = srcPos;
|
double pos = srcPos;
|
||||||
uint64_t written = 0;
|
uint64_t written = 0;
|
||||||
while (true)
|
while (true)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <limits>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
using slopsmith::RendererBus;
|
using slopsmith::RendererBus;
|
||||||
@@ -165,9 +166,35 @@ static void testFlushOnDisable()
|
|||||||
assert(dl[1] == 7.0f && "post-re-enable audio must be the fresh push");
|
assert(dl[1] == 7.0f && "post-re-enable audio must be the fresh push");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate validation (PR #107 review): non-finite rates cross the JS/IPC
|
||||||
|
// boundary; NaN passes a plain `<= 0` check, and a subnormal source rate can
|
||||||
|
// underflow step to 0 — both must be rejected before the resample loop.
|
||||||
|
// A bad sourceRate falls back to deviceRate (documented behaviour).
|
||||||
|
static void testRejectsUnusableRates()
|
||||||
|
{
|
||||||
|
RendererBus bus;
|
||||||
|
bus.setEnabled(true, 1.0f);
|
||||||
|
const auto chunk = rampChunk(128, 1.0f, 0.0f);
|
||||||
|
const double nan = std::nan("");
|
||||||
|
const double inf = std::numeric_limits<double>::infinity();
|
||||||
|
assert(!bus.push(chunk.data(), 128, 48000.0, nan));
|
||||||
|
assert(!bus.push(chunk.data(), 128, 48000.0, inf));
|
||||||
|
assert(!bus.push(chunk.data(), 128, 48000.0, -48000.0));
|
||||||
|
assert(!bus.push(chunk.data(), 128, 48000.0, 0.0));
|
||||||
|
// step underflow: denormal source over huge device rate → step == 0.
|
||||||
|
assert(!bus.push(chunk.data(), 128, 5e-324, 1e308));
|
||||||
|
assert(bus.metrics().pushedFrames == 0 && "rejected pushes must stage nothing");
|
||||||
|
// NaN/Inf/negative SOURCE rate falls back to deviceRate (step == 1).
|
||||||
|
assert(bus.push(chunk.data(), 128, nan, 48000.0));
|
||||||
|
assert(bus.push(chunk.data(), 128, inf, 48000.0));
|
||||||
|
assert(bus.push(chunk.data(), 128, -1.0, 48000.0));
|
||||||
|
assert(bus.metrics().pushedFrames > 0);
|
||||||
|
}
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
{
|
{
|
||||||
testEqualRateBitExact();
|
testEqualRateBitExact();
|
||||||
|
testRejectsUnusableRates();
|
||||||
testResampleContinuityAcrossPushes();
|
testResampleContinuityAcrossPushes();
|
||||||
testPrimeGate();
|
testPrimeGate();
|
||||||
testUnderflowReprimes();
|
testUnderflowReprimes();
|
||||||
|
|||||||
Reference in New Issue
Block a user