ZeroDDS TCP Transport 1.0 — Spec Coverage

Source: this document is also the spec. For TCP, OMG standardizes only the locator kind (DDSI-RTPS 2.5 §9.4.5) and the wire mapping (§9.5); the stream framing and the connection bring-up are vendor-specific. The normative requirements of this document live in the ## sections §2 through §6; every ### item below carries a spec citation, a repo anchor, a test and a status.

Implementation: crates/transport-tcp/ — length-prefix framing (framing.rs), 16-byte handshake (handshake.rs), connection pool (tcp_transport.rs).

Open items: zerodds-tcp-transport-1.0.open.en.md.

§1 Scope and spec status

§1.1 Locator kind TCPv4 / TCPv6

Spec: DDSI-RTPS 2.5 §9.4.5 — LOCATOR_KIND_TCPv4 = 4, LOCATOR_KIND_TCPv6 = 8. The transport MUST carry exactly these values.

Repo: crates/rtps/src/wire_types.rs (LocatorKind::Tcpv4 = 0x01F0_0004, LocatorKind::Tcpv6 = 0x01F0_0005, Locator::tcp_v4, Locator::tcp_v6); bound in crates/transport-tcp/src/tcp_transport.rs (locator_to_socket, TcpTransport::local_locator).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::locator_kinds_are_zerodds_vendor_range_values (asserts the two numeric values 4 and 8 directly), crates/transport-tcp/src/tcp_transport.rs::tests::locator_to_socket_v4_maps_tcp, crates/transport-tcp/src/tcp_transport.rs::tests::locator_to_socket_v4_rejects_udp, crates/rtps/src/wire_types.rs::tests::locator_kind_roundtrip.

Status: partial — the transport carries the vendor-range values 0x01F0_0004 and 0x01F0_0005 (DDSI-RTPS 2.5 §9.3.2.4 reserves 3 to 0x01ffffff for vendors); set_tcp_kinds_fast_dds writes the values 4 and 8 of the spec line (Fast DDS, DDS-TCP-PSM), and both are read. Remainder: 4 and 8 are not the default.

§1.2 RTPS wire mapping over the stream

Spec: DDSI-RTPS 2.5 §9.5 — the frame body is a complete RTPS message (RTPS header + submessages), byte-identical to the UDP PSM. The TCP transport MUST pass it through unchanged and must not rewrite, coalesce or split it.

Repo: crates/transport-tcp/src/framing.rs (write_frame, read_frame — the payload is copied without interpretation); crates/transport-tcp/src/tcp_transport.rs (accept_one hands every frame it read up as a ReceivedDatagram).

Tests: crates/transport-tcp/tests/loopback.rs::loopback_single_frame_end_to_end (two frames over a real TCP connection, byte-equal and in order), crates/transport-tcp/tests/loopback.rs::concurrent_senders_to_one_server (100 frames from two threads, no loss), crates/transport-tcp/src/framing.rs::tests::roundtrip_small_frame.

Status: done

§1.3 What OMG does not standardize for TCP

Spec: DDSI-RTPS 2.5 knows no TCP connection bring-up, no logical-port multiplexing and no connection-pool policy. Cyclone DDS (ddsi_tcp) sends raw RTPS frames without a handshake, FastDDS uses BindConnectionRequest/Response as submessages 0x71/0x72, RTI a TLS-oriented handshake. The section records the scope and states no implementation requirement.

Repo: —

Tests: —

Status: n/a (informative) — scope statement; the requirements are the items from §2 on.

§2 Wire format

Every RTPS datagram is framed over TCP as follows:

+---------+---------+---------+---------+
| length (u32, big-endian)              |   frame length in bytes (header excluded)
+---------+---------+---------+---------+
| RTPS frame body (length bytes)        |   as in the UDP PSM
+---------+---------+---------+---------+

§2.1 Length-prefix frame (4 bytes big-endian)

Spec: §2 — the sender MUST prefix every frame with a 4-byte big-endian length counting the body without the header itself. An empty body is allowed (length = 0). Consecutive frames follow each other without a separator.

Repo: crates/transport-tcp/src/framing.rs (write_frame, read_frame, FRAME_HEADER_LEN = 4).

Tests: crates/transport-tcp/src/framing.rs::tests::roundtrip_small_frame (checks the four header bytes against 5u32.to_be_bytes()), crates/transport-tcp/src/framing.rs::tests::roundtrip_empty_frame, crates/transport-tcp/src/framing.rs::tests::two_frames_back_to_back.

Status: done

§2.2 Reassembly across write boundaries

Spec: §2 — TCP is a stream: the receiver MUST reassemble a frame the sender spread over several write calls, and must neither truncate it nor hand it up as two frames. That holds when the length field itself arrives split.

Repo: crates/transport-tcp/src/framing.rs (read_exact_or_eof — read_exact fills header and body completely, no matter how many read calls that costs).

Tests: crates/transport-tcp/src/framing.rs::tests::reassembles_message_split_across_two_writes (length prefix plus first half in the first write, remainder plus a follow-up frame in the second; the reader returns exactly one message and then the follow-up), crates/transport-tcp/src/framing.rs::tests::reassembles_frame_split_across_reads (4096-byte payload at chunk sizes 1, 3, 4, 5 and 4095), crates/transport-tcp/tests/loopback.rs::frame_split_across_two_writes_is_reassembled (the same split over a real TCP socket, with an 80 ms pause between the two writes; the server yields exactly one frame).

