Clean release snapshot

This commit is contained in:
Byron Gamatos
2026-06-16 18:48:12 +02:00
commit bd603184d5
291 changed files with 47318 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
# Standalone end-to-end harness for the out-of-process sandbox runtime.
# JUCE-only (no cmake-js / node-addon-api / ONNX): builds a passthrough VST3
# fixture, the real slopsmith-vst-host child, and a host-side driver that spawns
# the child, loads the plugin, and round-trips audio over the shm ring.
#
# Heavier than tests/sandbox/standalone (it pulls in juce_audio_processors +
# juce_gui_basics + a VST3), so it lives in its own bootstrap / CI job. POSIX
# only — the e2e driver uses the fd-passing host API.
#
# cmake -S tests/sandbox/e2e -B build/e2e -DCMAKE_BUILD_TYPE=Debug
# cmake --build build/e2e
# ctest --test-dir build/e2e --output-on-failure
cmake_minimum_required(VERSION 3.22)
project(slopsmith_sandbox_e2e VERSION 1.0.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
get_filename_component(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." ABSOLUTE)
if(NOT EXISTS "${REPO_ROOT}/JUCE/CMakeLists.txt")
message(FATAL_ERROR "JUCE submodule not found at ${REPO_ROOT}/JUCE. "
"Run: git submodule update --init --recursive")
endif()
if(WIN32)
message(FATAL_ERROR "The sandbox e2e harness is POSIX-only (fd-passing host API).")
endif()
add_subdirectory("${REPO_ROOT}/JUCE" juce_build)
set(SANDBOX "${REPO_ROOT}/src/audio/Sandbox")
enable_testing()
# --- passthrough VST3 fixture (doubles its input) ---
juce_add_plugin(SlopPassThrough
PRODUCT_NAME "SlopPassThrough"
COMPANY_NAME "Slop"
PLUGIN_MANUFACTURER_CODE Slop
PLUGIN_CODE Sptp
FORMATS VST3
IS_SYNTH FALSE
NEEDS_MIDI_INPUT FALSE
VST3_CATEGORIES Fx)
target_sources(SlopPassThrough PRIVATE passthrough.cpp)
target_compile_definitions(SlopPassThrough PRIVATE
JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 JUCE_VST3_CAN_REPLACE_VST2=0)
target_link_libraries(SlopPassThrough PRIVATE
juce::juce_audio_utils juce::juce_audio_processors juce::juce_gui_basics
juce::juce_audio_plugin_client)
# --- the real vst-host child (POSIX sources) ---
add_executable(slopsmith-vst-host
"${REPO_ROOT}/src/vst-host/main.cpp"
"${REPO_ROOT}/src/audio/VSTHost.cpp"
"${SANDBOX}/Protocol.cpp"
"${SANDBOX}/ControlChannel_shared.cpp" "${SANDBOX}/ControlChannel_posix.cpp"
"${SANDBOX}/AudioChannel_shared.cpp" "${SANDBOX}/AudioChannel_posix.cpp")
target_include_directories(slopsmith-vst-host PRIVATE "${REPO_ROOT}/src/audio")
target_link_libraries(slopsmith-vst-host PRIVATE
juce::juce_audio_basics juce::juce_audio_devices juce::juce_audio_formats
juce::juce_audio_processors juce::juce_core juce::juce_data_structures
juce::juce_dsp juce::juce_events juce::juce_graphics juce::juce_gui_basics)
target_compile_definitions(slopsmith-vst-host PRIVATE
JUCE_PLUGINHOST_VST3=1 JUCE_PLUGINHOST_AU=0 JUCE_PLUGINHOST_LV2=0
JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 JUCE_DISPLAY_SPLASH_SCREEN=0
JUCE_MODAL_LOOPS_PERMITTED=1 JUCE_STANDALONE_APPLICATION=1 JUCE_REPORT_APP_USAGE=0)
# main.cpp calls XInitThreads/XSetErrorHandler directly on Linux to install a
# non-fatal X error handler (JUCE only does this for standalone JUCEApplications,
# which this child is not). JUCE itself dlopen()s libX11, but our direct calls
# need it link-time. Mirrors src/vst-host/CMakeLists.txt.
if(UNIX AND NOT APPLE)
find_package(X11 REQUIRED)
target_link_libraries(slopsmith-vst-host PRIVATE ${X11_LIBRARIES})
target_include_directories(slopsmith-vst-host PRIVATE ${X11_INCLUDE_DIR})
endif()
# --- host-side e2e driver ---
add_executable(sandbox_e2e_test
e2e_test.cpp
"${SANDBOX}/SandboxedProcessor.cpp"
"${SANDBOX}/SandboxFactory_shared.cpp" "${SANDBOX}/SandboxFactory_posix.cpp"
"${SANDBOX}/AudioChannel_shared.cpp" "${SANDBOX}/AudioChannel_posix.cpp"
"${SANDBOX}/ControlChannel_shared.cpp" "${SANDBOX}/ControlChannel_posix.cpp"
"${SANDBOX}/SubprocessHandle_posix.cpp"
"${SANDBOX}/Protocol.cpp")
target_include_directories(sandbox_e2e_test PRIVATE "${REPO_ROOT}/src/audio")
target_link_libraries(sandbox_e2e_test PRIVATE
juce::juce_audio_basics juce::juce_audio_devices juce::juce_audio_formats
juce::juce_audio_processors juce::juce_core juce::juce_dsp juce::juce_events)
target_compile_definitions(sandbox_e2e_test PRIVATE
JUCE_PLUGINHOST_VST3=1 JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0
JUCE_STANDALONE_APPLICATION=0 JUCE_REPORT_APP_USAGE=0)
add_dependencies(sandbox_e2e_test slopsmith-vst-host SlopPassThrough_VST3)
# The VST3 bundle lands in <build>/SlopPassThrough_artefacts/<config>/VST3/.
add_test(NAME sandbox_e2e_test
COMMAND sandbox_e2e_test
"$<TARGET_FILE:slopsmith-vst-host>"
"${CMAKE_BINARY_DIR}/SlopPassThrough_artefacts/$<CONFIG>/VST3/SlopPassThrough.vst3")
# Orphan-cleanup regression (issue #265): host crash → child must not orphan.
# Linux-only — the driver's leak path + PR_SET_PDEATHSIG are JUCE_LINUX-gated;
# on macOS the env var is a no-op so this would assert clean-shutdown, not the
# crash path, which would be misleading. (UNIX AND NOT APPLE matches the X11
# linkage block above and excludes any other non-mac POSIX target.)
if(UNIX AND NOT APPLE)
add_test(NAME sandbox_e2e_leak
COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/leak_test.sh"
"$<TARGET_FILE:sandbox_e2e_test>"
"$<TARGET_FILE:slopsmith-vst-host>"
"${CMAKE_BINARY_DIR}/SlopPassThrough_artefacts/$<CONFIG>/VST3/SlopPassThrough.vst3")
endif()
+156
View File
@@ -0,0 +1,156 @@
// 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;
}
int main(int argc, char** argv)
{
if (argc < 3) { std::fprintf(stderr, "usage: e2e_test <vst-host> <plugin.vst3>\n"); return 2; }
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;
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Orphan-cleanup regression test (issue #265, Linux).
#
# A *crashed* host must not leave the slopsmith-vst-host child running and
# holding the audio device + shm. This drives the e2e driver in
# SLOPSMITH_E2E_LEAK_TEST mode — the driver _Exit()s the instant the child is
# alive, skipping its clean shutdown (no `shutdown` op, no SIGTERM ladder) —
# then asserts the child process is gone. Exercises both cleanup paths together:
# PR_SET_PDEATHSIG (installLinuxParentDeathSignal) and the control-socket
# disconnect teardown.
#
# leak_test.sh <e2e-driver> <vst-host> <plugin.vst3>
set -euo pipefail
E2E="${1:?usage: leak_test.sh <e2e-driver> <vst-host> <plugin.vst3>}"
HOST="${2:?missing vst-host path}"
PLUG="${3:?missing plugin path}"
# Fresh TMPDIR so we only see this run's child log (the child names its log
# $TMPDIR/slopsmith-vst-host-<pid>.log).
TMPDIR_RUN="$(mktemp -d)"
export TMPDIR="$TMPDIR_RUN"
trap 'rm -rf "$TMPDIR_RUN"' EXIT
SLOPSMITH_E2E_LEAK_TEST=1 "$E2E" "$HOST" "$PLUG" >/dev/null 2>&1 || true
# `|| true`: a no-match makes the ls pipeline non-zero, which would trip set -e —
# the empty-LOG case is handled explicitly just below.
LOG=$(ls -t "$TMPDIR_RUN"/slopsmith-vst-host-*.log 2>/dev/null | head -1 || true)
if [[ -z "${LOG:-}" ]]; then
echo "leak_test: FAIL — no child log produced (driver never spawned the host)"
exit 1
fi
# Anchored extract; if the name doesn't match, sed echoes it back unchanged, so
# validate the result is a bare pid — otherwise `kill -0` on garbage would fail
# and the test would PASS for the wrong reason.
CPID=$(basename "$LOG" | sed -E 's/^slopsmith-vst-host-([0-9]+)\.log$/\1/')
if [[ ! "$CPID" =~ ^[0-9]+$ ]]; then
echo "leak_test: FAIL — could not parse a numeric pid from log name '$LOG'"
exit 1
fi
echo "leak_test: host child pid=$CPID; driver has exited without clean shutdown"
# PDEATHSIG / disconnect are near-instant; poll up to ~5s for CI-runner slack.
for _ in $(seq 1 50); do
if ! kill -0 "$CPID" 2>/dev/null; then
echo "leak_test: PASS — child cleaned up after host crash"
exit 0
fi
sleep 0.1
done
echo "leak_test: FAIL — child $CPID still running 5s after host crash (orphan)"
kill -9 "$CPID" 2>/dev/null || true
exit 1
+49
View File
@@ -0,0 +1,49 @@
// Minimal passthrough VST3 fixture for the sandbox e2e test: doubles its
// input (×2) so the test can prove audio flowed host→sandbox→plugin→host,
// stores a 4-byte state blob so getState/setState round-trips are observable,
// and exposes a trivial editor so the editor open/close path is exercisable.
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_gui_basics/juce_gui_basics.h>
// Trivial fixed-size editor — enough for the sandbox child to create a
// top-level window and round-trip the open/close protocol.
class PassEditor : public juce::AudioProcessorEditor
{
public:
explicit PassEditor(juce::AudioProcessor& p) : juce::AudioProcessorEditor(p)
{ setSize(320, 200); }
void paint(juce::Graphics& g) override { g.fillAll(juce::Colours::black); }
};
class PassThrough : public juce::AudioProcessor
{
public:
PassThrough()
: juce::AudioProcessor(BusesProperties()
.withInput("In", juce::AudioChannelSet::stereo(), true)
.withOutput("Out", juce::AudioChannelSet::stereo(), true)) {}
const juce::String getName() const override { return "SlopPassThrough"; }
void prepareToPlay(double, int) override {}
void releaseResources() override {}
void processBlock(juce::AudioBuffer<float>& b, juce::MidiBuffer&) override
{
b.applyGain(2.0f); // ×2 — the e2e asserts output == 2 * input
}
double getTailLengthSeconds() const override { return 0.0; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
juce::AudioProcessorEditor* createEditor() override { return new PassEditor(*this); }
bool hasEditor() const override { return true; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock& d) override { d.append("SLOP", 4); }
void setStateInformation(const void*, int) override {}
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PassThrough)
};
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { return new PassThrough(); }