mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-11 03:09:56 +00:00
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:
co-authored by
Claude Opus 4.8
parent
7f8975641e
commit
cc0aceb365
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
@@ -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,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}`);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user