Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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})
Expand Down
37 changes: 21 additions & 16 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -87,8 +88,8 @@ rename is the first item of Phase 1, below.
under `osctap/` and use the `<osctap/...>` prefix; the old `<oscpack/...>` paths are
preserved by a redirect shim tree under `oscpack/` (each header forwards to its
`<osctap/...>` 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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, …).

Expand Down
19 changes: 13 additions & 6 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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 <osctap/ip/IpEndpointName.h>` 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
Expand Down Expand Up @@ -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.
15 changes: 15 additions & 0 deletions osctap/ip/AbstractUdpSocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions osctap/ip/posix/UdpSocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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_ );
Expand Down
28 changes: 26 additions & 2 deletions osctap/ip/win32/UdpSocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

#include <algorithm>
#include <cassert>
#include <chrono> // steady_clock for GetCurrentTimeMs()
#include <cstring> // for memset
#include <stdexcept>
#include <vector>
Expand Down Expand Up @@ -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_ );
Expand Down Expand Up @@ -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<milliseconds>( steady_clock::now().time_since_epoch() ).count();
}

public:
SocketReceiveMultiplexerImplementation()
Expand Down
28 changes: 13 additions & 15 deletions osctap/osc/OscReceivedElements.h
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down
Loading
Loading