Status: done

§2.3 Maximum frame size

Spec: §2 — the receiver MUST reject an announced length above the limit instead of allocating the buffer (DoS protection). Limit: 16 MiB. Larger samples are split at the RTPS level via DATA_FRAG (DDSI-RTPS §8.3.7), not at the transport level.

Repo: crates/transport-tcp/src/framing.rs (MAX_FRAME_SIZE = 16 * 1024 * 1024, FramingError::FrameTooLarge); send path in crates/transport-tcp/src/tcp_transport.rs (SendError::PayloadTooLarge).

Tests: crates/transport-tcp/src/framing.rs::tests::rejects_oversized_announcement (MAX_FRAME_SIZE + 1 is rejected before any read), crates/transport-tcp/src/tcp_transport.rs::tests::send_oversized_payload_returns_payload_too_large, crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_frame_too_large_errors.

Status: done

§2.4 Abort on EOF mid-frame

Spec: §2 — if the peer closes the connection in the middle of a header or body, the receiver MUST report UnexpectedEof and must not hand up a truncated frame. An EOF exactly on a frame boundary is the regular connection teardown.

Repo: crates/transport-tcp/src/framing.rs (FramingError::UnexpectedEof, read_exact_or_eof); crates/transport-tcp/src/tcp_transport.rs (accept_one ends the frame pump on UnexpectedEof with Ok(())).

Tests: crates/transport-tcp/src/framing.rs::tests::unexpected_eof_mid_header, crates/transport-tcp/src/framing.rs::tests::unexpected_eof_mid_payload, crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_reads_one_frame_then_eof.

Status: done

§3 Handshake (ZeroDDS-specific)

Client                                    Server
  |-- BindConnectionRequest  (16 B) ------->|   §3.1
  |<-- BindConnectionResponse (16 B) -------|   §3.2
  |-- length-prefix frame [RTPS] ---------->|   §2.1

§3.1 BindConnectionRequest — wire layout

Spec: §3 — 16 bytes big-endian: magic "ZDDS" (4), v_major (1), v_minor (1), vendor_id (2), flags (4, reserved), logical_port (4). The ZeroDDS version is (1,0), the vendor id [0x01, 0x0F]. A wrong magic MUST fail the connection.

Repo: crates/transport-tcp/src/handshake.rs (BindConnectionRequest, encode_request, decode_request, HANDSHAKE_MAGIC_REQUEST, HANDSHAKE_WIRE_SIZE = 16, VENDOR_ID_ZERODDS).

Tests: crates/transport-tcp/src/handshake.rs::tests::request_wire_layout_is_sixteen_bytes_big_endian (compares all 16 bytes against the diagram’s literal, including logical_port = 7400 as 00 00 1C E8), crates/transport-tcp/src/handshake.rs::tests::request_roundtrip, crates/transport-tcp/src/handshake.rs::tests::request_rejects_wrong_magic.

Status: done

§3.2 BindConnectionResponse — wire layout and status byte

Spec: §3 — 16 bytes big-endian: magic prefix "ZDA" plus a status byte (0x2B = '+' accept, 0x2D = '-' reject), v_major, v_minor, vendor_id, flags, reason_code (4, big-endian; 0 on accept). Any other magic MUST fail as BadResponse.

Repo: crates/transport-tcp/src/handshake.rs (BindConnectionResponse, ResponseStatus, encode_response, decode_response, HANDSHAKE_MAGIC_ACCEPT, HANDSHAKE_MAGIC_REJECT).

Tests: crates/transport-tcp/src/handshake.rs::tests::response_wire_layout_is_sixteen_bytes_big_endian (all 16 bytes for accept and for reject with reason_code = 3), crates/transport-tcp/src/handshake.rs::tests::response_roundtrip_accept, crates/transport-tcp/src/handshake.rs::tests::response_roundtrip_reject, crates/transport-tcp/src/handshake.rs::tests::client_handshake_errors_on_bad_response_magic.

Status: done

§3.3 Reject reason codes

Spec: §3 — five stable wire codes: 0 Unknown, 1 VersionMismatch, 2 ResourceLimit, 3 LogicalPortConflict, 4 VendorNotAccepted. An unknown code MUST map to Unknown so a newer peer’s reject is still processable.

Repo: crates/transport-tcp/src/handshake.rs (RejectReason, RejectReason::from_code, RejectReason::as_code).

Tests: crates/transport-tcp/src/handshake.rs::tests::reject_reason_roundtrip_known_codes, crates/transport-tcp/src/handshake.rs::tests::reject_reason_unknown_code_maps_to_unknown, crates/transport-tcp/src/handshake.rs::tests::server_rejects_version_mismatch, crates/transport-tcp/src/handshake.rs::tests::client_handshake_rejects_on_resource_limit, crates/transport-tcp/src/handshake.rs::tests::client_handshake_rejects_on_logical_port_conflict, crates/transport-tcp/src/handshake.rs::tests::client_handshake_rejects_on_vendor_not_accepted.

