mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-07-24 22:01:45 +00:00
* fix(sandbox): force PolyChrome DSP plugins out-of-process (in-process WndProc DEP crash) A tester crash dump (feedback.exe 0.3.0, Windows) showed an execute-DEP access violation (0xC0000005) killing the app while McRocklin Suite.vst3 (PolyChrome DSP) was loaded IN-PROCESS: Rax == Rip == McRocklin Suite.vst3 + 0x1D57050 (non-executable module data) caller [Rsp] = USER32.dll+0xEF5C WndProc(hwnd=0x51CCA, msg=0x1C WM_ACTIVATEAPP, wParam=1, lParam=0x1838) crash thread = the addon's background JUCE MessageManager thread (unnamed; start frame slopsmith_audio.node), NOT Electron's CrBrowserMain. PolyChrome creates a top-level window during in-process init on JUCE's *background* message thread. Its WndProc lands in non-executable memory there, so when Windows broadcasts WM_ACTIVATEAPP the OS message pump executes it → DEP AV. The plugin assumes a real host main UI thread (STA/main); the sandbox child provides exactly that, so routing it out-of-process both isolates the fault and gives the plugin the environment it needs. Crucially this crash cannot be caught by the SignalChain in-process fault guard: it arrives asynchronously via USER32→WndProc with NO host frame on the stack, so guarding prepareToPlay/processBlock (or even instantiation) never sees it. Under the current in-process-by-default policy (#24) the only fix is to not host these plugins in-process. Graphene (same vendor) was already pre-seeded; this extends the pre-seed to the whole PolyChrome vendor via a path-fragment match so McRocklin Suite and any other PolyChrome product route to the sandbox too. - Add kDefaultNeedsSandboxPathFragments (vendor/path match) + the loop in shouldSandbox; seed it with "PolyChrome". - Refresh the stale kDefaultNeedsSandboxFilenames comment (it still claimed sandbox-by-default; #24 made the list authoritative again). - e2e_test: add testShouldSandboxRouting() — pure shouldSandbox assertions (PolyChrome→sandbox, clean VST3→in-process, non-VST3→in-process). Verified: audio addon builds clean; sandbox_e2e_test green (16/16, routing assertions included). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * review: tighten PolyChrome match + dedupe path + harden routing test Local high-effort review of the pre-seed fix surfaced four items; addressed: - False-positive risk (no in-process fallback exists here — loadVstSandboxAware hard-fails a force-sandboxed load that can't spawn the child): narrow the fragment from the bare brand word "PolyChrome" to the vendor install folder "PolyChrome DSP", so an unrelated path (e.g. a username "polychrome") no longer forces the sandbox. Still matches McRocklin Suite + Graphene, which ship under Common Files/VST3/PolyChrome DSP/. - Dedupe: getFullPathName() was computed twice (blocklist `canonical` + vendor `fullPath`); hoist one `fullPath` above the mutex block and reuse it. - Test isolation: assert McRocklin Suite (NOT in the filename pre-seed) on both Windows- and POSIX-style paths so the case can only pass via the new vendor match; drop the redundant Graphene-in-folder line (Graphene already routes via the filename list). - Test specificity + exit-code masking: add a negative proving a bare "polychrome" path is NOT sandboxed (guards the tightening), and surface routing CHECK failures on the no-args path (return 1, not the usage code 2) so a regression isn't masked on a manual/argless run. Addon builds clean; sandbox_e2e_test 17/17 green (routing included). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
9.1 KiB
C++
198 lines
9.1 KiB
C++
// e2e: drive a real SandboxedProcessor (host side) that spawns the real
|
||
// slopsmith-vst-host child, which loads the passthrough VST3 and processes
|
||
// audio over the shm ring. Proves the whole POSIX runtime: posix_spawn + fd
|
||
// inheritance + ready handshake + prepare + audio round-trip + state + shutdown.
|
||
//
|
||
// argv[1] = path to slopsmith-vst-host
|
||
// argv[2] = path to SlopPassThrough.vst3
|
||
#include "Sandbox/SandboxedProcessor.h"
|
||
#include <juce_audio_processors/juce_audio_processors.h>
|
||
#include <chrono>
|
||
#include <cmath>
|
||
#include <cstdio>
|
||
#include <cstdlib>
|
||
#include <thread>
|
||
|
||
using namespace slopsmith::sandbox;
|
||
|
||
static int g_pass = 0, g_fail = 0;
|
||
static void check(bool c, const char* what, int line)
|
||
{
|
||
if (c) { ++g_pass; return; }
|
||
++g_fail; std::fprintf(stderr, " FAIL: %s (line %d)\n", what, line);
|
||
}
|
||
#define CHECK(c) check((c), #c, __LINE__)
|
||
|
||
static bool allClose(const juce::AudioBuffer<float>& b, float v)
|
||
{
|
||
for (int ch = 0; ch < b.getNumChannels(); ++ch)
|
||
for (int i = 0; i < b.getNumSamples(); ++i)
|
||
if (std::abs(b.getSample(ch, i) - v) > 1.0e-4f) return false;
|
||
return true;
|
||
}
|
||
|
||
// Pure routing checks for shouldSandbox() — no spawn / fixture needed. Guards
|
||
// the pre-seed policy: under in-process-by-default, a PolyChrome plugin (whose
|
||
// in-process WndProc DEP-faults via the OS message pump — uncatchable by the
|
||
// SignalChain guard) MUST be forced to the out-of-process sandbox, while an
|
||
// ordinary scanned-clean VST3 stays in-process. See dmp a06f48e1 / McRocklin.
|
||
static void testShouldSandboxRouting()
|
||
{
|
||
std::printf("--- shouldSandbox routing ---\n");
|
||
auto desc = [](const char* p)
|
||
{
|
||
juce::PluginDescription d;
|
||
d.fileOrIdentifier = juce::String::fromUTF8(p);
|
||
return d;
|
||
};
|
||
// PolyChrome DSP vendor folder → sandbox. McRocklin Suite is NOT in the
|
||
// filename pre-seed, so these can only pass via the new vendor/path match —
|
||
// they directly guard the fix on both Windows- and POSIX-style paths.
|
||
CHECK(shouldSandbox(desc("C:\\Program Files\\Common Files\\VST3\\PolyChrome DSP\\McRocklin Suite.vst3")));
|
||
CHECK(shouldSandbox(desc("/Library/Audio/Plug-Ins/VST3/PolyChrome DSP/McRocklin Suite.vst3")));
|
||
// Filename pre-seed still independently routes Graphene by name.
|
||
CHECK(shouldSandbox(desc("/plugins/Graphene.vst3")));
|
||
// Ordinary scanned-clean VST3 stays in-process.
|
||
CHECK(! shouldSandbox(desc("/Library/Audio/Plug-Ins/VST3/SomeCleanAmp.vst3")));
|
||
// Specificity: the fragment is the vendor FOLDER, not a bare brand word — a
|
||
// clean plugin under a path that merely contains 'polychrome' (e.g. a
|
||
// username) must NOT be force-sandboxed (no in-process fallback exists, so a
|
||
// false positive could hard-fail the load).
|
||
CHECK(! shouldSandbox(desc("/Users/polychrome/VST3/SomeCleanAmp.vst3")));
|
||
// Non-VST3 (NAM/IR) always in-process.
|
||
CHECK(! shouldSandbox(desc("/models/AwesomeAmp.nam")));
|
||
}
|
||
|
||
int main(int argc, char** argv)
|
||
{
|
||
testShouldSandboxRouting();
|
||
|
||
// Surface routing failures even on the no-args path: the pure routing checks
|
||
// above need no fixture, so a regression must not be masked by the usage
|
||
// sentinel (return 1 if a routing CHECK failed, else the usage code 2).
|
||
if (argc < 3)
|
||
{
|
||
std::fprintf(stderr, "usage: e2e_test <vst-host> <plugin.vst3>\n");
|
||
return g_fail == 0 ? 2 : 1;
|
||
}
|
||
|
||
SandboxedProcessor::SpawnConfig cfg;
|
||
cfg.pluginPath = juce::String::fromUTF8(argv[2]);
|
||
cfg.pluginName = "PassThrough";
|
||
cfg.sandboxExePath = juce::String::fromUTF8(argv[1]);
|
||
cfg.audio.sampleRate = 48000;
|
||
cfg.audio.maxBlockSamples = 256;
|
||
cfg.audio.maxChannels = 2;
|
||
cfg.audio.maxBlocks = 4;
|
||
cfg.spawnTimeoutMs = 20000;
|
||
|
||
std::printf("=== sandbox e2e: spawn → process → state → shutdown ===\n");
|
||
juce::String err;
|
||
auto sb = SandboxedProcessor::spawn(cfg, err);
|
||
CHECK(sb != nullptr);
|
||
if (!sb) { std::fprintf(stderr, "spawn failed: %s\n", err.toRawUTF8()); return 1; }
|
||
CHECK(sb->isAlive());
|
||
|
||
#if JUCE_LINUX
|
||
// Orphan-cleanup check (issue #265). When SLOPSMITH_E2E_LEAK_TEST is set,
|
||
// simulate a host *crash*: exit RIGHT NOW via _Exit, skipping sb's
|
||
// destructor — so no `shutdown` op and no SIGTERM→SIGKILL ladder ever runs.
|
||
// The child must still die, via PR_SET_PDEATHSIG (installLinuxParentDeathSignal
|
||
// in the child). The leak_test.sh wrapper reads the child pid from its log
|
||
// and asserts it is gone after this parent vanishes.
|
||
if (std::getenv("SLOPSMITH_E2E_LEAK_TEST") != nullptr)
|
||
{
|
||
std::printf("LEAK_TEST: child alive; crashing host without shutdown\n");
|
||
std::fflush(stdout);
|
||
std::_Exit(0);
|
||
}
|
||
#endif
|
||
|
||
sb->prepareToPlay(48000.0, 256);
|
||
|
||
juce::AudioBuffer<float> buf(2, 256);
|
||
juce::MidiBuffer midi;
|
||
|
||
// Pace at one block period (256 samples @ 48 kHz ≈ 5.33 ms, rounded up to
|
||
// 6 ms) so the host doesn't outrun the sandbox worker — a faster cadence
|
||
// would let the host's pop legitimately time out and read silence.
|
||
constexpr int kBlockPeriodMs = 6;
|
||
|
||
// A single constant level feeds both the warm-up and the steady-state loop.
|
||
// The sandbox is two independent rings (input, output), so it promises
|
||
// *bounded latency*, NOT exact per-block phase: if any block's round-trip
|
||
// overruns the pop timeout, the host inserts silence and moves on while the
|
||
// worker still produces that block's output, which shifts every later read
|
||
// one slot late. A distinct-per-block probe would then read the *previous*
|
||
// block's (valid, non-silent) output and flag it as a spurious mismatch —
|
||
// observed as a flaky "200 misvalued" on loaded CI runners. A constant
|
||
// level is phase-invariant: a lagged read still equals 2×kLevel (correct),
|
||
// a timed-out block is still silence (dropout), and a genuine scaling bug
|
||
// still produces a wrong value. In-phase slot correctness with distinct
|
||
// markers is covered by the deterministic standalone ring unit test.
|
||
constexpr float kLevel = 0.3f;
|
||
|
||
// Warm-up: a plugin's first few processBlock calls (VST3 activation,
|
||
// allocation, first-touch) can exceed one block period, so the sandbox
|
||
// inserts silence for those by design. Discard a warm-up burst so the
|
||
// steady-state assertions aren't measuring cold start.
|
||
for (int n = 0; n < 40; ++n)
|
||
{
|
||
for (int ch = 0; ch < 2; ++ch)
|
||
for (int i = 0; i < 256; ++i) buf.setSample(ch, i, kLevel);
|
||
sb->processBlock(buf, midi);
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(kBlockPeriodMs));
|
||
}
|
||
|
||
// Steady state. Each delivered block MUST be exactly 2×kLevel (a real
|
||
// scaling bug surfaces as a wrong non-zero value → `misvalued`, which must
|
||
// be zero). A block that times out under load is returned as silence by
|
||
// SandboxedProcessor (by design) → counted as a dropout, tolerated in small
|
||
// numbers since a shared CI runner can stall a single round-trip past the
|
||
// pop timeout even when the runtime is correct.
|
||
int correct = 0, dropouts = 0, misvalued = 0;
|
||
constexpr int kBlocks = 200;
|
||
for (int n = 0; n < kBlocks; ++n)
|
||
{
|
||
for (int ch = 0; ch < 2; ++ch)
|
||
for (int i = 0; i < 256; ++i) buf.setSample(ch, i, kLevel);
|
||
sb->processBlock(buf, midi);
|
||
if (allClose(buf, kLevel * 2.0f)) ++correct;
|
||
else if (allClose(buf, 0.0f)) ++dropouts; // timed-out → silence
|
||
else ++misvalued; // wrong value → real bug
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(kBlockPeriodMs));
|
||
}
|
||
std::printf(" steady-state: %d correct, %d dropouts, %d misvalued (of %d)\n",
|
||
correct, dropouts, misvalued, kBlocks);
|
||
CHECK(misvalued == 0); // every delivered block is exact
|
||
CHECK(correct >= kBlocks * 9 / 10); // overwhelmingly delivered (tolerate CI jitter)
|
||
|
||
// State round-trip: child returns the plugin's getStateInformation blob.
|
||
juce::MemoryBlock state;
|
||
sb->getStateInformation(state);
|
||
CHECK(state.getSize() > 0);
|
||
sb->setStateInformation(state.getData(), (int)state.getSize());
|
||
CHECK(sb->isAlive()); // setState shouldn't have torn the sandbox down
|
||
|
||
#if JUCE_MAC || JUCE_LINUX
|
||
// Editor open/close protocol: the child opens a floating top-level editor
|
||
// window in its own process (NSWindow on macOS, X11 window on Linux via
|
||
// JUCE 8's VST3 IRunLoop hosting) and the host tracks only the open bit.
|
||
// Proves the kOpenEditor round-trip + editorOpen tracking + kCloseEditor.
|
||
// Runs under xvfb on the Linux CI runner; visual focus/DPI is the one thing
|
||
// a headless runner can't verify (manual on real hardware).
|
||
CHECK(sb->hasEditor());
|
||
const bool opened = sb->requestOpenEditor();
|
||
CHECK(opened);
|
||
CHECK(sb->isEditorOpen());
|
||
sb->requestCloseEditor();
|
||
CHECK(!sb->isEditorOpen());
|
||
CHECK(sb->isAlive()); // open/close must not crash the child
|
||
#endif
|
||
|
||
sb.reset(); // destructor → shutdown op → SIGTERM ladder; must not hang
|
||
|
||
std::printf("\n%d passed, %d failed\n", g_pass, g_fail);
|
||
return g_fail == 0 ? 0 : 1;
|
||
}
|