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
+117
View File
@@ -0,0 +1,117 @@
# Sandbox IPC unit tests. Cross-platform: the audio-ring loopback runs
# everywhere; the control-channel loopback and the posix_spawn smoke test are
# POSIX-only (they use the fd-passed socketpair transport). Standalone console
# exes, exit 0 on pass.
#
# Source paths are CMAKE_CURRENT_SOURCE_DIR-relative so this file works both
# when add_subdirectory()'d from the main build (root CMakeLists → tests/) and
# from the JUCE-only standalone harness (tests/sandbox/standalone/) the CI job
# uses — neither needs cmake-js / node-addon-api / ONNX to build.
#
# Optional sanitizer: -DSLOPSMITH_SANITIZE=address|thread applies the sanitizer
# to these targets only (the CI job builds plain + ASan + TSan variants). TSan
# on the threaded audio loopback is the highest-value artifact here — it is
# what validates the arm64 release/acquire memory ordering a Linux-only dev
# can't otherwise exercise.
if(NOT TARGET juce::juce_core OR NOT TARGET juce::juce_audio_basics)
message(FATAL_ERROR "sandbox tests: JUCE targets not in scope; this dir "
"must be configured after JUCE is added.")
endif()
set(SANDBOX_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../src/audio/Sandbox")
set(AUDIO_INC "${CMAKE_CURRENT_SOURCE_DIR}/../../src/audio")
set(SLOPSMITH_SANITIZE "" CACHE STRING
"Sanitizer for sandbox tests: empty, address, or thread")
# Per-target shared config: include dir, JUCE-headless defs, Release output
# dir, optional sanitizer.
function(slopsmith_configure_sandbox_test target)
target_include_directories(${target} PRIVATE "${AUDIO_INC}")
target_compile_definitions(${target} PRIVATE
JUCE_STANDALONE_APPLICATION=1
JUCE_USE_CURL=0
JUCE_WEB_BROWSER=0
JUCE_DISPLAY_SPLASH_SCREEN=0
JUCE_REPORT_APP_USAGE=0)
set_target_properties(${target} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Release"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/Release"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/Release"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/Release"
RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/Release"
OUTPUT_NAME "${target}")
if(SLOPSMITH_SANITIZE)
# -fsanitize= is GCC/Clang syntax; MSVC uses a different mechanism and
# would choke on these flags. The sanitizer runs are POSIX-only CI jobs,
# so fail loudly rather than emit broken flags on an unsupported toolchain.
if(NOT CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
message(FATAL_ERROR
"SLOPSMITH_SANITIZE=${SLOPSMITH_SANITIZE} requires a GCC/Clang "
"compiler (got ${CMAKE_CXX_COMPILER_ID}); unset it on this toolchain.")
endif()
target_compile_options(${target} PRIVATE
-fsanitize=${SLOPSMITH_SANITIZE} -fno-omit-frame-pointer -g)
target_link_options(${target} PRIVATE -fsanitize=${SLOPSMITH_SANITIZE})
endif()
endfunction()
# Platform split of the channel backends.
if(WIN32)
set(AUDIO_CHANNEL_SRC "${SANDBOX_DIR}/AudioChannel_shared.cpp"
"${SANDBOX_DIR}/AudioChannel_win.cpp")
else()
set(AUDIO_CHANNEL_SRC "${SANDBOX_DIR}/AudioChannel_shared.cpp"
"${SANDBOX_DIR}/AudioChannel_posix.cpp")
endif()
# --- audio ring loopback (all platforms) ---------------------------------
add_executable(audio_channel_midi_test
audio_channel_midi_test.cpp
${AUDIO_CHANNEL_SRC}
"${SANDBOX_DIR}/Protocol.cpp")
target_link_libraries(audio_channel_midi_test PRIVATE
juce::juce_audio_basics juce::juce_core)
slopsmith_configure_sandbox_test(audio_channel_midi_test)
add_test(NAME audio_channel_midi_test
COMMAND audio_channel_midi_test
WORKING_DIRECTORY "$<TARGET_FILE_DIR:audio_channel_midi_test>")
# --- control channel + spawn smoke (POSIX only) --------------------------
# The Windows control transport is a named pipe re-opened by name and has no
# in-process fd-handoff path; these loopbacks use connectClientSideFd /
# startPosix, so they are POSIX-only. The Windows transport is covered on the
# Windows CI build of the addon + vst-host.
if(NOT WIN32)
set(CONTROL_SRC "${SANDBOX_DIR}/ControlChannel_shared.cpp"
"${SANDBOX_DIR}/ControlChannel_posix.cpp"
"${SANDBOX_DIR}/Protocol.cpp")
add_executable(control_channel_test
control_channel_test.cpp ${CONTROL_SRC})
target_link_libraries(control_channel_test PRIVATE juce::juce_core)
slopsmith_configure_sandbox_test(control_channel_test)
add_test(NAME control_channel_test
COMMAND control_channel_test
WORKING_DIRECTORY "$<TARGET_FILE_DIR:control_channel_test>")
# Child helper for the spawn smoke test (not a test itself).
add_executable(spawn_smoke_child
spawn_smoke_child.cpp ${CONTROL_SRC})
target_link_libraries(spawn_smoke_child PRIVATE juce::juce_core)
slopsmith_configure_sandbox_test(spawn_smoke_child)
add_executable(spawn_smoke_test
spawn_smoke_test.cpp
"${SANDBOX_DIR}/SubprocessHandle_posix.cpp"
${CONTROL_SRC})
target_link_libraries(spawn_smoke_test PRIVATE juce::juce_core)
slopsmith_configure_sandbox_test(spawn_smoke_test)
target_compile_definitions(spawn_smoke_test PRIVATE
SPAWN_CHILD_PATH="$<TARGET_FILE:spawn_smoke_child>")
add_dependencies(spawn_smoke_test spawn_smoke_child)
add_test(NAME spawn_smoke_test
COMMAND spawn_smoke_test
WORKING_DIRECTORY "$<TARGET_FILE_DIR:spawn_smoke_test>")
endif()
+413
View File
@@ -0,0 +1,413 @@
// audio_channel_midi_test — exercise pushInputBlock / popInputBlock + the
// global midiOverflows counter without spawning a subprocess.
//
// Closes the v2/v3 review-thread concern that the inline-MIDI path had no
// automated coverage (the existing GR6 smoke driver only pushes empty
// MidiBuffers). Both ends of an AudioChannel are opened in the same
// process — createHostSide on one instance, openSandboxSide on a second
// instance using the same Names — so we don't need a real spawn.
//
// Win32-only for the same reason AudioChannel.cpp is.
#include <juce_audio_basics/juce_audio_basics.h>
#include <juce_core/juce_core.h>
#include "../../src/audio/Sandbox/Protocol.h"
#include "../../src/audio/Sandbox/AudioChannel.h"
#include <atomic>
#include <cstdio>
#include <thread>
using namespace slopsmith::sandbox;
namespace {
int g_failed = 0;
int g_passed = 0;
void check(bool cond, const char* what, const char* file, int line)
{
if (cond) { ++g_passed; return; }
++g_failed;
std::fprintf(stderr, " FAIL: %s (%s:%d)\n", what, file, line);
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
// REQUIRE = fatal CHECK: bails the current test on failure so a busted
// setup precondition (e.g., HeaderPeek failing to open the mapping) doesn't
// cascade into a NULL deref + a barrage of misleading follow-on failures.
// Use for everything that subsequent test lines dereference / depend on.
#define REQUIRE(cond) \
do { if (!(cond)) { check(false, #cond, __FILE__, __LINE__); return; } } while (0)
// Helper: open a fresh host+sandbox AudioChannel pair with a given dims, run
// a callback against both ends, then tear down. The pair is unique per call
// (suffix-randomised mapping name) so concurrent test runs don't collide.
struct ChannelPair
{
AudioChannel host;
AudioChannel sandbox;
AudioChannel::Names names;
AudioDimensions dims;
juce::String err;
bool ok = false;
explicit ChannelPair(const AudioDimensions& d) : dims(d)
{
ok = host.createHostSide(dims, names, err);
if (!ok)
{
std::fprintf(stderr, " ChannelPair: createHostSide failed: %s\n",
err.toRawUTF8());
return;
}
ok = sandbox.openSandboxSide(names, err);
if (!ok)
{
std::fprintf(stderr, " ChannelPair: openSandboxSide failed: %s\n",
err.toRawUTF8());
// host's named mapping + events are released by AudioChannel's
// destructor when this ChannelPair goes out of scope (sandbox
// first, then host, per reverse-declaration-order rules).
// Names are randomised per ChannelPair so an aborted construct
// doesn't leak into a subsequent test in the same run.
}
}
};
void testRoundtripSmallBuffer()
{
std::printf("test: roundtrip small MidiBuffer (count, frames, bytes)\n");
AudioDimensions dims; // defaults: 4 blocks × 1024 samples × 2 ch
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
// 3 events at distinct frames — Note On, CC, Note Off.
midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8)100), 0);
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, 64), 64);
midi.addEvent(juce::MidiMessage::noteOff(1, 60), 200);
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, /*timeoutMs*/ 1000));
int n = 0;
int frames[3] = {-1, -1, -1};
juce::uint8 firstByte[3] = {0, 0, 0};
for (const auto& meta : drained)
{
if (n < 3) { frames[n] = meta.samplePosition;
firstByte[n] = meta.getMessage().getRawData()[0]; }
++n;
}
CHECK(n == 3);
CHECK(frames[0] == 0);
CHECK(frames[1] == 64);
CHECK(frames[2] == 200);
// Note On status nibble = 0x90, CC = 0xB0, Note Off = 0x80.
CHECK((firstByte[0] & 0xF0) == 0x90);
CHECK((firstByte[1] & 0xF0) == 0xB0);
CHECK((firstByte[2] & 0xF0) == 0x80);
// No overflows expected on the happy path.
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore);
}
void testSysExBumpsOverflow()
{
std::printf("test: SysEx-sized event drops + bumps midiOverflows\n");
AudioDimensions dims;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
// SysEx — JUCE wraps the payload with F0/F7 framing, so a 3-byte
// payload becomes a 5-byte raw message (> kMidiEventMaxBytes = 4),
// which pushInputBlock should drop and bump midiOverflows.
const juce::uint8 sysexPayload[] = { 0x7E, 0x7F, 0x06 };
midi.addEvent(juce::MidiMessage::createSysExMessage(sysexPayload, 3), 32);
// Plus a normal CC event at frame 100 — should round-trip.
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, 64), 100);
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, 1000));
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
CHECK(n == 1); // SysEx dropped, CC survives.
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore + 1);
}
void testOverCapBumpsOverflow()
{
std::printf("test: events past kMidiEventsPerSlot drop + bump overflows\n");
AudioDimensions dims;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
// Push kMidiEventsPerSlot + 8 events — the trailing 8 should be dropped.
constexpr int kExtra = 8;
const int total = (int)kMidiEventsPerSlot + kExtra;
for (int i = 0; i < total; ++i)
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, i & 0x7F), i % 256);
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, 1000));
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
CHECK(n == (int)kMidiEventsPerSlot);
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore + (uint64_t)kExtra);
}
void testFramePastSamplesDropped()
{
std::printf("test: events past block samples drop + bump overflows\n");
AudioDimensions dims;
dims.maxBlockSamples = 128;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 128);
srcAudio.clear();
juce::MidiBuffer midi;
// Caller passes numSamples=128 (within cap). Events at frames >= 128
// should DROP rather than clamp into the audible portion (which would
// silently re-time them, the worse failure mode).
midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8)100), 50); // in-range
midi.addEvent(juce::MidiMessage::noteOn(1, 61, (juce::uint8)100), 127); // last in-range frame
midi.addEvent(juce::MidiMessage::noteOn(1, 62, (juce::uint8)100), 128); // out-of-range (= samples)
midi.addEvent(juce::MidiMessage::noteOn(1, 63, (juce::uint8)100), 200); // out-of-range
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 128));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 128);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 128, 1000));
int n = 0;
int lastFrame = -1;
for (const auto& meta : drained) { ++n; lastFrame = meta.samplePosition; }
CHECK(n == 2); // events at 50 and 127
CHECK(lastFrame == 127); // 128 and 200 dropped, NOT clamped to 127
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore + 2);
}
void testNumSamplesOverCapRejected()
{
std::printf("test: numSamples > maxSamples rejected up front\n");
AudioDimensions dims;
dims.maxBlockSamples = 128;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8)100), 50);
// Caller passes numSamples=256 but spawn cap is 128. Old behavior was
// silently truncate audio + drop MIDI in [128, 256). New behavior:
// return false up front so the misuse is visible to the caller. No
// shm counter is bumped (caller misuse is a distinct class from
// real-dropout / ring-full, and dropouts/xruns are reserved for
// those — see the comment in pushInputBlock).
CHECK(! pair.host.pushInputBlock(srcAudio, midi, 256));
}
void testSlotReuseAcrossWraparound()
{
// Push/pop more blocks than the ring has slots so each slot is used
// multiple times. Catches a regression in the "count is always
// overwritten on push" invariant — if pushInputBlock ever skipped the
// count store on a slot whose prior cycle had MIDI events, the next
// pop would replay those stale events against the fresh audio.
std::printf("test: slot reuse across ring wrap-around (no MIDI leakage)\n");
AudioDimensions dims;
// Pin maxBlocks explicitly: the modulus-coprime reasoning below depends
// on it. If AudioDimensions{}'s default ever changes, this test would
// silently stop exercising the slot-reuse-with-different-counts property.
constexpr uint32_t kRingSize = 4;
dims.maxBlocks = kRingSize;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
// Run enough cycles for every slot to be reused multiple times.
// 3*maxBlocks + 2 = 14 cycles with maxBlocks=4 means each slot is hit
// 3 or 4 times.
const int kCycles = 3 * (int)dims.maxBlocks + 2;
// Vary the MIDI count per block so a leaked stale count from a prior
// cycle on the SAME slot would show up as a wrong-count assertion.
// Modulus must be COPRIME with maxBlocks (4) — using `i % 4` would
// make each slot see the same count on every wrap (defeating the
// test). 5 is coprime with 4: slot 0 across cycles 0/4/8/12 sees
// counts 0/4/3/2, so a stale count from the prior visit would mismatch.
constexpr int kEventCountModulus = 5;
// Real coprimality check (not just oddness — those happen to coincide for
// kRingSize=4 because 4 = 2², but a future bump to e.g. 6 would let
// odd-but-not-coprime values like 9 silently slip through and defeat the
// stale-count detection).
constexpr auto gcd = [](int a, int b)
{
while (b != 0) { a %= b; auto t = a; a = b; b = t; }
return a;
};
static_assert(gcd((int)kRingSize, kEventCountModulus) == 1,
"kEventCountModulus must stay coprime with kRingSize — "
"otherwise each ring slot sees the same MIDI-event count "
"on every wrap and the stale-count regression test "
"becomes trivially-passing.");
for (int i = 0; i < kCycles; ++i)
{
juce::MidiBuffer midi;
const int eventCount = i % kEventCountModulus; // 0, 1, 2, 3, 4, 0, 1, ...
for (int e = 0; e < eventCount; ++e)
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, e * 16),
e * 32);
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, 1000));
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
CHECK(n == eventCount);
}
}
void testThreadedProducerConsumer()
{
// Cross-thread loopback: a producer thread pushes ordered blocks while a
// consumer thread drains them, both blocking on the real doorbell
// (Win32 auto-reset events / POSIX socketpair). This is the case the
// single-threaded tests above can't cover — the producer/consumer
// happens-before edge runs through the shared atomic write index plus the
// doorbell wake, and is what ThreadSanitizer actually inspects. Each block
// carries a unique audio marker + a varying MIDI count so a torn handoff,
// a dropped/duplicated block, or stale-slot MIDI would surface as a
// mismatch rather than passing silently.
std::printf("test: threaded producer/consumer over the doorbell\n");
AudioDimensions dims; // 4 blocks × 1024 samples × 2 ch
ChannelPair pair{dims};
REQUIRE(pair.ok);
constexpr int kBlocks = 4000;
const int samples = 256;
std::atomic<bool> producerOk{true};
std::atomic<int> mismatches{0};
std::thread producer([&]
{
juce::AudioBuffer<float> src((int)dims.maxChannels, samples);
for (int i = 0; i < kBlocks; ++i)
{
// Unique per-block marker in sample 0 of every channel.
src.clear();
for (int ch = 0; ch < (int)dims.maxChannels; ++ch)
src.setSample(ch, 0, (float)i);
juce::MidiBuffer midi;
const int eventCount = i % 7; // 0..6 events, < kMidiEventsPerSlot
for (int e = 0; e < eventCount; ++e)
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, e & 0x7F),
e); // frames 0..5 < samples
// The host audio thread would drop on a full ring (xrun); this
// test wants lossless ordering, so spin-retry until the consumer
// frees a slot. yield() keeps it from starving the consumer.
int spins = 0;
while (!pair.host.pushInputBlock(src, midi, samples))
{
std::this_thread::yield();
if (++spins > 50'000'000) { producerOk.store(false); return; }
}
}
});
juce::AudioBuffer<float> dst((int)dims.maxChannels, samples);
for (int i = 0; i < kBlocks; ++i)
{
juce::MidiBuffer drained;
// popInputBlock returns false on a coalesced / spurious doorbell wake
// (it rechecks the ring index, finds nothing new yet, and returns) —
// that is NOT a lost block, just "try again", exactly as the real
// runAudioThread loops. Retry until the real block arrives; the
// doorbell byte is sticky (socket-buffered) so there is no lost-wakeup
// window. A genuine stall (producer died) trips the bounded retry cap.
bool got = false;
for (int tries = 0; tries < 2'000'000 && !got; ++tries)
{
drained.clear();
got = pair.sandbox.popInputBlock(dst, drained, samples, 5000);
if (!got) std::this_thread::yield();
}
if (!got) { ++mismatches; break; }
if (dst.getSample(0, 0) != (float)i) ++mismatches; // ordering / torn handoff
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
if (n != i % 7) ++mismatches; // stale-slot MIDI
}
producer.join();
CHECK(producerOk.load());
CHECK(mismatches.load() == 0);
// xruns are EXPECTED here: the spin-retry producer deliberately hammers a
// full ring (the real host audio thread would drop instead), so xruns
// climbing just means back-pressure worked — not asserted. What matters is
// that every block arrived exactly once, in order, with its MIDI intact.
}
} // namespace
int main()
{
std::printf("=== audio_channel_midi_test ===\n");
testRoundtripSmallBuffer();
testSysExBumpsOverflow();
testOverCapBumpsOverflow();
testFramePastSamplesDropped();
testNumSamplesOverCapRejected();
testSlotReuseAcrossWraparound();
testThreadedProducerConsumer();
std::printf("\n%d passed, %d failed\n", g_passed, g_failed);
return g_failed == 0 ? 0 : 1;
}
+218
View File
@@ -0,0 +1,218 @@
// control_channel_test — exercise the ControlChannel request/reply/event
// machinery + transport over an in-process loopback, without spawning a
// subprocess. The "host" (server) and "sandbox" (client) ControlChannels are
// wired together through the POSIX socketpair handoff (createServerSide →
// sandboxFd → connectClientSideFd), so the framing, the poll()-driven I/O
// thread, the pending-promise map, and peer-close detection all run for real.
//
// POSIX-only: it uses connectClientSideFd / sandboxFd (the Windows transport
// is a named pipe re-opened by name and is covered by its own path).
#include <juce_core/juce_core.h>
#include "../../src/audio/Sandbox/Protocol.h"
#include "../../src/audio/Sandbox/ControlChannel.h"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>
using namespace slopsmith::sandbox;
namespace {
int g_failed = 0;
int g_passed = 0;
void check(bool cond, const char* what, const char* file, int line)
{
if (cond) { ++g_passed; return; }
++g_failed;
std::fprintf(stderr, " FAIL: %s (%s:%d)\n", what, file, line);
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
#define REQUIRE(cond) \
do { if (!(cond)) { check(false, #cond, __FILE__, __LINE__); return; } } while (0)
// Spin-wait up to timeoutMs for a predicate to hold. Keeps the tests free of
// fixed sleeps that would be either flaky or slow.
template <typename Pred>
bool waitFor(Pred p, int timeoutMs = 2000)
{
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(timeoutMs);
while (std::chrono::steady_clock::now() < deadline)
{
if (p()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return p();
}
// A connected host+sandbox ControlChannel pair over a socketpair. The sandbox
// side installs an echo request handler; the host side records events +
// disconnects.
struct ChannelPair
{
ControlChannel host; // server
ControlChannel sandbox; // client
juce::String err;
bool ok = false;
std::atomic<int> hostEventCount{0};
juce::String lastEvent;
std::atomic<int> sandboxRequestCount{0};
std::atomic<bool> hostDisconnected{false};
juce::String hostDisconnectReason;
ChannelPair()
{
juce::String unusedName;
if (!host.createServerSide(unusedName, err)) return;
if (!sandbox.connectClientSideFd(host.sandboxFd(), err)) return;
// Sandbox echoes "ping" args back; rejects anything else; counts
// fire-and-forget "noop". Must be installed before start().
sandbox.setRequestHandler([this](int id, const juce::String& op,
const juce::var& args)
{
++sandboxRequestCount;
if (id < 0) return; // fire-and-forget, no reply
if (op == "ping") sandbox.sendReply(id, true, args);
else sandbox.sendReply(id, false, {}, "unknown op");
});
const bool sb = sandbox.start(
/*onEvent*/ [](const juce::String&, const juce::var&) {},
/*onDisconnect*/ [](const juce::String&) {});
const bool hb = host.start(
[this](const juce::String& ev, const juce::var&)
{
lastEvent = ev;
++hostEventCount;
},
[this](const juce::String& reason)
{
hostDisconnectReason = reason;
hostDisconnected.store(true);
});
ok = sb && hb;
if (!ok)
err = "start failed: host=" + host.getLastStartError()
+ " sandbox=" + sandbox.getLastStartError();
}
~ChannelPair()
{
// Stop both channels (joining their I/O threads) BEFORE the recording
// members below are destroyed — the I/O threads' onEvent/onDisconnect
// callbacks capture `this` and write those members. Stop the host
// first: host.stop() clears `alive`, so a teardown-triggered failWith
// becomes a no-op and never touches our fields. This mirrors the real
// SandboxedProcessor::teardown ordering invariant.
host.stop();
sandbox.stop();
}
};
void testRequestReply()
{
std::printf("test: request/reply round-trip (echo)\n");
ChannelPair pair;
REQUIRE(pair.ok);
juce::DynamicObject::Ptr argObj(new juce::DynamicObject());
argObj->setProperty("n", 42);
argObj->setProperty("s", "hello");
juce::String reqErr;
juce::var result = pair.host.request("ping", juce::var(argObj.get()),
/*timeoutMs*/ 2000, &reqErr);
CHECK(reqErr.isEmpty());
CHECK(result.isObject());
CHECK((int)result.getProperty("n", -1) == 42);
CHECK(result.getProperty("s", "").toString() == "hello");
}
void testRequestError()
{
std::printf("test: request to unknown op returns error\n");
ChannelPair pair;
REQUIRE(pair.ok);
juce::String reqErr;
juce::var result = pair.host.request("nope", juce::var(), 2000, &reqErr);
CHECK(result.isVoid());
CHECK(reqErr == "unknown op");
}
void testEvent()
{
std::printf("test: sandbox-originated event reaches host\n");
ChannelPair pair;
REQUIRE(pair.ok);
juce::DynamicObject::Ptr data(new juce::DynamicObject());
data->setProperty("pluginName", "TestPlug");
CHECK(pair.sandbox.sendEvent(event::kReady, juce::var(data.get())));
CHECK(waitFor([&] { return pair.hostEventCount.load() >= 1; }));
CHECK(pair.lastEvent == juce::String(event::kReady));
}
void testPostNoReply()
{
std::printf("test: fire-and-forget reaches the sandbox handler\n");
ChannelPair pair;
REQUIRE(pair.ok);
CHECK(pair.host.postNoReply("noop", juce::var()));
CHECK(waitFor([&] { return pair.sandboxRequestCount.load() >= 1; }));
}
void testManyRequests()
{
std::printf("test: 500 sequential requests, all matched\n");
ChannelPair pair;
REQUIRE(pair.ok);
int okCount = 0;
for (int i = 0; i < 500; ++i)
{
juce::DynamicObject::Ptr a(new juce::DynamicObject());
a->setProperty("n", i);
juce::String e;
juce::var r = pair.host.request("ping", juce::var(a.get()), 2000, &e);
if (e.isEmpty() && (int)r.getProperty("n", -1) == i) ++okCount;
}
CHECK(okCount == 500);
}
void testPeerClosedDetected()
{
std::printf("test: sandbox close → host sees peer-closed disconnect\n");
ChannelPair pair;
REQUIRE(pair.ok);
// Tear down the sandbox end; the host I/O thread should read EOF and
// classify it as a clean peer close (not a read error).
pair.sandbox.stop();
CHECK(waitFor([&] { return pair.hostDisconnected.load(); }));
CHECK(pair.hostDisconnectReason == ControlChannel::kReasonPeerClosed);
}
} // namespace
int main()
{
std::printf("=== control_channel_test ===\n");
testRequestReply();
testRequestError();
testEvent();
testPostNoReply();
testManyRequests();
testPeerClosedDetected();
std::printf("\n%d passed, %d failed\n", g_passed, g_failed);
return g_failed == 0 ? 0 : 1;
}
+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(); }
+79
View File
@@ -0,0 +1,79 @@
// spawn_smoke_child — the child half of spawn_smoke_test. Stands in for the
// real slopsmith-vst-host (Slice 2) with the bare minimum: adopt the inherited
// control-socket fd, answer a couple of control ops, and exit. Exercises the
// SubprocessHandle POSIX spawn + fd-inheritance + ControlChannel handshake
// end-to-end across a real process boundary.
//
// --control-fd N the dup2()'d socketpair end the parent passed us
//
// Ops it understands (host → child):
// ping → echo args back (round-trip proof)
// exit → reply ok, then exit(0) (clean-shutdown proof)
// abort → std::abort() (crash-detection proof; no reply)
#include <juce_core/juce_core.h>
#include "../../src/audio/Sandbox/Protocol.h"
#include "../../src/audio/Sandbox/ControlChannel.h"
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <thread>
using namespace slopsmith::sandbox;
int main(int argc, char** argv)
{
int controlFd = -1;
for (int i = 1; i < argc - 1; ++i)
if (juce::String(argv[i]) == "--control-fd")
controlFd = juce::String(argv[i + 1]).getIntValue();
if (controlFd < 0)
{
std::fprintf(stderr, "spawn_smoke_child: missing --control-fd\n");
return 2;
}
ControlChannel ctl;
juce::String err;
if (!ctl.connectClientSideFd(controlFd, err))
{
std::fprintf(stderr, "spawn_smoke_child: connect failed: %s\n",
err.toRawUTF8());
return 3;
}
std::atomic<bool> quit{false};
ctl.setRequestHandler([&](int id, const juce::String& op,
const juce::var& args)
{
if (op == "ping") { if (id >= 0) ctl.sendReply(id, true, args); }
else if (op == "exit") { if (id >= 0) ctl.sendReply(id, true, {});
quit.store(true); }
else if (op == "abort") { std::abort(); } // crash on purpose
else { if (id >= 0) ctl.sendReply(id, false, {},
"unknown op"); }
});
if (!ctl.start(/*onEvent*/ [](const juce::String&, const juce::var&) {},
/*onDisconnect*/ [&](const juce::String&) { quit.store(true); }))
{
std::fprintf(stderr, "spawn_smoke_child: start failed: %s\n",
ctl.getLastStartError().toRawUTF8());
return 4;
}
// Announce readiness, then run until told to exit / the parent drops the
// pipe. The 30 s safety deadline keeps a buggy test from leaving a zombie.
ctl.sendEvent(event::kReady, juce::var());
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::seconds(30);
while (!quit.load() && std::chrono::steady_clock::now() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
ctl.stop();
return 0;
}
+192
View File
@@ -0,0 +1,192 @@
// spawn_smoke_test — end-to-end across a real process boundary: posix_spawn a
// child (spawn_smoke_child), hand it the sandbox end of a control socketpair by
// fd inheritance, and drive it over ControlChannel. Validates the pieces the
// in-process loopback tests can't: SubprocessHandle::startPosix, fd
// inheritance, exit-code detection (clean + crash), and SIGPIPE suppression on
// a write to a dead peer.
//
// SPAWN_CHILD_PATH is injected by CMake as the absolute path to the child exe.
#include <juce_core/juce_core.h>
#include "../../src/audio/Sandbox/Protocol.h"
#include "../../src/audio/Sandbox/ControlChannel.h"
#include "../../src/audio/Sandbox/SubprocessHandle.h"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>
using namespace slopsmith::sandbox;
namespace {
int g_failed = 0;
int g_passed = 0;
void check(bool c, const char* what, const char* file, int line)
{
if (c) { ++g_passed; return; }
++g_failed;
std::fprintf(stderr, " FAIL: %s (%s:%d)\n", what, file, line);
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
#define REQUIRE(cond) \
do { if (!(cond)) { check(false, #cond, __FILE__, __LINE__); return; } } while (0)
template <typename Pred>
bool waitFor(Pred p, int timeoutMs = 5000)
{
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(timeoutMs);
while (std::chrono::steady_clock::now() < deadline)
{
if (p()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
return p();
}
#ifndef SPAWN_CHILD_PATH
#error "SPAWN_CHILD_PATH must be defined by the build (path to spawn_smoke_child)."
#endif
// childFd 3 is the first fd past stdin/stdout/stderr; the child reads the
// number from argv.
constexpr int kChildControlFd = 3;
// Bring up the host control channel and spawn a child wired to it, in the
// required order: createServerSide (makes the socketpair) → host.start (begins
// reading) → startPosix (dup2()s the sandbox fd into the child) → closeSandboxFd
// (drop our copy so the host sees EOF on child death). Returns false on
// failure. `onExit` records the child's exit code.
bool spawnChild(ControlChannel& host, SubprocessHandle& sub,
ControlChannel::EventCallback onEvent,
std::function<void(const juce::String&)> onDisconnect,
std::atomic<int>& exitCode, std::atomic<bool>& exited)
{
juce::String unusedName, err;
if (!host.createServerSide(unusedName, err)) return false;
if (!host.start(std::move(onEvent), std::move(onDisconnect)))
{
std::fprintf(stderr, " spawnChild: host.start failed: %s\n",
host.getLastStartError().toRawUTF8());
return false;
}
juce::StringArray args;
args.add("--control-fd");
args.add(juce::String(kChildControlFd));
std::vector<SubprocessHandle::InheritedFd> inherited{
{ kChildControlFd, host.sandboxFd() }
};
const bool ok = sub.startPosix(SPAWN_CHILD_PATH, args, inherited,
[&](int code) { exitCode.store(code); exited.store(true); }, err);
// The host has its own end; close our copy of the child's end so the host
// observes EOF when the child dies (otherwise crash detection never fires).
host.closeSandboxFd();
if (!ok)
std::fprintf(stderr, " spawnChild: startPosix failed: %s\n",
err.toRawUTF8());
return ok;
}
void testSpawnHandshakeAndCleanExit()
{
std::printf("test: spawn → ready handshake → ping round-trip → clean exit\n");
ControlChannel host;
SubprocessHandle sub;
std::atomic<int> exitCode{-999};
std::atomic<bool> exited{false};
std::atomic<bool> gotReady{false};
REQUIRE(spawnChild(host, sub,
[&](const juce::String& ev, const juce::var&)
{ if (ev == juce::String(event::kReady)) gotReady.store(true); },
[](const juce::String&) {},
exitCode, exited));
// fd inheritance + child connect + event delivery across the process line.
CHECK(waitFor([&] { return gotReady.load(); }));
// Bidirectional round-trip over the inherited socket.
juce::DynamicObject::Ptr a(new juce::DynamicObject());
a->setProperty("n", 7);
juce::String e;
juce::var r = host.request("ping", juce::var(a.get()), 3000, &e);
CHECK(e.isEmpty());
CHECK((int)r.getProperty("n", -1) == 7);
// Ask the child to exit cleanly; watcher should report code 0.
host.request("exit", juce::var(), 3000, &e);
CHECK(waitFor([&] { return exited.load(); }));
CHECK(exitCode.load() == 0);
host.stop();
}
void testCrashDetection()
{
std::printf("test: child abort() → watcher reports a non-zero exit\n");
ControlChannel host;
SubprocessHandle sub;
std::atomic<int> exitCode{-999};
std::atomic<bool> exited{false};
std::atomic<bool> disconnected{false};
REQUIRE(spawnChild(host, sub,
[](const juce::String&, const juce::var&) {},
[&](const juce::String&) { disconnected.store(true); },
exitCode, exited));
// Fire-and-forget abort: the child crashes without replying.
host.postNoReply("abort", juce::var());
CHECK(waitFor([&] { return exited.load(); }));
CHECK(exitCode.load() != 0); // SIGABRT → 128 + 6 = 134
// The host's I/O thread should also see the pipe drop.
CHECK(waitFor([&] { return disconnected.load(); }));
host.stop();
}
void testWriteToDeadPeerNoSigpipe()
{
std::printf("test: writing to a dead child returns false, no SIGPIPE\n");
ControlChannel host;
SubprocessHandle sub;
std::atomic<int> exitCode{-999};
std::atomic<bool> exited{false};
REQUIRE(spawnChild(host, sub,
[](const juce::String&, const juce::var&) {},
[](const juce::String&) {},
exitCode, exited));
// Kill the child and wait for the exit to be observed.
host.request("exit", juce::var(), 3000);
CHECK(waitFor([&] { return exited.load(); }));
// Give the host I/O thread a moment to mark the channel not-alive.
waitFor([&] { return !host.isAlive(); }, 2000);
// Writing now must fail gracefully — NOT raise SIGPIPE and kill us. If
// SIGPIPE weren't suppressed this process would have died before here.
const bool posted = host.postNoReply("ping", juce::var());
CHECK(!posted);
std::printf(" (survived the write to a dead peer; SIGPIPE suppressed)\n");
host.stop();
}
} // namespace
int main()
{
std::printf("=== spawn_smoke_test ===\n");
testSpawnHandshakeAndCleanExit();
testCrashDetection();
testWriteToDeadPeerNoSigpipe();
std::printf("\n%d passed, %d failed\n", g_passed, g_failed);
return g_failed == 0 ? 0 : 1;
}
+30
View File
@@ -0,0 +1,30 @@
# Standalone bootstrap for the sandbox IPC tests — JUCE only, no cmake-js /
# node-addon-api / ONNX. Lets a Linux-only developer (and the lightweight
# `sandbox.yml` CI job) build + run the loopback / control / spawn tests
# without configuring the whole native addon.
#
# Usage (from the repo root):
# cmake -S tests/sandbox/standalone -B build/sandbox -DCMAKE_BUILD_TYPE=Debug
# cmake --build build/sandbox
# ctest --test-dir build/sandbox --output-on-failure
#
# Sanitized variants:
# cmake -S tests/sandbox/standalone -B build/sandbox-tsan -DSLOPSMITH_SANITIZE=thread
cmake_minimum_required(VERSION 3.22)
project(slopsmith_sandbox_tests CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# This file lives at tests/sandbox/standalone — the repo root is three up.
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()
add_subdirectory("${REPO_ROOT}/JUCE" juce_build)
enable_testing()
# Reuse the single source-of-truth test definitions.
add_subdirectory("${REPO_ROOT}/tests/sandbox" sandbox_tests)