Status: partial — the codec and the client-side handling exist and are tested for all five codes. server_handshake however emits only VersionMismatch; ResourceLimit, LogicalPortConflict and VendorNotAccepted never reach the wire from a ZeroDDS server. The three missing sender paths are carried separately as §3.5, §3.6 and §3.7.

§3.4 Handshake sequence and its precedence over the first frame

Spec: §3 — the client MUST send the request before the first length-prefix frame, the server MUST answer with the response before the first frame. A peer without a handshake MUST be refused; on reject both sides drop the connection.

Repo: crates/transport-tcp/src/handshake.rs (client_handshake, server_handshake); crates/transport-tcp/src/tcp_transport.rs (PeerConn::ensure_connected calls client_handshake right after connect; accept_one calls server_handshake before the frame pump).

Tests: crates/transport-tcp/src/handshake.rs::tests::full_roundtrip_accept_via_paired_cursors, crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_rejects_peer_without_handshake (an HTTP probe is refused with TcpTransportError::Handshake), crates/transport-tcp/tests/loopback.rs::loopback_single_frame_end_to_end (handshake and frames over a real connection).

Status: done

§3.5 Reserved flags — receiver check

Spec: §3 — the flags field is reserved and MUST be 0. A receiver that accepts a field with bits set blocks the later use of those bits: an old peer would silently ignore a new flag instead of refusing the connection.

Repo: —

Tests: —

Status: open — crates/transport-tcp/src/handshake.rs decodes flags and passes it on; neither server nor client refuses set bits.

§3.6 Logical-port claim

Spec: §3 — logical_port is the DDS endpoint port the client claims on this connection; 0 means “no claim”. The server MUST refuse an already-bound port with LogicalPortConflict and otherwise associate the connection with the claimed port.

Repo: —

Tests: —

Status: open — server_handshake does not check logical_port and assigns the connection to no port; LogicalPortConflict is only encoded and decoded.

§3.7 ResourceLimit reject at the peer cap

Spec: §3 — once the server has reached its peer limit it MUST refuse the handshake with ResourceLimit instead of accepting the connection and dropping it afterwards. Only then can the client tell the backoff apart from a connection failure.

Repo: —

Tests: —

Status: open — TcpTransport::serve closes the connection beyond MAX_PEERS after accept and counts it (refused_connections); no handshake with ResourceLimit goes on the wire.

§3.8 Time budget in the accept path

Spec: §3 — the accept path MUST carry a total budget for the handshake, not only a per-syscall timeout. A peer dribbling byte by byte would otherwise pin the accept thread for 16 × slice seconds.

Repo: crates/transport-tcp/src/tcp_transport.rs (accept_one: handshake_total_budget = 2 s, handshake_syscall_slice = 200 ms, deadline check after server_handshake; the timeouts are lifted again after accept).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_gives_up_on_a_stalling_handshake_peer (the peer sends one handshake byte and goes quiet while holding the connection open; accept_one returns Handshake(_) in under 3 s), crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_rejects_peer_without_handshake.

Status: done

§4 Cyclone ddsi_tcp compatibility

§4.1 Handshake-skip mode

Spec: §4 — Cyclone DDS speaks raw RTPS frames with a length prefix over TCP, without a handshake. For interop a client variant MUST exist that skips §3 and sends §2.1 frames directly, plus a server side that accepts such connections.

Repo: —

Tests: —

Status: open — a peer without a handshake is refused with HandshakeError::BadMagic (crates/transport-tcp/src/handshake.rs); a client and server mode without §3 is missing.

§4.2 Auto-detect on the first frame byte

Spec: §4 — the server side MUST recognize the Cyclone mode from the first byte: 'Z' is the ZeroDDS handshake, 'R' the first byte of the RTPS magic. Without detection a deployment would have to configure the mode per port up front.

Repo: —

Tests: —

Status: open — the server always expects the ZeroDDS handshake; telling 'Z' from 'R' on the first byte is missing.

§5 Connection pool

§5.1 Connection reuse and lazy connect

Spec: §5 — the transport holds exactly one outbound connection per destination socket address, opened on the first send and reused afterwards. The pool lock must not be held across the blocking I/O.

Repo: crates/transport-tcp/src/tcp_transport.rs (PeerConn::ensure_connected, TcpTransport::send — step 1 takes the pool lock only for lookup/insert, step 2 the peer lock for the I/O).

Tests: crates/transport-tcp/tests/loopback.rs::loopback_single_frame_end_to_end (two sends over the same reused connection), crates/transport-tcp/tests/loopback.rs::reconnect_after_peer_restart, crates/transport-tcp/src/tcp_transport.rs::tests::send_unsupported_locator_does_not_pollute_pool.

Status: done

§5.2 Exponential backoff after a connection failure

Spec: §5 — after a failed connect the next attempt MUST be throttled (50 ms, doubling, capped at 5 s) so a dead peer cannot trigger a connect loop. A successful connect resets the backoff.

