feat(sandbox): native last-chance crash attribution for in-process VST3 faults (#36)

* feat(sandbox): native last-chance crash attribution for in-process VST3 faults (#35)

The vst-crash-guard sentinel only covers the windows it arms around an
in-process load or editor-open. A plugin that creates a top-level window keeps
it for its whole loaded lifetime, and the OS can dispatch to its WndProc at any
time (e.g. WM_ACTIVATEAPP on an alt-tab). A fault there arrives via USER32→
WndProc with no host frame on the stack — outside every armed sentinel window
and uncatchable by the SignalChain guard — so it's never attributed and the app
crash-loops (diagnosed from dmp a06f48e1 / McRocklin Suite; see #35).

Add a process-wide last-chance attributor (Windows): a SetUnhandledException
filter, chained to the previously installed filter (Crashpad), that on a fatal
fault whose faulting instruction lies inside a loaded .vst3 module stamps the
existing crash sentinel with { plugin, op: "native-crash" } and then defers to
the prior filter so the dump is still produced and the process dies normally.
initVstCrashGuard() already promotes a leftover sentinel into the persistent
blocklist, so the next launch routes the offender to the out-of-process sandbox.
This makes the dead-man's-pedal cover ANY fatal in-process VST3 fault, not just
the armed load/editor windows — generalizing beyond the per-vendor pre-seed.

- src/audio/Sandbox/CrashAttribution.{h,cpp}: install/uninstall + the filter.
  SetUnhandledExceptionFilter (last-chance only) avoids first-chance false
  positives and per-exception I/O; the write is allocation-free (stack buffers +
  raw Win32). No-op on non-Windows (POSIX SignalChain guard covers the armed
  path; sandbox is Windows-only today).
- NodeAddon: setVstCrashSentinelPath(path) binding arms it; uninstall on
  shutdown (the addon/filter code may be unloaded).
- vst-crash-guard.ts: export getSentinelPath(); audio-bridge wires it after
  initVstCrashGuard().

Addon builds clean; tsc --noEmit clean; sandbox tests + e2e unaffected. The
Windows filter path needs hands-on validation (confirm the sentinel is written
and Crashpad still dumps under the target Electron/Crashpad version).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: fix bundle-path attribution + one-shot gate in crash attributor

Local review of #36 found two correctness bugs:

- Inert on bundle VST3s: GetModuleFileNameW returns the INNER DLL of a Windows
  VST3 bundle (Foo.vst3\Contents\x86_64-win\Foo.vst3), but the blocklist keys on
  the bundle dir (desc.fileOrIdentifier = …\Foo.vst3). The two never matched, so
  a native-written sentinel never routed the offender to the sandbox — defeating
  the fix for bundle plugins. Add truncateToVst3Bundle(): resolve the module
  path to its enclosing .vst3 component in place before writing (single-file
  .vst3 is unchanged). Replaces endsWithVst3IgnoreCase.

- One-shot latch burned by the wrong exception: the g_writing.exchange gate
  wrapped the whole filter evaluation, so the FIRST unhandled exception to reach
  the filter — even a non-VST3 or concurrent benign one — permanently disabled
  attribution for the real plugin fault. Move the latch to gate only the write,
  after a CONFIRMED .vst3 fatal fault; it still serialises concurrent plugin
  faults and guards write re-entrancy.

Also: stop zeroing g_sentinelPathW in uninstall (the g_installed acquire-gate
already disarms the write path; zeroing was the only non-atomic mutation that
could race a faulting thread during teardown), and note the address-based
attribution is a heuristic.

Addon builds clean; tsc clean. Windows filter path still needs hands-on
validation (sentinel written for a bundle + single-file VST3; Crashpad still
dumps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-24 11:03:06 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7f8975641e
commit cc0aceb365
6 changed files with 262 additions and 1 deletions
+1
View File
@@ -32,6 +32,7 @@ set(AUDIO_SOURCES
# nullptr and the caller falls back to in-process loading.
set(SANDBOX_SOURCES
Sandbox/SandboxedProcessor.cpp
Sandbox/CrashAttribution.cpp
Sandbox/ControlChannel_shared.cpp
Sandbox/AudioChannel_shared.cpp
Sandbox/SandboxFactory_shared.cpp
+21
View File
@@ -26,6 +26,7 @@ static void cancelAllPendingLoads();
#include "NAMProcessor.h"
#include "IRLoader.h"
#include "Sandbox/SandboxedProcessor.h"
#include "Sandbox/CrashAttribution.h"
#include <juce_events/juce_events.h>
@@ -256,6 +257,10 @@ static void doShutdown()
}
stopJuceMessageThread();
// Restore the previous top-level exception filter — the addon (and thus our
// unhandledFilter's code) may be unloaded, so it must not stay installed.
slopsmith::sandbox::uninstallVstCrashAttribution();
}
static Napi::Value Shutdown(const Napi::CallbackInfo& info)
@@ -1812,6 +1817,21 @@ static Napi::Value SetCrashedPlugins(const Napi::CallbackInfo& info)
return env.Undefined();
}
// Arm the native last-chance crash attributor with the path to the crash-guard
// sentinel file (src/main/vst-crash-guard.ts owns it). A fatal in-process fault
// inside a loaded .vst3 then stamps the sentinel before the process dies, so the
// next launch sandboxes the offender — covering crashes that arrive outside the
// JS load/editor sentinel windows (e.g. a plugin WndProc on WM_ACTIVATEAPP).
// No-op on non-Windows. See issue #35.
static Napi::Value SetVstCrashSentinelPath(const Napi::CallbackInfo& info)
{
auto env = info.Env();
if (info.Length() > 0 && info[0].IsString())
slopsmith::sandbox::installVstCrashAttribution(
juce::String(info[0].As<Napi::String>().Utf8Value()));
return env.Undefined();
}
// ── Signal Chain Management ──────────────────────────────────────────────────
// Pending in-process loads: each LoadVSTWorker / LoadPresetWorker that's
@@ -3155,6 +3175,7 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports)
exports.Set("savePluginList", Napi::Function::New(env, SavePluginList));
exports.Set("loadPluginList", Napi::Function::New(env, LoadPluginList));
exports.Set("setCrashedPlugins", Napi::Function::New(env, SetCrashedPlugins));
exports.Set("setVstCrashSentinelPath", Napi::Function::New(env, SetVstCrashSentinelPath));
// Signal chain
exports.Set("loadVST", Napi::Function::New(env, LoadVST));
+188
View File
@@ -0,0 +1,188 @@
#include "CrashAttribution.h"
#include "../VSTTrace.h"
#if JUCE_WINDOWS
#include <windows.h>
#include <atomic>
namespace slopsmith::sandbox {
namespace {
// STATUS_STACK_BUFFER_OVERRUN (__fastfail / __security_check_cookie) isn't named
// in <windows.h>; it's the code raised by /GS and many fatal aborts.
constexpr DWORD kStatusStackBufferOverrun = 0xC0000409u;
// Sentinel path captured at install, as a fixed UTF-16 buffer so the filter
// touches no heap while the process is faulting.
wchar_t g_sentinelPathW[1024] = { 0 };
LPTOP_LEVEL_EXCEPTION_FILTER g_prevFilter = nullptr;
std::atomic<bool> g_installed{ false };
// Re-entrancy guard: if the sentinel write itself faulted we must not recurse.
std::atomic<bool> g_writing{ false };
// Resolve a loaded-module path to its enclosing `.vst3` BUNDLE path, IN PLACE,
// so it matches the blocklist key (shouldSandbox/setCrashedPlugins key on
// desc.fileOrIdentifier = the bundle directory). A Windows VST3 bundle is loaded
// via its inner DLL (`Foo.vst3\Contents\x86_64-win\Foo.vst3`), so
// GetModuleFileNameW returns that inner path; truncate at the first `.vst3`
// path-component boundary to recover `…\Foo.vst3`. A single-file `.vst3` already
// ends there and is left unchanged. Returns false (→ not a VST3 → skip) when no
// `.vst3` component is present. Case-insensitive (ASCII), allocation-free.
bool truncateToVst3Bundle(wchar_t* p) noexcept
{
static const wchar_t ext[] = L".vst3";
for (size_t i = 0; p[i] != L'\0'; ++i)
{
size_t k = 0;
for (; k < 5; ++k)
{
wchar_t a = p[i + k];
if (a >= L'A' && a <= L'Z') a = static_cast<wchar_t>(a + 32);
if (a != ext[k]) break;
}
if (k == 5)
{
const wchar_t after = p[i + 5];
if (after == L'\0' || after == L'\\' || after == L'/')
{
p[i + 5] = L'\0'; // keep "…\Foo.vst3", drop any \Contents\… tail
return true;
}
}
}
return false;
}
// Allocation-free write of {"plugin":"<json-escaped>","op":"native-crash"} —
// the exact shape src/main/vst-crash-guard.ts reads from the sentinel. Called
// from the unhandled-exception filter on the faulting thread, so it uses only
// stack buffers + raw Win32 file I/O.
void writeSentinel(const wchar_t* modulePathW) noexcept
{
char utf8[1024];
const int n = WideCharToMultiByte(CP_UTF8, 0, modulePathW, -1,
utf8, static_cast<int>(sizeof(utf8)) - 1,
nullptr, nullptr);
if (n <= 0) return;
char json[1400];
size_t j = 0;
const auto put = [&](const char* s) {
while (*s && j < sizeof(json) - 1) json[j++] = *s++;
};
put("{\"plugin\":\"");
for (int i = 0; utf8[i] != '\0' && j < sizeof(json) - 24; ++i)
{
const char c = utf8[i];
if (c == '\\' || c == '"') { json[j++] = '\\'; json[j++] = c; }
else if (c == '\r' || c == '\n' || c == '\t') { /* drop control chars */ }
else json[j++] = c;
}
put("\",\"op\":\"native-crash\"}");
const HANDLE h = CreateFileW(g_sentinelPathW, GENERIC_WRITE, FILE_SHARE_READ,
nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL,
nullptr);
if (h == INVALID_HANDLE_VALUE) return;
DWORD written = 0;
WriteFile(h, json, static_cast<DWORD>(j), &written, nullptr);
FlushFileBuffers(h);
CloseHandle(h);
}
LONG WINAPI unhandledFilter(EXCEPTION_POINTERS* info) noexcept
{
// SetUnhandledExceptionFilter only fires for genuinely UNHANDLED exceptions
// (last chance), so a plugin that first-chance-faults-and-handles never
// reaches here — no false attribution and no per-exception I/O.
if (g_installed.load(std::memory_order_acquire)
&& g_sentinelPathW[0] != L'\0'
&& info != nullptr && info->ExceptionRecord != nullptr)
{
const DWORD code = info->ExceptionRecord->ExceptionCode;
const bool fatalFault =
code == EXCEPTION_ACCESS_VIOLATION
|| code == EXCEPTION_ILLEGAL_INSTRUCTION
|| code == EXCEPTION_PRIV_INSTRUCTION
|| code == EXCEPTION_IN_PAGE_ERROR
|| code == kStatusStackBufferOverrun;
if (fatalFault)
{
// Map the faulting instruction to its owning module. If that module
// is a loaded .vst3, the fault is (heuristically — by faulting
// address, with no host-frame corroboration) the plugin's, so
// record it. A corrupted control transfer INTO plugin code can
// mis-attribute; the cost is a good plugin forced to the sandbox,
// never a crash, so the heuristic is acceptable here.
HMODULE mod = nullptr;
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS
| GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(
info->ExceptionRecord->ExceptionAddress),
&mod)
&& mod != nullptr)
{
wchar_t pathW[1024];
const DWORD cap = static_cast<DWORD>(sizeof(pathW) / sizeof(pathW[0]));
const DWORD len = GetModuleFileNameW(mod, pathW, cap);
// Gate the one-shot on a CONFIRMED VST3 fault, not on merely
// reaching the filter: a non-VST3 unhandled exception (or a
// concurrent benign one) must not burn the latch and disable
// attribution for the real plugin fault. The exchange also
// serialises two threads faulting in plugins at once + guards
// against a fault inside writeSentinel re-entering.
if (len != 0 && len < cap && truncateToVst3Bundle(pathW)
&& !g_writing.exchange(true, std::memory_order_acq_rel))
writeSentinel(pathW);
}
}
}
// Never handle — defer to the previously installed top-level filter
// (Crashpad) so the dump is still produced and the process terminates as it
// otherwise would.
return g_prevFilter != nullptr ? g_prevFilter(info)
: EXCEPTION_CONTINUE_SEARCH;
}
} // namespace
void installVstCrashAttribution(const juce::String& sentinelPath)
{
if (sentinelPath.isEmpty()) return;
// Copy the path into the fixed buffer (manual, to avoid CRT-secure deps).
const wchar_t* wide = sentinelPath.toWideCharPointer();
const size_t cap = sizeof(g_sentinelPathW) / sizeof(g_sentinelPathW[0]) - 1;
size_t i = 0;
for (; wide[i] != L'\0' && i < cap; ++i) g_sentinelPathW[i] = wide[i];
g_sentinelPathW[i] = L'\0';
// Install once, chaining to whatever filter is already in place (Crashpad).
if (!g_installed.exchange(true, std::memory_order_acq_rel))
g_prevFilter = SetUnhandledExceptionFilter(unhandledFilter);
VST_TRACE("installVstCrashAttribution: armed");
}
void uninstallVstCrashAttribution()
{
// Clearing g_installed (acquire-load gated in the filter) is what disarms
// the write path; we deliberately do NOT mutate g_sentinelPathW here so a
// fault racing this teardown can't read a half-zeroed path.
if (g_installed.exchange(false, std::memory_order_acq_rel))
SetUnhandledExceptionFilter(g_prevFilter);
}
} // namespace slopsmith::sandbox
#else // ── non-Windows: no-op (POSIX SignalChain guard covers the armed path) ──
namespace slopsmith::sandbox {
void installVstCrashAttribution(const juce::String&) {}
void uninstallVstCrashAttribution() {}
} // namespace slopsmith::sandbox
#endif
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <juce_core/juce_core.h>
namespace slopsmith::sandbox {
// Process-wide last-chance crash attributor.
//
// The vst-crash-guard sentinel (src/main/vst-crash-guard.ts) only covers the
// brief windows it arms around an in-process load or editor-open. But a plugin
// that creates a top-level window keeps that window for its whole loaded
// lifetime, and the OS can dispatch a message to its WndProc at ANY time (e.g.
// WM_ACTIVATEAPP on an alt-tab) — a fault there arrives via USER32→WndProc with
// no host frame on the stack, outside every armed sentinel window, so it is
// never attributed and the app crash-loops. (See issue #35; diagnosed from dmp
// a06f48e1 / McRocklin Suite.)
//
// installVstCrashAttribution arms a SetUnhandledExceptionFilter that, when a
// fatal fault's faulting instruction lies inside a loaded `.vst3` module,
// stamps `sentinelPath` with {"plugin": <module path>, "op": "native-crash"}
// and then chains to the previously installed top-level filter (Crashpad) so
// the crash dump is still produced and the process dies normally. The next
// launch's initVstCrashGuard() promotes that leftover sentinel into the
// persistent blocklist, routing the offender to the out-of-process sandbox.
//
// This makes the existing dead-man's-pedal cover ANY fatal in-process VST3
// fault, not just the armed load/editor windows. Idempotent: re-calling just
// refreshes the sentinel path. No-op on non-Windows (the POSIX SignalChain
// signal guard already covers the armed call path, and the sandbox is
// Windows-only today).
void installVstCrashAttribution(const juce::String& sentinelPath);
// Restore the previous top-level exception filter and disarm. Safe to call when
// not installed.
void uninstallVstCrashAttribution();
} // namespace slopsmith::sandbox
+7 -1
View File
@@ -7,7 +7,7 @@ import * as path from 'path';
import * as fs from 'fs';
import { app } from 'electron';
import { isDebugEnabled, getDebugLogPath } from './debug-log';
import { initVstCrashGuard, armSentinel, disarmSentinel, armEditorSentinel } from './vst-crash-guard';
import { initVstCrashGuard, armSentinel, disarmSentinel, armEditorSentinel, getSentinelPath } from './vst-crash-guard';
import { createAudioEffectsExecutor } from './audio-effects-executor';
type AudioModule = Record<string, (...args: any[]) => any>;
@@ -285,6 +285,12 @@ export function initAudioBridge(): void {
audio.setCrashedPlugins(blocked);
if (blocked.length)
console.log(`[audio] ${blocked.length} VST(s) on the crash blocklist — will load sandboxed`);
// Arm the native last-chance attributor so a fatal in-process VST3
// fault outside the load/editor sentinel windows (e.g. a plugin
// WndProc on WM_ACTIVATEAPP) still stamps the sentinel and gets
// sandboxed next launch. No-op on non-Windows. See issue #35.
if (typeof audio.setVstCrashSentinelPath === 'function')
audio.setVstCrashSentinelPath(getSentinelPath());
} catch (e: any) {
console.warn(`[audio] VST crash guard init failed: ${e.message}`);
}
+8
View File
@@ -75,6 +75,14 @@ export function initVstCrashGuard(): string[] {
return [...blocklist];
}
// Absolute path to the sentinel file, for handing to the native crash
// attributor (NodeAddon setVstCrashSentinelPath) so a fatal in-process VST3
// fault can stamp the same sentinel this module promotes on next launch. Empty
// until initVstCrashGuard() has run.
export function getSentinelPath(): string {
return sentinelPath;
}
function persist(): void {
try {
fs.writeFileSync(blocklistPath, JSON.stringify([...blocklist], null, 2));