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