Repo: crates/transport-tcp/src/tcp_transport.rs (PeerConn::bump_backoff, INITIAL_BACKOFF_MS = 50, MAX_BACKOFF_MS = 5_000, reset in ensure_connected).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::peer_conn_bump_backoff_sequence (50 → 100 → … → 5000, then constant), crates/transport-tcp/src/tcp_transport.rs::tests::peer_conn_backoff_throttles_connect, crates/transport-tcp/src/tcp_transport.rs::tests::backoff_resets_after_successful_connect, crates/transport-tcp/tests/loopback.rs::backoff_prevents_tight_loop_on_dead_peer (a second send to a dead port returns in under 20 ms).

Status: done

§5.3 Peer cap with deterministic eviction

Spec: §5 — the pool is capped at MAX_PEERS = 256. When it is full and a new destination arrives, the lowest key is evicted deterministically; a destination already in the pool triggers no eviction.

Repo: crates/transport-tcp/src/tcp_transport.rs (MAX_PEERS, eviction branch in Transport::send).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::max_peers_eviction_removes_first_key_on_new_peer, crates/transport-tcp/src/tcp_transport.rs::tests::max_peers_no_eviction_for_existing_peer.

Status: done

§5.4 Bounded inbound queue with a counted drop

Spec: §5 — the receive queue is capped at MAX_INBOUND_QUEUE = 1024. On overflow the oldest frame is discarded and counted; a silent loss is not acceptable. recv blocks on a condvar and wakes on push, without a poll loop.

Repo: crates/transport-tcp/src/tcp_transport.rs (MAX_INBOUND_QUEUE, push_inbound, dropped_frames, Transport::recv with Condvar::wait).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::inbound_overflow_drops_oldest_and_counts, crates/transport-tcp/src/tcp_transport.rs::tests::dropped_frames_zero_on_fresh_transport, crates/transport-tcp/src/tcp_transport.rs::tests::recv_wakes_on_push_from_other_thread, crates/transport-tcp/src/tcp_transport.rs::tests::push_preserves_fifo_order.

Status: done

§6 Cross-vendor extension points

§6.1 FastDDS BindConnection (submessages 0x71/0x72)

Spec: §6 — FastDDS carries request and response as RTPS submessages 0x71 and 0x72. For interop with FastDDS TCPv4 this format MUST be selectable as an additional handshake dialect; the ZeroDDS-own handshake from §3 stays the default.

Repo: —

Tests: —

Status: open — no handshake dialect with the submessages 0x71/0x72; set_tcp_kinds_fast_dds concerns only the locator kinds (§1.1).

§6.2 RTI TLS-oriented handshake

Spec: §6 — RTI DDS TCP uses a TLS-oriented bring-up. For interop with RTI this dialect MUST be selectable as an additional mode; the ZeroDDS-own handshake from §3 stays the default.

Repo: —

Tests: —

Status: open — no mode with RTI’s TLS-oriented bring-up.


Audit-Status

13 done / 2 partial / 7 open / 0 blocked / 1 n/a (informative).

Test run: cargo test -p zerodds-transport-tcp — 58 tests (lib) + 6 tests (tests/loopback.rs) green, 0 failed. Measured on macOS 15 (Apple Clang toolchain, Rust host aarch64-apple-darwin).

Open items: zerodds-tcp-transport-1.0.open.en.md.

ZeroDDS-TCP-Transport 1.0 — Spec-Coverage

Quelle: dieses Dokument ist zugleich die Spec. Für TCP normiert OMG nur den Locator-Kind (DDSI-RTPS 2.5 §9.4.5) und das Wire-Mapping (§9.5); das Stream-Framing und der Connection-Bring-up sind vendor-spezifisch. Die normativen Anforderungen dieses Dokuments stehen in den ##-Sektionen §2 bis §6; jedes ###-Item darunter trägt Spec-Zitat, Repo-Anker, Test und Status.

Implementation: crates/transport-tcp/ — Length-Prefix-Framing (framing.rs), 16-Byte-Handshake (handshake.rs), Connection-Pool (tcp_transport.rs).

Offene Punkte: zerodds-tcp-transport-1.0.open.md.

§1 Scope und Spec-Status

§1.1 Locator-Kind TCPv4 / TCPv6

Spec: DDSI-RTPS 2.5 §9.4.5 — LOCATOR_KIND_TCPv4 = 4, LOCATOR_KIND_TCPv6 = 8. Der Transport MUSS genau diese Werte führen.

Repo: crates/rtps/src/wire_types.rs (LocatorKind::Tcpv4 = 0x01F0_0004, LocatorKind::Tcpv6 = 0x01F0_0005, Locator::tcp_v4, Locator::tcp_v6); gebunden in crates/transport-tcp/src/tcp_transport.rs (locator_to_socket, TcpTransport::local_locator).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::locator_kinds_are_zerodds_vendor_range_values (prüft die beiden Zahlwerte 0x01F0_0004 und 0x01F0_0005 direkt), crates/transport-tcp/src/tcp_transport.rs::tests::locator_to_socket_v4_maps_tcp, crates/transport-tcp/src/tcp_transport.rs::tests::locator_to_socket_v4_rejects_udp, crates/rtps/src/wire_types.rs::tests::locator_kind_roundtrip.

