diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e1d95a..b9ea65e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,11 @@ jobs: - name: Run unit + realtime tests under RTSan run: ctest --test-dir build-rtsan --output-on-failure + # Secondary RT signal: worst-case parse/serialize latency. Informational + # (printed to the log, not gating) -- a regression shows up as a number. + - name: Realtime latency benchmark (informational) + run: ./build-rtsan/OscLatencyBench + freestanding: name: Freestanding profile (${{ matrix.cxx }}, -fno-exceptions -fno-rtti) runs-on: ubuntu-latest @@ -190,7 +195,7 @@ jobs: - name: Cross-compile the socket loopback tests for win32 run: | - for t in OscUdpTest OscTcpTest; do + for t in OscUdpTest OscTcpTest OscMulticastTest; do x86_64-w64-mingw32-g++ -std=c++17 -O1 -static -I . -I osctap \ tests/$t.cpp -o $t.exe -lws2_32 -lwinmm done @@ -205,7 +210,7 @@ jobs: run: | wineboot -i || true wineserver -w || true - for t in OscUdpTest OscTcpTest; do + for t in OscUdpTest OscTcpTest OscMulticastTest; do echo "=== $t (win32, under Wine) ===" wine ./$t.exe done diff --git a/CMakeLists.txt b/CMakeLists.txt index dbef9c4..9487b9c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,12 @@ if(OSCPACK_BUILD_EXAMPLES) target_link_options(OscRealtimeTest PRIVATE -fsanitize=realtime) endif() + # Worst-case parse/serialize latency benchmark -- the secondary RT signal (the + # primary one is the compiler-checked OscRealtimeTest). Built here; not a ctest + # (it asserts nothing, just prints a distribution). The rtsan CI job runs it. + add_executable(OscLatencyBench tests/OscLatencyBench.cpp) + target_link_libraries(OscLatencyBench oscpack) + # Concurrency test for the receive loop: Run() on one thread vs # AsynchronousBreak() (+ a loopback packet) from another. Runs as a plain # functional test on POSIX; with -DOSCTAP_TSAN=ON it is built under @@ -104,7 +110,7 @@ if(OSCPACK_BUILD_EXAMPLES) # TCP segments to exercise deframer reassembly). Both SKIP gracefully if the # environment forbids loopback networking. POSIX-only (std::thread + sockets); # also built under the TSan job to vet Run() vs AsynchronousBreak(). - foreach(socktest OscUdpTest OscTcpTest) + foreach(socktest OscUdpTest OscTcpTest OscMulticastTest) add_executable(${socktest} tests/${socktest}.cpp) target_link_libraries(${socktest} oscpack Threads::Threads) add_test(NAME ${socktest} COMMAND ${socktest}) diff --git a/ROADMAP.md b/ROADMAP.md index b590443..22a1342 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,10 +5,11 @@ OscTap is the actively-maintained, security-hardened, modern-C++ continuation of document is the source of truth for the rebrand and the work plan. See [`docs/HERITAGE.md`](docs/HERITAGE.md) for lineage and credits. -> Status: Phase 0 and Phase 1 complete. Phase 2 ("Reach") underway — freestanding -> profile, aarch64/Pi 5 CI, and the Pi 5 ⇄ Pico 2W ⇄ Android integration (demos + -> tutorial + Android JNI bridge) have landed (see Phase 2 below). Remaining: -> multicast, armv7, and a full Android sample app. +> Status: Phase 0 and Phase 1 complete. Phase 2 ("Reach") well underway — freestanding +> profile + non-throwing validation, aarch64/Pi 5 CI, OSC-over-TCP (v1), win32 runtime +> testing (Wine), code-coverage gate, and the Pi 5 ⇄ Pico 2W ⇄ Android integration have +> landed (see Phase 2 below). Remaining: multicast, armv7, a full Android sample app, +> and OSS-Fuzz submission (#7, a Phase 1 tail). ## Why OscTap exists @@ -87,8 +88,8 @@ rename is the first item of Phase 1, below. under `osctap/` and use the `` prefix; the old `` paths are preserved by a redirect shim tree under `oscpack/` (each header forwards to its `` counterpart). `tests/CompatIncludeShim.cpp` is the CI-built guard for - both the include-path shim and the `oscpack::` namespace alias. Deferred: renaming - the cosmetic `INCLUDED_OSCPACK_*` include guards. + both the include-path shim and the `oscpack::` namespace alias. (The cosmetic + `INCLUDED_OSCPACK_*` include guards were later renamed to `INCLUDED_OSCTAP_*`.) - [x] **ClusterFuzzLite** — in-repo continuous fuzzing (OSS-Fuzz's CI-driven sibling). `.clusterfuzzlite/` (Dockerfile + build.sh over the existing `fuzz/` harness + seed corpus) plus two workflows: per-PR code-change fuzzing (`cflite_pr.yml`) and a daily @@ -111,18 +112,21 @@ rename is the first item of Phase 1, below. `OSCTAP_WARNINGS_AS_ERRORS` option (default OFF so downstream consumers of the INTERFACE library are not forced onto our warning bar). The Clang warning flags now match GCC's, and the win32 `GetHostByName` was ported to `getaddrinfo` (mirroring the - posix backend). Deferred: the uncompiled `ip/*/UdpSocket.h` socket backends still use - `strcpy`/`gethostbyname` and will be cleaned when they enter the compiled CI surface. + posix backend). The `ip/*/UdpSocket.h` socket backends have since entered the compiled + CI surface (demos + win32 smoke/Wine) and are `-Werror`/`/W4`-clean; no `strcpy`/ + `gethostbyname` remain. - [x] **RTSan**: the read/dispatch hot path (iterating and reading a known-valid message via the throw-free `*Unchecked` accessors) is annotated `OSCTAP_REALTIME` (`noexcept [[clang::nonblocking]]` on Clang ≥ 20). A dedicated Clang-20 CI job builds `tests/OscRealtimeTest.cpp` with `-fsanitize=realtime` (runtime) **and** `-Wfunction-effects -Werror` (static), so the contract is enforced both ways. The validating/throwing surface (message construction/`Init()`, the checked accessors, - `AsBoolUnchecked`/`AsBlobUnchecked`, and serialization's overflow check) is - deliberately left off the contract — it runs off the audio thread. - Deferred: a non-throwing realtime blob accessor, and recording worst-case latency as - a secondary benchmark. + and serialization's overflow check) is deliberately left off the contract — it runs + off the audio thread. **Every `*Unchecked` read accessor is now throw-free and on the + contract**, including `AsBoolUnchecked` and the blob accessor `AsBlobUnchecked` (they + trust the validation done at construction). Worst-case parse/serialize latency is + recorded by `tests/OscLatencyBench.cpp` — the secondary signal the strategy calls for, + run (informationally) in the RTSan CI job. - [x] **TSan**: `tests/OscConcurrencyTest.cpp` runs `SocketReceiveMultiplexer::Run()` on one thread and stops it via `AsynchronousBreak()` from another (signalling in a loop so it can't race ahead of `Run()`'s break-flag reset), plus a best-effort loopback @@ -192,10 +196,11 @@ See [Sanitizer strategy](#sanitizer-strategy) for scope and rationale. `OscUdpTest`/`OscTcpTest` with MinGW and runs them under Wine). See [`docs/OSC_OVER_TCP.md`](docs/OSC_OVER_TCP.md). Deferred: SLIP framing, TLS, WebSocket, and `epoll`. -- [ ] Multicast receive (cherry-pick from `stephram/oscpack`). *(Self-contained; - the next demand-driven feature pickup. Note: the `ip/*/UdpSocket.h` backends - now enter the compiled surface via the demos, so the deferred `strcpy`/ - `gethostbyname` cleanup from Phase 1 #4 can ride along here.)* +- [x] **Multicast receive** — `UdpSocket::JoinMulticastGroup()` / + `LeaveMulticastGroup()` (IP_ADD/DROP_MEMBERSHIP) on both the posix and win32 + backends, exposed on any UDP receive socket. `tests/OscMulticastTest.cpp` is a + real loopback test (join a group, send OSC to it, receive — skip-resilient), + ASan/UBSan-clean and also runtime-tested on win32 under Wine. ## Milestones → GitHub diff --git a/docs/API.md b/docs/API.md index 10b7074..0713186 100644 --- a/docs/API.md +++ b/docs/API.md @@ -136,6 +136,9 @@ Abstract base: `virtual void ProcessPacket(const char* data, int size, const IpE - `UdpTransmitSocket(const IpEndpointName& remote)` — `Send(data, size)`, `SendTo(to, data, size)`. - `UdpReceiveSocket`, and `UdpListeningReceiveSocket(local, PacketListener*)` — `Run()` (blocks), `Break()`, `AsynchronousBreak()`, `int LocalPort()`. +- **Multicast** (any UDP socket, after `Bind()`): `JoinMulticastGroup(const IpEndpointName&)` + / `LeaveMulticastGroup(...)` — IP_ADD/DROP_MEMBERSHIP for an IPv4 group + (224.0.0.0–239.255.255.255). Closing the socket leaves joined groups automatically. - `SocketReceiveMultiplexer` — multiple sockets/timers in one `Run()` loop (`AttachSocketListener`, `AttachPeriodicTimerListener`, …). diff --git a/docs/STATUS.md b/docs/STATUS.md index 950176f..58bc868 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -77,9 +77,11 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin - **`OSCTAP_REALTIME` marks the realtime hot path** (`OscTypes.h`). It is `noexcept [[clang::nonblocking]]` on Clang ≥ 20 and a **no-op everywhere else**, so it must stay applied only to genuinely allocation-/throw-free functions — the read/iterate - path over a *known-valid* message. **Do not annotate anything that can throw or allocate** - (message construction/`Init()`, checked accessors, `AsBoolUnchecked`/`AsBlobUnchecked`, - serialization): the Clang-20 RTSan job (`-DOSCTAP_RTSAN=ON`) will fail it both at runtime + path over a *known-valid* message. Every `*Unchecked` read accessor (incl. `AsBoolUnchecked` + and `AsBlobUnchecked`, which trust the validation done at construction) is throw-free and + carries `OSCTAP_REALTIME`. **Do not annotate anything that can throw or allocate** (message + construction/`Init()`, the *checked* accessors, serialization's overflow check): the + Clang-20 RTSan job (`-DOSCTAP_RTSAN=ON`) will fail it both at runtime (`-fsanitize=realtime`) and statically (`-Wfunction-effects -Werror`). `tests/OscRealtimeTest.cpp` is the guard and also runs as a plain functional test on the rest of the matrix. Local RTSan needs Clang ≥ 20 (`apt-get install clang-20 libclang-rt-20-dev`). @@ -161,6 +163,10 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin socket (no `pipe()` on Windows). NB the TCP backend headers must be **self-contained** (they `#include ` explicitly) — including `TcpSocket.h` without `UdpSocket.h` first previously failed on win32; the Wine job guards that. +- **Multicast receive**: `UdpSocket::JoinMulticastGroup()`/`LeaveMulticastGroup()` + (IP_ADD/DROP_MEMBERSHIP) on both backends. `OscMulticastTest` (POSIX-only, skip- + resilient like the other socket tests; also Wine-tested on win32) joins a group, sends + OSC to it, and asserts receipt. Bind the socket to the port *before* joining. - **Socket loopback tests** (`OscUdpTest`, `OscTcpTest`, POSIX-only): real client+ server asserting that messages arrive and decode. They **SKIP** (print a notice, exit 0) if the environment denies loopback networking, so they don't false-fail on @@ -228,6 +234,7 @@ See `ROADMAP.md` Phase 1 for the complete list, the sanitizer strategy, and rati renamed from `tap/oscpack`; old URLs redirect. - The README CI badge tracks the **default branch**; it lights up once this work merges to the default branch. -- Phase 1 milestones/issues are **not yet created**. The plan (per the locked decision) - is: `ROADMAP.md` is the source of truth, decomposed into GitHub milestones/issues for - tracking. +- Phase 1/2 work is tracked as GitHub **issues** (#3–#8 for Phase 1; #14–#19 for Phase 2), + with `ROADMAP.md` as the source of truth (the locked decision). The **"Phase 2 — Reach" + milestone object** is still an owner action — the GitHub MCP tooling can't create + milestones. diff --git a/osctap/ip/AbstractUdpSocket.h b/osctap/ip/AbstractUdpSocket.h index 0853f5d..f3afaff 100644 --- a/osctap/ip/AbstractUdpSocket.h +++ b/osctap/ip/AbstractUdpSocket.h @@ -138,6 +138,21 @@ class UdpSocket{ impl_.SetAllowReuse( allowReuse ); } + // Join (or later leave) an IPv4 multicast group on this socket so it receives + // datagrams sent to that group address (224.0.0.0 .. 239.255.255.255). Bind() + // the socket to the listening port first; the group is taken from the + // IpEndpointName and the default interface is used. Closing the socket leaves + // any joined groups automatically, so LeaveMulticastGroup() is only needed to + // stop receiving a group while keeping the socket open. + void JoinMulticastGroup( const IpEndpointName& multicastGroup ) + { + impl_.JoinMulticastGroup( multicastGroup ); + } + void LeaveMulticastGroup( const IpEndpointName& multicastGroup ) + { + impl_.LeaveMulticastGroup( multicastGroup ); + } + // The socket is created in an unbound, unconnected state // such a socket can only be used to send to an arbitrary diff --git a/osctap/ip/posix/UdpSocket.h b/osctap/ip/posix/UdpSocket.h index bd33a1d..8760de3 100644 --- a/osctap/ip/posix/UdpSocket.h +++ b/osctap/ip/posix/UdpSocket.h @@ -116,6 +116,14 @@ class UdpSocketImplementation{ throw std::runtime_error("unable to create udp socket\n"); } +#ifdef SO_NOSIGPIPE + // macOS / BSD: a send() to an unreachable destination (e.g. an unrouted + // multicast group) raises SIGPIPE and would kill the process. Suppress it + // so send() returns an error instead, mirroring the TCP backend. + int noSigpipe = 1; + setsockopt( socket_, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, sizeof(noSigpipe) ); +#endif + std::memset( &sendToAddr_, 0, sizeof(sendToAddr_) ); sendToAddr_.sin_family = AF_INET; } @@ -143,6 +151,26 @@ class UdpSocketImplementation{ #endif } + void JoinMulticastGroup( const IpEndpointName& multicastGroup ) + { + struct ip_mreq mreq; + std::memset( &mreq, 0, sizeof(mreq) ); + mreq.imr_multiaddr.s_addr = htonl( multicastGroup.address ); + mreq.imr_interface.s_addr = INADDR_ANY; // default interface + if( setsockopt( socket_, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq) ) < 0 ) + throw std::runtime_error( "unable to join multicast group\n" ); + } + + void LeaveMulticastGroup( const IpEndpointName& multicastGroup ) + { + struct ip_mreq mreq; + std::memset( &mreq, 0, sizeof(mreq) ); + mreq.imr_multiaddr.s_addr = htonl( multicastGroup.address ); + mreq.imr_interface.s_addr = INADDR_ANY; + if( setsockopt( socket_, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq) ) < 0 ) + throw std::runtime_error( "unable to leave multicast group\n" ); + } + IpEndpointName LocalEndpointFor( const IpEndpointName& remoteEndpoint ) const { assert( isBound_ ); diff --git a/osctap/ip/win32/UdpSocket.h b/osctap/ip/win32/UdpSocket.h index 13174a0..ee62614 100644 --- a/osctap/ip/win32/UdpSocket.h +++ b/osctap/ip/win32/UdpSocket.h @@ -48,6 +48,7 @@ #include #include +#include // steady_clock for GetCurrentTimeMs() #include // for memset #include #include @@ -141,6 +142,26 @@ class UdpSocketImplementation{ setsockopt(socket_, SOL_SOCKET, SO_REUSEADDR, &reuseAddr, sizeof(reuseAddr)); } + void JoinMulticastGroup( const IpEndpointName& multicastGroup ) + { + struct ip_mreq mreq; + std::memset( &mreq, 0, sizeof(mreq) ); + mreq.imr_multiaddr.s_addr = htonl( multicastGroup.address ); + mreq.imr_interface.s_addr = INADDR_ANY; // default interface + if( setsockopt( socket_, IPPROTO_IP, IP_ADD_MEMBERSHIP, (const char*)&mreq, sizeof(mreq) ) == SOCKET_ERROR ) + throw std::runtime_error( "unable to join multicast group\n" ); + } + + void LeaveMulticastGroup( const IpEndpointName& multicastGroup ) + { + struct ip_mreq mreq; + std::memset( &mreq, 0, sizeof(mreq) ); + mreq.imr_multiaddr.s_addr = htonl( multicastGroup.address ); + mreq.imr_interface.s_addr = INADDR_ANY; + if( setsockopt( socket_, IPPROTO_IP, IP_DROP_MEMBERSHIP, (const char*)&mreq, sizeof(mreq) ) == SOCKET_ERROR ) + throw std::runtime_error( "unable to leave multicast group\n" ); + } + IpEndpointName LocalEndpointFor( const IpEndpointName& remoteEndpoint ) const { assert( isBound_ ); @@ -295,8 +316,11 @@ class SocketReceiveMultiplexerImplementation { double GetCurrentTimeMs() const { - return timeGetTime(); // FIXME: bad choice if you want to run for more than 40 days - } + // std::chrono::steady_clock (matches the posix backend): monotonic and 64-bit, + // so unlike the old timeGetTime() it does not wrap after ~49 days. + using namespace std::chrono; + return (double)duration_cast( steady_clock::now().time_since_epoch() ).count(); + } public: SocketReceiveMultiplexerImplementation() diff --git a/osctap/osc/OscReceivedElements.h b/osctap/osc/OscReceivedElements.h index 5ac23d8..4b699ad 100644 --- a/osctap/osc/OscReceivedElements.h +++ b/osctap/osc/OscReceivedElements.h @@ -252,14 +252,12 @@ class ReceivedMessageArgument{ else OSCTAP_THROW( WrongArgumentTypeException() ); } - bool AsBoolUnchecked() const + bool AsBoolUnchecked() const OSCTAP_REALTIME { - if( !typeTagPtr_ ) - OSCTAP_THROW( MissingArgumentException() ); - else if( *typeTagPtr_ == TRUE_TYPE_TAG ) - return true; - else - return false; + // Unchecked: assumes a valid bool argument (tag already checked / message + // validated at construction), so it just reads the tag -- throw-free and + // realtime-safe, like the other *Unchecked accessors. + return *typeTagPtr_ == TRUE_TYPE_TAG; } bool IsNil() const { return *typeTagPtr_ == NIL_TYPE_TAG; } @@ -419,15 +417,15 @@ class ReceivedMessageArgument{ else OSCTAP_THROW( WrongArgumentTypeException() ); } - void AsBlobUnchecked( const void*& data, osc_bundle_element_size_t& size ) const + void AsBlobUnchecked( const void*& data, osc_bundle_element_size_t& size ) const OSCTAP_REALTIME { - // read blob size as an unsigned int then validate - osc_bundle_element_size_t sizeResult = (osc_bundle_element_size_t)ToUInt32( argumentPtr_ ); - if( !IsValidElementSizeValue(sizeResult) ) - OSCTAP_THROW( MalformedMessageException("invalid blob size") ); - - size = sizeResult; - data = (void*)(argumentPtr_+ osctap::OSC_SIZEOF_INT32); + // Like the other *Unchecked accessors, this trusts that the message was + // validated at construction: ReceivedMessage::TryInit() bounds-checks every + // blob (valid size AND within the message), so reading the size here without + // re-validating is safe. That makes this throw-free and realtime-safe -- the + // non-throwing blob accessor for the RT read path. + size = (osc_bundle_element_size_t)ToUInt32( argumentPtr_ ); + data = (const void*)( argumentPtr_ + osctap::OSC_SIZEOF_INT32 ); } bool IsArrayBegin() const { return *typeTagPtr_ == ARRAY_BEGIN_TYPE_TAG; } diff --git a/tests/OscLatencyBench.cpp b/tests/OscLatencyBench.cpp new file mode 100644 index 0000000..7d63569 --- /dev/null +++ b/tests/OscLatencyBench.cpp @@ -0,0 +1,111 @@ +/* + OscTap realtime latency benchmark (secondary RT signal). + + The primary realtime guarantee is compiler-checked (OSCTAP_REALTIME + + RealtimeSanitizer / -Wfunction-effects; see OscRealtimeTest.cpp and ROADMAP.md). + This benchmark is the *secondary* signal the ROADMAP calls for: it measures the + worst-case wall-clock latency of the two hot paths over many iterations so a + regression (e.g. an accidental allocation, or a much slower max than median) + shows up as a number. + + It asserts nothing and always exits 0 -- it prints a distribution + (min/median/p99/max ns). Run it standalone: + cmake -S . -B build && cmake --build build --target OscLatencyBench + ./build/OscLatencyBench [iterations] +*/ + +#include "osc/OscReceivedElements.h" +#include "osc/OscOutboundPacketStream.h" + +#include +#include +#include +#include +#include +#include + +using namespace oscpack; + +// volatile sink: keeps the optimiser from deleting the work we are timing. +static volatile int64_t g_sink = 0; + +static std::size_t BuildMessage( char* buf, std::size_t cap ) +{ + OutboundPacketStream p( buf, cap ); + const unsigned char blob[] = { 1, 2, 3, 4, 5, 6, 7, 8 }; + p << BeginMessage( "/bench/path" ) + << (int32_t)42 << 3.14159f << (int64_t)123456789 + << "a-string-argument" << true + << Blob( blob, (osc_bundle_element_size_t)sizeof(blob) ) + << EndMessage(); + return p.Size(); +} + +// The realtime read/dispatch hot path over a known-valid message. +static int64_t ReadHotPath( const ReceivedMessage& m ) +{ + int64_t acc = m.AddressPattern()[0]; + for( ReceivedMessage::const_iterator i = m.ArgumentsBegin(); i != m.ArgumentsEnd(); ++i ){ + switch( i->TypeTag() ){ + case INT32_TYPE_TAG: acc += i->AsInt32Unchecked(); break; + case FLOAT_TYPE_TAG: acc += (int64_t)i->AsFloatUnchecked(); break; + case INT64_TYPE_TAG: acc += i->AsInt64Unchecked(); break; + case STRING_TYPE_TAG: acc += i->AsStringUnchecked()[0]; break; + case TRUE_TYPE_TAG: acc += 1; break; + case BLOB_TYPE_TAG: { + const void* d; osc_bundle_element_size_t s; + i->AsBlobUnchecked( d, s ); + acc += s; + } break; + default: break; + } + } + return acc; +} + +static void Report( const char* label, std::vector& ns ) +{ + std::sort( ns.begin(), ns.end() ); + auto pct = [&]( double p ){ return ns[(std::size_t)(p * (ns.size() - 1))]; }; + std::printf( " %-16s min=%6.0f median=%6.0f p99=%7.0f max=%8.0f ns/op\n", + label, ns.front(), pct(0.5), pct(0.99), ns.back() ); +} + +int main( int argc, char** argv ) +{ + const int N = (argc > 1) ? std::atoi( argv[1] ) : 200000; + using clk = std::chrono::high_resolution_clock; + + char buffer[256]; + const std::size_t size = BuildMessage( buffer, sizeof(buffer) ); + + // --- read/dispatch hot path: read an already-validated message --- + ReceivedMessage m( ReceivedPacket( buffer, size ) ); + for( int i = 0; i < 1000; ++i ) g_sink += ReadHotPath( m ); // warm up + + std::vector readNs; readNs.reserve( N ); + for( int i = 0; i < N; ++i ){ + const auto t0 = clk::now(); + g_sink += ReadHotPath( m ); + const auto t1 = clk::now(); + readNs.push_back( std::chrono::duration( t1 - t0 ).count() ); + } + + // --- serialize: build the message into a buffer --- + char obuf[256]; + for( int i = 0; i < 1000; ++i ) g_sink += (int)BuildMessage( obuf, sizeof(obuf) ); // warm up + + std::vector sendNs; sendNs.reserve( N ); + for( int i = 0; i < N; ++i ){ + const auto t0 = clk::now(); + const std::size_t s = BuildMessage( obuf, sizeof(obuf) ); + const auto t1 = clk::now(); + g_sink += (int64_t)s + obuf[0]; + sendNs.push_back( std::chrono::duration( t1 - t0 ).count() ); + } + + std::printf( "OscLatencyBench (%d iterations; timer overhead included):\n", N ); + Report( "read hot path", readNs ); + Report( "serialize", sendNs ); + return 0; +} diff --git a/tests/OscMulticastTest.cpp b/tests/OscMulticastTest.cpp new file mode 100644 index 0000000..78d61b8 --- /dev/null +++ b/tests/OscMulticastTest.cpp @@ -0,0 +1,126 @@ +/* + OscTap multicast-receive loopback test (POSIX). + + Binds a receiver, joins an IPv4 multicast group, sends OSC to the group, and + verifies it is received -- the asserting test for JoinMulticastGroup(). Like + the unicast UDP/TCP loopback tests it SKIPs gracefully (prints a notice, exits + 0) if the environment doesn't support multicast (the join or delivery fails), + so it never false-fails on a restricted runner. +*/ + +#include "ip/UdpSocket.h" +#include "ip/IpEndpointName.h" +#include "osc/OscPacketListener.h" +#include "osc/OscOutboundPacketStream.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class RecordingListener : public osctap::OscPacketListener { +public: + std::atomic count{ 0 }; + std::vector addresses; // written by receive thread, read after join + std::vector values; + +protected: + void ProcessMessage( const osctap::ReceivedMessage& m, const osctap::IpEndpointName& ) override + { + addresses.emplace_back( m.AddressPattern() ); + int v = 0; + auto a = m.ArgumentsBegin(); + if( a != m.ArgumentsEnd() && a->IsInt32() ) v = a->AsInt32Unchecked(); + values.push_back( v ); + count.fetch_add( 1, std::memory_order_relaxed ); + } +}; + +int failures = 0; +#define CHECK(c) do{ if(!(c)){ std::printf("FAIL line %d: %s\n", __LINE__, #c); ++failures; } }while(0) + +} // namespace + +int main() +{ +#ifndef _WIN32 + // A multicast send to an unrouted group can raise SIGPIPE on macOS/BSD; ignore + // it so the send merely fails and the test SKIPs instead of being killed. (The + // UDP backend also sets SO_NOSIGPIPE; this is belt-and-suspenders.) + std::signal( SIGPIPE, SIG_IGN ); +#endif + + // Administratively-scoped multicast group (239.0.0.0/8). + const int A = 239, B = 7, C = 7, D = 7; + + RecordingListener listener; + + // Bind to an OS-assigned port on all interfaces, then join the group on it. + osctap::UdpListeningReceiveSocket* receiver = nullptr; + int port = 0; + try { + receiver = new osctap::UdpListeningReceiveSocket( + osctap::IpEndpointName( osctap::IpEndpointName::ANY_ADDRESS, 0 ), &listener ); + port = receiver->LocalPort(); + receiver->JoinMulticastGroup( osctap::IpEndpointName( A, B, C, D, port ) ); + } catch( const std::exception& e ) { + std::printf( "OscMulticastTest: SKIP (multicast unavailable: %s)\n", e.what() ); + delete receiver; + return 0; + } + + std::thread runner( [&]{ receiver->Run(); } ); + + bool sent = false; + try { + osctap::UdpTransmitSocket sender( osctap::IpEndpointName( A, B, C, D, port ) ); + char buf[128]; + for( int i = 0; i < 3; ++i ){ + osctap::OutboundPacketStream p( buf, sizeof(buf) ); + p << osctap::BeginMessage( "/mc" ) << (int32_t)(100 + i) << osctap::EndMessage(); + sender.Send( p.Data(), p.Size() ); + } + sent = true; + } catch( const std::exception& e ) { + std::printf( "OscMulticastTest: SKIP (multicast send unavailable: %s)\n", e.what() ); + } + + if( sent ){ + for( int i = 0; i < 500 && listener.count.load() < 3; ++i ) + std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); + } + + receiver->AsynchronousBreak(); + runner.join(); + + // No delivery is ambiguous (often a sandbox without multicast routing on the + // default interface), so SKIP rather than fail. + if( !sent || listener.count.load() == 0 ){ + std::printf( "OscMulticastTest: SKIP (no multicast delivery in this environment)\n" ); + delete receiver; + return 0; + } + + CHECK( listener.count.load() == 3 ); + if( listener.addresses.size() == 3 ){ + CHECK( listener.addresses[0] == "/mc" && listener.values[0] == 100 ); + CHECK( listener.addresses[1] == "/mc" && listener.values[1] == 101 ); + CHECK( listener.addresses[2] == "/mc" && listener.values[2] == 102 ); + } + + // Leaving the group while the socket stays open should also succeed. + try { receiver->LeaveMulticastGroup( osctap::IpEndpointName( A, B, C, D, port ) ); } + catch( const std::exception& e ) { std::printf( "FAIL: leave: %s\n", e.what() ); ++failures; } + + delete receiver; + + if( failures == 0 ) + std::printf( "OscMulticastTest: OK (3 OSC messages over multicast 239.7.7.7)\n" ); + return failures == 0 ? 0 : 1; +} diff --git a/tests/OscRealtimeTest.cpp b/tests/OscRealtimeTest.cpp index 156f1e0..dda43c0 100644 --- a/tests/OscRealtimeTest.cpp +++ b/tests/OscRealtimeTest.cpp @@ -21,10 +21,11 @@ What is deliberately NOT in the realtime region (per the contract -- these may throw and run off the audio thread): message construction/validation (ReceivedMessage's constructor calls Init(), which validates), the *checked* - accessors (AsInt32() etc., which throw on type mismatch), AsBoolUnchecked() - and AsBlobUnchecked() (which still validate), and serialization (operator<< - throws on buffer overflow). Iterating *past* a blob is realtime-safe, so the - message below includes one; only the validating blob accessor is avoided. + accessors (AsInt32() etc., which throw on type mismatch), and serialization + (operator<< throws on buffer overflow). Every *Unchecked read accessor -- + including AsBoolUnchecked() and the blob accessor AsBlobUnchecked() -- is + throw-free and on the contract; the message below includes a blob and a bool, + read on the hot path through them. */ #include "osc/OscReceivedElements.h" @@ -50,6 +51,8 @@ struct ReadResult { char ch = 0; bool boolTrue = false, boolFalse = true; const char *str = nullptr, *sym = nullptr; + const void *blob = nullptr; + osc_bundle_element_size_t blobSize = 0; uint32_t argCount = 0; char firstAddrChar = 0; }; @@ -76,13 +79,16 @@ static ReadResult ReadHotPath( const ReceivedMessage& m ) OSCTAP_REALTIME case DOUBLE_TYPE_TAG: r.d = i->AsDoubleUnchecked(); break; case STRING_TYPE_TAG: r.str = i->AsStringUnchecked(); break; case SYMBOL_TYPE_TAG: r.sym = i->AsSymbolUnchecked(); break; - // bool's value lives in the type tag itself -- read it RT-safely - // without the (validating) AsBoolUnchecked(). - case TRUE_TYPE_TAG: r.boolTrue = true; break; - case FALSE_TYPE_TAG: r.boolFalse = false; break; - // nil / infinitum / array markers / blob: iterating past them is - // realtime-safe (Advance() does no allocation or throwing); we just - // don't read the blob payload here (that accessor validates). + // AsBoolUnchecked() is throw-free / realtime-safe too, so read bool + // through it (its value lives in the type tag). + case TRUE_TYPE_TAG: r.boolTrue = i->AsBoolUnchecked(); break; + case FALSE_TYPE_TAG: r.boolFalse = i->AsBoolUnchecked(); break; + // Blob: AsBlobUnchecked() is now throw-free / realtime-safe (the size + // was validated at construction), so the blob payload is read on the + // hot path too. + case BLOB_TYPE_TAG: i->AsBlobUnchecked( r.blob, r.blobSize ); break; + // nil / infinitum / array markers: iterating past them is realtime-safe + // (Advance() does no allocation or throwing). default: break; } } @@ -138,6 +144,9 @@ int main() CHECK( r.boolTrue == true ); CHECK( r.boolFalse == false ); CHECK( r.argCount > 0 ); + CHECK( r.blob != nullptr && r.blobSize == 5 + && static_cast(r.blob)[0] == 1 + && static_cast(r.blob)[4] == 5 ); if( g_failures == 0 ) std::cout << "realtime test: read hot path OK (RT-safe)\n";