mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-13 20:21:37 +00:00
fix(audio): promote in-process VST3 to sandbox on editor-open (Windows crash) (#54)
Windows-only crash fix: opening an in-process VST3 editor faults via WndProc on the background message thread. OpenPluginEditor now promotes the slot to the out-of-process sandbox (state transferred via get/setStateInformation) via the new SignalChain::replaceProcessor, and opens the editor there. Review hardening (multi-angle + Codex): state capture runs under the audio lock + SEH guard (SignalChain::captureVstStateForPromotion) so it can't race processBlock or fault the app; the transient sandbox pin is undone on promotion failure (isCrashedPlugin/removeCrashedPlugin) so a healthy plugin isn't stranded; replaceProcessor stages type/name/path for correct blocklist attribution; shared prepareForPlayback helper. CI green incl. addon (windows-latest). Editor-open crash repro on a real Windows host still recommended as follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
850d0926c7
commit
27e8f56ad8
+98
-1
@@ -2776,8 +2776,105 @@ static Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info)
|
|||||||
{
|
{
|
||||||
auto liveEngine = snapshotEngine();
|
auto liveEngine = snapshotEngine();
|
||||||
if (!liveEngine) return;
|
if (!liveEngine) return;
|
||||||
auto* slot = liveEngine->getSignalChain().getSlot(slotId);
|
auto& chain = liveEngine->getSignalChain();
|
||||||
|
auto* slot = chain.getSlot(slotId);
|
||||||
if (!slot || !slot->processor) return;
|
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;
|
||||||
|
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).
|
||||||
auto* processor = slot->processor.get();
|
auto* processor = slot->processor.get();
|
||||||
auto name = slot->name;
|
auto name = slot->name;
|
||||||
juce::AudioProcessorEditor* editor = nullptr;
|
juce::AudioProcessorEditor* editor = nullptr;
|
||||||
|
|||||||
@@ -189,6 +189,35 @@ void addCrashedPlugin(const juce::String& pluginPath)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isCrashedPlugin(const juce::String& pluginPath)
|
||||||
|
{
|
||||||
|
if (pluginPath.isEmpty()) return false;
|
||||||
|
const auto canonical = juce::File(pluginPath).getFullPathName();
|
||||||
|
const std::lock_guard<std::mutex> lock(g_crashedPluginsMutex);
|
||||||
|
return g_crashedPlugins.contains(canonical, /*ignoreCase*/ true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeCrashedPlugin(const juce::String& pluginPath)
|
||||||
|
{
|
||||||
|
if (pluginPath.isEmpty()) return;
|
||||||
|
const auto canonical = juce::File(pluginPath).getFullPathName();
|
||||||
|
const std::lock_guard<std::mutex> lock(g_crashedPluginsMutex);
|
||||||
|
// Case-insensitive match, mirroring addCrashedPlugin/shouldSandbox's
|
||||||
|
// contains(..., ignoreCase=true). Iterate backwards to remove safely.
|
||||||
|
bool removed = false;
|
||||||
|
for (int i = g_crashedPlugins.size(); --i >= 0;)
|
||||||
|
{
|
||||||
|
if (g_crashedPlugins[i].equalsIgnoreCase(canonical))
|
||||||
|
{
|
||||||
|
g_crashedPlugins.remove(i);
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed)
|
||||||
|
VST_TRACE("removeCrashedPlugin: %s removed from runtime crash blocklist",
|
||||||
|
canonical.toRawUTF8());
|
||||||
|
}
|
||||||
|
|
||||||
void setCrashedPlugins(const juce::StringArray& pluginPaths)
|
void setCrashedPlugins(const juce::StringArray& pluginPaths)
|
||||||
{
|
{
|
||||||
const std::lock_guard<std::mutex> lock(g_crashedPluginsMutex);
|
const std::lock_guard<std::mutex> lock(g_crashedPluginsMutex);
|
||||||
|
|||||||
@@ -236,6 +236,18 @@ void setCrashedPlugins(const juce::StringArray& pluginPaths);
|
|||||||
// offending plugin to the out-of-process sandbox.
|
// offending plugin to the out-of-process sandbox.
|
||||||
void addCrashedPlugin(const juce::String& pluginPath);
|
void addCrashedPlugin(const juce::String& pluginPath);
|
||||||
|
|
||||||
|
// Query whether a path is currently on the runtime crash blocklist (matched
|
||||||
|
// case-insensitively, same as shouldSandbox). Lets a caller that pins a plugin
|
||||||
|
// only for a transient operation know whether the entry was already present.
|
||||||
|
bool isCrashedPlugin(const juce::String& pluginPath);
|
||||||
|
|
||||||
|
// Remove one plugin path from the runtime crash blocklist (case-insensitive).
|
||||||
|
// Companion to addCrashedPlugin for callers that pin a plugin to force a
|
||||||
|
// sandbox load for a single operation (e.g. promoting an editor) and must undo
|
||||||
|
// the pin if that operation fails — so a plugin that never actually crashed
|
||||||
|
// isn't left permanently forced to the sandbox for the session.
|
||||||
|
void removeCrashedPlugin(const juce::String& pluginPath);
|
||||||
|
|
||||||
// Resolve the path to slopsmith-vst-host.exe (sits next to the audio addon
|
// Resolve the path to slopsmith-vst-host.exe (sits next to the audio addon
|
||||||
// .node). Returns a non-existent File if it can't be located. Exposed so the
|
// .node). Returns a non-existent File if it can't be located. Exposed so the
|
||||||
// out-of-process VST scan path can spawn the same host binary as the sandbox.
|
// out-of-process VST scan path can spawn the same host binary as the sandbox.
|
||||||
|
|||||||
@@ -384,6 +384,15 @@ void SignalChain::queueMidiMessage(int targetSlotId, const juce::MidiMessage& ms
|
|||||||
// If queue full, message silently dropped (acceptable for PC messages)
|
// If queue full, message silently dropped (acceptable for PC messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared prepare sequence for a processor entering the live chain — used by
|
||||||
|
// addProcessor and replaceProcessor so the channel config and prepare ordering
|
||||||
|
// stay identical between them. Call from inside invokePlugin's fault guard.
|
||||||
|
static void prepareForPlayback(juce::AudioProcessor& p, double sampleRate, int blockSize)
|
||||||
|
{
|
||||||
|
p.setPlayConfigDetails(2, 2, sampleRate, blockSize);
|
||||||
|
p.prepareToPlay(sampleRate, blockSize);
|
||||||
|
}
|
||||||
|
|
||||||
int SignalChain::addProcessor(std::unique_ptr<juce::AudioProcessor> processor,
|
int SignalChain::addProcessor(std::unique_ptr<juce::AudioProcessor> processor,
|
||||||
ProcessorSlot::Type type,
|
ProcessorSlot::Type type,
|
||||||
const juce::String& name,
|
const juce::String& name,
|
||||||
@@ -403,8 +412,7 @@ int SignalChain::addProcessor(std::unique_ptr<juce::AudioProcessor> processor,
|
|||||||
// slot is dropped, rather than taking the app down.
|
// slot is dropped, rather than taking the app down.
|
||||||
invokePlugin(*slot, [&](juce::AudioProcessor& p)
|
invokePlugin(*slot, [&](juce::AudioProcessor& p)
|
||||||
{
|
{
|
||||||
p.setPlayConfigDetails(2, 2, currentSampleRate, currentBlockSize);
|
prepareForPlayback(p, currentSampleRate, currentBlockSize);
|
||||||
p.prepareToPlay(currentSampleRate, currentBlockSize);
|
|
||||||
});
|
});
|
||||||
if (! slot->processor) return -1;
|
if (! slot->processor) return -1;
|
||||||
|
|
||||||
@@ -421,6 +429,91 @@ void SignalChain::removeProcessor(int slotId)
|
|||||||
if (idx >= 0) slots.remove(idx);
|
if (idx >= 0) slots.remove(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool SignalChain::replaceProcessor(int slotId, std::unique_ptr<juce::AudioProcessor> processor)
|
||||||
|
{
|
||||||
|
if (!processor) return false;
|
||||||
|
|
||||||
|
// Copy the target slot's identity (type/name/path) onto the staging slot
|
||||||
|
// BEFORE preparing, so if the incoming processor faults during prepareToPlay
|
||||||
|
// invokePlugin's catch blocklists the RIGHT plugin path (addProcessor sets
|
||||||
|
// these before its own prepare for the same reason). Without this the staging
|
||||||
|
// path is empty and the fault is recorded against "".
|
||||||
|
ProcessorSlot staging;
|
||||||
|
{
|
||||||
|
const juce::ScopedLock sl(lock);
|
||||||
|
const int idx = findSlotIndex(slotId);
|
||||||
|
if (idx < 0) return false; // nothing to replace
|
||||||
|
staging.type = slots[idx]->type;
|
||||||
|
staging.name = slots[idx]->name;
|
||||||
|
staging.path = slots[idx]->path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the incoming processor before it goes live, exactly as addProcessor
|
||||||
|
// does — under invokePlugin's SEH/signal guard so a fault in prepareToPlay is
|
||||||
|
// contained (the processor is dropped) rather than taking the app down.
|
||||||
|
staging.processor = std::move(processor);
|
||||||
|
invokePlugin(staging, [&](juce::AudioProcessor& p)
|
||||||
|
{
|
||||||
|
prepareForPlayback(p, currentSampleRate, currentBlockSize);
|
||||||
|
});
|
||||||
|
if (! staging.processor) return false; // faulted during prepare → leave the slot as-is
|
||||||
|
|
||||||
|
std::unique_ptr<juce::AudioProcessor> old;
|
||||||
|
{
|
||||||
|
const juce::ScopedLock sl(lock);
|
||||||
|
const int idx = findSlotIndex(slotId);
|
||||||
|
if (idx < 0) return false; // slot was removed underneath us
|
||||||
|
auto* slot = slots[idx];
|
||||||
|
old = std::move(slot->processor);
|
||||||
|
slot->processor = std::move(staging.processor);
|
||||||
|
}
|
||||||
|
// Tear the old processor down OUTSIDE the audio lock: releaseResources() (and
|
||||||
|
// a VST3 destructor) can block, and must never stall process() on it.
|
||||||
|
if (old)
|
||||||
|
{
|
||||||
|
old->releaseResources();
|
||||||
|
old.reset();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SignalChain::captureVstStateForPromotion(int slotId, juce::MemoryBlock& state)
|
||||||
|
{
|
||||||
|
// Hold the audio lock across the whole check+snapshot: hasEditor() and
|
||||||
|
// getStateInformation() are plugin calls on a LIVE processor, and process()
|
||||||
|
// runs processBlock on that same instance under this lock (ScopedTryLock, so
|
||||||
|
// it simply drops a block here rather than deadlocking). Doing the snapshot
|
||||||
|
// off-lock would be a data race with the audio thread.
|
||||||
|
const juce::ScopedLock sl(lock);
|
||||||
|
const int idx = findSlotIndex(slotId);
|
||||||
|
if (idx < 0) return false;
|
||||||
|
auto* slot = slots[idx];
|
||||||
|
if (! slot->processor) return false;
|
||||||
|
if (slot->type != ProcessorSlot::Type::VST) return false;
|
||||||
|
// Already out-of-process — nothing to promote (its editor path is safe).
|
||||||
|
if (dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()) != nullptr)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Run the plugin calls under invokePlugin's SEH/signal guard: a plugin that
|
||||||
|
// faults in hasEditor()/getStateInformation() is contained + blocklisted +
|
||||||
|
// released (leaving slot->processor null), never fatal.
|
||||||
|
bool promotable = false;
|
||||||
|
invokePlugin(*slot, [&](juce::AudioProcessor& p)
|
||||||
|
{
|
||||||
|
if (! p.hasEditor()) return; // editor-less VST3 → nothing to open
|
||||||
|
p.getStateInformation(state);
|
||||||
|
promotable = true;
|
||||||
|
});
|
||||||
|
// slot->processor is null iff the guarded call faulted (invokePlugin released
|
||||||
|
// it). Don't promote from a released slot; the empty `state` is discarded.
|
||||||
|
if (! promotable || slot->processor == nullptr)
|
||||||
|
{
|
||||||
|
state.reset();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void SignalChain::moveProcessor(int fromIndex, int toIndex)
|
void SignalChain::moveProcessor(int fromIndex, int toIndex)
|
||||||
{
|
{
|
||||||
const juce::ScopedLock sl(lock);
|
const juce::ScopedLock sl(lock);
|
||||||
|
|||||||
@@ -52,6 +52,27 @@ public:
|
|||||||
const juce::String& name,
|
const juce::String& name,
|
||||||
const juce::String& path);
|
const juce::String& path);
|
||||||
void removeProcessor(int slotId);
|
void removeProcessor(int slotId);
|
||||||
|
// Swap the processor in an existing slot IN PLACE — same slotId, position,
|
||||||
|
// name, path, type and routing are preserved; only the underlying processor
|
||||||
|
// changes. Used to promote an in-process VST3 to the out-of-process sandbox
|
||||||
|
// when its editor is opened (an in-process editor is the Windows WndProc/Qt
|
||||||
|
// crash path). Prepares the incoming processor under the SEH guard, then
|
||||||
|
// swaps under the audio lock; the old processor is torn down off the lock.
|
||||||
|
// Returns false if the slot is gone or the incoming processor faulted in
|
||||||
|
// prepareToPlay (in which case the existing processor is left untouched).
|
||||||
|
bool replaceProcessor(int slotId, std::unique_ptr<juce::AudioProcessor> processor);
|
||||||
|
// Snapshot a slot's state for sandbox promotion, SAFELY. Runs hasEditor()
|
||||||
|
// and getStateInformation() under the audio lock (so they can't race
|
||||||
|
// process()'s processBlock on the same instance) and under the SEH/signal
|
||||||
|
// guard (so a plugin that faults during the snapshot is contained +
|
||||||
|
// blocklisted, not fatal — an unguarded getStateInformation on the very
|
||||||
|
// plugins this promotion targets would reintroduce the editor crash on the
|
||||||
|
// message thread). Returns true and fills `state` only for a non-sandboxed
|
||||||
|
// in-process VST3 slot that actually has an editor; returns false (leaving
|
||||||
|
// `state` empty) for a missing/non-VST/editor-less/already-sandboxed slot or
|
||||||
|
// if the guarded calls faulted (processor released). A false result means
|
||||||
|
// "not promotable" — the caller may safely open the editor in-process.
|
||||||
|
bool captureVstStateForPromotion(int slotId, juce::MemoryBlock& state);
|
||||||
void moveProcessor(int fromIndex, int toIndex);
|
void moveProcessor(int fromIndex, int toIndex);
|
||||||
void setBypass(int slotId, bool bypassed);
|
void setBypass(int slotId, bool bypassed);
|
||||||
void setMultiBypass(const juce::Array<std::pair<int, bool>>& changes);
|
void setMultiBypass(const juce::Array<std::pair<int, bool>>& changes);
|
||||||
@@ -66,6 +87,10 @@ public:
|
|||||||
int getNumSlots() const;
|
int getNumSlots() const;
|
||||||
const ProcessorSlot* getSlot(int slotId) const;
|
const ProcessorSlot* getSlot(int slotId) const;
|
||||||
juce::Array<const ProcessorSlot*> getAllSlots() const;
|
juce::Array<const ProcessorSlot*> getAllSlots() const;
|
||||||
|
// Current prepared playback format — used to prepare a processor that is
|
||||||
|
// swapped in mid-session (replaceProcessor) at the same rate as the chain.
|
||||||
|
double getCurrentSampleRate() const { return currentSampleRate; }
|
||||||
|
int getCurrentBlockSize() const { return currentBlockSize; }
|
||||||
|
|
||||||
// Parameters for a specific slot
|
// Parameters for a specific slot
|
||||||
struct ParamInfo
|
struct ParamInfo
|
||||||
|
|||||||
Reference in New Issue
Block a user