Skip to content

TCP Support #14

Description

@tap

Today the receive path is datagram-shaped. In posix/UdpSocket.h, the multiplexer's select() loop does:
Code
UDP hands you message boundaries for free — one datagram is exactly one OSC packet. TCP is a byte stream with no boundaries: a single recv() can return half a packet, one and a half packets, or ten packets. So a TCP backend isn't "swap SOCK_DGRAM → SOCK_STREAM" — you have to add a framing/deframing layer and per-connection reassembly buffers. There are two standard conventions (you'd pick one or support both, negotiated out-of-band):
Length-prefix — a 4-byte big-endian int32 byte-count before each packet. The de-facto standard (CNMAT, liblo's osc.tcp, SuperCollider, Max, JUCE). OscTap already has every primitive for this: it's the exact same shape as the bundle element-size slot, and FromInt32/ToInt32 (just made UB-free in #8) read/write it.
SLIP (RFC 1055 double-END byte-stuffing) — what the OSC 1.1 spec nominates as the preferred stream framing; used by some hardware/firmware.
This framing codec is transport-agnostic and header-only — it belongs in osc/ (or a new osc/OscStreamFraming.h), independent of sockets, and it's the one genuinely new algorithm.
What changes vs. what doesn't
Doesn't change (the good news — the layering already isolates this):
The whole osc/ parsing layer. ReceivedPacket/ReceivedMessage just take (bytes, size) — they never knew or cared where the bytes came from. Zero changes.
PacketListener::ProcessPacket(data, size, from) — it already means "here's one complete OSC packet + its peer," which is exactly what a deframer emits. The listener contract is reusable as-is.
OutboundPacketStream — unchanged; the transmit side just prepends the frame header.
New work:
The framing codec (above).
Platform TCP socket impls in ip/posix/ and ip/win32/ (mirror UdpSocketImplementation).
A connection-aware receive loop. This is the second real cost: TCP is connection-oriented, so a server listen()/accept()s N clients → N sockets, each needing its own persistent reassembly buffer. The select() loop grows to handle: the listening socket (accept), each connection (read → reassemble → deframe → dispatch per complete frame), and disconnects (cleanup). Materially more state than UDP's stateless recvfrom.
Public types mirroring the UDP set, e.g. TcpTransmitSocket (client) and TcpListeningReceiveSocket (server). The seam is the existing Impl_T template (udp_socket_t / socket_multiplexer_t in the Implementation struct) — you add a tcp_socket_t + a stream multiplexer alongside it.
Things that bite (worth deciding up front)
Security / DoS — the length prefix is attacker-controlled. A peer sending 0x7FFFFFFF must not make you allocate 2 GB. This is exactly the bounded-size discipline the audit already established (the blob-OOB fix #1/#4) — cap the frame size and the per-connection buffer. And it's fuzzable: a fuzz_deframe target feeding the stream decoder slots straight into the ClusterFuzzLite setup that just landed.
RT-safety — parsing a deframed packet is still the allocation-free RT read path (OSCTAP_REALTIME is untouched). Reassembly buffering happens on the network thread, which is off the RT contract by design — so it's consistent, but the buffers are new allocations to keep bounded.
Latency — set TCP_NODELAY (disable Nagle); OSC-over-TCP without it is a classic latency footgun.
Send-side backpressure — send() on TCP can partially write or block; the transmit socket needs to loop, unlike UDP's atomic datagram.
select() + FD_SETSIZE — fine for a handful of connections; many connections eventually wants poll/epoll, but that's a v2 concern.
My recommendation
It's very doable and purely additive — oscpack never had TCP, so there's no compat constraint (just keep the oscpack:: alias on any new types). I'd scope a v1 as: length-prefix framing, a single-threaded multiplexer that accepts multiple connections, server + client types, TCP_NODELAY on, frame-size capped, plus a deframer fuzz target. Defer SLIP, TLS, WebSocket, and epoll until there's demand — consistent with the ROADMAP's "keep each phase green indefinitely" principle. This is genuinely Phase-2-"Reach" shaped.
Prior art to copy decisions from: liblo (osc.tcp, supports both framings), libossia/ossia (TCP + WebSocket), and the OSC 1.1 spec (SLIP).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions