From 69663da713aeaef5a3adbde92b166aec9b698a32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 20:21:37 +0000 Subject: [PATCH 01/16] Phase 2: Pi 5 hub + aarch64 CI + Android NDK bridge + integration tutorial Build out the Pi 5 <-> Pico 2W <-> Android OSC integration on top of the freestanding groundwork. Raspberry Pi 5 (aarch64 Linux): - demos/pi5_hub.cpp + demos/osc_send.cpp: a runnable OSC hub/router and a CLI sender over the POSIX UDP backend (OSCTAP_BUILD_DEMOS, POSIX-only). First compiled coverage of the ip/ networking layer. Verified end-to-end on loopback. - aarch64-qemu CI job: cross-compile with aarch64-linux-gnu-g++ and run the suite under qemu-user (CMAKE_CROSSCOMPILING_EMULATOR), proving the Pi-5-class build. Excludes OscConcurrencyTest (emulated threads/sockets are flaky). Verified locally: 98/98 unit tests + freestanding pass under qemu-aarch64. Android: - android/osctap_jni.cpp: JNI bridge (buildMessage/describe) exposing the core to Kotlin; compile-verified against jni.h. android/CMakeLists.txt (NDK) and android/OscTap.kt (facade + JVM UDP transport). Plus a JVM-OSC-library alternative in the tutorial. Library fix (found via the hub): - OutboundPacketStream now has operator<<(const char*) -> OSC string. Previously a runtime const char* bound to operator<<(bool) (pointer->bool standard conversion outranks the user-defined string_view conversion) and was silently serialized as a boolean; only string literals worked. Freestanding-safe; OscFreestandingTest sends a runtime const char* to guard it. Docs: - docs/INTEGRATION_PI5_PICO_ANDROID.md: end-to-end tutorial (OSC contract, ports, per-node code, bring-up order, troubleshooting). - ROADMAP.md / docs/STATUS.md updated; new landmines for the const char* overload, the now-compiled ip/ layer, and the aarch64/demos/Android surfaces. Verified: hosted suite (C++17/20, GCC/Clang/MSVC, ASan/UBSan) green; freestanding green (GCC+Clang); aarch64 suite green under qemu; demos run on loopback; JNI bridge compiles against jni.h. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- .github/workflows/ci.yml | 32 ++++ CMakeLists.txt | 13 ++ ROADMAP.md | 33 +++- android/CMakeLists.txt | 32 ++++ android/OscTap.kt | 73 ++++++++ android/osctap_jni.cpp | 133 ++++++++++++++ demos/osc_send.cpp | 106 ++++++++++++ demos/pi5_hub.cpp | 163 +++++++++++++++++ docs/INTEGRATION_PI5_PICO_ANDROID.md | 250 +++++++++++++++++++++++++++ docs/STATUS.md | 35 +++- osctap/osc/OscOutboundPacketStream.h | 13 ++ tests/OscFreestandingTest.cpp | 4 +- 12 files changed, 873 insertions(+), 14 deletions(-) create mode 100644 android/CMakeLists.txt create mode 100644 android/OscTap.kt create mode 100644 android/osctap_jni.cpp create mode 100644 demos/osc_send.cpp create mode 100644 demos/pi5_hub.cpp create mode 100644 docs/INTEGRATION_PI5_PICO_ANDROID.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 113f75a..626a86d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,6 +136,38 @@ jobs: - name: Run freestanding smoke test run: ctest --test-dir build-freestanding -R OscFreestandingTest --output-on-failure + aarch64-qemu: + name: aarch64 cross-build + qemu (Pi-5-class / big-endian-agnostic) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Cross-compile for 64-bit Arm (the Raspberry Pi 5 ISA) and run the suite + # under qemu-user, so the aarch64 build is proven green on every push. This + # also exercises the endian-agnostic (de)serialization on a non-x86 target. + - name: Install aarch64 toolchain + qemu-user + run: | + sudo apt-get update + sudo apt-get install -y g++-aarch64-linux-gnu qemu-user-static + + - name: Configure (aarch64 cross; qemu as the test emulator) + run: > + cmake -S . -B build-aarch64 -DOSCPACK_BUILD_EXAMPLES=ON + -DOSCTAP_WARNINGS_AS_ERRORS=ON + -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=aarch64 + -DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc + -DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ + "-DCMAKE_CROSSCOMPILING_EMULATOR=qemu-aarch64-static;-L;/usr/aarch64-linux-gnu" + + - name: Build + run: cmake --build build-aarch64 + + # OscConcurrencyTest is excluded: it uses real threads + loopback sockets, + # which are flaky under qemu-user emulation. Native TSan/POSIX legs already + # cover it; here we vet the parse/serialize suite on the aarch64 ISA. + - name: Run suite under qemu + run: ctest --test-dir build-aarch64 -E OscConcurrencyTest --output-on-failure + tsan: name: ThreadSanitizer (receive loop concurrency) runs-on: ubuntu-latest diff --git a/CMakeLists.txt b/CMakeLists.txt index a9f9e49..5cfe194 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,6 +111,19 @@ if(OSCTAP_FREESTANDING) add_test(NAME OscFreestandingTest COMMAND OscFreestandingTest) endif() +# Integration demos (Pi 5 <-> Pico 2W <-> Android tutorial). Real, runnable OSC +# programs over the POSIX UDP backend: a hub/router and a CLI sender. POSIX-only +# (they use the ip/posix sockets + a SIGINT handler), mirroring the Pi 5 target. +# See docs/INTEGRATION_PI5_PICO_ANDROID.md. +set(OSCTAP_BUILD_DEMOS OFF CACHE BOOL "Build the Pi 5 integration demos (POSIX sockets)") +if(OSCTAP_BUILD_DEMOS AND NOT WIN32) + add_executable(pi5_hub demos/pi5_hub.cpp) + target_link_libraries(pi5_hub oscpack) + + add_executable(osc_send demos/osc_send.cpp) + target_link_libraries(osc_send oscpack) +endif() + set(OSCTAP_BUILD_FUZZERS OFF CACHE BOOL "Build the libFuzzer fuzz target (requires Clang)") set(OSCTAP_FUZZER_STANDALONE OFF CACHE BOOL "Build the fuzz target with the standalone driver instead of libFuzzer (works with g++/ASan, no fuzzer runtime needed)") diff --git a/ROADMAP.md b/ROADMAP.md index 5bdc2ef..510e045 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,9 +5,10 @@ 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") prep in progress — the -> freestanding/embedded profile groundwork has landed (see Phase 2 below); the -> remaining Reach items stay demand-gated. +> 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. ## Why OscTap exists @@ -157,12 +158,28 @@ See [Sanitizer strategy](#sanitizer-strategy) for scope and rationale. build can *reject* untrusted packets by returning an error instead of aborting (today, malformed input on an exceptions-off build is fatal — safe only on a trusted link; open networks should keep exceptions on and `catch`). -- [ ] QEMU aarch64 / armv7 CI. *(Demand-gated — adds standing CI surface; stand up - only when a real big-endian/cross target needs it. The freestanding profile - already exercises the no-OS code paths without QEMU's cost.)* -- [ ] Android NDK build. *(Demand-gated — pursue when an Android consumer appears.)* +- [x] **aarch64 (Raspberry Pi 5) CI under QEMU** — *landed.* The `aarch64-qemu` CI + job cross-compiles the suite with `aarch64-linux-gnu-g++` and runs it under + `qemu-user` (via `CMAKE_CROSSCOMPILING_EMULATOR`), proving the Pi-5-class build + green on every push and exercising the endian-agnostic (de)serialization on a + non-x86 ISA. `OscConcurrencyTest` is excluded from the emulated run (real + threads + loopback sockets are flaky under `qemu-user`; the native TSan/POSIX + legs cover it). Deferred: **armv7** (32-bit) and **real-hardware** runners. +- [x] **Android NDK build** — *groundwork landed.* A JNI bridge (`android/osctap_jni.cpp`) + exposes the header-only core to Kotlin (`buildMessage`/`describe`), with an NDK + `android/CMakeLists.txt` and a `android/OscTap.kt` facade (JVM UDP transport). + The bridge is compile-verified against `jni.h` + the core. Deferred: a full + Gradle sample app and optional NDK CI (needs the NDK image; weigh against the + standing-surface caution). +- [x] **Pi 5 ⇄ Pico 2W ⇄ Android integration** — runnable Pi 5 hub/router + CLI + sender demos (`demos/`, `OSCTAP_BUILD_DEMOS`, POSIX sockets — the first + compiled coverage of the `ip/` layer) and an end-to-end tutorial + ([`docs/INTEGRATION_PI5_PICO_ANDROID.md`](docs/INTEGRATION_PI5_PICO_ANDROID.md)) + tying all three nodes together over OSC/UDP. - [ ] Multicast receive (cherry-pick from `stephram/oscpack`). *(Self-contained; - the next demand-driven feature pickup after the freestanding groundwork.)* + 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.)* ## Milestones → GitHub diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt new file mode 100644 index 0000000..24fbb02 --- /dev/null +++ b/android/CMakeLists.txt @@ -0,0 +1,32 @@ +# Android NDK build for the OscTap JNI bridge (libosctap_jni.so). +# +# Built by Gradle's externalNativeBuild, or directly with the NDK toolchain: +# +# cmake -B build-android android \ +# -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \ +# -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-24 \ +# -DOSCTAP_ROOT=/abs/path/to/OscTap +# cmake --build build-android +# +# (Repeat per ABI: arm64-v8a, armeabi-v7a, x86_64 — or let Gradle fan out.) +cmake_minimum_required(VERSION 3.22) +project(osctap_jni CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# OscTap repo root (header-only core). Defaults to the parent of android/. +set(OSCTAP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." CACHE PATH "Path to the OscTap repo root") + +add_library(osctap_jni SHARED osctap_jni.cpp) +target_include_directories(osctap_jni PRIVATE ${OSCTAP_ROOT} ${OSCTAP_ROOT}/osctap) + +# Parsing untrusted OSC throws on malformed input; keep exceptions enabled so the +# bridge can catch and surface a Java exception instead of aborting. (RTTI is not +# required.) The NDK enables exceptions by default for CMake projects; we set it +# explicitly so the contract is unambiguous. +target_compile_options(osctap_jni PRIVATE -fexceptions -fno-rtti) + +# Uncomment if you add __android_log_print logging in the bridge: +# find_library(log-lib log) +# target_link_libraries(osctap_jni ${log-lib}) diff --git a/android/OscTap.kt b/android/OscTap.kt new file mode 100644 index 0000000..2a04391 --- /dev/null +++ b/android/OscTap.kt @@ -0,0 +1,73 @@ +/* + OscTap demo: Kotlin facade for the Android app. + + Part of the Pi 5 <-> Pico 2W <-> Android integration tutorial + (docs/INTEGRATION_PI5_PICO_ANDROID.md). The OScTap C++ core (via the JNI + bridge in osctap_jni.cpp -> libosctap_jni.so) builds and parses OSC; the UDP + transport is pure JVM (java.net.DatagramSocket), which is the idiomatic way + to do networking in an Android app. + + Requires in the + manifest. Do socket I/O off the main thread (a coroutine / thread). +*/ +package org.osctap.demo + +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress + +/** Native OSC (de)serialization, backed by the OscTap C++ core through JNI. */ +object OscTap { + init { System.loadLibrary("osctap_jni") } + + /** Build an OSC message. Each arg may be Int, Float, Boolean, or String. */ + external fun buildMessage(address: String, args: Array): ByteArray + + /** Parse one OSC *message* into a human-readable summary. + * Throws IllegalArgumentException on a malformed packet. */ + external fun describe(packet: ByteArray): String +} + +/** + * Minimal UDP OSC endpoint for the app. Construct once; call send() to talk to + * the Pi 5 hub, and receive() in a loop on a background thread for telemetry. + */ +class OscUdp(private val socket: DatagramSocket = DatagramSocket()) { + + /** Fire one OSC message at host:port (e.g. the Pi 5 hub on 9000). */ + fun send(host: String, port: Int, address: String, vararg args: Any) { + val bytes = OscTap.buildMessage(address, arrayOf(*args)) + socket.send(DatagramPacket(bytes, bytes.size, InetAddress.getByName(host), port)) + } + + /** Block for one inbound packet and return its parsed summary (or null if + * malformed). Call from a background thread bound to your telemetry port. */ + fun receive(): String? { + val buf = ByteArray(1500) + val pkt = DatagramPacket(buf, buf.size) + socket.receive(pkt) + return try { + OscTap.describe(pkt.data.copyOf(pkt.length)) + } catch (e: IllegalArgumentException) { + null // drop malformed datagram + } + } + + fun bind(port: Int): OscUdp = OscUdp(DatagramSocket(port)) + fun close() = socket.close() +} + +/* + Usage sketch (in a ViewModel / coroutine): + + val hub = "192.168.1.10" + val tx = OscUdp() + tx.send(hub, 9000, "/hub/led", 1) // turn an LED on via the hub + tx.send(hub, 9000, "/hub/pwm", 0.75f) // set a PWM duty + + // telemetry: the hub relays /sensor/* to us re-addressed as /ui/* + val rx = OscUdp(DatagramSocket(9001)) + thread { + while (true) rx.receive()?.let { println("telemetry: $it") } + } +*/ diff --git a/android/osctap_jni.cpp b/android/osctap_jni.cpp new file mode 100644 index 0000000..46e6600 --- /dev/null +++ b/android/osctap_jni.cpp @@ -0,0 +1,133 @@ +/* + OscTap demo: Android NDK / JNI bridge. + + Part of the Pi 5 <-> Pico 2W <-> Android integration tutorial + (docs/INTEGRATION_PI5_PICO_ANDROID.md). Exposes the OscTap C++ core to + Kotlin/Java so an Android app can build and parse OSC packets natively; the + UDP transport itself stays on the JVM side (java.net.DatagramSocket). + + Two entry points, matching OscTap.kt (package org.osctap.demo): + * buildMessage(String address, Object[] args) -> byte[] + args may be Integer / Float / Boolean / String (mapped to OSC i f T/F s). + * describe(byte[] packet) -> String + parse one OSC *message* and return a human-readable summary. + + Build with the Android NDK via android/CMakeLists.txt (see the tutorial). The + core is header-only, so there is nothing to link but this bridge. + + NOTE on untrusted input: parsing throws on malformed packets. Exceptions must + be ENABLED for the NDK target (the CMakeLists sets -fexceptions); describe() + catches and surfaces a Java exception instead of aborting. +*/ + +#include +#include + +#include "osc/OscReceivedElements.h" +#include "osc/OscOutboundPacketStream.h" + +namespace { + +// Append one boxed Java argument (Integer/Float/Boolean/String) as the matching +// OSC argument. Returns false if the type is unsupported. +bool AppendBoxedArg( JNIEnv* env, osctap::OutboundPacketStream& p, jobject arg ) +{ + if( arg == nullptr ) return false; + + jclass integerCls = env->FindClass( "java/lang/Integer" ); + jclass floatCls = env->FindClass( "java/lang/Float" ); + jclass boolCls = env->FindClass( "java/lang/Boolean" ); + jclass stringCls = env->FindClass( "java/lang/String" ); + + if( env->IsInstanceOf( arg, integerCls ) ){ + jint v = env->CallIntMethod( arg, env->GetMethodID( integerCls, "intValue", "()I" ) ); + p << (int32_t)v; + } else if( env->IsInstanceOf( arg, floatCls ) ){ + jfloat v = env->CallFloatMethod( arg, env->GetMethodID( floatCls, "floatValue", "()F" ) ); + p << (float)v; + } else if( env->IsInstanceOf( arg, boolCls ) ){ + jboolean v = env->CallBooleanMethod( arg, env->GetMethodID( boolCls, "booleanValue", "()Z" ) ); + p << (bool)(v == JNI_TRUE); + } else if( env->IsInstanceOf( arg, stringCls ) ){ + const char* s = env->GetStringUTFChars( (jstring)arg, nullptr ); + p << s; // const char* overload -> OSC string + env->ReleaseStringUTFChars( (jstring)arg, s ); + } else { + return false; + } + return true; +} + +void ThrowJava( JNIEnv* env, const char* cls, const char* msg ) +{ + jclass c = env->FindClass( cls ); + if( c ) env->ThrowNew( c, msg ); +} + +} // namespace + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_org_osctap_demo_OscTap_buildMessage( JNIEnv* env, jclass, jstring jaddress, jobjectArray args ) +{ + const char* address = env->GetStringUTFChars( jaddress, nullptr ); + char buffer[1024]; + jbyteArray result = nullptr; + + try { + osctap::OutboundPacketStream p( buffer, sizeof(buffer) ); + p << osctap::BeginMessage( address ); + + const jsize n = args ? env->GetArrayLength( args ) : 0; + for( jsize i = 0; i < n; ++i ){ + jobject a = env->GetObjectArrayElement( args, i ); + if( !AppendBoxedArg( env, p, a ) ){ + env->ReleaseStringUTFChars( jaddress, address ); + ThrowJava( env, "java/lang/IllegalArgumentException", + "unsupported OSC argument type (use Integer/Float/Boolean/String)" ); + return nullptr; + } + } + p << osctap::EndMessage(); + + result = env->NewByteArray( (jsize)p.Size() ); + env->SetByteArrayRegion( result, 0, (jsize)p.Size(), + reinterpret_cast( p.Data() ) ); + } catch( const osctap::Exception& e ) { + ThrowJava( env, "java/lang/IllegalArgumentException", e.what() ); + } + + env->ReleaseStringUTFChars( jaddress, address ); + return result; +} + +extern "C" JNIEXPORT jstring JNICALL +Java_org_osctap_demo_OscTap_describe( JNIEnv* env, jclass, jbyteArray packet ) +{ + const jsize n = env->GetArrayLength( packet ); + jbyte* bytes = env->GetByteArrayElements( packet, nullptr ); + + std::string out; + try { + // The parser uses byte-assembly (de)serialization, so the buffer needs + // no special alignment. Untrusted input -> wrap in try/catch. + osctap::ReceivedMessage m( osctap::ReceivedPacket( + reinterpret_cast( bytes ), (std::size_t)n ) ); + + out = m.AddressPattern(); + for( auto a = m.ArgumentsBegin(); a != m.ArgumentsEnd(); ++a ){ + out += ' '; + if( a->IsInt32() ) out += std::to_string( a->AsInt32Unchecked() ); + else if( a->IsFloat() ) out += std::to_string( a->AsFloatUnchecked() ); + else if( a->IsString() ) out += std::string("\"") + a->AsStringUnchecked() + "\""; + else if( a->IsBool() ) out += a->AsBoolUnchecked() ? "true" : "false"; + else out += '?'; + } + } catch( const osctap::Exception& e ) { + env->ReleaseByteArrayElements( packet, bytes, JNI_ABORT ); + ThrowJava( env, "java/lang/IllegalArgumentException", e.what() ); + return nullptr; + } + + env->ReleaseByteArrayElements( packet, bytes, JNI_ABORT ); + return env->NewStringUTF( out.c_str() ); +} diff --git a/demos/osc_send.cpp b/demos/osc_send.cpp new file mode 100644 index 0000000..76dcd57 --- /dev/null +++ b/demos/osc_send.cpp @@ -0,0 +1,106 @@ +/* + OscTap demo: command-line OSC sender. + + Part of the Pi 5 <-> Pico 2W <-> Android integration tutorial + (docs/INTEGRATION_PI5_PICO_ANDROID.md). Sends a single OSC message, so you + can drive the Pi 5 hub (or a Pico) from a shell to test the wiring before the + real Android app / firmware exists. + + Build via the OSCTAP_BUILD_DEMOS CMake option, or directly: + g++ -std=c++17 -I. -Iosctap demos/osc_send.cpp -o osc_send + + Usage: + osc_send
[args...] + + Each arg is typed by a one-letter prefix (default is auto: int if it parses + as an integer, else float if it parses as a float, else string): + i:42 int32 f:3.14 float + s:hello string T / F bool true / false + + Examples: + osc_send 192.168.1.10 9000 /hub/led i:1 + osc_send 192.168.1.10 9000 /hub/pwm f:0.75 + osc_send 192.168.1.10 9000 /sensor/temp f:21.4 +*/ + +#include "ip/UdpSocket.h" +#include "ip/IpEndpointName.h" +#include "osc/OscOutboundPacketStream.h" + +#include +#include +#include +#include + +namespace { + +bool ParseInt( const char *s, int32_t& out ) +{ + char *end = nullptr; + long v = std::strtol( s, &end, 10 ); + if( end == s || *end != '\0' ) return false; + out = static_cast( v ); + return true; +} + +bool ParseFloat( const char *s, float& out ) +{ + char *end = nullptr; + float v = std::strtof( s, &end ); + if( end == s || *end != '\0' ) return false; + out = v; + return true; +} + +void AppendArg( osctap::OutboundPacketStream& p, const char *tok ) +{ + if( std::strcmp( tok, "T" ) == 0 ) { p << true; return; } + if( std::strcmp( tok, "F" ) == 0 ) { p << false; return; } + + if( std::strncmp( tok, "i:", 2 ) == 0 ) { p << (int32_t)std::atoi( tok + 2 ); return; } + if( std::strncmp( tok, "f:", 2 ) == 0 ) { p << (float)std::atof( tok + 2 ); return; } + if( std::strncmp( tok, "s:", 2 ) == 0 ) { const char *s = tok + 2; p << s; return; } + + // Auto: int, else float, else string. + int32_t i; float f; + if( ParseInt( tok, i ) ) p << i; + else if( ParseFloat( tok, f ) ) p << f; + else p << tok; +} + +} // namespace + +int main( int argc, char *argv[] ) +{ + if( argc < 4 ){ + std::cerr << "usage: osc_send
[args...]\n"; + return 2; + } + const char *host = argv[1]; + int port = std::atoi( argv[2] ); + const char *address = argv[3]; + + char buffer[1024]; + osctap::OutboundPacketStream p( buffer, sizeof(buffer) ); + try { + p << osctap::BeginMessage( address ); + for( int i = 4; i < argc; ++i ) + AppendArg( p, argv[i] ); + p << osctap::EndMessage(); + } catch( const osctap::Exception& e ) { + std::cerr << "failed to build message: " << e.what() << '\n'; + return 1; + } + + try { + osctap::UdpTransmitSocket( osctap::IpEndpointName( host, port ) ) + .Send( p.Data(), p.Size() ); + } catch( const std::exception& e ) { + std::cerr << "send failed: " << e.what() << '\n'; + return 1; + } + + std::cout << "sent " << p.Size() << " bytes to " << host << ':' << port + << " " << address << '\n'; + return 0; +} diff --git a/demos/pi5_hub.cpp b/demos/pi5_hub.cpp new file mode 100644 index 0000000..3616d84 --- /dev/null +++ b/demos/pi5_hub.cpp @@ -0,0 +1,163 @@ +/* + OscTap demo: Raspberry Pi 5 OSC hub / router. + + Part of the Pi 5 <-> Pico 2W <-> Android integration tutorial + (docs/INTEGRATION_PI5_PICO_ANDROID.md). Runs on the Pi 5 (or any POSIX host; + the source path is identical on aarch64 and x86-64) as the central node: + + * binds a UDP socket and listens for OSC, + * prints every message it receives (address + typed arguments + sender), + * translates/relays between the controller (Android) and the device (Pico): + - from Android: /hub/led -> Pico as /led + /hub/pwm -> Pico as /pwm + - from Pico: /sensor/ ... -> Android as /ui/ ... (telemetry) + + Build via the OSCTAP_BUILD_DEMOS CMake option, or directly: + g++ -std=c++17 -I. -Iosctap demos/pi5_hub.cpp -o pi5_hub + + Usage: + pi5_hub [listenPort] [picoHost:picoPort] [androidHost:androidPort] + Defaults: + listenPort 9000 + pico 192.168.1.50:9000 + android 192.168.1.20:9001 +*/ + +#include "ip/UdpSocket.h" +#include "ip/IpEndpointName.h" +#include "osc/OscPacketListener.h" +#include "osc/OscOutboundPacketStream.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +// Parse "host:port" (port optional -> fallback). IPv4 dotted or hostname. +osctap::IpEndpointName ParseEndpoint( const char *s, int fallbackPort ) +{ + const char *colon = std::strrchr( s, ':' ); + if( !colon ) + return osctap::IpEndpointName( s, fallbackPort ); + std::string host( s, colon - s ); + int port = std::atoi( colon + 1 ); + return osctap::IpEndpointName( host.c_str(), port ? port : fallbackPort ); +} + +void PrintMessage( const osctap::ReceivedMessage& m, const osctap::IpEndpointName& from ) +{ + char who[ osctap::IpEndpointName::ADDRESS_AND_PORT_STRING_LENGTH ]; + from.AddressAndPortAsString( who ); + std::cout << "[recv " << who << "] " << m.AddressPattern() + << " (" << m.ArgumentCount() << " args)"; + for( auto a = m.ArgumentsBegin(); a != m.ArgumentsEnd(); ++a ){ + std::cout << ' '; + if( a->IsInt32() ) std::cout << a->AsInt32Unchecked(); + else if( a->IsFloat() ) std::cout << a->AsFloatUnchecked(); + else if( a->IsString() ) std::cout << '"' << a->AsStringUnchecked() << '"'; + else if( a->IsBool() ) std::cout << (a->AsBoolUnchecked() ? "true" : "false"); + else std::cout << '?'; + } + std::cout << '\n'; +} + +class HubListener : public osctap::OscPacketListener { +public: + HubListener( const osctap::IpEndpointName& pico, const osctap::IpEndpointName& android ) + : pico_( pico ), android_( android ) {} + + // Guard the dispatch: parsing untrusted UDP can throw on a malformed packet. + // Catch it so one bad datagram drops instead of taking the hub down. + void ProcessPacket( const char *data, int size, const osctap::IpEndpointName& from ) override + { + try { + osctap::OscPacketListener::ProcessPacket( data, size, from ); + } catch( const osctap::Exception& e ) { + std::cerr << "[drop] malformed packet (" << e.what() << ")\n"; + } + } + +protected: + void ProcessMessage( const osctap::ReceivedMessage& m, const osctap::IpEndpointName& from ) override + { + PrintMessage( m, from ); + + const char *addr = m.AddressPattern(); + char out[256]; + + // Controller -> device: re-address /hub/ to / and forward to Pico. + if( std::strncmp( addr, "/hub/", 5 ) == 0 ){ + osctap::OutboundPacketStream p( out, sizeof(out) ); + p << osctap::BeginMessage( addr + 4 ); // "/hub/led" -> "/led" + for( auto a = m.ArgumentsBegin(); a != m.ArgumentsEnd(); ++a ) + CopyArg( p, a ); + p << osctap::EndMessage(); + Forward( pico_, p, "Pico" ); + } + // Device -> controller: re-address /sensor/ to /ui/ (telemetry). + else if( std::strncmp( addr, "/sensor/", 8 ) == 0 ){ + std::string ui = std::string("/ui/") + (addr + 8); + osctap::OutboundPacketStream p( out, sizeof(out) ); + p << osctap::BeginMessage( ui.c_str() ); + for( auto a = m.ArgumentsBegin(); a != m.ArgumentsEnd(); ++a ) + CopyArg( p, a ); + p << osctap::EndMessage(); + Forward( android_, p, "Android" ); + } + } + +private: + static void CopyArg( osctap::OutboundPacketStream& p, + osctap::ReceivedMessage::const_iterator a ) + { + if( a->IsInt32() ) p << a->AsInt32Unchecked(); + else if( a->IsFloat() ) p << a->AsFloatUnchecked(); + else if( a->IsString() ) p << a->AsStringUnchecked(); + else if( a->IsBool() ) p << a->AsBoolUnchecked(); + } + + void Forward( const osctap::IpEndpointName& to, const osctap::OutboundPacketStream& p, + const char *label ) + { + try { + osctap::UdpTransmitSocket( to ).Send( p.Data(), p.Size() ); + char dst[ osctap::IpEndpointName::ADDRESS_AND_PORT_STRING_LENGTH ]; + to.AddressAndPortAsString( dst ); + std::cout << " -> " << label << " (" << dst << ")\n"; + } catch( const std::exception& e ) { + std::cerr << " -> " << label << " send failed: " << e.what() << '\n'; + } + } + + osctap::IpEndpointName pico_; + osctap::IpEndpointName android_; +}; + +osctap::UdpListeningReceiveSocket *gSocket = nullptr; +void HandleSigInt( int ) { if( gSocket ) gSocket->AsynchronousBreak(); } + +} // namespace + +int main( int argc, char *argv[] ) +{ + int listenPort = (argc > 1) ? std::atoi( argv[1] ) : 9000; + osctap::IpEndpointName pico = (argc > 2) ? ParseEndpoint( argv[2], 9000 ) + : osctap::IpEndpointName( "192.168.1.50", 9000 ); + osctap::IpEndpointName android = (argc > 3) ? ParseEndpoint( argv[3], 9001 ) + : osctap::IpEndpointName( "192.168.1.20", 9001 ); + + HubListener listener( pico, android ); + osctap::UdpListeningReceiveSocket socket( + osctap::IpEndpointName( osctap::IpEndpointName::ANY_ADDRESS, listenPort ), &listener ); + gSocket = &socket; + std::signal( SIGINT, HandleSigInt ); + + std::cout << "OscTap Pi 5 hub listening on UDP " << listenPort << " (Ctrl-C to stop)\n"; + socket.Run(); + std::cout << "\nhub stopped.\n"; + return 0; +} diff --git a/docs/INTEGRATION_PI5_PICO_ANDROID.md b/docs/INTEGRATION_PI5_PICO_ANDROID.md new file mode 100644 index 0000000..679d204 --- /dev/null +++ b/docs/INTEGRATION_PI5_PICO_ANDROID.md @@ -0,0 +1,250 @@ +# Integration tutorial: Raspberry Pi 5 ⇄ Pico 2W ⇄ Android over OSC + +A worked end-to-end example wiring three nodes together with OscTap and OSC/UDP: + +- **Raspberry Pi 5** — full Linux (aarch64). The **hub / router**: receives OSC, + prints it, and relays/translates between the controller and the device. +- **Raspberry Pi Pico 2W** — RP2350 microcontroller + Wi-Fi. The **I/O device**: + drives an LED/PWM from OSC and publishes sensor readings. +- **Android app** — the **controller / UI**: sends commands, shows telemetry. + +OscTap runs the OSC (de)serialization on **all three** (header-only C++ core); each +node uses whatever UDP transport is native to it. The OSC *wire format* is the +contract, so any node could equally be a different OSC implementation. + +``` + ┌────────────────────┐ /hub/led, /hub/pwm ┌────────────────────┐ + │ Android app │ ─────────────────────────▶ │ Raspberry Pi 5 │ + │ (controller/UI) │ │ hub / router │ + │ java.net UDP + │ ◀───────────────────────── │ pi5_hub (POSIX) │ + │ OscTap via NDK │ /ui/temp, /ui/name └─────────┬──────────┘ + └────────────────────┘ (relayed telemetry) │ + /led, /pwm │ ▲ /sensor/temp + ▼ │ /sensor/name + ┌────────────────────┐ + │ Pico 2W (RP2350) │ + │ lwIP UDP + │ + │ OscTap freestyle │ + └────────────────────┘ +``` + +> What's verified in this repo: the Pi 5 hub + sender demos build and run +> (loopback), the aarch64 build runs green under qemu (CI `aarch64-qemu` job), and +> the Android JNI bridge compiles against `jni.h` + the OscTap core. The Pico +> firmware and the full Android NDK/Gradle build are integration recipes — they +> need the Pico SDK / Android NDK on your machine. + +## 1. The OSC contract (address map) + +Agree this up front; it's the only thing the three nodes truly share. + +| Direction | Address | Args | Meaning | +|-----------|---------|------|---------| +| Android → Pi 5 | `/hub/led` | `int` (0/1) | request LED state | +| Android → Pi 5 | `/hub/pwm` | `float` (0..1) | request PWM duty | +| Pi 5 → Pico | `/led` | `int` | hub relays the LED command | +| Pi 5 → Pico | `/pwm` | `float` | hub relays the PWM command | +| Pico → Pi 5 | `/sensor/temp` | `float` (°C) | temperature reading | +| Pico → Pi 5 | `/sensor/name` | `string` | device label | +| Pi 5 → Android | `/ui/temp` | `float` | relayed telemetry | +| Pi 5 → Android | `/ui/name` | `string` | relayed telemetry | + +The hub's rule is mechanical: `/hub/` → `/` to the Pico; `/sensor/` → +`/ui/` to the Android. See `demos/pi5_hub.cpp`. + +## 2. Addresses & ports + +Put all three on the same LAN/subnet. Example: + +| Node | IP (example) | Listens on | Sends to | +|------|--------------|-----------|----------| +| Pi 5 hub | `192.168.1.10` | `9000` | Pico `:9000`, Android `:9001` | +| Pico 2W | `192.168.1.50` | `9000` | Pi 5 `:9000` | +| Android | `192.168.1.20` | `9001` | Pi 5 `:9000` | + +Substitute your real IPs (find them with `hostname -I` on the Pi, your router's +DHCP table for the Pico, and Wi-Fi settings on the phone). + +## 3. Node 1 — Raspberry Pi 5 (the hub) + +Build the demos (POSIX sockets; identical source on aarch64 and x86-64): + +```sh +cmake -S . -B build -DOSCTAP_BUILD_DEMOS=ON +cmake --build build --target pi5_hub osc_send +``` + +Run the hub, telling it where the Pico and Android are: + +```sh +./build/pi5_hub 9000 192.168.1.50:9000 192.168.1.20:9001 +# OscTap Pi 5 hub listening on UDP 9000 (Ctrl-C to stop) +``` + +It prints every message it receives and logs each relay. `osc_send` is a CLI for +poking the system before the real devices exist: + +```sh +./build/osc_send 192.168.1.10 9000 /hub/led i:1 +./build/osc_send 192.168.1.10 9000 /hub/pwm f:0.75 +./build/osc_send 192.168.1.10 9000 /sensor/temp f:21.4 +``` + +The hub guards itself against malformed UDP: parsing throws, and it catches the +exception to drop the bad datagram rather than crash (see `HubListener::ProcessPacket`). + +## 4. Node 2 — Raspberry Pi Pico 2W (the device) + +Full embedded setup is in [`EMBEDDED_PICO2W.md`](EMBEDDED_PICO2W.md) (freestanding +profile, Pico SDK + lwIP, the exceptions/untrusted-input trade-off). For *this* +contract, the Pico does two things. + +**Receive `/led` and `/pwm`** in the lwIP `udp_recv` callback: + +```cpp +#include "osc/OscReceivedElements.h" + +static void on_osc(void*, udp_pcb*, pbuf* p, const ip_addr_t*, u16_t) { + if (!p) return; + alignas(4) static char buf[256]; + const u16_t n = pbuf_copy_partial(p, buf, sizeof(buf), 0); + pbuf_free(p); + + osctap::ReceivedMessage m(osctap::ReceivedPacket(buf, n)); // (try/catch if exceptions on) + const char* a = m.AddressPattern(); + auto arg = m.ArgumentsBegin(); + if (std::strcmp(a, "/led") == 0) set_led(arg->AsInt32Unchecked() != 0); + else if (std::strcmp(a, "/pwm") == 0) set_pwm(arg->AsFloatUnchecked()); +} +``` + +**Publish `/sensor/temp`** periodically into a `pbuf` and `udp_sendto` the Pi 5 +(buffer on the stack, no heap): + +```cpp +#include "osc/OscOutboundPacketStream.h" + +void publish_temp(udp_pcb* pcb, const ip_addr_t* hub, uint16_t port, float c) { + char buffer[64]; + osctap::OutboundPacketStream p(buffer, sizeof(buffer)); + p << osctap::BeginMessage("/sensor/temp") << c << osctap::EndMessage(); + pbuf* pb = pbuf_alloc(PBUF_TRANSPORT, p.Size(), PBUF_RAM); + std::memcpy(pb->payload, p.Data(), p.Size()); + udp_sendto(pcb, pb, hub, port); + pbuf_free(pb); +} +``` + +> Untrusted-input reminder: on a Wi-Fi LAN, prefer **exceptions enabled** on the +> Pico so a malformed packet is caught and dropped, not fatal. On a trusted/closed +> link you can run the no-exceptions freestanding profile. Full rationale in the +> Pico guide. + +## 5. Node 3 — Android (the controller) + +Two ways to speak OSC from the app. Pick one. + +### 5a. OscTap via the NDK (native core) + +Reuse the exact OscTap C++ core on Android through a thin JNI bridge. Files in +[`../android/`](../android): `osctap_jni.cpp` (bridge), `CMakeLists.txt` (NDK +build), `OscTap.kt` (Kotlin facade + JVM UDP transport). + +Wire it into your app module's `build.gradle`: + +```gradle +android { + defaultConfig { + externalNativeBuild { cmake { cppFlags "-std=c++17 -fexceptions" } } + ndk { abiFilters "arm64-v8a", "armeabi-v7a", "x86_64" } + } + externalNativeBuild { + cmake { path "../android/CMakeLists.txt" ; version "3.22.1" } + } +} +``` + +The CMake passes `OSCTAP_ROOT` to the header-only core (override if the repo +lives elsewhere). Or build a single ABI by hand to sanity-check the toolchain: + +```sh +cmake -B build-android android \ + -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-24 \ + -DOSCTAP_ROOT=$PWD +cmake --build build-android # -> libosctap_jni.so +``` + +Then in Kotlin (`OscTap.kt`): + +```kotlin +val hub = "192.168.1.10" +val tx = OscUdp() +tx.send(hub, 9000, "/hub/led", 1) // Int -> OSC int +tx.send(hub, 9000, "/hub/pwm", 0.75f) // Float -> OSC float + +val rx = OscUdp(DatagramSocket(9001)) // telemetry relayed by the hub +thread { while (true) rx.receive()?.let { updateUi(it) } } // off the main thread +``` + +Add the permission to `AndroidManifest.xml`: + +```xml + +``` + +The bridge builds/parses OSC natively; `java.net.DatagramSocket` carries the +bytes (idiomatic Android networking). Parsing throws on malformed input and the +bridge re-throws it as a Kotlin `IllegalArgumentException`, which `receive()` +catches to drop the datagram. + +### 5b. Simpler: a JVM OSC library (no native code) + +If you don't want an NDK toolchain in your build, the app can use a pure-JVM OSC +library (e.g. **JavaOSC / illposed**) and talk to the same hub — the wire format +is identical, so OscTap on the Pi/Pico interoperates with it transparently: + +```kotlin +// implementation("com.illposed.osc:javaosc-core:0.8") +val sender = OSCPortOut(InetAddress.getByName("192.168.1.10"), 9000) +sender.send(OSCMessage("/hub/led", listOf(1))) +``` + +Trade-off: 5a dogfoods OscTap end-to-end and shares one code path across all +nodes; 5b is less setup for app developers. Both are wire-compatible. + +## 6. Bring-up order (test each leg in isolation) + +1. **Hub alone**: run `pi5_hub`, then `osc_send … /hub/led i:1` from the same Pi. + You should see the recv line and a `-> Pico` relay line. +2. **Add a fake Pico**: run another `osc_send … /sensor/temp f:20` and watch the + `-> Android` relay. Point the hub's Android target at a host running + `nc -ul 9001` (or another listener) to confirm bytes arrive. +3. **Real Pico**: flash the firmware; confirm `/sensor/*` shows up at the hub and + `/led`/`/pwm` actuate. +4. **Real Android**: send from the app; watch the hub log; confirm telemetry + reaches the phone. + +## 7. Troubleshooting + +- **Nothing arrives**: same subnet? Host firewall (`sudo ufw allow 9000/udp` on + the Pi)? Phone on Wi-Fi, not cellular? Bind the receiver to `ANY_ADDRESS` + (the hub does) so it accepts on all interfaces, not just loopback. +- **Garbled values**: OSC is big-endian; OscTap handles byte order on every node, + so garbling almost always means an **address/type mismatch** vs. the table in §1 + (e.g. sending an int where a float is expected). The hub prints the decoded type + per arg — compare against the contract. +- **String shows as `true`/garbage**: you're on an OscTap build predating the + `const char*` overload fix — pass `osctap::string_view`/`std::string`, or update. +- **App ANR / NetworkOnMainThreadException**: do all socket I/O off the main + thread (a coroutine or `thread { }`). +- **Pico resets on bad input**: that's the no-exceptions fatal handler — switch to + exceptions-enabled on the Pico for untrusted LANs (Pico guide, §"Handling + validation when exceptions are off"). + +## See also + +- [`EMBEDDED_PICO2W.md`](EMBEDDED_PICO2W.md) — the Pico 2W deep dive. +- [`../demos/pi5_hub.cpp`](../demos/pi5_hub.cpp), [`../demos/osc_send.cpp`](../demos/osc_send.cpp) — the runnable Pi 5 side. +- [`../android/`](../android) — the JNI bridge, NDK CMake, and Kotlin facade. +- [`../ROADMAP.md`](../ROADMAP.md) — where this fits in Phase 2 ("Reach"). diff --git a/docs/STATUS.md b/docs/STATUS.md index 37581da..57bb188 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -10,11 +10,15 @@ the original conversation. For the full plan and rationale see See the scorecard in `ROADMAP.md`. - **Phase 1 is complete** (directory rename + shim, ClusterFuzzLite, `bit_cast`/ `constexpr` parsing, warnings-as-errors, RTSan, TSan). -- **Phase 2 ("Reach") prep has started**: the freestanding/embedded profile - groundwork is landed — the `OSCTAP_THROW` seam (`osc/OscConfig.h`), the - `OSCTAP_FREESTANDING` CMake option + `freestanding` CI job, and the Raspberry Pi - Pico 2W guide ([`EMBEDDED_PICO2W.md`](EMBEDDED_PICO2W.md)). The remaining Reach - items (QEMU, Android NDK, multicast) stay demand-gated. +- **Phase 2 ("Reach") is underway**: landed so far — + - the freestanding/embedded profile (the `OSCTAP_THROW` seam in `osc/OscConfig.h`, + `OSCTAP_FREESTANDING` + the `freestanding` CI job) and the Pico 2W guide + ([`EMBEDDED_PICO2W.md`](EMBEDDED_PICO2W.md)); + - **aarch64 / Raspberry Pi 5 CI** under `qemu-user` (the `aarch64-qemu` job); + - the **Pi 5 ⇄ Pico 2W ⇄ Android integration**: runnable Pi 5 demos + (`demos/`, `OSCTAP_BUILD_DEMOS`), an Android JNI bridge (`android/`), and the + tutorial ([`INTEGRATION_PI5_PICO_ANDROID.md`](INTEGRATION_PI5_PICO_ANDROID.md)). + Remaining Reach items (multicast, armv7, a full Android sample app) stay demand-gated. - All six audit findings are fixed with regression tests; see commit history and `tests/OscUnitTests.cpp` (`test4`/`test5`). - **CI is the source of truth for build health.** `.github/workflows/ci.yml` builds and @@ -105,6 +109,27 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin hits the fatal handler (a remote reset/DoS). Safe only on a trusted link; open networks should keep exceptions on and `catch` the `Malformed*Exception` types. A non-throwing `TryInit`/validate is the tracked Phase 2 follow-up. +- **A runtime `const char*` now serializes as an OSC string**, via a dedicated + `OutboundPacketStream::operator<<(const char*)`. Before, a `const char*` bound to + `operator<<(bool)` (standard pointer→bool conversion outranks the user-defined + `string_view` conversion) and was silently sent as a boolean — only string + *literals* (the `const char(&)[N]` overload) worked. The fix is freestanding-safe + (forwards to `string_view`); `OscFreestandingTest` sends a runtime `const char*` + to guard it. **Don't remove it**, and don't assume `<< somePtr` ever meant bool. +- **The `ip/` networking layer now has compiled coverage** via the `demos/` + (`OSCTAP_BUILD_DEMOS`, POSIX-only) and the `aarch64-qemu` CI job. Previously the + POSIX/win32 `UdpSocket`/`NetworkingUtils` backends were header-only-but-uncompiled. + The win32 backend and the deferred `strcpy`/`gethostbyname` cleanup (#4) are still + not in the compiled surface — fold them in with multicast (#19). +- **`OSCTAP_BUILD_DEMOS` and the `aarch64-qemu` job**: demos are POSIX-only + (`ip/posix` + a SIGINT handler) and gated `NOT WIN32`. The aarch64 job runs the + suite under `qemu-user` but **excludes `OscConcurrencyTest`** (emulated threads + + loopback sockets are flaky); keep new socket/thread tests off the emulated run or + they'll flake CI. +- **Android lives in `android/`** (`osctap_jni.cpp` bridge, NDK `CMakeLists.txt`, + `OscTap.kt`). The bridge is compile-checked against `jni.h` in CI-adjacent dev but + there is **no NDK build in CI** yet — changes there aren't gated, so keep the + bridge's OscTap API usage in sync by hand (or add an NDK job when one's warranted). - **Include guards are still named `INCLUDED_OSCPACK_*`** — cosmetic, left as-is. - The test harness (`NewMessageBuffer`/`AllocateAligned4`) **intentionally leaks** its aligned scratch buffers, which is why the ASan job runs with diff --git a/osctap/osc/OscOutboundPacketStream.h b/osctap/osc/OscOutboundPacketStream.h index 8979acc..08ca475 100644 --- a/osctap/osc/OscOutboundPacketStream.h +++ b/osctap/osc/OscOutboundPacketStream.h @@ -466,6 +466,19 @@ class OutboundPacketStream{ return *this; } + // A runtime const char* would otherwise bind to operator<<(bool): the + // standard pointer->bool conversion outranks the user-defined conversion to + // string_view, so a C-string pointer was silently serialized as a boolean. + // This overload makes a const char* serialize as an OSC string, as expected. + // (String *literals* still match the more specialised const char(&)[N] + // overload below; this catches decayed/runtime pointers.) Freestanding-safe: + // forwards to the string_view overload, no heap. + OutboundPacketStream& operator<<( const char *rhs ) + { + operator<<(osctap::string_view(rhs)); + return *this; + } + #ifndef OSCTAP_FREESTANDING // Hosted convenience: std::string pulls in (and heap). The // freestanding profile omits it; pass const char*, a char array, or diff --git a/tests/OscFreestandingTest.cpp b/tests/OscFreestandingTest.cpp index e8b9237..20be81d 100644 --- a/tests/OscFreestandingTest.cpp +++ b/tests/OscFreestandingTest.cpp @@ -37,9 +37,11 @@ int main() { // --- serialize a message on the stack (no heap) ------------------------ char buffer[256]; + const char* runtimeStr = "pico"; // a runtime const char* (not a literal): + // must serialize as a string, not a bool. osctap::OutboundPacketStream p( buffer, sizeof(buffer) ); p << osctap::BeginMessage( "/freestanding" ) - << true << (int32_t)2350 << (float)3.14159f << "pico" + << true << (int32_t)2350 << (float)3.14159f << runtimeStr << osctap::EndMessage(); CHECK( p.IsReady() ); From 8bb249716629ac14c519649c94c32fe66879b945 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:31:36 +0000 Subject: [PATCH 02/16] Phase 1 cleanup: rename include guards to INCLUDED_OSCTAP_*; close SmallString.h Knock out the cosmetic Phase 1 tails (OSS-Fuzz #7 excluded): - Rename the internal include guards INCLUDED_OSCPACK_* -> INCLUDED_OSCTAP_* across the 13 osctap/ headers. The include *paths* are unchanged (redirect shim tree); only the guard macro names moved. CompatIncludeShim still green. - Close audit finding #6's "empty SmallString.h": the outbound stream no longer needs it (uses std::string_view + the const char* overload), so drop the dead include and replace the zero-byte file with a guarded, documented no-op. The file and its shim are retained so the public path keeps resolving. Verified: hosted suite + CompatIncludeShim green; freestanding green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- docs/STATUS.md | 7 +++- osctap/ip/AbstractUdpSocket.h | 6 +-- osctap/ip/IpEndpointName.h | 6 +-- osctap/ip/PacketListener.h | 6 +-- osctap/ip/TimerListener.h | 6 +-- osctap/osc/MessageMappingOscPacketListener.h | 6 +-- osctap/osc/OscConfig.h | 6 +-- osctap/osc/OscException.h | 6 +-- osctap/osc/OscHostEndianness.h | 6 +-- osctap/osc/OscOutboundPacketStream.h | 8 ++-- osctap/osc/OscPacketListener.h | 6 +-- osctap/osc/OscPrintReceivedElements.h | 6 +-- osctap/osc/OscReceivedElements.h | 6 +-- osctap/osc/OscTypes.h | 6 +-- osctap/osc/SmallString.h | 41 ++++++++++++++++++++ 15 files changed, 86 insertions(+), 42 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 57bb188..99c82d7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -130,7 +130,12 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin `OscTap.kt`). The bridge is compile-checked against `jni.h` in CI-adjacent dev but there is **no NDK build in CI** yet — changes there aren't gated, so keep the bridge's OscTap API usage in sync by hand (or add an NDK job when one's warranted). -- **Include guards are still named `INCLUDED_OSCPACK_*`** — cosmetic, left as-is. +- **Include guards are named `INCLUDED_OSCTAP_*`** (renamed from `INCLUDED_OSCPACK_*` + in Phase 2 cleanup). The `` *include paths* still work via the redirect + shim tree — only the internal guard macro names changed. +- **`osc/SmallString.h` is an intentionally-empty, guarded no-op** (audit #6 closed). + The outbound stream no longer includes it; it's kept only so the public path and its + `` shim keep resolving. Don't "fill it in" without a real need. - The test harness (`NewMessageBuffer`/`AllocateAligned4`) **intentionally leaks** its aligned scratch buffers, which is why the ASan job runs with `ASAN_OPTIONS=detect_leaks=0`. (Cleaning this up is a fine low-priority task.) diff --git a/osctap/ip/AbstractUdpSocket.h b/osctap/ip/AbstractUdpSocket.h index fbf5d84..0853f5d 100644 --- a/osctap/ip/AbstractUdpSocket.h +++ b/osctap/ip/AbstractUdpSocket.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_UDPSOCKET_H -#define INCLUDED_OSCPACK_UDPSOCKET_H +#ifndef INCLUDED_OSCTAP_UDPSOCKET_H +#define INCLUDED_OSCTAP_UDPSOCKET_H #include // size_t @@ -240,4 +240,4 @@ class UdpListeningReceiveSocket : public UdpSocket{ // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_UDPSOCKET_H */ +#endif /* INCLUDED_OSCTAP_UDPSOCKET_H */ diff --git a/osctap/ip/IpEndpointName.h b/osctap/ip/IpEndpointName.h index c30830f..7e1c22f 100644 --- a/osctap/ip/IpEndpointName.h +++ b/osctap/ip/IpEndpointName.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_IPENDPOINTNAME_H -#define INCLUDED_OSCPACK_IPENDPOINTNAME_H +#ifndef INCLUDED_OSCTAP_IPENDPOINTNAME_H +#define INCLUDED_OSCTAP_IPENDPOINTNAME_H #include @@ -132,4 +132,4 @@ inline bool operator!=( const IpEndpointName& lhs, const IpEndpointName& rhs ) // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_IPENDPOINTNAME_H */ +#endif /* INCLUDED_OSCTAP_IPENDPOINTNAME_H */ diff --git a/osctap/ip/PacketListener.h b/osctap/ip/PacketListener.h index b645b23..192cc7d 100644 --- a/osctap/ip/PacketListener.h +++ b/osctap/ip/PacketListener.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_PACKETLISTENER_H -#define INCLUDED_OSCPACK_PACKETLISTENER_H +#ifndef INCLUDED_OSCTAP_PACKETLISTENER_H +#define INCLUDED_OSCTAP_PACKETLISTENER_H namespace osctap { @@ -53,4 +53,4 @@ class PacketListener{ // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_PACKETLISTENER_H */ +#endif /* INCLUDED_OSCTAP_PACKETLISTENER_H */ diff --git a/osctap/ip/TimerListener.h b/osctap/ip/TimerListener.h index f875f08..e594b26 100644 --- a/osctap/ip/TimerListener.h +++ b/osctap/ip/TimerListener.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_TIMERLISTENER_H -#define INCLUDED_OSCPACK_TIMERLISTENER_H +#ifndef INCLUDED_OSCTAP_TIMERLISTENER_H +#define INCLUDED_OSCTAP_TIMERLISTENER_H namespace osctap { @@ -50,4 +50,4 @@ class TimerListener{ // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_TIMERLISTENER_H */ +#endif /* INCLUDED_OSCTAP_TIMERLISTENER_H */ diff --git a/osctap/osc/MessageMappingOscPacketListener.h b/osctap/osc/MessageMappingOscPacketListener.h index 72c4100..1fb934f 100644 --- a/osctap/osc/MessageMappingOscPacketListener.h +++ b/osctap/osc/MessageMappingOscPacketListener.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_MESSAGEMAPPINGOSCPACKETLISTENER_H -#define INCLUDED_OSCPACK_MESSAGEMAPPINGOSCPACKETLISTENER_H +#ifndef INCLUDED_OSCTAP_MESSAGEMAPPINGOSCPACKETLISTENER_H +#define INCLUDED_OSCTAP_MESSAGEMAPPINGOSCPACKETLISTENER_H #include #include @@ -82,4 +82,4 @@ class MessageMappingOscPacketListener : public OscPacketListener{ // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_MESSAGEMAPPINGOSCPACKETLISTENER_H */ \ No newline at end of file +#endif /* INCLUDED_OSCTAP_MESSAGEMAPPINGOSCPACKETLISTENER_H */ \ No newline at end of file diff --git a/osctap/osc/OscConfig.h b/osctap/osc/OscConfig.h index c3cf8d9..7514707 100644 --- a/osctap/osc/OscConfig.h +++ b/osctap/osc/OscConfig.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_OSCCONFIG_H -#define INCLUDED_OSCPACK_OSCCONFIG_H +#ifndef INCLUDED_OSCTAP_OSCCONFIG_H +#define INCLUDED_OSCTAP_OSCCONFIG_H /* OscTap build-configuration seam. @@ -103,4 +103,4 @@ namespace detail { # endif #endif -#endif /* INCLUDED_OSCPACK_OSCCONFIG_H */ +#endif /* INCLUDED_OSCTAP_OSCCONFIG_H */ diff --git a/osctap/osc/OscException.h b/osctap/osc/OscException.h index ab462c7..eb0c0b3 100644 --- a/osctap/osc/OscException.h +++ b/osctap/osc/OscException.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_OSCEXCEPTION_H -#define INCLUDED_OSCPACK_OSCEXCEPTION_H +#ifndef INCLUDED_OSCTAP_OSCEXCEPTION_H +#define INCLUDED_OSCTAP_OSCEXCEPTION_H #include @@ -64,4 +64,4 @@ class Exception : public std::exception { // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_OSCEXCEPTION_H */ +#endif /* INCLUDED_OSCTAP_OSCEXCEPTION_H */ diff --git a/osctap/osc/OscHostEndianness.h b/osctap/osc/OscHostEndianness.h index 4edb370..e1fc441 100644 --- a/osctap/osc/OscHostEndianness.h +++ b/osctap/osc/OscHostEndianness.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_OSCHOSTENDIANNESS_H -#define INCLUDED_OSCPACK_OSCHOSTENDIANNESS_H +#ifndef INCLUDED_OSCTAP_OSCHOSTENDIANNESS_H +#define INCLUDED_OSCTAP_OSCHOSTENDIANNESS_H /* Make sure either OSC_HOST_LITTLE_ENDIAN or OSC_HOST_BIG_ENDIAN is defined @@ -123,5 +123,5 @@ #endif -#endif /* INCLUDED_OSCPACK_OSCHOSTENDIANNESS_H */ +#endif /* INCLUDED_OSCTAP_OSCHOSTENDIANNESS_H */ diff --git a/osctap/osc/OscOutboundPacketStream.h b/osctap/osc/OscOutboundPacketStream.h index 08ca475..794884f 100644 --- a/osctap/osc/OscOutboundPacketStream.h +++ b/osctap/osc/OscOutboundPacketStream.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_OSCOUTBOUNDPACKETSTREAM_H -#define INCLUDED_OSCPACK_OSCOUTBOUNDPACKETSTREAM_H +#ifndef INCLUDED_OSCTAP_OSCOUTBOUNDPACKETSTREAM_H +#define INCLUDED_OSCTAP_OSCOUTBOUNDPACKETSTREAM_H #include // size_t @@ -51,8 +51,6 @@ namespace osctap { using string_view = std::string_view; } -#include "SmallString.h" - #include "OscTypes.h" #include "OscException.h" #include "OscConfig.h" // OSCTAP_THROW, OSCTAP_FREESTANDING @@ -678,4 +676,4 @@ class OutboundPacketStream{ // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_OSCOUTBOUNDPACKETSTREAM_H */ +#endif /* INCLUDED_OSCTAP_OSCOUTBOUNDPACKETSTREAM_H */ diff --git a/osctap/osc/OscPacketListener.h b/osctap/osc/OscPacketListener.h index d956e50..125e957 100644 --- a/osctap/osc/OscPacketListener.h +++ b/osctap/osc/OscPacketListener.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_OSCPACKETLISTENER_H -#define INCLUDED_OSCPACK_OSCPACKETLISTENER_H +#ifndef INCLUDED_OSCTAP_OSCPACKETLISTENER_H +#define INCLUDED_OSCTAP_OSCPACKETLISTENER_H #include "OscReceivedElements.h" #include "../ip/PacketListener.h" @@ -108,4 +108,4 @@ class OscPacketListener : public PacketListener{ // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_OSCPACKETLISTENER_H */ +#endif /* INCLUDED_OSCTAP_OSCPACKETLISTENER_H */ diff --git a/osctap/osc/OscPrintReceivedElements.h b/osctap/osc/OscPrintReceivedElements.h index f4686bc..82c7a65 100644 --- a/osctap/osc/OscPrintReceivedElements.h +++ b/osctap/osc/OscPrintReceivedElements.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_OSCPRINTRECEIVEDELEMENTS_H -#define INCLUDED_OSCPACK_OSCPRINTRECEIVEDELEMENTS_H +#ifndef INCLUDED_OSCTAP_OSCPRINTRECEIVEDELEMENTS_H +#define INCLUDED_OSCTAP_OSCPRINTRECEIVEDELEMENTS_H #include @@ -298,4 +298,4 @@ inline Ostream_T& operator<<( Ostream_T& os, const ReceivedPacket& p ) // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_OSCPRINTRECEIVEDELEMENTS_H */ +#endif /* INCLUDED_OSCTAP_OSCPRINTRECEIVEDELEMENTS_H */ diff --git a/osctap/osc/OscReceivedElements.h b/osctap/osc/OscReceivedElements.h index 0f5b058..76729c4 100644 --- a/osctap/osc/OscReceivedElements.h +++ b/osctap/osc/OscReceivedElements.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_OSCRECEIVEDELEMENTS_H -#define INCLUDED_OSCPACK_OSCRECEIVEDELEMENTS_H +#ifndef INCLUDED_OSCTAP_OSCRECEIVEDELEMENTS_H +#define INCLUDED_OSCTAP_OSCRECEIVEDELEMENTS_H #include #include @@ -1070,4 +1070,4 @@ inline auto end(const osctap::ReceivedMessage& mes) // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_OSCRECEIVEDELEMENTS_H */ +#endif /* INCLUDED_OSCTAP_OSCRECEIVEDELEMENTS_H */ diff --git a/osctap/osc/OscTypes.h b/osctap/osc/OscTypes.h index b17cf6b..c767ec3 100644 --- a/osctap/osc/OscTypes.h +++ b/osctap/osc/OscTypes.h @@ -34,8 +34,8 @@ requested that these non-binding requests be included whenever the above license is reproduced. */ -#ifndef INCLUDED_OSCPACK_OSCTYPES_H -#define INCLUDED_OSCPACK_OSCTYPES_H +#ifndef INCLUDED_OSCTAP_OSCTYPES_H +#define INCLUDED_OSCTAP_OSCTYPES_H #include // OSCTAP_REALTIME marks the allocation- and exception-free realtime hot path @@ -212,4 +212,4 @@ constexpr ArrayTerminator EndArray() { return {}; } // Existing code that uses the oscpack:: namespace continues to compile. namespace oscpack = osctap; -#endif /* INCLUDED_OSCPACK_OSCTYPES_H */ +#endif /* INCLUDED_OSCTAP_OSCTYPES_H */ diff --git a/osctap/osc/SmallString.h b/osctap/osc/SmallString.h index e69de29..1429ac8 100644 --- a/osctap/osc/SmallString.h +++ b/osctap/osc/SmallString.h @@ -0,0 +1,41 @@ +/* + oscpack -- Open Sound Control (OSC) packet manipulation library + http://www.rossbencina.com/code/oscpack + + Copyright (c) 2004-2013 Ross Bencina + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files + (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Software, + and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR + ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +#ifndef INCLUDED_OSCTAP_SMALLSTRING_H +#define INCLUDED_OSCTAP_SMALLSTRING_H + +/* + Intentionally empty. + + This header was a placeholder in a fork's small-string-optimisation experiment + and never carried a definition. OscTap's outbound stream now uses + std::string_view (plus a const char* overload), so nothing here is needed. The + file is retained only so the public include path -- and its + compatibility shim -- keep resolving for any downstream code that still + #includes it. Audit finding #6 ("empty SmallString.h") closed: it is now an + explicit, guarded no-op rather than a zero-byte mystery file. +*/ + +#endif /* INCLUDED_OSCTAP_SMALLSTRING_H */ From c24f522ce5ab486869e8c98b133e4709c1f5eeed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:43:09 +0000 Subject: [PATCH 03/16] Phase 2: non-throwing TryInit / TryValidatePacket for untrusted input Make the freestanding/no-exceptions build safe against malformed packets. The parser validates by throwing, and with exceptions off OSCTAP_THROW aborts, so a single bad datagram was a remote DoS. Add a non-throwing validation path: - ReceivedMessage::TryInit / ReceivedBundle::TryInit: the existing Init() logic refactored to RETURN a static error string (nullptr == ok) instead of throwing, setting the boundary members on success. The throwing Init() now delegates (Init = TryInit + OSCTAP_THROW on error), and ReceivedPacket grows a matching ValidateSizeNoThrow(). One source of truth -> the throwing and non-throwing paths cannot drift. - ReceivedMessage/ReceivedBundle gain a default ctor + static Validate() for the default-construct-then-TryInit pattern. (ReceivedMessage::size_ is no longer const.) - osctap::TryValidatePacket(data, size): recursive, non-throwing, non-allocating whole-packet gate (message, or bundle whose elements are all well-formed), bounded against deep bundle nesting. Returns nullptr iff the packet can be constructed AND read in full without any OSCTAP_THROW firing. Tests: - tests/OscValidateTest.cpp (hosted): differential check that the gate agrees with the throwing path across a valid message/bundle, every truncation, and several corruptions. Wired into ctest. - OscFreestandingTest extended: under -fno-exceptions, malformed input returns an error from TryValidatePacket instead of aborting; default-ctor + TryInit parses a valid message. Verified: hosted suite + new differential test green (incl. ASan/UBSan); test4/ test5 unchanged (throwing behaviour identical); freestanding green on GCC+Clang; aarch64 suite green under qemu. Pico guide / ROADMAP / STATUS updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- CMakeLists.txt | 6 + ROADMAP.md | 12 +- docs/EMBEDDED_PICO2W.md | 18 ++- docs/STATUS.md | 18 ++- osctap/osc/OscReceivedElements.h | 202 +++++++++++++++++++++++++------ tests/OscFreestandingTest.cpp | 19 +++ tests/OscValidateTest.cpp | 129 ++++++++++++++++++++ 7 files changed, 353 insertions(+), 51 deletions(-) create mode 100644 tests/OscValidateTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5cfe194..d8b8dec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,12 @@ if(OSCPACK_BUILD_EXAMPLES) target_link_libraries(CompatIncludeShim oscpack) add_test(NAME CompatIncludeShim COMMAND CompatIncludeShim) + # Differential test: the non-throwing TryValidatePacket() gate must agree with + # the throwing parse path (single source of truth). See OscValidateTest.cpp. + add_executable(OscValidateTest tests/OscValidateTest.cpp) + target_link_libraries(OscValidateTest oscpack) + add_test(NAME OscValidateTest COMMAND OscValidateTest) + # Realtime-safety test. Runs as a plain functional test of the read/dispatch # hot path everywhere (OSCTAP_REALTIME is a no-op off Clang>=20). With # -DOSCTAP_RTSAN=ON (Clang>=20) it additionally enforces the realtime contract: diff --git a/ROADMAP.md b/ROADMAP.md index 510e045..15e5b3e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -154,10 +154,14 @@ See [Sanitizer strategy](#sanitizer-strategy) for scope and rationale. `-fno-exceptions -fno-rtti`; a `freestanding` CI job (GCC + Clang) keeps it green. Hosted builds are byte-for-byte unchanged. **Demand signal: Raspberry Pi Pico 2W (RP2350)** — see [`docs/EMBEDDED_PICO2W.md`](docs/EMBEDDED_PICO2W.md). - Deferred: a **non-throwing `TryInit`/validate** entry point so a no-exceptions - build can *reject* untrusted packets by returning an error instead of aborting - (today, malformed input on an exceptions-off build is fatal — safe only on a - trusted link; open networks should keep exceptions on and `catch`). + **Non-throwing `TryInit`/validate landed**: `ReceivedMessage::TryInit` / + `ReceivedBundle::TryInit` and the recursive `TryValidatePacket()` gate return a + status (static error string, `nullptr` == valid) instead of throwing, so a + no-exceptions build can *reject* untrusted packets rather than abort. Built from + a single source of truth — the throwing constructors delegate to the same + validators — and guarded by `OscValidateTest` (differential vs. the throwing + path) plus a freestanding-build check that malformed input returns instead of + aborting. - [x] **aarch64 (Raspberry Pi 5) CI under QEMU** — *landed.* The `aarch64-qemu` CI job cross-compiles the suite with `aarch64-linux-gnu-g++` and runs it under `qemu-user` (via `CMAKE_CROSSCOMPILING_EMULATOR`), proving the Pi-5-class build diff --git a/docs/EMBEDDED_PICO2W.md b/docs/EMBEDDED_PICO2W.md index 8dfb600..63ea881 100644 --- a/docs/EMBEDDED_PICO2W.md +++ b/docs/EMBEDDED_PICO2W.md @@ -68,14 +68,24 @@ Pick the model that matches your threat surface: identical either way — `OSCTAP_THROW` is just `throw` here. You still get the rest of the embedded posture (no heap on the hot path, small code). +3. **Untrusted / open network, exceptions still off** — gate with the non-throwing + validator. `osctap::TryValidatePacket(data, size)` returns `nullptr` when the + packet is fully well-formed (and therefore safe to construct and read without any + `OSCTAP_THROW` firing), else a static error string. It recurses through bundles + with a nesting bound and never throws or allocates, so you can reject bad input + on a `-fno-exceptions` build instead of aborting: + + ```cpp + if (osctap::TryValidatePacket(buf, n) == nullptr) { + osctap::ReceivedMessage m(osctap::ReceivedPacket(buf, n)); // won't abort + // ... read m ... + } // else: drop the datagram + ``` + Either way, **validation runs in the lwIP receive callback, off any audio/render thread** — consistent with OscTap's realtime contract (validation may throw and is not part of the RT hot path; the throw-free `*Unchecked` accessors are). -> Future work (tracked in Phase 2): a non-throwing `TryInit`/validate entry point -> so a no-exceptions build can *reject* untrusted packets by returning an error -> instead of aborting. Until then, option 2 is the safe choice for open networks. - ## Build integration (CMake + Pico SDK) Add the OscTap include root and define the profile. OscTap's core is diff --git a/docs/STATUS.md b/docs/STATUS.md index 99c82d7..65df175 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -104,11 +104,19 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin or it will break the `freestanding` job. The freestanding flags are **PRIVATE to the `OscFreestandingTest` target**, so the exception-based tests are unaffected. Embedded posture and the Pico 2W recipe live in `docs/EMBEDDED_PICO2W.md`. -- **Untrusted input on a no-exceptions build is fatal.** The parser validates by - throwing; with exceptions off there is nothing to catch, so a malformed packet - hits the fatal handler (a remote reset/DoS). Safe only on a trusted link; open - networks should keep exceptions on and `catch` the `Malformed*Exception` types. A - non-throwing `TryInit`/validate is the tracked Phase 2 follow-up. +- **Untrusted input on a no-exceptions build: gate it with `TryValidatePacket()`.** + The parser validates by throwing, and with exceptions off `OSCTAP_THROW` aborts — + so on a freestanding/no-exceptions build, call `osctap::TryValidatePacket(data, size)` + (returns `nullptr` when fully well-formed, else a static error string) before + constructing/reading; it recurses through bundles with a nesting bound and never + throws/allocates. `ReceivedMessage::TryInit`/`ReceivedBundle::TryInit` are the + per-element non-throwing parses. **These are the single source of truth** — the + throwing `Init()`/constructors delegate to them (`Init` = `TryInit` + `OSCTAP_THROW` + on the returned error), so **don't fork the validation logic**: edit `TryInit` and + both paths stay in lock-step. `OscValidateTest` is the differential guard (gate vs. + throwing path) and must stay green. Note `ReceivedMessage::size_` is no longer + `const` (TryInit assigns it) and both classes gained a default ctor for the + default-construct-then-`TryInit` pattern. - **A runtime `const char*` now serializes as an OSC string**, via a dedicated `OutboundPacketStream::operator<<(const char*)`. Before, a `const char*` bound to `operator<<(bool)` (standard pointer→bool conversion outranks the user-defined diff --git a/osctap/osc/OscReceivedElements.h b/osctap/osc/OscReceivedElements.h index 76729c4..f1085ee 100644 --- a/osctap/osc/OscReceivedElements.h +++ b/osctap/osc/OscReceivedElements.h @@ -117,22 +117,34 @@ class ReceivedPacket{ osc_bundle_element_size_t Size() const { return size_; } const char *Contents() const { return contents_; } - private: - const char *contents_; - osc_bundle_element_size_t size_; - - static osc_bundle_element_size_t ValidateSize( osc_bundle_element_size_t size ) + // Non-throwing size validation: returns nullptr if `size` is an acceptable + // packet/element size, else a static error string. The single source of the + // size rules, shared by the throwing ValidateSize() and the non-throwing + // TryValidatePacket(). + static const char* ValidateSizeNoThrow( osc_bundle_element_size_t size ) { // sanity check integer types declared in OscTypes.h // you'll need to fix OscTypes.h if any of these asserts fail if( !IsValidElementSizeValue(size) ) - OSCTAP_THROW( MalformedPacketException( "invalid packet size" ) ); + return "invalid packet size"; if( size == 0 ) - OSCTAP_THROW( MalformedPacketException( "zero length elements not permitted" ) ); + return "zero length elements not permitted"; if( !IsMultipleOf4(size) ) - OSCTAP_THROW( MalformedPacketException( "element size must be multiple of four" ) ); + return "element size must be multiple of four"; + + return nullptr; + } + + private: + const char *contents_; + osc_bundle_element_size_t size_; + + static osc_bundle_element_size_t ValidateSize( osc_bundle_element_size_t size ) + { + if( const char* err = ValidateSizeNoThrow( size ) ) + OSCTAP_THROW( MalformedPacketException( err ) ); return size; } @@ -717,23 +729,35 @@ class ReceivedMessageArgumentStream{ class ReceivedMessage{ - void Init( const char *message, osc_bundle_element_size_t size ) + public: + // Non-throwing parse + structural validation. Sets all boundary members and + // returns nullptr on success, or a static error string on malformed input. + // Use on no-exceptions / untrusted-input paths: + // ReceivedMessage m; + // if( m.TryInit(data, size) == nullptr ) { /* read m */ } + // This is the single source of truth: the throwing Init() below delegates + // here, as does the non-throwing Validate() / TryValidatePacket() gate for + // untrusted input on no-exceptions builds. + const char* TryInit( const char *message, osc_bundle_element_size_t size ) { + addressPattern_ = message; + size_ = size; + if( !IsValidElementSizeValue(size) ) - OSCTAP_THROW( MalformedMessageException( "invalid message size" ) ); + return "invalid message size"; if( size == 0 ) - OSCTAP_THROW( MalformedMessageException( "zero length messages not permitted" ) ); + return "zero length messages not permitted"; if( !IsMultipleOf4(size) ) - OSCTAP_THROW( MalformedMessageException( "message size must be multiple of four" ) ); + return "message size must be multiple of four"; const char *end = message + size; typeTagsBegin_ = FindStr4End( addressPattern_, end ); if( typeTagsBegin_ == 0 ){ // address pattern was not terminated before end - OSCTAP_THROW( MalformedMessageException( "unterminated address pattern" ) ); + return "unterminated address pattern"; } if( typeTagsBegin_ == end ){ @@ -744,7 +768,7 @@ class ReceivedMessage{ }else{ if( *typeTagsBegin_ != ',' ) - OSCTAP_THROW( MalformedMessageException( "type tags not present" ) ); + return "type tags not present"; if( *(typeTagsBegin_ + 1) == '\0' ){ // zero length type tags @@ -757,7 +781,7 @@ class ReceivedMessage{ arguments_ = FindStr4End( typeTagsBegin_, end ); if( arguments_ == 0 ){ - OSCTAP_THROW( MalformedMessageException( "type tags were not terminated before end of message" ) ); + return "type tags were not terminated before end of message"; } ++typeTagsBegin_; // advance past initial ',' @@ -785,7 +809,7 @@ class ReceivedMessage{ case ARRAY_END_TYPE_TAG: if( arrayLevel == 0 ) - OSCTAP_THROW( MalformedMessageException( "array close tag ']' without matching open tag '['" ) ); + return "array close tag ']' without matching open tag '['"; --arrayLevel; // (zero length argument data) break; @@ -797,10 +821,10 @@ class ReceivedMessage{ case MIDI_MESSAGE_TYPE_TAG: if( argument == end ) - OSCTAP_THROW( MalformedMessageException( "arguments exceed message size" ) ); + return "arguments exceed message size"; argument += 4; if( argument > end ) - OSCTAP_THROW( MalformedMessageException( "arguments exceed message size" ) ); + return "arguments exceed message size"; break; case INT64_TYPE_TAG: @@ -808,31 +832,31 @@ class ReceivedMessage{ case DOUBLE_TYPE_TAG: if( argument == end ) - OSCTAP_THROW( MalformedMessageException( "arguments exceed message size" ) ); + return "arguments exceed message size"; argument += 8; if( argument > end ) - OSCTAP_THROW( MalformedMessageException( "arguments exceed message size" ) ); + return "arguments exceed message size"; break; case STRING_TYPE_TAG: case SYMBOL_TYPE_TAG: if( argument == end ) - OSCTAP_THROW( MalformedMessageException( "arguments exceed message size" ) ); + return "arguments exceed message size"; argument = FindStr4End( argument, end ); if( argument == 0 ) - OSCTAP_THROW( MalformedMessageException( "unterminated string argument" ) ); + return "unterminated string argument"; break; case BLOB_TYPE_TAG: { if( argument + osctap::OSC_SIZEOF_INT32 > end ) - OSCTAP_THROW( MalformedMessageException( "arguments exceed message size" ) ); + return "arguments exceed message size"; // treat blob size as an unsigned int for the purposes of this calculation uint32_t blobSize = ToUInt32( argument ); if( !IsValidElementSizeValue( (osc_bundle_element_size_t)blobSize ) ) - OSCTAP_THROW( MalformedMessageException( "invalid blob size" ) ); + return "invalid blob size"; // Compare sizes rather than advancing the pointer first: a huge // blobSize must not be allowed to overflow the pointer (or RoundUp4) @@ -840,21 +864,21 @@ class ReceivedMessage{ // guaranteed by the check above. const char *blobData = argument + osctap::OSC_SIZEOF_INT32; if( RoundUp4( blobSize ) > (uint32_t)(end - blobData) ) - OSCTAP_THROW( MalformedMessageException( "arguments exceed message size" ) ); + return "arguments exceed message size"; argument = blobData + RoundUp4( blobSize ); } break; default: - OSCTAP_THROW( MalformedMessageException( "unknown type tag" ) ); + return "unknown type tag"; } }while( *++typeTag != '\0' ); typeTagsEnd_ = typeTag; if( arrayLevel != 0 ) - OSCTAP_THROW( MalformedMessageException( "array was not terminated before end of message (expected ']' end of array tag)" ) ); + return "array was not terminated before end of message (expected ']' end of array tag)"; } // These invariants should be guaranteed by the above code. @@ -865,8 +889,23 @@ class ReceivedMessage{ assert( argumentCount <= OSC_INT32_MAX ); #endif } + + return nullptr; + } + + // Throwing wrapper used by the constructors (preserves the original API). + void Init( const char *message, osc_bundle_element_size_t size ) + { + if( const char* err = TryInit( message, size ) ) + OSCTAP_THROW( MalformedMessageException( err ) ); } public: + // Default-constructs an empty (invalid) message for use with the non-throwing + // TryInit() below. Reading it before a successful TryInit() is undefined. + ReceivedMessage() + : addressPattern_( nullptr ), typeTagsBegin_( nullptr ) + , typeTagsEnd_( nullptr ), arguments_( nullptr ), size_( 0 ) {} + explicit ReceivedMessage( const ReceivedPacket& packet ) : addressPattern_( packet.Contents() ), size_{packet.Size()} { @@ -877,6 +916,14 @@ class ReceivedMessage{ { Init( bundleElement.Contents(), bundleElement.Size() ); } + + // Non-throwing structural validation of a message body, without retaining the + // parsed object. nullptr == well-formed. + static const char* Validate( const char *message, osc_bundle_element_size_t size ) + { + ReceivedMessage m; + return m.TryInit( message, size ); + } const char *AddressPattern() const OSCTAP_REALTIME { return addressPattern_; } // Support for non-standard SuperCollider integer address patterns: @@ -936,7 +983,7 @@ class ReceivedMessage{ const char *typeTagsBegin_; const char *typeTagsEnd_; const char *arguments_; - const osc_bundle_element_size_t size_; + osc_bundle_element_size_t size_; // not const: TryInit() (re)assigns during parse }; #ifndef OSCTAP_FREESTANDING @@ -963,17 +1010,25 @@ class OwnedMessage #endif // OSCTAP_FREESTANDING class ReceivedBundle{ - void Init( const char *bundle, osc_bundle_element_size_t size ) + public: + // Non-throwing parse + structural validation of the bundle framing (size, + // "#bundle" tag, and element-size table). Returns nullptr on success (members + // set), or a static error string. Single source of truth; the throwing Init() + // delegates here. Note: this validates the bundle's own framing, not the + // contents of each element -- use TryValidatePacket() for a full recursive + // check before reading untrusted bundles on a no-exceptions build. + const char* TryInit( const char *bundle, osc_bundle_element_size_t size ) { + elementCount_ = 0; if( !IsValidElementSizeValue(size) ) - OSCTAP_THROW( MalformedBundleException( "invalid bundle size" ) ); + return "invalid bundle size"; if( size < 16 ) - OSCTAP_THROW( MalformedBundleException( "packet too short for bundle" ) ); + return "packet too short for bundle"; if( !IsMultipleOf4(size) ) - OSCTAP_THROW( MalformedBundleException( "bundle size must be multiple of four" ) ); + return "bundle size must be multiple of four"; if( bundle[0] != '#' || bundle[1] != 'b' @@ -983,7 +1038,7 @@ class ReceivedBundle{ || bundle[5] != 'l' || bundle[6] != 'e' || bundle[7] != '\0' ) - OSCTAP_THROW( MalformedBundleException( "bad bundle address pattern" ) ); + return "bad bundle address pattern"; end_ = bundle + size; @@ -993,18 +1048,18 @@ class ReceivedBundle{ while( p < end_ ){ if( p + osctap::OSC_SIZEOF_INT32 > end_ ) - OSCTAP_THROW( MalformedBundleException( "packet too short for elementSize" ) ); + return "packet too short for elementSize"; // treat element size as an unsigned int for the purposes of this calculation uint32_t elementSize = ToUInt32( p ); if( (elementSize & ((uint32_t)0x03)) != 0 ) - OSCTAP_THROW( MalformedBundleException( "bundle element size must be multiple of four" ) ); + return "bundle element size must be multiple of four"; // Compare sizes rather than advancing the pointer first, so that a huge // elementSize can't overflow the pointer and slip past the bounds check. const char *elementData = p + osctap::OSC_SIZEOF_INT32; if( elementSize > (uint32_t)(end_ - elementData) ) - OSCTAP_THROW( MalformedBundleException( "packet too short for bundle element" ) ); + return "packet too short for bundle element"; p = elementData + elementSize; @@ -1012,9 +1067,22 @@ class ReceivedBundle{ } if( p != end_ ) - OSCTAP_THROW( MalformedBundleException( "bundle contents " ) ); + return "bundle contents "; + + return nullptr; } - public: + + // Throwing wrapper used by the constructors (preserves the original API). + void Init( const char *bundle, osc_bundle_element_size_t size ) + { + if( const char* err = TryInit( bundle, size ) ) + OSCTAP_THROW( MalformedBundleException( err ) ); + } + + // Default-constructs an empty (invalid) bundle for use with TryInit(). + ReceivedBundle() + : timeTag_( nullptr ), end_( nullptr ), elementCount_( 0 ) {} + explicit ReceivedBundle( const ReceivedPacket& packet ) : elementCount_( 0 ) { @@ -1026,6 +1094,14 @@ class ReceivedBundle{ Init( bundleElement.Contents(), bundleElement.Size() ); } + // Non-throwing structural validation of the bundle framing, without retaining + // the parsed object. nullptr == well-formed framing. + static const char* Validate( const char *bundle, osc_bundle_element_size_t size ) + { + ReceivedBundle b; + return b.TryInit( bundle, size ); + } + uint64_t TimeTag() const { return ToUInt64( timeTag_ ); @@ -1062,6 +1138,56 @@ inline auto end(const osctap::ReceivedMessage& mes) return mes.ArgumentsEnd(); } + +// Non-throwing, recursive validation of a complete OSC packet -- a message, or a +// bundle whose every element is itself well-formed, recursively. Returns nullptr +// if [data, data+size) is fully well-formed and therefore safe to construct *and +// read in full* without any OSCTAP_THROW firing; otherwise a static error string. +// +// This is the gate to use before handling untrusted input on a no-exceptions / +// freestanding build, where a malformed packet would otherwise hit the fatal +// handler (abort) during construction or iteration: +// +// if( osctap::TryValidatePacket(buf, n) == nullptr ) { +// osctap::ReceivedPacket p(buf, n); // won't abort +// ... read the message / iterate the bundle ... +// } else { +// ... drop the datagram ... +// } +// +// maxBundleNestingDepth bounds the recursion so a deeply-nested bundle from an +// attacker cannot exhaust the stack (mirrors OscPacketListener's dispatch bound). +inline const char* TryValidatePacket( const char *data, osc_bundle_element_size_t size, + unsigned int maxBundleNestingDepth = 64 ) +{ + if( const char* err = ReceivedPacket::ValidateSizeNoThrow( size ) ) + return err; + + if( size > 0 && data[0] == '#' ){ + // Bundle: validate the framing, then recurse into each element's contents. + if( const char* err = ReceivedBundle::Validate( data, size ) ) + return err; + if( maxBundleNestingDepth == 0 ) + return "bundle nested too deeply"; + + const char *end = data + size; + const char *p = data + 16; // skip "#bundle\0" (8) + time tag (8) + while( p < end ){ + // Framing was validated above: elementSize is multiple-of-4 and in bounds. + uint32_t elementSize = ToUInt32( p ); + const char *elementData = p + osctap::OSC_SIZEOF_INT32; + if( const char* err = TryValidatePacket( elementData, + (osc_bundle_element_size_t)elementSize, maxBundleNestingDepth - 1 ) ) + return err; + p = elementData + elementSize; + } + return nullptr; + } + + // Message. + return ReceivedMessage::Validate( data, size ); +} + } // namespace osctap diff --git a/tests/OscFreestandingTest.cpp b/tests/OscFreestandingTest.cpp index 20be81d..ce592b5 100644 --- a/tests/OscFreestandingTest.cpp +++ b/tests/OscFreestandingTest.cpp @@ -70,6 +70,25 @@ int main() CHECK( arg->AsFloatUnchecked() > 3.14f ); ++arg; CHECK( std::strcmp( arg->AsStringUnchecked(), "pico" ) == 0 ); + // --- non-throwing validation gate (the point of TryInit/TryValidatePacket) --- + // On this build OSCTAP_THROW would abort via the fatal handler, so the only + // safe way to handle untrusted input is to gate it first. Reaching these lines + // at all proves the gate returns instead of throwing/aborting. + typedef osctap::osc_bundle_element_size_t sz_t; + CHECK( osctap::TryValidatePacket( p.Data(), (sz_t)p.Size() ) == nullptr ); // valid -> accepted + + // truncate the valid message by one 4-byte word: arguments now exceed size. + CHECK( osctap::TryValidatePacket( p.Data(), (sz_t)(p.Size() - 4) ) != nullptr ); + + // structurally bogus little buffer (not a valid message): rejected, not fatal. + const char bad[6] = { '/', 'x', '\0', '\0', ',', 'i' }; + CHECK( osctap::TryValidatePacket( bad, (sz_t)sizeof(bad) ) != nullptr ); + + // The same gate also drives a no-abort ReceivedMessage parse: + osctap::ReceivedMessage probe; + CHECK( probe.TryInit( p.Data(), (sz_t)p.Size() ) == nullptr ); + CHECK( std::strcmp( probe.AddressPattern(), "/freestanding" ) == 0 ); + if( failures == 0 ) std::printf( "OscFreestandingTest: OK (exceptions disabled, freestanding)\n" ); return failures == 0 ? 0 : 1; diff --git a/tests/OscValidateTest.cpp b/tests/OscValidateTest.cpp new file mode 100644 index 0000000..6c61928 --- /dev/null +++ b/tests/OscValidateTest.cpp @@ -0,0 +1,129 @@ +/* + OscTap non-throwing-validation test. + + Verifies that osctap::TryValidatePacket() (the non-throwing gate for untrusted + input on no-exceptions / freestanding builds) agrees with the throwing parse + path: for any input, TryValidatePacket() returns nullptr (valid) iff a full + recursive read of that packet would NOT throw. This differential check is what + makes the single-source refactor trustworthy -- the validator and the throwing + constructors share one implementation, and this proves they stay in lock-step. + + Built on the hosted matrix (exceptions on) so it can use try/catch as the + oracle. The freestanding harness separately proves TryValidatePacket() rejects + malformed input by *returning* instead of aborting. +*/ + +#include "osc/OscReceivedElements.h" +#include "osc/OscOutboundPacketStream.h" + +#include +#include +#include + +using osctap::osc_bundle_element_size_t; + +static int failures = 0; +#define CHECK(cond) do{ if(!(cond)){ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++failures; } }while(0) + +// Oracle: does a *full recursive read* of this packet throw? Mirrors exactly what +// TryValidatePacket promises is safe -- construct, and recurse through bundles and +// messages, touching everything the validator walks. +static void FullRead( const char* data, osc_bundle_element_size_t size ) +{ + osctap::ReceivedPacket p( data, size ); + if( p.IsBundle() ){ + osctap::ReceivedBundle b( p ); + for( auto i = b.ElementsBegin(); i != b.ElementsEnd(); ++i ){ + if( i->IsBundle() ) { + osctap::ReceivedBundle nested( *i ); + (void)nested.ElementCount(); + for( auto j = nested.ElementsBegin(); j != nested.ElementsEnd(); ++j ){ + if( !j->IsBundle() ){ osctap::ReceivedMessage m(*j); (void)m.ArgumentCount(); } + } + } else { + osctap::ReceivedMessage m( *i ); + (void)m.ArgumentCount(); + } + } + } else { + osctap::ReceivedMessage m( p ); + (void)m.ArgumentCount(); + } +} + +static bool ThrowingPathRejects( const char* data, osc_bundle_element_size_t size ) +{ + try { FullRead( data, size ); return false; } + catch( const osctap::Exception& ) { return true; } +} + +// Assert the non-throwing gate and the throwing path agree on this input. +static void Differential( const char* label, const std::vector& buf ) +{ + const osc_bundle_element_size_t n = (osc_bundle_element_size_t)buf.size(); + const bool rejects = ThrowingPathRejects( buf.data(), n ); + const bool gateRejects = ( osctap::TryValidatePacket( buf.data(), n ) != nullptr ); + if( rejects != gateRejects ) + std::printf( "FAIL [%s]: throwing-path rejects=%d but gate rejects=%d\n", + label, (int)rejects, (int)gateRejects ), ++failures; +} + +static std::vector BuildMessage() +{ + char tmp[256]; + osctap::OutboundPacketStream p( tmp, sizeof(tmp) ); + p << osctap::BeginMessage( "/x" ) << (int32_t)1 << (float)2.5f << "hi" << true + << osctap::EndMessage(); + return std::vector( p.Data(), p.Data() + p.Size() ); +} + +static std::vector BuildBundle() +{ + char tmp[256]; + osctap::OutboundPacketStream p( tmp, sizeof(tmp) ); + p << osctap::BeginBundleImmediate() + << osctap::BeginMessage( "/a" ) << (int32_t)7 << osctap::EndMessage() + << osctap::BeginMessage( "/b" ) << "yo" << osctap::EndMessage() + << osctap::EndBundle(); + return std::vector( p.Data(), p.Data() + p.Size() ); +} + +int main() +{ + // --- valid inputs: both paths accept --- + const std::vector msg = BuildMessage(); + const std::vector bun = BuildBundle(); + CHECK( osctap::TryValidatePacket( msg.data(), (osc_bundle_element_size_t)msg.size() ) == nullptr ); + CHECK( osctap::TryValidatePacket( bun.data(), (osc_bundle_element_size_t)bun.size() ) == nullptr ); + Differential( "valid message", msg ); + Differential( "valid bundle", bun ); + + // --- malformed inputs: both paths must reject, identically --- + // truncations (every prefix that isn't the whole thing) + for( std::size_t k = 1; k < msg.size(); ++k ) + Differential( "msg prefix", std::vector( msg.begin(), msg.begin() + k ) ); + for( std::size_t k = 1; k < bun.size(); ++k ) + Differential( "bun prefix", std::vector( bun.begin(), bun.begin() + k ) ); + + // corrupt the type tag to an unknown tag + { auto b = msg; // find the ',' type-tag start and poke a bogus tag after it + for( std::size_t i = 0; i + 1 < b.size(); ++i ) if( b[i] == ',' ){ b[i+1] = 'Q'; break; } + Differential( "unknown type tag", b ); } + + // claim a huge blob/forge: flip a size-ish word in the bundle's element size + { auto b = bun; if( b.size() > 19 ){ b[16] = (char)0x7F; b[17] = (char)0xFF; } // element size huge + Differential( "bundle element size overflow", b ); } + + // non-multiple-of-4 total size + { auto b = msg; b.push_back( 'x' ); Differential( "size not mult of 4", b ); } + + // --- explicit checks: trivial bad size + the nesting bound --- + CHECK( osctap::TryValidatePacket( "\0\0\0", 3 ) != nullptr ); // size 3: rejected + // The top-level bundle counts as the first nesting level, so maxDepth 0 rejects + // it outright; the default depth accepts the same (shallow) bundle. + CHECK( osctap::TryValidatePacket( bun.data(), (osc_bundle_element_size_t)bun.size(), 0 ) != nullptr ); + CHECK( osctap::TryValidatePacket( bun.data(), (osc_bundle_element_size_t)bun.size() ) == nullptr ); + + if( failures == 0 ) std::printf( "OscValidateTest: OK (gate agrees with throwing path)\n" ); + return failures == 0 ? 0 : 1; +} From 25fc5c298d843571e98c479df7124700282754fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:46:59 +0000 Subject: [PATCH 04/16] Phase 2: win32 socket backend compile/link smoke The ip/win32 socket backend had no compiled coverage (the POSIX demos/tests are gated off Windows), so the getaddrinfo port and the rest only ever existed on paper. Add tests/Win32SocketSmoke.cpp, a WIN32-gated target the existing windows-latest CI legs build: it instantiates and links the win32 UdpSocket/SocketReceiveMultiplexer templates and the getaddrinfo-based GetHostByName (so a hard error or missing symbol fails the build), while guarding the actual socket calls behind a never-taken branch so CI needs no live network. Built with /WX- (warnings-as-errors off for this TU only): it brings the win32 sockets into the build for the first time, and gating CI on their previously unexercised /W4 surface is a separate cleanup. Verified locally by cross-compiling with MinGW-w64 (-Wall -Wextra, clean): the backend compiles and links to a PE32+ executable (winsock2 / getaddrinfo / timeGetTime all resolved). STATUS.md updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- CMakeLists.txt | 15 +++++++++ docs/STATUS.md | 15 ++++++--- tests/Win32SocketSmoke.cpp | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 tests/Win32SocketSmoke.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d8b8dec..de1220e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,21 @@ if(OSCPACK_BUILD_EXAMPLES) target_link_libraries(OscValidateTest oscpack) add_test(NAME OscValidateTest COMMAND OscValidateTest) + # Compile/link smoke for the win32 socket backend, which otherwise has no + # compiled coverage (the POSIX demos/tests are gated off Windows). Built by the + # existing windows-latest CI legs. Warnings-as-errors is deliberately OFF for + # this TU (via /WX-, overriding the INTERFACE /WX): it brings the win32 sockets + # into the build for the first time, and gating CI on their previously + # unexercised /W4 surface is a separate cleanup. + if(WIN32) + add_executable(Win32SocketSmoke tests/Win32SocketSmoke.cpp) + target_link_libraries(Win32SocketSmoke oscpack) + if(OSCTAP_WARNINGS_AS_ERRORS) + target_compile_options(Win32SocketSmoke PRIVATE /WX-) + endif() + add_test(NAME Win32SocketSmoke COMMAND Win32SocketSmoke) + endif() + # Realtime-safety test. Runs as a plain functional test of the read/dispatch # hot path everywhere (OSCTAP_REALTIME is a no-op off Clang>=20). With # -DOSCTAP_RTSAN=ON (Clang>=20) it additionally enforces the realtime contract: diff --git a/docs/STATUS.md b/docs/STATUS.md index 65df175..a09f8d5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -124,11 +124,16 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin *literals* (the `const char(&)[N]` overload) worked. The fix is freestanding-safe (forwards to `string_view`); `OscFreestandingTest` sends a runtime `const char*` to guard it. **Don't remove it**, and don't assume `<< somePtr` ever meant bool. -- **The `ip/` networking layer now has compiled coverage** via the `demos/` - (`OSCTAP_BUILD_DEMOS`, POSIX-only) and the `aarch64-qemu` CI job. Previously the - POSIX/win32 `UdpSocket`/`NetworkingUtils` backends were header-only-but-uncompiled. - The win32 backend and the deferred `strcpy`/`gethostbyname` cleanup (#4) are still - not in the compiled surface — fold them in with multicast (#19). +- **The `ip/` networking layer now has compiled coverage on both platforms.** POSIX + via the `demos/` (`OSCTAP_BUILD_DEMOS`) and the `aarch64-qemu` CI job; **win32** via + `tests/Win32SocketSmoke.cpp` (a WIN32-gated target the existing windows-latest legs + build — it instantiates + links the win32 `UdpSocket`/multiplexer/`getaddrinfo` + paths but guards the socket calls off the runtime path, so CI needs no live + network). The win32 smoke builds with `/WX-` (warnings-as-errors off for that TU): + cleaning the win32 backend's `/W4` surface (and the `timeGetTime` 40-day `FIXME`) + to pass `/WX` is the remaining follow-up. It compiles clean under MinGW + `-Wall -Wextra`. (The Phase 1 `strcpy`/`gethostbyname` deferral is fully resolved — + no occurrences remain in `ip/`.) - **`OSCTAP_BUILD_DEMOS` and the `aarch64-qemu` job**: demos are POSIX-only (`ip/posix` + a SIGINT handler) and gated `NOT WIN32`. The aarch64 job runs the suite under `qemu-user` but **excludes `OscConcurrencyTest`** (emulated threads + diff --git a/tests/Win32SocketSmoke.cpp b/tests/Win32SocketSmoke.cpp new file mode 100644 index 0000000..40579a2 --- /dev/null +++ b/tests/Win32SocketSmoke.cpp @@ -0,0 +1,64 @@ +/* + OscTap win32 socket-backend compile/link smoke (Windows-only). + + The win32 socket backend (ip/win32/UdpSocket.h, NetworkingUtils.h) had no compiled + coverage: the POSIX demos/tests are gated off Windows, and nothing else pulled + the win32 sockets into a build. This TU closes that gap. It is built by the + existing windows-latest CI legs via the WIN32-gated CMake target, so the + getaddrinfo port and the rest of the win32 backend now actually compile + link + on every push. + + It is a *compile/link* smoke, not a runtime network test: the socket paths are + instantiated and linked (so a hard error or missing symbol fails the build) but + are guarded behind a condition that is never taken, so CI needs no live network. + Only IpEndpointName string formatting actually runs. +*/ + +#include "ip/UdpSocket.h" +#include "ip/IpEndpointName.h" +#include "ip/PacketListener.h" +#include "osc/OscOutboundPacketStream.h" + +#include + +namespace { + +class NullListener : public osctap::PacketListener { +public: + void ProcessPacket( const char *, int, const osctap::IpEndpointName & ) override {} +}; + +// Defined and ODR-used (its address is taken below) but never executed on CI. +// Forces the win32 UdpSocket / SocketReceiveMultiplexer template members -- the +// ctor, Bind/SendTo/Send, Run/AsynchronousBreak, and the getaddrinfo-based +// GetHostByName -- to compile and link. +void exercise_win32_backend() +{ + osctap::IpEndpointName ep( "127.0.0.1", 9000 ); // -> GetHostByName -> getaddrinfo + + osctap::UdpTransmitSocket tx( ep ); + char buf[8] = { 0 }; + tx.Send( buf, sizeof(buf) ); + + NullListener listener; + osctap::UdpListeningReceiveSocket rx( + osctap::IpEndpointName( osctap::IpEndpointName::ANY_ADDRESS, 9000 ), &listener ); + rx.AsynchronousBreak(); +} + +} // namespace + +int main( int argc, char ** /*argv*/ ) +{ + // Actually runs (no network): exercise IpEndpointName formatting. + char s[ osctap::IpEndpointName::ADDRESS_AND_PORT_STRING_LENGTH ]; + osctap::IpEndpointName( 127, 0, 0, 1, 9000 ).AddressAndPortAsString( s ); + std::printf( "win32 socket smoke: %s\n", s ); + + // ODR-use the socket exercise so it links, but never call it on CI. + void (*fn)() = &exercise_win32_backend; + if( argc == 0x7fffffff ) // never true; opaque to the optimiser + fn(); + + return 0; +} From c261239c0d7a2ed064f1226ce460d2015836f908 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 22:00:16 +0000 Subject: [PATCH 05/16] TCP v1 (issue #14), part 1: length-prefix stream framing codec The transport-agnostic foundation for OSC over TCP. A byte stream has no message boundaries, so the receiver must reassemble packets from arbitrarily chunked reads; this is the one genuinely new algorithm, and it is security-critical (the length prefix is attacker-controlled). - osc/OscStreamFraming.h (header-only, + oscpack shim): * WriteOscStreamFrameHeader / FrameOscPacket: trivial length-prefix encoder. * OscStreamDeframer: streaming decoder. Reassembles complete packets across any read boundary, dispatches whole-in-a-chunk packets in place (no copy), and caps the frame size (default 64 KiB) so a hostile prefix can't make it buffer unbounded data (cf. the blob-size discipline from #1/#4). Non-throwing. - tests/OscStreamFramingTest.cpp: reassembly is invariant across every chunk size from 1 byte to the whole stream; coalesced packets; header split across reads; empty payloads; the oversized-frame guard and the exact cap boundary. - fuzz/fuzz_deframe.cpp + fuzz/corpus_deframe/: feeds bytes through the deframer in pseudo-random chunks and runs each frame through the parser (the full TCP receive path). Wired into CMake (both fuzzers via a foreach), the CI fuzz-smoke job, and ClusterFuzzLite. 200k standalone ASan/UBSan mutations: no crash. SLIP framing is intentionally deferred (length-prefix is the de-facto standard). Sockets land next. Verified: hosted suite 6/6; framing test green; deframer fuzzer ASan/UBSan-clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- .clusterfuzzlite/build.sh | 11 +- .github/workflows/ci.yml | 11 +- CMakeLists.txt | 32 +-- fuzz/corpus_deframe/bundle.osc.framed | Bin 0 -> 60 bytes .../corpus_deframe/msg_blob_string.osc.framed | Bin 0 -> 32 bytes fuzz/corpus_deframe/msg_simple.osc.framed | Bin 0 -> 28 bytes .../corpus_deframe/msg_types_array.osc.framed | Bin 0 -> 52 bytes fuzz/corpus_deframe/multi.framed | Bin 0 -> 172 bytes fuzz/fuzz_deframe.cpp | 75 +++++++ oscpack/osc/OscStreamFraming.h | 11 ++ osctap/osc/OscStreamFraming.h | 183 ++++++++++++++++++ tests/OscStreamFramingTest.cpp | 124 ++++++++++++ 12 files changed, 431 insertions(+), 16 deletions(-) create mode 100644 fuzz/corpus_deframe/bundle.osc.framed create mode 100644 fuzz/corpus_deframe/msg_blob_string.osc.framed create mode 100644 fuzz/corpus_deframe/msg_simple.osc.framed create mode 100644 fuzz/corpus_deframe/msg_types_array.osc.framed create mode 100644 fuzz/corpus_deframe/multi.framed create mode 100644 fuzz/fuzz_deframe.cpp create mode 100644 oscpack/osc/OscStreamFraming.h create mode 100644 osctap/osc/OscStreamFraming.h create mode 100644 tests/OscStreamFramingTest.cpp diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh index 8ca10d5..8eea43d 100755 --- a/.clusterfuzzlite/build.sh +++ b/.clusterfuzzlite/build.sh @@ -7,11 +7,20 @@ # ($LIB_FUZZING_ENGINE) rather than going through the project's CMake (which pins # its own sanitizer flags for local use). WORKDIR is the project root. +# fuzz_parse: the OSC packet parser (untrusted bytes -> ReceivedPacket/...). $CXX $CXXFLAGS -std=c++17 -I osctap \ fuzz/fuzz_parse.cpp \ $LIB_FUZZING_ENGINE \ -o "$OUT/fuzz_parse" -# Ship the seed corpus next to the target. OSS-Fuzz / ClusterFuzzLite +# fuzz_deframe: the OSC-over-TCP stream deframer + parser (length-prefix +# reassembly across arbitrary chunk boundaries, bounded-buffer / DoS guard). +$CXX $CXXFLAGS -std=c++17 -I osctap \ + fuzz/fuzz_deframe.cpp \ + $LIB_FUZZING_ENGINE \ + -o "$OUT/fuzz_deframe" + +# Ship the seed corpora next to the targets. OSS-Fuzz / ClusterFuzzLite # automatically load _seed_corpus.zip before fuzzing. zip -j "$OUT/fuzz_parse_seed_corpus.zip" fuzz/corpus/* +zip -j "$OUT/fuzz_deframe_seed_corpus.zip" fuzz/corpus_deframe/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 626a86d..3e312c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,15 +68,18 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Configure standalone fuzzer + - name: Configure standalone fuzzers run: cmake -S . -B build-fuzz -DOSCPACK_BUILD_EXAMPLES=OFF -DOSCTAP_FUZZER_STANDALONE=ON - - name: Build fuzzer - run: cmake --build build-fuzz --target fuzz_parse + - name: Build fuzzers + run: cmake --build build-fuzz --target fuzz_parse fuzz_deframe - - name: Replay corpus and run mutation loop + - name: Replay corpus and run mutation loop (parser) run: ./build-fuzz/fuzz_parse fuzz/corpus/* + - name: Replay corpus and run mutation loop (TCP deframer) + run: ./build-fuzz/fuzz_deframe fuzz/corpus_deframe/* + rtsan: name: RTSan + function-effects (realtime hot path) runs-on: ubuntu-latest diff --git a/CMakeLists.txt b/CMakeLists.txt index de1220e..3c8da01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,12 @@ if(OSCPACK_BUILD_EXAMPLES) target_link_libraries(OscValidateTest oscpack) add_test(NAME OscValidateTest COMMAND OscValidateTest) + # Length-prefix stream framing (OSC over TCP). The reassembly/DoS-guard logic is + # transport-independent and tested without sockets. See OscStreamFramingTest.cpp. + add_executable(OscStreamFramingTest tests/OscStreamFramingTest.cpp) + target_link_libraries(OscStreamFramingTest oscpack) + add_test(NAME OscStreamFramingTest COMMAND OscStreamFramingTest) + # Compile/link smoke for the win32 socket backend, which otherwise has no # compiled coverage (the POSIX demos/tests are gated off Windows). Built by the # existing windows-latest CI legs. Warnings-as-errors is deliberately OFF for @@ -149,17 +155,21 @@ set(OSCTAP_BUILD_FUZZERS OFF CACHE BOOL "Build the libFuzzer fuzz target (requir set(OSCTAP_FUZZER_STANDALONE OFF CACHE BOOL "Build the fuzz target with the standalone driver instead of libFuzzer (works with g++/ASan, no fuzzer runtime needed)") if(OSCTAP_BUILD_FUZZERS OR OSCTAP_FUZZER_STANDALONE) - if(OSCTAP_FUZZER_STANDALONE) - add_executable(fuzz_parse fuzz/fuzz_parse.cpp fuzz/standalone_main.cpp) - target_compile_options(fuzz_parse PRIVATE -g -O1 -fsanitize=address,undefined -fno-sanitize-recover=all) - target_link_libraries(fuzz_parse oscpack -fsanitize=address,undefined) - else() - # real coverage-guided libFuzzer build (Clang only) - add_executable(fuzz_parse fuzz/fuzz_parse.cpp) - target_compile_options(fuzz_parse PRIVATE -g -O1 -fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all) - target_link_libraries(fuzz_parse oscpack -fsanitize=fuzzer,address,undefined) - endif() - set_target_properties(fuzz_parse PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) + # fuzz_parse: the OSC packet parser. fuzz_deframe: the OSC-over-TCP stream + # deframer + parser (length-prefix reassembly, the bounded-buffer/DoS guard). + foreach(fuzzer fuzz_parse fuzz_deframe) + if(OSCTAP_FUZZER_STANDALONE) + add_executable(${fuzzer} fuzz/${fuzzer}.cpp fuzz/standalone_main.cpp) + target_compile_options(${fuzzer} PRIVATE -g -O1 -fsanitize=address,undefined -fno-sanitize-recover=all) + target_link_libraries(${fuzzer} oscpack -fsanitize=address,undefined) + else() + # real coverage-guided libFuzzer build (Clang only) + add_executable(${fuzzer} fuzz/${fuzzer}.cpp) + target_compile_options(${fuzzer} PRIVATE -g -O1 -fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all) + target_link_libraries(${fuzzer} oscpack -fsanitize=fuzzer,address,undefined) + endif() + set_target_properties(${fuzzer} PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) + endforeach() endif() # Warnings-as-errors is opt-in (default OFF) so that consumers building against diff --git a/fuzz/corpus_deframe/bundle.osc.framed b/fuzz/corpus_deframe/bundle.osc.framed new file mode 100644 index 0000000000000000000000000000000000000000..02782ef628888822aaac3f464d3c6cbe16dd2fd1 GIT binary patch literal 60 ucmZQzV6ae5D$PsDNo9ZnMj$PqpPvU7(8&agLc~hS^FchFGzJC-APoTaVh33O literal 0 HcmV?d00001 diff --git a/fuzz/corpus_deframe/msg_blob_string.osc.framed b/fuzz/corpus_deframe/msg_blob_string.osc.framed new file mode 100644 index 0000000000000000000000000000000000000000..4b17bb20191082a1dc8ccd523e35a41d92f4f97c GIT binary patch literal 32 kcmZQzV35&IVqnloDrNuzc19*<7FITPhK$sloP3}F05`w`<^TWy literal 0 HcmV?d00001 diff --git a/fuzz/corpus_deframe/msg_simple.osc.framed b/fuzz/corpus_deframe/msg_simple.osc.framed new file mode 100644 index 0000000000000000000000000000000000000000..aa35bc6912f3496f2b9296e0ac70ec0ae93868bc GIT binary patch literal 28 fcmZQzV35!+Ni8m6U|`V6ObY>0ARzAG@%1nOL@Wia literal 0 HcmV?d00001 diff --git a/fuzz/corpus_deframe/msg_types_array.osc.framed b/fuzz/corpus_deframe/msg_types_array.osc.framed new file mode 100644 index 0000000000000000000000000000000000000000..19c8c92dc4d80731ff1ebf94b28096a39d885946 GIT binary patch literal 52 ucmZQzU@*`xsVqn>W?<0CNJ);)%#39~04)dBePt)MSVjPaDu9>~h?xK%g9)Vo literal 0 HcmV?d00001 diff --git a/fuzz/corpus_deframe/multi.framed b/fuzz/corpus_deframe/multi.framed new file mode 100644 index 0000000000000000000000000000000000000000..7e7bfc31a62afb7231abd90af663e93f79f2ef32 GIT binary patch literal 172 zcmZQzV6ae5D$PsDNo9ZnMj$PqpPvU7(8&agLc~hS^FchFGzJC-urPy+eiD$MR1D&= zGcqx=u(Gi;WTfWgfE6+@Na&ZO7MB1ebu!aJAX*s29X!4s2C@zGODYRefm(GkQj()H bGh?ChAWF-Dbzj+uEtU~Lb_EcF>}LW1CvF;# literal 0 HcmV?d00001 diff --git a/fuzz/fuzz_deframe.cpp b/fuzz/fuzz_deframe.cpp new file mode 100644 index 0000000..e7515d0 --- /dev/null +++ b/fuzz/fuzz_deframe.cpp @@ -0,0 +1,75 @@ +/* + oscpack / OscTap -- libFuzzer entry point for the OSC-over-TCP receive path. + + TCP delivers a byte stream with no message boundaries, so OscStreamDeframer + reassembles complete packets from arbitrarily chunked reads (and caps the frame + size so a hostile length prefix can't make it buffer unbounded data). This is + attacker-controlled input, so it is fuzzed: the harness feeds the input through + the deframer in pseudo-random chunks (exercising reassembly across every read + boundary) and runs each reassembled frame through the OSC parser, exactly as the + TcpListeningReceiveSocket loop does. Any out-of-bounds read surfaces under ASan. + + Build with real libFuzzer (preferred): + clang++ -std=c++17 -g -O1 -I oscpack \ + -fsanitize=fuzzer,address,undefined fuzz/fuzz_deframe.cpp -o fuzz_deframe + ./fuzz_deframe fuzz/corpus_deframe + + Or link the standalone driver in fuzz/standalone_main.cpp where the libFuzzer + runtime is unavailable (see fuzz/README.md). +*/ +#include +#include +#include +#include + +#include "osc/OscStreamFraming.h" +#include "osc/OscReceivedElements.h" + +using namespace oscpack; + +// Run one reassembled frame through the parser, exactly as a consumer would. +static void HandleFrame( const char* data, uint32_t size ) +{ + if( size == 0 ) + return; + // Exactly-sized heap copy: ASan redzones flag any read past the frame length. + std::vector buffer( data, data + size ); + try{ + ReceivedPacket p( buffer.data(), (osc_bundle_element_size_t)size ); + if( p.IsBundle() ){ + ReceivedBundle b( p ); + for( auto it = b.ElementsBegin(); it != b.ElementsEnd(); ++it ) + if( !it->IsBundle() ){ ReceivedMessage m( *it ); (void)m.ArgumentCount(); } + }else{ + ReceivedMessage m( p ); + for( auto it = m.ArgumentsBegin(); it != m.ArgumentsEnd(); ++it ) + (void)it->TypeTag(); + } + }catch( const oscpack::Exception& ){ + // Expected: malformed frame rejected by the parser. + }catch( const std::exception& ){ + // Tolerate std exceptions (e.g. bad_alloc) -- not a memory-safety finding. + } +} + +extern "C" int LLVMFuzzerTestOneInput( const uint8_t* data, size_t size ) +{ + // A small cap so the fuzzer reaches the oversized-frame rejection path quickly. + osctap::OscStreamDeframer deframer( 4096 ); + + // Feed the byte stream in pseudo-random 1..8-byte chunks (derived from the + // data itself) so reassembly across arbitrary read boundaries is exercised. + size_t i = 0; + while( i < size ){ + size_t chunk = ( (size_t)data[i] & 0x7 ) + 1; // 1..8 + if( i + chunk > size ) + chunk = size - i; + const bool ok = deframer.Consume( + reinterpret_cast( data ) + i, chunk, + []( const char* p, uint32_t n ){ HandleFrame( p, n ); } ); + if( !ok ) + break; // oversized frame: the real loop drops the connection here + i += chunk; + } + return 0; +} diff --git a/oscpack/osc/OscStreamFraming.h b/oscpack/osc/OscStreamFraming.h new file mode 100644 index 0000000..3615953 --- /dev/null +++ b/oscpack/osc/OscStreamFraming.h @@ -0,0 +1,11 @@ +/* + OscTap compatibility shim. + + The library directory and public include prefix moved from + to (the namespace likewise moved oscpack -> osctap, kept as a + deprecated alias). This header redirects the old include path to the new one + so existing consumers keep compiling unchanged. + + Deprecated: prefer . See ROADMAP.md / docs/STATUS.md. +*/ +#include diff --git a/osctap/osc/OscStreamFraming.h b/osctap/osc/OscStreamFraming.h new file mode 100644 index 0000000..9e75fce --- /dev/null +++ b/osctap/osc/OscStreamFraming.h @@ -0,0 +1,183 @@ +/* + oscpack -- Open Sound Control (OSC) packet manipulation library + http://www.rossbencina.com/code/oscpack + + Copyright (c) 2004-2013 Ross Bencina + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files + (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Software, + and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR + ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +#ifndef INCLUDED_OSCTAP_OSCSTREAMFRAMING_H +#define INCLUDED_OSCTAP_OSCSTREAMFRAMING_H + +#include +#include +#include // memcpy +#include + +#include "OscUtilities.h" // FromUInt32 / ToUInt32 + +/* + Length-prefixed OSC stream framing -- the de-facto convention for OSC over a + reliable byte stream such as TCP (CNMAT, liblo's osc.tcp, SuperCollider, Max, + JUCE). Each packet on the wire is a 4-byte big-endian length followed by that + many payload bytes -- the same shape as a bundle element's size slot. + + A datagram transport (UDP) hands you message boundaries for free; a byte stream + does not, so the receiver must reassemble complete packets from arbitrarily + chunked reads. The encoder here is trivial (write the header, then the payload); + OscStreamDeframer is the real work, and it caps the frame size so a hostile + length prefix cannot make it buffer unbounded data (cf. the blob-size discipline + from audit findings #1/#4). + + This codec is transport-agnostic: it knows nothing about sockets. The TCP socket + types (ip/TcpSocket.h) use it; you can equally drive it from any byte source. + SLIP framing (the OSC 1.1 nominated alternative) is intentionally deferred. +*/ + +namespace osctap{ + +enum { OSC_STREAM_FRAME_HEADER_SIZE = 4 }; + +// Default cap on a single framed packet (and therefore on the per-connection +// reassembly buffer). 64 KiB comfortably exceeds any normal OSC packet while +// bounding the memory a peer can make you hold. Override per-deframer. +enum { OSC_DEFAULT_MAX_FRAME_SIZE = 64 * 1024 }; + +// Encoder: write the 4-byte big-endian length prefix for a `packetSize`-byte +// packet into `header`. The caller then writes the payload. Pure, non-allocating, +// freestanding-safe. (The socket transmit path writes the header then the payload +// directly, avoiding a copy; FrameOscPacket() below is the one-buffer convenience.) +inline void WriteOscStreamFrameHeader( char header[OSC_STREAM_FRAME_HEADER_SIZE], uint32_t packetSize ) +{ + FromUInt32( header, packetSize ); +} + +// Convenience: write [4-byte length][payload] contiguously into `out` (capacity +// `outCapacity`). Returns the framed size (4 + packetSize), or 0 if it does not +// fit. Non-allocating. +inline std::size_t FrameOscPacket( const char* packet, uint32_t packetSize, + char* out, std::size_t outCapacity ) +{ + if( (std::size_t)packetSize + OSC_STREAM_FRAME_HEADER_SIZE > outCapacity ) + return 0; + WriteOscStreamFrameHeader( out, packetSize ); + if( packetSize ) + std::memcpy( out + OSC_STREAM_FRAME_HEADER_SIZE, packet, packetSize ); + return (std::size_t)packetSize + OSC_STREAM_FRAME_HEADER_SIZE; +} + +// Streaming decoder: feed it received bytes in whatever chunks the transport +// delivers; it emits each complete OSC packet exactly once. One instance per +// connection (it holds that connection's reassembly state). +// +// Non-throwing. Allocates at most one bounded reassembly buffer (<= maxFrameSize), +// and only when a packet straddles a read boundary -- packets contained whole in a +// single chunk are dispatched in place with no copy. +class OscStreamDeframer{ +public: + explicit OscStreamDeframer( uint32_t maxFrameSize = OSC_DEFAULT_MAX_FRAME_SIZE ) + : maxFrameSize_( maxFrameSize ) + , frameSize_( 0 ) + , headerFill_( 0 ) + , haveHeader_( false ) {} + + uint32_t MaxFrameSize() const { return maxFrameSize_; } + + // Feed `size` bytes received from the stream. For each complete packet, calls + // sink(const char* packet, uint32_t packetSize). Returns true normally; returns + // false as soon as a frame header announces a size greater than maxFrameSize() + // -- a protocol violation / DoS attempt, on which the caller should drop the + // connection (the deframer's state is then undefined until Reset()). + template + bool Consume( const char* data, std::size_t size, Sink&& sink ) + { + const char* p = data; + const char* const end = data + size; + + while( p < end ){ + if( !haveHeader_ ){ + if( headerFill_ == 0 && (std::size_t)(end - p) >= OSC_STREAM_FRAME_HEADER_SIZE ){ + // whole header present contiguously, nothing carried over + frameSize_ = ToUInt32( p ); + p += OSC_STREAM_FRAME_HEADER_SIZE; + }else{ + // accumulate the length prefix across reads + while( headerFill_ < OSC_STREAM_FRAME_HEADER_SIZE && p < end ) + header_[headerFill_++] = *p++; + if( headerFill_ < OSC_STREAM_FRAME_HEADER_SIZE ) + return true; // need more bytes to complete the header + frameSize_ = ToUInt32( header_ ); + headerFill_ = 0; + } + + if( frameSize_ > maxFrameSize_ ) + return false; // oversized / hostile frame + + haveHeader_ = true; + } + + // accumulate / dispatch the payload of frameSize_ bytes + if( buffer_.empty() && (std::size_t)(end - p) >= frameSize_ ){ + // whole payload present contiguously -> dispatch in place, no copy + sink( p, frameSize_ ); + p += frameSize_; + haveHeader_ = false; + }else{ + const std::size_t still = (std::size_t)frameSize_ - buffer_.size(); + const std::size_t avail = (std::size_t)(end - p); + const std::size_t take = avail < still ? avail : still; + buffer_.insert( buffer_.end(), p, p + take ); + p += take; + if( buffer_.size() == (std::size_t)frameSize_ ){ + sink( buffer_.empty() ? p : buffer_.data(), frameSize_ ); + buffer_.clear(); + haveHeader_ = false; + } + } + } + return true; + } + + // Discard any partial-frame state (e.g. after a connection reset). + void Reset() + { + buffer_.clear(); + frameSize_ = 0; + headerFill_ = 0; + haveHeader_ = false; + } + +private: + std::vector buffer_; // accumulates a payload that spans reads + uint32_t maxFrameSize_; + uint32_t frameSize_; // payload size of the frame in progress + char header_[OSC_STREAM_FRAME_HEADER_SIZE]; + uint32_t headerFill_; // bytes of the header accumulated so far + bool haveHeader_; // false: reading header; true: reading payload +}; + +} // namespace osctap + + +// Backwards-compatibility alias: this library was formerly named oscpack. +// Existing code that uses the oscpack:: namespace continues to compile. +namespace oscpack = osctap; + +#endif /* INCLUDED_OSCTAP_OSCSTREAMFRAMING_H */ diff --git a/tests/OscStreamFramingTest.cpp b/tests/OscStreamFramingTest.cpp new file mode 100644 index 0000000..0314566 --- /dev/null +++ b/tests/OscStreamFramingTest.cpp @@ -0,0 +1,124 @@ +/* + OscTap length-prefix stream-framing test. + + Exercises OscStreamDeframer's reassembly across every chunk boundary a byte + stream (TCP) can impose: coalesced packets, packets split across reads, the + 4-byte header itself split across reads, empty payloads, and the oversized- + frame DoS guard. This is the security-critical half of OSC-over-TCP, so it is + tested independently of any socket. +*/ + +#include "osc/OscStreamFraming.h" + +#include +#include +#include +#include + +static int failures = 0; +#define CHECK(cond) do{ if(!(cond)){ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++failures; } }while(0) + +// Collects emitted packets so we can compare against what was framed. +struct Collector { + std::vector packets; + void operator()( const char* p, uint32_t n ) { packets.emplace_back( p, p + n ); } +}; + +// Build a wire buffer: each input packet length-prefixed and concatenated. +static std::vector Wire( const std::vector& packets ) +{ + std::vector w; + for( const auto& pkt : packets ){ + char hdr[4]; + osctap::WriteOscStreamFrameHeader( hdr, (uint32_t)pkt.size() ); + w.insert( w.end(), hdr, hdr + 4 ); + w.insert( w.end(), pkt.begin(), pkt.end() ); + } + return w; +} + +// Feed `wire` to a fresh deframer in fixed-size chunks; return the emitted packets. +static std::vector DeframeInChunks( const std::vector& wire, std::size_t chunk ) +{ + osctap::OscStreamDeframer d; + Collector c; + bool ok = true; + for( std::size_t i = 0; i < wire.size(); i += chunk ){ + std::size_t n = (i + chunk <= wire.size()) ? chunk : (wire.size() - i); + ok = d.Consume( wire.data() + i, n, c ) && ok; + } + if( !ok ) c.packets.clear(); // signal protocol error to the caller's check + return c.packets; +} + +int main() +{ + const std::vector packets = { + std::string( "/a\0\0,i\0\0\0\0\0\1", 12 ), // a small "message-ish" blob + std::string( "hello" ), + std::string( 1000, 'x' ), // spans typical read sizes + std::string( "" ), // empty payload (valid frame) + std::string( "/z" ), + }; + const std::vector wire = Wire( packets ); + + // Feed the same stream at every chunk size from 1 byte up to the whole buffer: + // reassembly must be invariant to how the bytes are split. + for( std::size_t chunk = 1; chunk <= wire.size(); ++chunk ){ + const std::vector got = DeframeInChunks( wire, chunk ); + bool same = ( got.size() == packets.size() ); + for( std::size_t i = 0; same && i < got.size(); ++i ) + same = ( got[i] == packets[i] ); + if( !same ){ + std::printf( "FAIL: mismatch at chunk size %zu (got %zu packets)\n", chunk, got.size() ); + ++failures; + } + } + + // Coalesced: the whole stream in one Consume() yields every packet in order. + { + osctap::OscStreamDeframer d; Collector c; + CHECK( d.Consume( wire.data(), wire.size(), c ) ); + CHECK( c.packets.size() == packets.size() ); + } + + // Two deframers fed the same split stream agree (no shared/static state). + { + CHECK( DeframeInChunks( wire, 3 ) == DeframeInChunks( wire, 5 ) ); + } + + // Oversized-frame DoS guard: a header announcing > maxFrameSize -> Consume + // returns false, and does so even when only the header has arrived. + { + osctap::OscStreamDeframer d( 16 ); // tiny cap + char hdr[4]; + osctap::WriteOscStreamFrameHeader( hdr, 1u << 20 ); // 1 MiB announced + Collector c; + CHECK( d.Consume( hdr, 4, c ) == false ); + CHECK( c.packets.empty() ); + } + + // A frame exactly at the cap is accepted; one byte over is rejected. + { + osctap::OscStreamDeframer ok( 8 ), over( 8 ); + Collector c1, c2; + const std::vector w8 = Wire( { std::string( 8, 'a' ) } ); + const std::vector w9 = Wire( { std::string( 9, 'b' ) } ); + CHECK( ok.Consume( w8.data(), w8.size(), c1 ) == true ); + CHECK( c1.packets.size() == 1 && c1.packets[0].size() == 8 ); + CHECK( over.Consume( w9.data(), w9.size(), c2 ) == false ); + } + + // FrameOscPacket convenience: correct framing + capacity check. + { + char out[16]; + std::size_t n = osctap::FrameOscPacket( "hey", 3, out, sizeof(out) ); + CHECK( n == 7 ); + CHECK( osctap::ToUInt32( out ) == 3 ); + CHECK( std::memcmp( out + 4, "hey", 3 ) == 0 ); + CHECK( osctap::FrameOscPacket( "hey", 3, out, 6 ) == 0 ); // 4+3 > 6 -> no fit + } + + if( failures == 0 ) std::printf( "OscStreamFramingTest: OK\n" ); + return failures == 0 ? 0 : 1; +} From 14b82ccc77ca67a9a23f183283edbdb03f3cfb99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 22:05:14 +0000 Subject: [PATCH 06/16] TCP v1 (issue #14), part 2: posix TCP sockets (client + multi-connection server) The OSC-over-TCP socket layer on top of the framing codec. - ip/posix/TcpSocket.h: * TcpTransmitSocket (client): connect + framed Send() that loops over partial writes; TCP_NODELAY on (Nagle off); SIGPIPE suppressed (MSG_NOSIGNAL / SO_NOSIGPIPE). * TcpListeningReceiveSocket (server): single-threaded, select()-based, and connection-aware -- accept()s any number of clients, keeps a per-connection OscStreamDeframer, and dispatches each complete packet to a PacketListener, with the peer endpoint. Self-pipe Break()/AsynchronousBreak() mirroring the UDP multiplexer; closes + reaps connections on EOF/error and drops any peer that sends an over-cap frame. - ip/TcpSocket.h facade (+ oscpack shims for it and OscStreamFraming.h): selects the per-platform backend and exposes osctap::TcpTransmitSocket / TcpListeningReceiveSocket. (Concrete per-platform classes + `using`, rather than the UDP facade's template-over-Implementation -- lighter for v1.) - tests/OscTcpTest.cpp: real loopback -- server on a thread, client sends four messages incl. a 4000-byte one that spans TCP segments, so the deframer reassembles through actual sockets. POSIX-only (threads+sockets), and also built under the TSan job (Run() vs AsynchronousBreak() on the TCP loop). Verified: TCP test green, and clean under ASan/UBSan and TSan; full hosted suite 7/7; cross-compiles for aarch64. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- CMakeLists.txt | 12 ++ oscpack/ip/TcpSocket.h | 10 ++ osctap/ip/TcpSocket.h | 36 +++++ osctap/ip/posix/TcpSocket.h | 296 ++++++++++++++++++++++++++++++++++++ tests/OscTcpTest.cpp | 114 ++++++++++++++ 5 files changed, 468 insertions(+) create mode 100644 oscpack/ip/TcpSocket.h create mode 100644 osctap/ip/TcpSocket.h create mode 100644 osctap/ip/posix/TcpSocket.h create mode 100644 tests/OscTcpTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3c8da01..d11d116 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,18 @@ if(OSCPACK_BUILD_EXAMPLES) target_compile_options(OscConcurrencyTest PRIVATE -fsanitize=thread -g) target_link_options(OscConcurrencyTest PRIVATE -fsanitize=thread) endif() + + # OSC-over-TCP loopback test: real client + multi-connection server, exercising + # the deframer through actual sockets (incl. a segment-spanning message). Uses + # std::thread + sockets, so POSIX-only for now (like OscConcurrencyTest). Also + # runs under the TSan job to vet Run() vs AsynchronousBreak() on the TCP loop. + add_executable(OscTcpTest tests/OscTcpTest.cpp) + target_link_libraries(OscTcpTest oscpack Threads::Threads) + add_test(NAME OscTcpTest COMMAND OscTcpTest) + if(OSCTAP_TSAN) + target_compile_options(OscTcpTest PRIVATE -fsanitize=thread -g) + target_link_options(OscTcpTest PRIVATE -fsanitize=thread) + endif() endif() #add_executable(OscSendTests tests/OscSendTests.cpp) diff --git a/oscpack/ip/TcpSocket.h b/oscpack/ip/TcpSocket.h new file mode 100644 index 0000000..8ab1e47 --- /dev/null +++ b/oscpack/ip/TcpSocket.h @@ -0,0 +1,10 @@ +/* + OscTap compatibility shim. + + Redirects the old include path to the new + . (TCP support is new in OscTap; the shim exists only + so the deprecated prefix keeps working uniformly across the library.) + + Deprecated: prefer . See ROADMAP.md / docs/STATUS.md. +*/ +#include diff --git a/osctap/ip/TcpSocket.h b/osctap/ip/TcpSocket.h new file mode 100644 index 0000000..9de5d66 --- /dev/null +++ b/osctap/ip/TcpSocket.h @@ -0,0 +1,36 @@ +#ifndef INCLUDED_OSCTAP_TCPSOCKET_H +#define INCLUDED_OSCTAP_TCPSOCKET_H + +// Public OSC-over-TCP socket types. Length-prefix framing (see +// osc/OscStreamFraming.h); the platform backends mirror the UDP ones. +// +// TcpTransmitSocket -- client: connect + Send(packet, size) +// TcpListeningReceiveSocket -- server: accept N clients, dispatch each +// complete packet to a PacketListener +// +// Unlike the UDP facade (which templates one class over a per-platform +// Implementation), the TCP types are concrete per-platform classes selected here +// by `using`. v1 scope: length-prefix framing, single-threaded multi-connection +// server, TCP_NODELAY, frame-size cap. SLIP/TLS/WebSocket/epoll are deferred. + +#if defined(_WIN32) +#include "win32/TcpSocket.h" +#else +#include "posix/TcpSocket.h" +#endif + +namespace osctap +{ +#if defined(_WIN32) +using TcpTransmitSocket = win32::TcpTransmitSocket; +using TcpListeningReceiveSocket = win32::TcpListeningReceiveSocket; +#else +using TcpTransmitSocket = posix::TcpTransmitSocket; +using TcpListeningReceiveSocket = posix::TcpListeningReceiveSocket; +#endif +} + +// Backwards-compatibility alias: this library was formerly named oscpack. +namespace oscpack = osctap; + +#endif /* INCLUDED_OSCTAP_TCPSOCKET_H */ diff --git a/osctap/ip/posix/TcpSocket.h b/osctap/ip/posix/TcpSocket.h new file mode 100644 index 0000000..4ec5511 --- /dev/null +++ b/osctap/ip/posix/TcpSocket.h @@ -0,0 +1,296 @@ +/* + oscpack -- Open Sound Control (OSC) packet manipulation library + http://www.rossbencina.com/code/oscpack + + Copyright (c) 2004-2013 Ross Bencina + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files + (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Software, + and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR + ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +#ifndef INCLUDED_OSCTAP_POSIX_TCPSOCKET_H +#define INCLUDED_OSCTAP_POSIX_TCPSOCKET_H + +// Reuse the posix socket includes and the SockaddrFromIpEndpointName / +// IpEndpointNameFromSockaddr helpers defined in the UDP backend. +#include +#include +#include + +#include // TCP_NODELAY +#include + +namespace osctap +{ +namespace posix +{ + +// --------------------------------------------------------------------------- +// TcpTransmitSocket -- connect to a remote OSC-over-TCP server and send packets. +// +// Each Send() writes one length-prefixed frame (4-byte big-endian count + +// payload), looping over partial writes (a TCP send() may transfer fewer bytes +// than requested). TCP_NODELAY is enabled (Nagle off) -- OSC over TCP without it +// is a classic latency footgun. +// --------------------------------------------------------------------------- +class TcpTransmitSocket +{ +public: + explicit TcpTransmitSocket( const IpEndpointName& remoteEndpoint ) + { + socket_ = ::socket( AF_INET, SOCK_STREAM, 0 ); + if( socket_ == -1 ) + throw std::runtime_error( "unable to create tcp socket\n" ); + +#ifdef SO_NOSIGPIPE + int noSigpipe = 1; // macOS / BSD: suppress SIGPIPE on send to a closed peer + setsockopt( socket_, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, sizeof(noSigpipe) ); +#endif + int one = 1; + setsockopt( socket_, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one) ); + + struct sockaddr_in addr; + SockaddrFromIpEndpointName( addr, remoteEndpoint ); + if( ::connect( socket_, (struct sockaddr*)&addr, sizeof(addr) ) < 0 ){ + ::close( socket_ ); + socket_ = -1; + throw std::runtime_error( "unable to connect tcp socket\n" ); + } + } + + ~TcpTransmitSocket() { if( socket_ != -1 ) ::close( socket_ ); } + + TcpTransmitSocket( const TcpTransmitSocket& ) = delete; + TcpTransmitSocket& operator=( const TcpTransmitSocket& ) = delete; + + // Send one complete OSC packet, length-prefixed. Blocks until fully written. + void Send( const char* data, std::size_t size ) + { + char header[OSC_STREAM_FRAME_HEADER_SIZE]; + WriteOscStreamFrameHeader( header, (uint32_t)size ); + SendAll( header, OSC_STREAM_FRAME_HEADER_SIZE ); + SendAll( data, size ); + } + + int Socket() const { return socket_; } + +private: + void SendAll( const char* p, std::size_t n ) + { +#ifdef MSG_NOSIGNAL + const int flags = MSG_NOSIGNAL; // Linux: don't raise SIGPIPE +#else + const int flags = 0; +#endif + std::size_t sent = 0; + while( sent < n ){ + ssize_t r = ::send( socket_, p + sent, n - sent, flags ); + if( r < 0 ){ + if( errno == EINTR ) continue; + throw std::runtime_error( "tcp send failed\n" ); + } + sent += (std::size_t)r; + } + } + + int socket_ = -1; +}; + + +// --------------------------------------------------------------------------- +// TcpListeningReceiveSocket -- listen for OSC-over-TCP clients and dispatch each +// complete packet to a PacketListener. +// +// Single-threaded, select()-based, and connection-aware: it accept()s any number +// of clients and keeps a per-connection OscStreamDeframer (each connection's byte +// stream reassembles independently). Run() blocks; Break()/AsynchronousBreak() +// stop it (the latter via a self-pipe, so it works from another thread or a +// signal handler -- mirroring the UDP multiplexer). +// --------------------------------------------------------------------------- +class TcpListeningReceiveSocket +{ + struct Connection + { + IpEndpointName peer; + OscStreamDeframer deframer; + Connection( const IpEndpointName& p, uint32_t maxFrame ) + : peer( p ), deframer( maxFrame ) {} + }; + +public: + TcpListeningReceiveSocket( const IpEndpointName& localEndpoint, PacketListener* listener, + uint32_t maxFrameSize = OSC_DEFAULT_MAX_FRAME_SIZE ) + : listener_( listener ), maxFrameSize_( maxFrameSize ) + { + if( pipe( breakPipe_ ) != 0 ) + throw std::runtime_error( "creation of asynchronous break pipes failed\n" ); + + listenSocket_ = ::socket( AF_INET, SOCK_STREAM, 0 ); + if( listenSocket_ == -1 ){ + close( breakPipe_[0] ); close( breakPipe_[1] ); + throw std::runtime_error( "unable to create tcp socket\n" ); + } + + int reuse = 1; + setsockopt( listenSocket_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse) ); + + struct sockaddr_in addr; + SockaddrFromIpEndpointName( addr, localEndpoint ); + if( ::bind( listenSocket_, (struct sockaddr*)&addr, sizeof(addr) ) < 0 ){ + Cleanup(); + throw std::runtime_error( "unable to bind tcp socket\n" ); + } + if( ::listen( listenSocket_, SOMAXCONN ) < 0 ){ + Cleanup(); + throw std::runtime_error( "unable to listen on tcp socket\n" ); + } + } + + ~TcpListeningReceiveSocket() { Cleanup(); } + + TcpListeningReceiveSocket( const TcpListeningReceiveSocket& ) = delete; + TcpListeningReceiveSocket& operator=( const TcpListeningReceiveSocket& ) = delete; + + // The bound local endpoint (resolves the OS-assigned port when bound to 0). + IpEndpointName LocalEndpointFor( const IpEndpointName& requested ) const + { + struct sockaddr_in addr; + socklen_t len = sizeof(addr); + if( getsockname( listenSocket_, (struct sockaddr*)&addr, &len ) == 0 ) + return IpEndpointName( requested.address, ntohs( addr.sin_port ) ); + return requested; + } + + void Run() + { + break_ = false; + char buf[4096]; + + while( !break_ ){ + fd_set readfds; + FD_ZERO( &readfds ); + FD_SET( listenSocket_, &readfds ); + FD_SET( breakPipe_[0], &readfds ); + int fdmax = listenSocket_ > breakPipe_[0] ? listenSocket_ : breakPipe_[0]; + for( const auto& kv : connections_ ){ + FD_SET( kv.first, &readfds ); + if( kv.first > fdmax ) fdmax = kv.first; + } + + if( select( fdmax + 1, &readfds, 0, 0, 0 ) < 0 ){ + if( break_ ) break; + if( errno == EINTR ) continue; + throw std::runtime_error( "select failed\n" ); + } + + if( FD_ISSET( breakPipe_[0], &readfds ) ){ + char c; ssize_t r = read( breakPipe_[0], &c, 1 ); (void)r; + } + if( break_ ) break; + + if( FD_ISSET( listenSocket_, &readfds ) ) + AcceptConnection(); + + // Collect ready connection fds first; processing may erase entries. + std::vector ready; + for( const auto& kv : connections_ ) + if( FD_ISSET( kv.first, &readfds ) ) ready.push_back( kv.first ); + + for( int fd : ready ){ + ServiceConnection( fd, buf, sizeof(buf) ); + if( break_ ) break; + } + } + } + + void Break() { break_ = true; } + + void AsynchronousBreak() + { + break_ = true; + ssize_t r = write( breakPipe_[1], "!", 1 ); (void)r; + } + + int Socket() const { return listenSocket_; } + +private: + void AcceptConnection() + { + struct sockaddr_in peerAddr; + socklen_t len = sizeof(peerAddr); + int conn = ::accept( listenSocket_, (struct sockaddr*)&peerAddr, &len ); + if( conn == -1 ) + return; + int one = 1; + setsockopt( conn, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one) ); + connections_.emplace( std::piecewise_construct, + std::forward_as_tuple( conn ), + std::forward_as_tuple( IpEndpointNameFromSockaddr( peerAddr ), maxFrameSize_ ) ); + } + + void ServiceConnection( int fd, char* buf, std::size_t bufSize ) + { + auto it = connections_.find( fd ); + if( it == connections_.end() ) + return; + + ssize_t n = ::recv( fd, buf, bufSize, 0 ); + if( n <= 0 ){ // 0 = peer closed; <0 = error -> drop the connection + CloseConnection( it ); + return; + } + + // Reassemble and dispatch every complete packet in this read. The sink + // runs synchronously, so `it` (and its peer) stays valid throughout. + const bool ok = it->second.deframer.Consume( buf, (std::size_t)n, + [&]( const char* packet, uint32_t size ){ + listener_->ProcessPacket( packet, (int)size, it->second.peer ); + } ); + + if( !ok ) // a frame exceeded maxFrameSize -> protocol violation + CloseConnection( it ); + } + + void CloseConnection( std::map::iterator it ) + { + ::close( it->first ); + connections_.erase( it ); + } + + void Cleanup() + { + for( auto& kv : connections_ ) + ::close( kv.first ); + connections_.clear(); + if( listenSocket_ != -1 ){ ::close( listenSocket_ ); listenSocket_ = -1; } + close( breakPipe_[0] ); + close( breakPipe_[1] ); + } + + int listenSocket_ = -1; + PacketListener* listener_; + uint32_t maxFrameSize_; + std::atomic_bool break_{ false }; + int breakPipe_[2]; + std::map connections_; +}; + +} // namespace posix +} // namespace osctap + +#endif /* INCLUDED_OSCTAP_POSIX_TCPSOCKET_H */ diff --git a/tests/OscTcpTest.cpp b/tests/OscTcpTest.cpp new file mode 100644 index 0000000..782eeff --- /dev/null +++ b/tests/OscTcpTest.cpp @@ -0,0 +1,114 @@ +/* + OscTap OSC-over-TCP loopback test (POSIX). + + Drives the real TcpListeningReceiveSocket / TcpTransmitSocket over loopback: + a server runs on one thread; a client connects and sends several OSC messages, + including a large one that spans multiple TCP segments (exercising the + per-connection deframer's reassembly through actual sockets, not just a unit + test). Verifies every packet arrives intact and in order, then stops the + server with AsynchronousBreak() from the main thread. +*/ + +#include "ip/TcpSocket.h" +#include "ip/IpEndpointName.h" +#include "osc/OscPacketListener.h" +#include "osc/OscOutboundPacketStream.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class RecordingListener : public osctap::OscPacketListener { +public: + std::atomic count{ 0 }; + std::vector addresses; // written by server thread, read after join + std::vector sizes; // payload size hint per message + +protected: + void ProcessMessage( const osctap::ReceivedMessage& m, const osctap::IpEndpointName& ) override + { + addresses.emplace_back( m.AddressPattern() ); + int sz = 0; + auto a = m.ArgumentsBegin(); + if( a != m.ArgumentsEnd() ){ + if( a->IsInt32() ) sz = a->AsInt32Unchecked(); + else if( a->IsString() ) sz = (int)std::strlen( a->AsStringUnchecked() ); + } + sizes.push_back( sz ); + 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() +{ + RecordingListener listener; + + // Bind the server to an OS-assigned loopback port, then discover it. + osctap::TcpListeningReceiveSocket server( + osctap::IpEndpointName( 127, 0, 0, 1, 0 ), &listener ); + const int port = server.LocalEndpointFor( osctap::IpEndpointName( 127, 0, 0, 1, 0 ) ).port; + CHECK( port > 0 ); + + std::thread serverThread( [&]{ server.Run(); } ); + + // Client: connect and send four messages. The last is large enough to be + // split across TCP segments, forcing the server-side deframer to reassemble. + const std::string big( 4000, 'x' ); + try { + osctap::TcpTransmitSocket client( osctap::IpEndpointName( 127, 0, 0, 1, port ) ); + char buf[8192]; + { + osctap::OutboundPacketStream p( buf, sizeof(buf) ); + p << osctap::BeginMessage( "/m1" ) << (int32_t)11 << osctap::EndMessage(); + client.Send( p.Data(), p.Size() ); + } + { + osctap::OutboundPacketStream p( buf, sizeof(buf) ); + p << osctap::BeginMessage( "/m2" ) << (int32_t)22 << osctap::EndMessage(); + client.Send( p.Data(), p.Size() ); + } + { + osctap::OutboundPacketStream p( buf, sizeof(buf) ); + p << osctap::BeginMessage( "/m3" ) << "hello" << osctap::EndMessage(); + client.Send( p.Data(), p.Size() ); + } + { + osctap::OutboundPacketStream p( buf, sizeof(buf) ); + p << osctap::BeginMessage( "/big" ) << big.c_str() << osctap::EndMessage(); + client.Send( p.Data(), p.Size() ); + } + } catch( const std::exception& e ) { + std::printf( "FAIL: client error: %s\n", e.what() ); + ++failures; + } + + // Wait (bounded) for all four to arrive, then stop the server. + for( int i = 0; i < 500 && listener.count.load() < 4; ++i ) + std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); + + server.AsynchronousBreak(); + serverThread.join(); + + CHECK( listener.count.load() == 4 ); + if( listener.addresses.size() == 4 ){ + CHECK( listener.addresses[0] == "/m1" && listener.sizes[0] == 11 ); + CHECK( listener.addresses[1] == "/m2" && listener.sizes[1] == 22 ); + CHECK( listener.addresses[2] == "/m3" && listener.sizes[2] == 5 ); // strlen("hello") + CHECK( listener.addresses[3] == "/big" && listener.sizes[3] == 4000 ); // reassembled + } + + if( failures == 0 ) + std::printf( "OscTcpTest: OK (4 packets over TCP, incl. a reassembled 4000-byte message)\n" ); + return failures == 0 ? 0 : 1; +} From cda89d68cbeb08710e635bef70e03488dfcd16c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 22:08:11 +0000 Subject: [PATCH 07/16] TCP v1 (issue #14), part 3: win32 TCP backend + MinGW compile-smoke Mirror the posix TCP backend on Winsock so OSC-over-TCP works on Windows too. - ip/win32/TcpSocket.h: TcpTransmitSocket + TcpListeningReceiveSocket using SOCKET/closesocket/WSAStartup (via NetworkInitializer). The connection-aware server uses select() (Winsock supports select() over SOCKETs) with a self-connected loopback UDP "break" socket standing in for the posix self-pipe, so AsynchronousBreak() works cross-thread. Structurally identical to the posix backend (same deframer, same per-connection dispatch, same frame-size cap). - ip/TcpSocket.h facade already selects win32 vs posix. - tests/Win32SocketSmoke.cpp extended to instantiate + link the TCP types. Verified by cross-compiling the win32 smoke with MinGW-w64 (-Wall -Wextra, clean) to a PE32+ executable: the win32 UDP+TCP backends compile and link (winsock2 / TCP_NODELAY / select all resolved). Also built by the windows-latest MSVC CI legs. Caveat: not runtime-tested on Windows (no Windows runner / no Wine here) -- the posix backend is the runtime-verified reference. Linux suite still 7/7. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- osctap/ip/win32/TcpSocket.h | 291 ++++++++++++++++++++++++++++++++++++ tests/Win32SocketSmoke.cpp | 10 ++ 2 files changed, 301 insertions(+) create mode 100644 osctap/ip/win32/TcpSocket.h diff --git a/osctap/ip/win32/TcpSocket.h b/osctap/ip/win32/TcpSocket.h new file mode 100644 index 0000000..3c1210f --- /dev/null +++ b/osctap/ip/win32/TcpSocket.h @@ -0,0 +1,291 @@ +/* + oscpack -- Open Sound Control (OSC) packet manipulation library + http://www.rossbencina.com/code/oscpack + + Copyright (c) 2004-2013 Ross Bencina + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files + (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Software, + and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR + ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +#ifndef INCLUDED_OSCTAP_WIN32_TCPSOCKET_H +#define INCLUDED_OSCTAP_WIN32_TCPSOCKET_H + +// Reuse the win32 socket includes, NetworkInitializer (WSAStartup), and the +// SockaddrFromIpEndpointName / IpEndpointNameFromSockaddr helpers from the UDP +// backend. +#include +#include +#include + +#include // TCP_NODELAY, IPPROTO_TCP +#include +#include + +namespace osctap +{ +namespace win32 +{ + +// Mirrors ip/posix/TcpSocket.h on Winsock. The connection-aware server uses +// select() (Winsock supports select() over SOCKETs) with a self-connected UDP +// "break" socket standing in for the posix self-pipe, so AsynchronousBreak() +// works from another thread / signal handler. +// +// NOTE: this backend is built by the windows-latest CI legs and cross-compiled + +// link-checked with MinGW, but -- unlike the posix backend -- it is not yet +// runtime-tested in CI (no Windows runner). The posix backend is the +// runtime-verified reference. + +class TcpTransmitSocket +{ +public: + explicit TcpTransmitSocket( const IpEndpointName& remoteEndpoint ) + { + NetworkInitializer::instance(); + + socket_ = ::socket( AF_INET, SOCK_STREAM, 0 ); + if( socket_ == INVALID_SOCKET ) + throw std::runtime_error( "unable to create tcp socket\n" ); + + int one = 1; + setsockopt( socket_, IPPROTO_TCP, TCP_NODELAY, (const char*)&one, sizeof(one) ); + + struct sockaddr_in addr; + SockaddrFromIpEndpointName( addr, remoteEndpoint ); + if( ::connect( socket_, (struct sockaddr*)&addr, sizeof(addr) ) == SOCKET_ERROR ){ + closesocket( socket_ ); + socket_ = INVALID_SOCKET; + throw std::runtime_error( "unable to connect tcp socket\n" ); + } + } + + ~TcpTransmitSocket() { if( socket_ != INVALID_SOCKET ) closesocket( socket_ ); } + + TcpTransmitSocket( const TcpTransmitSocket& ) = delete; + TcpTransmitSocket& operator=( const TcpTransmitSocket& ) = delete; + + void Send( const char* data, std::size_t size ) + { + char header[OSC_STREAM_FRAME_HEADER_SIZE]; + WriteOscStreamFrameHeader( header, (uint32_t)size ); + SendAll( header, OSC_STREAM_FRAME_HEADER_SIZE ); + SendAll( data, size ); + } + + SOCKET Socket() const { return socket_; } + +private: + void SendAll( const char* p, std::size_t n ) + { + std::size_t sent = 0; + while( sent < n ){ + int r = ::send( socket_, p + sent, (int)( n - sent ), 0 ); + if( r == SOCKET_ERROR ) + throw std::runtime_error( "tcp send failed\n" ); + sent += (std::size_t)r; + } + } + + SOCKET socket_ = INVALID_SOCKET; +}; + + +class TcpListeningReceiveSocket +{ + struct Connection + { + IpEndpointName peer; + OscStreamDeframer deframer; + Connection( const IpEndpointName& p, uint32_t maxFrame ) + : peer( p ), deframer( maxFrame ) {} + }; + +public: + TcpListeningReceiveSocket( const IpEndpointName& localEndpoint, PacketListener* listener, + uint32_t maxFrameSize = OSC_DEFAULT_MAX_FRAME_SIZE ) + : listener_( listener ), maxFrameSize_( maxFrameSize ) + { + NetworkInitializer::instance(); + CreateBreakSocket(); + + listenSocket_ = ::socket( AF_INET, SOCK_STREAM, 0 ); + if( listenSocket_ == INVALID_SOCKET ){ + closesocket( breakSocket_ ); + throw std::runtime_error( "unable to create tcp socket\n" ); + } + + int reuse = 1; + setsockopt( listenSocket_, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse) ); + + struct sockaddr_in addr; + SockaddrFromIpEndpointName( addr, localEndpoint ); + if( ::bind( listenSocket_, (struct sockaddr*)&addr, sizeof(addr) ) == SOCKET_ERROR ){ + Cleanup(); + throw std::runtime_error( "unable to bind tcp socket\n" ); + } + if( ::listen( listenSocket_, SOMAXCONN ) == SOCKET_ERROR ){ + Cleanup(); + throw std::runtime_error( "unable to listen on tcp socket\n" ); + } + } + + ~TcpListeningReceiveSocket() { Cleanup(); } + + TcpListeningReceiveSocket( const TcpListeningReceiveSocket& ) = delete; + TcpListeningReceiveSocket& operator=( const TcpListeningReceiveSocket& ) = delete; + + IpEndpointName LocalEndpointFor( const IpEndpointName& requested ) const + { + struct sockaddr_in addr; + socklen_t len = sizeof(addr); + if( getsockname( listenSocket_, (struct sockaddr*)&addr, &len ) == 0 ) + return IpEndpointName( requested.address, ntohs( addr.sin_port ) ); + return requested; + } + + void Run() + { + break_ = false; + char buf[4096]; + + while( !break_ ){ + fd_set readfds; + FD_ZERO( &readfds ); + FD_SET( listenSocket_, &readfds ); + FD_SET( breakSocket_, &readfds ); + for( const auto& kv : connections_ ) + FD_SET( kv.first, &readfds ); + + if( select( 0, &readfds, 0, 0, 0 ) == SOCKET_ERROR ){ + if( break_ ) break; + throw std::runtime_error( "select failed\n" ); + } + + if( FD_ISSET( breakSocket_, &readfds ) ){ + char c; recv( breakSocket_, &c, 1, 0 ); + } + if( break_ ) break; + + if( FD_ISSET( listenSocket_, &readfds ) ) + AcceptConnection(); + + std::vector ready; + for( const auto& kv : connections_ ) + if( FD_ISSET( kv.first, &readfds ) ) ready.push_back( kv.first ); + + for( SOCKET fd : ready ){ + ServiceConnection( fd, buf, sizeof(buf) ); + if( break_ ) break; + } + } + } + + void Break() { break_ = true; } + + void AsynchronousBreak() + { + break_ = true; + send( breakSocket_, "!", 1, 0 ); // wake select() + } + + SOCKET Socket() const { return listenSocket_; } + +private: + void CreateBreakSocket() + { + // A loopback UDP socket connected to itself: writing a byte to it wakes the + // select() loop (the Winsock analogue of the posix self-pipe). + breakSocket_ = ::socket( AF_INET, SOCK_DGRAM, 0 ); + if( breakSocket_ == INVALID_SOCKET ) + throw std::runtime_error( "creation of asynchronous break socket failed\n" ); + + struct sockaddr_in addr; + std::memset( &addr, 0, sizeof(addr) ); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl( INADDR_LOOPBACK ); + addr.sin_port = 0; + bind( breakSocket_, (struct sockaddr*)&addr, sizeof(addr) ); + + socklen_t len = sizeof(addr); + getsockname( breakSocket_, (struct sockaddr*)&addr, &len ); + connect( breakSocket_, (struct sockaddr*)&addr, sizeof(addr) ); + } + + void AcceptConnection() + { + struct sockaddr_in peerAddr; + socklen_t len = sizeof(peerAddr); + SOCKET conn = ::accept( listenSocket_, (struct sockaddr*)&peerAddr, &len ); + if( conn == INVALID_SOCKET ) + return; + int one = 1; + setsockopt( conn, IPPROTO_TCP, TCP_NODELAY, (const char*)&one, sizeof(one) ); + connections_.emplace( std::piecewise_construct, + std::forward_as_tuple( conn ), + std::forward_as_tuple( IpEndpointNameFromSockaddr( peerAddr ), maxFrameSize_ ) ); + } + + void ServiceConnection( SOCKET fd, char* buf, std::size_t bufSize ) + { + auto it = connections_.find( fd ); + if( it == connections_.end() ) + return; + + int n = ::recv( fd, buf, (int)bufSize, 0 ); + if( n <= 0 ){ // 0 = peer closed; SOCKET_ERROR -> drop + CloseConnection( it ); + return; + } + + const bool ok = it->second.deframer.Consume( buf, (std::size_t)n, + [&]( const char* packet, uint32_t size ){ + listener_->ProcessPacket( packet, (int)size, it->second.peer ); + } ); + + if( !ok ) + CloseConnection( it ); + } + + void CloseConnection( std::map::iterator it ) + { + closesocket( it->first ); + connections_.erase( it ); + } + + void Cleanup() + { + for( auto& kv : connections_ ) + closesocket( kv.first ); + connections_.clear(); + if( listenSocket_ != INVALID_SOCKET ){ closesocket( listenSocket_ ); listenSocket_ = INVALID_SOCKET; } + if( breakSocket_ != INVALID_SOCKET ){ closesocket( breakSocket_ ); breakSocket_ = INVALID_SOCKET; } + } + + SOCKET listenSocket_ = INVALID_SOCKET; + SOCKET breakSocket_ = INVALID_SOCKET; + PacketListener* listener_; + uint32_t maxFrameSize_; + std::atomic_bool break_{ false }; + std::map connections_; +}; + +} // namespace win32 +} // namespace osctap + +#endif /* INCLUDED_OSCTAP_WIN32_TCPSOCKET_H */ diff --git a/tests/Win32SocketSmoke.cpp b/tests/Win32SocketSmoke.cpp index 40579a2..764571b 100644 --- a/tests/Win32SocketSmoke.cpp +++ b/tests/Win32SocketSmoke.cpp @@ -15,6 +15,7 @@ */ #include "ip/UdpSocket.h" +#include "ip/TcpSocket.h" #include "ip/IpEndpointName.h" #include "ip/PacketListener.h" #include "osc/OscOutboundPacketStream.h" @@ -44,6 +45,15 @@ void exercise_win32_backend() osctap::UdpListeningReceiveSocket rx( osctap::IpEndpointName( osctap::IpEndpointName::ANY_ADDRESS, 9000 ), &listener ); rx.AsynchronousBreak(); + + // TCP backend (ip/win32/TcpSocket.h): client Send + connection-aware server. + osctap::TcpTransmitSocket tcpTx( ep ); + tcpTx.Send( buf, sizeof(buf) ); + + osctap::TcpListeningReceiveSocket tcpRx( + osctap::IpEndpointName( osctap::IpEndpointName::ANY_ADDRESS, 9001 ), &listener ); + tcpRx.Run(); + tcpRx.AsynchronousBreak(); } } // namespace From 930ccf49ecf2f12507f28bb419f6d17eaf18e763 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 22:09:41 +0000 Subject: [PATCH 08/16] TCP v1 (issue #14), part 4: docs + ROADMAP/STATUS - docs/OSC_OVER_TCP.md: usage guide -- framing, server/client snippets, the frame-size cap / DoS note, the realtime split, interop (SuperCollider/Max/JUCE/ liblo osc.tcp), and what's deferred. - ROADMAP.md: TCP v1 marked landed under Phase 2. - docs/STATUS.md: landmines for the framing single-source, the cap, the concrete per-platform TCP facade, and the posix-runtime-tested / win32-compile-only split. Final verification across surfaces: hosted suite 7/7; freestanding green (GCC+ Clang); win32 UDP+TCP compile/link clean (MinGW); deframer fuzzer 200k mutations ASan/UBSan-clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- ROADMAP.md | 11 ++++ docs/OSC_OVER_TCP.md | 124 +++++++++++++++++++++++++++++++++++++++++++ docs/STATUS.md | 12 +++++ 3 files changed, 147 insertions(+) create mode 100644 docs/OSC_OVER_TCP.md diff --git a/ROADMAP.md b/ROADMAP.md index 15e5b3e..f0e1232 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -180,6 +180,17 @@ See [Sanitizer strategy](#sanitizer-strategy) for scope and rationale. compiled coverage of the `ip/` layer) and an end-to-end tutorial ([`docs/INTEGRATION_PI5_PICO_ANDROID.md`](docs/INTEGRATION_PI5_PICO_ANDROID.md)) tying all three nodes together over OSC/UDP. +- [x] **OSC over TCP (issue #14)** — *v1 landed.* Length-prefix framing + (`osc/OscStreamFraming.h`: encoder + bounded streaming deframer with a + frame-size cap, fuzzed via `fuzz/fuzz_deframe.cpp`), and public TCP socket + types (`ip/TcpSocket.h`): `TcpTransmitSocket` (client, `TCP_NODELAY`, + partial-write loop) and a single-threaded, multi-connection + `TcpListeningReceiveSocket` (per-connection deframer → `PacketListener`). + POSIX backend is runtime-tested (`OscTcpTest`, a real loopback incl. a + segment-spanning message; ASan/UBSan/TSan-clean); the win32 backend mirrors it + and is MinGW/Windows-CI compile-verified. See + [`docs/OSC_OVER_TCP.md`](docs/OSC_OVER_TCP.md). Deferred: SLIP framing, TLS, + WebSocket, `epoll`, and win32 runtime testing. - [ ] 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`/ diff --git a/docs/OSC_OVER_TCP.md b/docs/OSC_OVER_TCP.md new file mode 100644 index 0000000..e74d326 --- /dev/null +++ b/docs/OSC_OVER_TCP.md @@ -0,0 +1,124 @@ +# OSC over TCP + +OscTap speaks OSC over TCP as well as UDP. This is useful when you need reliable, +ordered delivery (no dropped packets), to traverse a connection-oriented link, or +to interoperate with the many tools that default to OSC-over-TCP (SuperCollider, +Max, JUCE, liblo's `osc.tcp`). + +> Status: v1 (issue #14). Length-prefix framing, a single-threaded +> multi-connection server, `TCP_NODELAY`, and a frame-size cap. SLIP framing, TLS, +> WebSocket, and `epoll` are intentionally deferred until there's demand. + +## Framing + +UDP gives you message boundaries for free — one datagram is exactly one OSC +packet. TCP is a byte stream with **no** boundaries, so packets must be framed. +OscTap uses the de-facto convention: each packet is a **4-byte big-endian length** +followed by that many payload bytes (the same shape as a bundle element's size +slot). Both ends must agree on this out of band — it's what SuperCollider/Max/ +JUCE/liblo's `osc.tcp` use, so OscTap interoperates with them. + +The framing codec ([`osc/OscStreamFraming.h`](../osctap/osc/OscStreamFraming.h)) is +transport-agnostic and usable on its own (e.g. over a serial link or your own +socket loop): + +```cpp +#include "osc/OscStreamFraming.h" + +// Decode: feed received bytes in whatever chunks arrive; the deframer reassembles +// complete packets and calls your sink once per packet. +osctap::OscStreamDeframer deframer; // default 64 KiB frame-size cap +bool ok = deframer.Consume( data, n, []( const char* packet, uint32_t size ){ + osctap::ReceivedPacket p( packet, size ); // one whole OSC packet + // ... dispatch ... +}); +if( !ok ) { /* a peer announced an over-cap frame: drop the connection */ } +``` + +## Server (receive) + +`TcpListeningReceiveSocket` accepts any number of clients and dispatches each +complete packet to your `PacketListener` / `OscPacketListener`, exactly like the +UDP `UdpListeningReceiveSocket` — the listener contract is identical, so existing +listeners work unchanged. + +```cpp +#include "ip/TcpSocket.h" +#include "osc/OscPacketListener.h" + +class MyListener : public osctap::OscPacketListener { +protected: + void ProcessMessage( const osctap::ReceivedMessage& m, + const osctap::IpEndpointName& from ) override { + // ... handle m ... + } +}; + +MyListener listener; +osctap::TcpListeningReceiveSocket server( + osctap::IpEndpointName( osctap::IpEndpointName::ANY_ADDRESS, 9000 ), &listener ); +server.Run(); // blocks; call server.AsynchronousBreak() to stop +``` + +`Run()` is single-threaded and `select()`-based; `Break()` / +`AsynchronousBreak()` stop it (the latter from another thread or a signal +handler). Connections are reaped on disconnect. + +## Client (send) + +`TcpTransmitSocket` connects and sends length-prefixed packets, looping over +partial writes; `TCP_NODELAY` is on (Nagle off — without it OSC-over-TCP latency +is a classic footgun). + +```cpp +#include "ip/TcpSocket.h" +#include "osc/OscOutboundPacketStream.h" + +osctap::TcpTransmitSocket client( osctap::IpEndpointName( "127.0.0.1", 9000 ) ); + +char buf[1024]; +osctap::OutboundPacketStream p( buf, sizeof(buf) ); +p << osctap::BeginMessage( "/fader/1" ) << 0.75f << osctap::EndMessage(); +client.Send( p.Data(), p.Size() ); // prepends the 4-byte length frame +``` + +## Security: the length prefix is attacker-controlled + +A hostile peer can announce a 2 GB frame. The deframer therefore **caps** the +frame size (default 64 KiB; configurable per socket/deframer) and refuses to +buffer beyond it — `Consume()` returns `false` and the server drops that +connection. This is the same bounded-size discipline as the blob-size fixes +(audit #1/#4), and the deframer is continuously fuzzed (`fuzz/fuzz_deframe.cpp`, +wired into ClusterFuzzLite). + +```cpp +osctap::TcpListeningReceiveSocket server( endpoint, &listener, /*maxFrameSize=*/ 4096 ); +``` + +As always, parsing the reassembled packet still validates the OSC structure +itself (and throws on malformed input, or — on a no-exceptions build — gate it +with `TryValidatePacket()`; see the embedded guide). + +## Realtime note + +Reassembly buffering happens on the network thread, which is **off** the realtime +contract by design — consistent with OscTap's split between validation (may +allocate/throw, off the audio thread) and the throw-free `*Unchecked` read path. +Parsing a reassembled packet is the same allocation-free RT read path as for UDP. + +## Status / caveats + +- **POSIX is the runtime-verified backend** (`tests/OscTcpTest.cpp` exercises a + real loopback client+server, including a message that spans TCP segments, and is + clean under ASan/UBSan and TSan). +- **Windows** (`ip/win32/TcpSocket.h`) mirrors it on Winsock and is compile/link- + verified (MinGW + the windows-latest CI legs) but not yet runtime-tested in CI. +- Deferred to a future version: SLIP framing, TLS, WebSocket transport, and an + `epoll`/`poll` loop for very high connection counts (`select()`/`FD_SETSIZE` is + fine for a handful of connections). + +## See also + +- [`../osctap/osc/OscStreamFraming.h`](../osctap/osc/OscStreamFraming.h) — the framing codec. +- [`../osctap/ip/TcpSocket.h`](../osctap/ip/TcpSocket.h) — the public TCP types. +- [`../ROADMAP.md`](../ROADMAP.md) — where this fits in Phase 2. diff --git a/docs/STATUS.md b/docs/STATUS.md index a09f8d5..20ecc15 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -143,6 +143,18 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin `OscTap.kt`). The bridge is compile-checked against `jni.h` in CI-adjacent dev but there is **no NDK build in CI** yet — changes there aren't gated, so keep the bridge's OscTap API usage in sync by hand (or add an NDK job when one's warranted). +- **OSC over TCP lives in `osc/OscStreamFraming.h` + `ip/TcpSocket.h`** (issue #14). + The framing codec is transport-agnostic and the **single place** length-prefix + framing is defined — the sockets only call it; don't reimplement the length + handling in the socket layer. The deframer **caps the frame size** (default 64 KiB) + so a hostile prefix can't exhaust memory; keep that guard and the `fuzz_deframe` + coverage. Unlike the UDP facade (template-over-`Implementation`), the TCP types are + **concrete per-platform classes** (`posix::`/`win32::`) selected by `using` in the + facade. **POSIX is runtime-tested** (`OscTcpTest`, POSIX-only like + `OscConcurrencyTest`, also under TSan); **win32 is compile/link-verified only** + (MinGW + windows-latest legs, no Windows runner) — treat win32 TCP changes as + unverified at runtime and lean on the posix backend as the reference. The win32 + break uses a self-connected loopback UDP socket (no `pipe()` on Windows). - **Include guards are named `INCLUDED_OSCTAP_*`** (renamed from `INCLUDED_OSCPACK_*` in Phase 2 cleanup). The `` *include paths* still work via the redirect shim tree — only the internal guard macro names changed. From ccd07cb4f990d5aba3c1b221bd06d176dbbe5daf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 22:35:49 +0000 Subject: [PATCH 09/16] TCP v1 (issue #14): runnable demo pair (tcp_server + tcp_send) OSC-over-TCP counterparts to the UDP pi5_hub / osc_send demos: - demos/tcp_server.cpp: listens, accepts multiple clients, prints each received message (address + typed args + peer); drops malformed frames instead of dying. - demos/tcp_send.cpp: connects and sends one message, typed-arg CLI mirroring osc_send (i:/f:/s:/T/F). Wired into OSCTAP_BUILD_DEMOS (POSIX); docs/OSC_OVER_TCP.md gains a "try it" section. Verified on loopback: three connections, all messages received and decoded correctly. Built -Werror-clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- CMakeLists.txt | 8 ++++ demos/tcp_send.cpp | 102 +++++++++++++++++++++++++++++++++++++++++++ demos/tcp_server.cpp | 76 ++++++++++++++++++++++++++++++++ docs/OSC_OVER_TCP.md | 14 ++++++ 4 files changed, 200 insertions(+) create mode 100644 demos/tcp_send.cpp create mode 100644 demos/tcp_server.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d11d116..566ee38 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -161,6 +161,14 @@ if(OSCTAP_BUILD_DEMOS AND NOT WIN32) add_executable(osc_send demos/osc_send.cpp) target_link_libraries(osc_send oscpack) + + # OSC-over-TCP demo pair (see docs/OSC_OVER_TCP.md): a server/monitor and a + # CLI client, counterparts to pi5_hub / osc_send. + add_executable(tcp_server demos/tcp_server.cpp) + target_link_libraries(tcp_server oscpack) + + add_executable(tcp_send demos/tcp_send.cpp) + target_link_libraries(tcp_send oscpack) endif() set(OSCTAP_BUILD_FUZZERS OFF CACHE BOOL "Build the libFuzzer fuzz target (requires Clang)") diff --git a/demos/tcp_send.cpp b/demos/tcp_send.cpp new file mode 100644 index 0000000..b60f614 --- /dev/null +++ b/demos/tcp_send.cpp @@ -0,0 +1,102 @@ +/* + OscTap demo: command-line OSC-over-TCP sender. + + Connects to an OSC-over-TCP server (e.g. tcp_server) and sends one OSC message. + TCP counterpart to the UDP osc_send demo. See docs/OSC_OVER_TCP.md. + + Build via the OSCTAP_BUILD_DEMOS CMake option, or directly: + g++ -std=c++17 -I. -Iosctap demos/tcp_send.cpp -o tcp_send + + Usage: + tcp_send
[args...] + + Each arg is typed by a one-letter prefix (default is auto: int, else float, + else string): + i:42 int32 f:3.14 float + s:hello string T / F bool true / false + + Examples: + tcp_send 127.0.0.1 9000 /fader/1 f:0.75 + tcp_send 127.0.0.1 9000 /chat s:hello T +*/ + +#include "ip/TcpSocket.h" +#include "ip/IpEndpointName.h" +#include "osc/OscOutboundPacketStream.h" + +#include +#include +#include +#include + +namespace { + +bool ParseInt( const char *s, int32_t& out ) +{ + char *end = nullptr; + long v = std::strtol( s, &end, 10 ); + if( end == s || *end != '\0' ) return false; + out = static_cast( v ); + return true; +} + +bool ParseFloat( const char *s, float& out ) +{ + char *end = nullptr; + float v = std::strtof( s, &end ); + if( end == s || *end != '\0' ) return false; + out = v; + return true; +} + +void AppendArg( osctap::OutboundPacketStream& p, const char *tok ) +{ + if( std::strcmp( tok, "T" ) == 0 ) { p << true; return; } + if( std::strcmp( tok, "F" ) == 0 ) { p << false; return; } + + if( std::strncmp( tok, "i:", 2 ) == 0 ) { p << (int32_t)std::atoi( tok + 2 ); return; } + if( std::strncmp( tok, "f:", 2 ) == 0 ) { p << (float)std::atof( tok + 2 ); return; } + if( std::strncmp( tok, "s:", 2 ) == 0 ) { const char *s = tok + 2; p << s; return; } + + int32_t i; float f; + if( ParseInt( tok, i ) ) p << i; + else if( ParseFloat( tok, f ) ) p << f; + else p << tok; +} + +} // namespace + +int main( int argc, char *argv[] ) +{ + if( argc < 4 ){ + std::cerr << "usage: tcp_send
[args...]\n"; + return 2; + } + const char *host = argv[1]; + const int port = std::atoi( argv[2] ); + const char *address = argv[3]; + + char buffer[1024]; + osctap::OutboundPacketStream p( buffer, sizeof(buffer) ); + try { + p << osctap::BeginMessage( address ); + for( int i = 4; i < argc; ++i ) + AppendArg( p, argv[i] ); + p << osctap::EndMessage(); + } catch( const osctap::Exception& e ) { + std::cerr << "failed to build message: " << e.what() << '\n'; + return 1; + } + + try { + osctap::TcpTransmitSocket client( osctap::IpEndpointName( host, port ) ); + client.Send( p.Data(), p.Size() ); + } catch( const std::exception& e ) { + std::cerr << "send failed: " << e.what() << '\n'; + return 1; + } + + std::cout << "sent " << p.Size() << " bytes to " << host << ':' << port + << " " << address << " (TCP)\n"; + return 0; +} diff --git a/demos/tcp_server.cpp b/demos/tcp_server.cpp new file mode 100644 index 0000000..5187a05 --- /dev/null +++ b/demos/tcp_server.cpp @@ -0,0 +1,76 @@ +/* + OscTap demo: OSC-over-TCP server / monitor. + + Listens for OSC-over-TCP clients and prints every message it receives (address + + typed arguments + peer). A TCP counterpart to the UDP pi5_hub demo, and the + server side for testing tcp_send. See docs/OSC_OVER_TCP.md. + + Build via the OSCTAP_BUILD_DEMOS CMake option, or directly: + g++ -std=c++17 -I. -Iosctap demos/tcp_server.cpp -o tcp_server + + Usage: + tcp_server [port] (default port 9000) +*/ + +#include "ip/TcpSocket.h" +#include "ip/IpEndpointName.h" +#include "osc/OscPacketListener.h" + +#include +#include +#include +#include + +namespace { + +class PrintingListener : public osctap::OscPacketListener { + // Untrusted input: parsing can throw on a malformed frame. Catch it so one bad + // client can't take the server down. + void ProcessPacket( const char *data, int size, const osctap::IpEndpointName& from ) override + { + try { + osctap::OscPacketListener::ProcessPacket( data, size, from ); + } catch( const osctap::Exception& e ) { + std::cerr << "[drop] malformed packet (" << e.what() << ")\n"; + } + } + +protected: + void ProcessMessage( const osctap::ReceivedMessage& m, const osctap::IpEndpointName& from ) override + { + char who[ osctap::IpEndpointName::ADDRESS_AND_PORT_STRING_LENGTH ]; + from.AddressAndPortAsString( who ); + std::cout << "[recv " << who << "] " << m.AddressPattern() + << " (" << m.ArgumentCount() << " args)"; + for( auto a = m.ArgumentsBegin(); a != m.ArgumentsEnd(); ++a ){ + std::cout << ' '; + if( a->IsInt32() ) std::cout << a->AsInt32Unchecked(); + else if( a->IsFloat() ) std::cout << a->AsFloatUnchecked(); + else if( a->IsString() ) std::cout << '"' << a->AsStringUnchecked() << '"'; + else if( a->IsBool() ) std::cout << (a->AsBoolUnchecked() ? "true" : "false"); + else std::cout << '?'; + } + std::cout << '\n'; + } +}; + +osctap::TcpListeningReceiveSocket *gSocket = nullptr; +void HandleSigInt( int ) { if( gSocket ) gSocket->AsynchronousBreak(); } + +} // namespace + +int main( int argc, char *argv[] ) +{ + const int port = (argc > 1) ? std::atoi( argv[1] ) : 9000; + + PrintingListener listener; + osctap::TcpListeningReceiveSocket socket( + osctap::IpEndpointName( osctap::IpEndpointName::ANY_ADDRESS, port ), &listener ); + gSocket = &socket; + std::signal( SIGINT, HandleSigInt ); + + std::cout << "OscTap TCP server listening on TCP " << port << " (Ctrl-C to stop)\n"; + socket.Run(); + std::cout << "\nserver stopped.\n"; + return 0; +} diff --git a/docs/OSC_OVER_TCP.md b/docs/OSC_OVER_TCP.md index e74d326..439d94a 100644 --- a/docs/OSC_OVER_TCP.md +++ b/docs/OSC_OVER_TCP.md @@ -117,6 +117,20 @@ Parsing a reassembled packet is the same allocation-free RT read path as for UDP `epoll`/`poll` loop for very high connection counts (`select()`/`FD_SETSIZE` is fine for a handful of connections). +## Try it (runnable demos) + +A server/monitor and a CLI client (`OSCTAP_BUILD_DEMOS`, POSIX), counterparts to +the UDP `pi5_hub` / `osc_send`: + +```sh +cmake -S . -B build -DOSCTAP_BUILD_DEMOS=ON +cmake --build build --target tcp_server tcp_send + +./build/tcp_server 9000 & # prints each received message +./build/tcp_send 127.0.0.1 9000 /fader/1 f:0.75 +./build/tcp_send 127.0.0.1 9000 /chat s:hello T +``` + ## See also - [`../osctap/osc/OscStreamFraming.h`](../osctap/osc/OscStreamFraming.h) — the framing codec. From 66f109a9879c2cd7c72822b0dcdb0a639aec4022 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 22:59:25 +0000 Subject: [PATCH 10/16] Testing cleanup: UDP loopback test (+ LocalPort bug fix), revive examples, drop dead tests Addresses 3 of the 5 testing-gap items: - Item 2 (UDP loopback): tests/OscUdpTest.cpp -- asserting client+server over UDP loopback, the counterpart to OscTcpTest. Writing it surfaced a real bug: UdpSocketImplementation::Bind() never read the OS-assigned port back, so LocalPort() returned 0 after a port-0 bind (sender then targeted port 0 -> nothing delivered; the old best-effort, never-asserted OscConcurrencyTest packet hid it). Fixed in posix and win32 via getsockname() after bind. OscTcpTest + OscUdpTest now SKIP gracefully if the environment forbids loopback networking. - Item 1 (dead tests): delete tests/OscSendTests.{cpp,h} and OscReceiveTest.{cpp,h} -- interactive, dead osc:: namespace, superseded by demos/ + the loopback tests. - Item 5 (stale examples): fix examples/SimpleSend.cpp + SimpleReceive.cpp to compile against the current API (and the oscpack:: alias); OscDump.cpp was already current. All three are now compile-checked in CMake (not run). Verified: full suite 8/8 (incl. OscUdpTest); win32 compiles via MinGW. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- CMakeLists.txt | 46 +++--- docs/STATUS.md | 14 ++ examples/SimpleReceive.cpp | 81 +++++------ examples/SimpleSend.cpp | 34 +++-- osctap/ip/posix/UdpSocket.h | 8 ++ osctap/ip/win32/UdpSocket.h | 7 + tests/OscReceiveTest.cpp | 278 ------------------------------------ tests/OscReceiveTest.h | 40 ------ tests/OscSendTests.cpp | 230 ----------------------------- tests/OscSendTests.h | 39 ----- tests/OscTcpTest.cpp | 31 +++- tests/OscUdpTest.cpp | 112 +++++++++++++++ 12 files changed, 246 insertions(+), 674 deletions(-) delete mode 100644 tests/OscReceiveTest.cpp delete mode 100644 tests/OscReceiveTest.h delete mode 100644 tests/OscSendTests.cpp delete mode 100644 tests/OscSendTests.h create mode 100644 tests/OscUdpTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 566ee38..2ccf999 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -103,33 +103,33 @@ if(OSCPACK_BUILD_EXAMPLES) target_link_options(OscConcurrencyTest PRIVATE -fsanitize=thread) endif() - # OSC-over-TCP loopback test: real client + multi-connection server, exercising - # the deframer through actual sockets (incl. a segment-spanning message). Uses - # std::thread + sockets, so POSIX-only for now (like OscConcurrencyTest). Also - # runs under the TSan job to vet Run() vs AsynchronousBreak() on the TCP loop. - add_executable(OscTcpTest tests/OscTcpTest.cpp) - target_link_libraries(OscTcpTest oscpack Threads::Threads) - add_test(NAME OscTcpTest COMMAND OscTcpTest) - if(OSCTAP_TSAN) - target_compile_options(OscTcpTest PRIVATE -fsanitize=thread -g) - target_link_options(OscTcpTest PRIVATE -fsanitize=thread) - endif() + # OSC-over-UDP and OSC-over-TCP loopback tests: real socket client + server, + # asserting that messages arrive and decode correctly (the TCP one also spans + # 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) + add_executable(${socktest} tests/${socktest}.cpp) + target_link_libraries(${socktest} oscpack Threads::Threads) + add_test(NAME ${socktest} COMMAND ${socktest}) + if(OSCTAP_TSAN) + target_compile_options(${socktest} PRIVATE -fsanitize=thread -g) + target_link_options(${socktest} PRIVATE -fsanitize=thread) + endif() + endforeach() endif() - #add_executable(OscSendTests tests/OscSendTests.cpp) - #target_link_libraries(OscSendTests oscpack) - - #add_executable(OscReceiveTest tests/OscReceiveTest.cpp) - #target_link_libraries(OscReceiveTest oscpack) - - #add_executable(OscDump examples/OscDump.cpp) - #target_link_libraries(OscDump oscpack) + # Canonical oscpack examples, compile-checked so they can't bit-rot (they use + # the deprecated oscpack:: alias on purpose). Not run -- they bind sockets / + # block on input; see demos/ for the modern, tested equivalents. + add_executable(SimpleSend examples/SimpleSend.cpp) + target_link_libraries(SimpleSend oscpack) - #add_executable(SimpleReceive examples/SimpleReceive.cpp) - #target_link_libraries(SimpleReceive oscpack) + add_executable(SimpleReceive examples/SimpleReceive.cpp) + target_link_libraries(SimpleReceive oscpack) - #add_executable(SimpleSend examples/SimpleSend.cpp) - #target_link_libraries(SimpleSend oscpack) + add_executable(OscDump examples/OscDump.cpp) + target_link_libraries(OscDump oscpack) endif() # Freestanding / embedded profile (Phase 2 "Reach"). Builds a single smoke test diff --git a/docs/STATUS.md b/docs/STATUS.md index 20ecc15..cd03ad4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -155,6 +155,20 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin (MinGW + windows-latest legs, no Windows runner) — treat win32 TCP changes as unverified at runtime and lean on the posix backend as the reference. The win32 break uses a self-connected loopback UDP socket (no `pipe()` on Windows). +- **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 + restricted runners — but a real regression still fails them. Writing `OscUdpTest` + surfaced a latent bug: `UdpSocketImplementation::Bind()` never read the OS-assigned + port back, so `LocalPort()` returned 0 after a port-0 bind (a sender using it + targeted port 0 → nothing delivered; the old `OscConcurrencyTest` packet was + "best-effort, never asserted", which hid it). Fixed in both posix and win32 via + `getsockname()` after bind. **Don't drop that read-back.** +- **`examples/` (SimpleSend/SimpleReceive/OscDump) are compile-checked, not run** + (they bind sockets / block on input). They use the `oscpack::` alias on purpose + (extra shim coverage). `demos/` are the modern, tested equivalents. The old + interactive `tests/OscSendTests`/`OscReceiveTest` (dead `osc::` namespace) were + deleted — superseded by `demos/` + the loopback tests. - **Include guards are named `INCLUDED_OSCTAP_*`** (renamed from `INCLUDED_OSCPACK_*` in Phase 2 cleanup). The `` *include paths* still work via the redirect shim tree — only the internal guard macro names changed. diff --git a/examples/SimpleReceive.cpp b/examples/SimpleReceive.cpp index 07822fa..d6df5d3 100644 --- a/examples/SimpleReceive.cpp +++ b/examples/SimpleReceive.cpp @@ -1,86 +1,81 @@ -/* - Example of two different ways to process received OSC messages using oscpack. - Receives the messages from the SimpleSend.cpp example. +/* + Example of two ways to process received OSC messages using OscTap. + Receives the messages sent by SimpleSend.cpp. + + Canonical oscpack example, kept compiling against the current API (uses the + deprecated `oscpack` alias on purpose). For a routing/monitoring receiver see + demos/pi5_hub.cpp and demos/tcp_server.cpp. */ -#include +#include #include - -#if defined(__BORLANDC__) // workaround for BCB4 release build intrinsics bug -namespace std { -using ::__strcmp__; // avoid error: E2316 '__strcmp__' is not a member of 'std'. -} -#endif +#include #include "osc/OscReceivedElements.h" #include "osc/OscPacketListener.h" #include "ip/UdpSocket.h" +using namespace oscpack; // OscTap's deprecated oscpack:: alias, exercised here #define PORT 7000 -class ExamplePacketListener : public osc::OscPacketListener { +class ExamplePacketListener : public OscPacketListener { protected: - - virtual void ProcessMessage( const osc::ReceivedMessage& m, - const IpEndpointName& remoteEndpoint ) + void ProcessMessage( const ReceivedMessage& m, + const IpEndpointName& remoteEndpoint ) override { - (void) remoteEndpoint; // suppress unused parameter warning + (void) remoteEndpoint; try{ - // example of parsing single messages. osc::OsckPacketListener - // handles the bundle traversal. - + // OscPacketListener handles bundle traversal; we just read messages. if( std::strcmp( m.AddressPattern(), "/test1" ) == 0 ){ - // example #1 -- argument stream interface - osc::ReceivedMessageArgumentStream args = m.ArgumentStream(); - bool a1; - osc::int32_t a2; - float a3; - const char *a4; - args >> a1 >> a2 >> a3 >> a4 >> osc::EndMessage; - + // example #1 -- argument-stream interface + ReceivedMessageArgumentStream args = m.ArgumentStream(); + bool a1; int32_t a2; float a3; const char *a4; + MessageTerminator end; + args >> a1 >> a2 >> a3 >> a4 >> end; + std::cout << "received '/test1' message with arguments: " << a1 << " " << a2 << " " << a3 << " " << a4 << "\n"; - + }else if( std::strcmp( m.AddressPattern(), "/test2" ) == 0 ){ - // example #2 -- argument iterator interface, supports - // reflection for overloaded messages (eg you can call - // (*arg)->IsBool() to check if a bool was passed etc). - osc::ReceivedMessage::const_iterator arg = m.ArgumentsBegin(); + // example #2 -- argument-iterator interface (supports reflection, + // e.g. arg->IsBool() to check the type of an overloaded argument) + ReceivedMessage::const_iterator arg = m.ArgumentsBegin(); bool a1 = (arg++)->AsBool(); int a2 = (arg++)->AsInt32(); float a3 = (arg++)->AsFloat(); const char *a4 = (arg++)->AsString(); if( arg != m.ArgumentsEnd() ) - throw osc::ExcessArgumentException(); - + throw ExcessArgumentException(); + std::cout << "received '/test2' message with arguments: " << a1 << " " << a2 << " " << a3 << " " << a4 << "\n"; } - }catch( osc::Exception& e ){ - // any parsing errors such as unexpected argument types, or - // missing arguments get thrown as exceptions. + }catch( Exception& e ){ + // parsing errors (wrong/missing argument types) are thrown std::cout << "error while parsing message: " << m.AddressPattern() << ": " << e.what() << "\n"; } } }; +namespace { UdpListeningReceiveSocket* gSocket = nullptr; + void HandleSigInt( int ){ if( gSocket ) gSocket->AsynchronousBreak(); } } + int main(int argc, char* argv[]) { - (void) argc; // suppress unused parameter warnings - (void) argv; // suppress unused parameter warnings + (void) argc; (void) argv; ExamplePacketListener listener; UdpListeningReceiveSocket s( - IpEndpointName( IpEndpointName::ANY_ADDRESS, PORT ), - &listener ); + IpEndpointName( IpEndpointName::ANY_ADDRESS, PORT ), &listener ); - std::cout << "press ctrl-c to end\n"; + gSocket = &s; + std::signal( SIGINT, HandleSigInt ); - s.RunUntilSigInt(); + std::cout << "press ctrl-c to end\n"; + s.Run(); return 0; } - diff --git a/examples/SimpleSend.cpp b/examples/SimpleSend.cpp index 0049fcb..2215c35 100644 --- a/examples/SimpleSend.cpp +++ b/examples/SimpleSend.cpp @@ -1,10 +1,15 @@ -/* - Simple example of sending an OSC message using oscpack. +/* + Simple example of sending an OSC message bundle using OscTap. + + Canonical oscpack example, kept compiling against the current API (and using + the deprecated `oscpack` alias on purpose, to exercise the migration shim). + For a typed command-line sender see demos/osc_send.cpp. */ #include "osc/OscOutboundPacketStream.h" #include "ip/UdpSocket.h" +using namespace oscpack; // OscTap's deprecated oscpack:: alias, exercised here #define ADDRESS "127.0.0.1" #define PORT 7000 @@ -14,20 +19,21 @@ int main(int argc, char* argv[]) { (void) argc; // suppress unused parameter warnings - (void) argv; // suppress unused parameter warnings + (void) argv; UdpTransmitSocket transmitSocket( IpEndpointName( ADDRESS, PORT ) ); - + char buffer[OUTPUT_BUFFER_SIZE]; - osc::OutboundPacketStream p( buffer, OUTPUT_BUFFER_SIZE ); - - p << osc::BeginBundleImmediate - << osc::BeginMessage( "/test1" ) - << true << 23 << (float)3.1415 << "hello" << osc::EndMessage - << osc::BeginMessage( "/test2" ) - << true << 24 << (float)10.8 << "world" << osc::EndMessage - << osc::EndBundle; - + OutboundPacketStream p( buffer, OUTPUT_BUFFER_SIZE ); + + p << BeginBundleImmediate() + << BeginMessage( "/test1" ) + << true << (int32_t)23 << (float)3.1415f << "hello" << EndMessage() + << BeginMessage( "/test2" ) + << true << (int32_t)24 << (float)10.8f << "world" << EndMessage() + << EndBundle(); + transmitSocket.Send( p.Data(), p.Size() ); -} + return 0; +} diff --git a/osctap/ip/posix/UdpSocket.h b/osctap/ip/posix/UdpSocket.h index 7f265d3..bd33a1d 100644 --- a/osctap/ip/posix/UdpSocket.h +++ b/osctap/ip/posix/UdpSocket.h @@ -234,6 +234,14 @@ class UdpSocketImplementation{ } isBound_ = true; + + // Read back the actual local port. When the caller binds to port 0 the OS + // assigns one; without this LocalPort() would still report 0 (so a sender + // using LocalPort() would target port 0 and nothing would be delivered). + struct sockaddr_in boundAddr; + socklen_t boundLen = sizeof(boundAddr); + if( getsockname( socket_, (struct sockaddr *)&boundAddr, &boundLen ) == 0 ) + localPort_ = ntohs( boundAddr.sin_port ); } bool IsBound() const { return isBound_; } diff --git a/osctap/ip/win32/UdpSocket.h b/osctap/ip/win32/UdpSocket.h index d76c70b..fbf3e41 100644 --- a/osctap/ip/win32/UdpSocket.h +++ b/osctap/ip/win32/UdpSocket.h @@ -232,6 +232,13 @@ class UdpSocketImplementation{ } isBound_ = true; + + // Read back the actual local port (resolves an OS-assigned port when the + // caller binds to port 0; otherwise LocalPort() would still report 0). + struct sockaddr_in boundAddr; + socklen_t boundLen = sizeof(boundAddr); + if( getsockname( socket_, (struct sockaddr *)&boundAddr, &boundLen ) == 0 ) + localPort_ = ntohs( boundAddr.sin_port ); } bool IsBound() const { return isBound_; } diff --git a/tests/OscReceiveTest.cpp b/tests/OscReceiveTest.cpp deleted file mode 100644 index 72a7f07..0000000 --- a/tests/OscReceiveTest.cpp +++ /dev/null @@ -1,278 +0,0 @@ -/* - oscpack -- Open Sound Control (OSC) packet manipulation library - http://www.rossbencina.com/code/oscpack - - Copyright (c) 2004-2013 Ross Bencina - - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files - (the "Software"), to deal in the Software without restriction, - including without limitation the rights to use, copy, modify, merge, - publish, distribute, sublicense, and/or sell copies of the Software, - and to permit persons to whom the Software is furnished to do so, - subject to the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR - ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF - CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -/* - The text above constitutes the entire oscpack license; however, - the oscpack developer(s) also make the following non-binding requests: - - Any person wishing to distribute modifications to the Software is - requested to send the modifications to the original developer so that - they can be incorporated into the canonical version. It is also - requested that these non-binding requests be included whenever the - above license is reproduced. -*/ -#include "OscReceiveTest.h" - -#include -#include -#include - -#if defined(__BORLANDC__) // workaround for BCB4 release build intrinsics bug -namespace std { -using ::__strcmp__; // avoid error: E2316 '__strcmp__' is not a member of 'std'. -} -#endif - -#include "osc/OscReceivedElements.h" - -#include "ip/UdpSocket.h" -#include "osc/OscPacketListener.h" - - -namespace osc{ - -class OscReceiveTestPacketListener : public OscPacketListener{ -protected: - - void ProcessMessage( const osc::ReceivedMessage& m, - const IpEndpointName& remoteEndpoint ) - { - (void) remoteEndpoint; // suppress unused parameter warning - - // a more complex scheme involving std::map or some other method of - // processing address patterns could be used here - // (see MessageMappingOscPacketListener.h for example). however, the main - // purpose of this example is to illustrate and test different argument - // parsing methods - - try { - // argument stream, and argument iterator, used in different - // examples below. - ReceivedMessageArgumentStream args = m.ArgumentStream(); - ReceivedMessage::const_iterator arg = m.ArgumentsBegin(); - - if( std::strcmp( m.AddressPattern(), "/test1" ) == 0 ){ - - // example #1: - // parse an expected format using the argument stream interface: - bool a1; - osc::int32_t a2; - float a3; - const char *a4; - args >> a1 >> a2 >> a3 >> a4 >> osc::EndMessage; - - std::cout << "received '/test1' message with arguments: " - << a1 << " " << a2 << " " << a3 << " " << a4 << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/test2" ) == 0 ){ - - // example #2: - // parse an expected format using the argument iterator interface - // this is a more complicated example of doing the same thing - // as above. - bool a1 = (arg++)->AsBool(); - int a2 = (arg++)->AsInt32(); - float a3 = (arg++)->AsFloat(); - const char *a4 = (arg++)->AsString(); - if( arg != m.ArgumentsEnd() ) - throw ExcessArgumentException(); - - std::cout << "received '/test2' message with arguments: " - << a1 << " " << a2 << " " << a3 << " " << a4 << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/test3" ) == 0 ){ - - // example #3: - // parse a variable argument format using the argument iterator - // interface. this is where it is necessary to use - // argument iterators instead of streams. - // When messages may contain arguments of varying type, you can - // use the argument iterator interface to query the types at - // runtime. this is more flexible that the argument stream - // interface, which requires each argument to have a fixed type - - if( arg->IsBool() ){ - bool a = (arg++)->AsBoolUnchecked(); - std::cout << "received '/test3' message with bool argument: " - << a << "\n"; - }else if( arg->IsInt32() ){ - int a = (arg++)->AsInt32Unchecked(); - std::cout << "received '/test3' message with int32_t argument: " - << a << "\n"; - }else if( arg->IsFloat() ){ - float a = (arg++)->AsFloatUnchecked(); - std::cout << "received '/test3' message with float argument: " - << a << "\n"; - }else if( arg->IsString() ){ - const char *a = (arg++)->AsStringUnchecked(); - std::cout << "received '/test3' message with string argument: '" - << a << "'\n"; - }else{ - std::cout << "received '/test3' message with unexpected argument type\n"; - } - - if( arg != m.ArgumentsEnd() ) - throw ExcessArgumentException(); - - - }else if( std::strcmp( m.AddressPattern(), "/no_arguments" ) == 0 ){ - - args >> osc::EndMessage; - std::cout << "received '/no_arguments' message\n"; - - }else if( std::strcmp( m.AddressPattern(), "/a_bool" ) == 0 ){ - - bool a; - args >> a >> osc::EndMessage; - std::cout << "received '/a_bool' message: " << a << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/nil" ) == 0 ){ - - std::cout << "received '/nil' message\n"; - - }else if( std::strcmp( m.AddressPattern(), "/inf" ) == 0 ){ - - std::cout << "received '/inf' message\n"; - - }else if( std::strcmp( m.AddressPattern(), "/an_int" ) == 0 ){ - - osc::int32_t a; - args >> a >> osc::EndMessage; - std::cout << "received '/an_int' message: " << a << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/a_float" ) == 0 ){ - - float a; - args >> a >> osc::EndMessage; - std::cout << "received '/a_float' message: " << a << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/a_char" ) == 0 ){ - - char a; - args >> a >> osc::EndMessage; - char s[2] = {0}; - s[0] = a; - std::cout << "received '/a_char' message: '" << s << "'\n"; - - }else if( std::strcmp( m.AddressPattern(), "/an_rgba_color" ) == 0 ){ - - osc::RgbaColor a; - args >> a >> osc::EndMessage; - std::cout << "received '/an_rgba_color' message: " << a.value << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/a_midi_message" ) == 0 ){ - - osc::MidiMessage a; - args >> a >> osc::EndMessage; - std::cout << "received '/a_midi_message' message: " << a.value << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/an_int64_t" ) == 0 ){ - - osc::int64_t a; - args >> a >> osc::EndMessage; - std::cout << "received '/an_int64_t' message: " << a << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/a_time_tag" ) == 0 ){ - - osc::TimeTag a; - args >> a >> osc::EndMessage; - std::cout << "received '/a_time_tag' message: " << a.value << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/a_double" ) == 0 ){ - - double a; - args >> a >> osc::EndMessage; - std::cout << "received '/a_double' message: " << a << "\n"; - - }else if( std::strcmp( m.AddressPattern(), "/a_string" ) == 0 ){ - - const char *a; - args >> a >> osc::EndMessage; - std::cout << "received '/a_string' message: '" << a << "'\n"; - - }else if( std::strcmp( m.AddressPattern(), "/a_symbol" ) == 0 ){ - - osc::Symbol a; - args >> a >> osc::EndMessage; - std::cout << "received '/a_symbol' message: '" << a.value << "'\n"; - - }else if( std::strcmp( m.AddressPattern(), "/a_blob" ) == 0 ){ - - osc::Blob a; - args >> a >> osc::EndMessage; - std::cout << "received '/a_blob' message\n"; - - }else{ - std::cout << "unrecognised address pattern: " - << m.AddressPattern() << "\n"; - } - - }catch( Exception& e ){ - std::cout << "error while parsing message: " - << m.AddressPattern() << ": " << e.what() << "\n"; - } - } -}; - - -void RunReceiveTest( int port ) -{ - osc::OscReceiveTestPacketListener listener; - UdpListeningReceiveSocket s( - IpEndpointName( IpEndpointName::ANY_ADDRESS, port ), - &listener ); - - std::cout << "listening for input on port " << port << "...\n"; - std::cout << "press ctrl-c to end\n"; - - s.RunUntilSigInt(); - - std::cout << "finishing.\n"; -} - -} // namespace osc - -#ifndef NO_OSC_TEST_MAIN - -int main(int argc, char* argv[]) -{ - if( argc >= 2 && std::strcmp( argv[1], "-h" ) == 0 ){ - std::cout << "usage: OscReceiveTest [port]\n"; - return 0; - } - - int port = 7000; - - if( argc >= 2 ) - port = std::atoi( argv[1] ); - - osc::RunReceiveTest( port ); - - return 0; -} - -#endif /* NO_OSC_TEST_MAIN */ - diff --git a/tests/OscReceiveTest.h b/tests/OscReceiveTest.h deleted file mode 100644 index c5effa5..0000000 --- a/tests/OscReceiveTest.h +++ /dev/null @@ -1,40 +0,0 @@ -/* - oscpack -- Open Sound Control packet manipulation library - http://www.audiomulch.com/~rossb/oscpack - - Copyright (c) 2004-2005 Ross Bencina - - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files - (the "Software"), to deal in the Software without restriction, - including without limitation the rights to use, copy, modify, merge, - publish, distribute, sublicense, and/or sell copies of the Software, - and to permit persons to whom the Software is furnished to do so, - subject to the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - Any person wishing to distribute modifications to the Software is - requested to send the modifications to the original developer so that - they can be incorporated into the canonical version. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR - ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF - CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -#ifndef INCLUDED_OSCRECEIVETEST_H -#define INCLUDED_OSCRECEIVETEST_H - -namespace osc{ - -void RunReceiveTest( int port ); - -} // namespace osc - -#endif /* INCLUDED_OSCSENDTESTS_H */ diff --git a/tests/OscSendTests.cpp b/tests/OscSendTests.cpp deleted file mode 100644 index f5ab0e3..0000000 --- a/tests/OscSendTests.cpp +++ /dev/null @@ -1,230 +0,0 @@ -/* - oscpack -- Open Sound Control (OSC) packet manipulation library - http://www.rossbencina.com/code/oscpack - - Copyright (c) 2004-2013 Ross Bencina - - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files - (the "Software"), to deal in the Software without restriction, - including without limitation the rights to use, copy, modify, merge, - publish, distribute, sublicense, and/or sell copies of the Software, - and to permit persons to whom the Software is furnished to do so, - subject to the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR - ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF - CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -/* - The text above constitutes the entire oscpack license; however, - the oscpack developer(s) also make the following non-binding requests: - - Any person wishing to distribute modifications to the Software is - requested to send the modifications to the original developer so that - they can be incorporated into the canonical version. It is also - requested that these non-binding requests be included whenever the - above license is reproduced. -*/ -#include "OscSendTests.h" - -#include -#include -#include - -#if defined(__BORLANDC__) // workaround for BCB4 release build intrinsics bug -namespace std { -using ::__strcmp__; // avoid error: E2316 '__strcmp__' is not a member of 'std'. -} -#endif - -#include "osc/OscOutboundPacketStream.h" - -#include "ip/UdpSocket.h" -#include "ip/IpEndpointName.h" - -#define IP_MTU_SIZE 1536 - -namespace osc{ - -void RunSendTests( const IpEndpointName& host ) -{ - char buffer[IP_MTU_SIZE]; - osc::OutboundPacketStream p( buffer, IP_MTU_SIZE ); - UdpTransmitSocket socket( host ); - - p.Clear(); - p << osc::BeginMessage( "/test1" ) - << true << 23 << (float)3.1415 << "hello" << osc::EndMessage; - socket.Send( p.Data(), p.Size() ); - - std::cout << "NOTE: sending /test1 message with too few arguments\n"\ - "(expect an exception if receiving with OscReceiveTest)\n\n"; - p.Clear(); - p << osc::BeginMessage( "/test1" ) - << true << osc::EndMessage; - socket.Send( p.Data(), p.Size() ); - - std::cout << "NOTE: sending /test1 message with too many arguments\n"\ - "(expect an exception if receiving with OscReceiveTest)\n\n"; - p.Clear(); - p << osc::BeginMessage( "/test1" ) - << true << 23 << (float)3.1415 << "hello" << 42 << osc::EndMessage; - socket.Send( p.Data(), p.Size() ); - - std::cout << "NOTE: sending /test1 message with wrong argument type\n"\ - "(expect an exception if receiving with OscReceiveTest)\n\n"; - p.Clear(); - p << osc::BeginMessage( "/test1" ) - << true << 1.0 << (float)3.1415 << "hello" << osc::EndMessage; - socket.Send( p.Data(), p.Size() ); - - p.Clear(); - p << osc::BeginMessage( "/test2" ) - << true << 23 << (float)3.1415 << "hello" << osc::EndMessage; - socket.Send( p.Data(), p.Size() ); - - // send four /test3 messages, each with a different type of argument - p.Clear(); - p << osc::BeginMessage( "/test3" ) - << true << osc::EndMessage; - socket.Send( p.Data(), p.Size() ); - - p.Clear(); - p << osc::BeginMessage( "/test3" ) - << 23 << osc::EndMessage; - socket.Send( p.Data(), p.Size() ); - - p.Clear(); - p << osc::BeginMessage( "/test3" ) - << (float)3.1415 << osc::EndMessage; - socket.Send( p.Data(), p.Size() ); - - p.Clear(); - p << osc::BeginMessage( "/test3" ) - << "hello" << osc::EndMessage; - socket.Send( p.Data(), p.Size() ); - - - // send a bundle - p.Clear(); - p << osc::BeginBundle(); - - p << osc::BeginMessage( "/no_arguments" ) - << osc::EndMessage; - - p << osc::BeginMessage( "/a_bool" ) - << true << osc::EndMessage; - - p << osc::BeginMessage( "/a_bool" ) - << false << osc::EndMessage; - - p << osc::BeginMessage( "/a_bool" ) - << (bool)1234 << osc::EndMessage; - - p << osc::BeginMessage( "/nil" ) - << osc::Nil << osc::EndMessage; - - p << osc::BeginMessage( "/inf" ) - << osc::Infinitum << osc::EndMessage; - - p << osc::BeginMessage( "/an_int" ) << 1234 << osc::EndMessage; - - p << osc::BeginMessage( "/a_float" ) - << 3.1415926f << osc::EndMessage; - - p << osc::BeginMessage( "/a_char" ) - << 'c' << osc::EndMessage; - - p << osc::BeginMessage( "/an_rgba_color" ) - << osc::RgbaColor(0x22334455) << osc::EndMessage; - - p << osc::BeginMessage( "/a_midi_message" ) - << MidiMessage(0x7F) << osc::EndMessage; - - p << osc::BeginMessage( "/an_int64_t" ) - << (int64_t)(0xFFFFFFF) << osc::EndMessage; - - p << osc::BeginMessage( "/a_time_tag" ) - << osc::TimeTag(0xFFFFFFFUL) << osc::EndMessage; - - p << osc::BeginMessage( "/a_double" ) - << (double)3.1415926 << osc::EndMessage; - - p << osc::BeginMessage( "/a_string" ) - << "hello world" << osc::EndMessage; - - p << osc::BeginMessage( "/a_symbol" ) - << osc::Symbol("foobar") << osc::EndMessage; - - // blob - { - char blobData[] = "abcd"; - - p << osc::BeginMessage( "/a_blob" ) - << osc::Blob( blobData, 4 ) - << osc::EndMessage; - } - - p << osc::EndBundle; - socket.Send( p.Data(), p.Size() ); - - - - // nested bundles, and multiple messages in bundles... - p.Clear(); - p << osc::BeginBundle( 1234 ) - << osc::BeginMessage( "/an_int" ) << 1 << osc::EndMessage - << osc::BeginMessage( "/an_int" ) << 2 << osc::EndMessage - << osc::BeginMessage( "/an_int" ) << 3 << osc::EndMessage - << osc::BeginMessage( "/an_int" ) << 4 << osc::EndMessage - << osc::BeginBundle( 12345 ) - << osc::BeginMessage( "/an_int" ) << 5 << osc::EndMessage - << osc::BeginMessage( "/an_int" ) << 6 << osc::EndMessage - << osc::EndBundle - << osc::EndBundle; - - socket.Send( p.Data(), p.Size() ); -} - -} // namespace osc - -#ifndef NO_OSC_TEST_MAIN - -int main(int argc, char* argv[]) -{ - if( argc >= 2 && std::strcmp( argv[1], "-h" ) == 0 ){ - std::cout << "usage: OscSendTests [hostname [port]]\n"; - return 0; - } - - const char *hostName = "localhost"; - int port = 7000; - - if( argc >= 2 ) - hostName = argv[1]; - - if( argc >= 3 ) - port = std::atoi( argv[2] ); - - - IpEndpointName host( hostName, port ); - - char hostIpAddress[ IpEndpointName::ADDRESS_STRING_LENGTH ]; - host.AddressAsString( hostIpAddress ); - - std::cout << "sending test messages to " << hostName - << " (" << hostIpAddress << ") on port " << port << "...\n\n"; - - osc::RunSendTests( host ); -} - -#endif /* NO_OSC_TEST_MAIN */ diff --git a/tests/OscSendTests.h b/tests/OscSendTests.h deleted file mode 100644 index 7a564b2..0000000 --- a/tests/OscSendTests.h +++ /dev/null @@ -1,39 +0,0 @@ -/* - oscpack -- Open Sound Control packet manipulation library - http://www.audiomulch.com/~rossb/oscpack - - Copyright (c) 2004-2005 Ross Bencina - - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files - (the "Software"), to deal in the Software without restriction, - including without limitation the rights to use, copy, modify, merge, - publish, distribute, sublicense, and/or sell copies of the Software, - and to permit persons to whom the Software is furnished to do so, - subject to the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - Any person wishing to distribute modifications to the Software is - requested to send the modifications to the original developer so that - they can be incorporated into the canonical version. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR - ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF - CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ -#ifndef INCLUDED_OSCSENDTESTS_H -#define INCLUDED_OSCSENDTESTS_H - -namespace osc{ - -void RunSendTests( unsigned long address, int port ); - -} // namespace osc - -#endif /* INCLUDED_OSCSENDTESTS_H */ diff --git a/tests/OscTcpTest.cpp b/tests/OscTcpTest.cpp index 782eeff..915b5a4 100644 --- a/tests/OscTcpTest.cpp +++ b/tests/OscTcpTest.cpp @@ -54,9 +54,18 @@ int main() { RecordingListener listener; - // Bind the server to an OS-assigned loopback port, then discover it. - osctap::TcpListeningReceiveSocket server( - osctap::IpEndpointName( 127, 0, 0, 1, 0 ), &listener ); + // Bind the server to an OS-assigned loopback port, then discover it. A bind + // failure means the environment forbids loopback networking (some sandboxed + // CI) -> skip rather than fail, matching OscUdpTest / OscConcurrencyTest. + osctap::TcpListeningReceiveSocket* serverPtr = nullptr; + try { + serverPtr = new osctap::TcpListeningReceiveSocket( + osctap::IpEndpointName( 127, 0, 0, 1, 0 ), &listener ); + } catch( const std::exception& e ) { + std::printf( "OscTcpTest: SKIP (cannot bind loopback TCP: %s)\n", e.what() ); + return 0; + } + osctap::TcpListeningReceiveSocket& server = *serverPtr; const int port = server.LocalEndpointFor( osctap::IpEndpointName( 127, 0, 0, 1, 0 ) ).port; CHECK( port > 0 ); @@ -65,6 +74,7 @@ int main() // Client: connect and send four messages. The last is large enough to be // split across TCP segments, forcing the server-side deframer to reassemble. const std::string big( 4000, 'x' ); + bool sent = false; try { osctap::TcpTransmitSocket client( osctap::IpEndpointName( 127, 0, 0, 1, port ) ); char buf[8192]; @@ -88,17 +98,24 @@ int main() p << osctap::BeginMessage( "/big" ) << big.c_str() << osctap::EndMessage(); client.Send( p.Data(), p.Size() ); } + sent = true; } catch( const std::exception& e ) { - std::printf( "FAIL: client error: %s\n", e.what() ); - ++failures; + // Connect/send denied by the environment -> skip (not a library failure). + std::printf( "OscTcpTest: SKIP (loopback TCP send unavailable: %s)\n", e.what() ); } // Wait (bounded) for all four to arrive, then stop the server. - for( int i = 0; i < 500 && listener.count.load() < 4; ++i ) - std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); + if( sent ){ + for( int i = 0; i < 500 && listener.count.load() < 4; ++i ) + std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); + } server.AsynchronousBreak(); serverThread.join(); + delete serverPtr; + + if( !sent ) + return 0; // skipped: send path unavailable in this environment CHECK( listener.count.load() == 4 ); if( listener.addresses.size() == 4 ){ diff --git a/tests/OscUdpTest.cpp b/tests/OscUdpTest.cpp new file mode 100644 index 0000000..12224de --- /dev/null +++ b/tests/OscUdpTest.cpp @@ -0,0 +1,112 @@ +/* + OscTap OSC-over-UDP loopback test (POSIX). + + Asserting counterpart to the best-effort packet in OscConcurrencyTest: it binds + a real UdpListeningReceiveSocket on loopback, sends several OSC messages through + a UdpTransmitSocket, and verifies each arrives and decodes correctly. The TCP + path already had such a test (OscTcpTest); this gives the UDP socket path the + same asserting coverage. + + Resilient to sandboxed CI that forbids loopback UDP (some macOS runners throw + on connect/send): if the environment denies networking before any data flows, + the test SKIPs (prints a notice, returns success) rather than failing. +*/ + +#include "ip/UdpSocket.h" +#include "ip/IpEndpointName.h" +#include "osc/OscPacketListener.h" +#include "osc/OscOutboundPacketStream.h" + +#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() ){ + if( a->IsInt32() ) v = a->AsInt32Unchecked(); + else if( a->IsString() ) v = (int)std::strlen( a->AsStringUnchecked() ); + } + 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() +{ + RecordingListener listener; + + // Bind the receiver to an OS-assigned loopback port. A bind failure here means + // the environment forbids loopback networking -> skip. + osctap::UdpListeningReceiveSocket* receiver = nullptr; + try { + receiver = new osctap::UdpListeningReceiveSocket( + osctap::IpEndpointName( 127, 0, 0, 1, 0 ), &listener ); + } catch( const std::exception& e ) { + std::printf( "OscUdpTest: SKIP (cannot bind loopback UDP: %s)\n", e.what() ); + return 0; + } + const int port = receiver->LocalPort(); + + std::thread runner( [&]{ receiver->Run(); } ); + + bool sent = false; + try { + osctap::UdpTransmitSocket sender( osctap::IpEndpointName( 127, 0, 0, 1, port ) ); + char buf[256]; + const char* addrs[] = { "/u1", "/u2", "/u3" }; + const int ints[] = { 11, 22, 33 }; + for( int i = 0; i < 3; ++i ){ + osctap::OutboundPacketStream p( buf, sizeof(buf) ); + p << osctap::BeginMessage( addrs[i] ) << (int32_t)ints[i] << osctap::EndMessage(); + sender.Send( p.Data(), p.Size() ); + } + sent = true; + } catch( const std::exception& e ) { + std::printf( "OscUdpTest: SKIP (loopback UDP 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(); + delete receiver; + + if( !sent ) + return 0; // skipped: send path unavailable in this environment + + CHECK( listener.count.load() == 3 ); + if( listener.addresses.size() == 3 ){ + CHECK( listener.addresses[0] == "/u1" && listener.values[0] == 11 ); + CHECK( listener.addresses[1] == "/u2" && listener.values[1] == 22 ); + CHECK( listener.addresses[2] == "/u3" && listener.values[2] == 33 ); + } + + if( failures == 0 ) + std::printf( "OscUdpTest: OK (3 packets over UDP loopback)\n" ); + return failures == 0 ? 0 : 1; +} From ad66c23c26bb4e56e56b1b2aad854ce49090eaa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 23:03:01 +0000 Subject: [PATCH 11/16] Item 3: win32 sockets runtime-tested under Wine (+ self-contained TCP headers) The win32 socket backends were compile-only. Add a win32-wine CI job that cross-compiles OscUdpTest/OscTcpTest with MinGW-w64 and runs them under Wine, so the win32 UDP and TCP backends get real runtime coverage on every push. Doing this locally caught a latent bug: ip/win32/TcpSocket.h (and the posix one) used IpEndpointName before it was a complete type -- it only compiled when UdpSocket.h happened to be included first (as in Win32SocketSmoke). Including TcpSocket.h on its own (as OscTcpTest does) failed on win32. Fixed by making both TCP backend headers self-contained (explicit ); the Wine job now guards against the regression. Verified locally under Wine 9.0: OscUdpTest and OscTcpTest both pass on win32 (incl. the reassembled 4000-byte TCP message). Linux suite still 8/8. Docs updated (win32 is now runtime-tested, not compile-only). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ ROADMAP.md | 5 +++-- docs/OSC_OVER_TCP.md | 5 +++-- docs/STATUS.md | 11 +++++++---- osctap/ip/posix/TcpSocket.h | 1 + osctap/ip/win32/TcpSocket.h | 1 + 6 files changed, 51 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e312c0..d19dbdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,6 +171,42 @@ jobs: - name: Run suite under qemu run: ctest --test-dir build-aarch64 -E OscConcurrencyTest --output-on-failure + win32-wine: + name: win32 sockets runtime (MinGW + Wine) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The win32 socket backends are otherwise only compile-checked (the MSVC + # legs + the MinGW smoke). Here we cross-compile the real socket loopback + # tests for win32 and run them under Wine, so the win32 UDP + TCP backends + # get actual runtime coverage on every push. + - name: Install MinGW-w64 + Wine + run: | + sudo dpkg --add-architecture i386 + sudo apt-get update + sudo apt-get install -y g++-mingw-w64-x86-64 wine64 wine + + - name: Cross-compile the socket loopback tests for win32 + run: | + for t in OscUdpTest OscTcpTest; do + x86_64-w64-mingw32-g++ -std=c++17 -O1 -static -I . -I osctap \ + tests/$t.cpp -o $t.exe -lws2_32 -lwinmm + done + + - name: Run under Wine + env: + WINEPREFIX: /tmp/wineprefix + WINEDEBUG: "-all" + WINEDLLOVERRIDES: "mscoree,mshtml=" # skip the mono/gecko install prompts + run: | + wineboot -i || true + wineserver -w || true + for t in OscUdpTest OscTcpTest; do + echo "=== $t (win32, under Wine) ===" + wine ./$t.exe + done + tsan: name: ThreadSanitizer (receive loop concurrency) runs-on: ubuntu-latest diff --git a/ROADMAP.md b/ROADMAP.md index f0e1232..b590443 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -188,9 +188,10 @@ See [Sanitizer strategy](#sanitizer-strategy) for scope and rationale. `TcpListeningReceiveSocket` (per-connection deframer → `PacketListener`). POSIX backend is runtime-tested (`OscTcpTest`, a real loopback incl. a segment-spanning message; ASan/UBSan/TSan-clean); the win32 backend mirrors it - and is MinGW/Windows-CI compile-verified. See + and is **runtime-tested under Wine** (the `win32-wine` CI job cross-compiles + `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, `epoll`, and win32 runtime testing. + 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`/ diff --git a/docs/OSC_OVER_TCP.md b/docs/OSC_OVER_TCP.md index 439d94a..3af589e 100644 --- a/docs/OSC_OVER_TCP.md +++ b/docs/OSC_OVER_TCP.md @@ -111,8 +111,9 @@ Parsing a reassembled packet is the same allocation-free RT read path as for UDP - **POSIX is the runtime-verified backend** (`tests/OscTcpTest.cpp` exercises a real loopback client+server, including a message that spans TCP segments, and is clean under ASan/UBSan and TSan). -- **Windows** (`ip/win32/TcpSocket.h`) mirrors it on Winsock and is compile/link- - verified (MinGW + the windows-latest CI legs) but not yet runtime-tested in CI. +- **Windows** (`ip/win32/TcpSocket.h`) mirrors it on Winsock and is **runtime-tested** + via the `win32-wine` CI job (MinGW cross-compile + Wine), in addition to the + compile/link checks on the windows-latest legs. - Deferred to a future version: SLIP framing, TLS, WebSocket transport, and an `epoll`/`poll` loop for very high connection counts (`select()`/`FD_SETSIZE` is fine for a handful of connections). diff --git a/docs/STATUS.md b/docs/STATUS.md index cd03ad4..78ac005 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -151,10 +151,13 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin coverage. Unlike the UDP facade (template-over-`Implementation`), the TCP types are **concrete per-platform classes** (`posix::`/`win32::`) selected by `using` in the facade. **POSIX is runtime-tested** (`OscTcpTest`, POSIX-only like - `OscConcurrencyTest`, also under TSan); **win32 is compile/link-verified only** - (MinGW + windows-latest legs, no Windows runner) — treat win32 TCP changes as - unverified at runtime and lean on the posix backend as the reference. The win32 - break uses a self-connected loopback UDP socket (no `pipe()` on Windows). + `OscConcurrencyTest`, also under TSan). **win32 is now runtime-tested too** via the + `win32-wine` CI job: it cross-compiles `OscUdpTest`/`OscTcpTest` with MinGW and runs + them under Wine (both pass), so the win32 UDP **and** TCP backends have real runtime + coverage, not just compile/link. The win32 break uses a self-connected loopback UDP + 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. - **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 diff --git a/osctap/ip/posix/TcpSocket.h b/osctap/ip/posix/TcpSocket.h index 4ec5511..37ff359 100644 --- a/osctap/ip/posix/TcpSocket.h +++ b/osctap/ip/posix/TcpSocket.h @@ -28,6 +28,7 @@ // Reuse the posix socket includes and the SockaddrFromIpEndpointName / // IpEndpointNameFromSockaddr helpers defined in the UDP backend. +#include // complete type before the helpers below use it #include #include #include diff --git a/osctap/ip/win32/TcpSocket.h b/osctap/ip/win32/TcpSocket.h index 3c1210f..b09a595 100644 --- a/osctap/ip/win32/TcpSocket.h +++ b/osctap/ip/win32/TcpSocket.h @@ -29,6 +29,7 @@ // Reuse the win32 socket includes, NetworkInitializer (WSAStartup), and the // SockaddrFromIpEndpointName / IpEndpointNameFromSockaddr helpers from the UDP // backend. +#include // complete type before the helpers below use it #include #include #include From 80d6ed3c035f1c81ab1f00923629cf9b96ae110a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 23:05:20 +0000 Subject: [PATCH 12/16] Item 4: code-coverage measurement in CI (gcovr) Add a coverage CI job: instrument with gcov (-O0 --coverage), run the suite, and report library-header coverage with gcovr. Uses gcovr rather than lcov because lcov 2.0 mis-merges header-only inline functions across the many test binaries (>100% / 0% artifacts); gcovr handles it cleanly. Baseline ~85% lines / ~94% functions over osctap/. The job fails under 80% lines so coverage regressions are caught, and uploads a Cobertura XML for trend tooling. Known-uncovered noted in STATUS (hostname-resolution path; some socket error branches). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ docs/STATUS.md | 6 ++++++ 2 files changed, 48 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d19dbdb..3cb64e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,6 +207,48 @@ jobs: wine ./$t.exe done + coverage: + name: Code coverage (gcovr) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install gcovr + run: | + sudo apt-get update + sudo apt-get install -y gcovr + + # Instrument with gcov (-O0 --coverage) and run the full suite. Coverage is + # measured over the library headers only (osctap/), since the library is + # header-only -- the test/demo TUs are the carriers. gcovr handles the + # inline-functions-across-many-binaries case cleanly (lcov over-/under-counts + # it). Fails if line coverage drops below 80% (currently ~85%), so coverage + # regressions are caught; a Cobertura XML is uploaded for trend tooling. + - name: Configure with coverage instrumentation + run: > + cmake -S . -B build-cov -DOSCPACK_BUILD_EXAMPLES=ON + -DCMAKE_BUILD_TYPE=Debug + -DCMAKE_CXX_FLAGS="-O0 --coverage" + -DCMAKE_EXE_LINKER_FLAGS="--coverage" + + - name: Build + run: cmake --build build-cov + + - name: Run tests + run: ctest --test-dir build-cov --output-on-failure + + - name: Coverage report (library headers; fail under 80% lines) + run: > + gcovr --root . --filter 'osctap/' --print-summary + --fail-under-line 80 --cobertura coverage.xml + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-cobertura + path: coverage.xml + tsan: name: ThreadSanitizer (receive loop concurrency) runs-on: ubuntu-latest diff --git a/docs/STATUS.md b/docs/STATUS.md index 78ac005..eb9d08a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -167,6 +167,12 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin targeted port 0 → nothing delivered; the old `OscConcurrencyTest` packet was "best-effort, never asserted", which hid it). Fixed in both posix and win32 via `getsockname()` after bind. **Don't drop that read-back.** +- **Code coverage** is measured by the `coverage` CI job with **gcovr** (not lcov — + lcov 2.0 mis-merges header-only inline functions across the many test binaries, + producing >100%/0% artifacts). Baseline is **~85% lines / ~94% functions** over + `osctap/`; the job **fails under 80% lines**, so coverage regressions are caught. + Known-uncovered: `ip/posix/NetworkingUtils.h` `GetHostByName` (tests use numeric + IPs, not hostname resolution) and some error/EINTR branches in the socket loops. - **`examples/` (SimpleSend/SimpleReceive/OscDump) are compile-checked, not run** (they bind sockets / block on input). They use the `oscpack::` alias on purpose (extra shim coverage). `demos/` are the modern, tested equivalents. The old From 6999a85274f4f58e611f78ac845acef0ddc48520 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 00:01:05 +0000 Subject: [PATCH 13/16] Docs: add Getting Started + API reference; archive legacy oscpack files Closes the documentation gaps from the testing/docs briefing: - docs/GETTING_STARTED.md: the missing "OSC 101 over UDP" guide -- build, send, receive, parse arguments (three styles), bundles, and untrusted-input handling. - docs/API.md: a curated reference of the public surface, grouped by header (outbound stream + manipulators, received elements + accessors, framing, dispatch, networking, the config seam). Hand-maintained. - docs/legacy/: moved the original oscpack README/CHANGES/TODO here (renamed oscpack-*.txt) with an explainer, de-cluttering the repo root while preserving the heritage files. - README.md: refreshed -- links the new guides, fixes the now-stale Layout table (adds demos/, android/, docs/; drops the deleted in-tree send/receive tests), updates the status blurb (TCP, validation gate, aarch64, Wine, ~85% coverage), and repoints the legacy link to docs/legacy/. Verified: all doc links resolve, no dangling references to the moved files; build + suite still 8/8. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- README.md | 33 +++-- docs/API.md | 161 ++++++++++++++++++++ docs/GETTING_STARTED.md | 165 +++++++++++++++++++++ docs/STATUS.md | 6 + docs/legacy/README.md | 15 ++ CHANGES => docs/legacy/oscpack-CHANGES.txt | 0 README => docs/legacy/oscpack-README.txt | 0 TODO => docs/legacy/oscpack-TODO.txt | 0 8 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/GETTING_STARTED.md create mode 100644 docs/legacy/README.md rename CHANGES => docs/legacy/oscpack-CHANGES.txt (100%) rename README => docs/legacy/oscpack-README.txt (100%) rename TODO => docs/legacy/oscpack-TODO.txt (100%) diff --git a/README.md b/README.md index f2a9562..c1a789f 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,17 @@ **OscTap** is the actively-maintained, security-hardened continuation of [oscpack](http://www.rossbencina.com/code/oscpack) — Ross Bencina's C++ library for packing and unpacking [Open Sound Control](https://opensoundcontrol.stanford.edu/) -(OSC) packets, with a minimal set of UDP networking classes for Windows and POSIX. +(OSC) packets, with UDP **and TCP** networking classes for Windows and POSIX. It is a drop-in successor: the `oscpack` namespace and include paths are retained as a deprecated compatibility alias, so existing code keeps building while new code uses the `osctap` name. -> **Status:** modernization in progress. The parsing path has been audited and -> hardened (see the security fixes in the history), fuzzed, and is covered by CI across -> Linux/macOS/Windows and GCC/Clang/MSVC at C++17 and C++20. See -> [`ROADMAP.md`](ROADMAP.md) for what's done and what's next. +> **Status:** actively modernized. The parsing path is audited, hardened, and fuzzed; +> a non-throwing validation gate, a freestanding/embedded profile, OSC-over-TCP, and an +> aarch64 (Raspberry Pi 5) target have landed. CI spans Linux/macOS/Windows × +> GCC/Clang/MSVC at C++17 and C++20, plus ASan/UBSan, RTSan, TSan, fuzzing, aarch64 +> (QEMU), Windows-runtime (Wine), and ~85% coverage. See [`ROADMAP.md`](ROADMAP.md). ## What it is @@ -45,19 +46,27 @@ The library itself is header-only; the build targets are the tests and examples. | Path | Contents | |------|----------| -| `osctap/osc/` | OSC packet classes (parsing, printing, outbound packing, listeners) | -| `osctap/ip/` | UDP networking; `posix/` and `win32/` backends | +| `osctap/osc/` | OSC packet classes (parsing, printing, outbound packing, listeners, stream framing) | +| `osctap/ip/` | UDP + TCP networking; `posix/` and `win32/` backends | | `oscpack/` | redirect shim — old `` include paths forwarding to `` (deprecated compatibility) | -| `tests/` | unit tests (incl. malformed-input regression tests), the compat-shim guard, and send/receive examples | -| `examples/` | `OscDump`, `SimpleSend`, `SimpleReceive` | -| `fuzz/` | libFuzzer harness, corpus, and standalone driver — see [`fuzz/README.md`](fuzz/README.md) | +| `tests/` | unit tests (malformed-input regression, validation, framing, UDP/TCP loopback), the compat-shim guard | +| `demos/` | runnable OSC programs (UDP hub + sender, TCP server + sender) | +| `examples/` | the canonical oscpack examples — `OscDump`, `SimpleSend`, `SimpleReceive` | +| `android/` | Android NDK JNI bridge + Kotlin facade | +| `fuzz/` | libFuzzer harnesses (parser + TCP deframer), corpora, standalone driver — see [`fuzz/README.md`](fuzz/README.md) | +| `docs/` | guides (below); `docs/legacy/` keeps the original oscpack README/CHANGES/TODO | ## Documentation +- [`docs/GETTING_STARTED.md`](docs/GETTING_STARTED.md) — **start here**: build, send, receive, parse (OSC over UDP). +- [`docs/API.md`](docs/API.md) — the public API, grouped by header. +- [`docs/OSC_OVER_TCP.md`](docs/OSC_OVER_TCP.md) — reliable/stream transport. +- [`docs/EMBEDDED_PICO2W.md`](docs/EMBEDDED_PICO2W.md) — no-heap / no-exceptions builds (Raspberry Pi Pico 2W). +- [`docs/INTEGRATION_PI5_PICO_ANDROID.md`](docs/INTEGRATION_PI5_PICO_ANDROID.md) — a worked Pi 5 ⇄ Pico 2W ⇄ Android system. - [`ROADMAP.md`](ROADMAP.md) — plan of record, design decisions, sanitizer strategy. - [`docs/HERITAGE.md`](docs/HERITAGE.md) — lineage and credits. -- [`fuzz/README.md`](fuzz/README.md) — how to fuzz the parser. -- [`README`](README) — original oscpack build notes (legacy reference). +- [`fuzz/README.md`](fuzz/README.md) — how to fuzz the parser and deframer. +- [`docs/legacy/`](docs/legacy/) — original oscpack README / CHANGES / TODO (historical). - [`LICENSE`](LICENSE) — MIT-style license. ## Heritage & license diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..10b7074 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,161 @@ +# OscTap API reference + +A curated reference of the public surface, grouped by header. Everything lives in +namespace `osctap` (the `oscpack` alias is retained, deprecated). This is a +hand-written overview — the headers themselves are the authoritative source. + +New here? Start with [Getting Started](GETTING_STARTED.md). + +Conventions: +- **(RT)** = realtime-safe (allocation-/exception-free; annotated `OSCTAP_REALTIME`). +- **(throws)** = validates and may throw on bad input / state. + +--- + +## Building OSC — `osc/OscOutboundPacketStream.h` + +### `OutboundPacketStream` +Serializes OSC into a caller-owned buffer (no heap). + +| Member | Notes | +|--------|-------| +| `OutboundPacketStream(char* buf, size_t capacity)` | wrap a buffer | +| `const char* Data() const` · `size_t Size() const` | the framed bytes so far | +| `size_t Capacity() const` | buffer size | +| `void Clear()` | reset to empty | +| `bool IsReady() const` | all messages/bundles closed | +| `bool IsMessageInProgress() const` · `bool IsBundleInProgress() const` | | +| `operator<<( T )` | append a value or manipulator (below) | + +Streamed value types: `bool` (→`T`/`F`), `int32_t`, `int64_t`, `float`, `double`, +`char`, `const char*` / `std::string` / `string_view` (→ string), `Blob`, +`Symbol`, `TimeTag`, `MidiMessage`, `RgbaColor`. Overflowing the buffer throws +`OutOfBufferMemoryException`. + +### Manipulators & types — `osc/OscTypes.h` +`BeginMessage(addr)` · `EndMessage()` · `BeginBundle(timeTag=1)` · +`BeginBundleImmediate()` · `EndBundle()` · `BeginArray()` · `EndArray()` · +`OscNil()` · `Infinitum()`. Value wrappers: `Blob(ptr, size)`, `Symbol(s)`, +`TimeTag(v)`, `MidiMessage(v)`, `RgbaColor(v)`. + +### Exceptions +`OutOfBufferMemoryException`, `MessageInProgressException`, +`MessageNotInProgressException`, `BundleNotInProgressException` — all derive from +`osctap::Exception`. + +--- + +## Parsing OSC — `osc/OscReceivedElements.h` + +### `ReceivedPacket` +| Member | Notes | +|--------|-------| +| `ReceivedPacket(const char* data, size_t size)` | (throws) validates size | +| `bool IsBundle() const` · `bool IsMessage() const` | | +| `const char* Contents() const` · `osc_bundle_element_size_t Size() const` | | +| `static const char* ValidateSizeNoThrow(size)` | non-throwing size check (`nullptr` = ok) | + +### `ReceivedMessage` +| Member | Notes | +|--------|-------| +| `ReceivedMessage(const ReceivedPacket&)` / `(const ReceivedBundleElement&)` | (throws) | +| `ReceivedMessage()` + `const char* TryInit(data, size)` | **non-throwing** parse (`nullptr` = ok) | +| `static const char* Validate(data, size)` | non-throwing structural check | +| `const char* AddressPattern() const` | (RT) | +| `uint32_t ArgumentCount() const` · `const char* TypeTags() const` | (RT) | +| `const_iterator ArgumentsBegin() / ArgumentsEnd() const` | (RT) | +| `ReceivedMessageArgumentStream ArgumentStream() const` | for `>>` reads | + +### `ReceivedMessageArgument` +Type tests (all `bool`, RT): `IsBool IsInt32 IsInt64 IsFloat IsDouble IsChar +IsString IsSymbol IsBlob IsRgbaColor IsMidiMessage IsTimeTag IsNil IsInfinitum +IsArrayBegin IsArrayEnd`. + +Accessors — checked (throw `WrongArgumentTypeException`) and `*Unchecked` (RT): +`AsBool AsInt32 AsInt64 AsFloat AsDouble AsChar AsString AsSymbol AsRgbaColor +AsMidiMessage AsTimeTag` (+ `…Unchecked`). Blobs: +`void AsBlob(const void*& data, osc_bundle_element_size_t& size)` (+ `Unchecked`). + +`char TypeTag() const` (RT) returns the raw tag. Iterate with +`ReceivedMessageArgumentIterator`, or read positionally with +`ReceivedMessageArgumentStream`: `args >> b >> i >> f >> endTag;` (where `endTag` +is a `MessageTerminator` lvalue; an over-read throws `ExcessArgumentException`). + +### `ReceivedBundle` +`TimeTag()`, `ElementCount()`, `ElementsBegin()/ElementsEnd()`, plus the same +non-throwing `TryInit` / `static Validate` pair as `ReceivedMessage`. Iterate with +`ReceivedBundleElementIterator`; each element is a `ReceivedBundleElement` you +construct a `ReceivedMessage`/`ReceivedBundle` from. + +### Non-throwing whole-packet gate +```cpp +const char* TryValidatePacket(const char* data, osc_bundle_element_size_t size, + unsigned int maxBundleNestingDepth = 64); +``` +Returns `nullptr` iff the packet (message, or bundle whose elements are all +well-formed, recursively) is safe to construct **and read in full** without any +throw/abort. The gate to use for untrusted input on a no-exceptions build. + +### Exceptions +`MalformedPacketException`, `MalformedMessageException`, `MalformedBundleException`, +`WrongArgumentTypeException`, `MissingArgumentException`, `ExcessArgumentException`. + +--- + +## Dispatch — `osc/OscPacketListener.h` +`OscPacketListener : public PacketListener` — unpacks bundles (depth-bounded) and +calls your `ProcessMessage`. +- `virtual void ProcessMessage(const ReceivedMessage&, const IpEndpointName&)` — override this. +- `virtual void ProcessBundle(...)` — override to handle time tags. +- `void SetMaxBundleNestingDepth(unsigned)` (default 64). + +## Pretty-printing — `osc/OscPrintReceivedElements.h` +`std::ostream& operator<<(std::ostream&, const ReceivedPacket& | ReceivedMessage& +| ReceivedBundle& | ReceivedMessageArgument&)` — human-readable dump (see `OscDump`). + +--- + +## Stream framing (OSC over TCP) — `osc/OscStreamFraming.h` +- `void WriteOscStreamFrameHeader(char header[4], uint32_t packetSize)` — length prefix. +- `size_t FrameOscPacket(const char* pkt, uint32_t size, char* out, size_t cap)` — frame into one buffer (`0` if it doesn't fit). +- `class OscStreamDeframer` — `explicit OscStreamDeframer(uint32_t maxFrameSize = 65536)`; `bool Consume(const char* data, size_t size, Sink&& sink)` (calls `sink(packet, size)` per complete packet; returns `false` on an over-cap frame); `void Reset()`; `uint32_t MaxFrameSize() const`. See [OSC over TCP](OSC_OVER_TCP.md). + +--- + +## Networking — `ip/` + +### `IpEndpointName` (`ip/IpEndpointName.h`) +Ctors: `(const char* host, int port)`, `(uint32_t ip, int port)`, +`(a,b,c,d, port)`, `(int port)`. Constants `ANY_ADDRESS`, `ANY_PORT`. Helpers +`AddressAsString(char*)`, `AddressAndPortAsString(char*)`, `IsMulticastAddress()`. + +### `PacketListener` (`ip/PacketListener.h`) +Abstract base: `virtual void ProcessPacket(const char* data, int size, const IpEndpointName& from)`. + +### UDP (`ip/UdpSocket.h`) +- `UdpTransmitSocket(const IpEndpointName& remote)` — `Send(data, size)`, `SendTo(to, data, size)`. +- `UdpReceiveSocket`, and `UdpListeningReceiveSocket(local, PacketListener*)` — + `Run()` (blocks), `Break()`, `AsynchronousBreak()`, `int LocalPort()`. +- `SocketReceiveMultiplexer` — multiple sockets/timers in one `Run()` loop + (`AttachSocketListener`, `AttachPeriodicTimerListener`, …). + +### TCP (`ip/TcpSocket.h`) +- `TcpTransmitSocket(const IpEndpointName& remote)` — `Send(data, size)` (length-prefixed; `TCP_NODELAY` on). +- `TcpListeningReceiveSocket(local, PacketListener*, uint32_t maxFrameSize = 65536)` — + `Run()`, `Break()`, `AsynchronousBreak()`, `IpEndpointName LocalEndpointFor(requested)`. + Accepts multiple clients, each deframed independently. + +--- + +## Build-configuration seam — `osc/OscConfig.h` +- `OSCTAP_HAS_EXCEPTIONS` — auto-detected (0 under `-fno-exceptions`). +- `OSCTAP_FREESTANDING` — define to drop hosted-only facilities (``, + `std::vector` `OwnedMessage`, `std::string operator<<`). +- `OSCTAP_THROW(EXC)` — `throw` when exceptions are on; otherwise a non-returning + fatal handler. +- `OSCTAP_FATAL_HANDLER(whatCStr)` — pre-define to route the no-exceptions failure + path (default `std::abort()`). See [Embedded (Pico 2W)](EMBEDDED_PICO2W.md). + +## Base exception — `osc/OscException.h` +`class Exception : public std::exception` — base of every OscTap exception; +`const char* what() const noexcept`. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..09d543a --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,165 @@ +# Getting started with OscTap + +A 10-minute tour: build OscTap, send an OSC message over UDP, receive and parse +one. This covers the common case (OSC over UDP on a desktop/SBC). For other +transports and targets see [OSC over TCP](OSC_OVER_TCP.md), the +[Pico 2W embedded guide](EMBEDDED_PICO2W.md), and the +[Pi 5 ⇄ Pico ⇄ Android tutorial](INTEGRATION_PI5_PICO_ANDROID.md). For the full +public surface see the [API reference](API.md). + +## Install / build + +OscTap's **core is header-only** — add the repo root to your include path and +`#include` what you need; there's nothing to compile or link for the OSC packet +classes. The UDP/TCP socket classes are also header-only but link a platform +socket library (`ws2_32`/`winmm` on Windows; nothing extra on POSIX). + +With CMake, link the interface target: + +```cmake +add_subdirectory(OscTap) # or vendor the headers +target_link_libraries(myapp PRIVATE oscpack) # header-only INTERFACE target +``` + +Or just point at the headers: + +```sh +g++ -std=c++17 -I path/to/OscTap -I path/to/OscTap/osctap myapp.cpp -o myapp +``` + +Public headers live under ``; the old `` paths and the +`oscpack::` namespace still work as a deprecated compatibility alias, so code +written for oscpack keeps compiling. + +## Send a message + +Serialize into a buffer **you** own (no heap allocation), then put the bytes on +the wire. The buffer can be on the stack. + +```cpp +#include "osc/OscOutboundPacketStream.h" +#include "ip/UdpSocket.h" + +int main() +{ + osctap::UdpTransmitSocket tx( osctap::IpEndpointName( "127.0.0.1", 7000 ) ); + + char buffer[1024]; + osctap::OutboundPacketStream p( buffer, sizeof(buffer) ); + p << osctap::BeginMessage( "/synth/freq" ) + << 440.0f << true << "sine" + << osctap::EndMessage(); + + tx.Send( p.Data(), p.Size() ); +} +``` + +`OutboundPacketStream` writes OSC types from their C++ counterparts: `int32_t`, +`float`, `double`, `bool` (→ `T`/`F`), `const char*`/`std::string` (→ string), +plus `Blob`, `TimeTag`, `MidiMessage`, `RgbaColor`, `OscNil()`, `Infinitum()`. If +the message would overflow your buffer it throws `OutOfBufferMemoryException`, so +size the buffer for your largest message. + +## Receive and parse a message + +Subclass `OscPacketListener` (it unpacks bundles for you and bounds nesting +depth), bind a socket, and run the receive loop. + +```cpp +#include "osc/OscPacketListener.h" +#include "ip/UdpSocket.h" +#include +#include + +class MyListener : public osctap::OscPacketListener { +protected: + void ProcessMessage( const osctap::ReceivedMessage& m, + const osctap::IpEndpointName& from ) override + { + try { + if( std::strcmp( m.AddressPattern(), "/synth/freq" ) == 0 ) { + auto arg = m.ArgumentsBegin(); + float freq = (arg++)->AsFloat(); + bool on = (arg++)->AsBool(); + const char* wave = (arg++)->AsString(); + std::cout << "freq=" << freq << " on=" << on << " wave=" << wave << "\n"; + } + } catch( const osctap::Exception& e ) { + // wrong/missing argument types are reported by exception + std::cout << "bad message: " << e.what() << "\n"; + } + } +}; + +int main() +{ + MyListener listener; + osctap::UdpListeningReceiveSocket s( + osctap::IpEndpointName( osctap::IpEndpointName::ANY_ADDRESS, 7000 ), &listener ); + s.Run(); // blocks; call s.AsynchronousBreak() from another thread/handler to stop +} +``` + +### Reading arguments + +Three styles, pick what fits: + +- **Checked accessors** — `arg->AsInt32()`, `AsFloat()`, `AsString()`, … throw + `WrongArgumentTypeException` / `MissingArgumentException` on a mismatch. Safe + default. +- **Type-test then unchecked** — `if (arg->IsFloat()) arg->AsFloatUnchecked()`. + The `*Unchecked` accessors don't validate; they're the realtime-safe read path + once you've checked the tag (or validated the whole packet — see below). +- **Argument stream** — `m.ArgumentStream() >> a >> b >> endTag;` for fixed + layouts. + +### Bundles + +`OscPacketListener` traverses bundles automatically and calls `ProcessMessage` +for each contained message. To build one: + +```cpp +p << osctap::BeginBundleImmediate() + << osctap::BeginMessage( "/a" ) << 1 << osctap::EndMessage() + << osctap::BeginMessage( "/b" ) << 2 << osctap::EndMessage() + << osctap::EndBundle(); +``` + +## Handling untrusted input + +OSC arriving over a network is untrusted. By default the parser **throws** on a +malformed packet (`Malformed{Packet,Message,Bundle}Exception`), which you catch to +drop it. If you build with **exceptions disabled** (the freestanding/embedded +profile), validation instead aborts — so first gate the bytes: + +```cpp +if( osctap::TryValidatePacket( data, size ) == nullptr ) { + osctap::ReceivedPacket p( data, size ); // guaranteed safe to read + // ... +} // else: drop it +``` + +`TryValidatePacket` is non-throwing, non-allocating, and recurses through bundles +with a nesting bound. See the [Pico 2W guide](EMBEDDED_PICO2W.md) for the embedded +story. + +## Try the demos + +Runnable programs under [`../demos/`](../demos) (`-DOSCTAP_BUILD_DEMOS=ON`): + +```sh +cmake -S . -B build -DOSCTAP_BUILD_DEMOS=ON +cmake --build build --target osc_send pi5_hub +./build/pi5_hub 9000 & # a UDP hub/monitor + router +./build/osc_send 127.0.0.1 9000 /hub/led i:1 # a typed CLI sender +``` + +The canonical oscpack examples (`SimpleSend`, `SimpleReceive`, `OscDump`) are in +[`../examples/`](../examples). + +## Where next + +- [API reference](API.md) — every public type, grouped by header. +- [OSC over TCP](OSC_OVER_TCP.md) — reliable/stream transport. +- [Embedded (Pico 2W)](EMBEDDED_PICO2W.md) — no-heap / no-exceptions builds. +- [`../ROADMAP.md`](../ROADMAP.md) — design decisions and project direction. diff --git a/docs/STATUS.md b/docs/STATUS.md index eb9d08a..7888537 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -167,6 +167,12 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin targeted port 0 → nothing delivered; the old `OscConcurrencyTest` packet was "best-effort, never asserted", which hid it). Fixed in both posix and win32 via `getsockname()` after bind. **Don't drop that read-back.** +- **Docs map**: `docs/GETTING_STARTED.md` (OSC-101 over UDP) and `docs/API.md` + (public-surface reference) are the entry points, linked from `README.md`; feature + guides are `OSC_OVER_TCP.md`, `EMBEDDED_PICO2W.md`, `INTEGRATION_PI5_PICO_ANDROID.md`. + The original oscpack `README`/`CHANGES`/`TODO` were moved to `docs/legacy/` + (historical; don't treat as current). `API.md` is hand-maintained — update it when + the public surface changes. - **Code coverage** is measured by the `coverage` CI job with **gcovr** (not lcov — lcov 2.0 mis-merges header-only inline functions across the many test binaries, producing >100%/0% artifacts). Baseline is **~85% lines / ~94% functions** over diff --git a/docs/legacy/README.md b/docs/legacy/README.md new file mode 100644 index 0000000..b26f5ca --- /dev/null +++ b/docs/legacy/README.md @@ -0,0 +1,15 @@ +# Legacy oscpack files + +These are the original **oscpack** project files, preserved verbatim for heritage +and reference. They predate the OscTap rebrand and the current build/CI, so they +are **historical, not current** — for up-to-date information use the top-level +[`README.md`](../../README.md), [`ROADMAP.md`](../../ROADMAP.md), and the guides in +[`docs/`](../). + +| File | What it was | +|------|-------------| +| [`oscpack-README.txt`](oscpack-README.txt) | oscpack's original README / build notes (Makefile-era) | +| [`oscpack-CHANGES.txt`](oscpack-CHANGES.txt) | oscpack's changelog, last entry April 2013 | +| [`oscpack-TODO.txt`](oscpack-TODO.txt) | oscpack's TODO list | + +For OscTap's lineage and credits see [`docs/HERITAGE.md`](../HERITAGE.md). diff --git a/CHANGES b/docs/legacy/oscpack-CHANGES.txt similarity index 100% rename from CHANGES rename to docs/legacy/oscpack-CHANGES.txt diff --git a/README b/docs/legacy/oscpack-README.txt similarity index 100% rename from README rename to docs/legacy/oscpack-README.txt diff --git a/TODO b/docs/legacy/oscpack-TODO.txt similarity index 100% rename from TODO rename to docs/legacy/oscpack-TODO.txt From 7851c263a4adf6f9cf5d6ba4900103d848bac8ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 00:09:09 +0000 Subject: [PATCH 14/16] Review fixes: win32 break-socket + FD_SETSIZE guards, hygiene, CI cleanup From an independent review pass of the branch: - win32/TcpSocket.h: CreateBreakSocket() now checks bind/getsockname/connect and throws on failure (an unchecked failure could leave AsynchronousBreak() unable to wake Run(), hanging the thread). [HIGH] - win32/TcpSocket.h: refuse new connections at the Winsock FD_SETSIZE (~64) limit instead of silently dropping them from the select() set (they'd stall forever). v1 targets a handful of connections; high counts are a future poll/epoll item. [HIGH] - OscReceivedElements.h: complete the truncated "bundle contents " error string. - posix/TcpSocket.h: include / explicitly (don't rely on transitive includes -- same class of issue as the win32 self-contained-header fix). - OscStreamFraming.h: drop a dead ternary; document that a zero-length frame is valid framing forwarded as an empty packet (the OSC layer rejects it). - ci.yml: drop the unnecessary i386 multiarch from the win32-wine job (the cross-build is 64-bit only). Re-verified: Linux suite 8/8; win32 OscUdpTest + OscTcpTest still pass under Wine; win32 smoke compiles clean (MinGW). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- .github/workflows/ci.yml | 3 ++- osctap/ip/posix/TcpSocket.h | 2 ++ osctap/ip/win32/TcpSocket.h | 24 +++++++++++++++++++++--- osctap/osc/OscReceivedElements.h | 2 +- osctap/osc/OscStreamFraming.h | 7 ++++++- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cb64e9..93509be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,8 +182,9 @@ jobs: # tests for win32 and run them under Wine, so the win32 UDP + TCP backends # get actual runtime coverage on every push. - name: Install MinGW-w64 + Wine + # The tests are cross-compiled to 64-bit .exe, so only 64-bit Wine is + # needed (no i386 multiarch, which is an unnecessary failure surface). run: | - sudo dpkg --add-architecture i386 sudo apt-get update sudo apt-get install -y g++-mingw-w64-x86-64 wine64 wine diff --git a/osctap/ip/posix/TcpSocket.h b/osctap/ip/posix/TcpSocket.h index 37ff359..cf6ecdf 100644 --- a/osctap/ip/posix/TcpSocket.h +++ b/osctap/ip/posix/TcpSocket.h @@ -34,7 +34,9 @@ #include #include // TCP_NODELAY +#include // errno (don't rely on transitive includes) #include +#include namespace osctap { diff --git a/osctap/ip/win32/TcpSocket.h b/osctap/ip/win32/TcpSocket.h index b09a595..1393333 100644 --- a/osctap/ip/win32/TcpSocket.h +++ b/osctap/ip/win32/TcpSocket.h @@ -221,11 +221,18 @@ class TcpListeningReceiveSocket addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl( INADDR_LOOPBACK ); addr.sin_port = 0; - bind( breakSocket_, (struct sockaddr*)&addr, sizeof(addr) ); + // bind -> read back the assigned port -> connect to self. If any step + // fails, AsynchronousBreak() could never wake Run() (it would hang in + // select()), so treat it as fatal. socklen_t len = sizeof(addr); - getsockname( breakSocket_, (struct sockaddr*)&addr, &len ); - connect( breakSocket_, (struct sockaddr*)&addr, sizeof(addr) ); + if( bind( breakSocket_, (struct sockaddr*)&addr, sizeof(addr) ) == SOCKET_ERROR + || getsockname( breakSocket_, (struct sockaddr*)&addr, &len ) == SOCKET_ERROR + || connect( breakSocket_, (struct sockaddr*)&addr, sizeof(addr) ) == SOCKET_ERROR ){ + closesocket( breakSocket_ ); + breakSocket_ = INVALID_SOCKET; + throw std::runtime_error( "setup of asynchronous break socket failed\n" ); + } } void AcceptConnection() @@ -235,6 +242,17 @@ class TcpListeningReceiveSocket SOCKET conn = ::accept( listenSocket_, (struct sockaddr*)&peerAddr, &len ); if( conn == INVALID_SOCKET ) return; + + // Winsock select() works over an fd_set array bounded by FD_SETSIZE + // (default 64). Beyond it, FD_SET silently drops sockets and those + // connections would stall forever -- so refuse new connections at the + // limit instead. (v1 targets a handful of connections; high connection + // counts are a future poll/epoll concern -- see issue #14.) + if( connections_.size() + 2 >= FD_SETSIZE ){ + closesocket( conn ); + return; + } + int one = 1; setsockopt( conn, IPPROTO_TCP, TCP_NODELAY, (const char*)&one, sizeof(one) ); connections_.emplace( std::piecewise_construct, diff --git a/osctap/osc/OscReceivedElements.h b/osctap/osc/OscReceivedElements.h index f1085ee..5ac23d8 100644 --- a/osctap/osc/OscReceivedElements.h +++ b/osctap/osc/OscReceivedElements.h @@ -1067,7 +1067,7 @@ class ReceivedBundle{ } if( p != end_ ) - return "bundle contents "; + return "bundle contents did not match bundle size"; return nullptr; } diff --git a/osctap/osc/OscStreamFraming.h b/osctap/osc/OscStreamFraming.h index 9e75fce..39dfcb5 100644 --- a/osctap/osc/OscStreamFraming.h +++ b/osctap/osc/OscStreamFraming.h @@ -130,6 +130,11 @@ class OscStreamDeframer{ if( frameSize_ > maxFrameSize_ ) return false; // oversized / hostile frame + // A zero-length frame is structurally valid framing; it is + // forwarded as an empty packet, which the OSC layer + // (ReceivedPacket) then rejects -- framing doesn't judge OSC + // validity, it only delimits packets. + haveHeader_ = true; } @@ -146,7 +151,7 @@ class OscStreamDeframer{ buffer_.insert( buffer_.end(), p, p + take ); p += take; if( buffer_.size() == (std::size_t)frameSize_ ){ - sink( buffer_.empty() ? p : buffer_.data(), frameSize_ ); + sink( buffer_.data(), frameSize_ ); buffer_.clear(); haveHeader_ = false; } From 5c1c0407c9726d6fa0a8fb1b2a1fab8bfd1dbd78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 00:13:23 +0000 Subject: [PATCH 15/16] Fix CI: MSVC C4505 on win32 timer helper; Wine prefix; gate examples /WX- The first real CI run on this branch (exercising the new jobs) flagged two issues: - windows-latest MSVC build failed: wiring the examples in made the Windows legs compile win32/UdpSocket.h with /WX, surfacing C4505 -- CompareScheduledTimerCalls is `static` (internal linkage), so a transmit-only TU (SimpleSend, which never instantiates the multiplexer's timer sort) defines it unreferenced. Made it `inline` to match the posix backend (inline functions may be unused without warning). Verified via MinGW -Wunused-function -Werror (the GCC analogue of C4505), which now passes for SimpleSend. Also gate the three examples with /WX- on MSVC (like Win32SocketSmoke), since they pull in the not-yet-/W4-audited win32 socket backend; GCC/Clang keep -Werror. - win32-wine job failed: Wine refuses WINEPREFIX under /tmp when /tmp isn't owned by the runner user. Point it at ${{ runner.temp }}/wineprefix (runner-owned). Re-verified locally: Linux suite 8/8; all three examples compile clean for win32 under MinGW -Wall -Wextra -Werror. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- .github/workflows/ci.yml | 4 +++- CMakeLists.txt | 20 +++++++++++--------- osctap/ip/win32/UdpSocket.h | 5 ++++- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93509be..6e1d95a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,7 +197,9 @@ jobs: - name: Run under Wine env: - WINEPREFIX: /tmp/wineprefix + # A runner-owned prefix dir: Wine refuses to use /tmp when it isn't + # owned by the runner user ("'/tmp' is not owned by you"). + WINEPREFIX: ${{ runner.temp }}/wineprefix WINEDEBUG: "-all" WINEDLLOVERRIDES: "mscoree,mshtml=" # skip the mono/gecko install prompts run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ccf999..e0899b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -121,15 +121,17 @@ if(OSCPACK_BUILD_EXAMPLES) # Canonical oscpack examples, compile-checked so they can't bit-rot (they use # the deprecated oscpack:: alias on purpose). Not run -- they bind sockets / - # block on input; see demos/ for the modern, tested equivalents. - add_executable(SimpleSend examples/SimpleSend.cpp) - target_link_libraries(SimpleSend oscpack) - - add_executable(SimpleReceive examples/SimpleReceive.cpp) - target_link_libraries(SimpleReceive oscpack) - - add_executable(OscDump examples/OscDump.cpp) - target_link_libraries(OscDump oscpack) + # block on input; see demos/ for the modern, tested equivalents. On MSVC they + # turn /WX off (like Win32SocketSmoke): they pull in the win32 socket backend, + # whose /W4 surface is not yet audited (a tracked follow-up). On GCC/Clang they + # keep -Werror (they build clean there). + foreach(example SimpleSend SimpleReceive OscDump) + add_executable(${example} examples/${example}.cpp) + target_link_libraries(${example} oscpack) + if(MSVC AND OSCTAP_WARNINGS_AS_ERRORS) + target_compile_options(${example} PRIVATE /WX-) + endif() + endforeach() endif() # Freestanding / embedded profile (Phase 2 "Reach"). Builds a single smoke test diff --git a/osctap/ip/win32/UdpSocket.h b/osctap/ip/win32/UdpSocket.h index fbf3e41..12457a2 100644 --- a/osctap/ip/win32/UdpSocket.h +++ b/osctap/ip/win32/UdpSocket.h @@ -275,7 +275,10 @@ struct AttachedTimerListener{ }; -static bool CompareScheduledTimerCalls( +// inline (not static) to match the posix backend: a static function is internal +// to each TU and trips MSVC C4505 in translation units that include this header +// but never instantiate the multiplexer's timer sort (e.g. a transmit-only TU). +inline bool CompareScheduledTimerCalls( const std::pair< double, AttachedTimerListener > & lhs, const std::pair< double, AttachedTimerListener > & rhs ) { return lhs.first < rhs.first; From c7ae9a10eac8e1526b043bf7f54fc16fc9dfac0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 00:21:14 +0000 Subject: [PATCH 16/16] Fix CI: make win32 backend /W4-clean; drop ineffective /WX- The previous CI run got past C4505 but surfaced two more MSVC warnings-as-errors, and revealed that the per-target /WX- "suppression" never worked: CMake appends a linked INTERFACE target's options AFTER the target's own, so the INTERFACE /WX always wins (verified with a GCC -Werror/-Wno-error repro). So the win32 backend had to actually be cleaned (it's instantiated by Win32SocketSmoke regardless). - win32/UdpSocket.h: fix the C4456 shadow -- the inner `currentTimeMs` in the multiplexer Run() loop redeclared the outer; reuse it (assignment), matching the posix backend. - tests/OscValidateTest.cpp: fix C4310 -- `(char)0xFF` truncates a constant; use the `'\xFF'` char literal (the same fix STATUS notes was applied before; I'd reintroduced it). - CMakeLists: drop the ineffective /WX- from the examples and Win32SocketSmoke; the win32 surface now builds clean under the INTERFACE /W4 /WX. Verified: all win32 targets (smoke + 3 examples) compile clean under MinGW -Wall -Wextra -Wshadow -Werror (the MSVC /W4 proxy); Linux suite 8/8; win32 UDP+TCP still pass under Wine. STATUS updated (win32 is /W4-clean; the /WX- ordering trap documented). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MMR6tmM7H3obaL7SX14bkJ --- CMakeLists.txt | 20 +++++--------------- docs/STATUS.md | 13 ++++++++----- osctap/ip/win32/UdpSocket.h | 2 +- tests/OscValidateTest.cpp | 2 +- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e0899b8..dbef9c4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,17 +61,13 @@ if(OSCPACK_BUILD_EXAMPLES) add_test(NAME OscStreamFramingTest COMMAND OscStreamFramingTest) # Compile/link smoke for the win32 socket backend, which otherwise has no - # compiled coverage (the POSIX demos/tests are gated off Windows). Built by the - # existing windows-latest CI legs. Warnings-as-errors is deliberately OFF for - # this TU (via /WX-, overriding the INTERFACE /WX): it brings the win32 sockets - # into the build for the first time, and gating CI on their previously - # unexercised /W4 surface is a separate cleanup. + # compiled coverage (the POSIX demos/tests are gated off Windows). Built and run + # by the existing windows-latest CI legs. The win32 backend is /W4-clean, so it + # builds under the INTERFACE /W4 /WX like everything else (a per-target /WX- + # would be ineffective anyway -- CMake appends INTERFACE options last). if(WIN32) add_executable(Win32SocketSmoke tests/Win32SocketSmoke.cpp) target_link_libraries(Win32SocketSmoke oscpack) - if(OSCTAP_WARNINGS_AS_ERRORS) - target_compile_options(Win32SocketSmoke PRIVATE /WX-) - endif() add_test(NAME Win32SocketSmoke COMMAND Win32SocketSmoke) endif() @@ -121,16 +117,10 @@ if(OSCPACK_BUILD_EXAMPLES) # Canonical oscpack examples, compile-checked so they can't bit-rot (they use # the deprecated oscpack:: alias on purpose). Not run -- they bind sockets / - # block on input; see demos/ for the modern, tested equivalents. On MSVC they - # turn /WX off (like Win32SocketSmoke): they pull in the win32 socket backend, - # whose /W4 surface is not yet audited (a tracked follow-up). On GCC/Clang they - # keep -Werror (they build clean there). + # block on input; see demos/ for the modern, tested equivalents. foreach(example SimpleSend SimpleReceive OscDump) add_executable(${example} examples/${example}.cpp) target_link_libraries(${example} oscpack) - if(MSVC AND OSCTAP_WARNINGS_AS_ERRORS) - target_compile_options(${example} PRIVATE /WX-) - endif() endforeach() endif() diff --git a/docs/STATUS.md b/docs/STATUS.md index 7888537..950176f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -129,11 +129,14 @@ cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandin `tests/Win32SocketSmoke.cpp` (a WIN32-gated target the existing windows-latest legs build — it instantiates + links the win32 `UdpSocket`/multiplexer/`getaddrinfo` paths but guards the socket calls off the runtime path, so CI needs no live - network). The win32 smoke builds with `/WX-` (warnings-as-errors off for that TU): - cleaning the win32 backend's `/W4` surface (and the `timeGetTime` 40-day `FIXME`) - to pass `/WX` is the remaining follow-up. It compiles clean under MinGW - `-Wall -Wextra`. (The Phase 1 `strcpy`/`gethostbyname` deferral is fully resolved — - no occurrences remain in `ip/`.) + network). The win32 backend is now **`/W4`-clean and built under `/WX`** like + everything else — the deferred cleanup was forced once the examples/smoke pulled it + into the compiled surface (fixed C4505 by making `CompareScheduledTimerCalls` `inline`, + and a C4456 `currentTimeMs` shadow). NB: a per-target `/WX-` is **ineffective** here — + CMake appends a linked INTERFACE's options *after* the target's own, so the INTERFACE + `/WX` always wins; don't try to suppress warnings that way, fix them. Approximate MSVC + `/W4` locally with MinGW `-Wall -Wextra -Wshadow -Werror`. (Open: the `timeGetTime` + 40-day `FIXME`; the Phase 1 `strcpy`/`gethostbyname` deferral is fully resolved.) - **`OSCTAP_BUILD_DEMOS` and the `aarch64-qemu` job**: demos are POSIX-only (`ip/posix` + a SIGINT handler) and gated `NOT WIN32`. The aarch64 job runs the suite under `qemu-user` but **excludes `OscConcurrencyTest`** (emulated threads + diff --git a/osctap/ip/win32/UdpSocket.h b/osctap/ip/win32/UdpSocket.h index 12457a2..13174a0 100644 --- a/osctap/ip/win32/UdpSocket.h +++ b/osctap/ip/win32/UdpSocket.h @@ -387,7 +387,7 @@ class SocketReceiveMultiplexerImplementation { while( !break_ ){ - double currentTimeMs = GetCurrentTimeMs(); + currentTimeMs = GetCurrentTimeMs(); // reuse outer (avoid MSVC C4456 shadow) DWORD waitTime = INFINITE; if( !timerQueue_.empty() ){ diff --git a/tests/OscValidateTest.cpp b/tests/OscValidateTest.cpp index 6c61928..d52806c 100644 --- a/tests/OscValidateTest.cpp +++ b/tests/OscValidateTest.cpp @@ -111,7 +111,7 @@ int main() Differential( "unknown type tag", b ); } // claim a huge blob/forge: flip a size-ish word in the bundle's element size - { auto b = bun; if( b.size() > 19 ){ b[16] = (char)0x7F; b[17] = (char)0xFF; } // element size huge + { auto b = bun; if( b.size() > 19 ){ b[16] = '\x7F'; b[17] = '\xFF'; } // element size huge Differential( "bundle element size overflow", b ); } // non-multiple-of-4 total size