Status: partial — der Transport führt die Werte des Herstellerbereichs 0x01F0_0004 und 0x01F0_0005 (DDSI-RTPS 2.5 §9.3.2.4 reserviert 3 bis 0x01ffffff für Hersteller); die Werte 4 und 8 der Spec-Zeile schreibt set_tcp_kinds_fast_dds (Fast DDS, DDS-TCP-PSM), gelesen werden beide. Rest: die Werte 4 und 8 sind nicht der Vorgabewert.

§1.2 RTPS-Wire-Mapping über den Stream

Spec: DDSI-RTPS 2.5 §9.5 — der Frame-Body ist ein vollständiges RTPS-Message (RTPS-Header + Submessages), byte-identisch zum UDP-PSM. Der TCP-Transport MUSS ihn unverändert durchreichen und darf ihn nicht umschreiben, zusammenfassen oder aufteilen.

Repo: crates/transport-tcp/src/framing.rs (write_frame, read_frame — der Payload wird ohne Interpretation kopiert); crates/transport-tcp/src/tcp_transport.rs (accept_one reicht jeden gelesenen Frame als ReceivedDatagram weiter).

Tests: crates/transport-tcp/tests/loopback.rs::loopback_single_frame_end_to_end (zwei Frames über eine echte TCP-Verbindung, byte-gleich und in Reihenfolge zurück), crates/transport-tcp/tests/loopback.rs::concurrent_senders_to_one_server (100 Frames aus zwei Prozess-Threads, kein Verlust), crates/transport-tcp/src/framing.rs::tests::roundtrip_small_frame.

Status: done

§1.3 Was OMG für TCP nicht normiert

Spec: DDSI-RTPS 2.5 kennt keinen TCP-Connection-Bring-up, kein Logical-Port-Multiplexing und keine Connection-Pool-Policy. Cyclone DDS (ddsi_tcp) sendet rohe RTPS-Frames ohne Handshake, FastDDS nutzt BindConnectionRequest/Response als Submessages 0x71/0x72, RTI einen TLS-orientierten Handshake. Die Sektion hält den Scope fest und stellt keine Implementierungs-Anforderung.

Repo: —

Tests: —

Status: n/a (informative) — Abgrenzung des Geltungsbereichs; die Anforderungen stehen in den Items ab §2.

§2 Wire-Format

Jedes RTPS-Datagramm wird über TCP wie folgt gerahmt:

+---------+---------+---------+---------+
| length (u32, big-endian)              |   Frame-Länge in Bytes (ohne Header)
+---------+---------+---------+---------+
| RTPS-Frame-Body (length Bytes)        |   wie beim UDP-PSM
+---------+---------+---------+---------+

§2.1 Length-Prefix-Frame (4 Byte big-endian)

Spec: §2 — der Sender MUSS jedem Frame eine 4-Byte-Länge in Big-Endian voranstellen, die den Body ohne den Header selbst zählt. Ein leerer Body ist zulässig (length = 0). Aufeinanderfolgende Frames stehen ohne Trenner hintereinander.

Repo: crates/transport-tcp/src/framing.rs (write_frame, read_frame, FRAME_HEADER_LEN = 4).

Tests: crates/transport-tcp/src/framing.rs::tests::roundtrip_small_frame (prüft die vier Header-Bytes gegen 5u32.to_be_bytes()), crates/transport-tcp/src/framing.rs::tests::roundtrip_empty_frame, crates/transport-tcp/src/framing.rs::tests::two_frames_back_to_back.

Status: done

§2.2 Reassembly über Write-Grenzen hinweg

Spec: §2 — TCP ist ein Stream: der Empfänger MUSS einen Frame zusammensetzen, der beim Sender über mehrere write-Aufrufe verteilt wurde, und darf ihn weder verkürzt noch als zwei Frames weiterreichen. Das gilt auch, wenn schon das Längenfeld gesplittet ankommt.

Repo: crates/transport-tcp/src/framing.rs (read_exact_or_eof — read_exact füllt Header und Body vollständig, unabhängig davon, wie viele read-Aufrufe das kostet).

Tests: crates/transport-tcp/src/framing.rs::tests::reassembles_message_split_across_two_writes (Längen-Präfix plus erste Hälfte im ersten Write, Rest plus Folgeframe im zweiten; der Reader liefert genau eine Nachricht und danach den Folgeframe), crates/transport-tcp/src/framing.rs::tests::reassembles_frame_split_across_reads (4096-Byte-Payload bei Chunk-Größen 1, 3, 4, 5 und 4095), crates/transport-tcp/tests/loopback.rs::frame_split_across_two_writes_is_reassembled (dieselbe Teilung über einen echten TCP-Socket, mit 80 ms Pause zwischen den beiden Writes; der Server liefert genau einen Frame).

Status: done

§2.3 Maximum-Frame-Size

Spec: §2 — der Empfänger MUSS eine angekündigte Länge oberhalb des Limits ablehnen, statt den Puffer zu allokieren (DoS-Schutz). Limit: 16 MiB. Größere Samples werden auf RTPS-Ebene per DATA_FRAG (DDSI-RTPS §8.3.7) zerlegt, nicht auf Transport-Ebene.

