feedBack-desktop/src/audio/addon/EditorWindows.cpp
OmikronApex 1359c7c24d fix(audio): P0 lifecycle executor — serialized ops, generation guard, module pinning
Three identical field crash dumps (2026-07-17, CFG fail-fast 0xc0000409
param 0xa = GUARD_ICALL_CHECK_FAILURE on the JUCE message thread, plugin
WndProc frame on the stack) traced to the dispatch timeout desync: every
lifecycle call site could give up after 15 s while its op was still queued,
leaving two owners mutating engine/plugin lifecycle state with no ordering
authority.

- New LifecycleExecutor (guide §12 P0): named lifecycle ops, strictly FIFO
  on the message thread, engine-generation stamped at submit and no-oped
  when stale. The wait is unbounded with a 15 s watchdog that reports and
  keeps waiting; false now always means "verifiably did not run" — the
  "false but it may still run later" state is gone.
- initialize/doShutdown bump the generation and run as executor ops;
  shutdown is the one bounded caller (60 s, then leak the pump — beats
  hanging process exit behind a wedged driver call).
- runDeviceLifecycleOp and closeAllPluginEditorWindows route through the
  executor; the editor-teardown 15 s give-up (delayed #56 UAF) is removed.
- pinPluginModuleForever: plugin modules are pinned on load so JUCE's
  refcount can never unload them mid-session — a queued window/timer
  message into an unloaded module is the CFG fail-fast in the dumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:20:46 +02:00

415 lines
20 KiB
C++

// EditorWindows implementation — moved verbatim from NodeAddon.cpp (TLC plan
// phase 7 / §3.4). Only edits: statics live in this namespace now, and the
// two bindings validate their slot-id argument through NapiHelpers (the same
// deep-read 2 fix the other bindings got in phase 6 — a NaN slot id used to
// coerce to slot 0 and open/close the wrong editor).
#include "EditorWindows.h"
#include "AddonContext.h"
#include "NapiHelpers.h"
#include "ChainOps.h"
#include "../Sandbox/SandboxedProcessor.h"
#include "../Sandbox/CrashAttribution.h"
#include <cstdio>
#include <map>
#include <memory>
#include <mutex>
namespace slopsmith::addon {
class PluginEditorWindow;
static std::map<int, std::unique_ptr<PluginEditorWindow>> editorWindows;
class PluginEditorWindow : public juce::DocumentWindow
{
public:
PluginEditorWindow(juce::AudioProcessorEditor* ed, const juce::String& title)
: DocumentWindow(title, juce::Colours::darkgrey, DocumentWindow::closeButton)
{
setContentOwned(ed, true);
setResizable(true, false);
setUsingNativeTitleBar(true);
centreWithSize(ed->getWidth(), ed->getHeight());
setVisible(true);
toFront(true);
}
void closeButtonPressed() override
{
// Remove from map so editor can be reopened
for (auto it = editorWindows.begin(); it != editorWindows.end(); ++it)
{
if (it->second.get() == this)
{
auto slotId = it->first;
juce::MessageManager::callAsync([slotId]() {
editorWindows.erase(slotId);
});
break;
}
}
setVisible(false);
}
};
// Inline teardown: destroys every editor window. Caller MUST already be on the
// message thread (editorWindows holds JUCE GUI objects). Forward-declared near
// Init for doShutdown's use.
void destroyAllPluginEditorWindowsOnMessageThread()
{
// Fails fast in assertion-enabled builds if a caller violates the
// precondition. Compiled out here under -DJUCE_DISABLE_ASSERTIONS, so it is
// documentation + a debug-build tripwire, never runtime cost.
JUCE_ASSERT_MESSAGE_THREAD
editorWindows.clear();
}
// See the forward declaration above ClearChain for why this exists. Tears down
// the in-process editor windows so they are destroyed before the caller frees
// the processors those editors point at. Clearing an empty map is cheap, so
// calling this on every teardown is fine even when no editor is open.
//
// IMPORTANT: every caller runs on a MAIN-thread / message-thread context —
// ClearChain and LoadPreset are N-API calls on the Node thread, doShutdown uses
// the inline variant directly. This is NOT called from a libuv worker (that is
// why LoadPreset closes editors before queuing LoadPresetWorker, rather than
// letting the worker do it). Given that:
// - Already on the message thread (doShutdown; ClearChain / LoadPreset on
// macOS, where Node's main thread IS the JUCE message thread) → tear down
// inline; posting-and-waiting on ourselves would deadlock.
// - Otherwise (ClearChain / LoadPreset on Linux/Windows, where the JUCE
// message thread is a dedicated std::thread) → post to that thread and block
// 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
// timeout so a lingering-editor UAF stays diagnosable.
bool closeAllPluginEditorWindows()
{
// P0 (guide §12): serialized behind every other lifecycle op with an
// unbounded wait — the old 15 s give-up let a caller proceed to free
// slot processors while this teardown was still queued (#56, delayed).
// A false return now means the teardown verifiably did NOT run (pump
// gone / generation moved) — callers still must not free processors on
// false, but there is no longer a "maybe it runs later" state.
// Note: inline execution when already on the message thread is handled
// inside runLifecycleOp.
const bool toreDown = runLifecycleOpOk("editor-teardown", []()
{
destroyAllPluginEditorWindowsOnMessageThread();
});
if (!toreDown)
fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: teardown did "
"not run; caller must not free chain processors\n");
return toreDown;
}
Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto liveEngine = snapshotEngine();
const auto slotIdOpt = argSlotId(info, 0);
if (!liveEngine || !slotIdOpt)
return Napi::Boolean::New(env, false);
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);
if (!slot || !slot->processor || !slot->processor->hasEditor())
return Napi::Boolean::New(env, false);
// Sandboxed plugins: the editor is a top-level window owned by the
// sandbox child process. No host-side PluginEditorWindow and no
// cross-process SetParent reparent — that path produced a blank
// rendered surface for D3D / OpenGL plugins (Neural DSP Archetypes,
// etc.) because their render context lives in the child. The child's
// kOpenEditor handler brings the existing window to front on a repeat
// click, so re-entry is cheap and we don't track host-side state.
//
// Dispatch off the N-API call thread: requestOpenEditor() uses a
// blocking control->request (kDefaultReplyTimeoutMs = 10s), which on
// a slow or hung sandbox would otherwise stall V8's JS thread for
// the full timeout. Capture slotId rather than a raw processor
// pointer and re-resolve inside the message-thread lambda — that
// closes a UAF window where the slot could be removed (or the engine
// torn down) between this call returning and the async firing.
// Return optimistically; matches the in-process path below.
//
// SandboxedProcessor is compiled on all desktop platforms now (the POSIX
// sandbox runtime is active — see src/audio/CMakeLists.txt), so the
// editor-open IPC routes to the sandbox child on macOS/Linux too. The
// child owns a floating editor window (Reaper-style); the host only tracks
// the open/closed bit.
#if defined(SLOPSMITH_AUDIO_ADDON)
if (auto* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()))
{
// Synchronous gate: if the sandbox child is already gone (crashed
// or shut down) there's no point scheduling the IPC. Return false
// so the renderer can surface "editor unavailable" rather than
// toggling its UI into a fake-open state that no event will ever
// contradict. hasEditor() above already gated on isAlive() but a
// crash between then and now is possible — re-check here.
if (!sb->isAlive())
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]()
{
auto liveEngine = snapshotEngine();
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* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()))
sb->requestOpenEditor();
});
if (!queued)
{
// Message queue refused the post — typically only during
// shutdown. Surface the failure so the renderer doesn't
// toggle its UI into a fake-open state.
return Napi::Boolean::New(env, false);
}
return Napi::Boolean::New(env, true);
}
#endif
// In-process plugin — host-side PluginEditorWindow flow. Everything —
// including the duplicate-window check — runs on the message thread:
// 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);
if (it != editorWindows.end() && it->second)
{
if (it->second->isVisible())
{
it->second->toFront(true);
return;
}
// Window was hidden/closed, remove stale entry
editorWindows.erase(it);
}
auto liveEngine = snapshotEngine();
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* slot = chain.getSlot(slotId);
if (!slot || !slot->processor) return;
// ── Windows editor-crash class fix ───────────────────────────────────
// An in-process VST3 editor is created on JUCE's BACKGROUND message
// thread (V8 owns the OS main thread inside a Node addon). On Windows a
// Qt-using / window-on-init plugin then faults via USER32->WndProc on
// WM_ACTIVATEAPP with NO host frame on the stack, so the SignalChain SEH
// guard can't catch it and the whole app dies (0xC0000005 / 0xC0000409).
// Fix: never open a VST3 editor in-process on Windows — promote the slot
// to the out-of-process sandbox (which hosts the editor on a real
// top-level message thread, the environment the plugin needs) and open
// it there. Compiled on every platform so the swap path keeps building;
// gated to Windows at runtime since the in-process editor is fine on
// macOS/Linux (no WndProc) and the sandbox hop is pure overhead there.
static constexpr bool kPromoteEditorToSandbox =
#if JUCE_WINDOWS
true;
#else
false;
#endif
if (kPromoteEditorToSandbox)
{
// Decide + snapshot state SAFELY. captureVstStateForPromotion runs
// hasEditor()/getStateInformation() under the audio lock and the SEH
// guard (see its contract), so they neither race process()'s
// processBlock nor fault the app — an UNguarded getStateInformation on
// the very plugins this promotion targets would reintroduce the editor
// crash on the message thread. It returns true only for a non-sandboxed
// in-process VST3 that actually has an editor.
juce::MemoryBlock state;
if (chain.captureVstStateForPromotion(slotId, state))
{
const juce::String path = slot->path; // immutable; message-thread only
fprintf(stderr, "[AudioEngine] editor-open: promoting in-process VST3 to sandbox: slot %d '%s'\n",
slotId, path.toRawUTF8());
juce::PluginDescription desc;
desc.fileOrIdentifier = path;
desc.name = juce::File(path).getFileNameWithoutExtension();
// tryLoadSandboxed only accepts a plugin that shouldSandbox()
// approves, so pin this path to the runtime sandbox list first.
// Remember whether it was ALREADY pinned: if the promotion fails
// we undo only OUR pin below, so a healthy, never-crashed plugin
// isn't left permanently forced to a sandbox that just proved
// unavailable (while a pre-existing/real blocklist entry stays).
const bool wasAlreadyPinned = slopsmith::sandbox::isCrashedPlugin(path);
slopsmith::sandbox::addCrashedPlugin(path);
bool promoted = false;
juce::String err;
auto sandboxed = slopsmith::sandbox::tryLoadSandboxed(
desc, chain.getCurrentSampleRate(), chain.getCurrentBlockSize(), err);
if (sandboxed)
{
if (state.getSize() > 0)
sandboxed->setStateInformation(state.getData(), (int) state.getSize());
if (chain.replaceProcessor(slotId, std::move(sandboxed)))
{
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;
if (auto* slot2 = chain.getSlot(slotId))
if (auto* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot2->processor.get()))
editorOpened = sb->requestOpenEditor();
fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion OK for slot %d (editor %s)\n",
slotId, editorOpened ? "opened" : "FAILED to open");
}
else
{
fprintf(stderr, "[AudioEngine] editor-open: replaceProcessor failed for slot %d\n", slotId);
}
}
else
{
fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion failed for '%s': %s\n",
path.toRawUTF8(), err.toRawUTF8());
}
// Undo our transient pin on failure so a plugin that never crashed
// isn't stranded on the (evidently unavailable) sandbox route.
if (! promoted && ! wasAlreadyPinned)
slopsmith::sandbox::removeCrashedPlugin(path);
// Promoted or not, never fall through to the in-process editor on
// Windows — that is the WndProc/Qt crash path this branch exists
// to avoid.
return;
}
// Not promotable (non-VST / editor-less / already-sandboxed, or the
// guarded capture faulted and released the processor). Fall through to
// the in-process branch below, which is safe for all of those cases
// (an already-sandboxed slot opens its editor out-of-process; an
// editor-less or released processor simply opens no window).
}
// In-process editor: non-VST3, editor-less, already-sandboxed, or POSIX
// (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();
if (processor == nullptr)
return;
auto name = slot->name;
juce::AudioProcessorEditor* editor = nullptr;
try {
editor = processor->createEditorAndMakeActive();
} catch (const std::exception& e) {
fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': %s\n", name.toRawUTF8(), e.what());
} catch (...) {
fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': unknown error\n", name.toRawUTF8());
}
if (editor)
{
editorWindows[slotId] = std::make_unique<PluginEditorWindow>(editor, name);
fprintf(stderr, "[AudioEngine] Opened editor for slot %d: %s (%dx%d)\n",
slotId, name.toRawUTF8(), editor->getWidth(), editor->getHeight());
}
});
return Napi::Boolean::New(env, queued);
}
Napi::Value ClosePluginEditor(const Napi::CallbackInfo& info)
{
auto env = info.Env();
const auto slotIdOpt = argSlotId(info, 0);
if (!slotIdOpt) return Napi::Boolean::New(env, false);
const int slotId = *slotIdOpt;
// One queued lambda handles both the sandbox and in-process paths, for
// two reasons:
// - editorWindows is message-thread-owned; the old synchronous
// find() here raced the message-thread inserts/erases.
// - getSlot() from this (N-API) thread dereferenced a slot a chain
// worker could free mid-call; the slot is now resolved inside the
// lambda under a try_lock on the chain-mutation mutex.
// requestCloseEditor() ultimately writes to the control pipe (writeFrame
// can block up to ~5s on a stalled reader), so dispatching also keeps a
// slow sandbox from freezing JS / the renderer UI.
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();
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* sb = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()))
sb->requestCloseEditor();
#endif
});
return Napi::Boolean::New(env, queued);
}
} // namespace slopsmith::addon