mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-15 21:27:12 +00:00
Clean release snapshot
This commit is contained in:
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+55
@@ -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
|
||||
@@ -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(); }
|
||||
Reference in New Issue
Block a user