Repo: crates/transport-tcp/src/framing.rs (MAX_FRAME_SIZE = 16 * 1024 * 1024, FramingError::FrameTooLarge); Sendepfad in crates/transport-tcp/src/tcp_transport.rs (SendError::PayloadTooLarge).

Tests: crates/transport-tcp/src/framing.rs::tests::rejects_oversized_announcement (MAX_FRAME_SIZE + 1 wird abgelehnt, bevor gelesen wird), crates/transport-tcp/src/tcp_transport.rs::tests::send_oversized_payload_returns_payload_too_large, crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_frame_too_large_errors.

Status: done

§2.4 Abbruch bei EOF mitten im Frame

Spec: §2 — schließt der Peer die Verbindung mitten in Header oder Body, MUSS der Empfänger das als UnexpectedEof melden und darf keinen verkürzten Frame nach oben geben. Ein EOF genau auf einer Frame-Grenze ist dagegen der reguläre Verbindungsabbau.

Repo: crates/transport-tcp/src/framing.rs (FramingError::UnexpectedEof, read_exact_or_eof); crates/transport-tcp/src/tcp_transport.rs (accept_one beendet die Frame-Pumpe bei UnexpectedEof mit Ok(())).

Tests: crates/transport-tcp/src/framing.rs::tests::unexpected_eof_mid_header, crates/transport-tcp/src/framing.rs::tests::unexpected_eof_mid_payload, crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_reads_one_frame_then_eof.

Status: done

§3 Handshake (ZeroDDS-spezifisch)

Client                                    Server
  |-- BindConnectionRequest  (16 B) ------->|   §3.1
  |<-- BindConnectionResponse (16 B) -------|   §3.2
  |-- Length-Prefix-Frame [RTPS] ---------->|   §2.1

§3.1 BindConnectionRequest — Wire-Layout

Spec: §3 — 16 Byte big-endian: Magic "ZDDS" (4), v_major (1), v_minor (1), vendor_id (2), flags (4, reserviert), logical_port (4). ZeroDDS-Version ist (1,0), die VendorId [0x01, 0x0F]. Ein falsches Magic MUSS die Verbindung scheitern lassen.

Repo: crates/transport-tcp/src/handshake.rs (BindConnectionRequest, encode_request, decode_request, HANDSHAKE_MAGIC_REQUEST, HANDSHAKE_WIRE_SIZE = 16, VENDOR_ID_ZERODDS).

Tests: crates/transport-tcp/src/handshake.rs::tests::request_wire_layout_is_sixteen_bytes_big_endian (vergleicht alle 16 Bytes gegen das Literal des Diagramms, inklusive logical_port = 7400 als 00 00 1C E8), crates/transport-tcp/src/handshake.rs::tests::request_roundtrip, crates/transport-tcp/src/handshake.rs::tests::request_rejects_wrong_magic.

Status: done

§3.2 BindConnectionResponse — Wire-Layout und Status-Byte

Spec: §3 — 16 Byte big-endian: Magic-Prefix "ZDA" plus Status-Byte (0x2B = '+' Accept, 0x2D = '-' Reject), v_major, v_minor, vendor_id, flags, reason_code (4, big-endian; 0 bei Accept). Ein anderes Magic MUSS als BadResponse scheitern.

Repo: crates/transport-tcp/src/handshake.rs (BindConnectionResponse, ResponseStatus, encode_response, decode_response, HANDSHAKE_MAGIC_ACCEPT, HANDSHAKE_MAGIC_REJECT).

Tests: crates/transport-tcp/src/handshake.rs::tests::response_wire_layout_is_sixteen_bytes_big_endian (alle 16 Bytes für Accept und für Reject mit reason_code = 3), crates/transport-tcp/src/handshake.rs::tests::response_roundtrip_accept, crates/transport-tcp/src/handshake.rs::tests::response_roundtrip_reject, crates/transport-tcp/src/handshake.rs::tests::client_handshake_errors_on_bad_response_magic.

Status: done

§3.3 Reject-Reason-Codes

Spec: §3 — fünf stabile Wire-Codes: 0 Unknown, 1 VersionMismatch, 2 ResourceLimit, 3 LogicalPortConflict, 4 VendorNotAccepted. Ein unbekannter Code MUSS auf Unknown abgebildet werden, damit ein neuerer Peer den Reject trotzdem verarbeiten kann.

Repo: crates/transport-tcp/src/handshake.rs (RejectReason, RejectReason::from_code, RejectReason::as_code).

Tests: crates/transport-tcp/src/handshake.rs::tests::reject_reason_roundtrip_known_codes, crates/transport-tcp/src/handshake.rs::tests::reject_reason_unknown_code_maps_to_unknown, crates/transport-tcp/src/handshake.rs::tests::server_rejects_version_mismatch, crates/transport-tcp/src/handshake.rs::tests::client_handshake_rejects_on_resource_limit, crates/transport-tcp/src/handshake.rs::tests::client_handshake_rejects_on_logical_port_conflict, crates/transport-tcp/src/handshake.rs::tests::client_handshake_rejects_on_vendor_not_accepted.

