C++ Testing Guide
Building Test Binaries
# Build the shared library
cargo build --no-default-features -p horus_cpp
# Compile a C++ test
g++ -std=c++17 -fext-numeric-literals \
-I horus_cpp/include \
-o my_test tests/my_test.cpp \
-L target/debug -lhorus_cpp -lpthread -ldl -lm
# Run
LD_LIBRARY_PATH=target/debug ./my_test
Writing C++ Tests
Use a simple CHECK macro pattern:
#include <horus/horus.hpp>
static int pass = 0, fail = 0;
#define CHECK(cond, name) do { \
if (cond) { printf("[PASS] %s\n", name); pass++; } \
else { printf("[FAIL] %s\n", name); fail++; } \
} while(0)
void test_pubsub() {
horus::Publisher<horus::msg::CmdVel> pub("test.cmd");
horus::Subscriber<horus::msg::CmdVel> sub("test.cmd");
horus::msg::CmdVel msg{}; msg.linear = 1.5f;
pub.send(msg);
auto recv = sub.recv();
CHECK(recv.has_value(), "message received");
CHECK(recv->get()->linear == 1.5f, "field preserved");
}
int main() {
test_pubsub();
printf("Results: %d passed, %d failed\n", pass, fail);
return fail > 0 ? 1 : 0;
}
Sanitizers
Four of the CI gates are sanitizer runs. Each rebuilds the same sources with different flags, so reproduce a failing job locally by recompiling your test the same way.
AddressSanitizer
Heap and stack overflows, use-after-free:
g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g \
-I horus_cpp/include \
-o my_test_asan tests/my_test.cpp \
-L target/debug -lhorus_cpp -lpthread -ldl -lm
LD_LIBRARY_PATH=target/debug ASAN_OPTIONS=detect_leaks=0 ./my_test_asan
UndefinedBehaviorSanitizer
Integer overflow, misalignment, null deref, out-of-range enums:
g++ -std=c++17 -fsanitize=undefined -fno-sanitize-recover=all \
-fno-omit-frame-pointer -g \
-I horus_cpp/include \
-o my_test_ubsan tests/my_test.cpp \
-L target/debug -lhorus_cpp -lpthread -ldl -lm
LD_LIBRARY_PATH=target/debug \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1:abort_on_error=1 ./my_test_ubsan
Rust exposes no undefined sanitizer, so this instruments the C++ side only and
libhorus_cpp is built normally.
ThreadSanitizer
Races across the FFI boundary only surface if both sides are instrumented, so the Rust library has to be rebuilt on nightly first:
RUSTFLAGS=-Zsanitizer=thread cargo +nightly build -Zbuild-std \
--target x86_64-unknown-linux-gnu \
--no-default-features -p horus_cpp
g++ -std=c++17 -fsanitize=thread -fPIE -g -fno-omit-frame-pointer \
-I horus_cpp/include \
-o my_test_tsan tests/my_test.cpp \
-L target/x86_64-unknown-linux-gnu/debug -lhorus_cpp -lpthread -ldl -lm -pie
LD_LIBRARY_PATH=target/x86_64-unknown-linux-gnu/debug \
TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1:history_size=7 ./my_test_tsan
ASAN and TSan cannot coexist in one build — that is why CI runs them as separate jobs.
Valgrind memcheck
Uninitialized reads and heap-range errors ASAN misses, notably inside the
mmap'd SHM regions. It is 10-50x slower than native, so CI points it at a
single test:
g++ -std=c++17 -g -O0 -fno-omit-frame-pointer \
-I horus_cpp/include \
-o my_test_vg tests/my_test.cpp \
-L target/debug -lhorus_cpp -lpthread -ldl -lm
LD_LIBRARY_PATH=target/debug valgrind --tool=memcheck --error-exitcode=1 \
--leak-check=full --show-leak-kinds=definite,indirect \
--errors-for-leak-kinds=definite,indirect \
--track-origins=yes ./my_test_vg
Stress Testing
Test stability under load — but note the callback ceiling first.
NodeBuilder::build() hands any node that registers a tick, init,
safe_state, or shutdown callback one of 32 slots in a fixed trampoline
table, and the slot counter is only ever incremented on success — destroying
the Scheduler or the NodeBuilder does not give the slot back. The counter
is a C++17 inline variable, so there is exactly one of it for the whole
program, not one per translation unit. The 33rd callback node built by a
process throws horus::Error("Too many nodes (max 32 with callbacks)"), no
matter how many schedulers have come and gone in between. Nodes built without
any callback consume no slot and are unlimited.
// 1000 scheduler create/destroy cycles.
// No callback, so no trampoline slot is consumed and the loop can run
// unbounded — this exercises scheduler construction and teardown only.
for (int i = 0; i < 1000; i++) {
horus::Scheduler sched;
sched.add("node").build();
sched.tick_once();
}
// 32 nodes with 100 ticks each — 32 is the hard ceiling for callback nodes,
// and it is a per-process lifetime budget, not a per-scheduler one.
{
horus::Scheduler sched;
for (int i = 0; i < 32; i++) {
sched.add(("node_" + std::to_string(i)).c_str())
.tick([]{ }).build();
}
for (int i = 0; i < 100; i++) sched.tick_once();
}
To stress callback dispatch beyond 32 nodes, split the work across separate test binaries — each process gets its own budget of 32.
Cross-Process Testing
Test IPC between separate processes:
# Terminal 1: subscriber (start first)
LD_LIBRARY_PATH=target/debug ./cross_process_sub "test.topic"
# Terminal 2: publisher
LD_LIBRARY_PATH=target/debug ./cross_process_pub "test.topic"
The subscriber must start first — it creates the SHM ring buffer that both processes share.
Fuzzing and Coverage
The four libFuzzer targets live in horus_cpp/fuzz/fuzz_targets/. CI gives each
one 60 seconds; locally, run one for as long as you want:
cd horus_cpp
cargo +nightly fuzz run fuzz_topic_send_recv -- -max_total_time=60
Coverage is measured twice: cargo llvm-cov for the Rust side, and lcov over
a --coverage CMake build of the C++ tests for the headers. Both floors are
70% lines. The Rust half reproduces in one command:
cargo llvm-cov --no-default-features -p horus_cpp \
--fail-under-lines 70 -- --test-threads=1
CI Integration
The .github/workflows/cpp-bindings.yml pipeline runs:
- Rust FFI tests (139 tests)
- C++ compilation (15 binaries)
- C++ unit tests (e2e, ergonomic, full API, user API, stress)
- Cross-process IPC (CmdVel, JSON, Service, Action)
- Cross-language IPC (C++ ↔ Python,
horus_cpp/tests/cross_lang_tri_test.sh) - ASAN (stress + full API under AddressSanitizer)
- TSan (stress + ergonomic e2e; Rust rebuilt on nightly with
-Zbuild-stdandRUSTFLAGS=-Zsanitizer=thread) - UBSan (full API + ergonomic e2e,
-fsanitize=undefined -fno-sanitize-recover=all) - Valgrind memcheck (ergonomic e2e,
--leak-check=full --track-origins=yes) - libFuzzer smoke (4 targets × 60 s:
fuzz_json_service,fuzz_params,fuzz_topic_send_recv,fuzz_service_roundtrip) - Coverage (
cargo llvm-cov+ lcov; 70% line floor for both Rust and C++, merged report uploaded to Codecov under flaghorus_cpp) - Benchmarks (release-mode Criterion,
cargo bench)
The cpp-success gate requires every job above except the benchmark, whose result is reported but not enforced. (Items 2 and 3 are two halves of the same cpp-compile-test job, so the workflow file defines 11 jobs plus the gate.)