Status: partial — Codec und Client-Verarbeitung sind für alle fünf Codes vorhanden und getestet. server_handshake erzeugt jedoch nur VersionMismatch; ResourceLimit, LogicalPortConflict und VendorNotAccepted kommen nie aus einem ZeroDDS-Server auf die Leitung. Die drei fehlenden Sender-Pfade sind als §3.5, §3.6 und §3.7 getrennt geführt.

§3.4 Handshake-Sequenz und Pflicht vor dem ersten Frame

Spec: §3 — der Client MUSS den Request vor dem ersten Length-Prefix-Frame senden, der Server MUSS die Response vor dem ersten Frame beantworten. Ein Peer ohne Handshake MUSS abgewiesen werden; bei Reject droppen beide Seiten die Verbindung.

Repo: crates/transport-tcp/src/handshake.rs (client_handshake, server_handshake); crates/transport-tcp/src/tcp_transport.rs (PeerConn::ensure_connected ruft client_handshake direkt nach connect; accept_one ruft server_handshake vor der Frame-Pumpe).

Tests: crates/transport-tcp/src/handshake.rs::tests::full_roundtrip_accept_via_paired_cursors, crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_rejects_peer_without_handshake (HTTP-Probe wird mit TcpTransportError::Handshake abgewiesen), crates/transport-tcp/tests/loopback.rs::loopback_single_frame_end_to_end (Handshake und Frames über eine echte Verbindung).

Status: done

§3.5 Reserved flags — Empfänger-Prüfung

Spec: §3 — das flags-Feld ist reserviert und MUSS 0 sein. Ein Empfänger, der ein Feld mit gesetzten Bits akzeptiert, verbaut die spätere Belegung dieser Bits: ein alter Peer würde ein neues Flag still ignorieren statt die Verbindung abzulehnen.

Repo: —

Tests: —

Status: open — crates/transport-tcp/src/handshake.rs dekodiert flags und gibt es weiter; weder Server noch Client lehnen gesetzte Bits ab.

§3.6 Logical-Port-Claim

Spec: §3 — logical_port ist der DDS-Endpoint-Port, den der Client auf dieser Verbindung beansprucht; 0 heißt „kein Claim”. Der Server MUSS einen bereits gebundenen Port mit LogicalPortConflict ablehnen und die Verbindung sonst dem beanspruchten Port zuordnen.

Repo: —

Tests: —

Status: open — server_handshake prüft logical_port nicht und ordnet die Verbindung keinem Port zu; LogicalPortConflict wird nur kodiert und dekodiert.

§3.7 ResourceLimit-Reject bei erreichtem Peer-Limit

Spec: §3 — hat der Server sein Peer-Limit erreicht, MUSS er den Handshake mit ResourceLimit ablehnen, statt die Verbindung anzunehmen und danach zu verwerfen. Nur so kann der Client den Backoff vom Verbindungsfehler unterscheiden.

Repo: —

Tests: —

Status: open — TcpTransport::serve schließt die Verbindung über MAX_PEERS nach dem Accept und zählt sie (refused_connections); ein Handshake mit ResourceLimit geht nicht auf die Leitung.

§3.8 Zeitbudget im Accept-Pfad

Spec: §3 — der Accept-Pfad MUSS ein Gesamtbudget für den Handshake führen, nicht nur ein Timeout pro Syscall. Ein byte-weise trödelnder Peer würde sonst 16 × slice Sekunden des Accept-Threads binden.

Repo: crates/transport-tcp/src/tcp_transport.rs (accept_one: handshake_total_budget = 2 s, handshake_syscall_slice = 200 ms, Deadline-Prüfung nach server_handshake; nach Accept werden die Timeouts wieder aufgehoben).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_gives_up_on_a_stalling_handshake_peer (Peer sendet ein Handshake-Byte und schweigt, hält die Verbindung aber offen; accept_one kehrt mit Handshake(_) in unter 3 s zurück), crates/transport-tcp/src/tcp_transport.rs::tests::accept_one_rejects_peer_without_handshake.

Status: done

§4 Cyclone-ddsi_tcp-Kompatibilität

§4.1 Handshake-Skip-Mode

Spec: §4 — Cyclone DDS spricht rohe RTPS-Frames mit Length-Prefix über TCP, ohne Handshake. Für Interop MUSS eine Client-Variante existieren, die §3 überspringt und direkt §2.1-Frames sendet, und eine Server-Seite, die solche Verbindungen annimmt.

Repo: —

Tests: —

Status: open — ein Peer ohne Handshake wird mit HandshakeError::BadMagic abgewiesen (crates/transport-tcp/src/handshake.rs); eine Client- und Server-Betriebsart ohne §3 fehlt.

§4.2 Auto-Detect am ersten Frame-Byte

Spec: §4 — die Server-Seite MUSS den Cyclone-Modus am ersten Byte erkennen: 'Z' ist der ZeroDDS-Handshake, 'R' das erste Byte des RTPS-Magic. Ohne Erkennung müsste ein Deployment die Betriebsart pro Port vorab konfigurieren.

Repo: —

Tests: —

Status: open — der Server erwartet immer den ZeroDDS-Handshake; eine Unterscheidung von 'Z' und 'R' am ersten Byte fehlt.

§5 Connection-Pool

§5.1 Connection-Reuse und Lazy Connect

Spec: §5 — pro Ziel-Socket-Adresse hält der Transport genau eine ausgehende Verbindung, die beim ersten send aufgebaut und danach wiederverwendet wird. Der Pool-Lock darf nicht über die blockierende I/O gehalten werden.

Repo: crates/transport-tcp/src/tcp_transport.rs (PeerConn::ensure_connected, TcpTransport::send — Schritt 1 nimmt den Pool-Lock nur für Lookup/Insert, Schritt 2 den Peer-Lock für die I/O).

Tests: crates/transport-tcp/tests/loopback.rs::loopback_single_frame_end_to_end (zwei Sends über dieselbe wiederverwendete Verbindung), crates/transport-tcp/tests/loopback.rs::reconnect_after_peer_restart, crates/transport-tcp/src/tcp_transport.rs::tests::send_unsupported_locator_does_not_pollute_pool.

Status: done

§5.2 Exponentieller Backoff nach Verbindungsfehler

Spec: §5 — nach einem fehlgeschlagenen Connect MUSS der nächste Versuch gedrosselt werden (50 ms, verdoppelnd, Deckel 5 s), damit ein toter Peer keine Connect-Schleife auslöst. Ein erfolgreicher Connect setzt den Backoff zurück.

Repo: crates/transport-tcp/src/tcp_transport.rs (PeerConn::bump_backoff, INITIAL_BACKOFF_MS = 50, MAX_BACKOFF_MS = 5_000, Rücksetzung in ensure_connected).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::peer_conn_bump_backoff_sequence (50 → 100 → … → 5000, dann konstant), crates/transport-tcp/src/tcp_transport.rs::tests::peer_conn_backoff_throttles_connect, crates/transport-tcp/src/tcp_transport.rs::tests::backoff_resets_after_successful_connect, crates/transport-tcp/tests/loopback.rs::backoff_prevents_tight_loop_on_dead_peer (zweiter Send an einen toten Port kehrt in unter 20 ms zurück).

Status: done

§5.3 Peer-Cap mit deterministischer Eviction

Spec: §5 — der Pool ist auf MAX_PEERS = 256 gedeckelt. Ist er voll und kommt ein neues Ziel dazu, wird deterministisch der kleinste Schlüssel verdrängt; ein bereits enthaltenes Ziel löst keine Eviction aus.

Repo: crates/transport-tcp/src/tcp_transport.rs (MAX_PEERS, Eviction-Zweig in Transport::send).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::max_peers_eviction_removes_first_key_on_new_peer, crates/transport-tcp/src/tcp_transport.rs::tests::max_peers_no_eviction_for_existing_peer.

Status: done

§5.4 Begrenzte Inbound-Queue mit gezähltem Drop

Spec: §5 — die Empfangs-Queue ist auf MAX_INBOUND_QUEUE = 1024 gedeckelt. Läuft sie über, wird der älteste Frame verworfen und gezählt; ein stiller Verlust ist nicht zulässig. recv blockiert per Condvar und wacht beim Push auf, ohne Poll-Schleife.

Repo: crates/transport-tcp/src/tcp_transport.rs (MAX_INBOUND_QUEUE, push_inbound, dropped_frames, Transport::recv mit Condvar::wait).

Tests: crates/transport-tcp/src/tcp_transport.rs::tests::inbound_overflow_drops_oldest_and_counts, crates/transport-tcp/src/tcp_transport.rs::tests::dropped_frames_zero_on_fresh_transport, crates/transport-tcp/src/tcp_transport.rs::tests::recv_wakes_on_push_from_other_thread, crates/transport-tcp/src/tcp_transport.rs::tests::push_preserves_fifo_order.

Status: done

§6 Cross-Vendor-Erweiterungspunkte

§6.1 FastDDS-BindConnection (Submessages 0x71/0x72)

Spec: §6 — FastDDS trägt Request und Response als RTPS-Submessages 0x71 und 0x72. Für Interop mit FastDDS-TCPv4 MUSS dieses Format als zusätzlicher Handshake-Dialekt wählbar sein; der ZeroDDS-eigene Handshake aus §3 bleibt Default.

Repo: —

Tests: —

Status: open — kein Handshake-Dialekt mit den Submessages 0x71/0x72; set_tcp_kinds_fast_dds betrifft nur die Locator-Kinds (§1.1).

§6.2 RTI-TLS-orientierter Handshake

Spec: §6 — RTI DDS TCP nutzt einen TLS-orientierten Bring-up. Für Interop mit RTI MUSS dieser Dialekt als zusätzliche Betriebsart wählbar sein; der ZeroDDS-eigene Handshake aus §3 bleibt Default.

Repo: —

Tests: —

Status: open — keine Betriebsart mit dem TLS-orientierten Bring-up von RTI.


Audit-Status

13 done / 2 partial / 7 open / 0 blocked / 1 n/a (informative).

Test-Lauf: cargo test -p zerodds-transport-tcp — 58 Tests (lib) + 6 Tests (tests/loopback.rs) grün, 0 failed. Gemessen auf macOS 15 (Apple-Clang-Toolchain, Rust-Host aarch64-apple-darwin).

Offene Punkte: zerodds-tcp-transport-1.0.open.md.