zerodds_dcps/runtime.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! DcpsRuntime — event loop + UDP sockets per DomainParticipant.
4//!
5//! # Structure
6//!
7//! - Binds 3 UDP sockets per participant:
8//! * SPDP multicast receiver (domain-based port).
9//! * SPDP unicast fallback (ephemeral, for bidirectional SPDP).
10//! * User unicast (ephemeral, where matched peers send to).
11//! - Spawns a single event-loop thread that periodically:
12//! * sends the SPDP beacon (every 5 s by default),
13//! * polls all sockets non-blocking,
14//! * moves SPDP datagrams into the DiscoveredParticipantsCache,
15//! * dispatches SEDP datagrams (pub/sub announces),
16//! * delivers user data to the correct DataReader slots,
17//! * runs the WLP/liveliness tick,
18//! * serves the TypeLookup service endpoints (XTypes 1.3 §7.6.3.3.4).
19//! - Thread lifecycle via `Arc<AtomicBool> stop_flag` + `JoinHandle` in
20//! `Drop`.
21//!
22//! With the `security` feature active, all outbound/inbound bytes pass
23//! through the `SharedSecurityGate` (DDS-Security 1.2). Multi-interface
24//! binding (RuntimeConfig::interface_bindings) enables per-subnet routing
25//! for production topologies.
26
27extern crate alloc;
28use alloc::collections::BTreeMap;
29use alloc::string::String;
30use alloc::sync::Arc;
31use alloc::vec::Vec;
32use core::time::Duration;
33use std::net::{Ipv4Addr, SocketAddr};
34use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
35use std::sync::mpsc;
36use std::sync::{Condvar, Mutex, RwLock};
37use std::thread::{self, JoinHandle};
38use std::time::Instant;
39
40use zerodds_discovery::security::SecurityBuiltinStack;
41use zerodds_discovery::sedp::SedpStack;
42use zerodds_discovery::spdp::{
43 DiscoveredParticipant, DiscoveredParticipantsCache, SpdpBeacon, SpdpReader,
44};
45use zerodds_discovery::type_lookup::{
46 TypeLookupClient, TypeLookupEndpoints, TypeLookupReply, TypeLookupServer,
47};
48use zerodds_qos::Duration as QosDuration;
49use zerodds_rtps::EntityId;
50use zerodds_rtps::datagram::{ParsedSubmessage, decode_datagram};
51use zerodds_rtps::fragment_assembler::AssemblerCaps;
52use zerodds_rtps::history_cache::HistoryKind;
53use zerodds_rtps::message_builder::DEFAULT_MTU;
54use zerodds_rtps::participant_data::{ParticipantBuiltinTopicData, endpoint_flag};
55use zerodds_rtps::reliable_reader::{ReliableReader, ReliableReaderConfig};
56use zerodds_rtps::reliable_writer::{
57 DEFAULT_FRAGMENT_SIZE, DEFAULT_HEARTBEAT_PERIOD, LOOPBACK_FRAGMENT_SIZE, LOOPBACK_MTU,
58 ReliableWriter, ReliableWriterConfig,
59};
60use zerodds_rtps::wire_types::{
61 Guid, GuidPrefix, Locator, LocatorKind, ProtocolVersion, SPDP_DEFAULT_MULTICAST_ADDRESS,
62 VendorId, spdp_multicast_port,
63};
64use zerodds_transport::Transport;
65use zerodds_transport_udp::UdpTransport;
66
67#[cfg(feature = "security")]
68use zerodds_security_runtime::{EndpointProtection, IpRange, NetInterface, ProtectionLevel};
69
70use crate::error::{DdsError, Result};
71
72/// Default tick period of the event loop.
73///
74/// This is the worst-case quantization for sub-tick-driven tasks
75/// (SEDP heartbeats, reliable-writer resends, ACKNACK emit). Short enough
76/// for sub-ms round-trip latency (5 ms = 100 Hz tick rate), long enough
77/// to keep idle CPU cost small.
78///
79/// Phase-3 migration: this tick loop is replaced by a deadline heap +
80/// condvar worker (`scheduler.rs`) — then this value is only the
81/// idle-floor sleep (no quantization tax for events).
82pub const DEFAULT_TICK_PERIOD: Duration = Duration::from_millis(5);
83
84/// Default SPDP announce period (Spec §8.5.3.2 recommends 5 s).
85pub const DEFAULT_SPDP_PERIOD: Duration = Duration::from_secs(5);
86
87/// Default number of SPDP announces sent at the fast initial-burst cadence
88/// (C3 WiFi-robust discovery) before falling back to [`DEFAULT_SPDP_PERIOD`].
89/// Analogous to Fast DDS `initial_announcements`.
90pub const DEFAULT_INITIAL_ANNOUNCE_COUNT: u32 = 10;
91
92/// Default period between initial-announcement-burst SPDP sends.
93pub const DEFAULT_INITIAL_ANNOUNCE_PERIOD: Duration = Duration::from_millis(200);
94
95/// Deadline/lease compat check: the offered period must be <= requested.
96/// `0` is the sentinel for INFINITE — there any combination is compatible
97/// (offered INFINITE implies "I promise nothing faster than infinity",
98/// but a reader with INFINITE also requests nothing).
99fn deadline_compat(offered_nanos: u64, requested_nanos: u64) -> bool {
100 if offered_nanos == 0 || requested_nanos == 0 {
101 // INFINITE on one side → compatible.
102 return true;
103 }
104 offered_nanos <= requested_nanos
105}
106
107/// Partition matching: both sides have at least one common partition OR
108/// both are empty (default partition "").
109/// Enforces a per-instance KeepLast depth over a TransientLocal retained-sample
110/// buffer (DDS 1.4 §2.2.3.18). Keeps at most `depth` *Alive* entries per
111/// instance key (oldest evicted first), preserving order; terminal lifecycle
112/// markers (dispose/unregister) are never counted against the depth and are
113/// always retained so a late joiner observes the final NOT_ALIVE state.
114fn enforce_retained_depth(
115 retained: &mut alloc::collections::VecDeque<RetainedSample>,
116 depth: usize,
117) {
118 use alloc::collections::BTreeMap;
119 // Count alive samples per key.
120 let mut alive_per_key: BTreeMap<[u8; 16], usize> = BTreeMap::new();
121 for s in retained.iter() {
122 if s.lifecycle.is_none() {
123 *alive_per_key.entry(s.key_hash).or_insert(0) += 1;
124 }
125 }
126 // Determine how many to drop per key.
127 let mut to_drop: BTreeMap<[u8; 16], usize> = BTreeMap::new();
128 for (k, count) in &alive_per_key {
129 if *count > depth {
130 to_drop.insert(*k, count - depth);
131 }
132 }
133 if to_drop.is_empty() {
134 return;
135 }
136 retained.retain(|s| {
137 if s.lifecycle.is_some() {
138 return true;
139 }
140 if let Some(rem) = to_drop.get_mut(&s.key_hash) {
141 if *rem > 0 {
142 *rem -= 1;
143 return false; // evict this (oldest) alive sample for the key
144 }
145 }
146 true
147 });
148}
149
150fn partitions_overlap(offered: &[String], requested: &[String]) -> bool {
151 if offered.is_empty() && requested.is_empty() {
152 return true;
153 }
154 // An empty list is treated as ["" (default)].
155 let off_default = offered.is_empty();
156 let req_default = requested.is_empty();
157 if off_default && requested.iter().any(|s| s.is_empty()) {
158 return true;
159 }
160 if req_default && offered.iter().any(|s| s.is_empty()) {
161 return true;
162 }
163 // Both non-default: intersect.
164 offered.iter().any(|o| requested.iter().any(|r| r == o))
165}
166
167/// Materializes the locator address that we announce in the SPDP beacon
168/// from an UdpTransport bound to UNSPECIFIED.
169///
170/// Binding to `0.0.0.0` yields `local_addr() == 0.0.0.0:port`, which is
171/// not routable for peers. Via a UDP connect probe to a non-routable
172/// address we resolve the outbound interface address (no traffic —
173/// `connect()` on a UDP socket only sets the routing information). Falls
174/// back to `multicast_interface` (RuntimeConfig) if the probe fails, or
175/// to the unchanged locator as a last resort.
176#[cfg(feature = "std")]
177fn announce_locator(uc: &(dyn Transport + Send + Sync), hint: Ipv4Addr) -> Locator {
178 let raw = uc.local_locator();
179 // Keep the port from the bound socket.
180 let port = raw.port;
181 // V6 resolution: with a `::` bind, announce `::1` (loopback) as a
182 // sensible default reachability. Cross-host v6 is its own sprint
183 // (needs a v6 interface probe analogous to the v4 path below).
184 if raw.kind == LocatorKind::UdpV6 || raw.kind == LocatorKind::Tcpv6 {
185 let all_zero = raw.address.iter().all(|b| *b == 0);
186 if all_zero {
187 let mut loopback_addr = [0u8; 16];
188 loopback_addr[15] = 1;
189 return match raw.kind {
190 LocatorKind::Tcpv6 => Locator::tcp_v6(loopback_addr, port),
191 _ => Locator::udp_v6(loopback_addr, port),
192 };
193 }
194 return raw;
195 }
196 // V4 resolution: only meaningful for UDPv4/TCPv4 locators with an
197 // UNSPECIFIED bind. For SHM, return raw — the locator kind has its
198 // own pairing resolution (its own sprint).
199 if raw.kind != LocatorKind::UdpV4 && raw.kind != LocatorKind::Tcpv4 {
200 return raw;
201 }
202 // Extract the address — only the last 4 bytes are the IPv4.
203 let ip = Ipv4Addr::new(
204 raw.address[12],
205 raw.address[13],
206 raw.address[14],
207 raw.address[15],
208 );
209 if !ip.is_unspecified() {
210 return raw;
211 }
212 // Helper: construct a locator with the original kind (UdpV4 or
213 // Tcpv4) and the now-resolved v4 address.
214 let to_locator = |octets: [u8; 4]| -> Locator {
215 match raw.kind {
216 LocatorKind::Tcpv4 => Locator::tcp_v4(octets, port),
217 _ => Locator::udp_v4(octets, port),
218 }
219 };
220 // Interface pinning: an explicitly set interface
221 // (`ZERODDS_INTERFACE` / `RuntimeConfig.multicast_interface`) takes
222 // **precedence over the route probe**. On multi-homed hosts (VPN/
223 // Docker/macOS bridge100) the probe might otherwise pick the wrong
224 // source IP and announce an unreachable address → discovery fails.
225 if !hint.is_unspecified() {
226 return to_locator(hint.octets());
227 }
228 // Probe: temporary socket, "connect" to 192.0.2.1 (RFC 5737
229 // TEST-NET-1, guaranteed non-routable). connect only sets the routing
230 // table — no packet goes out.
231 if let Ok(probe) =
232 std::net::UdpSocket::bind(std::net::SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
233 {
234 if probe
235 .connect(std::net::SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 1), 7))
236 .is_ok()
237 {
238 if let Ok(std::net::SocketAddr::V4(local)) = probe.local_addr() {
239 let resolved = local.ip();
240 if !resolved.is_unspecified() {
241 return to_locator(resolved.octets());
242 }
243 }
244 }
245 }
246 // Fallback: loopback (the pin hint is already handled above). Not
247 // ideal, but better than 0.0.0.0 as a locator (at least routable on
248 // the same host).
249 to_locator([127, 0, 0, 1])
250}
251
252/// Converts a `core::time::Duration` (std) to a `zerodds_qos::Duration`
253/// (spec 2^-32 fraction encoding). Saturates on overflow — `i32::MAX`
254/// seconds suffices for over 60 years of lease.
255fn qos_duration_from_std(d: Duration) -> QosDuration {
256 let secs = i32::try_from(d.as_secs()).unwrap_or(i32::MAX);
257 let nanos = d.subsec_nanos();
258 // The spec fraction is 2^-32 s; from nanos back via (nanos << 32) / 1e9.
259 let fraction = ((u64::from(nanos)) << 32) / 1_000_000_000u64;
260 QosDuration {
261 seconds: secs,
262 fraction: fraction as u32,
263 }
264}
265
266/// Converts a `zerodds_qos::Duration` to nanoseconds (0 = INFINITE,
267/// "no monitoring"). `seconds` is i32 — we clamp to non-negative.
268fn qos_duration_to_nanos(d: zerodds_qos::Duration) -> u64 {
269 if d.is_infinite() {
270 return 0;
271 }
272 let secs = d.seconds.max(0) as u64;
273 // fraction is 2^-32 s, i.e. nanos = fraction * 1e9 / 2^32.
274 let frac_nanos = ((d.fraction as u64) * 1_000_000_000u64) >> 32;
275 secs.saturating_mul(1_000_000_000u64)
276 .saturating_add(frac_nanos)
277}
278
279/// Human-readable name of a QoS policy id (Spec OMG DDS 1.4 §2.2.3,
280/// PSM ids from [`crate::psm_constants::qos_policy_id`]). Used for the
281/// C2 "loud instead of silent" log on an incompatible QoS match, so that
282/// it states in plain text *which* policy prevented the match.
283#[must_use]
284fn qos_policy_id_name(pid: u32) -> &'static str {
285 use crate::psm_constants::qos_policy_id as qid;
286 match pid {
287 qid::DURABILITY => "DURABILITY",
288 qid::PRESENTATION => "PRESENTATION",
289 qid::DEADLINE => "DEADLINE",
290 qid::LATENCY_BUDGET => "LATENCY_BUDGET",
291 qid::OWNERSHIP => "OWNERSHIP",
292 qid::OWNERSHIP_STRENGTH => "OWNERSHIP_STRENGTH",
293 qid::LIVELINESS => "LIVELINESS",
294 qid::PARTITION => "PARTITION",
295 qid::RELIABILITY => "RELIABILITY",
296 qid::DESTINATION_ORDER => "DESTINATION_ORDER",
297 qid::DURABILITY_SERVICE => "DURABILITY_SERVICE",
298 qid::TYPE_CONSISTENCY_ENFORCEMENT => "TYPE_CONSISTENCY_ENFORCEMENT",
299 qid::DATA_REPRESENTATION => "DATA_REPRESENTATION",
300 _ => "OTHER",
301 }
302}
303
304/// RTPS serialized-payload header for user samples: `CDR_LE`
305/// (PLAIN_CDR / XCDR1, little-endian) + options=0. Spec OMG RTPS 2.5
306/// §9.4.2.13.
307///
308/// Prepended to every user payload before it goes into the DATA
309/// submessage — without this header, vendor readers (Cyclone / Fast-DDS)
310/// refuse to deliver the sample.
311///
312/// **Why `0x01` (XCDR1) and not `0x07` (XCDR2):** the C++ PSM codegen
313/// (`dds/topic/xcdr2.hpp`) aligns 8-byte primitives to `sizeof` — that
314/// is the PLAIN_CDR/XCDR1 rule, NOT XCDR2 (which requires
315/// `min(sizeof,4)`). ZeroDDS therefore effectively produces an XCDR1
316/// layout; the encapsulation header must declare that honestly,
317/// otherwise the peer reads the body with the wrong alignment (e.g.
318/// OpenDDS' `dds_demarshal` fails). Full XCDR2 support is a separate
319/// codegen feature.
320pub const USER_PAYLOAD_ENCAP: [u8; 4] = [0x00, 0x01, 0x00, 0x00];
321
322/// Encapsulation header for the user payload, based on the negotiated
323/// DataRepresentation (`offer_first`: the **first** element of the
324/// writer's offer list = the wire format actually emitted by the writer)
325/// and the type extensibility. The header MUST honestly declare the body
326/// encoding produced by the codegen, otherwise the peer (e.g.
327/// FastDDS/OpenDDS XCDR2-only reader) reads the body with the wrong
328/// alignment or wrongly expects a DHEADER.
329///
330/// DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 (little-endian variant):
331/// XCDR1 final/appendable -> CDR_LE `0x0001`
332/// XCDR1 mutable -> PL_CDR_LE `0x0003`
333/// XCDR2 final -> PLAIN_CDR2_LE `0x0007`
334/// XCDR2 appendable -> D_CDR2_LE `0x0009`
335/// XCDR2 mutable -> PL_CDR2_LE `0x000b`
336#[must_use]
337fn user_payload_encap(
338 offer_first: i16,
339 ext: zerodds_types::qos::ExtensibilityForRepr,
340 big_endian: bool,
341) -> [u8; 4] {
342 use zerodds_rtps::publication_data::data_representation as dr;
343 use zerodds_types::qos::ExtensibilityForRepr::{Appendable, Final, Mutable};
344 let id: u8 = match (offer_first, ext) {
345 (dr::XCDR2, Final) => 0x07,
346 (dr::XCDR2, Appendable) => 0x09,
347 (dr::XCDR2, Mutable) => 0x0b,
348 // XCDR1: appendable is treated like final (Tab.59: XCDR1 has no
349 // dedicated APPENDABLE encoding).
350 (dr::XCDR, Mutable) => 0x03,
351 // (dr::XCDR, Final|Appendable) as well as XML/unknown -> CDR_LE.
352 _ => 0x01,
353 };
354 // RTPS 2.5 §10.5: the `_LE` representation ids are odd, the matching `_BE`
355 // ones are the even predecessor (CDR_BE 0x00, CDR2_BE 0x06, D_CDR2_BE 0x08,
356 // PL_CDR2_BE 0x0a). Clearing the low bit converts LE -> BE.
357 let id = if big_endian { id & 0xFE } else { id };
358 [0x00, id, 0x00, 0x00]
359}
360
361/// Stack PoolBuffer cap for the small-sample path in
362/// [`DcpsRuntime::write_user_sample`]. A 1.5 KiB payload + 4 B encap
363/// header fit through the framing without touching the heap.
364const SMALL_FRAME_CAP: usize = 1536;
365
366/// Small-sample hot-path helper: frames `USER_PAYLOAD_ENCAP` + payload
367/// into a stack `PoolBuffer<SMALL_FRAME_CAP>` and hands the slice to the
368/// writer. No Vec/Box/Rc/Arc allocation in this function — verified by
369/// the `dds_no_realloc_in_hot_path` lint.
370///
371/// zerodds-lint: hot-path-realloc-free
372fn write_user_sample_pooled(
373 writer: &mut ReliableWriter,
374 payload: &[u8],
375 now: Duration,
376 encap: &[u8; 4],
377 source_ts_override: Option<zerodds_rtps::header_extension::HeTimestamp>,
378) -> Result<Vec<zerodds_rtps::message_builder::OutboundDatagram>> {
379 let mut frame = zerodds_foundation::PoolBuffer::<SMALL_FRAME_CAP>::new();
380 frame
381 .extend_from_slice(encap)
382 .map_err(|_| DdsError::WireError {
383 message: String::from("user encap framing"),
384 })?;
385 frame
386 .extend_from_slice(payload)
387 .map_err(|_| DdsError::WireError {
388 message: String::from("user payload framing"),
389 })?;
390 // Hot path: only DATA, NO HEARTBEAT. Cyclone DDS rate-limits the
391 // HB piggyback (≥100 µs spacing, or a packet boundary) — so it does
392 // NOT send an HB per write. At 14k writes/sec this would fire 14k
393 // unnecessary submessages and (with an unaligned payload) 14k extra
394 // sendto syscalls. Periodic HBs are handled by the tick loop (every
395 // `heartbeat_period` ms, default 100 ms); we no longer attach `_now`
396 // to `last_heartbeat`, because we emit nothing.
397 let _ = now;
398 // RTPS-F1: attach the source timestamp so the peer can populate
399 // SampleInfo.source_timestamp + honour DESTINATION_ORDER = BY_SOURCE_TIMESTAMP
400 // (DDSI-RTPS §8.7.3). The writer prepends an INFO_TS before the DATA. A
401 // routing service that preserves the input's source timestamp passes it as
402 // the override; otherwise the writer stamps the current wall clock.
403 let source_ts = source_ts_override
404 .unwrap_or_else(|| crate::time::time_to_he_timestamp(crate::time::get_current_time()));
405 writer
406 .write_stamped(frame.as_slice(), Some(source_ts))
407 .map_err(|_| DdsError::WireError {
408 message: String::from("user writer encode"),
409 })
410}
411
412/// Choice of transport for DCPS user traffic. Discovery (SPDP/SEDP)
413/// remains UDPv4 multicast independently of this.
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415#[non_exhaustive]
416pub enum UserTransportKind {
417 /// UDP IPv4 (default).
418 UdpV4,
419 /// UDP IPv6.
420 UdpV6,
421 /// TCP IPv4 (DDS-TCP-PSM `LOCATOR_KIND_TCPV4`).
422 TcpV4,
423 /// TCP IPv6 (DDS-TCP-PSM `LOCATOR_KIND_TCPV6`).
424 TcpV6,
425 /// POSIX shared memory (same-host). Only with the `same-host-shm`
426 /// feature.
427 #[cfg(feature = "same-host-shm")]
428 Shm,
429 /// Unix domain socket (same-host, container-friendly). Only with the
430 /// `same-host-uds` feature.
431 #[cfg(feature = "same-host-uds")]
432 Uds,
433 /// TSN L2 transport (AF_PACKET, RTPS direct on Ethernet, EtherType
434 /// 0x88B5). Only with the `tsn-live` feature on Linux. Interface/VLAN/
435 /// PCP via the env vars `ZERODDS_TSN_IFACE`/`_VLAN`/`_PCP`.
436 #[cfg(all(feature = "tsn-live", target_os = "linux"))]
437 Tsn,
438}
439
440/// Maps the `ZERODDS_USER_TRANSPORT` env var to a [`UserTransportKind`].
441/// `None` if unset or unknown — the caller then falls back to UDPv4.
442fn parse_user_transport_env() -> Option<UserTransportKind> {
443 match std::env::var("ZERODDS_USER_TRANSPORT").ok()?.as_str() {
444 "UDPv4" => Some(UserTransportKind::UdpV4),
445 "UDPv6" => Some(UserTransportKind::UdpV6),
446 "TCPv4" => Some(UserTransportKind::TcpV4),
447 "TCPv6" => Some(UserTransportKind::TcpV6),
448 #[cfg(feature = "same-host-shm")]
449 "SHM" => Some(UserTransportKind::Shm),
450 #[cfg(feature = "same-host-uds")]
451 "UDS" => Some(UserTransportKind::Uds),
452 #[cfg(all(feature = "tsn-live", target_os = "linux"))]
453 "TSN" => Some(UserTransportKind::Tsn),
454 _ => None,
455 }
456}
457
458/// Result of [`select_user_transport`]: the user-traffic transport plus
459/// an optional `TcpTransport` accept handle (only for TCP).
460type UserTransportSelection = (
461 Arc<dyn Transport + Send + Sync>,
462 Option<Arc<zerodds_transport_tcp::TcpTransport>>,
463);
464
465/// Binds the user-traffic transport for the selected
466/// [`UserTransportKind`]. Discovery (SPDP/SEDP) runs separately over
467/// UDPv4 multicast; this transport carries only the DCPS user traffic.
468///
469/// Additionally returns an optional `TcpTransport` accept handle: TCP has
470/// no implicit accept thread in the constructor, so the caller starts an
471/// `accept_one` worker for it.
472#[cfg_attr(
473 not(any(feature = "same-host-shm", feature = "same-host-uds")),
474 allow(unused_variables)
475)]
476fn select_user_transport(
477 kind: UserTransportKind,
478 guid_prefix: GuidPrefix,
479 domain_id: i32,
480 pinned: Ipv4Addr,
481) -> Result<UserTransportSelection> {
482 match kind {
483 UserTransportKind::UdpV4 => {
484 // Interface pinning: bind to the pinned IPv4 (egress + receive
485 // on exactly this interface), otherwise `0.0.0.0` (auto).
486 let udp = UdpTransport::bind_v4(pinned, 0)
487 .map_err(|_| DdsError::TransportError {
488 label: "user unicast bind (UDPv4)",
489 })?
490 .with_timeout(Some(Duration::from_secs(1)))
491 .map_err(|_| DdsError::TransportError {
492 label: "user unicast set_timeout (UDPv4)",
493 })?;
494 Ok((Arc::new(udp), None))
495 }
496 UserTransportKind::UdpV6 => {
497 let udp = UdpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
498 .map_err(|_| DdsError::TransportError {
499 label: "user unicast bind (UDPv6)",
500 })?
501 .with_timeout(Some(Duration::from_secs(1)))
502 .map_err(|_| DdsError::TransportError {
503 label: "user unicast set_timeout (UDPv6)",
504 })?;
505 Ok((Arc::new(udp), None))
506 }
507 UserTransportKind::TcpV4 => {
508 // Interface pinning analogous to UDPv4.
509 let tcp = zerodds_transport_tcp::TcpTransport::bind_v4(pinned, 0).map_err(|_| {
510 DdsError::TransportError {
511 label: "user unicast bind (TCPv4)",
512 }
513 })?;
514 let arc = Arc::new(tcp);
515 let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
516 Ok((dynamic, Some(arc)))
517 }
518 UserTransportKind::TcpV6 => {
519 let tcp =
520 zerodds_transport_tcp::TcpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
521 .map_err(|_| DdsError::TransportError {
522 label: "user unicast bind (TCPv6)",
523 })?;
524 let arc = Arc::new(tcp);
525 let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
526 Ok((dynamic, Some(arc)))
527 }
528 #[cfg(feature = "same-host-shm")]
529 UserTransportKind::Shm => {
530 // local_id = guid_prefix (12 bytes) + 4-byte domain id so that
531 // separate domains get separate segments (no cross-domain
532 // collisions).
533 let mut local_id = [0u8; 16];
534 local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
535 local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
536 let shm = crate::shm_user::ShmUserTransport::new(
537 local_id,
538 zerodds_transport_shm::posix::ShmConfig::default(),
539 );
540 Ok((Arc::new(shm), None))
541 }
542 #[cfg(feature = "same-host-uds")]
543 UserTransportKind::Uds => {
544 // local_id = guid_prefix (12 bytes) + 4-byte domain id — the
545 // peer resolves this id from the announced UDS locator into the
546 // same socket path.
547 let mut local_id = [0u8; 16];
548 local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
549 local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
550 // recv_timeout analogous to the UDP path: the recv loop must
551 // periodically check the stop flag (otherwise a thread hang on
552 // shutdown on a blocking recv).
553 let uds_cfg = zerodds_transport_uds::UdsConfig {
554 recv_timeout: Some(Duration::from_secs(1)),
555 ..zerodds_transport_uds::UdsConfig::default()
556 };
557 let uds =
558 zerodds_transport_uds::UdsTransport::bind(local_id, uds_cfg).map_err(|_| {
559 DdsError::TransportError {
560 label: "user unicast bind (UDS)",
561 }
562 })?;
563 Ok((Arc::new(uds), None))
564 }
565 #[cfg(all(feature = "tsn-live", target_os = "linux"))]
566 UserTransportKind::Tsn => {
567 // Interface/VLAN/PCP via env (TSN needs a concrete interface;
568 // not bindable to 0.0.0.0 like UDP/TCP). recv_timeout 1s
569 // analogous to UDP for the stop-flag check.
570 let iface =
571 std::env::var("ZERODDS_TSN_IFACE").map_err(|_| DdsError::TransportError {
572 label: "ZERODDS_TSN_IFACE not set (TSN transport)",
573 })?;
574 let vlan = std::env::var("ZERODDS_TSN_VLAN")
575 .ok()
576 .and_then(|s| s.parse::<u16>().ok())
577 .unwrap_or(0);
578 let pcp = std::env::var("ZERODDS_TSN_PCP")
579 .ok()
580 .and_then(|s| s.parse::<u8>().ok())
581 .unwrap_or(0);
582 let tsn = zerodds_transport_tsn::socket::TsnTransport::bind(
583 &iface,
584 vlan,
585 pcp,
586 Some(Duration::from_secs(1)),
587 )
588 .map_err(|_| DdsError::TransportError {
589 label: "user unicast bind (TSN)",
590 })?;
591 Ok((Arc::new(tsn), None))
592 }
593 }
594}
595
596/// Configuration for the runtime. Exposed via DomainParticipant factory
597/// methods.
598#[derive(Clone)]
599pub struct RuntimeConfig {
600 /// Tick period of the event loop. Default 50 ms.
601 pub tick_period: Duration,
602 /// SPDP announce period. Default 5 s.
603 pub spdp_period: Duration,
604 /// C3 WiFi-robust discovery — number of initial SPDP announces sent at the
605 /// fast [`Self::initial_announce_period`] cadence (instead of `spdp_period`)
606 /// while no peer is yet discovered. Default
607 /// [`DEFAULT_INITIAL_ANNOUNCE_COUNT`]. `0` disables the burst (legacy
608 /// single-announce-then-`spdp_period` behaviour).
609 pub initial_announce_count: u32,
610 /// Period between initial-announcement-burst SPDP sends. Default
611 /// [`DEFAULT_INITIAL_ANNOUNCE_PERIOD`].
612 pub initial_announce_period: Duration,
613 /// SPDP multicast group (IPv4). Default 239.255.0.1 (Spec §9.6.1.4.1).
614 pub spdp_multicast_group: Ipv4Addr,
615 /// Interface address for the multicast join. Default 0.0.0.0 (the
616 /// kernel picks the default interface).
617 pub multicast_interface: Ipv4Addr,
618
619 /// Opt-in multicast allowlist (IPv4 groups). When non-empty, ZeroDDS joins
620 /// only multicast groups on this list — any other group, including a
621 /// `spdp_multicast_group` not on the list, is refused at participant
622 /// creation. Empty = no restriction (default).
623 ///
624 /// Defense-in-depth: announced multicast locators are already never used as
625 /// send targets (they are dropped when a reader proxy is built, see
626 /// `ReaderProxy::new(.., Vec::new(), ..)` on the SEDP match path), so ZeroDDS
627 /// only ever touches its own `spdp_multicast_group`. This allowlist turns
628 /// that implicit code property into an enforced, auditable config.
629 /// Env: `ZERODDS_MULTICAST_ALLOWLIST` (comma-separated IPv4).
630 pub multicast_allowlist: Vec<Ipv4Addr>,
631
632 /// C1: whether SPDP beacons are sent via multicast. Default `true`
633 /// (spec behavior). `false` (env `ZERODDS_NO_MULTICAST`) → pure
634 /// unicast discovery via [`Self::initial_peers`], not a single
635 /// multicast packet — for networks that drop multicast (WiFi/cloud
636 /// VPC), and for a rigorous multicast-free discovery proof.
637 pub spdp_multicast_send: bool,
638
639 /// A1: **discovery-server** mode. When `true`, this participant relays the
640 /// raw SPDP (participant-locator) announcements between the clients that
641 /// point their [`Self::initial_peers`] at it — so N clients discover each
642 /// other through one well-known address instead of an O(N²) peer list or
643 /// multicast. Crucially it relays **only SPDP** (participant discovery);
644 /// SEDP (endpoint discovery, incl. dynamically-created ROS-2 Action
645 /// endpoints) then happens **directly peer-to-peer** between the real
646 /// participants — which is exactly why ROS-2 Actions work over it, unlike a
647 /// SEDP-proxying discovery server. Default `false`. Plain discovery only
648 /// (DDS-Security secured discovery-server relay is a follow-up).
649 pub discovery_server: bool,
650
651 /// C3: max reassemblable sample size (DoS cap of the fragment
652 /// assembler). Larger samples are silently discarded. The rtps
653 /// default was 1 MiB (the phase-1 assumption "large images = no
654 /// use case") — too small for ROS PointCloud2/Image (often several
655 /// MB). Default here 16 MiB; env `ZERODDS_MAX_SAMPLE_BYTES` (bytes)
656 /// overrides. Still a deliberate DoS guard, just ROS-realistic.
657 pub max_reassembly_sample_bytes: usize,
658
659 /// C1 multicast-free discovery: unicast initial-peer locators to
660 /// which SPDP beacons are sent **in addition** to multicast. Default
661 /// empty (= pure multicast behavior as before). Populated via
662 /// [`RuntimeConfig::default`] from the env `ZERODDS_PEERS` (comma
663 /// list of `ip` or `ip:port`). An `ip` without a port is expanded to
664 /// the well-known SPDP unicast ports of participant indices 0..N
665 /// (see [`expand_initial_peer`]).
666 pub initial_peers: Vec<Locator>,
667
668 /// Transport for DCPS user traffic. `None` (default) → fall back to
669 /// the env var `ZERODDS_USER_TRANSPORT`, otherwise UDPv4. Discovery
670 /// (SPDP/SEDP) remains UDPv4 multicast independently of this. Ignored when
671 /// [`Self::user_transports`] is non-empty.
672 pub user_transport: Option<UserTransportKind>,
673
674 /// Preference-ordered set of transports for DCPS user traffic. When
675 /// non-empty, the runtime builds a [`LayeredUserTransport`](crate::layered_transport::LayeredUserTransport)
676 /// over all of them: each datagram is routed to the first transport whose
677 /// locator kind matches the destination (so list the fast/local transport
678 /// first — e.g. `[Shm, UdpV4]` — and the fallback last), and receives are
679 /// multiplexed from all of them. Empty (default) → single-transport via
680 /// [`Self::user_transport`].
681 pub user_transports: alloc::vec::Vec<UserTransportKind>,
682
683 /// Optional security gate. Active only with the `security` feature.
684 /// When set, UDP outbound messages are pulled through
685 /// [`SharedSecurityGate::transform_outbound`], and inbound messages
686 /// through [`SharedSecurityGate::transform_inbound_from`] (peer key
687 /// from RTPS header bytes 8..20).
688 #[cfg(feature = "security")]
689 pub security: Option<std::sync::Arc<zerodds_security_runtime::SharedSecurityGate>>,
690 /// Optional LoggingPlugin for security events. Called by the inbound
691 /// path when packets are dropped due to a policy violation, tampering
692 /// or a legacy block.
693 #[cfg(feature = "security")]
694 pub security_logger: Option<std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin>>,
695
696 /// Multi-interface bindings. Empty → `user_unicast` is the only
697 /// outbound socket (legacy behavior). Non-empty →
698 /// `DcpsRuntime::start` builds a dedicated UDP socket per spec and the
699 /// writer tick loop routes to the matching socket per destination
700 /// locator.
701 #[cfg(feature = "security")]
702 pub interface_bindings: Vec<InterfaceBindingSpec>,
703
704 /// `true` → the SPDP beacon additionally announces the 12 secure
705 /// discovery bits (16..27, DDS-Security 1.2 §7.4.7.1). Default
706 /// `false` — only standard bits are announced. Set by the DCPS
707 /// factory once a PolicyEngine is configured. This flag is available
708 /// even without the `security` feature, so that tests can check bit
709 /// presence without activating the whole crypto crate.
710 pub announce_secure_endpoints: bool,
711
712 /// FastDDS interop: run the reliable secure SPDP channel (0xff0101c2/c7,
713 /// `ENTITYID_SPDP_RELIABLE_BUILTIN_PARTICIPANT_SECURE_*`). FastDDS announces
714 /// its full secured participant data (identity_token/security_info) over
715 /// this channel and gates the crypto-token reciprocation/endpoint matching
716 /// on it; cyclone does NOT need it (cyclone↔zerodds runs without). Default off
717 /// — enable only for FastDDS cross-vendor.
718 pub enable_secure_spdp: bool,
719
720 /// WLP-Tick-Periode (Writer-Liveliness-Protocol, RTPS 2.5 §8.4.13).
721 /// `Duration::ZERO` → default `participant_lease_duration / 3`
722 /// (spec recommendation: three misses before the reader marks the
723 /// writer as not-alive). A direct override enables aggressive
724 /// tests.
725 pub wlp_period: Duration,
726
727 /// Lease duration announced in the SPDP beacon as
728 /// `PARTICIPANT_LEASE_DURATION` (spec default 100 s). Also used as the
729 /// basis for the AUTOMATIC WLP tick (`wlp_period =
730 /// participant_lease_duration / 3` if `wlp_period == Duration::ZERO`).
731 pub participant_lease_duration: Duration,
732
733 /// USER_DATA bytes of the participant (DDS 1.4 §2.2.3.1
734 /// `UserDataQosPolicy`). Announced in the SPDP beacon as PID_USER_DATA
735 /// (DDSI-RTPS §9.6.3.2) and exposed on the receiver side in
736 /// `ParticipantBuiltinTopicData.user_data`. Default empty.
737 pub user_data: Vec<u8>,
738
739 /// Observability sink. Default is `null_sink()` — each event emit is
740 /// then a direct return without allocation on the consumer side.
741 /// Consumers inject e.g.
742 /// [`zerodds_foundation::observability::StderrJsonSink`] (JSON lines
743 /// for Vector/fluentd/Datadog) or their own OTLP bridge.
744 pub observability: zerodds_foundation::observability::SharedSink,
745
746 /// Sprint D.5d lever C — RT pinning + priority. Linux-only; on
747 /// macOS/Windows the hooks are no-ops.
748 ///
749 /// SCHED_FIFO priority (1-99) for the three recv workers (SPDP MC,
750 /// metatraffic, user data). `None` = default scheduler (CFS).
751 /// `Some(80)` is the spec recommendation for real-time paths. Requires
752 /// `CAP_SYS_NICE` or an `RLIMIT_RTPRIO`-permitted user.
753 pub recv_thread_priority: Option<i32>,
754
755 /// Like [`Self::recv_thread_priority`], but for the tick worker.
756 pub tick_thread_priority: Option<i32>,
757
758 /// CPU affinity mask for the recv workers. `None` = no affinity (the
759 /// kernel schedules freely). A list of CPU indices, e.g.
760 /// `vec![2, 3]` for cores 2+3. Set via `sched_setaffinity`; all three
761 /// recv threads share the same mask.
762 pub recv_thread_cpus: Option<Vec<usize>>,
763
764 /// Like [`Self::recv_thread_cpus`], but for the tick worker.
765 pub tick_thread_cpus: Option<Vec<usize>>,
766
767 /// Opt-3 (Spec `zerodds-zero-copy-1.0` §9): number of additional
768 /// user-data recv workers that listen on the same port as
769 /// `user_unicast` via `SO_REUSEPORT`. `0` (default) = only the primary
770 /// `recv_user_data_loop` worker. Under high recv load the pool scales
771 /// linearly with cores (kernel flow hashing distributes incoming
772 /// datagrams). Recommended values: 1-3 additional workers per CPU
773 /// core.
774 pub extra_recv_threads: usize,
775
776 /// D.5g — default DataRepresentation list announced in SEDP
777 /// PublicationData and SEDP SubscriptionData, when not overridden
778 /// per-writer/reader (UserWriterConfig/UserReaderConfig).
779 ///
780 /// **Important**: per strict spec (XTypes 1.3 §7.6.3.1.2) the first
781 /// element is the writer's "offered" and must be in the reader's
782 /// "accepted" list for a match to happen. Default `[XCDR1, XCDR2]` =
783 /// legacy-first → max interop with the RTI Connext Shapes Demo
784 /// (XCDR1-only). Pure-XCDR2 deployments can switch this to `[XCDR2]`
785 /// or `[XCDR2, XCDR1]` for bandwidth efficiency and
786 /// @appendable/@mutable support.
787 ///
788 /// Empty (`vec![]`) is interpreted per spec as `[XCDR1]`.
789 pub data_representation_offer: Vec<i16>,
790
791 /// D.5g — default match mode for DataRepresentation negotiation.
792 ///
793 /// `Strict` (XTypes 1.3 §7.6.3.1.2 normative): writer.first ∈
794 /// reader.list = match. `Tolerant` (industry norm): any overlap =
795 /// match, picks the first overlap as the wire format.
796 ///
797 /// Default `Tolerant` because Cyclone DDS and FastDDS match this way —
798 /// maximizes interop. The strict setting is only meaningful for
799 /// formal spec-compliance tests.
800 pub data_rep_match_mode: zerodds_rtps::publication_data::data_representation::DataRepMatchMode,
801
802 /// zerodds-async-1.0 §4 — when `true`, `start()` does **not** spawn the
803 /// dedicated `zdds-tick` std::thread. The periodic tick (SPDP announce,
804 /// SEDP/WLP, deadline/lifespan/liveliness) must then be driven externally
805 /// via [`DcpsRuntime::tick_driver`]. Used by the async API's
806 /// `spawn_in_tokio`, which multiplexes many participants' tick loops onto
807 /// a tokio runtime instead of one thread each. Default `false` (internal
808 /// thread, unchanged behaviour). The recv worker threads are unaffected —
809 /// they block on socket recv and stay regardless.
810 pub external_tick: bool,
811
812 /// D.5e Phase 3 — when `true`, `start()` drives the periodic tick via the
813 /// event-driven deadline scheduler ([`crate::scheduler`]) instead of the
814 /// fixed-`tick_period` poll: the worker parks until the next due deadline
815 /// (SPDP announce, or a fine floor while user endpoints/QoS timers are
816 /// active) or until a write/recv `raise` wakes it — no busy-poll, lower idle
817 /// CPU, lower tail latency. The work done per wake is the **unchanged**
818 /// `run_tick_iteration` (identical wire output + cadence — cross-vendor
819 /// safe). **Default `true`** since D.5e Phase C (2026-06-14) — set
820 /// `ZERODDS_SCHEDULER_TICK=0` or this field to `false` for the classic
821 /// fixed-period `tick_loop`. Mutually exclusive with `external_tick`
822 /// (external wins).
823 pub scheduler_tick: bool,
824}
825
826/// Configuration entry for a physical or logical network interface.
827///
828/// A binding describes an outbound socket: which IP/port it binds to,
829/// which `NetInterface` class the interface represents, and which IP
830/// range counts as "associated peers" (routing match).
831#[cfg(feature = "security")]
832#[derive(Clone, Debug)]
833pub struct InterfaceBindingSpec {
834 /// Name for diagnostics + log attribution (e.g. `"eth0"`, `"tun0"`,
835 /// `"lo"`).
836 pub name: String,
837 /// Bind address. `0.0.0.0` leaves the interface to the kernel.
838 pub bind_addr: Ipv4Addr,
839 /// Bind port. `0` = ephemeral.
840 pub bind_port: u16,
841 /// Interface class — feeds into the PolicyEngine context.
842 pub kind: NetInterface,
843 /// Destination IP range this binding is responsible for. Example:
844 /// `127.0.0.0/8` for loopback. A target whose IP lies in this range is
845 /// routed to this binding.
846 pub subnet: IpRange,
847 /// If `true`: this binding is used when **no** other subnet match
848 /// applies. Exactly one entry should have `default = true` (usually
849 /// the WAN binding).
850 pub default: bool,
851}
852
853/// Fully bound interface with its UDP socket.
854#[cfg(feature = "security")]
855struct InterfaceBinding {
856 spec: InterfaceBindingSpec,
857 socket: Arc<UdpTransport>,
858}
859
860/// Pool of per-interface UDP sockets with target-based routing.
861///
862/// Decision:
863/// 1. Iterates over all bindings; the first whose subnet contains the
864/// target wins.
865/// 2. If no match and a default binding exists → default path.
866/// 3. No match + no default → `None`, the caller drops.
867#[cfg(feature = "security")]
868struct OutboundSocketPool {
869 bindings: Vec<InterfaceBinding>,
870 default_idx: Option<usize>,
871}
872
873#[cfg(feature = "security")]
874impl OutboundSocketPool {
875 fn bind_all(specs: &[InterfaceBindingSpec]) -> Result<Self> {
876 let mut bindings = Vec::with_capacity(specs.len());
877 for spec in specs {
878 let socket = UdpTransport::bind_v4(spec.bind_addr, spec.bind_port).map_err(|_| {
879 DdsError::TransportError {
880 label: "interface-binding bind_v4 failed",
881 }
882 })?;
883 // Short read timeout so that the per-interface inbound poll in
884 // the event loop becomes non-blocking. 5 ms is small enough not
885 // to create latency elsewhere (the tick period defaults to
886 // 50 ms), but large enough to amortize context switches.
887 let socket = socket
888 .with_timeout(Some(Duration::from_millis(5)))
889 .map_err(|_| DdsError::TransportError {
890 label: "interface-binding set_timeout failed",
891 })?;
892 bindings.push(InterfaceBinding {
893 spec: spec.clone(),
894 socket: Arc::new(socket),
895 });
896 }
897 let default_idx = bindings.iter().position(|b| b.spec.default);
898 Ok(Self {
899 bindings,
900 default_idx,
901 })
902 }
903
904 /// Returns `(socket, NetInterface class)` for a destination locator.
905 /// `None` if neither a subnet match nor a default binding exists.
906 fn route(&self, target: &Locator) -> Option<(&Arc<UdpTransport>, NetInterface)> {
907 let ip = ipv4_from_locator(target)?;
908 let addr = core::net::IpAddr::V4(core::net::Ipv4Addr::from(ip));
909 for b in &self.bindings {
910 if b.spec.subnet.contains(&addr) {
911 return Some((&b.socket, b.spec.kind.clone()));
912 }
913 }
914 let idx = self.default_idx?;
915 let b = self.bindings.get(idx)?;
916 Some((&b.socket, b.spec.kind.clone()))
917 }
918}
919
920/// True if the locator is routable over the user-data transport
921/// (trait object). Accepts UDPv4, UDPv6, TCPv4, Shm. The concrete
922/// transport (UdpTransport/TcpTransport/ShmUserTransport) then returns
923/// `UnsupportedLocator` for kinds it does not itself speak;
924/// the filter here only prevents sending to clearly non-IP/SHM
925/// locators like UDS (for which we have no transport plugin).
926fn is_routable_user_locator(loc: &Locator) -> bool {
927 matches!(
928 loc.kind,
929 LocatorKind::UdpV4
930 | LocatorKind::UdpV6
931 | LocatorKind::Tcpv4
932 | LocatorKind::Tcpv6
933 | LocatorKind::Shm
934 | LocatorKind::Uds
935 | LocatorKind::Tsn
936 )
937}
938
939/// Computes the user-endpoint `EndpointSecurityInfo` mask from the governance
940/// protection kinds (DDS-Security 1.2 §10.4.1.2.6 / §9.4.2.4). The wire mask
941/// MUST match cyclone/FastDDS/OpenDDS byte-exactly, otherwise the peer rejects
942/// the endpoint match with "security_attributes mismatch".
943///
944/// - metadata=SIGN/ENCRYPT → IS_SUBMESSAGE_PROTECTED (+ plugin SUBMESSAGE_ENCRYPTED on ENCRYPT)
945/// - data=SIGN → IS_PAYLOAD_PROTECTED
946/// - data=ENCRYPT → IS_PAYLOAD_PROTECTED | **IS_KEY_PROTECTED** (+ plugin PAYLOAD_ENCRYPTED)
947/// - liveliness=SIGN/ENCRYPT → **IS_LIVELINESS_PROTECTED** (§9.4.1.3: per-endpoint!)
948/// - topic enable_discovery_protection → IS_DISCOVERY_PROTECTED
949///
950/// is_key_protected follows §10.4.1.2.6 exclusively from the **DATA** protection
951/// and only on ENCRYPT — NOT from the metadata protection. is_liveliness_protected
952/// in contrast MUST be on every user endpoint as soon as liveliness_protection is active;
953/// cyclone compares the mask at endpoint match and otherwise rejects with
954/// "security_attributes mismatch" (0x..30 vs 0x..70).
955#[cfg(feature = "security")]
956fn compute_user_endpoint_attrs(
957 meta: ProtectionLevel,
958 data: ProtectionLevel,
959 discovery_protected: bool,
960 liveliness_protected: bool,
961 read_protected: bool,
962 write_protected: bool,
963) -> zerodds_rtps::endpoint_security_info::EndpointSecurityInfo {
964 use zerodds_rtps::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
965 let mut a = attrs::IS_VALID;
966 let mut p = plugin_attrs::IS_VALID;
967 if read_protected {
968 a |= attrs::IS_READ_PROTECTED;
969 }
970 if write_protected {
971 a |= attrs::IS_WRITE_PROTECTED;
972 }
973 if meta != ProtectionLevel::None {
974 a |= attrs::IS_SUBMESSAGE_PROTECTED;
975 }
976 if meta == ProtectionLevel::Encrypt {
977 p |= plugin_attrs::IS_SUBMESSAGE_ENCRYPTED;
978 }
979 if data != ProtectionLevel::None {
980 a |= attrs::IS_PAYLOAD_PROTECTED;
981 }
982 if data == ProtectionLevel::Encrypt {
983 a |= attrs::IS_KEY_PROTECTED;
984 p |= plugin_attrs::IS_PAYLOAD_ENCRYPTED;
985 }
986 if discovery_protected {
987 a |= attrs::IS_DISCOVERY_PROTECTED;
988 }
989 if liveliness_protected {
990 a |= attrs::IS_LIVELINESS_PROTECTED;
991 }
992 EndpointSecurityInfo {
993 endpoint_security_attributes: a,
994 plugin_endpoint_security_attributes: p,
995 }
996}
997
998#[cfg(all(test, feature = "security"))]
999mod endpoint_attr_tests {
1000 use super::compute_user_endpoint_attrs;
1001 use zerodds_rtps::endpoint_security_info::attrs;
1002 use zerodds_security_runtime::ProtectionLevel;
1003
1004 fn mask(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
1005 compute_user_endpoint_attrs(meta, data, false, false, false, false)
1006 .endpoint_security_attributes
1007 }
1008
1009 fn mask_liv(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
1010 compute_user_endpoint_attrs(meta, data, false, true, false, false)
1011 .endpoint_security_attributes
1012 }
1013
1014 #[test]
1015 fn liveliness_protected_sets_0x40_per_spec_9_4_1_3() {
1016 use ProtectionLevel::{Encrypt, None};
1017 let v = attrs::IS_VALID;
1018 let pay = attrs::IS_PAYLOAD_PROTECTED;
1019 let key = attrs::IS_KEY_PROTECTED;
1020 let liv = attrs::IS_LIVELINESS_PROTECTED;
1021 // liveliness=ENCRYPT + data=ENCRYPT → 0x..70 (cyclone's value at match).
1022 assert_eq!(mask_liv(None, Encrypt), v | pay | key | liv);
1023 // without liveliness → 0x..30, NO 0x40.
1024 assert_eq!(mask(None, Encrypt), v | pay | key);
1025 assert_eq!(mask_liv(None, None), v | liv);
1026 }
1027
1028 #[test]
1029 fn key_protected_follows_data_encrypt_per_spec_10_4_1_2_6() {
1030 use ProtectionLevel::{Encrypt, None, Sign};
1031 let v = attrs::IS_VALID;
1032 let sub = attrs::IS_SUBMESSAGE_PROTECTED;
1033 let pay = attrs::IS_PAYLOAD_PROTECTED;
1034 let key = attrs::IS_KEY_PROTECTED;
1035 // §10.4.1.2.6: is_key_protected follows ONLY data=ENCRYPT.
1036 // data=ENCRYPT → PAYLOAD|KEY (= cyclone's 0x30 in the common subset).
1037 assert_eq!(mask(None, Encrypt), v | pay | key);
1038 // data=SIGN → PAYLOAD, NO KEY.
1039 assert_eq!(mask(None, Sign), v | pay);
1040 // data=NONE → no payload/key bits.
1041 assert_eq!(mask(None, None), v);
1042 // KEY does NOT depend on metadata: meta=ENCRYPT/data=NONE → only SUBMESSAGE.
1043 assert_eq!(mask(Encrypt, None), v | sub);
1044 // meta=ENCRYPT + data=ENCRYPT → SUBMESSAGE|PAYLOAD|KEY (0x38).
1045 assert_eq!(mask(Encrypt, Encrypt), v | sub | pay | key);
1046 }
1047}
1048
1049/// Unicast targets for the WLP heartbeat fan-out (M-2): per discovered peer the
1050/// `metatraffic_unicast_locator` (fallback `default_unicast_locator`), filtered
1051/// to routable kinds. WLP is metatraffic (DDSI-RTPS §8.4.13); in multicast-
1052/// free environments (container/cloud) the pure multicast pulse never reaches the
1053/// peer reader → the lease expires although the peer is alive. The additional
1054/// unicast fan-out follows the SEDP locator model.
1055fn wlp_unicast_targets(peers: &[zerodds_discovery::spdp::DiscoveredParticipant]) -> Vec<Locator> {
1056 peers
1057 .iter()
1058 .filter_map(|dp| {
1059 dp.data
1060 .metatraffic_unicast_locator
1061 .or(dp.data.default_unicast_locator)
1062 })
1063 .filter(is_routable_user_locator)
1064 .collect()
1065}
1066
1067/// Extracts the IPv4 address from a `Locator` (UDP-V4).
1068/// `None` for SHM/UDS/IPv6.
1069#[cfg(feature = "security")]
1070fn ipv4_from_locator(loc: &Locator) -> Option<[u8; 4]> {
1071 if loc.kind != LocatorKind::UdpV4 {
1072 return None;
1073 }
1074 Some([
1075 loc.address[12],
1076 loc.address[13],
1077 loc.address[14],
1078 loc.address[15],
1079 ])
1080}
1081
1082impl core::fmt::Debug for RuntimeConfig {
1083 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1084 let mut dbg = f.debug_struct("RuntimeConfig");
1085 dbg.field("tick_period", &self.tick_period)
1086 .field("spdp_period", &self.spdp_period)
1087 .field("spdp_multicast_group", &self.spdp_multicast_group)
1088 .field("multicast_interface", &self.multicast_interface);
1089 #[cfg(feature = "security")]
1090 {
1091 dbg.field("security", &self.security.as_ref().map(|_| "<gate>"));
1092 dbg.field(
1093 "security_logger",
1094 &self.security_logger.as_ref().map(|_| "<logger>"),
1095 );
1096 }
1097 dbg.finish()
1098 }
1099}
1100
1101impl Default for RuntimeConfig {
1102 fn default() -> Self {
1103 // Env hook for bench tuning: ZERODDS_TICK_PERIOD_MS=N → overrides
1104 // the 5ms default. High (e.g. 1000) relieves the write hot path of
1105 // the periodic HB/tick overhead and makes spread spikes from tick
1106 // preemption visible. Production: do not set; the default 5 ms is
1107 // spec-compliant.
1108 let tick = std::env::var("ZERODDS_TICK_PERIOD_MS")
1109 .ok()
1110 .and_then(|s| s.parse::<u64>().ok())
1111 .map(Duration::from_millis)
1112 .unwrap_or(DEFAULT_TICK_PERIOD);
1113 // C3 WiFi-robust discovery — initial-announcement burst. Env overrides:
1114 // `ZERODDS_INITIAL_ANNOUNCE_COUNT` (0 disables) +
1115 // `ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS`.
1116 let initial_announce_count = std::env::var("ZERODDS_INITIAL_ANNOUNCE_COUNT")
1117 .ok()
1118 .and_then(|s| s.parse::<u32>().ok())
1119 .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_COUNT);
1120 let initial_announce_period = std::env::var("ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS")
1121 .ok()
1122 .and_then(|s| s.parse::<u64>().ok())
1123 .map(Duration::from_millis)
1124 .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_PERIOD);
1125 Self {
1126 tick_period: tick,
1127 spdp_period: DEFAULT_SPDP_PERIOD,
1128 initial_announce_count,
1129 initial_announce_period,
1130 // Env override `ZERODDS_SPDP_MC_GROUP` (IPv4) of the SPDP
1131 // multicast group. Two processes with different groups do NOT
1132 // see each other via multicast → enables a multicast-free C1
1133 // e2e proof (discovery then only via ZERODDS_PEERS). Default is
1134 // the spec group.
1135 spdp_multicast_group: std::env::var("ZERODDS_SPDP_MC_GROUP")
1136 .ok()
1137 .and_then(|s| s.parse::<Ipv4Addr>().ok())
1138 .unwrap_or_else(|| Ipv4Addr::from(SPDP_DEFAULT_MULTICAST_ADDRESS)),
1139 // Interface pinning (Cyclone `NetworkInterface`/FastDDS
1140 // whitelist equivalent): `ZERODDS_INTERFACE=<ipv4>` forces
1141 // announce + bind on this interface. Default UNSPECIFIED = auto
1142 // (route probe). Critical on multi-homed hosts (VPN/Docker/
1143 // macOS bridge100), where the auto choice may announce the
1144 // wrong interface.
1145 multicast_interface: std::env::var("ZERODDS_INTERFACE")
1146 .ok()
1147 .and_then(|s| s.parse::<Ipv4Addr>().ok())
1148 .unwrap_or(Ipv4Addr::UNSPECIFIED),
1149 // Opt-in multicast allowlist. Empty (unset) = no restriction.
1150 multicast_allowlist: std::env::var("ZERODDS_MULTICAST_ALLOWLIST")
1151 .ok()
1152 .map(|s| {
1153 s.split(',')
1154 .filter_map(|t| t.trim().parse::<Ipv4Addr>().ok())
1155 .collect()
1156 })
1157 .unwrap_or_default(),
1158 // Multicast send on by default; `ZERODDS_NO_MULTICAST` (any
1159 // non-empty value) turns it off → pure unicast discovery.
1160 spdp_multicast_send: std::env::var("ZERODDS_NO_MULTICAST")
1161 .map(|v| v.is_empty())
1162 .unwrap_or(true),
1163 // A1: off by default; env `ZERODDS_DISCOVERY_SERVER` (any non-empty
1164 // value) or `RuntimeConfig::discovery_server()` turns the relay on.
1165 discovery_server: std::env::var("ZERODDS_DISCOVERY_SERVER")
1166 .map(|v| !v.is_empty())
1167 .unwrap_or(false),
1168 // C3: 16 MiB default (suitable for ROS PointCloud2/Image),
1169 // env override `ZERODDS_MAX_SAMPLE_BYTES`.
1170 max_reassembly_sample_bytes: std::env::var("ZERODDS_MAX_SAMPLE_BYTES")
1171 .ok()
1172 .and_then(|s| s.parse::<usize>().ok())
1173 .unwrap_or(16 * 1024 * 1024),
1174 // Programmatic default empty. The env `ZERODDS_PEERS` is
1175 // expanded domain-aware only in `DcpsRuntime::start` and merged
1176 // with this field into the effective peer list.
1177 initial_peers: Vec::new(),
1178 user_transport: None,
1179 user_transports: alloc::vec::Vec::new(),
1180 #[cfg(feature = "security")]
1181 security: None,
1182 #[cfg(feature = "security")]
1183 security_logger: None,
1184 #[cfg(feature = "security")]
1185 interface_bindings: Vec::new(),
1186 announce_secure_endpoints: false,
1187 // Env hook for bench/FastDDS interop: ZERODDS_SECURE_SPDP=1 turns
1188 // on the reliable secure SPDP channel (0xff0101). Production sets this
1189 // explicitly via the SecurityProfile/config.
1190 enable_secure_spdp: std::env::var("ZERODDS_SECURE_SPDP").ok().as_deref() == Some("1"),
1191 wlp_period: Duration::ZERO,
1192 participant_lease_duration: Duration::from_secs(100),
1193 user_data: Vec::new(),
1194 observability: zerodds_foundation::observability::null_sink(),
1195 recv_thread_priority: None,
1196 tick_thread_priority: None,
1197 recv_thread_cpus: None,
1198 tick_thread_cpus: None,
1199 extra_recv_threads: 0,
1200 // D.5g — default `[XCDR1, XCDR2]` (legacy-first, max interop).
1201 // Env-var override `ZERODDS_DATA_REPR_OFFER` as a comma list
1202 // ("XCDR1", "XCDR2", "XCDR1,XCDR2", "XCDR2,XCDR1"). Cross-vendor
1203 // benches against strict-matching vendors (RTI) need XCDR2-only
1204 // so that every wire match happens.
1205 data_representation_offer: parse_data_repr_offer_env().unwrap_or_else(|| {
1206 zerodds_rtps::publication_data::data_representation::DEFAULT_OFFER.to_vec()
1207 }),
1208 data_rep_match_mode:
1209 zerodds_rtps::publication_data::data_representation::DataRepMatchMode::default(),
1210 external_tick: false,
1211 // D.5e Phase 3 — the event-driven deadline-heap scheduler is the
1212 // DEFAULT tick (Phase C, 2026-06-14): it parks until the next due
1213 // deadline / a write-recv raise instead of polling every 5 ms (~17×
1214 // fewer idle iterations, lower tail latency, identical wire output).
1215 // Verified cross-vendor secured (data-enc + rtps-enc all pairs) +
1216 // same_host_e2e + latency_assertions on codepit. Escape hatch:
1217 // `ZERODDS_SCHEDULER_TICK=0` restores the classic fixed-period
1218 // `tick_loop`.
1219 scheduler_tick: std::env::var("ZERODDS_SCHEDULER_TICK")
1220 .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
1221 .unwrap_or(true),
1222 }
1223 }
1224}
1225
1226impl RuntimeConfig {
1227 /// Whether `group` may be joined/used for multicast under this config. An
1228 /// empty [`Self::multicast_allowlist`] allows every group (the opt-in is
1229 /// off); a non-empty list allows only its members.
1230 #[must_use]
1231 pub fn multicast_allowed(&self, group: Ipv4Addr) -> bool {
1232 self.multicast_allowlist.is_empty() || self.multicast_allowlist.contains(&group)
1233 }
1234
1235 /// Apply a [`SecurityBundle`](zerodds_security_runtime::SecurityBundle):
1236 /// wires its security-event logger into [`Self::security_logger`] and, if
1237 /// the bundle carries a [`SecurityProfile`](zerodds_security_runtime::SecurityProfile),
1238 /// its gate into [`Self::security`]. Convenience for the common
1239 /// `SecurityBundle::builder()…build()` flow so callers don't have to set
1240 /// the two fields by hand.
1241 #[cfg(feature = "security")]
1242 #[must_use]
1243 pub fn with_security_bundle(
1244 mut self,
1245 bundle: &zerodds_security_runtime::SecurityBundle,
1246 ) -> Self {
1247 if let Some(logger) = bundle.logging_plugin() {
1248 self.security_logger = Some(logger);
1249 }
1250 if let Some(profile) = bundle.security_profile() {
1251 self.security = Some(profile.gate.clone());
1252 }
1253 self
1254 }
1255
1256 /// Materialize a security-event logger from `dds.sec.log.*` properties and
1257 /// wire it into [`Self::security_logger`]. This is the DDS-Security
1258 /// spec-style alternative to handing a logger object in directly (see
1259 /// [`Self::with_security_bundle`]): the participant carries
1260 /// `dds.sec.log.plugin = "stderr,jsonl"` (+ `dds.sec.log.*` parameters) on
1261 /// its [`PropertyQosPolicy`](zerodds_qos::PropertyQosPolicy), and the
1262 /// runtime builds the fan-out logger from them.
1263 ///
1264 /// No-op when `dds.sec.log.plugin` is absent. Errors if a selected sink is
1265 /// misconfigured (e.g. `jsonl` without `dds.sec.log.jsonl.path`).
1266 #[cfg(feature = "security")]
1267 pub fn with_security_log_properties(
1268 mut self,
1269 property: &zerodds_qos::PropertyQosPolicy,
1270 ) -> core::result::Result<Self, zerodds_security_logging::LogConfigError> {
1271 let pairs: alloc::vec::Vec<(&str, &str)> = property.iter().collect();
1272 if let Some(logger) = zerodds_security_logging::logging_plugin_from_properties(&pairs)? {
1273 self.security_logger = Some(alloc::sync::Arc::from(logger));
1274 }
1275 Ok(self)
1276 }
1277
1278 /// C4: robotics-capable defaults for **out-of-the-box ROS-2 interop**.
1279 /// Saves the manual env tuning otherwise needed for real ROS-2 nodes.
1280 /// Specifically, compared to [`RuntimeConfig::default`]:
1281 /// - **`data_representation_offer = [XCDR1, XCDR2]`**: `rmw_cyclonedds`/
1282 /// `rmw_fastrtps` write XCDR1 for final/simple types (e.g.
1283 /// `std_msgs/String`). An XCDR2-only reader does not match an XCDR1
1284 /// writer — so the ROS reader here offers both legacy-first
1285 /// (tolerant match is already the default). This is the clean,
1286 /// ROS-specific variant of the `ZERODDS_DATA_REPR_OFFER` env
1287 /// workaround, WITHOUT changing the global `DEFAULT_OFFER`
1288 /// (XCDR2-only, deliberately for FastDDS/OpenDDS XCDR2 readers).
1289 ///
1290 /// The ROS-realistic reassembly cap (16 MiB, PointCloud2/Image) is
1291 /// already the global default and is carried over here.
1292 #[must_use]
1293 pub fn ros_defaults() -> Self {
1294 use zerodds_rtps::publication_data::data_representation as dr;
1295 Self {
1296 data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1297 ..Self::default()
1298 }
1299 }
1300
1301 /// C6 multi-robot / WAN / cross-subnet profile.
1302 ///
1303 /// A named profile for fleets that span subnets, the cloud, or WiFi —
1304 /// environments that drop IP multicast, so SPDP discovery cannot rely on
1305 /// the multicast beacon. It is the [`ros_defaults`](Self::ros_defaults)
1306 /// representation offer **plus**:
1307 ///
1308 /// - **Multicast-free discovery** (`spdp_multicast_send = false`):
1309 /// participants find each other purely through unicast initial peers,
1310 /// regardless of the `ZERODDS_NO_MULTICAST` env. Set the peers via
1311 /// `ZERODDS_PEERS` (a comma list of `ip` or `ip:port`); a port-less
1312 /// `ip` is expanded to the well-known SPDP unicast ports of the first
1313 /// N participant indices (`ZERODDS_MAX_PEER_PARTICIPANTS`).
1314 /// - **WAN-tolerant liveliness**: a longer participant lease (300 s vs
1315 /// the 100 s spec default) so transient cross-subnet RTT spikes or
1316 /// brief link drops do not trigger a false liveliness loss.
1317 ///
1318 /// **Domain isolation** is the caller's lever: pass a fleet-dedicated
1319 /// `domain_id` to [`DcpsRuntime::start`] to keep robots off the default
1320 /// domain 0. The profile deliberately does not pick a domain for you.
1321 ///
1322 /// ```
1323 /// use zerodds_dcps::runtime::RuntimeConfig;
1324 /// let cfg = RuntimeConfig::multi_robot();
1325 /// assert!(!cfg.spdp_multicast_send); // unicast-only discovery
1326 /// ```
1327 pub fn multi_robot() -> Self {
1328 use zerodds_rtps::publication_data::data_representation as dr;
1329 Self {
1330 data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1331 spdp_multicast_send: false,
1332 participant_lease_duration: Duration::from_secs(300),
1333 ..Self::default()
1334 }
1335 }
1336
1337 /// A1 — **discovery-server** profile (the server side). Multicast-free, with
1338 /// the SPDP relay on ([`Self::discovery_server`]): clients point their
1339 /// `initial_peers`/`ZERODDS_PEERS` at this one well-known address and the
1340 /// server bridges their participant discovery, so N clients find each other
1341 /// without an O(N²) peer list or multicast. SEDP (incl. ROS-2 Action
1342 /// endpoints) stays direct peer-to-peer — no SEDP proxy, no Action breakage.
1343 ///
1344 /// Clients run with [`RuntimeConfig::multi_robot`] (or any multicast-free
1345 /// config) and set `ZERODDS_PEERS` to the server's address.
1346 ///
1347 /// ```
1348 /// use zerodds_dcps::runtime::RuntimeConfig;
1349 /// let server = RuntimeConfig::discovery_server();
1350 /// assert!(server.discovery_server);
1351 /// assert!(!server.spdp_multicast_send); // unicast-only
1352 /// ```
1353 pub fn discovery_server() -> Self {
1354 Self {
1355 discovery_server: true,
1356 ..Self::multi_robot()
1357 }
1358 }
1359}
1360
1361/// Parse the `ZERODDS_DATA_REPR_OFFER` env var. Values: "XCDR1", "XCDR2",
1362/// or a comma list. None if the env var is missing or invalid.
1363fn parse_data_repr_offer_env() -> Option<Vec<i16>> {
1364 let s = std::env::var("ZERODDS_DATA_REPR_OFFER").ok()?;
1365 parse_data_repr_offer_str(&s)
1366}
1367
1368/// Computes the **well-known** SPDP unicast discovery port for a
1369/// domain + participant index. Formula (DDSI-RTPS 2.5 §9.6.1.4.1):
1370/// port = PB + DG·domain + d1 + PG·pid = 7400 + 250·domain + 10 + 2·pid
1371///
1372/// This lets a configured unicast initial peer (multicast-free discovery)
1373/// reach a participant deterministically WITHOUT having found it via
1374/// multicast first. Defined locally in `dcps` to avoid touching
1375/// `crates/rtps` (spec constants as literals).
1376#[must_use]
1377fn spdp_unicast_port(domain_id: u32, participant_id: u32) -> u32 {
1378 7400 + 250 * domain_id + 10 + 2 * participant_id
1379}
1380
1381/// Default number of participant indices a port-less initial peer is
1382/// expanded to (Cyclone equivalent: `MaxAutoParticipantIndex`). The
1383/// beacon thereby reaches the first N participants of the peer host via
1384/// their well-known SPDP unicast ports. Overridable via the env
1385/// `ZERODDS_MAX_PEER_PARTICIPANTS` (e.g. for dense multi-robot / >10
1386/// participants-per-host scenarios). Cap 120 (= the well-known-port
1387/// allocation window).
1388const INITIAL_PEER_MAX_PARTICIPANTS: u32 = 10;
1389
1390/// Effective peer-expansion limit: env `ZERODDS_MAX_PEER_PARTICIPANTS`
1391/// or [`INITIAL_PEER_MAX_PARTICIPANTS`], clamped to 1..=120.
1392fn initial_peer_max_participants() -> u32 {
1393 std::env::var("ZERODDS_MAX_PEER_PARTICIPANTS")
1394 .ok()
1395 .and_then(|s| s.parse::<u32>().ok())
1396 .unwrap_or(INITIAL_PEER_MAX_PARTICIPANTS)
1397 .clamp(1, 120)
1398}
1399
1400/// C1 multicast-free discovery: parses the env `ZERODDS_PEERS` (comma
1401/// list of `ip` or `ip:port`) into SPDP unicast initial-peer locators for
1402/// `domain_id`. Empty/invalid → empty list.
1403fn parse_initial_peers_env(domain_id: u32) -> Vec<Locator> {
1404 let mut out = Vec::new();
1405 let max = initial_peer_max_participants();
1406 if let Ok(s) = std::env::var("ZERODDS_PEERS") {
1407 for entry in s.split(',') {
1408 expand_initial_peer(entry.trim(), domain_id, max, &mut out);
1409 }
1410 }
1411 out
1412}
1413
1414/// Expands a single peer spec into locator(s) and appends them to `out`.
1415/// `ip:port` → exact locator. Just `ip` → well-known SPDP unicast ports
1416/// of participant indices `0..max_participants` (Spec §9.6.1.4.1).
1417/// Invalid specs are ignored.
1418fn expand_initial_peer(spec: &str, domain_id: u32, max_participants: u32, out: &mut Vec<Locator>) {
1419 if spec.is_empty() {
1420 return;
1421 }
1422 if let Some((ip_s, port_s)) = spec.rsplit_once(':') {
1423 if let (Ok(ip), Ok(port)) = (ip_s.parse::<Ipv4Addr>(), port_s.parse::<u16>()) {
1424 out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1425 return;
1426 }
1427 }
1428 if let Ok(ip) = spec.parse::<Ipv4Addr>() {
1429 for pid in 0..max_participants {
1430 if let Ok(port) = u16::try_from(spdp_unicast_port(domain_id, pid)) {
1431 out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1432 }
1433 }
1434 }
1435}
1436
1437/// Pure parser for the `ZERODDS_DATA_REPR_OFFER` syntax (testable without
1438/// env). Returns the DataRepresentationId list with the **spec values**
1439/// `XCDR=0`, `XCDR2=2` (XTypes 1.3 §7.6.3.1.2) — NOT version numbers.
1440/// `None` on empty/invalid input.
1441fn parse_data_repr_offer_str(s: &str) -> Option<Vec<i16>> {
1442 use zerodds_rtps::publication_data::data_representation as dr;
1443 let mut out = Vec::new();
1444 for tok in s.split(',').map(str::trim) {
1445 let v = match tok.to_ascii_uppercase().as_str() {
1446 "XCDR1" | "XCDR" | "1" => dr::XCDR,
1447 "XCDR2" | "2" => dr::XCDR2,
1448 _ => return None,
1449 };
1450 out.push(v);
1451 }
1452 if out.is_empty() { None } else { Some(out) }
1453}
1454
1455// ---------------------------------------------------------------------------
1456// Security-gate helpers
1457// ---------------------------------------------------------------------------
1458
1459/// Pull outbound UDP bytes through the security gate (when configured).
1460/// Without the `security` feature or without a gate: pass-through (clone
1461/// as Vec).
1462///
1463/// Errors in the gate are logged silently and the packet is **not** sent —
1464/// better to drop than leak plaintext.
1465/// DDS-Security 8.4.2.4: the RTPS message protection (message-level SRTPS)
1466/// does NOT apply to bootstrap traffic that must flow BEFORE the participant
1467/// crypto-key exchange: SPDP (participant discovery, to everyone) and the
1468/// ParticipantStatelessMessage (auth handshake). Wrapping them in SRTPS would
1469/// mean a not-yet-authenticated peer could not decrypt them
1470/// (no key) -> discovery/auth breaks (match timeout pub=0 sub=0). Detection
1471/// via the writer EntityId of the DATA/DATA_FRAG submessages.
1472#[cfg(feature = "security")]
1473fn rtps_message_protection_exempt(
1474 bytes: &[u8],
1475 discovery_plain: bool,
1476 liveliness_plain: bool,
1477) -> bool {
1478 use zerodds_rtps::wire_types::EntityId;
1479 // Bootstrap endpoints (§8.4.2.4): SPDP/Stateless/Volatile ALWAYS flow
1480 // plain (before/during key exchange resp. their own submessage protection).
1481 // Discovery plane (SEDP pub/sub, TypeLookup) is plain when discovery_
1482 // protection_kind=NONE; WLP (ParticipantMessage) plain when liveliness_
1483 // protection_kind=NONE. cyclone<->cyclone reference capture: under rtps_
1484 // protection=ENCRYPT + discovery=NONE cyclone sends the ENTIRE discovery
1485 // plane (DATA+HEARTBEAT+ACKNACK) PLAINTEXT — only user DATA is SRTPS-
1486 // wrapped. ZeroDDS must mirror this, otherwise it drops cyclone's plain
1487 // SubscriptionData as legacy_blocked -> no user-endpoint match.
1488 let entity_exempt = |e: EntityId| -> bool {
1489 matches!(
1490 e,
1491 EntityId::SPDP_BUILTIN_PARTICIPANT_WRITER
1492 | EntityId::SPDP_BUILTIN_PARTICIPANT_READER
1493 | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
1494 | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
1495 | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
1496 | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
1497 ) || (discovery_plain
1498 && matches!(
1499 e,
1500 EntityId::SEDP_BUILTIN_PUBLICATIONS_WRITER
1501 | EntityId::SEDP_BUILTIN_PUBLICATIONS_READER
1502 | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_WRITER
1503 | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_READER
1504 | EntityId::TL_SVC_REQ_WRITER
1505 | EntityId::TL_SVC_REQ_READER
1506 | EntityId::TL_SVC_REPLY_WRITER
1507 | EntityId::TL_SVC_REPLY_READER
1508 ))
1509 || (liveliness_plain
1510 && matches!(
1511 e,
1512 EntityId::BUILTIN_PARTICIPANT_MESSAGE_WRITER
1513 | EntityId::BUILTIN_PARTICIPANT_MESSAGE_READER
1514 ))
1515 };
1516 let Ok(parsed) = decode_datagram(bytes) else {
1517 return false;
1518 };
1519 // Datagram exempt if it has at least one relevant submessage AND
1520 // ALL relevant ones are exempt (.all) — otherwise a bundled
1521 // exempt+non-exempt datagram leaks the protection-worthy submessage.
1522 let relevant: alloc::vec::Vec<bool> = parsed
1523 .submessages
1524 .iter()
1525 .filter_map(|sm| match sm {
1526 ParsedSubmessage::Data(d) => {
1527 Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1528 }
1529 ParsedSubmessage::DataFrag(d) => {
1530 Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1531 }
1532 ParsedSubmessage::Heartbeat(h) => {
1533 Some(entity_exempt(h.reader_id) || entity_exempt(h.writer_id))
1534 }
1535 ParsedSubmessage::AckNack(a) => {
1536 Some(entity_exempt(a.reader_id) || entity_exempt(a.writer_id))
1537 }
1538 ParsedSubmessage::Gap(g) => {
1539 Some(entity_exempt(g.reader_id) || entity_exempt(g.writer_id))
1540 }
1541 ParsedSubmessage::NackFrag(n) => {
1542 Some(entity_exempt(n.reader_id) || entity_exempt(n.writer_id))
1543 }
1544 // SEC_PREFIX (Kx-Volatile, inner writer-id encrypted) -> exempt.
1545 ParsedSubmessage::Unknown { id: 0x31, .. } => Some(true),
1546 // Framing (INFO_DST/INFO_TS/...) -> neutral.
1547 _ => None,
1548 })
1549 .collect();
1550 !relevant.is_empty() && relevant.iter().all(|&b| b)
1551}
1552
1553#[cfg(feature = "security")]
1554fn secure_outbound_bytes<'a>(
1555 rt: &DcpsRuntime,
1556 bytes: &'a [u8],
1557) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1558 match &rt.config.security {
1559 // OUTBOUND is spec-strict (DDS-Security 8.4.2.4 Table 27 is_rtps_protected):
1560 // under rtps_protection the ENTIRE RTPS message is SRTPS-wrapped; ONLY the
1561 // "separate messages" (SPDP/Stateless/Volatile) flow plain. SEDP/WLP/
1562 // TypeLookup are NOT among them and must be wrapped — independent
1563 // of discovery_/liveliness_protection (those are orthogonal submessage layers).
1564 // -> discovery_plain=false, liveliness_plain=false forces the wrap.
1565 // OpenDDS' RtpsUdpReceiveStrategy::check_encoded otherwise drops every plain SEDP
1566 // as "Full message requires protection". cyclone does take the shortcut
1567 // (sends SEDP plain), but accepts wrapped SEDP inbound without issue.
1568 // The INBOUND path (secure_inbound_bytes) deliberately stays lenient and still
1569 // accepts cyclone's plain SEDP — the asymmetry is intentional.
1570 Some(gate) if rtps_message_protection_exempt(bytes, false, false) => {
1571 let _ = gate;
1572 Some(alloc::borrow::Cow::Borrowed(bytes))
1573 }
1574 Some(gate) => gate
1575 .transform_outbound(bytes)
1576 .ok()
1577 .map(alloc::borrow::Cow::Owned),
1578 None => Some(alloc::borrow::Cow::Borrowed(bytes)),
1579 }
1580}
1581
1582// Security off: no clone — the caller borrows the datagram bytes
1583// directly (copy 6 of the zero-copy audit eliminated).
1584#[cfg(not(feature = "security"))]
1585fn secure_outbound_bytes<'a>(
1586 _rt: &DcpsRuntime,
1587 bytes: &'a [u8],
1588) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1589 Some(alloc::borrow::Cow::Borrowed(bytes))
1590}
1591
1592/// Pull inbound UDP bytes through the security gate.
1593///
1594/// Expects an RTPS header with the GuidPrefix at bytes 8..20.
1595/// `None` → drop the packet.
1596///
1597/// Security: drop reasons are forwarded, differentiated, to the
1598/// configured `LoggingPlugin`:
1599/// * `Malformed` → `Error`
1600/// * `LegacyBlocked` → `Error`
1601/// * `PolicyViolation` → `Warning` (possible tampering)
1602/// * `CryptoError` → `Warning` (tag mismatch, replay etc.)
1603#[cfg(feature = "security")]
1604fn secure_inbound_bytes<'a>(
1605 rt: &DcpsRuntime,
1606 bytes: &'a [u8],
1607 iface: &NetInterface,
1608) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1609 use zerodds_security_runtime::{InboundVerdict, LogLevel};
1610 let Some(gate) = &rt.config.security else {
1611 return Some(alloc::borrow::Cow::Borrowed(bytes));
1612 };
1613 // DDS-Security 8.4.2.4 (symmetric to outbound): SPDP/Stateless are
1614 // message-protection-exempt and ALWAYS arrive plain (also from cyclone). Without
1615 // this exception classify_inbound discards plain SPDP on the WAN iface under
1616 // rtps_protection as LegacyBlocked -> no discovery (match timeout).
1617 {
1618 let looks_srtps = bytes.len() > 20usize && bytes[20usize] == 0x33;
1619 if !looks_srtps
1620 && rtps_message_protection_exempt(
1621 bytes,
1622 gate.discovery_protection().unwrap_or(ProtectionLevel::None)
1623 == ProtectionLevel::None,
1624 gate.liveliness_protection()
1625 .unwrap_or(ProtectionLevel::None)
1626 == ProtectionLevel::None,
1627 )
1628 {
1629 // SRTPS-exempt. BUT metadata_protection user DATA carries per-submessage
1630 // SEC_PREFIX/BODY/POSTFIX (§9.5.3.3, NO SRTPS) — that must still be
1631 // decrypted per-endpoint here, otherwise the reader gets the
1632 // SEC wrapper instead of the DATA. Volatile-Kx-SEC fails with None
1633 // (key_id not in user-remote_by_key_id) -> unchanged for the
1634 // Volatile handler in the metatraffic loop.
1635 if walk_submessages(bytes)
1636 .iter()
1637 .any(|(id, _, _)| *id == SMID_SEC_PREFIX)
1638 {
1639 let mut pk = [0u8; 12];
1640 pk.copy_from_slice(&bytes[8..20]);
1641 if let Some(mut dg) = unprotect_user_datagram(rt, bytes, &pk) {
1642 match unprotect_user_payload(rt, &dg) {
1643 PayloadDecode::Decoded(clear) => dg = clear,
1644 PayloadDecode::Failed => return None,
1645 PayloadDecode::NotEncrypted => {}
1646 }
1647 return Some(alloc::borrow::Cow::Owned(dg));
1648 }
1649 }
1650 return Some(alloc::borrow::Cow::Borrowed(bytes));
1651 }
1652 }
1653 let verdict = gate.classify_inbound(bytes, iface);
1654 let category = verdict.category();
1655 let (level, message): (LogLevel, String) = match &verdict {
1656 InboundVerdict::Accept(out) => {
1657 // Cross-vendor user DATA: cyclone protects the DATA submessage as a
1658 // SEC_PREFIX/BODY/POSTFIX sequence (metadata_protection=ENCRYPT). Before
1659 // the submessage parse, transform it back with the sender's data key
1660 // (GuidPrefix = bytes[8..20]). `unprotect_user_datagram` returns
1661 // `None` when no SEC_* sequence is present → normal accept path.
1662 // OUTER layer first (metadata_protection, SEC_PREFIX/BODY/
1663 // POSTFIX), then the INNER one (data_protection, encrypted
1664 // SerializedPayload §9.5.3.3.1). Both can be active at once
1665 // (full secure profile); each returns `None` when its layer
1666 // is not present -> then the datagram stays unchanged.
1667 let mut dg: alloc::vec::Vec<u8> = out.clone();
1668 if dg.len() >= 20 {
1669 let mut pk = [0u8; 12];
1670 pk.copy_from_slice(&dg[8..20]);
1671 if let Some(clear) = unprotect_user_datagram(rt, &dg, &pk) {
1672 dg = clear;
1673 }
1674 }
1675 match unprotect_user_payload(rt, &dg) {
1676 PayloadDecode::Decoded(clear) => dg = clear,
1677 // Undecodable encrypted payload -> discard the datagram
1678 // (no ciphertext garbage to the reader; reliable re-send resp. another
1679 // copy delivers the sample later).
1680 PayloadDecode::Failed => return None,
1681 PayloadDecode::NotEncrypted => {}
1682 }
1683 return Some(alloc::borrow::Cow::Owned(dg));
1684 }
1685 InboundVerdict::Malformed => (
1686 LogLevel::Error,
1687 alloc::format!(
1688 "inbound datagram too short ({} bytes, iface={:?})",
1689 bytes.len(),
1690 iface
1691 ),
1692 ),
1693 InboundVerdict::LegacyBlocked => (
1694 LogLevel::Error,
1695 alloc::format!(
1696 "legacy plaintext peer on protected domain \
1697 (iface={iface:?}, allow_unauthenticated_participants=false)"
1698 ),
1699 ),
1700 InboundVerdict::PolicyViolation(msg) => {
1701 (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1702 }
1703 InboundVerdict::CryptoError(msg) => {
1704 (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1705 }
1706 };
1707 if let Some(logger) = &rt.config.security_logger {
1708 // Participant ident: GuidPrefix (or 0-padding for Malformed).
1709 let mut participant = [0u8; 16];
1710 if bytes.len() >= 20 {
1711 participant[..12].copy_from_slice(&bytes[8..20]);
1712 }
1713 logger.log(level, participant, category, &message);
1714 }
1715 None
1716}
1717
1718#[cfg(not(feature = "security"))]
1719fn secure_inbound_bytes<'a>(
1720 _rt: &DcpsRuntime,
1721 bytes: &'a [u8],
1722) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1723 Some(alloc::borrow::Cow::Borrowed(bytes))
1724}
1725
1726/// Default interface class for inbound dispatch when the socket does not
1727/// belong to the `outbound_pool`. In the v1.4 setup (without
1728/// `interface_bindings`), all packets run through `user_unicast` and are
1729/// classified as `Wan` — the most conservative assumption (protection
1730/// rules apply as in the single-interface case).
1731#[cfg(feature = "security")]
1732const DEFAULT_INBOUND_IFACE: NetInterface = NetInterface::Wan;
1733
1734/// Per-reader outbound transform.
1735///
1736/// Looks up in the writer slot which `ProtectionLevel` the matched reader
1737/// expects at the given `target` locator, then pulls the datagram through
1738/// the security gate individually. This way each reader gets a wire
1739/// payload matching its security profile (Legacy=plain, Fast=Sign,
1740/// Secure=Encrypt).
1741///
1742/// Fallback paths:
1743/// * No security gate configured → passthrough.
1744/// * No `locator_to_peer` entry (reader not yet matched via SEDP) →
1745/// `transform_outbound` with the domain rule — that is the homogeneous
1746/// v1.4 path.
1747/// * The gate returns an error → `None` (the caller drops — better no
1748/// plaintext leak).
1749#[cfg(feature = "security")]
1750fn secure_outbound_for_target(
1751 rt: &DcpsRuntime,
1752 writer_eid: EntityId,
1753 bytes: &[u8],
1754 target: &Locator,
1755) -> Option<Vec<u8>> {
1756 let Some(gate) = &rt.config.security else {
1757 return Some(bytes.to_vec());
1758 };
1759 // FU2 S3: fallback level from our own governance (data_protection_
1760 // kind), in case the matched reader did not announce an explicit SEDP
1761 // security_info level. This way user data to an authenticated peer is
1762 // encrypted per our own governance, while SPDP/SEDP metatraffic
1763 // bootstraps plaintext over rtps_protection_kind=NONE.
1764 // Governance `data_protection` is a FLOOR, not a mere fallback: a
1765 // per-reader level can only STRENGTHEN (e.g. legacy plaintext is only
1766 // allowed if the domain policy itself permits plaintext), never fall
1767 // below the domain policy. Otherwise a matched-but-not-authenticated
1768 // peer (foreign CA, SEDP match over plaintext discovery,
1769 // reader_protection=None) leaks plaintext user data.
1770 let gov_data_level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1771 // metadata_protection (§8.4.2.4 / §9.5.3.3): EVERY writer submessage (DATA,
1772 // HEARTBEAT, GAP) is SEC_PREFIX/BODY/POSTFIX-wrapped per-submessage —
1773 // TARGET-INDEPENDENT, since the per-endpoint writer key is local (the peer fetches
1774 // it via datawriter_crypto_token). Must take effect BEFORE the locator-based reader
1775 // resolution: otherwise tick HEARTBEATs/GAPs to not-yet-locator-
1776 // matched targets fall into the None branch -> with rtps=NONE PLAIN -> leak + no
1777 // reliable recovery (breaks already zero<->zero). data_protection (inner
1778 // payload layer) first, then the outer submessage layer.
1779 if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1780 let inner = if gov_data_level != ProtectionLevel::None {
1781 protect_user_payload(rt, bytes)?
1782 } else {
1783 bytes.to_vec()
1784 };
1785 let meta_sec = protect_user_datagram(rt, &inner)?;
1786 // Under rtps_protection message-level SRTPS MUST additionally go on top —
1787 // BOTH layers, like cyclone<->cyclone. Without it the peer would see the
1788 // metadata-SEC-DATA as "clear submsg from protected src" and discard it.
1789 if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1790 return gate.transform_outbound(&meta_sec).ok();
1791 }
1792 return Some(meta_sec);
1793 }
1794 let resolved = rt.writer_slot(writer_eid).and_then(|arc| {
1795 arc.lock().ok().and_then(|slot| {
1796 let pk = slot.locator_to_peer.get(target).copied()?;
1797 // An EXPLICITLY negotiated per-reader level is respected: a
1798 // legacy-v1.4 reader has reader_protection=None and MUST get plaintext,
1799 // otherwise it cannot decode (heterogeneous domain). Only
1800 // when NO entry exists (matched via plaintext discovery, but
1801 // no level negotiated -> potentially unauthenticated) does the
1802 // governance data_protection FLOOR apply as leak protection.
1803 // Governance data_protection is a FLOOR (§8.4.2.4, memory-documented):
1804 // a per-reader level can only STRENGTHEN, never fall below the domain
1805 // policy. A reader discovered via secure SEDP whose security_info parses
1806 // to `Some(None)` (no is_payload_protected bit detected, discovery=
1807 // ENCRYPT) would otherwise yield level=None -> Some(None) arm -> PLAINTEXT
1808 // leak, although the domain requires data_protection=ENCRYPT (disc-data-
1809 // enc: zerodds sent user DATA without the N-flag -> OpenDDS decode_serialized_
1810 // payload=0 -> no echo). `.max` enforces at least the governance FLOOR.
1811 // With gov=None legacy plaintext (reader_lv) stays allowed.
1812 let level = match slot.reader_protection.get(&pk).copied() {
1813 Some(reader_lv) => reader_lv.max(gov_data_level),
1814 None => gov_data_level,
1815 };
1816 Some((pk, level))
1817 })
1818 });
1819 match resolved {
1820 // Matched reader with Sign/Encrypt: cyclone-conformant SUBMESSAGE
1821 // protection (SEC_PREFIX/BODY/POSTFIX around the DATA submessage, local
1822 // data key) instead of message-level SRTPS — `metadata_protection_kind=
1823 // ENCRYPT`, §9.5.3.3. cyclone decodes with the key sent via datawriter_crypto_
1824 // tokens. `None` level = byte-identical passthrough.
1825 Some((peer_key, level)) if level != ProtectionLevel::None => {
1826 // Layer choice per governance (DDS-Security §8.4.2.4 vs §7.3.7):
1827 // * metadata_protection_kind != NONE -> per-submessage protection
1828 // (`encode_datawriter_submessage`, SEC_PREFIX/BODY/POSTFIX) for
1829 // EVERY writer submessage (DATA, HEARTBEAT, GAP, ...). This is the
1830 // cyclone interop path: cyclone expects HEARTBEAT/GAP SEC_*-
1831 // wrapped too, otherwise its reader never NACKs (no reliable recovery).
1832 // * otherwise (only rtps_protection_kind != NONE) -> message-level SRTPS
1833 // via `transform_outbound_for` (whole message, §7.3.7).
1834 // INNER layer (§9.5.3.3.1): data_protection encrypts ONLY the
1835 // SerializedPayload of each DATA submessage. Applied BEFORE the outer
1836 // submessage/message layer — cyclone-conformant
1837 // nesting (§9.5.3.3): data_protection (inner) + metadata_
1838 // protection (outer). With pure data_protection this is the
1839 // only + complete protection.
1840 let inner: Vec<u8> = if gov_data_level != ProtectionLevel::None {
1841 // Crypto error -> drop instead of leak (None propagated via `?`).
1842 protect_user_payload(rt, bytes)?
1843 } else {
1844 bytes.to_vec()
1845 };
1846 // OUTER layer choice (DDS-Security §8.4.2.4 / §7.3.7):
1847 if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None
1848 {
1849 // metadata_protection -> per-submessage protection (DATA, HEARTBEAT,
1850 // GAP, ...) with the per-endpoint writer key (cyclone interop path).
1851 // Under additional rtps_protection message-level SRTPS MUST go on top
1852 // (both layers) — otherwise "clear submsg from protected src".
1853 match protect_user_datagram(rt, &inner) {
1854 Some(ms)
1855 if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1856 != ProtectionLevel::None =>
1857 {
1858 gate.transform_outbound(&ms).ok()
1859 }
1860 other => other,
1861 }
1862 } else if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1863 != ProtectionLevel::None
1864 {
1865 // rtps_protection -> message-level SRTPS (whole message, §7.3.7),
1866 // per-reader key.
1867 gate.transform_outbound_for(&peer_key, &inner, level).ok()
1868 } else {
1869 // only data_protection -> the payload layer is already the
1870 // complete protection (§9.5.3.3.1). Header/InlineQoS stay
1871 // plaintext, the encrypted payload carries the N-flag.
1872 Some(inner)
1873 }
1874 }
1875 // Matched reader with level None: a legacy-v1.4 reader (explicit
1876 // SEDP legacy or NONE governance) gets byte-identical plaintext —
1877 // message-level SRTPS would make it undecryptable.
1878 Some(_) => {
1879 // Matched reader with data level None: under rtps_protection the
1880 // message MUST still be message-level-SRTPS-wrapped (§8.4.2.4) —
1881 // the data_protection level only controls the payload/submessage layer.
1882 // Without it user DATA/HEARTBEAT leaks plain, although the domain
1883 // requires rtps_protection=ENCRYPT (the peer discards it as legacy).
1884 if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1885 gate.transform_outbound(bytes).ok()
1886 } else {
1887 Some(bytes.to_vec())
1888 }
1889 }
1890 // No locator-resolved reader: multicast/meta bootstrap OR a
1891 // user reader whose locator is (not yet) in `locator_to_peer`
1892 // (e.g. discovered via secure SEDP, discovery_protection=ENCRYPT). The
1893 // data_protection (inner payload layer §9.5.3.3.1) is TARGET-INDEPENDENT
1894 // (local writer key) and MUST still apply for a user writer —
1895 // otherwise under data_protection=ENCRYPT the user DATA leaks PLAINTEXT (N-flag
1896 // missing -> a spec-conformant remote reader never calls `decode_serialized_payload`
1897 // -> no sample, no echo; disc-data-enc stall, source-documented: OpenDDS
1898 // decode_serialized_payload=0). ONLY for user writers — SPDP/SEDP builtin DATA
1899 // must bootstrap plaintext (otherwise undecodable before key exchange).
1900 None => {
1901 use zerodds_rtps::wire_types::EntityKind;
1902 let is_user_writer = matches!(
1903 writer_eid.entity_kind,
1904 EntityKind::UserWriterWithKey | EntityKind::UserWriterNoKey
1905 );
1906 if is_user_writer && gov_data_level != ProtectionLevel::None {
1907 let inner = protect_user_payload(rt, bytes)?;
1908 gate.transform_outbound(&inner).ok()
1909 } else {
1910 gate.transform_outbound(bytes).ok()
1911 }
1912 }
1913 }
1914}
1915
1916#[cfg(not(feature = "security"))]
1917fn secure_outbound_for_target(
1918 _rt: &DcpsRuntime,
1919 _writer_eid: EntityId,
1920 bytes: &[u8],
1921 _target: &Locator,
1922) -> Option<Vec<u8>> {
1923 Some(bytes.to_vec())
1924}
1925
1926/// FU2 S3: data_protection-aware user DATA outbound. Encrypts the
1927/// datagram with the governance `data_protection` level. `transform_outbound_
1928/// for` ignores the `peer_key` and uses the local key — the ciphertext
1929/// is decryptable for EVERY authenticated peer (with our token),
1930/// non-authenticated peers cannot read it. A `None` level falls
1931/// back to message-level (`rtps_protection` resp. passthrough). Used for
1932/// UDP + in-process fastpath + SHM UNIFORMLY, so the
1933/// inproc path is secured too.
1934#[cfg(feature = "security")]
1935fn secure_user_outbound<'a>(
1936 rt: &DcpsRuntime,
1937 bytes: &'a [u8],
1938) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1939 let Some(gate) = &rt.config.security else {
1940 return Some(alloc::borrow::Cow::Borrowed(bytes));
1941 };
1942 let level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1943 if matches!(level, ProtectionLevel::None) {
1944 gate.transform_outbound(bytes)
1945 .ok()
1946 .map(alloc::borrow::Cow::Owned)
1947 } else {
1948 gate.transform_outbound_for(&[0u8; 12], bytes, level)
1949 .ok()
1950 .map(alloc::borrow::Cow::Owned)
1951 }
1952}
1953
1954#[cfg(not(feature = "security"))]
1955fn secure_user_outbound<'a>(
1956 _rt: &DcpsRuntime,
1957 bytes: &'a [u8],
1958) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1959 Some(alloc::borrow::Cow::Borrowed(bytes))
1960}
1961
1962/// Sends `bytes` to `target` on the matching interface socket.
1963/// Falls back to `rt.user_unicast` if no
1964/// pool is configured or no binding matches the target range
1965/// and no default binding is set either.
1966#[cfg(feature = "security")]
1967fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1968 if let Some(pool) = &rt.outbound_pool {
1969 if let Some((socket, _iface)) = pool.route(target) {
1970 let _ = socket.send(target, bytes);
1971 return;
1972 }
1973 }
1974 let _ = rt.user_unicast.send(target, bytes);
1975}
1976
1977#[cfg(not(feature = "security"))]
1978fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1979 let _ = rt.user_unicast.send(target, bytes);
1980}
1981
1982/// User-writer slot in the runtime. Carries ReliableWriter + topic meta +
1983/// fragment size (from QoS).
1984struct UserWriterSlot {
1985 writer: ReliableWriter,
1986 topic_name: String,
1987 type_name: String,
1988 reliable: bool,
1989 durability: zerodds_qos::DurabilityKind,
1990 /// Deadline period in nanoseconds (0 == INFINITE, no monitoring).
1991 deadline_nanos: u64,
1992 /// Last successful `write` relative to `DcpsRuntime::start_instant`.
1993 last_write: Option<Duration>,
1994 /// Counter for missed deadlines (Spec §2.2.4.2.9).
1995 offered_deadline_missed_count: u64,
1996 /// Counter for LivelinessLost detections from the writer's view
1997 /// (Spec §2.2.4.2.10). Incremented in `check_writer_liveliness` on
1998 /// manual-lease overrun. 0 == not monitored.
1999 liveliness_lost_count: u64,
2000 /// Last assert time (manual liveliness). `None` == never.
2001 last_liveliness_assert: Option<Duration>,
2002 /// Per-policy counter for offered_incompatible_qos. Spec
2003 /// §2.2.4.2.4.2 — writer side. Incremented on
2004 /// `wire_writer_to_remote_reader` reject.
2005 offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus,
2006 /// Lifespan duration in nanoseconds (0 == INFINITE, no expiry).
2007 lifespan_nanos: u64,
2008 /// Per sample SN the insert time (relative to start_instant).
2009 /// Removed from front on expiry — SNs are monotonic, lifespan
2010 /// is constant, so the expiry prefix is always front.
2011 sample_insert_times:
2012 alloc::collections::VecDeque<(zerodds_rtps::wire_types::SequenceNumber, Duration)>,
2013 /// Liveliness kind (Automatic / ManualByParticipant / ManualByTopic).
2014 liveliness_kind: zerodds_qos::LivelinessKind,
2015 /// Lease duration in nanoseconds (0 == INFINITE).
2016 liveliness_lease_nanos: u64,
2017 /// Ownership mode.
2018 ownership: zerodds_qos::OwnershipKind,
2019 /// Ownership strength (Spec §2.2.3.2). Mirrored in the same-runtime
2020 /// dispatch into `UserSample::Alive.writer_strength`, so that
2021 /// EXCLUSIVE ownership logic in the reader also works for intra-process
2022 /// loopback.
2023 ownership_strength: i32,
2024 /// Partition list.
2025 partition: Vec<String>,
2026 /// Per-matched-reader ProtectionLevel. Derived at the
2027 /// SEDP match from `sub.security_info`. `None` entries
2028 /// for legacy readers. Empty for writers without matched
2029 /// security peers — then the hot path is unchanged.
2030 #[cfg(feature = "security")]
2031 reader_protection: BTreeMap<[u8; 12], ProtectionLevel>,
2032 /// Mapping Locator → GuidPrefix for the writer tick loop, so that
2033 /// `secure_outbound_for_target` can look up the protection per target
2034 /// without breaking the writer-tick API (`dg.targets` are
2035 /// locator lists today).
2036 #[cfg(feature = "security")]
2037 locator_to_peer: BTreeMap<Locator, [u8; 12]>,
2038 /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the writer type
2039 /// (from `T::TYPE_IDENTIFIER` in `UserWriterConfig`).
2040 type_identifier: zerodds_types::TypeIdentifier,
2041 /// D.5g — per-writer override for the DataRepresentation offer.
2042 /// `None` = runtime default. `Some(vec)` = hardcoded per writer.
2043 data_rep_offer_override: Option<Vec<i16>>,
2044 /// Type extensibility of the writer type (FINAL/APPENDABLE/MUTABLE).
2045 /// Together with the offer `first` element it determines the
2046 /// encapsulation header of the user payload (see
2047 /// [`user_payload_encap`]). Default `Final`; set by codegen/FFI via
2048 /// `set_user_writer_wire_extensibility` when the type
2049 /// is appendable/mutable (relevant for XCDR2 wire: D_CDR2/PL_CDR2).
2050 wire_extensibility: zerodds_types::qos::ExtensibilityForRepr,
2051 /// Emit the big-endian encapsulation variant (`_BE`, RTPS 2.5 §10.5)
2052 /// instead of the little-endian default. Set by the durability service
2053 /// replay path so a big-endian peer's stored sample is re-published with a
2054 /// matching BE encap header (the body bytes are already big-endian). `false`
2055 /// = little-endian (the canonical wire for a normal writer).
2056 big_endian_override: bool,
2057 /// Spec §2.2.3.5 DurabilityService — with Durability=Transient/
2058 /// Persistent the backend holds in addition to the writer's own
2059 /// HistoryCache. On the first late-joiner match in
2060 /// `wire_writer_to_remote_reader` the backend samples are
2061 /// (re-)injected into the HistoryCache, so that the RTPS reliable
2062 /// path delivers them to the reader. `None` for Volatile/
2063 /// TransientLocal (the cache suffices).
2064 durability_backend: Option<alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>>,
2065 /// `true` as soon as the backend has been replayed once into the
2066 /// HistoryCache. Prevents repeated re-injection on further matches.
2067 backend_primed: bool,
2068 /// HISTORY KeepLast depth (DDS 1.4 §2.2.3.18). Per-instance retained-sample
2069 /// depth for the same-runtime durability replay path. Default
2070 /// [`DEFAULT_INTRA_HISTORY_DEPTH`]. KeepAll is modelled as `usize::MAX`.
2071 /// Settable via [`DcpsRuntime::set_user_writer_history_depth`].
2072 history_depth: usize,
2073 /// TRANSIENT_LOCAL retained samples (DDS 1.4 §2.2.3.4) for the
2074 /// same-runtime late-joiner replay path. Holds the *most recent*
2075 /// `history_depth` Alive samples **per instance key** plus any
2076 /// terminal lifecycle marker for an instance. A reader that joins an
2077 /// intra-runtime route AFTER these writes replays this buffer so it sees
2078 /// the retained history (the wire/SEDP path is separate, see
2079 /// `wire_writer_to_remote_reader`). Empty unless durability is
2080 /// TransientLocal or stronger.
2081 retained: alloc::collections::VecDeque<RetainedSample>,
2082 /// Set of intra-runtime reader EntityIds that have already received the
2083 /// TransientLocal retained-sample replay, so a route recompute does not
2084 /// replay the same history twice to the same reader.
2085 intra_replayed_readers: alloc::collections::BTreeSet<EntityId>,
2086}
2087
2088/// Default same-runtime HISTORY KeepLast depth when the user has not called
2089/// [`DcpsRuntime::set_user_writer_history_depth`]. Mirrors the DDS spec default
2090/// of `depth = 1` for KEEP_LAST (DDS 1.4 §2.2.3.18 Table).
2091const DEFAULT_INTRA_HISTORY_DEPTH: usize = 1;
2092
2093/// One retained sample for the same-runtime TransientLocal replay path.
2094#[derive(Debug, Clone)]
2095struct RetainedSample {
2096 /// Instance key hash (16 byte). All-zero for NoKey topics / unknown key.
2097 key_hash: [u8; 16],
2098 /// CDR body without encapsulation header.
2099 payload: Vec<u8>,
2100 /// XCDR version tag (`0` = XCDR1, `1` = XCDR2).
2101 representation: u8,
2102 /// Writer ownership strength at write time.
2103 strength: i32,
2104 /// `Some(kind)` if this entry is a terminal lifecycle marker
2105 /// (dispose / unregister) rather than an alive sample.
2106 lifecycle: Option<zerodds_rtps::history_cache::ChangeKind>,
2107}
2108
2109/// The listener dispatch carries, alongside the `UserSample`, a
2110/// zero-copy view on the original `Arc<[u8]>` with an encap offset
2111/// (lever-E zero-copy path).
2112pub type UserSampleWithEncap = (UserSample, Option<(Arc<[u8]>, usize)>);
2113
2114/// Sample channel item: either data payload or lifecycle marker.
2115/// Lifecycle is reconstructed by the wire path as `key_hash + ChangeKind` from
2116/// the PID_STATUS_INFO header; the DataReader DCPS layer
2117/// translates that into `__push_lifecycle`.
2118#[derive(Debug, Clone)]
2119pub enum UserSample {
2120 /// Normal sample with payload (CDR-encoded application type).
2121 /// `writer_guid` is the 16-byte GUID of the emitting writer
2122 /// — needed by the subscriber for exclusive-ownership resolution
2123 /// (DDS 1.4 §2.2.3.23 / §2.2.2.5.5).
2124 Alive {
2125 /// CDR payload (without encapsulation header). Zero-copy container:
2126 /// typically holds an `Arc<[u8]>` slice into the RTPS wire datagram
2127 /// without a heap re-alloc. See `docs/specs/zerodds-zero-copy-1.0.md`.
2128 payload: crate::sample_bytes::SampleBytes,
2129 /// Writer GUID — for strongest-writer selection.
2130 writer_guid: [u8; 16],
2131 /// Writer `ownership_strength` at the time of receipt.
2132 /// `0` if the writer is not yet known via discovery
2133 /// (the reader treats this as default strength = spec-conformant
2134 /// for shared-ownership topics; for exclusive the
2135 /// reader filters the real strength against the current owner).
2136 writer_strength: i32,
2137 /// XCDR version of the `payload` — extracted from the encapsulation
2138 /// header of the wire sample (RTPS 2.5 §10.5) BEFORE the
2139 /// header was stripped: `0` = XCDR1 (CDR/PL_CDR), `1` =
2140 /// XCDR2 (CDR2/D_CDR2/PL_CDR2). The typed consumer
2141 /// needs this to decode the body with the correct alignment rule
2142 /// (XTypes 1.3 §7.4.3.4.2).
2143 representation: u8,
2144 /// Byte order of the `payload` — extracted from the encapsulation
2145 /// representation identifier's low bit (RTPS 2.5 §10.5: the `_BE`
2146 /// variants 0x0000/0x0002/0x0006/0x0008/0x000a are even, the `_LE`
2147 /// variants odd). `false` = little-endian (the canonical wire and the
2148 /// intra-runtime default), `true` = big-endian. The typed consumer
2149 /// dispatches `DdsType::decode` vs `decode_be` on this.
2150 big_endian: bool,
2151 /// Source timestamp from the writer's INFO_TS submessage (DDSI-RTPS
2152 /// §8.7.3), if any. Threaded into `SampleInfo.source_timestamp` and the
2153 /// `DESTINATION_ORDER = BY_SOURCE_TIMESTAMP` decision. `None` ⇒ the
2154 /// reader uses reception order.
2155 source_timestamp: Option<zerodds_rtps::header_extension::HeTimestamp>,
2156 /// Source sequence number — the emitting writer's RTPS
2157 /// `CacheChange.sequence_number` (DDSI-RTPS §8.3.5.4). Together with
2158 /// `writer_guid` this is the globally-unique source identity of the
2159 /// sample, which a durability service uses to dedup a writer's history
2160 /// against its live stream (O2 P5). `SEQUENCENUMBER_UNKNOWN` (`-1`) when
2161 /// the delivery path has no source sequence (e.g. raw test injection).
2162 source_sequence_number: i64,
2163 },
2164 /// Lifecycle marker (dispose / unregister) — the reader sets
2165 /// InstanceState accordingly.
2166 Lifecycle {
2167 /// Key hash of the affected instance (16 byte).
2168 key_hash: [u8; 16],
2169 /// `NotAliveDisposed` / `NotAliveUnregistered` /
2170 /// `NotAliveDisposedUnregistered`.
2171 kind: zerodds_rtps::history_cache::ChangeKind,
2172 },
2173}
2174
2175/// User-reader slot. ReliableReader + topic meta + channel to the
2176/// DataReader (DCPS API side).
2177/// Listener callback for sample arrival.
2178///
2179/// Fired synchronously by `recv_user_data_loop` in the recv-thread
2180/// context as soon as an alive sample lands in the reader HistoryCache.
2181/// Eliminates the polling latency of `zerodds_reader_take()` —
2182/// the listener path typically saves 50-100 µs per side.
2183///
2184/// **Contract** (analogous to DDS spec §2.2.4.4 listener semantics):
2185/// * The callback runs on the recv thread, NOT the user thread.
2186/// * Short and non-blocking. No I/O, no locks, no
2187/// ZeroDDS API calls inside.
2188/// * `bytes` points to the CDR payload of the alive sample (without
2189/// encapsulation header). Lifetime only for the duration of the
2190/// callback; copy if needed beyond the call.
2191/// * Disposed/unregistered lifecycle events do NOT fire the listener
2192/// (only `Alive` samples) — for lifecycle tracking
2193/// keep using `zerodds_reader_take()` or add a
2194/// lifecycle-listener API.
2195///
2196/// Data-available listener. Arguments: CDR body (without encapsulation
2197/// header) and the XCDR version of the sample (`0` = XCDR1, `1` = XCDR2)
2198/// — the typed consumer needs the latter for the alignment
2199/// rule on decode (XTypes 1.3 §7.4.3.4.2).
2200pub type UserReaderListener = alloc::boxed::Box<dyn Fn(&[u8], u8, u8) + Send + Sync + 'static>;
2201
2202struct UserReaderSlot {
2203 reader: ReliableReader,
2204 topic_name: String,
2205 type_name: String,
2206 sample_tx: mpsc::Sender<UserSample>,
2207 /// Spec §3 zerodds-async-1.0: async waker slot. Registered by the
2208 /// async reader; on `sample_tx.send` we call
2209 /// `waker.wake()`. `None` if no async reader is active.
2210 async_waker: alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>,
2211 /// Listener callback for alive samples.
2212 /// Fired synchronously by `recv_user_data_loop`. `None` =
2213 /// no listener registered (the user polls via
2214 /// `zerodds_reader_take()`). Arc, so the recv thread can
2215 /// execute the callback cloned without another lock (minimize lock
2216 /// hold time).
2217 listener: Option<alloc::sync::Arc<UserReaderListener>>,
2218 durability: zerodds_qos::DurabilityKind,
2219 /// Deadline period in nanoseconds (0 == INFINITE).
2220 deadline_nanos: u64,
2221 /// Time of the last received sample relative to runtime start.
2222 last_sample_received: Option<Duration>,
2223 /// Counter for missed deadline expectations (Spec §2.2.4.2.11).
2224 requested_deadline_missed_count: u64,
2225 /// Per-policy counter for requested_incompatible_qos. Spec
2226 /// §2.2.4.2.6.5 — reader side. Incremented on
2227 /// `wire_reader_to_remote_writer` reject.
2228 requested_incompatible_qos: crate::status::RequestedIncompatibleQosStatus,
2229 /// Sample-lost counter (Spec §2.2.4.2.6.2). Incremented
2230 /// by `record_sample_lost`.
2231 sample_lost_count: u64,
2232 /// Sample-rejected counter (Spec §2.2.4.2.6.3). Incremented
2233 /// by `record_sample_rejected`.
2234 sample_rejected: crate::status::SampleRejectedStatus,
2235 /// Monotonically increasing count of alive samples delivered to the
2236 /// user. Serves as a non-consuming data-availability detector for
2237 /// `on_data_available` (DDS 1.4 §2.2.4.2.6.1) — unlike
2238 /// `last_sample_received`, this counter is only bumped on real sample
2239 /// delivery, never by the deadline path. Read via
2240 /// [`DcpsRuntime::user_reader_samples_delivered`].
2241 samples_delivered_count: u64,
2242 /// Reader-side requested liveliness lease (0 == INFINITE).
2243 liveliness_lease_nanos: u64,
2244 /// Reader-side requested liveliness kind.
2245 liveliness_kind: zerodds_qos::LivelinessKind,
2246 /// Counter: how often the writer was marked "alive"
2247 /// (Spec §2.2.4.2.14 alive_count).
2248 liveliness_alive_count: u64,
2249 /// Counter: how often it was marked "not_alive" (lease expired).
2250 liveliness_not_alive_count: u64,
2251 /// Current "alive/not-alive" state from the reader's view.
2252 liveliness_alive: bool,
2253 /// QR-cluster (e): set of writer GUIDs the reader currently considers alive
2254 /// via an AUTOMATIC-liveliness same-runtime match. Used to bump
2255 /// `liveliness_alive_count` exactly once per writer-alive transition on the
2256 /// intra-runtime path (the wire DATA path tracks this via reader proxies).
2257 liveliness_alive_writers: alloc::collections::BTreeSet<[u8; 16]>,
2258 /// Ownership.
2259 ownership: zerodds_qos::OwnershipKind,
2260 /// Partition.
2261 partition: Vec<String>,
2262 /// Per-writer strength cache for exclusive-ownership resolution
2263 /// (DDS 1.4 §2.2.3.23). Filled by `wire_reader_to_remote_writer`
2264 /// from each `PublicationBuiltinTopicData.ownership_strength`;
2265 /// `delivered_to_user_sample` looks it up here to pack the
2266 /// strength into `UserSample::Alive`.
2267 writer_strengths: alloc::collections::BTreeMap<[u8; 16], i32>,
2268 /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the reader type
2269 /// (from `T::TYPE_IDENTIFIER` in `UserReaderConfig`). Default
2270 /// `TypeIdentifier::None` signals "no TypeIdentifier" —
2271 /// the match falls back to a pure `type_name` comparison
2272 /// (DDS 1.4 §2.2.3 default path).
2273 type_identifier: zerodds_types::TypeIdentifier,
2274 /// XTypes 1.3 §7.6.3.7 — TCE policy controlling the strictness
2275 /// of the XTypes match path.
2276 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2277 /// A2 — TIME_BASED_FILTER `minimum_separation` (DDS 1.4 §2.2.3.12), in
2278 /// nanoseconds, for the runtime/C-FFI delivery path. `0` (default) = off.
2279 /// Set via [`DcpsRuntime::set_user_reader_time_based_filter`] (the
2280 /// `rmw_zerodds` / C-FFI path; the typed entity reader enforces TBF on its
2281 /// own QoS). See [`UserReaderSlot::tbf_should_deliver`].
2282 tbf_min_separation_nanos: u128,
2283 /// Per-instance last-delivered timestamp (nanoseconds since runtime start),
2284 /// keyed by the sample KeyHash (keyless types share the all-zero key). Only
2285 /// populated when `tbf_min_separation_nanos > 0`.
2286 tbf_last_delivered: alloc::collections::BTreeMap<[u8; 16], u128>,
2287}
2288
2289impl UserReaderSlot {
2290 /// A2 — TIME_BASED_FILTER gate (DDS 1.4 §2.2.3.12) for the runtime delivery
2291 /// path: returns `true` if a sample of the given instance may be delivered,
2292 /// i.e. at least `minimum_separation` has elapsed since the last delivered
2293 /// sample of that instance. The first sample of an instance always passes.
2294 /// `key_hash = None` (keyless type) collapses to a single instance. A
2295 /// `minimum_separation` of 0 disables the filter (always `true`).
2296 fn tbf_should_deliver(&mut self, key_hash: Option<[u8; 16]>, now_nanos: u128) -> bool {
2297 if self.tbf_min_separation_nanos == 0 {
2298 return true;
2299 }
2300 let inst = key_hash.unwrap_or([0u8; 16]);
2301 match self.tbf_last_delivered.get(&inst) {
2302 Some(&last) if now_nanos.saturating_sub(last) < self.tbf_min_separation_nanos => false,
2303 _ => {
2304 self.tbf_last_delivered.insert(inst, now_nanos);
2305 true
2306 }
2307 }
2308 }
2309}
2310
2311/// Helper struct for announcing a local publication/subscription
2312/// as SEDP BuiltinTopicData. The caller creates it once per
2313/// writer/reader registration and passes it to SedpStack.
2314/// QoS config for registering a user writer with the runtime.
2315/// Bundles all policies that go on the wire via SEDP plus the local
2316/// Per-endpoint discovery info for ROS 2 endpoint-info-by-topic introspection
2317/// (`rmw_get_publishers_info_by_topic` / `rmw_get_subscriptions_info_by_topic`,
2318/// the data behind `ros2 topic info -v`). Covers local user endpoints plus
2319/// remote SEDP-discovered ones. QoS is best-effort from what discovery carries
2320/// (history/depth are not on the wire, so the consumer fills rmw defaults).
2321#[derive(Debug, Clone)]
2322pub struct DiscoveredEndpointInfo {
2323 /// DDS topic name (raw, un-demangled).
2324 pub topic_name: String,
2325 /// IDL type name (raw).
2326 pub type_name: String,
2327 /// 16-byte endpoint GUID: 12-byte participant prefix + 4-byte entity id.
2328 /// Bytes 0..12 identify the owning participant (for node-name lookup).
2329 pub endpoint_guid: [u8; 16],
2330 /// RELIABLE (`true`) vs BEST_EFFORT (`false`).
2331 pub reliable: bool,
2332 /// TRANSIENT_LOCAL or stronger (`true`) vs VOLATILE (`false`).
2333 pub transient_local: bool,
2334 /// Deadline period in whole seconds (0 == INFINITE).
2335 pub deadline_seconds: i32,
2336 /// Lifespan in whole seconds (0 == INFINITE; always 0 for subscriptions).
2337 pub lifespan_seconds: i32,
2338 /// Liveliness lease in whole seconds (0 == INFINITE).
2339 pub liveliness_lease_seconds: i32,
2340}
2341
2342/// Packs an RTPS [`Guid`] into the 16-byte wire form (prefix ++ entity id).
2343fn guid_to_16(g: Guid) -> [u8; 16] {
2344 let mut b = [0u8; 16];
2345 b[..12].copy_from_slice(&g.prefix.to_bytes());
2346 b[12..].copy_from_slice(&g.entity_id.to_bytes());
2347 b
2348}
2349
2350/// monitoring. Avoids 10+-argument functions.
2351#[derive(Debug, Clone)]
2352pub struct UserWriterConfig {
2353 /// Topic name (DDS topic).
2354 pub topic_name: String,
2355 /// IDL type name.
2356 pub type_name: String,
2357 /// `true` = RELIABLE, `false` = BEST_EFFORT.
2358 pub reliable: bool,
2359 /// Durability.
2360 pub durability: zerodds_qos::DurabilityKind,
2361 /// Deadline period (offered).
2362 pub deadline: zerodds_qos::DeadlineQosPolicy,
2363 /// Lifespan duration (writer-only).
2364 pub lifespan: zerodds_qos::LifespanQosPolicy,
2365 /// Liveliness (offered).
2366 pub liveliness: zerodds_qos::LivelinessQosPolicy,
2367 /// Ownership mode (Shared / Exclusive).
2368 pub ownership: zerodds_qos::OwnershipKind,
2369 /// Strength for Exclusive (ignored for Shared).
2370 pub ownership_strength: i32,
2371 /// Partition list. Empty == default partition (`""`).
2372 pub partition: Vec<String>,
2373 /// UserData QoS (Spec §2.2.3.1) — opaque `sequence<octet>`, propagated
2374 /// via discovery.
2375 pub user_data: Vec<u8>,
2376 /// TopicData QoS (Spec §2.2.3.3).
2377 pub topic_data: Vec<u8>,
2378 /// GroupData QoS (Spec §2.2.3.2).
2379 pub group_data: Vec<u8>,
2380 /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up). Default
2381 /// `TypeIdentifier::None` for the `T::TYPE_IDENTIFIER` default.
2382 pub type_identifier: zerodds_types::TypeIdentifier,
2383
2384 /// D.5g — per-writer override of the DataRepresentation offer list.
2385 /// `None` = use `RuntimeConfig::data_representation_offer`.
2386 /// `Some(vec)` = overridden per writer (e.g. `[XCDR2]` for
2387 /// a modern-only pub).
2388 pub data_representation_offer: Option<Vec<i16>>,
2389}
2390
2391/// QoS config for registering a user reader.
2392#[derive(Debug, Clone)]
2393pub struct UserReaderConfig {
2394 /// Topic name.
2395 pub topic_name: String,
2396 /// IDL type name.
2397 pub type_name: String,
2398 /// `true` = RELIABLE, `false` = BEST_EFFORT.
2399 pub reliable: bool,
2400 /// Durability (requested).
2401 pub durability: zerodds_qos::DurabilityKind,
2402 /// Deadline (requested).
2403 pub deadline: zerodds_qos::DeadlineQosPolicy,
2404 /// Liveliness (requested).
2405 pub liveliness: zerodds_qos::LivelinessQosPolicy,
2406 /// Ownership.
2407 pub ownership: zerodds_qos::OwnershipKind,
2408 /// Partition.
2409 pub partition: Vec<String>,
2410 /// UserData QoS (Spec §2.2.3.1).
2411 pub user_data: Vec<u8>,
2412 /// TopicData QoS (Spec §2.2.3.3).
2413 pub topic_data: Vec<u8>,
2414 /// GroupData QoS (Spec §2.2.3.2).
2415 pub group_data: Vec<u8>,
2416 /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up).
2417 pub type_identifier: zerodds_types::TypeIdentifier,
2418 /// TypeConsistencyEnforcement (XTypes §7.6.3.7) — controls how strictly
2419 /// the reader match checks XTypes compatibility.
2420 pub type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2421
2422 /// D.5g — per-reader override of the DataRepresentation accept list.
2423 /// `None` = use `RuntimeConfig::data_representation_offer`.
2424 /// `Some(vec)` = overridden per reader (e.g. `[XCDR1]` for
2425 /// a reader that accepts only legacy XCDR1 wire).
2426 pub data_representation_offer: Option<Vec<i16>>,
2427}
2428
2429fn build_publication_data(
2430 owner_prefix: GuidPrefix,
2431 writer_eid: EntityId,
2432 cfg: &UserWriterConfig,
2433 runtime_offer: &[i16],
2434 user_locator: Locator,
2435) -> zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2436 use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2437 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2438 key: Guid::new(owner_prefix, writer_eid),
2439 participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2440 topic_name: cfg.topic_name.clone(),
2441 type_name: cfg.type_name.clone(),
2442 durability: cfg.durability,
2443 reliability: ReliabilityQosPolicy {
2444 kind: if cfg.reliable {
2445 ReliabilityKind::Reliable
2446 } else {
2447 ReliabilityKind::BestEffort
2448 },
2449 max_blocking_time: QosDuration::from_millis(100_i32),
2450 },
2451 ownership: cfg.ownership,
2452 ownership_strength: cfg.ownership_strength,
2453 liveliness: cfg.liveliness,
2454 deadline: cfg.deadline,
2455 lifespan: cfg.lifespan,
2456 partition: cfg.partition.clone(),
2457 user_data: cfg.user_data.clone(),
2458 topic_data: cfg.topic_data.clone(),
2459 group_data: cfg.group_data.clone(),
2460 type_information: None,
2461 // D.5g — PID_DATA_REPRESENTATION (XTypes 1.3 §7.6.3.1.1, RTPS 2.5
2462 // PID 0x0073). Per-Writer-Override (cfg.data_representation_offer)
2463 // overrides the RuntimeConfig default.
2464 data_representation: cfg
2465 .data_representation_offer
2466 .clone()
2467 .unwrap_or_else(|| runtime_offer.to_vec()),
2468 // Security: the PolicyEngine fills this later. Default
2469 // None = legacy behavior (no EndpointSecurityInfo PID).
2470 security_info: None,
2471 // .B — RPC discovery PIDs. Default None: no RPC endpoint;
2472 // the RpcEndpoint builder fills these fields.
2473 service_instance_name: None,
2474 related_entity_guid: None,
2475 topic_aliases: None,
2476 // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2477 type_identifier: cfg.type_identifier.clone(),
2478 // DDSI-RTPS 2.5 §8.5.3.3: endpoint locator. All user endpoints
2479 // share the one `user_unicast` socket — hence the
2480 // endpoint locator equals the resolved participant locator.
2481 unicast_locators: alloc::vec![user_locator],
2482 multicast_locators: Vec::new(),
2483 }
2484}
2485
2486/// The `DataRepresentation` set a **DataReader** announces (PID_DATA_REPRESENTATION
2487/// in its SEDP subscription). Per OMG XTypes 1.3 §7.6.2, a reader with the
2488/// default (empty) policy accepts **both** XCDR1 and XCDR2 — and ZeroDDS decodes
2489/// both (the read path dispatches on the per-sample encapsulation id). So the
2490/// reader advertises every representation it can decode, not just the writer's
2491/// preferred one.
2492///
2493/// This matters cross-vendor: CycloneDDS (and legacy RTI / OpenDDS < 3.16)
2494/// default their *writers* to **XCDR1** for `@final` types (non-XTypes backward
2495/// compat). A reader that only announces XCDR2 makes those writers fail the
2496/// `DataRepresentation` RxO check — the writer never forms a connection, and the
2497/// samples are dropped before any are sent. We therefore start from the
2498/// configured offer (which fixes the *preferred* order) and ensure both XCDR2
2499/// and XCDR1 are present. A WRITER keeps the narrow offer (`build_publication_data`),
2500/// because the generated encoder emits one representation.
2501fn reader_accept_repr(configured_offer: &[i16]) -> Vec<i16> {
2502 use zerodds_rtps::publication_data::data_representation as dr;
2503 let mut out: Vec<i16> = configured_offer.to_vec();
2504 for id in [dr::XCDR2, dr::XCDR] {
2505 if !out.contains(&id) {
2506 out.push(id);
2507 }
2508 }
2509 out
2510}
2511
2512fn build_subscription_data(
2513 owner_prefix: GuidPrefix,
2514 reader_eid: EntityId,
2515 cfg: &UserReaderConfig,
2516 runtime_offer: &[i16],
2517 user_locator: Locator,
2518) -> zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2519 use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2520 zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2521 key: Guid::new(owner_prefix, reader_eid),
2522 participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2523 topic_name: cfg.topic_name.clone(),
2524 type_name: cfg.type_name.clone(),
2525 durability: cfg.durability,
2526 reliability: ReliabilityQosPolicy {
2527 kind: if cfg.reliable {
2528 ReliabilityKind::Reliable
2529 } else {
2530 ReliabilityKind::BestEffort
2531 },
2532 max_blocking_time: QosDuration::from_millis(100_i32),
2533 },
2534 ownership: cfg.ownership,
2535 liveliness: cfg.liveliness,
2536 deadline: cfg.deadline,
2537 partition: cfg.partition.clone(),
2538 user_data: cfg.user_data.clone(),
2539 topic_data: cfg.topic_data.clone(),
2540 group_data: cfg.group_data.clone(),
2541 type_information: None,
2542 // D.5g — PID_DATA_REPRESENTATION (see build_publication_data).
2543 // A per-reader override overrides the RuntimeConfig default.
2544 data_representation: cfg
2545 .data_representation_offer
2546 .clone()
2547 .unwrap_or_else(|| runtime_offer.to_vec()),
2548 content_filter: None,
2549 security_info: None,
2550 service_instance_name: None,
2551 related_entity_guid: None,
2552 topic_aliases: None,
2553 // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2554 type_identifier: cfg.type_identifier.clone(),
2555 // DDSI-RTPS 2.5 §8.5.3.2: endpoint locator (see
2556 // build_publication_data).
2557 unicast_locators: alloc::vec![user_locator],
2558 multicast_locators: Vec::new(),
2559 }
2560}
2561
2562/// The runtime of a `DomainParticipant`. Hosts all background
2563/// threads and UDP sockets.
2564pub struct DcpsRuntime {
2565 /// Participant GUID prefix (12-byte identifier, random per instance).
2566 pub guid_prefix: GuidPrefix,
2567 /// Domain id.
2568 pub domain_id: i32,
2569 /// SPDP multicast receiver socket.
2570 pub spdp_multicast_rx: Arc<UdpTransport>,
2571 /// SPDP unicast socket (for bidirectional SPDP, B2).
2572 pub spdp_unicast: Arc<UdpTransport>,
2573 /// User-data unicast transport (default user unicast, where peers
2574 /// send matched samples). Trait object: can be UDP/v4 or /v6,
2575 /// and in phase C additionally TCP or SHM (env var
2576 /// `ZERODDS_USER_TRANSPORT`). Discovery (SPDP/SEDP) stays UDP-only.
2577 pub user_unicast: Arc<dyn Transport + Send + Sync>,
2578 /// Resolved user-unicast locator (routable interface address,
2579 /// not `0.0.0.0`). Written as `PID_UNICAST_LOCATOR` into EVERY
2580 /// SEDP pub/sub announce (DDSI-RTPS 2.5 §8.5.3.2/3)
2581 /// and as the participant `DEFAULT_UNICAST_LOCATOR` in SPDP. Precomputed
2582 /// via `announce_locator`, so the endpoint and participant locators
2583 /// are guaranteed identical.
2584 pub user_announce_locator: Locator,
2585 /// Sender socket for the SPDP multicast announce (separate UdpSocket
2586 /// without SO_REUSE/SO_BIND_IP_MULTICAST, so send_to routes cleanly).
2587 spdp_mc_tx: Arc<UdpTransport>,
2588 /// SPDP beacon (sends periodic announces).
2589 spdp_beacon: Mutex<SpdpBeacon>,
2590 /// Own participant data (SPDP self-view). Handed by the in-process
2591 /// discovery fastpath as a `DiscoveredParticipant` to same-process
2592 /// peers (see [`crate::inproc`]).
2593 participant_data: ParticipantBuiltinTopicData,
2594 /// Stash of all locally announced publications/subscriptions —
2595 /// so a peer runtime starting later in the same process
2596 /// can pull our endpoints via `inproc_snapshot`
2597 /// (pull-on-creation of the in-process discovery fastpath).
2598 /// Append-only; a future patch for endpoint deletion would
2599 /// remove by GUID here.
2600 announced_pubs: Mutex<Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>>,
2601 announced_subs: Mutex<Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>>,
2602 /// SPDP reader (parses incoming beacons).
2603 spdp_reader: SpdpReader,
2604 /// Discovered remote participants (prefix → data).
2605 discovered: Arc<Mutex<DiscoveredParticipantsCache>>,
2606 /// A1 discovery-server relay: cache of the last raw SPDP datagram per
2607 /// discovered participant prefix. Only populated in `discovery_server` mode;
2608 /// used to forward a newly-joined client the SPDP of every already-known
2609 /// client (and vice versa). Empty otherwise.
2610 spdp_relay_cache: Mutex<alloc::collections::BTreeMap<GuidPrefix, Vec<u8>>>,
2611 /// SEDP stack for publication/subscription announce + discovery.
2612 pub sedp: Arc<Mutex<SedpStack>>,
2613 /// TypeLookup-Service Builtin-Endpoint-GUIDs (XTypes 1.3 §7.6.3.3.4).
2614 pub type_lookup_endpoints: TypeLookupEndpoints,
2615 /// TypeLookup server (server-side handler over the local
2616 /// TypeRegistry).
2617 pub type_lookup_server: Arc<Mutex<TypeLookupServer>>,
2618 /// TypeLookup client (client-side correlation table for outstanding
2619 /// requests).
2620 pub type_lookup_client: Arc<Mutex<TypeLookupClient>>,
2621 /// Monotonically increasing sequence number of the TL_SVC_REPLY_WRITER. Reply DATA
2622 /// carry their OWN writer_sn (instead of echoing the request SN) — the
2623 /// correlation runs via PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2),
2624 /// so a reliable cross-vendor reply reader sees no SN jumps.
2625 tl_reply_sn: core::sync::atomic::AtomicU64,
2626 /// Security builtin endpoint stack
2627 /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
2628 /// MessageSecure`). `None` as long as no security plugin is active
2629 /// — the hot path then skips any security-builtin
2630 /// demux. `Some` is set via [`DcpsRuntime::enable_security_builtins`]
2631 /// as soon as the factory has registered a plugin.
2632 pub security_builtin: Mutex<Option<Arc<Mutex<SecurityBuiltinStack>>>>,
2633 /// Monotonic "start time" — for SEDP tick clocks.
2634 start_instant: Instant,
2635 /// Local user-writer registry (EntityId → writer state).
2636 user_writers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserWriterSlot>>>>>,
2637 /// ADR-0006 side map: per user writer an optional ShmLocator bytes
2638 /// value (PID_SHM_LOCATOR in the SEDP sample). `None` = no
2639 /// same-host backend attached. The wire encoder consults
2640 /// this map on the SEDP push.
2641 shm_locators: Arc<RwLock<BTreeMap<EntityId, Vec<u8>>>>,
2642 /// Wave 4 (Spec `zerodds-zero-copy-1.0` §6): tracker for
2643 /// same-host (writer, reader) pairs. The SEDP match hook registers
2644 /// here every pair whose remote prefix carries the same host-id prefix
2645 /// as the local participant. The hot-path send consults
2646 /// the tracker and routes over SHM instead of UDP in the `Bound` state.
2647 pub same_host: Arc<crate::same_host::SameHostTracker>,
2648 /// Local user-reader registry (EntityId → reader state).
2649 user_readers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserReaderSlot>>>>>,
2650 /// Cross-vendor step 6b: peers to whom we have already sent per-endpoint
2651 /// crypto tokens (datawriter/datareader). Prevents spam on the
2652 /// repeated receipt of cyclone's tokens; sending happens only once the
2653 /// user endpoints exist (the bench creates them after handshake start).
2654 #[cfg(feature = "security")]
2655 /// Already-sent per-endpoint crypto tokens, per dedup key
2656 /// (source_endpoint ++ destination_endpoint, see `endpoint_token_key`).
2657 /// Per-token instead of per-peer, so late-matched user endpoints still
2658 /// get their tokens (#29).
2659 endpoint_tokens_sent: Arc<RwLock<alloc::collections::BTreeSet<[u8; 32]>>>,
2660 /// Peers (prefix) to whom our SEDP endpoint records have already been
2661 /// re-announced after a completed crypto-token exchange. Under rtps_/discovery_
2662 /// protection the initial SEDP burst is discarded by the peer (no key), until
2663 /// the participant crypto token arrives via Volatile; a one-time
2664 /// re-announce from that moment (the peer can now decode) brings the
2665 /// dropped SEDP up (OpenDDS flow; cyclone/FastDDS converge anyway).
2666 #[cfg(feature = "security")]
2667 sedp_reannounced: Arc<RwLock<alloc::collections::BTreeSet<[u8; 12]>>>,
2668 /// Per-endpoint crypto (DDS-Security §9.5.3.3): per local writer/reader
2669 /// EntityId its own crypto slot handle (its own key material, not the
2670 /// participant key). Used for the per-endpoint token (prepare_endpoint_
2671 /// crypto_tokens) AND the per-endpoint encode (protect_user_datagram)
2672 /// — the same key on both sides. Get-or-register lazily via
2673 /// `local_endpoint_crypto_handle`.
2674 #[cfg(feature = "security")]
2675 endpoint_crypto:
2676 Arc<RwLock<alloc::collections::BTreeMap<EntityId, zerodds_security::crypto::CryptoHandle>>>,
2677 /// Same-runtime writer→reader routes: per local writer the list
2678 /// of local readers subscribed to the same topic+type.
2679 /// Rebuilt in `recompute_intra_runtime_routes` on every
2680 /// register/unregister. Looked up in the write hot path,
2681 /// to push samples directly into the reader slot's `sample_tx`
2682 /// (intra-process loopback without an RTPS roundtrip, in parallel to the
2683 /// inproc peer path that only serves cross-runtime peers).
2684 intra_runtime_routes: Arc<RwLock<BTreeMap<EntityId, Vec<EntityId>>>>,
2685 /// Entity key counter (3 byte, incrementing). User writers use
2686 /// `0xC2` (with-key, user), user readers `0xC7`.
2687 entity_counter: AtomicU32,
2688 /// Configuration (cloned from RuntimeConfig).
2689 pub config: RuntimeConfig,
2690 /// Per-interface outbound socket pool. `None`
2691 /// when `config.interface_bindings` is empty — then
2692 /// `user_unicast` stays the only outbound socket (v1.4 path).
2693 #[cfg(feature = "security")]
2694 outbound_pool: Option<Arc<OutboundSocketPool>>,
2695 /// Writer-Liveliness-Protocol endpoint (RTPS 2.5 §8.4.13).
2696 /// Sends periodic `ParticipantMessageData` heartbeats and
2697 /// tracks last-seen per remote participant.
2698 pub wlp: Arc<Mutex<crate::wlp::WlpEndpoint>>,
2699 /// Builtin-topic reader sinks (DDS 1.4 §2.2.5). Set by the
2700 /// `DomainParticipant` constructor via `attach_builtin_sinks`;
2701 /// before that this is `None` and the discovery hot path
2702 /// drops samples silently (e.g. when the runtime is
2703 /// started directly for internal tests, without a participant).
2704 builtin_sinks: Mutex<Option<crate::builtin_subscriber::BuiltinSinks>>,
2705 /// Ignore filter (DDS 1.4 §2.2.2.2.1.14-17). Set by the
2706 /// `DomainParticipant` constructor via `attach_ignore_filter`.
2707 /// `None` means: no participant hook → no
2708 /// filtering.
2709 ignore_filter: Mutex<Option<crate::participant::IgnoreFilter>>,
2710 /// Stop flag for all worker threads (recv loops + tick loop).
2711 stop: Arc<AtomicBool>,
2712 /// Monotonic count of completed tick iterations. Incremented once per
2713 /// [`run_tick_iteration`], regardless of whether the tick is driven by the
2714 /// internal `zdds-tick` thread or an external executor (zerodds-async-1.0
2715 /// §4 `spawn_in_tokio`). Diagnostic: a stalled count means the tick loop
2716 /// stopped advancing. Read via [`DcpsRuntime::tick_count`].
2717 tick_seq: AtomicU64,
2718 /// Total SPDP announces emitted (multicast + unicast fan-out count as one).
2719 /// Diagnostic for the C3 initial-announcement burst — a fresh, peer-less
2720 /// participant should advance this fast initially. Read via
2721 /// [`DcpsRuntime::spdp_announce_count`].
2722 spdp_announce_seq: AtomicU64,
2723 /// Inconsistent-topic counter (DDS 1.4 §2.2.4.2.4). Incremented when
2724 /// matching discovers a remote endpoint carrying the same `topic_name`
2725 /// but a differing `type_name` in the SEDP cache. Read via
2726 /// [`DcpsRuntime::inconsistent_topic_count`].
2727 inconsistent_topic_seq: AtomicU64,
2728 /// D.5e Phase 3 — wake handle for the event-driven scheduler tick. `Some`
2729 /// only when started with `scheduler_tick`. Recv loops + the write path call
2730 /// [`DcpsRuntime::raise_tick_wake`] to wake the worker immediately on new
2731 /// work (so HEARTBEAT/ACKNACK/HB processing does not wait for a deadline).
2732 tick_wake: Mutex<Option<crate::scheduler::SchedulerHandle<TickEvent>>>,
2733 /// Coalesces wake raises: many incoming datagrams collapse into one wake.
2734 tick_wake_pending: AtomicBool,
2735 /// Worker thread JoinHandles. Per-socket recv threads + tick thread,
2736 /// all terminated together via `stop` (Sprint D.5b — previously
2737 /// a single single-threaded `event_loop`).
2738 handles: Mutex<Vec<JoinHandle<()>>>,
2739 /// Match-event notifier (D.5e Phase-1 quick win). Notified by the
2740 /// SEDP match path after `add_reader_proxy` / `add_writer_proxy`;
2741 /// `wait_for_matched_*` parks on it instead of polling every 20 ms.
2742 /// The mutex content is only a lock anchor for the Condvar API; there is
2743 /// no state protected by it (the count is read independently
2744 /// via `user_*_matched_count`).
2745 match_event: Arc<(Mutex<()>, Condvar)>,
2746 /// Acknowledgments event notifier. Notified when a writer
2747 /// receives an ACKNACK that advances its acked-base.
2748 /// `wait_for_acknowledgments` parks on it instead of polling every 50 ms.
2749 ack_event: Arc<(Mutex<()>, Condvar)>,
2750}
2751
2752impl core::fmt::Debug for DcpsRuntime {
2753 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2754 f.debug_struct("DcpsRuntime")
2755 .field("domain_id", &self.domain_id)
2756 .field("guid_prefix", &self.guid_prefix)
2757 .field("spdp_group", &self.config.spdp_multicast_group)
2758 .finish_non_exhaustive()
2759 }
2760}
2761
2762/// Type alias: Arc-shared slot handles from the per-slot mutex
2763/// architecture.
2764type WriterSlotArc = Arc<Mutex<UserWriterSlot>>;
2765type ReaderSlotArc = Arc<Mutex<UserReaderSlot>>;
2766
2767impl DcpsRuntime {
2768 /// The 16-byte RTPS GUID of a local writer with EntityId `eid` (this
2769 /// participant's GUID prefix ++ the entity id). Used by the cross-vendor
2770 /// iceoryx-cyclone bridge to stamp the PSMX chunk with the writer's real
2771 /// GUID so a peer that discovered the writer over RTPS SEDP associates the
2772 /// shared-memory sample with it.
2773 #[must_use]
2774 pub fn writer_guid(&self, eid: EntityId) -> [u8; 16] {
2775 Guid::new(self.guid_prefix, eid).to_bytes()
2776 }
2777
2778 // ========================================================================
2779 // --- Per-Slot-Mutex-Helpers
2780 //
2781 // The `user_writers`/`user_readers` registry is `RwLock<BTreeMap<EntityId,
2782 // Arc<Mutex<Slot>>>>`. Hot-path accesses take the read lock briefly, clone
2783 // the slot Arc and release the read lock before taking the per-slot mutex.
2784 // Parallel writes to **different** slots thereby run
2785 // without global contention.
2786 //
2787 // Slot creation/deletion takes the write lock; that is rare and
2788 // amortizes out.
2789 // ========================================================================
2790
2791 /// Returns the slot Arc for a user writer, if present.
2792 /// Hot-path form: a single read lock + Arc clone, no
2793 /// per-slot mutex. The caller takes the mutex itself.
2794 fn writer_slot(&self, eid: EntityId) -> Option<WriterSlotArc> {
2795 self.user_writers
2796 .read()
2797 .ok()
2798 .and_then(|w| w.get(&eid).cloned())
2799 }
2800
2801 /// Returns the slot Arc for a user reader, if present.
2802 fn reader_slot(&self, eid: EntityId) -> Option<ReaderSlotArc> {
2803 self.user_readers
2804 .read()
2805 .ok()
2806 .and_then(|r| r.get(&eid).cloned())
2807 }
2808
2809 /// Snapshot of all writer slots as `Vec<(EntityId, Arc)>`. Allows
2810 /// iteration without holding the registry read lock — e.g. for
2811 /// the heartbeat tick or liveliness sweep, where we potentially take every
2812 /// slot's mutex.
2813 fn writer_slots_snapshot(&self) -> Vec<(EntityId, WriterSlotArc)> {
2814 match self.user_writers.read() {
2815 Ok(w) => w.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2816 Err(_) => Vec::new(),
2817 }
2818 }
2819
2820 /// Snapshot of all reader slots — symmetric to writer_slots_snapshot.
2821 fn reader_slots_snapshot(&self) -> Vec<(EntityId, ReaderSlotArc)> {
2822 match self.user_readers.read() {
2823 Ok(r) => r.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2824 Err(_) => Vec::new(),
2825 }
2826 }
2827
2828 /// Returns the list of EntityIds of all registered writers.
2829 /// Very lightweight — no slot-Arc clone, just EntityIds.
2830 fn writer_eids(&self) -> Vec<EntityId> {
2831 match self.user_writers.read() {
2832 Ok(w) => w.keys().copied().collect(),
2833 Err(_) => Vec::new(),
2834 }
2835 }
2836
2837 /// Returns the list of EntityIds of all registered readers.
2838 fn reader_eids(&self) -> Vec<EntityId> {
2839 match self.user_readers.read() {
2840 Ok(r) => r.keys().copied().collect(),
2841 Err(_) => Vec::new(),
2842 }
2843 }
2844
2845 /// Starts a new runtime for a participant.
2846 ///
2847 /// # Errors
2848 /// `TransportError` if one of the 3 UDP sockets fails to bind
2849 /// (e.g. a port collision on the SPDP multicast port in another
2850 /// SO_REUSE-less DDS instance).
2851 pub fn start(
2852 domain_id: i32,
2853 guid_prefix: GuidPrefix,
2854 mut config: RuntimeConfig,
2855 ) -> Result<Arc<Self>> {
2856 // C1 multicast-free discovery: merge the domain-aware env `ZERODDS_PEERS`
2857 // into the (programmatic) `config.initial_peers`. Default
2858 // is both empty → pure multicast behavior.
2859 config
2860 .initial_peers
2861 .extend(parse_initial_peers_env(domain_id as u32));
2862 // SPDP multicast receiver on the spec port.
2863 // u32 → u16 enforcing, the spec port is always < 65536.
2864 let spdp_port = u16::try_from(spdp_multicast_port(domain_id as u32)).map_err(|_| {
2865 DdsError::BadParameter {
2866 what: "domain_id too large for SPDP port mapping",
2867 }
2868 })?;
2869 // Enforce the opt-in multicast allowlist: the SPDP group is the only
2870 // multicast ZeroDDS joins, so if the operator set an allowlist that
2871 // does not include it, refuse rather than silently touching a group
2872 // they excluded.
2873 if !config.multicast_allowed(config.spdp_multicast_group) {
2874 return Err(DdsError::BadParameter {
2875 what: "spdp_multicast_group is not in multicast_allowlist",
2876 });
2877 }
2878 let spdp_mc = UdpTransport::bind_multicast_v4(
2879 config.spdp_multicast_group,
2880 spdp_port,
2881 config.multicast_interface,
2882 )
2883 .map_err(|_| DdsError::TransportError {
2884 label: "spdp multicast bind",
2885 })?
2886 // Sprint D.5b: recv sockets have their own thread that
2887 // blocks waiting for data. Timeout 1 s = stop-flag polling
2888 // granularity at shutdown, NOT the tick rhythm.
2889 .with_timeout(Some(Duration::from_secs(1)))
2890 .map_err(|_| DdsError::TransportError {
2891 label: "spdp multicast set_timeout",
2892 })?;
2893
2894 // SPDP unicast: bind to the **well-known** RTPS port
2895 // (7400+250*domain+10+2*pid, Spec §9.6.1.4.1), so a
2896 // configured unicast initial peer can reach this participant
2897 // WITHOUT prior multicast (C1 multicast-free
2898 // discovery). Participant index 0,1,2,… until a free port
2899 // is found (multiple participants per host, also alongside
2900 // Cyclone/FastDDS). Fallback ephemeral if all well-known
2901 // ports are taken (then multicast discovery only).
2902 // Interface pinning (ZERODDS_INTERFACE): UNSPECIFIED = auto. If
2903 // set, ALL IP sockets bind (SPDP-uc, SPDP-mc-tx, user UDP/TCP)
2904 // to this IP → announce + egress + receive on exactly this
2905 // interface (multi-homed robustness, cf. Cyclone `NetworkInterface`).
2906 let pinned = config.multicast_interface;
2907 let (spdp_uc_raw, _spdp_participant_id) = {
2908 let mut bound = None;
2909 for pid in 0u32..120 {
2910 let Ok(port) = u16::try_from(spdp_unicast_port(domain_id as u32, pid)) else {
2911 break;
2912 };
2913 if let Ok(sock) = UdpTransport::bind_v4(pinned, port) {
2914 bound = Some((sock, pid));
2915 break;
2916 }
2917 }
2918 match bound {
2919 Some(b) => b,
2920 None => (
2921 UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2922 label: "spdp unicast bind",
2923 })?,
2924 u32::MAX,
2925 ),
2926 }
2927 };
2928 let spdp_uc = spdp_uc_raw
2929 .with_timeout(Some(Duration::from_secs(1)))
2930 .map_err(|_| DdsError::TransportError {
2931 label: "spdp unicast set_timeout",
2932 })?;
2933
2934 // User-data unicast (ephemeral port). Transport choice primarily via
2935 // `RuntimeConfig::user_transport`, fallback to the env var
2936 // `ZERODDS_USER_TRANSPORT` (bench binaries), otherwise UDPv4.
2937 // SPDP multicast stays UDPv4 — the DDSI-RTPS spec mandates
2938 // 239.255.0.1 for cross-vendor discovery; v6-only hosts
2939 // cannot discover cross-vendor (its own sprint).
2940 let (user_uc, tcp_accept_handle): (Arc<dyn Transport + Send + Sync>, _) =
2941 if !config.user_transports.is_empty() {
2942 // Multi-transport: build each kind and layer them. Preference =
2943 // config order (first match by destination locator kind wins).
2944 let mut legs: alloc::vec::Vec<Arc<dyn Transport + Send + Sync>> =
2945 alloc::vec::Vec::new();
2946 let mut tcp_handle = None;
2947 for kind in &config.user_transports {
2948 let (leg, tcp) = select_user_transport(*kind, guid_prefix, domain_id, pinned)?;
2949 legs.push(leg);
2950 if tcp.is_some() {
2951 tcp_handle = tcp;
2952 }
2953 }
2954 let layered = Arc::new(crate::layered_transport::LayeredUserTransport::new(legs));
2955 (layered, tcp_handle)
2956 } else {
2957 let user_transport_kind = config
2958 .user_transport
2959 .or_else(parse_user_transport_env)
2960 .unwrap_or(UserTransportKind::UdpV4);
2961 select_user_transport(user_transport_kind, guid_prefix, domain_id, pinned)?
2962 };
2963
2964 // Separate sender socket for the SPDP announce. Ephemeral port; with
2965 // interface pinning it binds to the pinned IP (egress source), otherwise
2966 // `0.0.0.0` (the kernel picks the outgoing interface per route).
2967 let spdp_mc_tx =
2968 UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2969 label: "spdp mc-tx bind",
2970 })?;
2971
2972 let stop = Arc::new(AtomicBool::new(false));
2973
2974 // Materialize beacon locators for cross-host interop:
2975 // with a `0.0.0.0` bind address (UNSPECIFIED) the peer would
2976 // otherwise learn a non-routable address. We resolve UNSPECIFIED
2977 // via a UDP connect probe to a non-routable IP
2978 // (no traffic, just the routing table) and announce the
2979 // resulting local interface address — cross-host-capable
2980 // without an external crate dependency.
2981 let user_locator = announce_locator(&*user_uc, config.multicast_interface);
2982 let spdp_uc_locator = announce_locator(&spdp_uc, config.multicast_interface);
2983 let participant_data = ParticipantBuiltinTopicData {
2984 guid: Guid::new(guid_prefix, EntityId::PARTICIPANT),
2985 protocol_version: ProtocolVersion::V2_5,
2986 vendor_id: VendorId::ZERODDS,
2987 default_unicast_locator: Some(user_locator),
2988 default_multicast_locator: None,
2989 metatraffic_unicast_locator: Some(spdp_uc_locator),
2990 metatraffic_multicast_locator: Some(Locator {
2991 kind: LocatorKind::UdpV4,
2992 port: u32::from(spdp_port),
2993 address: {
2994 let mut a = [0u8; 16];
2995 a[12..].copy_from_slice(&config.spdp_multicast_group.octets());
2996 a
2997 },
2998 }),
2999 domain_id: Some(domain_id as u32),
3000 // We announce the endpoints we actually
3001 // implement: SPDP (participant ann/det) + SEDP
3002 // (publications/subscriptions ann+det) + WLP (10/11) +
3003 // TypeLookup service (12/13). Cyclone/Fast-DDS filter
3004 // their proxy setup by these flags — without them
3005 // we get no SEDP/WLP peers. SEDP topic
3006 // endpoints (bits 28/29) are optional per RTPS 2.5 §8.5.4.4
3007 // and covered in ZeroDDS via synthetic DCPSTopic
3008 // derivation from pub/sub — we do not announce them,
3009 // otherwise we promise peers a non-existent
3010 // endpoint pairing. When the caller sets
3011 // `announce_secure_endpoints = true` (security
3012 // factory path), we additionally mix in the 12 secure
3013 // discovery bits (16..27, DDS-Security 1.2 §7.4.7.1).
3014 builtin_endpoint_set: {
3015 let mut mask = endpoint_flag::ALL_STANDARD;
3016 if config.announce_secure_endpoints {
3017 mask |= endpoint_flag::ALL_SECURE;
3018 }
3019 mask
3020 },
3021 // Spec default lease = 100 s; configurable via
3022 // `RuntimeConfig::participant_lease_duration`.
3023 lease_duration: qos_duration_from_std(config.participant_lease_duration),
3024 // UserData on the participant — filled from
3025 // `DomainParticipantQos::user_data` via RuntimeConfig.
3026 user_data: config.user_data.clone(),
3027 // PROPERTY_LIST: security fills this with security caps
3028 // once a PolicyEngine is configured. Default-empty
3029 // stays backward-compatible with legacy peers.
3030 properties: Default::default(),
3031 // IdentityToken/PermissionsToken are filled by the security
3032 // layer once authentication + access control are
3033 // initialized. Default `None` = legacy announce.
3034 identity_token: None,
3035 permissions_token: None,
3036 identity_status_token: None,
3037 sig_algo_info: None,
3038 kx_algo_info: None,
3039 sym_cipher_algo_info: None,
3040 // Filled by the security layer (enable_security_builtins*) —
3041 // without PID_PARTICIPANT_SECURITY_INFO foreign vendors classify
3042 // us as non-secure. Default None = legacy/plain.
3043 participant_security_info: None,
3044 };
3045 let beacon = SpdpBeacon::new(participant_data.clone());
3046 let sedp = SedpStack::new(guid_prefix, VendorId::ZERODDS);
3047 // In-process discovery fastpath: remember the multicast group before
3048 // `config` is moved into the struct literal.
3049 let inproc_group = config.spdp_multicast_group;
3050
3051 #[cfg(feature = "security")]
3052 let outbound_pool = if config.interface_bindings.is_empty() {
3053 None
3054 } else {
3055 Some(Arc::new(OutboundSocketPool::bind_all(
3056 &config.interface_bindings,
3057 )?))
3058 };
3059
3060 // WLP endpoint (RTPS 2.5 §8.4.13). The tick period is explicit
3061 // `wlp_period`, or `lease/3` when `wlp_period == ZERO`
3062 // (spec recommendation: three misses before the reader marks the
3063 // writer as not-alive).
3064 let wlp_tick_period = if config.wlp_period.is_zero() {
3065 config.participant_lease_duration / 3
3066 } else {
3067 config.wlp_period
3068 };
3069 let wlp = crate::wlp::WlpEndpoint::new(guid_prefix, VendorId::ZERODDS, wlp_tick_period);
3070
3071 let rt = Arc::new(Self {
3072 guid_prefix,
3073 domain_id,
3074 spdp_multicast_rx: Arc::new(spdp_mc),
3075 spdp_unicast: Arc::new(spdp_uc),
3076 user_unicast: user_uc,
3077 user_announce_locator: user_locator,
3078 spdp_mc_tx: Arc::new(spdp_mc_tx),
3079 spdp_beacon: Mutex::new(beacon),
3080 participant_data,
3081 announced_pubs: Mutex::new(Vec::new()),
3082 announced_subs: Mutex::new(Vec::new()),
3083 spdp_reader: SpdpReader::new(),
3084 discovered: Arc::new(Mutex::new(DiscoveredParticipantsCache::new())),
3085 spdp_relay_cache: Mutex::new(alloc::collections::BTreeMap::new()),
3086 sedp: Arc::new(Mutex::new(sedp)),
3087 type_lookup_endpoints: TypeLookupEndpoints::new(guid_prefix),
3088 type_lookup_server: Arc::new(Mutex::new(TypeLookupServer::new())),
3089 type_lookup_client: Arc::new(Mutex::new(TypeLookupClient::new())),
3090 tl_reply_sn: core::sync::atomic::AtomicU64::new(0),
3091 security_builtin: Mutex::new(None),
3092 start_instant: Instant::now(),
3093 user_writers: Arc::new(RwLock::new(BTreeMap::new())),
3094 shm_locators: Arc::new(RwLock::new(BTreeMap::new())),
3095 same_host: Arc::new(crate::same_host::SameHostTracker::new()),
3096 user_readers: Arc::new(RwLock::new(BTreeMap::new())),
3097 #[cfg(feature = "security")]
3098 endpoint_tokens_sent: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
3099 #[cfg(feature = "security")]
3100 sedp_reannounced: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
3101 #[cfg(feature = "security")]
3102 endpoint_crypto: Arc::new(RwLock::new(alloc::collections::BTreeMap::new())),
3103 intra_runtime_routes: Arc::new(RwLock::new(BTreeMap::new())),
3104 entity_counter: AtomicU32::new(1),
3105 config,
3106 stop: stop.clone(),
3107 tick_seq: AtomicU64::new(0),
3108 spdp_announce_seq: AtomicU64::new(0),
3109 inconsistent_topic_seq: AtomicU64::new(0),
3110 tick_wake: Mutex::new(None),
3111 tick_wake_pending: AtomicBool::new(false),
3112 handles: Mutex::new(Vec::new()),
3113 match_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
3114 ack_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
3115 #[cfg(feature = "security")]
3116 outbound_pool,
3117 wlp: Arc::new(Mutex::new(wlp)),
3118 builtin_sinks: Mutex::new(None),
3119 ignore_filter: Mutex::new(None),
3120 });
3121
3122 // In-process discovery fastpath: register the runtime in the process
3123 // registry so same-process+domain peers find each other
3124 // deterministically (see `crate::inproc`). Right
3125 // after, `pull-on-creation`: pull all already-announced endpoints
3126 // of existing peers into our SEDP cache — otherwise
3127 // we see peers that announced endpoints BEFORE us
3128 // only via the (lossy) UDP SEDP path.
3129 crate::inproc::register(&rt, domain_id, inproc_group);
3130 rt.inproc_pull_from_peers();
3131
3132 // Per-socket recv threads + one tick thread (Sprint D.5b).
3133 //
3134 // Previously the entire stack ran in a single event loop
3135 // that went through three blocking `recv()`s with a `tick_period`
3136 // timeout (50 ms) sequentially per iteration. On a
3137 // roundtrip each stage waited up to 50 ms for timeouts of the
3138 // front sockets before its own datagram got its turn —
3139 // yielded 5-14 ms p50.
3140 //
3141 // Refit: every relevant recv path has its own thread
3142 // that sits directly blocking on its socket and dispatches
3143 // immediately when data arrives. The tick thread does the
3144 // periodic outbound work (HEARTBEAT/resend/ACKNACK/
3145 // SPDP announce/deadline/lifespan/liveliness) and sleeps
3146 // `tick_period` between iterations.
3147 //
3148 // Lock order (deadlock avoidance): the tick thread and
3149 // recv threads contend for `rt.sedp.lock()` / `rt.wlp.lock()`.
3150 // Convention: keep lock-hold times short (handle_datagram /
3151 // tick are both fast), do not take a sub-lock under the `sedp`
3152 // or `wlp` lock.
3153 let mut handles_init: Vec<JoinHandle<()>> = Vec::with_capacity(4);
3154
3155 let rt_recv_spdp_mc = Arc::clone(&rt);
3156 let stop_recv_spdp_mc = stop.clone();
3157 handles_init.push(
3158 thread::Builder::new()
3159 .name(String::from("zdds-recv-spdp-mc"))
3160 .spawn(move || recv_spdp_multicast_loop(rt_recv_spdp_mc, stop_recv_spdp_mc))
3161 .map_err(|_| DdsError::PreconditionNotMet {
3162 reason: "spawn zdds-recv-spdp-mc thread",
3163 })?,
3164 );
3165
3166 let rt_recv_meta = Arc::clone(&rt);
3167 let stop_recv_meta = stop.clone();
3168 handles_init.push(
3169 thread::Builder::new()
3170 .name(String::from("zdds-recv-meta"))
3171 .spawn(move || recv_metatraffic_loop(rt_recv_meta, stop_recv_meta))
3172 .map_err(|_| DdsError::PreconditionNotMet {
3173 reason: "spawn zdds-recv-meta thread",
3174 })?,
3175 );
3176
3177 let rt_recv_user = Arc::clone(&rt);
3178 let stop_recv_user = stop.clone();
3179 let primary_socket = Arc::clone(&rt.user_unicast);
3180 handles_init.push(
3181 thread::Builder::new()
3182 .name(String::from("zdds-recv-user"))
3183 .spawn(move || recv_user_data_loop(rt_recv_user, primary_socket, stop_recv_user))
3184 .map_err(|_| DdsError::PreconditionNotMet {
3185 reason: "spawn zdds-recv-user thread",
3186 })?,
3187 );
3188
3189 // TCPv4 variant: a separate accept worker (TcpTransport has
3190 // no implicit accept thread in the constructor — accept_one()
3191 // must be called explicitly).
3192 if let Some(tcp_arc) = tcp_accept_handle {
3193 let stop_accept = stop.clone();
3194 handles_init.push(
3195 thread::Builder::new()
3196 .name(String::from("zdds-tcp-accept"))
3197 .spawn(move || {
3198 while !stop_accept.load(Ordering::Relaxed) {
3199 // accept_one() blocks until connection +
3200 // handshake; on EOF it returns Ok(()) and
3201 // we accept the next peer.
3202 let _ = tcp_arc.accept_one();
3203 }
3204 })
3205 .map_err(|_| DdsError::PreconditionNotMet {
3206 reason: "spawn zdds-tcp-accept thread",
3207 })?,
3208 );
3209 }
3210
3211 // Opt-3 (Spec `zerodds-zero-copy-1.0` §9): additional
3212 // SO_REUSEPORT recv workers. Each binds to the same
3213 // user_unicast port; the kernel distributes incoming datagrams via
3214 // flow hash. On a bind error (e.g. a platform without
3215 // SO_REUSEPORT support) the worker is skipped and the
3216 // runtime continues with the available workers.
3217 if rt.config.extra_recv_threads > 0 {
3218 let user_port = u16::try_from(rt.user_unicast.local_locator().port).unwrap_or(0);
3219 // With an active security config, share the first interface bind address;
3220 // otherwise INADDR_ANY (the kernel chooses).
3221 #[cfg(feature = "security")]
3222 let bind_addr = rt
3223 .config
3224 .interface_bindings
3225 .first()
3226 .map(|spec| spec.bind_addr)
3227 .unwrap_or(Ipv4Addr::UNSPECIFIED);
3228 #[cfg(not(feature = "security"))]
3229 let bind_addr = Ipv4Addr::UNSPECIFIED;
3230 for i in 0..rt.config.extra_recv_threads {
3231 let extra_socket =
3232 match UdpTransport::bind_v4_reuse(bind_addr, user_port) {
3233 Ok(t) => Arc::new(t.with_timeout(Some(Duration::from_secs(1))).map_err(
3234 |_| DdsError::TransportError {
3235 label: "extra-recv set_timeout failed",
3236 },
3237 )?),
3238 Err(_) => break, // SO_REUSEPORT not available — skip.
3239 };
3240 let rt_extra = Arc::clone(&rt);
3241 let stop_extra = stop.clone();
3242 let name = format!("zdds-recv-user-{}", i + 1);
3243 handles_init.push(
3244 thread::Builder::new()
3245 .name(name)
3246 .spawn(move || recv_user_data_loop(rt_extra, extra_socket, stop_extra))
3247 .map_err(|_| DdsError::PreconditionNotMet {
3248 reason: "spawn zdds-recv-user-N thread",
3249 })?,
3250 );
3251 }
3252 }
3253
3254 // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6): per-owner
3255 // SHM recv loop. Polls all bound consumer entries of the
3256 // SameHostTracker round-robin and dispatches incoming
3257 // frames analogous to the UDP path. Only compiled when
3258 // the `same-host-shm` feature is on.
3259 #[cfg(feature = "same-host-shm")]
3260 {
3261 let rt_recv_shm = Arc::clone(&rt);
3262 let stop_recv_shm = stop.clone();
3263 handles_init.push(
3264 thread::Builder::new()
3265 .name(String::from("zdds-recv-shm"))
3266 .spawn(move || recv_user_shm_loop(rt_recv_shm, stop_recv_shm))
3267 .map_err(|_| DdsError::PreconditionNotMet {
3268 reason: "spawn zdds-recv-shm thread",
3269 })?,
3270 );
3271 }
3272
3273 // zerodds-async-1.0 §4: with `external_tick`, the tick loop is driven
3274 // by an external executor (tokio via `spawn_in_tokio`) rather than a
3275 // dedicated thread — so we skip the spawn here. `stop` is dropped; the
3276 // driver observes shutdown via `rt.stop` (set in `Drop`/`stop()`).
3277 if rt.config.external_tick {
3278 drop(stop);
3279 } else if rt.config.scheduler_tick {
3280 // D.5e Phase 3 — event-driven scheduler tick. Create the scheduler
3281 // up front, publish its wake handle so recv loops + the write path
3282 // can `raise_tick_wake`, then drive the (unchanged) tick from the
3283 // deadline-heap worker.
3284 let (scheduler, handle) =
3285 crate::scheduler::Scheduler::<TickEvent>::new(SCHEDULER_IDLE_FLOOR);
3286 if let Ok(mut g) = rt.tick_wake.lock() {
3287 *g = Some(handle.clone());
3288 }
3289 let rt_tick = Arc::clone(&rt);
3290 let stop_tick = stop;
3291 handles_init.push(
3292 thread::Builder::new()
3293 .name(String::from("zdds-tick-sched"))
3294 .spawn(move || scheduler_tick_loop(rt_tick, stop_tick, scheduler, handle))
3295 .map_err(|_| DdsError::PreconditionNotMet {
3296 reason: "spawn zdds-tick-sched thread",
3297 })?,
3298 );
3299 } else {
3300 let rt_tick = Arc::clone(&rt);
3301 let stop_tick = stop;
3302 handles_init.push(
3303 thread::Builder::new()
3304 .name(String::from("zdds-tick"))
3305 .spawn(move || tick_loop(rt_tick, stop_tick))
3306 .map_err(|_| DdsError::PreconditionNotMet {
3307 reason: "spawn zdds-tick thread",
3308 })?,
3309 );
3310 }
3311
3312 let mut guard = rt
3313 .handles
3314 .lock()
3315 .map_err(|_| DdsError::PreconditionNotMet {
3316 reason: "runtime handles mutex poisoned",
3317 })?;
3318 *guard = handles_init;
3319 drop(guard);
3320
3321 Ok(rt)
3322 }
3323
3324 /// Local unicast locator for user data (announced in SPDP).
3325 #[must_use]
3326 pub fn user_locator(&self) -> zerodds_rtps::wire_types::Locator {
3327 self.user_unicast.local_locator()
3328 }
3329
3330 /// Local unicast locator for SPDP metatraffic.
3331 #[must_use]
3332 pub fn spdp_unicast_locator(&self) -> zerodds_rtps::wire_types::Locator {
3333 self.spdp_unicast.local_locator()
3334 }
3335
3336 /// Returns the `BuiltinEndpointSet` bitmask that the runtime
3337 /// currently announces in the SPDP beacon. Used for tests + diagnostics;
3338 /// production consumers should decode the SPDP beacon
3339 /// themselves.
3340 #[must_use]
3341 pub fn announced_builtin_endpoint_set(&self) -> u32 {
3342 self.spdp_beacon
3343 .lock()
3344 .map(|b| b.data.builtin_endpoint_set)
3345 .unwrap_or(0)
3346 }
3347
3348 /// Registers a `TypeObject` in the local TypeLookup server
3349 /// registry. Other participants can then query this type via
3350 /// a `getTypes` request (XTypes 1.3 §7.6.3.3.4).
3351 ///
3352 /// Returns the `EquivalenceHash` of the registered type
3353 /// (the caller can embed it e.g. in `PublicationBuiltinTopicData` as a
3354 /// PID_TYPE_INFORMATION hint).
3355 ///
3356 /// # Errors
3357 /// `DdsError::PreconditionNotMet` on lock poisoning or a hash
3358 /// computation error.
3359 pub fn register_type_object(
3360 &self,
3361 obj: zerodds_types::type_object::TypeObject,
3362 ) -> Result<zerodds_types::EquivalenceHash> {
3363 let hash = zerodds_types::compute_hash(&obj).map_err(|_| DdsError::PreconditionNotMet {
3364 reason: "type hash computation failed",
3365 })?;
3366 let mut server =
3367 self.type_lookup_server
3368 .lock()
3369 .map_err(|_| DdsError::PreconditionNotMet {
3370 reason: "type_lookup_server mutex poisoned",
3371 })?;
3372 match obj {
3373 zerodds_types::type_object::TypeObject::Minimal(m) => {
3374 server.registry.insert_minimal(hash, m);
3375 }
3376 zerodds_types::type_object::TypeObject::Complete(c) => {
3377 server.registry.insert_complete(hash, c);
3378 }
3379 _ => {
3380 return Err(DdsError::PreconditionNotMet {
3381 reason: "unknown TypeObject variant",
3382 });
3383 }
3384 }
3385 Ok(hash)
3386 }
3387
3388 /// Sends a `getTypes` request to a discovered peer and
3389 /// returns a `RequestId` with which the caller can correlate the
3390 /// asynchronous reply later (XTypes 1.3
3391 /// §7.6.3.3.4 + `TypeLookupClient::handle_reply`).
3392 ///
3393 /// `peer` must be in `discovered_participants()` — otherwise
3394 /// `None` is returned (no known peer locator). On a
3395 /// successful send the request sample-identity sequence
3396 /// is returned as the `RequestId`; an incoming reply is correlated on
3397 /// this sequence ID.
3398 ///
3399 /// # Errors
3400 /// `DdsError::PreconditionNotMet` on encode errors or lock
3401 /// poisoning.
3402 pub fn send_type_lookup_request(
3403 &self,
3404 peer: zerodds_rtps::wire_types::GuidPrefix,
3405 type_hashes: &[zerodds_types::EquivalenceHash],
3406 ) -> Result<Option<zerodds_discovery::type_lookup::RequestId>> {
3407 use alloc::sync::Arc as AllocArc;
3408 use zerodds_discovery::type_lookup::request_types_payload;
3409 use zerodds_rtps::datagram::encode_data_datagram;
3410 use zerodds_rtps::header::RtpsHeader;
3411 use zerodds_rtps::submessages::DataSubmessage;
3412 use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber};
3413
3414 // Find peer's user-unicast locator (default-unicast first;
3415 // fallback metatraffic-unicast). TypeLookup datagrams go via
3416 // the user-unicast path — the peer DCPS runtime has a
3417 // shared receive loop there for SEDP/user data/TypeLookup.
3418 let target = {
3419 let discovered = self
3420 .discovered
3421 .lock()
3422 .map_err(|_| DdsError::PreconditionNotMet {
3423 reason: "discovered mutex poisoned",
3424 })?;
3425 let Some(dp) = discovered.get(&peer) else {
3426 return Ok(None);
3427 };
3428 dp.data
3429 .default_unicast_locator
3430 .or(dp.data.metatraffic_unicast_locator)
3431 };
3432 let Some(target) = target else {
3433 return Ok(None);
3434 };
3435
3436 // Allocate RequestId (client-side incrementing sequence). Reply
3437 // correlation runs via the `handle_reply` callback. We
3438 // register a callback that feeds the returned
3439 // TypeObjects into the local `TypeLookupServer.registry`
3440 // (XTypes 1.3 §7.6.3.3.4): hash-by-hash, separately
3441 // for Minimal and Complete variants. This way a hash that
3442 // was resolved once is recognized for future `has_type_for_hash`
3443 // checks (= no re-requests).
3444 let mut client =
3445 self.type_lookup_client
3446 .lock()
3447 .map_err(|_| DdsError::PreconditionNotMet {
3448 reason: "type_lookup_client mutex poisoned",
3449 })?;
3450 let type_ids: alloc::vec::Vec<zerodds_types::TypeIdentifier> = type_hashes
3451 .iter()
3452 .map(|h| zerodds_types::TypeIdentifier::EquivalenceHashMinimal(*h))
3453 .collect();
3454 let server_for_cb = Arc::clone(&self.type_lookup_server);
3455 let cb = Box::new(
3456 move |reply: zerodds_discovery::type_lookup::TypeLookupReply| {
3457 let zerodds_discovery::type_lookup::TypeLookupReply::Types(types_reply) = reply
3458 else {
3459 return;
3460 };
3461 let Ok(mut server) = server_for_cb.lock() else {
3462 return;
3463 };
3464 for t in &types_reply.types {
3465 match t {
3466 zerodds_types::type_lookup::ReplyTypeObject::Minimal(m) => {
3467 let to = zerodds_types::type_object::TypeObject::Minimal(m.clone());
3468 if let Ok(h) = zerodds_types::compute_hash(&to) {
3469 server.registry.insert_minimal(h, m.clone());
3470 }
3471 }
3472 zerodds_types::type_lookup::ReplyTypeObject::Complete(c) => {
3473 let to = zerodds_types::type_object::TypeObject::Complete(c.clone());
3474 if let Ok(h) = zerodds_types::compute_hash(&to) {
3475 server.registry.insert_complete(h, c.clone());
3476 }
3477 }
3478 }
3479 }
3480 },
3481 );
3482 let request_id = client.request_types(type_ids.clone(), cb);
3483 drop(client);
3484
3485 // Encode the wire request payload (PL_CDR_LE-Encapsulation).
3486 let body = request_types_payload(&type_ids).map_err(|_| DdsError::PreconditionNotMet {
3487 reason: "type_lookup request payload encode failed",
3488 })?;
3489 let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
3490 payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
3491 payload.extend_from_slice(&body);
3492
3493 // Use the RequestId as the writer_sn so the peer-side reply can
3494 // echo it for correlation (XTypes §7.6.3.3.3 Sample-Identity).
3495 let id_u64 = request_id.0;
3496 let sn =
3497 SequenceNumber::from_high_low((id_u64 >> 32) as i32, (id_u64 & 0xFFFF_FFFF) as u32);
3498 let header = RtpsHeader {
3499 protocol_version: ProtocolVersion::CURRENT,
3500 vendor_id: VendorId::ZERODDS,
3501 guid_prefix: self.guid_prefix,
3502 };
3503 let data = DataSubmessage {
3504 extra_flags: 0,
3505 reader_id: EntityId::TL_SVC_REQ_READER,
3506 writer_id: EntityId::TL_SVC_REQ_WRITER,
3507 writer_sn: sn,
3508 inline_qos: None,
3509 key_flag: false,
3510 non_standard_flag: false,
3511 serialized_payload: AllocArc::from(payload.into_boxed_slice()),
3512 };
3513 let datagram =
3514 encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
3515 reason: "type_lookup request datagram encode failed",
3516 })?;
3517
3518 if is_routable_user_locator(&target) {
3519 let _ = self.user_unicast.send(&target, &datagram);
3520 }
3521 Ok(Some(request_id))
3522 }
3523
3524 /// Activates the security builtin endpoint stack
3525 /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
3526 /// MessageSecure`). Typically called by the factory
3527 /// once a security plugin is registered on the participant.
3528 /// Idempotent: a second call has no effect. Returns the (possibly
3529 /// freshly created) stack handle.
3530 pub fn enable_security_builtins(
3531 &self,
3532 vendor_id: VendorId,
3533 ) -> Arc<Mutex<SecurityBuiltinStack>> {
3534 self.install_security_stack(SecurityBuiltinStack::new(self.guid_prefix, vendor_id))
3535 }
3536
3537 /// Like [`enable_security_builtins`](Self::enable_security_builtins),
3538 /// but with an active auth-handshake driver (FU2 Gap 4). The stack
3539 /// is built via [`SecurityBuiltinStack::with_auth`]: the shared
3540 /// `auth` plugin (= the same instance that hangs on the crypto gate as
3541 /// the `SharedSecretProvider`) drives the PKI handshake as soon as
3542 /// a peer with stateless bits + identity token is discovered.
3543 ///
3544 /// `local_identity` comes from `validate_local_identity`; the local
3545 /// 16-byte participant GUID is derived from the `guid_prefix`.
3546 ///
3547 /// Idempotent (first-wins): if a stack is already active — even one
3548 /// built without auth — that one is returned and the freshly
3549 /// built one discarded.
3550 #[cfg(feature = "security")]
3551 pub fn enable_security_builtins_with_auth(
3552 self: &Arc<Self>,
3553 vendor_id: VendorId,
3554 auth: Arc<Mutex<dyn zerodds_security::authentication::AuthenticationPlugin>>,
3555 local_identity: zerodds_security::authentication::IdentityHandle,
3556 ) -> Arc<Mutex<SecurityBuiltinStack>> {
3557 let local_guid = Guid::new(self.guid_prefix, EntityId::PARTICIPANT).to_bytes();
3558 // Announce the local IdentityToken in the SPDP beacon (PID_IDENTITY_TOKEN,
3559 // FU2 Gap 7c) + set the stateless/volatile-secure bits, so peers
3560 // initiate the auth handshake. Before moving `auth` into the stack.
3561 if let Ok(mut plugin) = auth.lock() {
3562 if let Ok(token) = plugin.get_identity_token(local_identity) {
3563 // PID_PERMISSIONS_TOKEN (§7.4.1.5, S4 point 1): secure
3564 // vendors (cyclone/FastDDS) start validate_remote_identity
3565 // only when SPDP carries identity_token AND permissions_token;
3566 // otherwise we stay non-secure and all endpoints are "not
3567 // allowed". Empty if no permissions are configured.
3568 let perm_token = plugin.get_permissions_token();
3569 let pdata = if let Ok(mut beacon) = self.spdp_beacon.lock() {
3570 if !token.is_empty() {
3571 beacon.data.identity_token = Some(token);
3572 }
3573 if !perm_token.is_empty() {
3574 beacon.data.permissions_token = Some(perm_token);
3575 }
3576 // Full secure builtin endpoint set (§7.4.7.1): stateless +
3577 // VolatileSecure (22-25) PLUS secure SEDP (16-19),
3578 // secure ParticipantMessage (20-21) and DCPSParticipantsSecure
3579 // (26-27). cyclone starts validate_remote_identity + creates the
3580 // secure builtin proxies ONLY when the remote announces the full
3581 // secure set (cyclone-trace-verified) — only
3582 // 22-25 → "Non secure remote ... not allowed", no handshake.
3583 beacon.data.builtin_endpoint_set |= endpoint_flag::PUBLICATIONS_SECURE_WRITER
3584 | endpoint_flag::PUBLICATIONS_SECURE_READER
3585 | endpoint_flag::SUBSCRIPTIONS_SECURE_WRITER
3586 | endpoint_flag::SUBSCRIPTIONS_SECURE_READER
3587 | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_WRITER
3588 | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_READER
3589 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
3590 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
3591 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
3592 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
3593 | endpoint_flag::PARTICIPANT_SECURE_WRITER
3594 | endpoint_flag::PARTICIPANT_SECURE_READER;
3595 // PID_PARTICIPANT_SECURITY_INFO (§7.4.1.6): marks us as a
3596 // secure participant — mandatory, otherwise cyclone/
3597 // FastDDS treat us as non-secure and reject all endpoints.
3598 // IS_VALID on both masks; derive the participant-level
3599 // ParticipantSecurityAttributes (§9.4.2.4) from the governance:
3600 // is_{rtps,discovery,liveliness}_protected in the
3601 // attr mask, is_*_encrypted in the plugin mask. cyclone
3602 // matches the announced bits against its own governance —
3603 // a null mask with e.g. discovery=ENCRYPT is a policy
3604 // mismatch and cyclone then establishes NO secured
3605 // participant crypto handshake (bug source protected discovery).
3606 use zerodds_rtps::participant_security_info::{
3607 ParticipantSecurityInfo, attrs, plugin_attrs,
3608 };
3609 let (mut a, mut p) = (attrs::IS_VALID, plugin_attrs::IS_VALID);
3610 if let Some(gate) = self.config.security.as_ref() {
3611 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
3612 let disc = gate.discovery_protection().unwrap_or(ProtectionLevel::None);
3613 let live = gate
3614 .liveliness_protection()
3615 .unwrap_or(ProtectionLevel::None);
3616 if rtps != ProtectionLevel::None {
3617 a |= attrs::IS_RTPS_PROTECTED;
3618 }
3619 if disc != ProtectionLevel::None {
3620 a |= attrs::IS_DISCOVERY_PROTECTED;
3621 }
3622 if live != ProtectionLevel::None {
3623 a |= attrs::IS_LIVELINESS_PROTECTED;
3624 }
3625 if rtps == ProtectionLevel::Encrypt {
3626 p |= plugin_attrs::IS_RTPS_ENCRYPTED;
3627 }
3628 if disc == ProtectionLevel::Encrypt {
3629 p |= plugin_attrs::IS_DISCOVERY_ENCRYPTED;
3630 }
3631 if live == ProtectionLevel::Encrypt {
3632 p |= plugin_attrs::IS_LIVELINESS_ENCRYPTED;
3633 }
3634 }
3635 beacon.data.participant_security_info = Some(ParticipantSecurityInfo {
3636 participant_security_attributes: a,
3637 plugin_participant_security_attributes: p,
3638 });
3639 // c.pdata (§9.3.2.5.2, S4 root 6+7): our own
3640 // ParticipantBuiltinTopicData as PL_CDR_**BE** — the replier
3641 // (cyclone) deserializes c.pdata strictly as a big-endian
3642 // ParameterList and binds the participant_guid to the
3643 // authenticated identity. LE → "payload too long".
3644 Some(beacon.data.to_pl_cdr_be())
3645 } else {
3646 None
3647 };
3648 if let Some(pd) = pdata {
3649 plugin.set_local_participant_data(pd);
3650 }
3651 }
3652 }
3653 let stack = self.install_security_stack(SecurityBuiltinStack::with_auth(
3654 self.guid_prefix,
3655 vendor_id,
3656 auth,
3657 local_identity,
3658 local_guid,
3659 ));
3660 // FU2 S3: kick off in-process participant discovery + handshake trigger
3661 // deterministically — decouples the secured discovery from the
3662 // flaky multicast path (codepit LXC). Bidirectional, idempotent.
3663 self.inproc_announce_participant();
3664 // FU2 S3: immediate token-carrying SPDP re-announce (event-driven).
3665 self.announce_spdp_now();
3666 stack
3667 }
3668
3669 /// Installs a freshly built `SecurityBuiltinStack` into the
3670 /// runtime slot (idempotent) and catches up on peers already
3671 /// discovered via SPDP. Shared core of
3672 /// [`enable_security_builtins`](Self::enable_security_builtins) and
3673 /// [`enable_security_builtins_with_auth`](Self::enable_security_builtins_with_auth).
3674 fn install_security_stack(
3675 &self,
3676 fresh: SecurityBuiltinStack,
3677 ) -> Arc<Mutex<SecurityBuiltinStack>> {
3678 // Lock poisoning is a bug indicator here (an earlier panic in the
3679 // hot path). In that case we return a fresh, isolated
3680 // stack — the caller gets at least a
3681 // functional slot, but the hot path writes its mutations
3682 // into the unlocked original. In production code this does not happen;
3683 // in tests (where poisoning can occur) this is a
3684 // best-effort recovery.
3685 let mut slot = match self.security_builtin.lock() {
3686 Ok(g) => g,
3687 Err(_) => {
3688 return Arc::new(Mutex::new(fresh));
3689 }
3690 };
3691 if let Some(existing) = slot.as_ref() {
3692 return Arc::clone(existing);
3693 }
3694 let stack = Arc::new(Mutex::new(fresh));
3695 // Catch up on already-discovered peers (discovery may have already
3696 // seen SPDP beacons before the plugin was activated).
3697 if let Ok(cache) = self.discovered.lock() {
3698 if let Ok(mut s) = stack.lock() {
3699 for peer in cache.iter() {
3700 s.handle_remote_endpoints(peer);
3701 }
3702 }
3703 }
3704 *slot = Some(Arc::clone(&stack));
3705 // Protected discovery (DDS-Security §8.4.2.4): if the governance demands
3706 // `discovery_protection_kind != NONE`, the SedpStack routes secured
3707 // endpoints via the secure SEDP (DCPSPublicationsSecure/Subscriptions
3708 // Secure) instead of plaintext — the runtime send path protects their DATA/
3709 // HEARTBEAT/GAP with the participant data key. Set before the first
3710 // announce_* (endpoint creation follows the security activation).
3711 #[cfg(feature = "security")]
3712 if let Some(gate) = self.config.security.as_ref() {
3713 let protected = gate
3714 .discovery_protection()
3715 .map(|l| l != ProtectionLevel::None)
3716 .unwrap_or(false);
3717 if protected {
3718 if let Ok(mut sedp) = self.sedp.lock() {
3719 sedp.set_discovery_protected(true);
3720 }
3721 }
3722 }
3723 stack
3724 }
3725
3726 /// Snapshot handle on the security builtin stack. `None` if
3727 /// [`enable_security_builtins`](Self::enable_security_builtins)
3728 /// has not been called yet.
3729 #[must_use]
3730 pub fn security_builtin_snapshot(&self) -> Option<Arc<Mutex<SecurityBuiltinStack>>> {
3731 self.security_builtin.lock().ok()?.as_ref().map(Arc::clone)
3732 }
3733
3734 /// `assert_liveliness()` on the `DomainParticipant` (DCPS 1.4
3735 /// §2.2.3.11 MANUAL_BY_PARTICIPANT). Sends exactly one WLP heartbeat
3736 /// with `kind = MANUAL_BY_PARTICIPANT` on the next tick;
3737 /// all readers matching this participant refresh their
3738 /// last-seen timestamp. Idempotent — multiple calls within
3739 /// one tick period result in multiple wire sends up to the
3740 /// cap (`MAX_QUEUED_PULSES = 32`).
3741 pub fn assert_liveliness(&self) {
3742 if let Ok(mut wlp) = self.wlp.lock() {
3743 wlp.assert_participant();
3744 }
3745 }
3746
3747 /// `assert_liveliness()` on a `DataWriter` (DCPS 1.4 §2.2.3.11
3748 /// MANUAL_BY_TOPIC). `topic_token` is an opaque token that
3749 /// matching readers can use to associate the pulse with a concrete
3750 /// topic. We use the ZeroDDS vendor kind (Cyclone /
3751 /// Fast-DDS ignore the vendor kind, which is spec-conformant —
3752 /// MSB-set in `kind` requests "ignore unknown" behavior).
3753 pub fn assert_writer_liveliness(&self, topic_token: Vec<u8>) {
3754 if let Ok(mut wlp) = self.wlp.lock() {
3755 wlp.assert_topic(topic_token);
3756 }
3757 }
3758
3759 /// Current WLP last-seen timestamp of a remote peer (relative
3760 /// to runtime start). `None` if the peer has not sent a WLP
3761 /// heartbeat yet.
3762 #[must_use]
3763 pub fn peer_liveliness_last_seen(&self, prefix: &GuidPrefix) -> Option<Duration> {
3764 self.wlp
3765 .lock()
3766 .ok()
3767 .and_then(|w| w.peer_state(prefix).map(|s| s.last_seen))
3768 }
3769
3770 /// Returns the [`zerodds_discovery::PeerCapabilities`] of a remote
3771 /// peer, based on its most recently received SPDP beacon.
3772 /// `None` if the peer has not been discovered via SPDP yet.
3773 #[must_use]
3774 pub fn peer_capabilities(
3775 &self,
3776 prefix: &GuidPrefix,
3777 ) -> Option<zerodds_discovery::PeerCapabilities> {
3778 self.discovered
3779 .lock()
3780 .ok()
3781 .and_then(|d| d.get(prefix).map(|p| p.data.builtin_endpoint_set))
3782 .map(zerodds_discovery::PeerCapabilities::from_bits)
3783 }
3784
3785 /// Snapshot of the currently discovered remote participants.
3786 /// Key = GUID prefix, value = last seen beacon content.
3787 #[must_use]
3788 pub fn discovered_participants(&self) -> Vec<DiscoveredParticipant> {
3789 self.discovered
3790 .lock()
3791 .map(|cache| cache.iter().cloned().collect())
3792 .unwrap_or_default()
3793 }
3794
3795 /// Wires the `BuiltinSinks` of the `DomainParticipant` into the
3796 /// discovery hot path. From this
3797 /// call on, all SPDP/SEDP receive events land as samples in
3798 /// the 4 builtin-topic readers.
3799 ///
3800 /// Called by the `DomainParticipant` constructor exactly once during
3801 /// setup.
3802 pub fn attach_builtin_sinks(&self, sinks: crate::builtin_subscriber::BuiltinSinks) {
3803 if let Ok(mut guard) = self.builtin_sinks.lock() {
3804 *guard = Some(sinks);
3805 }
3806 }
3807
3808 /// Snapshot of the currently wired BuiltinSinks (internal, for the
3809 /// hot path).
3810 pub(crate) fn builtin_sinks_snapshot(&self) -> Option<crate::builtin_subscriber::BuiltinSinks> {
3811 self.builtin_sinks.lock().ok().and_then(|g| g.clone())
3812 }
3813
3814 /// Wires the `IgnoreFilter` of the `DomainParticipant` into the
3815 /// discovery hot path. From
3816 /// this call on, SPDP/SEDP receive events are checked against the
3817 /// filter before being pushed as a builtin sample or used as an
3818 /// SEDP match source.
3819 ///
3820 /// Called by the `DomainParticipant` constructor exactly once during
3821 /// setup.
3822 pub fn attach_ignore_filter(&self, filter: crate::participant::IgnoreFilter) {
3823 if let Ok(mut guard) = self.ignore_filter.lock() {
3824 *guard = Some(filter);
3825 }
3826 }
3827
3828 /// Snapshot of the currently wired IgnoreFilter (internal, for
3829 /// the hot path).
3830 pub(crate) fn ignore_filter_snapshot(&self) -> Option<crate::participant::IgnoreFilter> {
3831 self.ignore_filter.lock().ok().and_then(|g| g.clone())
3832 }
3833
3834 /// Synchronizes the protected-discovery flag of the `SedpStack` with the
3835 /// governance (`discovery_protection_kind`). Idempotent, called before every
3836 /// `announce_*` — so the flag is set correctly regardless of the
3837 /// order in which security activation and endpoint creation ran.
3838 #[cfg(feature = "security")]
3839 fn sync_sedp_discovery_protected(&self, sedp: &mut SedpStack) {
3840 if let Some(gate) = self.config.security.as_ref() {
3841 let protected = gate
3842 .discovery_protection()
3843 .map(|l| l != ProtectionLevel::None)
3844 .unwrap_or(false);
3845 sedp.set_discovery_protected(protected);
3846 }
3847 }
3848
3849 /// Announces a local publication via SEDP. The runtime
3850 /// sends the generated datagrams immediately to all already-
3851 /// discovered remote participants.
3852 ///
3853 /// # Errors
3854 /// `WireError` if encoding fails.
3855 pub fn announce_publication(
3856 &self,
3857 data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3858 ) -> Result<()> {
3859 // In-process discovery fastpath: put it in the stash so a
3860 // peer runtime starting later in the same process can pull us
3861 // via `inproc_snapshot`.
3862 if let Ok(mut v) = self.announced_pubs.lock() {
3863 v.push(data.clone());
3864 }
3865 // ADR-0006: side-map lookup. If the local user writer has a
3866 // same-host backend attached (set_shm_locator was
3867 // called), we inject PID_SHM_LOCATOR into the SEDP
3868 // sample. Otherwise pure 1:1 spec wire.
3869 let shm = self.shm_locator(data.key.entity_id);
3870 let datagrams = {
3871 let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3872 reason: "sedp poisoned",
3873 })?;
3874 // Protected discovery (§8.4.2.4): set robustly before the announce —
3875 // independent of the order of enable_security_builtins vs.
3876 // endpoint creation. `discovery_protection_kind != NONE` routes
3877 // the announce into the secure SEDP writer.
3878 #[cfg(feature = "security")]
3879 self.sync_sedp_discovery_protected(&mut sedp);
3880 let res = if let Some(ref bytes) = shm {
3881 sedp.announce_publication_with_shm_locator(data, bytes)
3882 } else {
3883 sedp.announce_publication(data)
3884 };
3885 res.map_err(|_| DdsError::WireError {
3886 message: alloc::string::String::from("sedp announce_publication"),
3887 })?
3888 };
3889 // Send outside the lock (Rc<Vec<Locator>> is !Send,
3890 // but we are on the same thread as `self` — no
3891 // problem).
3892 for dg in datagrams {
3893 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3894 for t in dg.targets.iter() {
3895 if is_routable_user_locator(t) {
3896 // §8.3.7: unicast metatraffic (SEDP DATA to the remote
3897 // metatraffic_unicast_locator) MUST go out from the metatraffic
3898 // recv socket `spdp_unicast`, NOT from the ephemeral
3899 // `spdp_mc_tx` — otherwise the peer sees a foreign
3900 // source port and sends its reliable ACKNACK/resends
3901 // to a dead port (cross-vendor SEDP stall). Identical
3902 // to `send_discovery_datagram`.
3903 let _ = self.spdp_unicast.send(t, &secured);
3904 }
3905 }
3906 }
3907 }
3908 // In-process discovery fastpath: serve same-process+domain peers
3909 // synchronously + losslessly with this publication.
3910 self.inproc_announce_publication(data);
3911 Ok(())
3912 }
3913
3914 /// Announces a local subscription via SEDP. Analogous to
3915 /// `announce_publication`.
3916 ///
3917 /// # Errors
3918 /// `WireError` if encoding fails.
3919 pub fn announce_subscription(
3920 &self,
3921 data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3922 ) -> Result<()> {
3923 if let Ok(mut v) = self.announced_subs.lock() {
3924 v.push(data.clone());
3925 }
3926 let datagrams = {
3927 let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3928 reason: "sedp poisoned",
3929 })?;
3930 #[cfg(feature = "security")]
3931 self.sync_sedp_discovery_protected(&mut sedp);
3932 sedp.announce_subscription(data)
3933 .map_err(|_| DdsError::WireError {
3934 message: alloc::string::String::from("sedp announce_subscription"),
3935 })?
3936 };
3937 for dg in datagrams {
3938 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3939 for t in dg.targets.iter() {
3940 if is_routable_user_locator(t) {
3941 // Source port: metatraffic recv socket, not spdp_mc_tx
3942 // (see announce_publication / send_discovery_datagram).
3943 let _ = self.spdp_unicast.send(t, &secured);
3944 }
3945 }
3946 }
3947 }
3948 // In-process discovery fastpath: see `announce_publication`.
3949 self.inproc_announce_subscription(data);
3950 Ok(())
3951 }
3952
3953 /// Re-announces the local SEDP endpoint records (publications +
3954 /// subscriptions) to a peer whose crypto-token exchange has just
3955 /// completed. Background: under `rtps_protection`/`discovery_
3956 /// protection` ZeroDDS wraps the SEDP message-/submessage-protected; the
3957 /// peer discards the initial SEDP burst UNTIL it has our participant crypto
3958 /// token (via Volatile). From that moment it can decode — a
3959 /// one-time re-announce brings the previously dropped SEDP up (mints fresh
3960 /// SNs; the reliable SEDP writer delivers them, HEARTBEAT/NACK retry covers a
3961 /// not-quite-ready peer timing). Once per peer (dedup).
3962 ///
3963 /// No-op without active rtps_/discovery_protection (then the announce
3964 /// went through plaintext anyway) and for already re-announced peers. Emits
3965 /// the RETAINED records directly (NO additional `announced_pubs` push).
3966 #[cfg(feature = "security")]
3967 fn re_announce_sedp_to_peer(&self, peer_prefix: GuidPrefix) {
3968 let Some(gate) = &self.config.security else {
3969 return;
3970 };
3971 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3972 let disc =
3973 gate.discovery_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3974 if !rtps && !disc {
3975 return;
3976 }
3977 // First check whether we have any local endpoints at all — the token
3978 // exchange can complete BEFORE the user endpoint creation.
3979 // Without records do NOT mark as "re-announced" (the periodic tick
3980 // retriggers as soon as the user writer/reader is announced).
3981 let pubs = self
3982 .announced_pubs
3983 .lock()
3984 .map(|v| v.clone())
3985 .unwrap_or_default();
3986 let subs = self
3987 .announced_subs
3988 .lock()
3989 .map(|v| v.clone())
3990 .unwrap_or_default();
3991 if pubs.is_empty() && subs.is_empty() {
3992 return;
3993 }
3994 {
3995 let mut set = match self.sedp_reannounced.write() {
3996 Ok(s) => s,
3997 Err(_) => return,
3998 };
3999 if !set.insert(peer_prefix.0) {
4000 return; // already re-announced
4001 }
4002 }
4003 let send_dgs = |dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>| {
4004 for dg in dgs {
4005 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
4006 for t in dg.targets.iter() {
4007 if is_routable_user_locator(t) {
4008 let _ = self.spdp_unicast.send(t, &secured);
4009 }
4010 }
4011 }
4012 }
4013 };
4014 for data in &pubs {
4015 let shm = self.shm_locator(data.key.entity_id);
4016 let dgs = {
4017 let Ok(mut sedp) = self.sedp.lock() else {
4018 continue;
4019 };
4020 self.sync_sedp_discovery_protected(&mut sedp);
4021 let res = if let Some(ref bytes) = shm {
4022 sedp.announce_publication_with_shm_locator(data, bytes)
4023 } else {
4024 sedp.announce_publication(data)
4025 };
4026 match res {
4027 Ok(d) => d,
4028 Err(_) => continue,
4029 }
4030 };
4031 send_dgs(dgs);
4032 }
4033 for data in &subs {
4034 let dgs = {
4035 let Ok(mut sedp) = self.sedp.lock() else {
4036 continue;
4037 };
4038 self.sync_sedp_discovery_protected(&mut sedp);
4039 match sedp.announce_subscription(data) {
4040 Ok(d) => d,
4041 Err(_) => continue,
4042 }
4043 };
4044 send_dgs(dgs);
4045 }
4046 }
4047
4048 /// Own participant data as a `DiscoveredParticipant` — the
4049 /// self-view that the in-process fastpath hands to peers.
4050 fn self_as_discovered_participant(&self) -> zerodds_discovery::spdp::DiscoveredParticipant {
4051 // From the LIVE SPDP beacon: after `enable_security_builtins_with_auth`
4052 // it carries the `identity_token` + the secure endpoint bits that the
4053 // `participant_data` construction snapshot does NOT have. Without these the
4054 // in-process injected DP is worthless for the security handshake trigger
4055 // (`handle_remote_endpoints`/`begin_handshake_with` need
4056 // the token). Fallback to `participant_data` on lock poisoning.
4057 let data = self
4058 .spdp_beacon
4059 .lock()
4060 .map(|b| b.data.clone())
4061 .unwrap_or_else(|_| self.participant_data.clone());
4062 zerodds_discovery::spdp::DiscoveredParticipant {
4063 sender_prefix: self.guid_prefix,
4064 sender_vendor: VendorId::ZERODDS,
4065 data,
4066 }
4067 }
4068
4069 /// In-process discovery: injects the just-announced publication
4070 /// synchronously into all same-process+domain peer runtimes.
4071 fn inproc_announce_publication(
4072 &self,
4073 data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
4074 ) {
4075 let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4076 let mut dp = None;
4077 for peer in peers {
4078 if peer.guid_prefix == self.guid_prefix {
4079 continue;
4080 }
4081 let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
4082 peer.inproc_inject_publication(dp, data);
4083 }
4084 }
4085
4086 /// In-process discovery: injects the just-announced subscription
4087 /// synchronously into all same-process+domain peer runtimes.
4088 fn inproc_announce_subscription(
4089 &self,
4090 data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4091 ) {
4092 let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4093 let mut dp = None;
4094 for peer in peers {
4095 if peer.guid_prefix == self.guid_prefix {
4096 continue;
4097 }
4098 let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
4099 peer.inproc_inject_subscription(dp, data);
4100 }
4101 }
4102
4103 /// In-process discovery (receive side): wires the remote
4104 /// participant + injects the publication into the SEDP cache and
4105 /// matches the local readers. Idempotent — an announcement arriving
4106 /// later via UDP is thereby a no-op.
4107 fn inproc_inject_publication(
4108 self: &Arc<Self>,
4109 dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4110 data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
4111 ) {
4112 // §2.2.2.2.1.17: an ignored publication/participant must not be matched.
4113 // The in-process fastpath bypasses the wire match path, so the ignore
4114 // filter must be honored here too — otherwise the Durability-Service's
4115 // own two participants (ingest + replay, same process) would match and
4116 // echo-loop despite mutually ignoring each other.
4117 if let Some(filter) = self.ignore_filter_snapshot() {
4118 let pub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
4119 let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
4120 if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
4121 return;
4122 }
4123 }
4124 let now = self.start_instant.elapsed();
4125 let is_new = self
4126 .discovered
4127 .lock()
4128 .map(|mut c| c.insert(dp.clone()))
4129 .unwrap_or(false);
4130 if let Ok(mut sedp) = self.sedp.lock() {
4131 if is_new {
4132 sedp.on_participant_discovered(dp);
4133 }
4134 sedp.cache_mut().insert_publication(data.clone(), now);
4135 }
4136 run_matching_pass(self);
4137 }
4138
4139 /// In-process discovery (receive side): like `inproc_inject_publication`
4140 /// for a subscription.
4141 fn inproc_inject_subscription(
4142 self: &Arc<Self>,
4143 dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4144 data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4145 ) {
4146 // See `inproc_inject_publication`: honor the ignore filter on the
4147 // in-process fastpath (symmetric, subscription side).
4148 if let Some(filter) = self.ignore_filter_snapshot() {
4149 let sub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
4150 let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
4151 if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
4152 return;
4153 }
4154 }
4155 let now = self.start_instant.elapsed();
4156 let is_new = self
4157 .discovered
4158 .lock()
4159 .map(|mut c| c.insert(dp.clone()))
4160 .unwrap_or(false);
4161 if let Ok(mut sedp) = self.sedp.lock() {
4162 if is_new {
4163 sedp.on_participant_discovered(dp);
4164 }
4165 sedp.cache_mut().insert_subscription(data.clone(), now);
4166 }
4167 run_matching_pass(self);
4168 }
4169
4170 /// Snapshot of our own endpoints for the `pull-on-creation` path
4171 /// of a peer runtime starting later in the same process.
4172 fn inproc_snapshot(
4173 &self,
4174 ) -> (
4175 zerodds_discovery::spdp::DiscoveredParticipant,
4176 Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>,
4177 Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>,
4178 ) {
4179 let dp = self.self_as_discovered_participant();
4180 let pubs = self
4181 .announced_pubs
4182 .lock()
4183 .map(|v| v.clone())
4184 .unwrap_or_default();
4185 let subs = self
4186 .announced_subs
4187 .lock()
4188 .map(|v| v.clone())
4189 .unwrap_or_default();
4190 (dp, pubs, subs)
4191 }
4192
4193 /// At runtime creation: ask existing same-process+domain peers
4194 /// for their already-announced endpoints and inject these into
4195 /// our SEDP cache. Symmetric counterpart to the
4196 /// announce hook (which distributes live endpoints to peers).
4197 fn inproc_pull_from_peers(self: &Arc<Self>) {
4198 let peers: Vec<Arc<DcpsRuntime>> =
4199 crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
4200 .into_iter()
4201 .filter(|rt| rt.guid_prefix != self.guid_prefix)
4202 .collect();
4203 for peer in peers {
4204 let (dp, pubs, subs) = peer.inproc_snapshot();
4205 for p in &pubs {
4206 self.inproc_inject_publication(&dp, p);
4207 }
4208 for s in &subs {
4209 self.inproc_inject_subscription(&dp, s);
4210 }
4211 }
4212 }
4213
4214 /// FU2 S3: in-process counterpart to the security part of
4215 /// [`handle_spdp_datagram`]. Wires the secure builtin endpoints of the
4216 /// discovered peer and kicks off — if it announces an `identity_token`
4217 /// — the auth handshake; the resulting AUTH datagrams
4218 /// go to the peer via UDP **unicast** (reliable loopback).
4219 /// No-op without a local security stack or without a peer `identity_token`.
4220 #[cfg(feature = "security")]
4221 fn inproc_drive_security_handshake(
4222 self: &Arc<Self>,
4223 dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4224 ) {
4225 if dp.sender_prefix == self.guid_prefix {
4226 return;
4227 }
4228 let Some(sec) = self.security_builtin_snapshot() else {
4229 return;
4230 };
4231 let dgs = if let Ok(mut s) = sec.lock() {
4232 s.note_remote_vendor(dp.sender_prefix, dp.sender_vendor);
4233 s.handle_remote_endpoints(dp);
4234 match dp.data.identity_token.as_ref() {
4235 Some(token) => s
4236 .begin_handshake_with(dp.sender_prefix, dp.data.guid.to_bytes(), token)
4237 .unwrap_or_default(),
4238 None => Vec::new(),
4239 }
4240 } else {
4241 Vec::new()
4242 };
4243 for dg in dgs {
4244 send_discovery_datagram(self, &dg.targets, &dg.bytes);
4245 }
4246 }
4247
4248 /// FU2 S3: in-process SPDP **participant** discovery. This was the real
4249 /// gap — `inproc_inject_publication`/`_subscription` only inject
4250 /// SEDP endpoints, the SPDP participant level (identity_token +
4251 /// `begin_handshake_with`) ran EXCLUSIVELY over the multicast path
4252 /// that is flaky on the codepit LXC. This hook, on activation of the
4253 /// security builtins, exchanges the participant DPs (with token) **bidirectionally** with
4254 /// all same-process+domain peers and kicks off the auth handshakes
4255 /// — deterministically, without a single multicast beacon.
4256 #[cfg(feature = "security")]
4257 fn inproc_announce_participant(self: &Arc<Self>) {
4258 let self_dp = self.self_as_discovered_participant();
4259 let peers: Vec<Arc<DcpsRuntime>> =
4260 crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
4261 .into_iter()
4262 .filter(|rt| rt.guid_prefix != self.guid_prefix)
4263 .collect();
4264 for peer in peers {
4265 // self → peer: the peer discovers US + triggers its handshake.
4266 let _ = peer
4267 .discovered
4268 .lock()
4269 .map(|mut c| c.insert(self_dp.clone()));
4270 peer.inproc_drive_security_handshake(&self_dp);
4271 // peer → self: WE discover the peer + trigger our handshake.
4272 let peer_dp = peer.self_as_discovered_participant();
4273 let _ = self
4274 .discovered
4275 .lock()
4276 .map(|mut c| c.insert(peer_dp.clone()));
4277 self.inproc_drive_security_handshake(&peer_dp);
4278 }
4279 }
4280
4281 /// C1 multicast-free discovery: sends a (possibly already security-
4282 /// transformed) SPDP beacon additionally to all configured
4283 /// unicast initial peers. No-op without peers → pure multicast behavior,
4284 /// no additional syscalls by default.
4285 fn send_spdp_to_initial_peers(&self, bytes: &[u8]) {
4286 for peer in &self.config.initial_peers {
4287 let _ = self.spdp_mc_tx.send(peer, bytes);
4288 }
4289 }
4290
4291 /// FU2 S3: sends an SPDP beacon IMMEDIATELY via multicast, instead of waiting
4292 /// for the next periodic `spdp_period` tick. Critical for the
4293 /// cross-process secured handshake: `DcpsRuntime::start` starts the
4294 /// beacon sender, whose first beacon (token-LESS) goes out BEFORE
4295 /// `enable_security_builtins_with_auth` sets the `identity_token` on the beacon.
4296 /// If a peer latches this token-less first beacon, it calls
4297 /// `begin_handshake_with` with `token=None` → no-op → the handshake NEVER
4298 /// starts. An immediate re-announce after setting the token ensures
4299 /// that the first token-carrying beacon goes out promptly.
4300 #[cfg(feature = "security")]
4301 fn announce_spdp_now(&self) {
4302 let mc_target = Locator {
4303 kind: LocatorKind::UdpV4,
4304 port: u32::from(
4305 u16::try_from(spdp_multicast_port(self.domain_id as u32)).unwrap_or(7400),
4306 ),
4307 address: {
4308 let mut a = [0u8; 16];
4309 a[12..].copy_from_slice(&self.config.spdp_multicast_group.octets());
4310 a
4311 },
4312 };
4313 if let Ok(mut beacon) = self.spdp_beacon.lock() {
4314 if let Ok(datagram) = beacon.serialize() {
4315 if let Some(secured) = secure_outbound_bytes(self, &datagram) {
4316 let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4317 // C1 multicast-free discovery: on the immediate announce too, to
4318 // the configured initial peers (ZERODDS_PEERS).
4319 self.send_spdp_to_initial_peers(&secured);
4320 // Directed unicast fan-out to already-discovered peers:
4321 // covers the order in which we discover a peer
4322 // BEFORE our security builtins (token) are active — then the
4323 // directed response in handle_spdp_datagram skipped tokenless;
4324 // announce_spdp_now() (called by enable() after the token set)
4325 // catches up with the tokened beacon promptly + LXC-multicast-
4326 // independently. Otherwise the peer waits until spdp_period.
4327 for loc in wlp_unicast_targets(&self.discovered_participants()) {
4328 let _ = self.spdp_unicast.send(&loc, &secured);
4329 }
4330 }
4331 }
4332 // FastDDS interop: additionally announce on the reliable secure SPDP
4333 // writer (0xff0101c2), so FastDDS sees our full secured
4334 // participant data over its expected channel.
4335 if self.config.enable_secure_spdp {
4336 if let Ok(datagram) = beacon.serialize_secure() {
4337 let protected = protect_secure_spdp(self, &datagram).unwrap_or(datagram);
4338 if let Some(secured) = secure_outbound_bytes(self, &protected) {
4339 let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4340 }
4341 }
4342 }
4343 }
4344 }
4345
4346 /// FU2 cross-vendor: `EndpointSecurityInfo` (PID_ENDPOINT_SECURITY_INFO,
4347 /// 0x1004) for user endpoints, derived from the governance
4348 /// `data_protection`. Foreign vendors (cyclone/FastDDS) reject, with
4349 /// `data_protection=ENCRYPT`, a user endpoint WITHOUT this PID as
4350 /// non-secure ("Non secure remote ... not allowed by security").
4351 /// `None` without an active security gate (plain).
4352 #[cfg(feature = "security")]
4353 fn user_endpoint_security_info(
4354 &self,
4355 ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4356 let gate = self.config.security.as_ref()?;
4357 let meta = gate.metadata_protection().ok()?;
4358 let data = gate.data_protection().ok()?;
4359 let disc = gate.topic_discovery_protected().unwrap_or(false);
4360 let liv = gate
4361 .liveliness_protection()
4362 .map(|l| l != ProtectionLevel::None)
4363 .unwrap_or(false);
4364 let rdp = gate.topic_read_protected().unwrap_or(false);
4365 let wrp = gate.topic_write_protected().unwrap_or(false);
4366 Some(compute_user_endpoint_attrs(meta, data, disc, liv, rdp, wrp))
4367 }
4368
4369 #[cfg(not(feature = "security"))]
4370 fn user_endpoint_security_info(
4371 &self,
4372 ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4373 None
4374 }
4375
4376 /// Registers a local user writer. The caller gets the
4377 /// writer `EntityId`; for sends via `write_user_sample(eid, ...)`.
4378 ///
4379 /// In the runtime there is **still no** automatic SEDP announce +
4380 /// matching — that comes in B4b. Currently `register_user_writer`
4381 /// is just the wiring.
4382 ///
4383 /// # Errors
4384 /// `PreconditionNotMet` if the registry mutex is poisoned.
4385 pub fn register_user_writer(&self, cfg: UserWriterConfig) -> Result<EntityId> {
4386 // Default: WithKey. Backward-compat for all test callers.
4387 self.register_user_writer_kind(cfg, true)
4388 }
4389
4390 /// Like [`register_user_writer`] but with an explicit NoKey/WithKey
4391 /// flag. Cross-vendor interop needs it: if the IDL type has no
4392 /// `@key`, the writer MUST set `is_keyed=false`, otherwise
4393 /// a remote reader rejects the DATA submessage due to an
4394 /// entityKind mismatch (Spec §9.3.1.2 table 9.1: 0x02=WithKey
4395 /// vs 0x03=NoKey).
4396 pub fn register_user_writer_kind(
4397 &self,
4398 cfg: UserWriterConfig,
4399 is_keyed: bool,
4400 ) -> Result<EntityId> {
4401 let now = self.start_instant.elapsed();
4402 let key = self.next_entity_key();
4403 let eid = if is_keyed {
4404 EntityId::user_writer_with_key(key)
4405 } else {
4406 EntityId::user_writer_no_key(key)
4407 };
4408 let writer = ReliableWriter::new(ReliableWriterConfig {
4409 guid: Guid::new(self.guid_prefix, eid),
4410 vendor_id: VendorId::ZERODDS,
4411 reader_proxies: Vec::new(),
4412 max_samples: 1024,
4413 history_kind: HistoryKind::KeepLast { depth: 32 },
4414 heartbeat_period: DEFAULT_HEARTBEAT_PERIOD,
4415 // Ethernet-safe default; the value is raised at the reader match
4416 // if all readers are same-host (see the
4417 // set_fragmentation call after add_reader_proxy).
4418 fragment_size: DEFAULT_FRAGMENT_SIZE,
4419 mtu: DEFAULT_MTU,
4420 });
4421 let mut pub_data = build_publication_data(
4422 self.guid_prefix,
4423 eid,
4424 &cfg,
4425 &self.config.data_representation_offer,
4426 self.user_announce_locator,
4427 );
4428 // FU2 cross-vendor: EndpointSecurityInfo from the governance
4429 // data_protection — otherwise cyclone/FastDDS reject the user endpoint
4430 // with data_protection=ENCRYPT as non-secure.
4431 pub_data.security_info = self.user_endpoint_security_info();
4432 self.user_writers
4433 .write()
4434 .map_err(|_| DdsError::PreconditionNotMet {
4435 reason: "user_writers poisoned",
4436 })?
4437 .insert(
4438 eid,
4439 Arc::new(Mutex::new(UserWriterSlot {
4440 writer,
4441 topic_name: cfg.topic_name.clone(),
4442 type_name: cfg.type_name.clone(),
4443 reliable: cfg.reliable,
4444 durability: cfg.durability,
4445 deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4446 // Initial `None`: the deadline window starts only on the
4447 // first real write. Prevents false misses due to
4448 // slow entity setup (e.g. Linux CI container)
4449 // before the app does its first write(). On the
4450 // first write() `last_write = Some(now)` is set,
4451 // and from then the deadline counter ticks.
4452 last_write: None,
4453 offered_deadline_missed_count: 0,
4454 liveliness_lost_count: 0,
4455 last_liveliness_assert: Some(now),
4456 offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus::default(
4457 ),
4458 lifespan_nanos: qos_duration_to_nanos(cfg.lifespan.duration),
4459 sample_insert_times: alloc::collections::VecDeque::new(),
4460 liveliness_kind: cfg.liveliness.kind,
4461 liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4462 ownership: cfg.ownership,
4463 ownership_strength: cfg.ownership_strength,
4464 partition: cfg.partition.clone(),
4465 #[cfg(feature = "security")]
4466 reader_protection: BTreeMap::new(),
4467 #[cfg(feature = "security")]
4468 locator_to_peer: BTreeMap::new(),
4469 type_identifier: cfg.type_identifier.clone(),
4470 data_rep_offer_override: cfg.data_representation_offer.clone(),
4471 // Default FINAL: irrelevant for XCDR1 (default offer)
4472 // (final==appendable==CDR_LE), correct for XCDR2 for
4473 // @final types. Appendable/mutable types set this later via
4474 // set_user_writer_wire_extensibility.
4475 wire_extensibility: zerodds_types::qos::ExtensibilityForRepr::Final,
4476 big_endian_override: false,
4477 durability_backend: None,
4478 backend_primed: false,
4479 history_depth: DEFAULT_INTRA_HISTORY_DEPTH,
4480 retained: alloc::collections::VecDeque::new(),
4481 intra_replayed_readers: alloc::collections::BTreeSet::new(),
4482 })),
4483 );
4484 // FIRST match locally, THEN announce — symmetric to
4485 // register_user_reader_kind. Avoids a peer-side match
4486 // triggered by our announce_publication
4487 // starting a data flow to us before we have wired the
4488 // ReaderProxies.
4489 self.match_local_writer_against_cache(eid);
4490 let _ = self.announce_publication(&pub_data);
4491 // Intra-runtime routing: scan local readers for a match on
4492 // (topic, type). Applies to bridge daemons with writer+reader in
4493 // the same runtime (WS/MQTT/CoAP/AMQP bridges). Without this
4494 // route the local reader gets no samples from the local
4495 // writer — the `inproc` fastpath explicitly skips self, UDP loopback
4496 // is not guaranteed, and SEDP match paths go via
4497 // the discovered cache, which does not contain self.
4498 self.recompute_intra_runtime_routes();
4499 // FU2 F-ECHO-WRITE: a user writer created AFTER handshake completion
4500 // (e.g. the event-driven echo writer in the responder/pong) must send its
4501 // per-endpoint datawriter_crypto_tokens IMMEDIATELY to the already-
4502 // authenticated peers — not only on the next tick. Otherwise
4503 // cyclone's reader stays in "waiting for approval by security" beyond
4504 // its match deadline (the event-driven pong may not tick
4505 // in time) → flaky sub=0. Idempotent via endpoint_tokens_sent dedup.
4506 #[cfg(feature = "security")]
4507 self.flush_late_endpoint_tokens();
4508 // Observability event.
4509 self.config.observability.record(
4510 &zerodds_foundation::observability::Event::new(
4511 zerodds_foundation::observability::Level::Info,
4512 zerodds_foundation::observability::Component::Dcps,
4513 "user_writer.created",
4514 )
4515 .with_attr("topic", cfg.topic_name.as_str())
4516 .with_attr("type", cfg.type_name.as_str())
4517 .with_attr("reliable", if cfg.reliable { "true" } else { "false" }),
4518 );
4519 Ok(eid)
4520 }
4521
4522 /// FU2 F-ECHO-WRITE: sends pending per-endpoint crypto tokens IMMEDIATELY to all
4523 /// already-authenticated peers. For user endpoints created AFTER handshake
4524 /// completion (event-driven echo writer in the responder): their token
4525 /// must go out before cyclone's reader match deadline expires — the periodic
4526 /// tick (or a VolatileSecure recv) is otherwise possibly too late. Idempotent
4527 /// via `endpoint_tokens_sent` dedup (double-send with the tick excluded).
4528 #[cfg(feature = "security")]
4529 fn flush_late_endpoint_tokens(&self) {
4530 let Some(stack) = self.security_builtin_snapshot() else {
4531 return;
4532 };
4533 let Ok(mut s) = stack.lock() else {
4534 return;
4535 };
4536 let now = self.start_instant.elapsed();
4537 let peers: alloc::vec::Vec<GuidPrefix> = self
4538 .config
4539 .security
4540 .as_ref()
4541 .map(|g| {
4542 g.authenticated_peer_prefixes()
4543 .into_iter()
4544 .map(GuidPrefix::from_bytes)
4545 .collect()
4546 })
4547 .unwrap_or_default();
4548 for prefix in peers {
4549 let already = self
4550 .endpoint_tokens_sent
4551 .read()
4552 .map(|set| set.clone())
4553 .unwrap_or_default();
4554 let pending =
4555 pending_endpoint_tokens(prepare_endpoint_crypto_tokens(self, prefix), &already);
4556 for ep_msg in pending {
4557 let key = endpoint_token_key(&ep_msg);
4558 let dgs = protect_volatile_outbound(
4559 self,
4560 prefix,
4561 s.volatile_writer
4562 .write_with_heartbeat(&ep_msg, now)
4563 .unwrap_or_default(),
4564 );
4565 for dg in dgs {
4566 for t in dg.targets.iter() {
4567 let _ = self.spdp_unicast.send(t, &dg.bytes);
4568 }
4569 }
4570 if let Ok(mut set) = self.endpoint_tokens_sent.write() {
4571 set.insert(key);
4572 }
4573 }
4574 // Periodic re-announce retrigger: as soon as the user writer/reader
4575 // is announced (announced_pubs/subs not empty), this catches up the
4576 // SEDP initially dropped under rtps_/discovery_protection to this
4577 // (now tokened) peer. Once per peer (dedup in the method).
4578 self.re_announce_sedp_to_peer(prefix);
4579 }
4580 }
4581
4582 /// Spec §2.2.3.5 — registers a durability-service backend on
4583 /// a writer already registered via [`register_user_writer`].
4584 /// With Durability=Transient/Persistent the backend is replayed into the
4585 /// HistoryCache on the first late-joiner match in
4586 /// `wire_writer_to_remote_reader`, so the reader gets all samples —
4587 /// including those no longer in the writer cache due to history eviction
4588 /// or those that have survived a writer restart.
4589 pub fn attach_durability_backend(
4590 &self,
4591 eid: EntityId,
4592 backend: alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>,
4593 ) -> Result<()> {
4594 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4595 what: "attach_durability_backend: unknown writer entity id",
4596 })?;
4597 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4598 reason: "user_writer slot poisoned",
4599 })?;
4600 slot.durability_backend = Some(backend);
4601 slot.backend_primed = false;
4602 Ok(())
4603 }
4604
4605 /// Sets the type extensibility of a writer (FINAL/APPENDABLE/
4606 /// MUTABLE). Affects exclusively the encapsulation header
4607 /// of the user payload (see [`user_payload_encap`]) — relevant for
4608 /// XCDR2 wire, where @appendable requires a `D_CDR2_LE` and @mutable a
4609 /// `PL_CDR2_LE` header. The codegen/FFI calls this after
4610 /// `register_user_writer*` when the type is not @final.
4611 /// Does NOT change the SEDP announce offer list.
4612 ///
4613 /// # Errors
4614 /// `BadParameter` on an unknown EntityId, `PreconditionNotMet` on a
4615 /// poisoned slot mutex.
4616 pub fn set_user_writer_wire_extensibility(
4617 &self,
4618 eid: EntityId,
4619 ext: zerodds_types::qos::ExtensibilityForRepr,
4620 ) -> Result<()> {
4621 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4622 what: "set_user_writer_wire_extensibility: unknown writer entity id",
4623 })?;
4624 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4625 reason: "user_writer slot poisoned",
4626 })?;
4627 slot.wire_extensibility = ext;
4628 Ok(())
4629 }
4630
4631 /// Registers a local user reader. Returns the reader EntityId
4632 /// and an `mpsc::Receiver` through which DataReader handles
4633 /// consume incoming samples.
4634 ///
4635 /// # Errors
4636 /// `PreconditionNotMet` if the registry mutex is poisoned.
4637 /// Registers a user reader. Returns the EntityId and an
4638 /// `mpsc::Receiver<UserSample>` — alive samples deliver payload,
4639 /// lifecycle markers carry key hash + ChangeKind.
4640 pub fn register_user_reader(
4641 &self,
4642 cfg: UserReaderConfig,
4643 ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4644 // Default: WithKey. Backward-compat for all test callers.
4645 self.register_user_reader_kind(cfg, true)
4646 }
4647
4648 /// Like [`register_user_reader`] but with an explicit NoKey/WithKey
4649 /// flag. Symmetric to [`register_user_writer_kind`] — the reader kind
4650 /// must match the writer kind.
4651 pub fn register_user_reader_kind(
4652 &self,
4653 cfg: UserReaderConfig,
4654 is_keyed: bool,
4655 ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4656 let now = self.start_instant.elapsed();
4657 let key = self.next_entity_key();
4658 let eid = if is_keyed {
4659 EntityId::user_reader_with_key(key)
4660 } else {
4661 EntityId::user_reader_no_key(key)
4662 };
4663 let reader = ReliableReader::new(ReliableReaderConfig {
4664 guid: Guid::new(self.guid_prefix, eid),
4665 vendor_id: VendorId::ZERODDS,
4666 writer_proxies: Vec::new(),
4667 max_samples_per_proxy: 256,
4668 // D.5e: 0ms = synchronous ACK response (Cyclone parity).
4669 // Previously 200ms = pre-1.0 default without spec justification.
4670 heartbeat_response_delay:
4671 zerodds_rtps::reliable_reader::DEFAULT_HEARTBEAT_RESPONSE_DELAY,
4672 // C3: ROS-realistic reassembly cap (PointCloud2/Image),
4673 // instead of the conservative rtps 1-MiB default.
4674 assembler_caps: AssemblerCaps {
4675 max_sample_bytes: self.config.max_reassembly_sample_bytes,
4676 ..AssemblerCaps::default()
4677 },
4678 });
4679 // BEST_EFFORT readers must not block delivery on a leading sequence-number
4680 // gap (RTPS §8.4.12.1) — they don't NACK to repair it, so waiting would
4681 // deadlock against e.g. an OpenDDS/RTI writer whose first sample to us is
4682 // mid-stream. Reliable readers keep strict in-order delivery.
4683 let mut reader = reader;
4684 reader.set_best_effort(!cfg.reliable);
4685 let (tx, rx) = mpsc::channel();
4686 // A DataReader announces every representation it can decode (XCDR2 +
4687 // XCDR1), not just the writer-preferred one — see `reader_accept_repr`.
4688 let reader_repr = reader_accept_repr(&self.config.data_representation_offer);
4689 let mut sub_data = build_subscription_data(
4690 self.guid_prefix,
4691 eid,
4692 &cfg,
4693 &reader_repr,
4694 self.user_announce_locator,
4695 );
4696 // FU2 cross-vendor: EndpointSecurityInfo from the governance (see writer).
4697 sub_data.security_info = self.user_endpoint_security_info();
4698 self.user_readers
4699 .write()
4700 .map_err(|_| DdsError::PreconditionNotMet {
4701 reason: "user_readers poisoned",
4702 })?
4703 .insert(
4704 eid,
4705 Arc::new(Mutex::new(UserReaderSlot {
4706 reader,
4707 topic_name: cfg.topic_name.clone(),
4708 type_name: cfg.type_name.clone(),
4709 sample_tx: tx,
4710 async_waker: Arc::new(std::sync::Mutex::new(None)),
4711 listener: None,
4712 durability: cfg.durability,
4713 deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4714 // Start time as reference (see register_user_writer).
4715 last_sample_received: Some(now),
4716 requested_deadline_missed_count: 0,
4717 requested_incompatible_qos:
4718 crate::status::RequestedIncompatibleQosStatus::default(),
4719 sample_lost_count: 0,
4720 sample_rejected: crate::status::SampleRejectedStatus::default(),
4721 samples_delivered_count: 0,
4722 liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4723 liveliness_kind: cfg.liveliness.kind,
4724 liveliness_alive_count: 0,
4725 liveliness_not_alive_count: 0,
4726 // Optimistic init: we see the writer via SEDP,
4727 // until the lease expires it counts as alive.
4728 liveliness_alive: true,
4729 liveliness_alive_writers: alloc::collections::BTreeSet::new(),
4730 ownership: cfg.ownership,
4731 partition: cfg.partition.clone(),
4732 writer_strengths: alloc::collections::BTreeMap::new(),
4733 type_identifier: cfg.type_identifier.clone(),
4734 type_consistency: cfg.type_consistency,
4735 // A2 — TIME_BASED_FILTER off by default; the C-FFI/rmw path
4736 // arms it via `set_user_reader_time_based_filter`.
4737 tbf_min_separation_nanos: 0,
4738 tbf_last_delivered: alloc::collections::BTreeMap::new(),
4739 })),
4740 );
4741 // FIRST match locally (create the writer proxy on the reader),
4742 // THEN announce. Otherwise our announce_subscription triggers a
4743 // backend replay at the peer via the in-process fastpath
4744 // (Spec §2.2.3.5), which injects DATA into *our* reader
4745 // before we have wired the matching WriterProxies — the
4746 // samples are then discarded as unknown-source
4747 // (tests `{transient,persistent}_late_joiner_receives_backend_replay`).
4748 self.match_local_reader_against_cache(eid);
4749 let _ = self.announce_subscription(&sub_data);
4750 // Intra-runtime routing: see `register_user_writer_kind`.
4751 self.recompute_intra_runtime_routes();
4752 // Observability event.
4753 self.config.observability.record(
4754 &zerodds_foundation::observability::Event::new(
4755 zerodds_foundation::observability::Level::Info,
4756 zerodds_foundation::observability::Component::Dcps,
4757 "user_reader.created",
4758 )
4759 .with_attr("topic", cfg.topic_name.as_str())
4760 .with_attr("type", cfg.type_name.as_str()),
4761 );
4762 Ok((eid, rx))
4763 }
4764
4765 /// Tears down a deleted local user **writer** (DataWriter delete / drop):
4766 /// removes its slot — which drops the contained RTPS `ReliableWriter` and
4767 /// with it the heartbeat schedule + reader proxies — rebuilds the
4768 /// intra-runtime routes, stops announcing it to late in-process joiners, and
4769 /// sends an SEDP **dispose** so remote peers drop the matched writer at once
4770 /// instead of waiting for a liveliness timeout.
4771 pub(crate) fn remove_user_writer(&self, eid: EntityId) {
4772 if let Ok(mut w) = self.user_writers.write() {
4773 w.remove(&eid);
4774 }
4775 self.recompute_intra_runtime_routes();
4776 if let Ok(mut v) = self.announced_pubs.lock() {
4777 v.retain(|p| p.key.entity_id != eid);
4778 }
4779 self.send_endpoint_dispose(eid, true);
4780 }
4781
4782 /// Tears down a deleted local user **reader** — symmetric to
4783 /// [`Self::remove_user_writer`] (drops the slot, rebuilds routes, stops
4784 /// announcing, and disposes the SEDP subscription).
4785 pub(crate) fn remove_user_reader(&self, eid: EntityId) {
4786 if let Ok(mut r) = self.user_readers.write() {
4787 r.remove(&eid);
4788 }
4789 self.recompute_intra_runtime_routes();
4790 if let Ok(mut v) = self.announced_subs.lock() {
4791 v.retain(|s| s.key.entity_id != eid);
4792 }
4793 self.send_endpoint_dispose(eid, false);
4794 }
4795
4796 /// Sends the SEDP dispose datagrams for a deleted endpoint, mirroring the
4797 /// send path of `announce_publication` (secure-wrap + routable-locator
4798 /// filter + metatraffic unicast socket).
4799 fn send_endpoint_dispose(&self, eid: EntityId, is_publication: bool) {
4800 let guid = Guid::new(self.guid_prefix, eid);
4801 let datagrams = {
4802 let mut sedp = match self.sedp.lock() {
4803 Ok(s) => s,
4804 Err(_) => return,
4805 };
4806 #[cfg(feature = "security")]
4807 self.sync_sedp_discovery_protected(&mut sedp);
4808 let res = if is_publication {
4809 sedp.dispose_publication(guid)
4810 } else {
4811 sedp.dispose_subscription(guid)
4812 };
4813 match res {
4814 Ok(d) => d,
4815 Err(_) => return,
4816 }
4817 };
4818 for dg in datagrams {
4819 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
4820 for t in dg.targets.iter() {
4821 if is_routable_user_locator(t) {
4822 let _ = self.spdp_unicast.send(t, &secured);
4823 }
4824 }
4825 }
4826 }
4827 }
4828
4829 /// Unmatches a remote **writer** from every local user reader — the inverse
4830 /// of `wire_reader_to_remote_writer`/`add_writer_proxy` (`runtime.rs:5661`).
4831 ///
4832 /// Called when a remote writer is lost: SEDP dispose of its publication
4833 /// (the immediate driver), and — once wired — also participant-lost and a
4834 /// liveliness-lease expiry. ZeroDDS previously had **no** unmatch path at
4835 /// all (the matching subsystem was add-only), so a deleted remote writer
4836 /// lingered as a stale `WriterProxy` until the local reader was itself
4837 /// dropped. Removing the proxy here makes the reader's
4838 /// `subscription_matched_status` / `discovered_publications` drop
4839 /// immediately instead of after the liveliness timeout.
4840 ///
4841 /// Idempotent: a GUID that matches no local reader is a silent no-op.
4842 pub(crate) fn remove_remote_writer(&self, guid: Guid) {
4843 for (reader_eid, r_arc) in self.reader_slots_snapshot() {
4844 let Ok(mut slot) = r_arc.lock() else { continue };
4845 if slot.reader.remove_writer_proxy(guid).is_some() {
4846 // Drop the per-writer caches keyed by the remote GUID so a
4847 // later writer reusing the same GUID starts clean (ownership
4848 // strength + intra-runtime liveliness tracking).
4849 let key = guid.to_bytes();
4850 slot.writer_strengths.remove(&key);
4851 slot.liveliness_alive_writers.remove(&key);
4852 // Release the same-host SHM pairing, if one was registered.
4853 let local_reader_guid = Guid::new(self.guid_prefix, reader_eid);
4854 self.same_host.remove(guid, local_reader_guid);
4855 }
4856 }
4857 }
4858
4859 /// Unmatches a remote **reader** from every local user writer — the inverse
4860 /// of `wire_writer_to_remote_reader`/`add_reader_proxy`. Symmetric to
4861 /// [`Self::remove_remote_writer`]; driven by an SEDP dispose of the remote
4862 /// subscription. Idempotent.
4863 pub(crate) fn remove_remote_reader(&self, guid: Guid) {
4864 for (_writer_eid, w_arc) in self.writer_slots_snapshot() {
4865 let Ok(mut slot) = w_arc.lock() else { continue };
4866 slot.writer.remove_reader_proxy(guid);
4867 }
4868 }
4869
4870 /// Rebuilds the same-runtime writer→reader routing table.
4871 /// Called in `register_user_writer_kind` and `register_user_reader_kind`
4872 /// after every endpoint create, and on endpoint removal via
4873 /// [`Self::remove_user_writer`] / [`Self::remove_user_reader`]. Per local
4874 /// writer it collects all local readers that have exactly the same
4875 /// `topic_name` and `type_name`. The lookup in the write hot path
4876 /// (`write_user_sample_borrowed`) is read-locked and cheap
4877 /// (BTreeMap lookup → Vec clone).
4878 fn recompute_intra_runtime_routes(&self) {
4879 let writer_snap = self.writer_slots_snapshot();
4880 let reader_snap = self.reader_slots_snapshot();
4881 let mut new_map: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
4882 // QR-cluster (b): writers whose route gained a new reader and which are
4883 // TransientLocal must replay their retained history to those readers.
4884 // (writer_eid, reader_eid) pairs collected here, replayed after the
4885 // routing lock is released.
4886 let mut replay_targets: Vec<(EntityId, EntityId)> = Vec::new();
4887 for (writer_eid, w_arc) in writer_snap {
4888 let (w_topic, w_type, w_partition, w_transient_local) = match w_arc.lock() {
4889 Ok(s) => (
4890 s.topic_name.clone(),
4891 s.type_name.clone(),
4892 s.partition.clone(),
4893 !matches!(s.durability, zerodds_qos::DurabilityKind::Volatile),
4894 ),
4895 Err(_) => continue,
4896 };
4897 let mut readers: Vec<EntityId> = Vec::new();
4898 for (reader_eid, r_arc) in &reader_snap {
4899 let matches = match r_arc.lock() {
4900 Ok(s) => {
4901 s.topic_name == w_topic
4902 && s.type_name == w_type
4903 // QR-cluster (c): PARTITION gates the same-runtime
4904 // match exactly as on the wire (DDS 1.4 §2.2.3.13).
4905 && partitions_overlap(&w_partition, &s.partition)
4906 }
4907 Err(_) => false,
4908 };
4909 if matches {
4910 readers.push(*reader_eid);
4911 // Schedule a TransientLocal replay if this writer has not yet
4912 // replayed to this reader.
4913 if w_transient_local {
4914 let already = w_arc
4915 .lock()
4916 .map(|s| s.intra_replayed_readers.contains(reader_eid))
4917 .unwrap_or(true);
4918 if !already {
4919 replay_targets.push((writer_eid, *reader_eid));
4920 }
4921 }
4922 }
4923 }
4924 if !readers.is_empty() {
4925 new_map.insert(writer_eid, readers);
4926 }
4927 }
4928 // Perform the TransientLocal retained-sample replay to each new reader
4929 // (DDS 1.4 §2.2.3.4 late-joiner delivery). Done outside the routing
4930 // lock; the per-writer slot lock guards `retained` + the
4931 // already-replayed dedup set.
4932 for (writer_eid, reader_eid) in replay_targets {
4933 self.intra_runtime_replay_retained(writer_eid, reader_eid);
4934 }
4935 let changed = match self.intra_runtime_routes.write() {
4936 Ok(mut g) => {
4937 let changed = *g != new_map;
4938 *g = new_map;
4939 changed
4940 }
4941 Err(_) => false,
4942 };
4943 // A new/changed intra-runtime route is a same-participant
4944 // match → wake the `wait_for_matched_{subscription,publication}` waiter
4945 // (the matched count now includes these routes).
4946 if changed {
4947 self.match_event.1.notify_all();
4948 }
4949 }
4950
4951 /// QR-cluster (b): replays a TransientLocal writer's retained samples
4952 /// (DDS 1.4 §2.2.3.4) to a single late-joining intra-runtime reader. Each
4953 /// retained Alive entry is delivered as `UserSample::Alive`; each terminal
4954 /// lifecycle entry as `UserSample::Lifecycle`, so the reader's instance
4955 /// state reflects the most recent NOT_ALIVE_DISPOSED / NOT_ALIVE_NO_WRITERS.
4956 /// Idempotent via the per-writer `intra_replayed_readers` set.
4957 fn intra_runtime_replay_retained(&self, writer_eid: EntityId, reader_eid: EntityId) {
4958 // Snapshot retained under the writer lock, mark replayed, then release.
4959 let samples: Vec<RetainedSample> = {
4960 let Some(w_arc) = self.writer_slot(writer_eid) else {
4961 return;
4962 };
4963 let Ok(mut w) = w_arc.lock() else {
4964 return;
4965 };
4966 if !w.intra_replayed_readers.insert(reader_eid) {
4967 return; // already replayed to this reader
4968 }
4969 w.retained.iter().cloned().collect()
4970 };
4971 if samples.is_empty() {
4972 return;
4973 }
4974 let Some(r_arc) = self.reader_slot(reader_eid) else {
4975 return;
4976 };
4977 let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
4978 let (listener, waker, sender) = {
4979 let Ok(r) = r_arc.lock() else {
4980 return;
4981 };
4982 (
4983 r.listener.clone(),
4984 Arc::clone(&r.async_waker),
4985 r.sample_tx.clone(),
4986 )
4987 };
4988 for s in samples {
4989 match s.lifecycle {
4990 None => {
4991 if let Some(l) = &listener {
4992 // Durability replay is little-endian (the store does not
4993 // retain the original byte order) → big_endian = 0.
4994 l(&s.payload, s.representation, 0);
4995 } else {
4996 let sample = UserSample::Alive {
4997 payload: crate::sample_bytes::SampleBytes::from_vec(s.payload.clone()),
4998 writer_guid,
4999 writer_strength: s.strength,
5000 representation: s.representation,
5001 // Durability replay: the store does not yet retain
5002 // the original byte order (ZeroDDS-internal samples
5003 // are little-endian); a big-endian peer's durable
5004 // sample would replay LE. Tracked as a durability
5005 // followup, not part of the live RTPS BE path.
5006 big_endian: false,
5007 // Durability replay: original source timestamp not
5008 // retained in the store today → reception order.
5009 source_timestamp: None,
5010 // Intra-runtime TransientLocal replay: the retained
5011 // entry does not keep the writer's original RTPS
5012 // sequence → no source sequence to forward.
5013 source_sequence_number: -1,
5014 };
5015 let _ = sender.send(sample);
5016 wake_async_waker(&waker);
5017 }
5018 }
5019 Some(kind) => {
5020 // Lifecycle markers always go to the MPSC channel (the
5021 // alive-only listener does not carry instance state).
5022 let _ = sender.send(UserSample::Lifecycle {
5023 key_hash: s.key_hash,
5024 kind,
5025 });
5026 wake_async_waker(&waker);
5027 }
5028 }
5029 }
5030 }
5031
5032 /// QR-cluster (d): delivers a lifecycle marker (dispose / unregister) to all
5033 /// matched intra-runtime readers (DDS 1.4 §2.2.2.4.2.10 / §2.2.2.4.2.7) so
5034 /// their instance state becomes NOT_ALIVE_DISPOSED / NOT_ALIVE_NO_WRITERS,
5035 /// and records it in the writer's retained buffer so a later late joiner
5036 /// also observes the terminal state. The wire path is handled separately by
5037 /// [`Self::write_user_lifecycle`].
5038 fn intra_runtime_dispatch_lifecycle(
5039 &self,
5040 writer_eid: EntityId,
5041 key_hash: [u8; 16],
5042 kind: zerodds_rtps::history_cache::ChangeKind,
5043 ) {
5044 // Record in retained (terminal marker for the key) so future late
5045 // joiners observe the NOT_ALIVE state.
5046 if let Some(w_arc) = self.writer_slot(writer_eid) {
5047 if let Ok(mut w) = w_arc.lock() {
5048 if !matches!(w.durability, zerodds_qos::DurabilityKind::Volatile) {
5049 // Replace any prior terminal marker for this key; keep the
5050 // retained alive samples (the reader saw them already, but a
5051 // brand-new late joiner needs both the data and the state).
5052 w.retained
5053 .retain(|s| !(s.lifecycle.is_some() && s.key_hash == key_hash));
5054 w.retained.push_back(RetainedSample {
5055 key_hash,
5056 payload: Vec::new(),
5057 representation: 0,
5058 strength: 0,
5059 lifecycle: Some(kind),
5060 });
5061 }
5062 }
5063 }
5064 let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
5065 Ok(g) => match g.get(&writer_eid) {
5066 Some(v) => v.clone(),
5067 None => return,
5068 },
5069 Err(_) => return,
5070 };
5071 for reader_eid in routes {
5072 let Some(slot_arc) = self.reader_slot(reader_eid) else {
5073 continue;
5074 };
5075 let (waker, sender) = {
5076 let Ok(slot) = slot_arc.lock() else {
5077 continue;
5078 };
5079 (Arc::clone(&slot.async_waker), slot.sample_tx.clone())
5080 };
5081 let _ = sender.send(UserSample::Lifecycle { key_hash, kind });
5082 wake_async_waker(&waker);
5083 }
5084 }
5085
5086 /// Same-runtime direct dispatch: pushes the just-written
5087 /// sample directly into the `sample_tx` channel of all local readers
5088 /// on the same topic+type. Avoids an RTPS wire roundtrip + UDP
5089 /// loopback for the bridge-daemon case (writer+reader in the same
5090 /// `DcpsRuntime`). Called by the write hot path after the normal
5091 /// wire dispatch.
5092 fn intra_runtime_dispatch_alive(
5093 &self,
5094 writer_eid: EntityId,
5095 payload: &[u8],
5096 writer_strength: i32,
5097 // XCDR version tag of the writer's effective offer (`0` = XCDR1,
5098 // `1` = XCDR2), matching `encap_representation`'s convention on the
5099 // wire-receive path. On the wire path the reader recovers this from
5100 // byte[1] of the encap header; here the intra-runtime payload carries
5101 // no encap header, so the writer's actual representation must be
5102 // threaded through explicitly (Bug R4 — previously hardcoded `0`,
5103 // losing the XCDR version on the same-runtime loopback path).
5104 representation: u8,
5105 ) {
5106 let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
5107 Ok(g) => match g.get(&writer_eid) {
5108 Some(v) => v.clone(),
5109 None => return,
5110 },
5111 Err(_) => return,
5112 };
5113 if routes.is_empty() {
5114 return;
5115 }
5116 let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
5117 // QR-cluster (e): LIVELINESS AUTOMATIC auto-renew. A delivered sample
5118 // proves the matched writer is alive (DDS 1.4 §2.2.3.11). For AUTOMATIC
5119 // kind the infrastructure renews liveliness implicitly, so each
5120 // intra-runtime delivery marks the writer alive at the reader.
5121 let writer_liveliness_automatic = self
5122 .writer_slot(writer_eid)
5123 .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_kind))
5124 .map(|k| matches!(k, zerodds_qos::LivelinessKind::Automatic))
5125 .unwrap_or(false);
5126 for reader_eid in routes {
5127 let Some(slot_arc) = self.reader_slot(reader_eid) else {
5128 continue;
5129 };
5130 // Hold the slot lock only for the listener/sender clone, dispatch
5131 // outside (symmetric to the data-receive path above, which
5132 // preserves exactly the same order in the DATA arm).
5133 let listener;
5134 let waker;
5135 let sender;
5136 {
5137 let Ok(mut slot) = slot_arc.lock() else {
5138 continue;
5139 };
5140 // Liveliness renew: bump alive_count once per writer-alive
5141 // transition (the reader sees this writer become alive).
5142 if writer_liveliness_automatic {
5143 let newly_alive = slot.liveliness_alive_writers.insert(writer_guid);
5144 if newly_alive {
5145 slot.liveliness_alive = true;
5146 slot.liveliness_alive_count = slot.liveliness_alive_count.saturating_add(1);
5147 }
5148 }
5149 listener = slot.listener.clone();
5150 waker = Arc::clone(&slot.async_waker);
5151 sender = slot.sample_tx.clone();
5152 }
5153 // Listener and MPSC are exclusive (see the data-arm comment):
5154 // if a listener is set, the sample only goes to it;
5155 // otherwise to the MPSC receiver.
5156 if let Some(l) = listener {
5157 // The listener signature is `(payload, representation, big_endian)`.
5158 // Intra-runtime: no encap header, so carry the writer's
5159 // actual representation tag (Bug R4); same-process delivery is
5160 // always native little-endian → big_endian = 0.
5161 l(payload, representation, 0);
5162 } else {
5163 let sample = UserSample::Alive {
5164 payload: crate::sample_bytes::SampleBytes::from_vec(payload.to_vec()),
5165 writer_guid,
5166 writer_strength,
5167 representation,
5168 // Intra-runtime same-process delivery always produces the
5169 // native little-endian wire.
5170 big_endian: false,
5171 // Intra-runtime same-process delivery bypasses the INFO_TS
5172 // wire path → reception order.
5173 source_timestamp: None,
5174 // Intra-runtime handoff has no RTPS sequence (the SN is only
5175 // assigned on the wire path); a same-process durability
5176 // service cannot dedup by it. Wire-delivered samples do.
5177 source_sequence_number: -1,
5178 };
5179 let _ = sender.send(sample);
5180 wake_async_waker(&waker);
5181 }
5182 }
5183 }
5184
5185 /// On registration / SEDP event: for a local writer `eid`
5186 /// go through all subscriptions known in the cache; on a topic+type
5187 /// match add a `ReaderProxy` to the local ReliableWriter.
5188 fn match_local_writer_against_cache(&self, eid: EntityId) {
5189 let (topic, type_name) = {
5190 let Some(arc) = self.writer_slot(eid) else {
5191 return;
5192 };
5193 let Ok(s) = arc.lock() else {
5194 return;
5195 };
5196 (s.topic_name.clone(), s.type_name.clone())
5197 };
5198 let (matches, conflict): (Vec<_>, bool) = {
5199 let sedp = match self.sedp.lock() {
5200 Ok(s) => s,
5201 Err(_) => return,
5202 };
5203 let matches = sedp
5204 .cache()
5205 .match_subscriptions(&topic, &type_name)
5206 .map(|s| s.data.clone())
5207 .collect();
5208 let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
5209 (matches, conflict)
5210 };
5211 if conflict {
5212 self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
5213 }
5214 for sub in matches {
5215 self.wire_writer_to_remote_reader(eid, &sub);
5216 }
5217 }
5218
5219 fn match_local_reader_against_cache(&self, eid: EntityId) {
5220 let (topic, type_name) = {
5221 let Some(arc) = self.reader_slot(eid) else {
5222 return;
5223 };
5224 let Ok(s) = arc.lock() else {
5225 return;
5226 };
5227 (s.topic_name.clone(), s.type_name.clone())
5228 };
5229 let (matches, conflict): (Vec<_>, bool) = {
5230 let sedp = match self.sedp.lock() {
5231 Ok(s) => s,
5232 Err(_) => return,
5233 };
5234 let matches = sedp
5235 .cache()
5236 .match_publications(&topic, &type_name)
5237 .map(|p| p.data.clone())
5238 .collect();
5239 let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
5240 (matches, conflict)
5241 };
5242 if conflict {
5243 self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
5244 }
5245 for pubd in matches {
5246 self.wire_reader_to_remote_writer(eid, &pubd);
5247 }
5248 }
5249
5250 fn wire_writer_to_remote_reader(
5251 &self,
5252 writer_eid: EntityId,
5253 sub: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
5254 ) {
5255 // §2.2.2.2.1.16: an ignored subscription must not be MATCHED (symmetric
5256 // to the publication gate in `wire_reader_to_remote_writer`). The
5257 // Durability-Service ignores its own ingest reader here so the replay
5258 // writer never delivers back to it (echo loop).
5259 if let Some(filter) = self.ignore_filter_snapshot() {
5260 let sub_h = crate::instance_handle::InstanceHandle::from_guid(sub.key);
5261 let part_h = crate::instance_handle::InstanceHandle::from_guid(sub.participant_key);
5262 if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
5263 return;
5264 }
5265 }
5266 let locators =
5267 endpoint_or_default_locators(&sub.unicast_locators, sub.key.prefix, &self.discovered);
5268 if locators.is_empty() {
5269 return;
5270 }
5271 // Backend replay datagrams (Spec §2.2.3.5). Sent after
5272 // the slot-lock release, so the send path does not run under
5273 // the slot mutex.
5274 let mut replay_dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram> = Vec::new();
5275 if let Some(slot_arc) = self.writer_slot(writer_eid) {
5276 if let Ok(mut slot) = slot_arc.lock() {
5277 let slot = &mut *slot;
5278 // Idempotency gate: if a ReaderProxy already exists for this
5279 // remote reader, the match has already run
5280 // once. A re-wire (e.g. when the SEDP announcement
5281 // arrives at the writer both via the in-process fastpath and via UDP)
5282 // would REPLACE the proxy via
5283 // `add_reader_proxy` — and thereby reset
5284 // `highest_acked_sn`/`highest_sent_sn`.
5285 // The next tick then emits an invalid HEARTBEAT
5286 // with `first_sn > last_sn` (cache_min=N, highest_acked+1=N+1),
5287 // the reader interprets this as "everything before first_sn is
5288 // lost" and advances `delivered_up_to` past not-yet-
5289 // delivered backend replay samples (tests
5290 // `{transient,persistent}_late_joiner_receives_backend_replay`
5291 // — 3% flake without the gate).
5292 if slot
5293 .writer
5294 .reader_proxies()
5295 .iter()
5296 .any(|p| p.remote_reader_guid == sub.key)
5297 {
5298 return;
5299 }
5300 // --- QoS-Compatibility ---
5301 // Spec OMG DDS 1.4 §2.2.3.6: Writer offered >= Reader requested.
5302 //
5303 // Per reject, bump the responsible policy ID in
5304 // `offered_incompatible_qos.policies`, so the
5305 // DataWriter listener is triggered via `dispatch_offered_incompatible_qos`.
5306 // We track the *first* faulty
5307 // policy as `last_policy_id` (Spec §2.2.4.1: most-recent).
5308 use crate::psm_constants::qos_policy_id as qid;
5309 use crate::status::bump_policy_count;
5310 // C2 "loud instead of silent": an incompatible QoS match is
5311 // not only kept as a pollable status (Spec §2.2.4.1),
5312 // but logged loudly IMMEDIATELY. The central ROS-DDS
5313 // pain point is that QoS mismatches are silently discarded
5314 // (e.g. Cyclone's `DDS_INVALID_QOS_POLICY_ID` without a
5315 // log) — exactly that made the ROS-2 entityKind diagnosis so
5316 // expensive. The reject names the topic, remote reader and
5317 // the exact policy.
5318 let obs = self.config.observability.clone();
5319 let topic_for_log = slot.topic_name.clone();
5320 let remote_for_log = alloc::format!("{:?}", sub.key);
5321 let bump = |slot: &mut UserWriterSlot, pid: u32| {
5322 slot.offered_incompatible_qos.total_count =
5323 slot.offered_incompatible_qos.total_count.saturating_add(1);
5324 slot.offered_incompatible_qos.last_policy_id = pid;
5325 bump_policy_count(&mut slot.offered_incompatible_qos.policies, pid);
5326 obs.record(
5327 &zerodds_foundation::observability::Event::new(
5328 zerodds_foundation::observability::Level::Warn,
5329 zerodds_foundation::observability::Component::Dcps,
5330 "qos.incompatible.offered",
5331 )
5332 .with_attr("topic", topic_for_log.as_str())
5333 .with_attr("remote_reader", remote_for_log.as_str())
5334 .with_attr("policy", qos_policy_id_name(pid)),
5335 );
5336 };
5337
5338 // Durability rank: Volatile < TransientLocal < Transient <
5339 // Persistent. The writer may offer more than the reader requests.
5340 if (slot.durability as u8) < (sub.durability as u8) {
5341 bump(slot, qid::DURABILITY);
5342 return;
5343 }
5344 // Deadline: writer period <= reader period (the writer promises
5345 // to write faster than the reader expects).
5346 if !deadline_compat(
5347 slot.deadline_nanos,
5348 qos_duration_to_nanos(sub.deadline.period),
5349 ) {
5350 bump(slot, qid::DEADLINE);
5351 return;
5352 }
5353 // Liveliness-Kind: Automatic < ManualByParticipant < ManualByTopic.
5354 // Writer-Kind >= Reader-Kind. Lease: writer.lease <= reader.lease.
5355 if (slot.liveliness_kind as u8) < (sub.liveliness.kind as u8) {
5356 bump(slot, qid::LIVELINESS);
5357 return;
5358 }
5359 if !deadline_compat(
5360 slot.liveliness_lease_nanos,
5361 qos_duration_to_nanos(sub.liveliness.lease_duration),
5362 ) {
5363 bump(slot, qid::LIVELINESS);
5364 return;
5365 }
5366 // Ownership: both must be equal (Spec §2.2.3.6 Table:
5367 // no "compatible" case except exactly equal).
5368 if slot.ownership != sub.ownership {
5369 bump(slot, qid::OWNERSHIP);
5370 return;
5371 }
5372 // Partition: at least one common partition — or
5373 // both empty (default partition "").
5374 if !partitions_overlap(&slot.partition, &sub.partition) {
5375 bump(slot, qid::PARTITION);
5376 return;
5377 }
5378 // F-TYPES-3 XTypes-1.3 §7.6.3.7 symmetric writer-side check.
5379 // If both sides carry a TypeIdentifier (≠ None),
5380 // we check compatibility. The reader's TCE policy is not
5381 // directly available here; we take the default TCE
5382 // (AllowTypeCoercion without PreventWidening) — the reader-
5383 // side check in `wire_reader_to_remote_writer` validates
5384 // with the real reader TCE.
5385 if slot.type_identifier != zerodds_types::TypeIdentifier::None
5386 && sub.type_identifier != zerodds_types::TypeIdentifier::None
5387 // Equal TypeIdentifiers are by definition the same type
5388 // (XTypes 1.3 §7.2.4.1 identity). This is the typed-endpoint
5389 // case: writer + reader of the same generated type carry the
5390 // same (possibly complete) TypeIdentifier, whose TypeObject
5391 // is NOT in this fresh registry. Without this short-circuit a
5392 // complete-hash type-id would fail the assignability lookup
5393 // (Bug QT). Skip the registry-backed structural check when the
5394 // ids are identical.
5395 && slot.type_identifier != sub.type_identifier
5396 {
5397 let registry = zerodds_types::resolve::TypeRegistry::new();
5398 let tce = zerodds_types::qos::TypeConsistencyEnforcement::default();
5399 let matcher = zerodds_types::type_matcher::TypeMatcher::new(&tce);
5400 if !matcher
5401 .match_types(&slot.type_identifier, &sub.type_identifier, ®istry)
5402 .is_match()
5403 {
5404 bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5405 return;
5406 }
5407 }
5408
5409 let mut proxy = zerodds_rtps::reader_proxy::ReaderProxy::new(
5410 sub.key,
5411 locators.clone(),
5412 Vec::new(),
5413 slot.reliable,
5414 );
5415 // D.5g — Per-Peer DataRepresentation negotiation
5416 // (XTypes 1.3 §7.6.3.1.2). Writer-offered = Per-Writer-
5417 // Override (slot.data_rep_offer_override) ODER Runtime-
5418 // Default. Reader-accepted = sub.data_representation
5419 // (spec default `[XCDR1]` if empty). Match mode from
5420 // RuntimeConfig.
5421 {
5422 use zerodds_rtps::publication_data::data_representation as dr;
5423 let writer_offered: Vec<i16> = slot
5424 .data_rep_offer_override
5425 .clone()
5426 .unwrap_or_else(|| self.config.data_representation_offer.clone());
5427 let mode = self.config.data_rep_match_mode;
5428 if let Some(negotiated) =
5429 dr::negotiate(&writer_offered, &sub.data_representation, mode)
5430 {
5431 proxy.set_negotiated_data_representation(negotiated);
5432 } else {
5433 // No overlap → SEDP match spec violation.
5434 // We add the proxy anyway for best-effort
5435 // compat; the wire-format default stays XCDR2.
5436 // A spec-strict caller should reject the match.
5437 }
5438 }
5439 // Spec §2.2.3.4 Tab. 16: cache replay suppression. For
5440 // Volatile the reader must not see any late-joiner history
5441 // → skip up to `cache.max_sn`. For Transient/Persistent
5442 // the backend is authoritative — we deliver the history
5443 // via the backend replay path with NEW SNs; the
5444 // writer's own cache (especially gappy under KeepLast
5445 // eviction) must not serve the reader twice.
5446 // TransientLocal is the only tier where the
5447 // writer cache is the real history anchor.
5448 if !matches!(slot.durability, zerodds_qos::DurabilityKind::TransientLocal) {
5449 if let Some(max) = slot.writer.cache().max_sn() {
5450 proxy.skip_samples_up_to(max);
5451 }
5452 }
5453 // Spec §2.2.3.5 — Durability=Transient/Persistent:
5454 // on the first late-joiner match, re-inject the backend samples
5455 // into the HistoryCache. The existing
5456 // reliable-reader path then delivers them via DATA +
5457 // heartbeat/AckNack. Idempotent via the
5458 // `backend_primed` flag.
5459 let backend_writes: Vec<Vec<u8>> = if !slot.backend_primed
5460 && (slot.durability == zerodds_qos::DurabilityKind::Transient
5461 || slot.durability == zerodds_qos::DurabilityKind::Persistent)
5462 {
5463 slot.durability_backend
5464 .as_ref()
5465 .and_then(|b| b.replay_for_topic(&slot.topic_name).ok())
5466 .unwrap_or_default()
5467 .into_iter()
5468 .map(|s| s.payload)
5469 .collect()
5470 } else {
5471 Vec::new()
5472 };
5473 slot.writer.add_reader_proxy(proxy);
5474 // Path-MTU-aware fragmentation: if ALL matched
5475 // readers run on the same host, traffic goes via
5476 // loopback (MTU 65536) — then one datagram per sample
5477 // instead of N 1344-B fragments (halves the 8-kB roundtrip
5478 // latency). As soon as a reader is remote, it stays
5479 // Ethernet-safe at DEFAULT_FRAGMENT_SIZE, so no
5480 // oversized datagram gets IP-fragmented on the 1500-byte
5481 // path.
5482 let all_same_host = slot
5483 .writer
5484 .reader_proxies()
5485 .iter()
5486 .all(|p| self.guid_prefix.is_same_host(p.remote_reader_guid.prefix));
5487 if all_same_host {
5488 slot.writer
5489 .set_fragmentation(LOOPBACK_FRAGMENT_SIZE, LOOPBACK_MTU);
5490 } else {
5491 slot.writer
5492 .set_fragmentation(DEFAULT_FRAGMENT_SIZE, DEFAULT_MTU);
5493 }
5494 // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): if the
5495 // remote reader runs on the same host (matching
5496 // GuidPrefix host-id, wave 4a), register the pair in the
5497 // SameHostTracker. Wave 4b.3 (feature `same-host-shm`):
5498 // additionally try to set up a PosixShmTransport owner
5499 // segment — on success `mark_bound(Owner)`,
5500 // otherwise `mark_failed` and UDP fallback.
5501 if self.guid_prefix.is_same_host(sub.key.prefix) {
5502 let local_writer_guid =
5503 zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5504 self.same_host.register_pending(local_writer_guid, sub.key);
5505 #[cfg(feature = "same-host-shm")]
5506 {
5507 match crate::same_host_shm::open_owner_segment(
5508 self.guid_prefix,
5509 local_writer_guid,
5510 sub.key,
5511 ) {
5512 Ok(t) => self.same_host.mark_bound(
5513 local_writer_guid,
5514 sub.key,
5515 t,
5516 crate::same_host::Role::Owner,
5517 ),
5518 Err(reason) => {
5519 self.same_host
5520 .mark_failed(local_writer_guid, sub.key, reason)
5521 }
5522 }
5523 }
5524 }
5525 // Inject the backend replay into the HistoryCache (within
5526 // the slot lock). Important: with `KeepLast(N)` and a small N
5527 // the cache would immediately evict every replay sample
5528 // again — the subsequent writer tick then sees
5529 // SN=4,5 as "not in cache" and sends GAPs to the
5530 // reader, which marks our replay samples as irrelevant.
5531 // Solution: temporarily expand the cache to `KeepAll` with
5532 // a sufficient cap, for the duration of the
5533 // burst, then restore the user QoS.
5534 // Backend samples are in **raw** format (that is how
5535 // `DataWriter::write` in publisher.rs stores them) — before the
5536 // writer.write we must prepend the USER_PAYLOAD_ENCAP framing,
5537 // so the reader recognizes the stream value spec-conformantly
5538 // (see `validate_user_encap_offset`).
5539 let now_replay = self.start_instant.elapsed();
5540 if !backend_writes.is_empty() {
5541 // Same encap header as in the live-write path
5542 // (offer `first` + extensibility), so replay samples
5543 // declare the same wire encoding.
5544 let replay_encap = {
5545 let offer_first = slot
5546 .data_rep_offer_override
5547 .as_ref()
5548 .and_then(|v| v.first().copied())
5549 .or_else(|| self.config.data_representation_offer.first().copied())
5550 .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
5551 user_payload_encap(
5552 offer_first,
5553 slot.wire_extensibility,
5554 slot.big_endian_override,
5555 )
5556 };
5557 let original_kind = slot.writer.cache().kind();
5558 let original_max = slot.writer.cache().max_samples();
5559 let burst_max = original_max
5560 .saturating_add(backend_writes.len())
5561 .max(backend_writes.len() + 16);
5562 slot.writer.set_cache_kind_and_max(
5563 zerodds_rtps::history_cache::HistoryKind::KeepAll,
5564 burst_max,
5565 );
5566 for raw_payload in &backend_writes {
5567 let mut framed = Vec::with_capacity(replay_encap.len() + raw_payload.len());
5568 framed.extend_from_slice(&replay_encap);
5569 framed.extend_from_slice(raw_payload);
5570 if let Ok(out) = slot.writer.write_with_heartbeat(&framed, now_replay) {
5571 replay_dgs.extend(out);
5572 }
5573 }
5574 slot.writer
5575 .set_cache_kind_and_max(original_kind, original_max);
5576 slot.backend_primed = true;
5577 }
5578 // D.5e Phase-1: wake `wait_for_matched_subscription`-waiters.
5579 self.match_event.1.notify_all();
5580
5581 // Security: derive the per-reader protection level from
5582 // security_info and build the locator lookup map,
5583 // so the writer tick can serialize per target
5584 // individually.
5585 #[cfg(feature = "security")]
5586 {
5587 let peer_key = sub.key.prefix.0;
5588 // Set the per-reader level ONLY for an EXPLICITLY announced
5589 // `PID_ENDPOINT_SECURITY_INFO`. If it is missing (OpenDDS does not
5590 // send it — it relies on the domain governance), NO
5591 // None override: then the governance `data_protection` FLOOR
5592 // applies in `secure_outbound_for_target`. An authenticated peer
5593 // in a data_protection=ENCRYPT domain expects the encrypted
5594 // payload; a None override would leak plaintext (cyclone/
5595 // FastDDS announce security_info → unchanged).
5596 if let Some(info) = sub.security_info.as_ref() {
5597 let level = EndpointProtection::from_info(Some(info)).level;
5598 slot.reader_protection.insert(peer_key, level);
5599 }
5600 for loc in &locators {
5601 slot.locator_to_peer.insert(*loc, peer_key);
5602 }
5603 }
5604 }
5605 }
5606 // Send the backend replay datagrams (Spec §2.2.3.5). The slot mutex
5607 // is released here; the send path mirrors the pattern from
5608 // `write_user_sample` — including the in-process fastpath for
5609 // same-process peers (otherwise UDP loopback loss under load can
5610 // swallow the Transient/Persistent replay samples).
5611 let inproc_peers: Vec<Arc<DcpsRuntime>> = {
5612 let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
5613 all.into_iter()
5614 .filter(|rt| rt.guid_prefix != self.guid_prefix)
5615 .collect()
5616 };
5617 let now_send = self.start_instant.elapsed();
5618 for dg in &replay_dgs {
5619 for t in dg.targets.iter() {
5620 if is_routable_user_locator(t) {
5621 let _ = self.user_unicast.send(t, &dg.bytes);
5622 }
5623 }
5624 for peer in &inproc_peers {
5625 handle_user_datagram(peer, &dg.bytes, now_send);
5626 }
5627 }
5628 // Emit the match event outside the slot mutex.
5629 self.config.observability.record(
5630 &zerodds_foundation::observability::Event::new(
5631 zerodds_foundation::observability::Level::Info,
5632 zerodds_foundation::observability::Component::Discovery,
5633 "writer.matched_remote_reader",
5634 )
5635 .with_attr("writer_eid", alloc::format!("{writer_eid:?}")),
5636 );
5637 }
5638
5639 fn wire_reader_to_remote_writer(
5640 &self,
5641 reader_eid: EntityId,
5642 pubd: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
5643 ) {
5644 // §2.2.2.2.1.17: an ignored publication must not be MATCHED, not merely
5645 // hidden from the DCPSPublication builtin reader. The Durability-Service
5646 // relies on this to avoid ingesting its own replay writer (echo loop).
5647 if let Some(filter) = self.ignore_filter_snapshot() {
5648 let pub_h = crate::instance_handle::InstanceHandle::from_guid(pubd.key);
5649 let part_h = crate::instance_handle::InstanceHandle::from_guid(pubd.participant_key);
5650 if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
5651 return;
5652 }
5653 }
5654 let locators =
5655 endpoint_or_default_locators(&pubd.unicast_locators, pubd.key.prefix, &self.discovered);
5656 if locators.is_empty() {
5657 return;
5658 }
5659 if let Some(slot_arc) = self.reader_slot(reader_eid) {
5660 if let Ok(mut slot) = slot_arc.lock() {
5661 let slot = &mut *slot;
5662 // Idempotency gate (symmetric to
5663 // `wire_writer_to_remote_reader`): if a WriterProxy already
5664 // exists for this remote writer, the
5665 // match has already run. A re-wire via UDP SEDP after
5666 // an in-process pull would REPLACE via `add_writer_proxy` —
5667 // resetting `delivered_up_to`/`received` and
5668 // losing already-buffered/delivered samples.
5669 if slot
5670 .reader
5671 .writer_proxies()
5672 .iter()
5673 .any(|s| s.proxy.remote_writer_guid == pubd.key)
5674 {
5675 return;
5676 }
5677 // Per-policy bump for requested_incompatible_qos.
5678 use crate::psm_constants::qos_policy_id as qid;
5679 use crate::status::bump_policy_count;
5680 // C2 "loud instead of silent" (symmetric to the writer side):
5681 // an incompatible QoS match is logged loudly immediately.
5682 let obs = self.config.observability.clone();
5683 let topic_for_log = slot.topic_name.clone();
5684 let remote_for_log = alloc::format!("{:?}", pubd.key);
5685 let bump = |slot: &mut UserReaderSlot, pid: u32| {
5686 slot.requested_incompatible_qos.total_count = slot
5687 .requested_incompatible_qos
5688 .total_count
5689 .saturating_add(1);
5690 slot.requested_incompatible_qos.last_policy_id = pid;
5691 bump_policy_count(&mut slot.requested_incompatible_qos.policies, pid);
5692 obs.record(
5693 &zerodds_foundation::observability::Event::new(
5694 zerodds_foundation::observability::Level::Warn,
5695 zerodds_foundation::observability::Component::Dcps,
5696 "qos.incompatible.requested",
5697 )
5698 .with_attr("topic", topic_for_log.as_str())
5699 .with_attr("remote_writer", remote_for_log.as_str())
5700 .with_attr("policy", qos_policy_id_name(pid)),
5701 );
5702 };
5703
5704 // See wire_writer... — symmetric, the writer is now remote.
5705 if (pubd.durability as u8) < (slot.durability as u8) {
5706 bump(slot, qid::DURABILITY);
5707 return;
5708 }
5709 if !deadline_compat(
5710 qos_duration_to_nanos(pubd.deadline.period),
5711 slot.deadline_nanos,
5712 ) {
5713 bump(slot, qid::DEADLINE);
5714 return;
5715 }
5716 if (pubd.liveliness.kind as u8) < (slot.liveliness_kind as u8) {
5717 bump(slot, qid::LIVELINESS);
5718 return;
5719 }
5720 if !deadline_compat(
5721 qos_duration_to_nanos(pubd.liveliness.lease_duration),
5722 slot.liveliness_lease_nanos,
5723 ) {
5724 bump(slot, qid::LIVELINESS);
5725 return;
5726 }
5727 if pubd.ownership != slot.ownership {
5728 bump(slot, qid::OWNERSHIP);
5729 return;
5730 }
5731 if !partitions_overlap(&pubd.partition, &slot.partition) {
5732 bump(slot, qid::PARTITION);
5733 return;
5734 }
5735
5736 // F-TYPES-3 XTypes-1.3 §7.6.3.7 TypeConsistencyEnforcement.
5737 // If both sides carry a TypeIdentifier (≠ None),
5738 // we check compatibility via the TypeMatcher. Otherwise
5739 // the match falls back to a pure type_name comparison (default path).
5740 if slot.type_identifier != zerodds_types::TypeIdentifier::None
5741 && pubd.type_identifier != zerodds_types::TypeIdentifier::None
5742 // Equal TypeIdentifiers ⇒ same type (XTypes 1.3 §7.2.4.1).
5743 // The typed-endpoint case carries a complete TypeIdentifier
5744 // whose TypeObject is not in this fresh registry; identity
5745 // is decisive without a structural lookup (Bug QT).
5746 && pubd.type_identifier != slot.type_identifier
5747 {
5748 let registry = zerodds_types::resolve::TypeRegistry::new();
5749 let matcher =
5750 zerodds_types::type_matcher::TypeMatcher::new(&slot.type_consistency);
5751 if !matcher
5752 .match_types(&pubd.type_identifier, &slot.type_identifier, ®istry)
5753 .is_match()
5754 {
5755 bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5756 return;
5757 }
5758 }
5759
5760 slot.reader
5761 .add_writer_proxy(zerodds_rtps::writer_proxy::WriterProxy::new(
5762 pubd.key,
5763 locators,
5764 Vec::new(),
5765 true,
5766 ));
5767 // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): reader
5768 // side of the same-host match. If the remote writer runs on
5769 // the same host, register the pair AND
5770 // attach synchronously to the SHM segment.
5771 //
5772 // Idempotent: thanks to the `PosixShmTransport::open` refactor
5773 // (transport-shm bug fix 2026-05-19) it does not matter whether the
5774 // writer hook (open_owner) or the reader hook
5775 // (open_consumer) runs first — whoever comes first
5776 // creates the segment, whoever later attaches. Real-life
5777 // DDS has no guaranteed SEDP match order.
5778 if self.guid_prefix.is_same_host(pubd.key.prefix) {
5779 let local_reader_guid =
5780 zerodds_rtps::wire_types::Guid::new(self.guid_prefix, reader_eid);
5781 self.same_host.register_pending(pubd.key, local_reader_guid);
5782 #[cfg(feature = "same-host-shm")]
5783 {
5784 match crate::same_host_shm::open_consumer_segment(
5785 self.guid_prefix,
5786 pubd.key,
5787 local_reader_guid,
5788 ) {
5789 Ok(t) => self.same_host.mark_bound(
5790 pubd.key,
5791 local_reader_guid,
5792 t,
5793 crate::same_host::Role::Consumer,
5794 ),
5795 Err(reason) => {
5796 self.same_host
5797 .mark_failed(pubd.key, local_reader_guid, reason)
5798 }
5799 }
5800 }
5801 }
5802 // D.5e Phase-1: wake `wait_for_matched_publication`-waiters.
5803 self.match_event.1.notify_all();
5804
5805 // §2.2.3.23 exclusive-ownership resolver cache:
5806 // remember the writer `ownership_strength` from discovery, so
5807 // `delivered_to_user_sample` can pack the value into every
5808 // sample.
5809 slot.writer_strengths
5810 .insert(pubd.key.to_bytes(), pubd.ownership_strength);
5811 }
5812 }
5813 }
5814
5815 /// Writes a sample to a registered user writer and
5816 /// sends the generated datagrams.
5817 ///
5818 /// The payload is prefixed with the RTPS serialized-payload header
5819 /// (encapsulation scheme) before it goes into the DATA
5820 /// submessage. OMG RTPS 2.5 §9.4.2.13 requires exactly these
5821 /// 4 bytes at the start of every serialized user payload —
5822 /// see [`USER_PAYLOAD_ENCAP`] (`CDR_LE` / XCDR1).
5823 /// Without this header Cyclone/Fast-DDS readers refuse to
5824 /// deliver the sample (they parse the first 4 bytes as
5825 /// encapsulation kind + options and drop unknown-scheme).
5826 ///
5827 /// # Errors
5828 /// - `BadParameter` if the EntityId has no registered writer.
5829 /// - `WireError` on an encoding error.
5830 pub fn write_user_sample(&self, eid: EntityId, payload: Vec<u8>) -> Result<()> {
5831 // Vec-ownership API. The spec contract is unchanged. We delegate to
5832 // the borrowed variant; this saves a heap-allocation hop when
5833 // the caller already has a `&[u8]` (e.g. the C-FFI loan API).
5834 self.write_user_sample_borrowed(eid, &payload)
5835 }
5836
5837 /// Sets the per-writer data-representation override for a user writer. The
5838 /// next `write_user_sample*` derives its encapsulation header from this
5839 /// override's first element instead of the runtime default — so a
5840 /// representation-faithful re-publisher (e.g. the durability service
5841 /// replaying foreign-vendor XCDR1 bytes) can declare the encap that matches
5842 /// the body it holds. `None` clears the override (back to the runtime
5843 /// default). Idempotent + cheap; safe to call before every write.
5844 ///
5845 /// # Errors
5846 /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5847 /// poisoned slot lock.
5848 pub fn set_user_writer_data_rep_override(
5849 &self,
5850 eid: EntityId,
5851 offer: Option<Vec<i16>>,
5852 ) -> Result<()> {
5853 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5854 what: "unknown writer entity id",
5855 })?;
5856 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5857 reason: "user_writer slot poisoned",
5858 })?;
5859 slot.data_rep_offer_override = offer;
5860 Ok(())
5861 }
5862
5863 /// Forces the writer to emit the big-endian (`_BE`) encapsulation variant
5864 /// (RTPS 2.5 §10.5) instead of the little-endian default. Used by the
5865 /// durability service replay path: a big-endian peer's stored sample holds
5866 /// big-endian body bytes, so its replay must carry a matching BE encap
5867 /// header. `false` restores the canonical little-endian wire.
5868 ///
5869 /// # Errors
5870 /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5871 /// poisoned slot lock.
5872 pub fn set_user_writer_byte_order_override(
5873 &self,
5874 eid: EntityId,
5875 big_endian: bool,
5876 ) -> Result<()> {
5877 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5878 what: "unknown writer entity id",
5879 })?;
5880 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5881 reason: "user_writer slot poisoned",
5882 })?;
5883 slot.big_endian_override = big_endian;
5884 Ok(())
5885 }
5886
5887 /// Sets the HISTORY KeepLast depth (DDS 1.4 §2.2.3.18) for a user writer.
5888 /// This governs how many of the most-recent samples **per instance key**
5889 /// are retained for the same-runtime TransientLocal late-joiner replay path
5890 /// (`intra_runtime_dispatch_alive` retains, a new route replays). Pass
5891 /// `usize::MAX` for KeepAll. A binding maps its HistoryQosPolicy here.
5892 ///
5893 /// # Errors
5894 /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5895 /// poisoned slot lock.
5896 pub fn set_user_writer_history_depth(&self, eid: EntityId, depth: usize) -> Result<()> {
5897 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5898 what: "unknown writer entity id",
5899 })?;
5900 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5901 reason: "user_writer slot poisoned",
5902 })?;
5903 slot.history_depth = depth.max(1);
5904 // Re-enforce the new depth over the already-retained samples per key.
5905 let d = slot.history_depth;
5906 enforce_retained_depth(&mut slot.retained, d);
5907 Ok(())
5908 }
5909
5910 /// Reads the current TransientLocal retained-sample count for a user writer
5911 /// (test/introspection helper). `0` for an unknown writer.
5912 #[must_use]
5913 pub fn user_writer_retained_len(&self, eid: EntityId) -> usize {
5914 self.writer_slot(eid)
5915 .and_then(|arc| arc.lock().ok().map(|s| s.retained.len()))
5916 .unwrap_or(0)
5917 }
5918
5919 /// Writes a user sample from a borrowed byte slice.
5920 /// **Zero-copy path** for the loan API and SHM backend: avoids
5921 /// the Vec materialization when the caller holds a slot/stack buffer.
5922 ///
5923 /// Identical semantics to `write_user_sample`; it just takes no
5924 /// ownership of the buffer.
5925 ///
5926 /// # Errors
5927 /// As `write_user_sample`.
5928 pub fn write_user_sample_borrowed(&self, eid: EntityId, payload: &[u8]) -> Result<()> {
5929 self.write_user_sample_keyed(eid, payload, [0u8; 16], None)
5930 }
5931
5932 /// Like [`write_user_sample_borrowed`] but stamps the sample with an
5933 /// explicit source timestamp instead of the current wall clock. A routing
5934 /// service uses this to preserve the input sample's source timestamp on the
5935 /// forwarded output (so `DESTINATION_ORDER = BY_SOURCE_TIMESTAMP` stays
5936 /// correct end-to-end). `source_ts` is the `HeTimestamp` carried on the
5937 /// input `UserSample::Alive`.
5938 ///
5939 /// # Errors
5940 /// As [`write_user_sample_borrowed`].
5941 pub fn write_user_sample_stamped(
5942 &self,
5943 eid: EntityId,
5944 payload: &[u8],
5945 source_ts: zerodds_rtps::header_extension::HeTimestamp,
5946 ) -> Result<()> {
5947 self.write_user_sample_keyed(eid, payload, [0u8; 16], Some(source_ts))
5948 }
5949
5950 /// Like [`write_user_sample_borrowed`] but with an explicit 16-byte instance
5951 /// `key_hash` (DDS 1.4 §2.2.2.4.2 keyed topics). The key is used by the
5952 /// same-runtime TransientLocal retention path so KeepLast depth is enforced
5953 /// **per instance** and a late joiner replays the most-recent samples of
5954 /// every live instance (and any disposed/unregistered terminal marker).
5955 /// A binding that does not key its topic passes the all-zero key (one
5956 /// default instance), which is what `write_user_sample_borrowed` does.
5957 ///
5958 /// # Errors
5959 /// As [`write_user_sample_borrowed`].
5960 pub fn write_user_sample_keyed(
5961 &self,
5962 eid: EntityId,
5963 payload: &[u8],
5964 key_hash: [u8; 16],
5965 source_ts_override: Option<zerodds_rtps::header_extension::HeTimestamp>,
5966 ) -> Result<()> {
5967 let _phase_guard = if phase_timing_enabled() {
5968 Some(PhaseTimer {
5969 start: std::time::Instant::now(),
5970 ns_acc: &PHASE_WRITE_USER_NS,
5971 calls_acc: &PHASE_WRITE_USER_CALLS,
5972 })
5973 } else {
5974 None
5975 };
5976 let pt_on = phase_timing_enabled();
5977 let pt_t0 = if pt_on {
5978 Some(std::time::Instant::now())
5979 } else {
5980 None
5981 };
5982 // Hot path: for small samples (<= 1.5 kB payload)
5983 // the encap framing is copied into a stack PoolBuffer —
5984 // zero heap touch in the framing step. Large samples fall
5985 // back to Vec.
5986 let now = self.start_instant.elapsed();
5987 let total = USER_PAYLOAD_ENCAP.len() + payload.len();
5988 let pt_t2_out: Option<std::time::Instant>;
5989 // XCDR version tag of the writer's effective offer (`0` = XCDR1,
5990 // `1` = XCDR2), set below from the same `offer_first` that drives the
5991 // wire encap header. Carried into the same-runtime loopback dispatch
5992 // so the intra-runtime reader sees the writer's real representation
5993 // (Bug R4) instead of an unconditional `0`.
5994 let intra_representation: u8;
5995 let out_datagrams = {
5996 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5997 what: "unknown writer entity id",
5998 })?;
5999 let pt_t1 = if pt_on {
6000 Some(std::time::Instant::now())
6001 } else {
6002 None
6003 };
6004 if let (Some(t0), Some(t1)) = (pt_t0, pt_t1) {
6005 PHASE_WRITE_SUB_NS[0].fetch_add(
6006 (t1 - t0).as_nanos() as u64,
6007 core::sync::atomic::Ordering::Relaxed,
6008 );
6009 }
6010 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
6011 reason: "user_writer slot poisoned",
6012 })?;
6013 let pt_t2 = if pt_on {
6014 Some(std::time::Instant::now())
6015 } else {
6016 None
6017 };
6018 pt_t2_out = pt_t2;
6019 if let (Some(t1), Some(t2)) = (pt_t1, pt_t2) {
6020 PHASE_WRITE_SUB_NS[1].fetch_add(
6021 (t2 - t1).as_nanos() as u64,
6022 core::sync::atomic::Ordering::Relaxed,
6023 );
6024 }
6025 // Deadline timer: remember the last write for offered_deadline_missed.
6026 slot.last_write = Some(now);
6027 // Encap header from the effective offer `first` (per-writer
6028 // override else runtime default) + type extensibility. The app
6029 // encoder serializes exactly this wire format; the header must
6030 // declare it honestly (otherwise an XCDR2-only vendor
6031 // reader misparses). See `user_payload_encap`.
6032 let encap = {
6033 let offer_first = slot
6034 .data_rep_offer_override
6035 .as_ref()
6036 .and_then(|v| v.first().copied())
6037 .or_else(|| self.config.data_representation_offer.first().copied())
6038 .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
6039 // Map the negotiated i16 DataRepresentationId to the u8 XCDR
6040 // version tag used by `UserSample::Alive.representation` /
6041 // `encap_representation` (`1` = XCDR2, `0` = XCDR1). Mirrors
6042 // the wire path where the reader derives this from the encap
6043 // header byte[1].
6044 intra_representation =
6045 if offer_first == zerodds_rtps::publication_data::data_representation::XCDR2 {
6046 1
6047 } else {
6048 0
6049 };
6050 user_payload_encap(
6051 offer_first,
6052 slot.wire_extensibility,
6053 slot.big_endian_override,
6054 )
6055 };
6056 // Spec §2.2.3.5 backend filling happens in
6057 // `DataWriter::write` (publisher.rs) with the **raw** payload —
6058 // here only the HistoryCache filling + wire send.
6059 let dgs = if total <= SMALL_FRAME_CAP {
6060 write_user_sample_pooled(
6061 &mut slot.writer,
6062 payload,
6063 now,
6064 &encap,
6065 source_ts_override,
6066 )?
6067 } else {
6068 let mut framed = Vec::with_capacity(total);
6069 framed.extend_from_slice(&encap);
6070 framed.extend_from_slice(payload);
6071 // See write_user_sample_pooled: HB rate-limited via the
6072 // tick loop instead of per-write.
6073 let _ = now;
6074 slot.writer
6075 .write(&framed)
6076 .map_err(|_| DdsError::WireError {
6077 message: String::from("user writer encode"),
6078 })?
6079 };
6080 // Lifespan: remember the insert time of the just-written SN.
6081 if slot.lifespan_nanos != 0 {
6082 if let Some(sn) = slot.writer.cache().max_sn() {
6083 slot.sample_insert_times.push_back((sn, now));
6084 }
6085 }
6086 // QR-cluster (a)+(b): TRANSIENT_LOCAL same-runtime retention with
6087 // per-instance HISTORY KeepLast depth (DDS 1.4 §2.2.3.4 + §2.2.3.18).
6088 // A new sample for a key clears any prior terminal lifecycle marker
6089 // for that key (the instance is alive again) and is appended; the
6090 // depth is then re-enforced per key.
6091 if !matches!(slot.durability, zerodds_qos::DurabilityKind::Volatile) {
6092 slot.retained
6093 .retain(|s| !(s.lifecycle.is_some() && s.key_hash == key_hash));
6094 let strength = slot.ownership_strength;
6095 slot.retained.push_back(RetainedSample {
6096 key_hash,
6097 payload: payload.to_vec(),
6098 representation: intra_representation,
6099 strength,
6100 lifecycle: None,
6101 });
6102 let depth = slot.history_depth;
6103 enforce_retained_depth(&mut slot.retained, depth);
6104 }
6105 dgs
6106 };
6107 let pt_t3 = if pt_on {
6108 Some(std::time::Instant::now())
6109 } else {
6110 None
6111 };
6112 if let (Some(t2), Some(t3)) = (pt_t2_out, pt_t3) {
6113 PHASE_WRITE_SUB_NS[2].fetch_add(
6114 (t3 - t2).as_nanos() as u64,
6115 core::sync::atomic::Ordering::Relaxed,
6116 );
6117 }
6118 // Opt-4 (Spec `zerodds-zero-copy-1.0` §9): precompute the skip set
6119 // of UDP locators occupied by a bound same-host reader.
6120 // Readers on these locators get the sample via
6121 // SHM (`same_host_send_pass` below); a UDP send would be a duplicate.
6122 #[cfg(feature = "same-host-shm")]
6123 let same_host_skip_locators: Vec<Locator> = self.same_host_udp_skip_set(eid);
6124 // In-process fastpath (same-process+domain peers): snapshot the
6125 // peer runtimes ONCE per write, then feed each datagram directly into
6126 // their recv path — no UDP loopback, no reliable
6127 // recovery race. The receiver deduplicates by SequenceNumber,
6128 // a copy arriving additionally via UDP later is a
6129 // no-op. The wire path stays untouched for cross-process.
6130 //
6131 // Hot-path fast path: lock-free registry hint. In the typical
6132 // cross-process bench (ping in process A, pong in process B)
6133 // A's registry has only A — the `peers()` lock+Vec alloc would be
6134 // pure overhead per write. Skip when count ≤ 1.
6135 let inproc_peers: Vec<Arc<DcpsRuntime>> = if crate::inproc::registry_count_hint() <= 1 {
6136 Vec::new()
6137 } else {
6138 let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
6139 all.into_iter()
6140 .filter(|rt| rt.guid_prefix != self.guid_prefix)
6141 .collect()
6142 };
6143 for dg in out_datagrams {
6144 // FU2 S3: UDP per target with per-reader data_protection
6145 // (`secure_outbound_for_target` — heterogeneously correct: legacy readers
6146 // get plaintext, secure readers SRTPS; the governance
6147 // data_protection fallback applies for readers without explicit
6148 // SEDP security_info).
6149 for t in dg.targets.iter() {
6150 if is_routable_user_locator(t) {
6151 #[cfg(feature = "same-host-shm")]
6152 if same_host_skip_locators.iter().any(|s| s == t) {
6153 continue;
6154 }
6155 if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
6156 #[allow(clippy::print_stderr)]
6157 if let Err(e) = self.user_unicast.send(t, &secured) {
6158 if std::env::var("ZERODDS_TRACE_SEND_ERR")
6159 .map(|s| s == "1")
6160 .unwrap_or(false)
6161 {
6162 eprintln!("[TRACE] user_unicast.send({t:?}) failed: {e:?}");
6163 }
6164 }
6165 }
6166 }
6167 }
6168 // SHM + in-process fastpath: `secure_user_outbound` (uniform
6169 // governance data_protection level). The inproc peer runs through
6170 // its secured inbound path (decrypt or drop),
6171 // symmetric to the UDP recv — otherwise a non-
6172 // authenticated same-process peer could see encrypted data
6173 // unencrypted.
6174 if let Some(secured) = secure_user_outbound(self, &dg.bytes) {
6175 // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6):
6176 // parallel send via SHM to all bound-owner entries
6177 // for this writer. Opt-4 above filters their UDP
6178 // locators out beforehand, so nothing is sent twice.
6179 #[cfg(feature = "same-host-shm")]
6180 self.same_host_send_pass(eid, &secured);
6181 for peer in &inproc_peers {
6182 #[cfg(feature = "security")]
6183 {
6184 if let Some(clear) =
6185 secure_inbound_bytes(peer, &secured, &DEFAULT_INBOUND_IFACE)
6186 {
6187 handle_user_datagram(peer, &clear, now);
6188 }
6189 }
6190 #[cfg(not(feature = "security"))]
6191 handle_user_datagram(peer, &secured, now);
6192 }
6193 }
6194 }
6195 let pt_t4 = if pt_on {
6196 Some(std::time::Instant::now())
6197 } else {
6198 None
6199 };
6200 if let (Some(t3), Some(t4)) = (pt_t3, pt_t4) {
6201 PHASE_WRITE_SUB_NS[3].fetch_add(
6202 (t4 - t3).as_nanos() as u64,
6203 core::sync::atomic::Ordering::Relaxed,
6204 );
6205 }
6206 // Same-runtime writer→reader loopback: in parallel to the wire path
6207 // push directly into the `sample_tx` of all local readers on the same
6208 // topic+type. Bridge-daemon use case (writer+reader
6209 // in the same DcpsRuntime); without this hook intra-process
6210 // loopback would be completely dead, because `inproc_announce_*` skips self
6211 // and UDP multicast loopback is not guaranteed. Strength from
6212 // the writer slot.
6213 let writer_strength = self
6214 .writer_slot(eid)
6215 .and_then(|arc| arc.lock().ok().map(|s| s.ownership_strength))
6216 .unwrap_or(0);
6217 self.intra_runtime_dispatch_alive(eid, payload, writer_strength, intra_representation);
6218 // Embargo inspect tap at the DCPS layer (path-separated from the
6219 // production path). Only compiled when the `inspect` feature is
6220 // on. The topic name is fetched via a separate lookup, outside
6221 // the lock region so hooks do not run under the lock.
6222 #[cfg(feature = "inspect")]
6223 {
6224 self.dispatch_inspect_dcps_tap(eid, payload);
6225 }
6226 // D.5e Phase 3 — a freshly written sample makes a HEARTBEAT due: wake the
6227 // scheduler tick so it goes out immediately (no 5 ms tail), speeding the
6228 // reliable HB→ACKNACK handshake.
6229 self.raise_tick_wake();
6230 Ok(())
6231 }
6232
6233 /// Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) helper:
6234 /// sends `bytes` to all bound-owner entries of the [`SameHostTracker`]
6235 /// for this local writer (owner role).
6236 ///
6237 /// Called by the [`Self::write_user_sample`] hot path after the UDP send.
6238 /// Same-host readers thereby receive the sample frame
6239 /// via SHM **in addition** to the UDP path — the reader HistoryCache
6240 /// deduplicates by SequenceNumber.
6241 #[cfg(feature = "same-host-shm")]
6242 /// Opt-4 (Spec `zerodds-zero-copy-1.0` §9): locator skip set for
6243 /// the UDP send path. Returns all UDP default-unicast locators
6244 /// of the readers that have a bound same-host SHM pair with this
6245 /// writer — the hot-path caller filters these targets out of
6246 /// `dg.targets`, so the same readers are not served twice
6247 /// (UDP + SHM).
6248 #[cfg(feature = "same-host-shm")]
6249 fn same_host_udp_skip_set(&self, writer_eid: EntityId) -> Vec<Locator> {
6250 use crate::same_host::{Role, SameHostState};
6251 let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
6252 let mut skip: Vec<Locator> = Vec::new();
6253 let snapshot = self.same_host.snapshot();
6254 let discovered = self.discovered.clone();
6255 for (w, reader, state) in snapshot {
6256 if w != writer_guid {
6257 continue;
6258 }
6259 if !matches!(
6260 state,
6261 SameHostState::Bound {
6262 role: Role::Owner,
6263 ..
6264 }
6265 ) {
6266 continue;
6267 }
6268 // Reader prefix → default_unicast_locator from discovery.
6269 if let Ok(cache) = discovered.lock() {
6270 if let Some(p) = cache.get(&reader.prefix) {
6271 if let Some(loc) = p.data.default_unicast_locator {
6272 skip.push(loc);
6273 }
6274 }
6275 }
6276 }
6277 skip
6278 }
6279
6280 #[cfg(feature = "same-host-shm")]
6281 fn same_host_send_pass(&self, writer_eid: EntityId, bytes: &[u8]) {
6282 use crate::same_host::{Role, SameHostState};
6283 use zerodds_transport::Transport;
6284 use zerodds_transport_shm::PosixShmTransport;
6285
6286 let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
6287 let snapshot = self.same_host.snapshot();
6288 let total = snapshot.len();
6289 let mut matched = 0u32;
6290 let mut owners = 0u32;
6291 let mut sent = 0u32;
6292 for (w, _reader, state) in snapshot {
6293 if w != writer_guid {
6294 continue;
6295 }
6296 matched += 1;
6297 let SameHostState::Bound { transport, role } = state else {
6298 continue;
6299 };
6300 if !matches!(role, Role::Owner) {
6301 continue;
6302 }
6303 owners += 1;
6304 let Ok(t) = transport.downcast::<PosixShmTransport>() else {
6305 continue;
6306 };
6307 // ShmTransport is 1:1: send() validates `dest ==
6308 // peer_locator`. Owner.peer_locator points to the
6309 // consumer endpoint → that is our target.
6310 let target = t.peer_locator();
6311 if t.send(&target, bytes).is_ok() {
6312 sent += 1;
6313 }
6314 }
6315 let _ = (total, matched, owners, sent); // diag counter removed after the Bug-3 fix
6316 }
6317
6318 /// Inspect-endpoint tap dispatch for DCPS publish.
6319 /// Reads the topic name separately from the WriterSlot and passes
6320 /// a frame to the zerodds-inspect-endpoint tap registry.
6321 /// **Not** the production hot path: only when the `inspect` feature is on.
6322 #[cfg(feature = "inspect")]
6323 fn dispatch_inspect_dcps_tap(&self, eid: EntityId, payload: &[u8]) {
6324 let Some(slot_arc) = self.writer_slot(eid) else {
6325 return;
6326 };
6327 let topic = match slot_arc.lock() {
6328 Ok(slot) => slot.topic_name.clone(),
6329 Err(_) => return,
6330 };
6331 let ts_ns = std::time::SystemTime::now()
6332 .duration_since(std::time::UNIX_EPOCH)
6333 .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
6334 .unwrap_or(0);
6335 let mut corr: u64 = 0;
6336 for (i, byte) in eid.entity_key.iter().enumerate() {
6337 corr |= u64::from(*byte) << (i * 8);
6338 }
6339 corr |= u64::from(eid.entity_kind as u8) << 24;
6340 let frame = zerodds_inspect_endpoint::Frame::dcps(topic, ts_ns, corr, payload.to_vec());
6341 zerodds_inspect_endpoint::tap::dispatch(&frame);
6342 }
6343
6344 /// Sends a lifecycle marker (`dispose`/`unregister_instance`) to
6345 /// all matched readers. Spec §2.2.2.4.2.10/.7 + §9.6.3.9 PID_STATUS_INFO.
6346 /// `status_bits` is the OR combination of
6347 /// `zerodds_rtps::inline_qos::status_info::DISPOSED` and/or `UNREGISTERED`.
6348 ///
6349 /// # Errors
6350 /// - `BadParameter` if the EntityId has no registered writer.
6351 /// - `WireError` on an encode error.
6352 pub fn write_user_lifecycle(
6353 &self,
6354 eid: EntityId,
6355 key_hash: [u8; 16],
6356 status_bits: u32,
6357 ) -> Result<()> {
6358 let out_datagrams = {
6359 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
6360 what: "unknown writer entity id",
6361 })?;
6362 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
6363 reason: "user_writer slot poisoned",
6364 })?;
6365 slot.writer
6366 .write_lifecycle(key_hash, status_bits)
6367 .map_err(|_| DdsError::WireError {
6368 message: String::from("user writer lifecycle encode"),
6369 })?
6370 };
6371 for dg in out_datagrams {
6372 // FU2 S3: lifecycle DATA (dispose/unregister) per-target
6373 // data_protection-aware (heterogeneously correct like the immediate send).
6374 for t in dg.targets.iter() {
6375 if is_routable_user_locator(t) {
6376 if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
6377 let _ = self.user_unicast.send(t, &secured);
6378 }
6379 }
6380 }
6381 }
6382 // QR-cluster (d): also deliver the lifecycle marker to matched
6383 // same-runtime readers — the wire targets above never include
6384 // intra-runtime local readers (those go via the direct dispatch path).
6385 // Map the PID_STATUS_INFO bits to the HistoryCache ChangeKind.
6386 use zerodds_rtps::inline_qos::status_info;
6387 let disposed = status_bits & status_info::DISPOSED != 0;
6388 let unregistered = status_bits & status_info::UNREGISTERED != 0;
6389 let kind = match (disposed, unregistered) {
6390 (true, true) => zerodds_rtps::history_cache::ChangeKind::NotAliveDisposedUnregistered,
6391 (true, false) => zerodds_rtps::history_cache::ChangeKind::NotAliveDisposed,
6392 (false, true) => zerodds_rtps::history_cache::ChangeKind::NotAliveUnregistered,
6393 // No status bits set: nothing to deliver as a lifecycle marker.
6394 (false, false) => return Ok(()),
6395 };
6396 self.intra_runtime_dispatch_lifecycle(eid, key_hash, kind);
6397 Ok(())
6398 }
6399
6400 /// Generates a 3-byte entity key for new user endpoints.
6401 fn next_entity_key(&self) -> [u8; 3] {
6402 let n = self.entity_counter.fetch_add(1, Ordering::Relaxed);
6403 [(n >> 16) as u8, (n >> 8) as u8, n as u8]
6404 }
6405
6406 /// Snapshot of all currently known remote publications (topic
6407 /// name + type name + writer GUID).
6408 #[must_use]
6409 pub fn discovered_publications_count(&self) -> usize {
6410 self.sedp
6411 .lock()
6412 .map(|s| s.cache().publications_len())
6413 .unwrap_or(0)
6414 }
6415
6416 /// Snapshot of every publication on this domain as `(topic_name,
6417 /// type_name)` — raw DDS topic/type strings — for graph introspection
6418 /// (`rmw_get_topic_names_and_types`, `rmw_count_publishers`). Includes BOTH
6419 /// this participant's LOCAL user writers AND the remote publications from
6420 /// SEDP, so a node sees its own topics as well as its peers'.
6421 #[must_use]
6422 pub fn discovered_publication_topics(&self) -> Vec<(String, String)> {
6423 let mut out: Vec<(String, String)> = Vec::new();
6424 if let Ok(map) = self.user_writers.read() {
6425 for slot in map.values() {
6426 if let Ok(s) = slot.lock() {
6427 out.push((s.topic_name.clone(), s.type_name.clone()));
6428 }
6429 }
6430 }
6431 if let Ok(s) = self.sedp.lock() {
6432 out.extend(
6433 s.cache()
6434 .publications()
6435 .map(|p| (p.data.topic_name.clone(), p.data.type_name.clone())),
6436 );
6437 }
6438 out
6439 }
6440
6441 /// Snapshot of every subscription on this domain as `(topic_name,
6442 /// type_name)` (local user readers + remote SEDP). Counterpart to
6443 /// [`Self::discovered_publication_topics`].
6444 #[must_use]
6445 pub fn discovered_subscription_topics(&self) -> Vec<(String, String)> {
6446 let mut out: Vec<(String, String)> = Vec::new();
6447 if let Ok(map) = self.user_readers.read() {
6448 for slot in map.values() {
6449 if let Ok(s) = slot.lock() {
6450 out.push((s.topic_name.clone(), s.type_name.clone()));
6451 }
6452 }
6453 }
6454 if let Ok(s) = self.sedp.lock() {
6455 out.extend(
6456 s.cache()
6457 .subscriptions()
6458 .map(|s| (s.data.topic_name.clone(), s.data.type_name.clone())),
6459 );
6460 }
6461 out
6462 }
6463
6464 /// Snapshot of all currently known remote subscriptions.
6465 #[must_use]
6466 pub fn discovered_subscriptions_count(&self) -> usize {
6467 self.sedp
6468 .lock()
6469 .map(|s| s.cache().subscriptions_len())
6470 .unwrap_or(0)
6471 }
6472
6473 /// Per-endpoint snapshot of every publication on this domain (local user
6474 /// writers + remote SEDP), for ROS 2 `rmw_get_publishers_info_by_topic`.
6475 #[must_use]
6476 pub fn discovered_publication_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
6477 let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
6478 let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
6479 if let Ok(map) = self.user_writers.read() {
6480 for slot in map.values() {
6481 if let Ok(s) = slot.lock() {
6482 out.push(DiscoveredEndpointInfo {
6483 topic_name: s.topic_name.clone(),
6484 type_name: s.type_name.clone(),
6485 endpoint_guid: guid_to_16(s.writer.guid()),
6486 reliable: s.reliable,
6487 transient_local: !matches!(
6488 s.durability,
6489 zerodds_qos::DurabilityKind::Volatile
6490 ),
6491 deadline_seconds: secs(s.deadline_nanos),
6492 lifespan_seconds: secs(s.lifespan_nanos),
6493 liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
6494 });
6495 }
6496 }
6497 }
6498 if let Ok(s) = self.sedp.lock() {
6499 for p in s.cache().publications() {
6500 out.push(DiscoveredEndpointInfo {
6501 topic_name: p.data.topic_name.clone(),
6502 type_name: p.data.type_name.clone(),
6503 endpoint_guid: guid_to_16(p.data.key),
6504 reliable: matches!(
6505 p.data.reliability.kind,
6506 zerodds_qos::ReliabilityKind::Reliable
6507 ),
6508 transient_local: !matches!(
6509 p.data.durability,
6510 zerodds_qos::DurabilityKind::Volatile
6511 ),
6512 deadline_seconds: p.data.deadline.period.seconds,
6513 lifespan_seconds: p.data.lifespan.duration.seconds,
6514 liveliness_lease_seconds: p.data.liveliness.lease_duration.seconds,
6515 });
6516 }
6517 }
6518 out
6519 }
6520
6521 /// Counterpart to [`Self::discovered_publication_endpoints`] for
6522 /// subscriptions (`rmw_get_subscriptions_info_by_topic`).
6523 #[must_use]
6524 pub fn discovered_subscription_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
6525 let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
6526 let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
6527 if let Ok(map) = self.user_readers.read() {
6528 for slot in map.values() {
6529 if let Ok(s) = slot.lock() {
6530 out.push(DiscoveredEndpointInfo {
6531 topic_name: s.topic_name.clone(),
6532 type_name: s.type_name.clone(),
6533 endpoint_guid: guid_to_16(s.reader.guid()),
6534 // Reader requested-reliability is not retained in the
6535 // slot; RELIABLE is the rmw default (best-effort field).
6536 reliable: true,
6537 transient_local: !matches!(
6538 s.durability,
6539 zerodds_qos::DurabilityKind::Volatile
6540 ),
6541 deadline_seconds: secs(s.deadline_nanos),
6542 lifespan_seconds: 0,
6543 liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
6544 });
6545 }
6546 }
6547 }
6548 if let Ok(s) = self.sedp.lock() {
6549 for sub in s.cache().subscriptions() {
6550 out.push(DiscoveredEndpointInfo {
6551 topic_name: sub.data.topic_name.clone(),
6552 type_name: sub.data.type_name.clone(),
6553 endpoint_guid: guid_to_16(sub.data.key),
6554 reliable: matches!(
6555 sub.data.reliability.kind,
6556 zerodds_qos::ReliabilityKind::Reliable
6557 ),
6558 transient_local: !matches!(
6559 sub.data.durability,
6560 zerodds_qos::DurabilityKind::Volatile
6561 ),
6562 deadline_seconds: sub.data.deadline.period.seconds,
6563 lifespan_seconds: 0,
6564 liveliness_lease_seconds: sub.data.liveliness.lease_duration.seconds,
6565 });
6566 }
6567 }
6568 out
6569 }
6570
6571 /// Number of matched remote readers for a local user writer.
6572 /// Polled by `DataWriter::wait_for_matched_subscription`.
6573 #[must_use]
6574 pub fn user_writer_matched_count(&self, eid: EntityId) -> usize {
6575 // Distinct matched subscriptions = remote/cross-participant reader
6576 // proxies UNION same-participant (intra-runtime) local readers. The
6577 // intra-runtime self-match path delivers samples without adding a wire
6578 // reader-proxy (avoids UDP-to-self double-delivery), so its matches
6579 // would otherwise be invisible to `wait_for_matched_subscription`.
6580 self.user_writer_matched_subscription_handles(eid).len()
6581 }
6582
6583 /// List of `InstanceHandle`s of all matched readers for a local
6584 /// user writer (Spec §2.2.2.4.2.x `get_matched_subscriptions`): remote/
6585 /// cross-participant readers (reader proxies) plus the same-participant
6586 /// readers from the intra-runtime routes, deduplicated by GUID.
6587 #[must_use]
6588 pub fn user_writer_matched_subscription_handles(
6589 &self,
6590 eid: EntityId,
6591 ) -> Vec<crate::instance_handle::InstanceHandle> {
6592 let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
6593 .writer_slot(eid)
6594 .and_then(|arc| {
6595 arc.lock().ok().map(|s| {
6596 s.writer
6597 .reader_proxies()
6598 .iter()
6599 .map(|p| {
6600 crate::instance_handle::InstanceHandle::from_guid(p.remote_reader_guid)
6601 })
6602 .collect::<Vec<_>>()
6603 })
6604 })
6605 .unwrap_or_default();
6606 for h in self.intra_runtime_writer_matched_readers(eid) {
6607 if !handles.contains(&h) {
6608 handles.push(h);
6609 }
6610 }
6611 handles
6612 }
6613
6614 /// Same-participant readers that the local writer `eid` delivers to via
6615 /// an intra-runtime route (as matched-subscription handles).
6616 fn intra_runtime_writer_matched_readers(
6617 &self,
6618 writer_eid: EntityId,
6619 ) -> Vec<crate::instance_handle::InstanceHandle> {
6620 match self.intra_runtime_routes.read() {
6621 Ok(g) => g
6622 .get(&writer_eid)
6623 .map(|readers| {
6624 readers
6625 .iter()
6626 .map(|reid| {
6627 crate::instance_handle::InstanceHandle::from_guid(Guid::new(
6628 self.guid_prefix,
6629 *reid,
6630 ))
6631 })
6632 .collect()
6633 })
6634 .unwrap_or_default(),
6635 Err(_) => Vec::new(),
6636 }
6637 }
6638
6639 /// Same-participant writers that deliver to the local
6640 /// reader `reader_eid` via an intra-runtime route (as matched-publication handles).
6641 fn intra_runtime_reader_matched_writers(
6642 &self,
6643 reader_eid: EntityId,
6644 ) -> Vec<crate::instance_handle::InstanceHandle> {
6645 match self.intra_runtime_routes.read() {
6646 Ok(g) => g
6647 .iter()
6648 .filter(|(_, readers)| readers.contains(&reader_eid))
6649 .map(|(weid, _)| {
6650 crate::instance_handle::InstanceHandle::from_guid(Guid::new(
6651 self.guid_prefix,
6652 *weid,
6653 ))
6654 })
6655 .collect(),
6656 Err(_) => Vec::new(),
6657 }
6658 }
6659
6660 /// List of `InstanceHandle`s of all matched remote writers for a
6661 /// local user reader (Spec §2.2.2.5.x `get_matched_publications`).
6662 #[must_use]
6663 pub fn user_reader_matched_publication_handles(
6664 &self,
6665 eid: EntityId,
6666 ) -> Vec<crate::instance_handle::InstanceHandle> {
6667 let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
6668 .reader_slot(eid)
6669 .and_then(|arc| {
6670 arc.lock().ok().map(|s| {
6671 s.reader
6672 .writer_proxies()
6673 .iter()
6674 .map(|p| {
6675 crate::instance_handle::InstanceHandle::from_guid(
6676 p.proxy.remote_writer_guid,
6677 )
6678 })
6679 .collect::<Vec<_>>()
6680 })
6681 })
6682 .unwrap_or_default();
6683 for h in self.intra_runtime_reader_matched_writers(eid) {
6684 if !handles.contains(&h) {
6685 handles.push(h);
6686 }
6687 }
6688 handles
6689 }
6690
6691 /// Counter for missed offered deadlines on the user writer.
6692 /// Spec OMG DDS 1.4 §2.2.4.2.9 `OFFERED_DEADLINE_MISSED_STATUS`.
6693 #[must_use]
6694 pub fn user_writer_offered_deadline_missed(&self, eid: EntityId) -> u64 {
6695 self.writer_slot(eid)
6696 .and_then(|arc| arc.lock().ok().map(|s| s.offered_deadline_missed_count))
6697 .unwrap_or(0)
6698 }
6699
6700 /// Counter for missed requested deadlines on the user reader.
6701 /// Spec §2.2.4.2.11 `REQUESTED_DEADLINE_MISSED_STATUS`.
6702 #[must_use]
6703 pub fn user_reader_requested_deadline_missed(&self, eid: EntityId) -> u64 {
6704 self.reader_slot(eid)
6705 .and_then(|arc| arc.lock().ok().map(|s| s.requested_deadline_missed_count))
6706 .unwrap_or(0)
6707 }
6708
6709 /// Current liveliness status of a local user reader.
6710 /// Spec §2.2.4.2.14 `LIVELINESS_CHANGED_STATUS`:
6711 /// `(alive, alive_count, not_alive_count)`.
6712 #[must_use]
6713 pub fn user_reader_liveliness_status(&self, eid: EntityId) -> (bool, u64, u64) {
6714 self.reader_slot(eid)
6715 .and_then(|arc| {
6716 arc.lock().ok().map(|s| {
6717 (
6718 s.liveliness_alive,
6719 s.liveliness_alive_count,
6720 s.liveliness_not_alive_count,
6721 )
6722 })
6723 })
6724 .unwrap_or((false, 0, 0))
6725 }
6726
6727 /// LivelinessLost counter on the user writer (Spec §2.2.4.2.10).
6728 /// Incremented by `check_writer_liveliness`.
6729 #[must_use]
6730 pub fn user_writer_liveliness_lost(&self, eid: EntityId) -> u64 {
6731 self.writer_slot(eid)
6732 .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_lost_count))
6733 .unwrap_or(0)
6734 }
6735
6736 /// Snapshot of OfferedIncompatibleQosStatus on the writer.
6737 #[must_use]
6738 pub fn user_writer_offered_incompatible_qos(
6739 &self,
6740 eid: EntityId,
6741 ) -> crate::status::OfferedIncompatibleQosStatus {
6742 self.writer_slot(eid)
6743 .and_then(|arc| arc.lock().ok().map(|s| s.offered_incompatible_qos.clone()))
6744 .unwrap_or_default()
6745 }
6746
6747 /// Snapshot of RequestedIncompatibleQosStatus on the reader.
6748 #[must_use]
6749 pub fn user_reader_requested_incompatible_qos(
6750 &self,
6751 eid: EntityId,
6752 ) -> crate::status::RequestedIncompatibleQosStatus {
6753 self.reader_slot(eid)
6754 .and_then(|arc| {
6755 arc.lock()
6756 .ok()
6757 .map(|s| s.requested_incompatible_qos.clone())
6758 })
6759 .unwrap_or_default()
6760 }
6761
6762 /// Sample-lost counter (reader side). Spec §2.2.4.2.6.2.
6763 #[must_use]
6764 pub fn user_reader_sample_lost(&self, eid: EntityId) -> u64 {
6765 self.reader_slot(eid)
6766 .and_then(|arc| arc.lock().ok().map(|s| s.sample_lost_count))
6767 .unwrap_or(0)
6768 }
6769
6770 /// Monotonically increasing count of alive samples delivered to the
6771 /// user (Spec §2.2.4.2.6.1 `on_data_available` detector). A delta
6772 /// against the last poll snapshot means "new data available".
6773 #[must_use]
6774 pub fn user_reader_samples_delivered(&self, eid: EntityId) -> u64 {
6775 self.reader_slot(eid)
6776 .and_then(|arc| arc.lock().ok().map(|s| s.samples_delivered_count))
6777 .unwrap_or(0)
6778 }
6779
6780 /// A2 — arm TIME_BASED_FILTER (DDS 1.4 §2.2.3.12) on a runtime/C-FFI user
6781 /// reader: it then receives at most one sample per instance per
6782 /// `min_separation_nanos`; closer-spaced samples are dropped before they
6783 /// reach the reader's channel. `0` disables the filter. Returns `true` if
6784 /// the reader exists. This is the seam `rmw_zerodds` uses to rate-limit ROS-2
6785 /// subscriptions (`rmw_qos_profile_t` carries no TIME_BASED_FILTER field).
6786 pub fn set_user_reader_time_based_filter(
6787 &self,
6788 eid: EntityId,
6789 min_separation_nanos: u128,
6790 ) -> bool {
6791 let Some(arc) = self.reader_slot(eid) else {
6792 return false;
6793 };
6794 let Ok(mut slot) = arc.lock() else {
6795 return false;
6796 };
6797 slot.tbf_min_separation_nanos = min_separation_nanos;
6798 if min_separation_nanos == 0 {
6799 slot.tbf_last_delivered.clear();
6800 }
6801 true
6802 }
6803
6804 /// Bug-2 diagnosis (2026-05-19): number of submessages dropped
6805 /// because of an unknown writer_id. If this value is incremented
6806 /// after a write, it indicates an SEDP match
6807 /// race (writer_proxy not yet added when DATA is received).
6808 #[must_use]
6809 pub fn user_reader_unknown_src_count(&self, eid: EntityId) -> u64 {
6810 self.reader_slot(eid)
6811 .and_then(|arc| arc.lock().ok().map(|s| s.reader.unknown_src_count()))
6812 .unwrap_or(0)
6813 }
6814
6815 /// Sample-rejected status (reader side). Spec §2.2.4.2.6.3.
6816 #[must_use]
6817 pub fn user_reader_sample_rejected(
6818 &self,
6819 eid: EntityId,
6820 ) -> crate::status::SampleRejectedStatus {
6821 self.reader_slot(eid)
6822 .and_then(|arc| arc.lock().ok().map(|s| s.sample_rejected))
6823 .unwrap_or_default()
6824 }
6825
6826 /// Records a lost sample on the user reader. Called
6827 /// by resource-limit or decode-failure paths — the
6828 /// detector is application-external, because sample-lost depending on the
6829 /// implementation comes from several sources (cache drop, decode
6830 /// fail, sequence-number gap drop).
6831 pub fn record_sample_lost(&self, eid: EntityId, count: u32) {
6832 if count == 0 {
6833 return;
6834 }
6835 if let Some(arc) = self.reader_slot(eid) {
6836 if let Ok(mut slot) = arc.lock() {
6837 slot.sample_lost_count = slot.sample_lost_count.saturating_add(u64::from(count));
6838 }
6839 }
6840 }
6841
6842 /// Records a rejected sample on the user reader.
6843 pub fn record_sample_rejected(
6844 &self,
6845 eid: EntityId,
6846 kind: crate::status::SampleRejectedStatusKind,
6847 instance: crate::instance_handle::InstanceHandle,
6848 ) {
6849 if let Some(arc) = self.reader_slot(eid) {
6850 if let Ok(mut slot) = arc.lock() {
6851 slot.sample_rejected.total_count =
6852 slot.sample_rejected.total_count.saturating_add(1);
6853 slot.sample_rejected.last_reason = kind;
6854 slot.sample_rejected.last_instance_handle = instance;
6855 }
6856 }
6857 }
6858
6859 /// Manual liveliness assert on the user writer. Sets the
6860 /// `last_liveliness_assert` timestamp. For `LivelinessKind::Automatic`
6861 /// `last_write` is also set — the liveliness path
6862 /// otherwise never falls through the `assert` trigger, because every successful
6863 /// `write` already takes over the liveliness tick.
6864 pub fn assert_writer_liveliness_eid(&self, eid: EntityId) {
6865 let now = self.start_instant.elapsed();
6866 if let Some(arc) = self.writer_slot(eid) {
6867 if let Ok(mut slot) = arc.lock() {
6868 slot.last_liveliness_assert = Some(now);
6869 if slot.liveliness_kind == zerodds_qos::LivelinessKind::Automatic {
6870 slot.last_write = Some(now);
6871 }
6872 }
6873 }
6874 }
6875
6876 /// True if all matched readers have acknowledged all samples written
6877 /// so far. Empty cache or no proxies → true.
6878 #[must_use]
6879 pub fn user_writer_all_acknowledged(&self, eid: EntityId) -> bool {
6880 self.writer_slot(eid)
6881 .and_then(|arc| arc.lock().ok().map(|s| s.writer.all_samples_acknowledged()))
6882 .unwrap_or(true)
6883 }
6884
6885 /// Test helper — pushes a synthetic `UserSample::Alive`
6886 /// directly into the `mpsc::Sender` of the given reader, without
6887 /// going through the wire/discovery path. Enables end-to-end tests of
6888 /// downstream consumers (e.g. bridge-daemon pumps) that otherwise
6889 /// become flaky in CI containers due to multicast-loopback limits.
6890 /// **Not** for production code.
6891 ///
6892 /// `writer_guid` and `writer_strength` are set to default values
6893 /// (shared-ownership assumption).
6894 ///
6895 /// Returns `true` if the reader slot exists and the push
6896 /// succeeded, `false` if the EID is unknown or the channel is
6897 /// closed.
6898 #[doc(hidden)]
6899 pub fn test_inject_user_alive(&self, eid: EntityId, payload: Vec<u8>) -> bool {
6900 let Some(arc) = self.reader_slot(eid) else {
6901 return false;
6902 };
6903 let Ok(mut slot) = arc.lock() else {
6904 return false;
6905 };
6906 let sent = slot
6907 .sample_tx
6908 .send(UserSample::Alive {
6909 payload: crate::sample_bytes::SampleBytes::from_vec(payload),
6910 writer_guid: [0u8; 16],
6911 writer_strength: 0,
6912 representation: 0,
6913 big_endian: false,
6914 source_timestamp: None,
6915 source_sequence_number: -1,
6916 })
6917 .is_ok();
6918 if sent {
6919 slot.samples_delivered_count = slot.samples_delivered_count.saturating_add(1);
6920 }
6921 sent
6922 }
6923
6924 /// Test helper — bumps the inconsistent-topic counter as if matching had
6925 /// discovered a remote endpoint with the same `topic_name` but a
6926 /// different `type_name`. Lets listener-FFI tests exercise the
6927 /// `on_inconsistent_topic` poll path without standing up two
6928 /// participants with a real SEDP type mismatch. **Not** for production.
6929 #[doc(hidden)]
6930 pub fn test_bump_inconsistent_topic(&self) {
6931 self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
6932 }
6933
6934 /// Spec §3.1 zerodds-async-1.0: registers the waker of an
6935 /// async reader in the UserReaderSlot. On `sample_tx.send`
6936 /// the waker is woken. `None` as the argument clears the waker
6937 /// (e.g. after the async reader is dropped).
6938 pub fn register_user_reader_waker(&self, eid: EntityId, waker: Option<core::task::Waker>) {
6939 if let Some(arc) = self.reader_slot(eid) {
6940 if let Ok(slot) = arc.lock() {
6941 if let Ok(mut g) = slot.async_waker.lock() {
6942 *g = waker;
6943 }
6944 }
6945 }
6946 }
6947
6948 /// Register a listener callback for alive-sample
6949 /// arrival on the user reader. `None` clears an
6950 /// existing listener.
6951 ///
6952 /// The listener fires synchronously on the recv thread of
6953 /// `recv_user_data_loop` — see the contract doc on the
6954 /// [`UserReaderListener`] type. Eliminates the user-polling
6955 /// latency (~50-100 µs) compared to `sample_tx.recv()`.
6956 ///
6957 /// Returns `true` if the reader slot exists and the listener
6958 /// was set, `false` if the EID is not a known user reader.
6959 pub fn set_user_reader_listener(
6960 &self,
6961 eid: EntityId,
6962 listener: Option<UserReaderListener>,
6963 ) -> bool {
6964 let Some(arc) = self.reader_slot(eid) else {
6965 return false;
6966 };
6967 let Ok(mut slot) = arc.lock() else {
6968 return false;
6969 };
6970 slot.listener = listener.map(alloc::sync::Arc::new);
6971 true
6972 }
6973
6974 /// Number of matched writers for a local user reader: remote/cross-
6975 /// participant writers (writer proxies) plus same-participant writers from the
6976 /// intra-runtime routes, deduplicated by GUID (symmetric to the writer).
6977 #[must_use]
6978 pub fn user_reader_matched_count(&self, eid: EntityId) -> usize {
6979 self.user_reader_matched_publication_handles(eid).len()
6980 }
6981
6982 /// D.5e Phase-1 — waits until a match event occurs or the timeout
6983 /// is reached. Replaces 20-ms polling in `DataReader::wait_for_matched_*`
6984 /// and `DataWriter::wait_for_matched_*`.
6985 ///
6986 /// The caller checks the match count itself (via `user_*_matched_count`)
6987 /// before and after the wait — this function is only the block mechanics.
6988 /// Returns `false` if the timeout is reached, `true` if a notify came.
6989 #[cfg(feature = "std")]
6990 pub fn wait_match_event(&self, timeout: core::time::Duration) -> bool {
6991 let (lock, cvar) = &*self.match_event;
6992 let Ok(guard) = lock.lock() else { return false };
6993 match cvar.wait_timeout(guard, timeout) {
6994 Ok((_, t)) => !t.timed_out(),
6995 Err(_) => false,
6996 }
6997 }
6998
6999 /// D.5e Phase-1 — waits until an ACK event occurs or a timeout.
7000 /// Replaces 50-ms polling in `DataWriter::wait_for_acknowledgments`.
7001 #[cfg(feature = "std")]
7002 pub fn wait_ack_event(&self, timeout: core::time::Duration) -> bool {
7003 let (lock, cvar) = &*self.ack_event;
7004 let Ok(guard) = lock.lock() else { return false };
7005 match cvar.wait_timeout(guard, timeout) {
7006 Ok((_, t)) => !t.timed_out(),
7007 Err(_) => false,
7008 }
7009 }
7010
7011 /// D.5e Phase-1 — notify helper for the ACK event. Called by the reliable
7012 /// writer path when an ACKNACK advances the acked-base.
7013 #[cfg(feature = "std")]
7014 pub(crate) fn notify_ack_event(&self) {
7015 self.ack_event.1.notify_all();
7016 }
7017
7018 /// ADR-0006 — sets the PID_SHM_LOCATOR bytes for a local
7019 /// user writer in the side map. Called by the DataWriter
7020 /// once `set_flat_backend` has attached a same-host backend (POSIX shm /
7021 /// Iceoryx2). On the next SEDP push the wire encoder
7022 /// injects PID 0x8001 into the `PublicationData`.
7023 pub fn set_shm_locator(&self, eid: EntityId, bytes: Vec<u8>) {
7024 if let Ok(mut g) = self.shm_locators.write() {
7025 g.insert(eid, bytes);
7026 }
7027 }
7028
7029 /// ADR-0006 — reads the PID_SHM_LOCATOR bytes for a local
7030 /// user writer from the side map. Returns `None` if no
7031 /// same-host backend is set.
7032 #[must_use]
7033 pub fn shm_locator(&self, eid: EntityId) -> Option<Vec<u8>> {
7034 self.shm_locators.read().ok()?.get(&eid).cloned()
7035 }
7036
7037 /// ADR-0006 — removes the PID_SHM_LOCATOR entry (e.g. when the
7038 /// user writer is reconfigured without a backend).
7039 pub fn clear_shm_locator(&self, eid: EntityId) {
7040 if let Ok(mut g) = self.shm_locators.write() {
7041 g.remove(&eid);
7042 }
7043 }
7044
7045 /// Stops all worker threads (recv loops + tick loop) and joins
7046 /// them. Idempotent — repeated calls are no-ops.
7047 ///
7048 /// Shutdown delay: up to ~1 s, because the recv threads sit in
7049 /// `recv()` with a 1 s read timeout. After the
7050 /// current recv() call finishes they check the stop flag and
7051 /// terminate.
7052 pub fn shutdown(&self) {
7053 self.stop.store(true, Ordering::Relaxed);
7054 // D.5e Phase 3 — wake the scheduler tick worker so it observes `stop`
7055 // immediately instead of parking up to the idle floor.
7056 if let Ok(guard) = self.tick_wake.lock() {
7057 if let Some(h) = guard.as_ref() {
7058 h.stop();
7059 }
7060 }
7061 if let Ok(mut guard) = self.handles.lock() {
7062 for h in guard.drain(..) {
7063 let _ = h.join();
7064 }
7065 }
7066 }
7067}
7068
7069impl Drop for DcpsRuntime {
7070 // ZERODDS_PHASE_DUMP=1 is on-demand debug telemetry for
7071 // phase-latency profiling. eprintln is semantically correct here
7072 // (stderr diagnostics), no log-crate dependency wanted.
7073 #[allow(clippy::print_stderr)]
7074 fn drop(&mut self) {
7075 if std::env::var("ZERODDS_PHASE_DUMP")
7076 .map(|s| s == "1")
7077 .unwrap_or(false)
7078 {
7079 let hu_ns = PHASE_HANDLE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
7080 let hu_n = PHASE_HANDLE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
7081 let wu_ns = PHASE_WRITE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
7082 let wu_n = PHASE_WRITE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
7083 let hu_us = if hu_n > 0 {
7084 hu_ns as f64 / hu_n as f64 / 1000.0
7085 } else {
7086 0.0
7087 };
7088 let wu_us = if wu_n > 0 {
7089 wu_ns as f64 / wu_n as f64 / 1000.0
7090 } else {
7091 0.0
7092 };
7093 eprintln!(
7094 "[ZERODDS_PHASE] handle_user_datagram: N={} avg={:.3}us total={:.1}ms",
7095 hu_n,
7096 hu_us,
7097 hu_ns as f64 / 1_000_000.0
7098 );
7099 eprintln!(
7100 "[ZERODDS_PHASE] write_user_sample: N={} avg={:.3}us total={:.1}ms",
7101 wu_n,
7102 wu_us,
7103 wu_ns as f64 / 1_000_000.0
7104 );
7105 // Sub-phases of write_user_sample_borrowed.
7106 // [0] slot_lookup, [1] slot_lock_acquire,
7107 // [2] writer.write + framing, [3] dispatch (UDP + inproc).
7108 const SUB_LABELS: [&str; 4] = [
7109 " ├─ slot_lookup ",
7110 " ├─ slot_lock_acquire ",
7111 " ├─ writer.write+frame",
7112 " └─ dispatch (UDP+...)",
7113 ];
7114 for (i, label) in SUB_LABELS.iter().enumerate() {
7115 let s_ns = PHASE_WRITE_SUB_NS[i].load(core::sync::atomic::Ordering::Relaxed);
7116 if s_ns > 0 && wu_n > 0 {
7117 let s_us = s_ns as f64 / wu_n as f64 / 1000.0;
7118 eprintln!(
7119 "[ZERODDS_PHASE] {} avg={:.3}us total={:.1}ms",
7120 label,
7121 s_us,
7122 s_ns as f64 / 1_000_000.0
7123 );
7124 }
7125 }
7126 }
7127 self.shutdown();
7128 }
7129}
7130
7131// ---------------------------------------------------------------------
7132// Worker threads (Sprint D.5b — per-socket recv + central tick).
7133//
7134// Before: a single `event_loop` that went through three sequential
7135// blocking `recv()`s with a `tick_period` timeout (50 ms) per iteration.
7136// Roundtrip latency: 5-14 ms p50 (CFS drift + sequential wait stages).
7137//
7138// Now: four dedicated threads.
7139// * recv_spdp_multicast_loop — blocks on the SPDP multicast socket
7140// * recv_metatraffic_loop — blocks on SPDP unicast (= metatraffic)
7141// * recv_user_data_loop — blocks on user-data unicast
7142// * tick_loop — periodic outbound tasks +
7143// per-interface inbound (non-blocking) +
7144// deadline/lifespan/liveliness
7145//
7146// Lock discipline: the recv threads and the tick thread contend for
7147// `rt.sedp.lock()` / `rt.wlp.lock()` / per-slot `slot.lock()`.
7148// Convention: keep lock-hold times short (handle_datagram + tick each
7149// have only single-pass logic), no sub-lock under sedp/wlp.
7150// ---------------------------------------------------------------------
7151
7152/// Sprint D.5d lever C — applies SCHED_FIFO + CPU affinity to the
7153/// calling thread. Linux-only; no-op on macOS/Windows.
7154///
7155/// Called by every worker loop right at the start, so
7156/// the syscalls run on the actual worker thread
7157/// (`pthread_self()` must come from the thread itself).
7158///
7159/// Failures are logged to stderr but are not fatal — if
7160/// the process has no `CAP_SYS_NICE`, the runtime continues with
7161/// the CFS default scheduler.
7162#[allow(unused_variables)]
7163fn apply_thread_tuning(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
7164 #[cfg(target_os = "linux")]
7165 rt_pinning::apply(label, priority, cpus);
7166}
7167
7168/// Linux-only `pthread_setschedparam` + `sched_setaffinity` wrapper.
7169/// A dedicated module encapsulates the `unsafe` locally with safety notes; the
7170/// crate-level `#![deny(unsafe_code)]` stays active for the rest of the dcps
7171/// codebase.
7172#[cfg(target_os = "linux")]
7173#[allow(unsafe_code, clippy::print_stderr)]
7174mod rt_pinning {
7175 pub(super) fn apply(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
7176 if let Some(prio) = priority {
7177 // SAFETY: libc FFI with an owned `param` struct. The self-thread via
7178 // `pthread_self()` is always valid.
7179 // musl libc has additional `sched_ss_*` fields (POSIX
7180 // sporadic-server) that we do not set — `mem::zeroed`
7181 // initializes them cleanly to 0.
7182 unsafe {
7183 let mut param: libc::sched_param = core::mem::zeroed();
7184 param.sched_priority = prio;
7185 let rc = libc::pthread_setschedparam(
7186 libc::pthread_self(),
7187 libc::SCHED_FIFO,
7188 &raw const param,
7189 );
7190 if rc != 0 {
7191 eprintln!(
7192 "zdds[{label}]: pthread_setschedparam SCHED_FIFO {prio} \
7193 failed (rc={rc}). Need CAP_SYS_NICE or RLIMIT_RTPRIO."
7194 );
7195 }
7196 }
7197 }
7198 if let Some(cpu_list) = cpus {
7199 // SAFETY: cpu_set_t is POD; CPU_ZERO/SET are libc inline
7200 // functions without lifetime requirements.
7201 unsafe {
7202 let mut set: libc::cpu_set_t = core::mem::zeroed();
7203 libc::CPU_ZERO(&mut set);
7204 for &cpu in cpu_list {
7205 if cpu < libc::CPU_SETSIZE as usize {
7206 libc::CPU_SET(cpu, &mut set);
7207 }
7208 }
7209 let rc = libc::sched_setaffinity(
7210 0,
7211 core::mem::size_of::<libc::cpu_set_t>(),
7212 &raw const set,
7213 );
7214 if rc != 0 {
7215 eprintln!("zdds[{label}]: sched_setaffinity({cpu_list:?}) failed.");
7216 }
7217 }
7218 }
7219 }
7220}
7221
7222/// FastDDS interop (phase 2): acknowledges FastDDS' reliable secure SPDP writer
7223/// (0xff0101c2). FastDDS heartbeats its secure SPDP reliably and sends the
7224/// `participant_crypto_tokens` only once our 0xff0101c7 reader has acked its writer
7225/// (fast<->fast reference pcap: ACKNACK on 0xff0101c7). We respond to
7226/// every incoming secure-SPDP HEARTBEAT with an ACKNACK (base = last+1,
7227/// final), addressed via INFO_DST to the sender prefix. Gated on
7228/// `enable_secure_spdp`.
7229#[cfg(feature = "security")]
7230fn secure_spdp_reader_acks(rt: &DcpsRuntime, clear: &[u8]) -> Vec<Vec<u8>> {
7231 use zerodds_rtps::header::RtpsHeader;
7232 use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
7233 use zerodds_rtps::submessages::{AckNackSubmessage, HeartbeatSubmessage, SequenceNumberSet};
7234 use zerodds_rtps::wire_types::SequenceNumber;
7235 if !rt.config.enable_secure_spdp {
7236 return Vec::new();
7237 }
7238 let Ok(parsed) = decode_datagram(clear) else {
7239 return Vec::new();
7240 };
7241 let peer_prefix = parsed.header.guid_prefix;
7242 let mut out = Vec::new();
7243 let mut count = 0i32;
7244 let secure_writer = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER;
7245 let secure_reader = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER;
7246 // Header + INFO_DST(peer) + submessage. INFO_DST is mandatory, otherwise the
7247 // dest prefix is UNKNOWN -> FastDDS discards it as "not a connection".
7248 let wrap = |id: SubmessageId, body: &[u8], flags: u8| -> Option<Vec<u8>> {
7249 let blen = u16::try_from(body.len()).ok()?;
7250 let header = RtpsHeader::new(VendorId::ZERODDS, rt.guid_prefix);
7251 let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
7252 dg.extend_from_slice(&header.to_bytes());
7253 let info = SubmessageHeader {
7254 submessage_id: SubmessageId::InfoDst,
7255 flags: FLAG_E_LITTLE_ENDIAN,
7256 octets_to_next_header: 12,
7257 };
7258 dg.extend_from_slice(&info.to_bytes());
7259 dg.extend_from_slice(&peer_prefix.to_bytes());
7260 let sh = SubmessageHeader {
7261 submessage_id: id,
7262 flags: flags | FLAG_E_LITTLE_ENDIAN,
7263 octets_to_next_header: blen,
7264 };
7265 dg.extend_from_slice(&sh.to_bytes());
7266 dg.extend_from_slice(body);
7267 Some(dg)
7268 };
7269 for sub in &parsed.submessages {
7270 match sub {
7271 // FastDDS' secure-SPDP writer HEARTBEAT -> we ack (reader 0xff0101c7).
7272 ParsedSubmessage::Heartbeat(hb) if hb.writer_id == secure_writer => {
7273 count = count.wrapping_add(1);
7274 let ack = AckNackSubmessage {
7275 reader_id: secure_reader,
7276 writer_id: secure_writer,
7277 reader_sn_state: SequenceNumberSet {
7278 bitmap_base: SequenceNumber(hb.last_sn.0 + 1),
7279 num_bits: 0,
7280 bitmap: Vec::new(),
7281 },
7282 count,
7283 final_flag: true,
7284 };
7285 let (body, flags) = ack.write_body(true);
7286 if let Some(dg) = wrap(SubmessageId::AckNack, &body, flags) {
7287 out.push(dg);
7288 }
7289 }
7290 // FastDDS' reader requests (preemptive ACKNACK to our 0xff0101c2
7291 // writer) our secure-SPDP data reliably -> deliver DATA(SN=1) +
7292 // HEARTBEAT(1,1), otherwise FastDDS' reader never matches and
7293 // sends no crypto_tokens.
7294 ParsedSubmessage::AckNack(a) if a.writer_id == secure_writer => {
7295 if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7296 if let Ok(data_dg) = beacon.serialize_secure() {
7297 out.push(protect_secure_spdp(rt, &data_dg).unwrap_or(data_dg));
7298 }
7299 }
7300 count = count.wrapping_add(1);
7301 let hbsm = HeartbeatSubmessage {
7302 reader_id: secure_reader,
7303 writer_id: secure_writer,
7304 first_sn: SequenceNumber(1),
7305 last_sn: SequenceNumber(1),
7306 count,
7307 final_flag: false,
7308 liveliness_flag: false,
7309 group_info: None,
7310 };
7311 let (body, flags) = hbsm.write_body(true);
7312 if let Some(dg) = wrap(SubmessageId::Heartbeat, &body, flags) {
7313 out.push(dg);
7314 }
7315 }
7316 _ => {}
7317 }
7318 }
7319 out
7320}
7321
7322/// FastDDS interop (phase 2b): builds a secure-SPDP HEARTBEAT (writer
7323/// 0xff0101c2, first=1/last=1) with INFO_DST to `peer_prefix`. Sent periodically per
7324/// discovered peer, so FastDDS' reliable secure-SPDP reader is solicited to a
7325/// (preemptive) ACKNACK and matches our writer.
7326#[cfg(feature = "security")]
7327fn build_secure_spdp_heartbeat(
7328 local_prefix: GuidPrefix,
7329 peer_prefix: GuidPrefix,
7330 count: i32,
7331) -> Option<Vec<u8>> {
7332 use zerodds_rtps::header::RtpsHeader;
7333 use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
7334 use zerodds_rtps::submessages::HeartbeatSubmessage;
7335 use zerodds_rtps::wire_types::SequenceNumber;
7336 let hb = HeartbeatSubmessage {
7337 reader_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
7338 writer_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
7339 first_sn: SequenceNumber(1),
7340 last_sn: SequenceNumber(1),
7341 count,
7342 final_flag: false,
7343 liveliness_flag: false,
7344 group_info: None,
7345 };
7346 let (body, flags) = hb.write_body(true);
7347 let blen = u16::try_from(body.len()).ok()?;
7348 let header = RtpsHeader::new(VendorId::ZERODDS, local_prefix);
7349 let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
7350 dg.extend_from_slice(&header.to_bytes());
7351 let info = SubmessageHeader {
7352 submessage_id: SubmessageId::InfoDst,
7353 flags: FLAG_E_LITTLE_ENDIAN,
7354 octets_to_next_header: 12,
7355 };
7356 dg.extend_from_slice(&info.to_bytes());
7357 dg.extend_from_slice(&peer_prefix.to_bytes());
7358 let sh = SubmessageHeader {
7359 submessage_id: SubmessageId::Heartbeat,
7360 flags: flags | FLAG_E_LITTLE_ENDIAN,
7361 octets_to_next_header: blen,
7362 };
7363 dg.extend_from_slice(&sh.to_bytes());
7364 dg.extend_from_slice(&body);
7365 Some(dg)
7366}
7367
7368/// FastDDS interop: SEC-protects the secure-SPDP DATA (0xff0101c2) under
7369/// `discovery_protection != NONE` — FastDDS then encrypts the secure-SPDP DATA
7370/// (like the secure SEDP), and a PLAIN secure SPDP is discarded. Wraps
7371/// the DATA submessage with the per-endpoint writer key (0xff0101c2) as
7372/// SEC_PREFIX/BODY/POSTFIX; framing submessages (INFO_*) stay. Without
7373/// discovery_protection (common subset) passthrough. `None` on a crypto error.
7374#[cfg(feature = "security")]
7375fn protect_secure_spdp(rt: &DcpsRuntime, datagram: &[u8]) -> Option<Vec<u8>> {
7376 let gate = rt.config.security.as_ref()?;
7377 if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None
7378 || datagram.len() < 20
7379 {
7380 return Some(datagram.to_vec());
7381 }
7382 let h = local_endpoint_crypto_handle(
7383 rt,
7384 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
7385 true,
7386 )?;
7387 let mut out = datagram[..20].to_vec();
7388 for (id, start, total) in walk_submessages(datagram) {
7389 let submsg = &datagram[start..start + total];
7390 if id == SMID_DATA {
7391 match gate.encode_data_datawriter_by_handle(h, submsg) {
7392 Ok(s) => out.extend_from_slice(&s),
7393 Err(_) => return None,
7394 }
7395 } else {
7396 out.extend_from_slice(submsg);
7397 }
7398 }
7399 Some(out)
7400}
7401
7402/// Worker: blocks on the SPDP multicast socket, dispatches SPDP beacons +
7403/// WLP heartbeats that come in over multicast.
7404fn recv_spdp_multicast_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7405 apply_thread_tuning(
7406 "recv-spdp-mc",
7407 rt.config.recv_thread_priority,
7408 rt.config.recv_thread_cpus.as_deref(),
7409 );
7410 while !stop.load(Ordering::Relaxed) {
7411 let elapsed = rt.start_instant.elapsed();
7412 let sedp_now = Duration::from_secs(elapsed.as_secs())
7413 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7414 let Ok(dg) = rt.spdp_multicast_rx.recv() else {
7415 continue;
7416 };
7417 #[cfg(feature = "security")]
7418 let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7419 #[cfg(not(feature = "security"))]
7420 let clear = secure_inbound_bytes(&rt, &dg.data);
7421 if let Some(clear) = clear {
7422 handle_spdp_datagram(&rt, &clear);
7423 // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
7424 // reliably, otherwise FastDDS sends no crypto_tokens.
7425 #[cfg(feature = "security")]
7426 for ack in secure_spdp_reader_acks(&rt, &clear) {
7427 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7428 let _ = rt.spdp_unicast.send(&loc, &ack);
7429 }
7430 }
7431 // WLP heartbeats arrive on the SPDP multicast socket
7432 // (the sender sends them to the SPDP multicast group).
7433 // handle_spdp_datagram ignores them, so we also feed
7434 // the same buffer into the WLP endpoint. A
7435 // secure-WLP DATA is participant-key SEC-protected → decode
7436 // it first (like secure SEDP in the metatraffic loop), otherwise
7437 // wlp.handle_datagram would only see the SEC block.
7438 #[cfg(feature = "security")]
7439 let wlp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
7440 let mut pk = [0u8; 12];
7441 pk.copy_from_slice(&clear[8..20]);
7442 unprotect_user_datagram(&rt, &clear, &pk)
7443 } else {
7444 None
7445 };
7446 #[cfg(feature = "security")]
7447 let wlp_input: &[u8] = wlp_decoded.as_deref().unwrap_or(&clear);
7448 #[cfg(not(feature = "security"))]
7449 let wlp_input: &[u8] = &clear;
7450 if let Ok(mut wlp) = rt.wlp.lock() {
7451 let _ = wlp.handle_datagram(wlp_input, sedp_now);
7452 }
7453 }
7454 }
7455}
7456
7457/// Worker: blocks on SPDP unicast (= metatraffic socket), dispatches
7458/// SPDP reverse beacons + SEDP + WLP + security builtin.
7459fn recv_metatraffic_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7460 apply_thread_tuning(
7461 "recv-meta",
7462 rt.config.recv_thread_priority,
7463 rt.config.recv_thread_cpus.as_deref(),
7464 );
7465 while !stop.load(Ordering::Relaxed) {
7466 let elapsed = rt.start_instant.elapsed();
7467 let sedp_now = Duration::from_secs(elapsed.as_secs())
7468 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7469 let Ok(dg) = rt.spdp_unicast.recv() else {
7470 continue;
7471 };
7472 #[cfg(feature = "security")]
7473 let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7474 #[cfg(not(feature = "security"))]
7475 let clear = secure_inbound_bytes(&rt, &dg.data);
7476 if let Some(clear) = clear {
7477 // A single recv call, both handlers on the same
7478 // datagram. SPDP first (Cyclone reverse beacons), then
7479 // SEDP, then WLP, then security builtin.
7480 handle_spdp_datagram(&rt, &clear);
7481 // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
7482 // reliably (they arrive unicast over the metatraffic socket).
7483 #[cfg(feature = "security")]
7484 for ack in secure_spdp_reader_acks(&rt, &clear) {
7485 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7486 let _ = rt.spdp_unicast.send(&loc, &ack);
7487 }
7488 }
7489 // Protected discovery: secure-SEDP DATA is SEC_* submessage-
7490 // protected (the sender's participant data key). Before the SEDP parse
7491 // decode it with the sender prefix (RTPS header bytes[8..20]); for
7492 // plaintext SEDP (no SEC_*) unprotect_user_datagram returns None
7493 // and we use `clear` unchanged.
7494 #[cfg(feature = "security")]
7495 let sedp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
7496 let mut pk = [0u8; 12];
7497 pk.copy_from_slice(&clear[8..20]);
7498 unprotect_user_datagram(&rt, &clear, &pk)
7499 } else {
7500 None
7501 };
7502 // OPEN (phase 3, internal/security/per-endpoint-crypto-followup.md):
7503 // if `unprotect_user_datagram` fails for a secure-SEDP DATA
7504 // (cyclone's per-endpoint token not yet installed — race),
7505 // `sedp_input` falls back to the SEC_* bytes and the DATA is discarded.
7506 // Cross-vendor (discovery=ENCRYPT) must make this deterministic:
7507 // treat the reliable secure-SEDP DATA as not-received (NACK,
7508 // no SN advance), so the re-send after token install decodes.
7509 #[cfg(feature = "security")]
7510 let sedp_input: &[u8] = sedp_decoded.as_deref().unwrap_or(&clear);
7511 #[cfg(not(feature = "security"))]
7512 let sedp_input: &[u8] = &clear;
7513 let events = {
7514 if let Ok(mut sedp) = rt.sedp.lock() {
7515 sedp.handle_datagram(sedp_input, sedp_now).ok()
7516 } else {
7517 None
7518 }
7519 };
7520 if let Some(ev) = events {
7521 if !ev.is_empty() {
7522 run_matching_pass(&rt);
7523 apply_sedp_removals(&rt, &ev);
7524 push_sedp_events_to_builtin_readers(&rt, &ev);
7525 }
7526 }
7527
7528 // Secure WLP (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER) is, like
7529 // secure SEDP, participant-key SEC-protected → feed the decoded variant
7530 // (sedp_input), not the still SEC-wrapped `clear`. For
7531 // plaintext WLP, sedp_input == clear.
7532 let wlp_resends = if let Ok(mut wlp) = rt.wlp.lock() {
7533 let _ = wlp.handle_datagram(sedp_input, sedp_now);
7534 // Reliable resend: if the peer NACKs our (secure-)WLP writer,
7535 // we re-emit the missing beats (cyclone treats WLP as
7536 // reliable; without a resend it would never get the liveliness assertion).
7537 wlp.wlp_acknack_resends(sedp_input)
7538 } else {
7539 Vec::new()
7540 };
7541 for beat in wlp_resends {
7542 if let Some(secured) = protect_wlp_outbound(&rt, &beat) {
7543 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7544 let _ = rt.spdp_unicast.send(&loc, &secured);
7545 }
7546 }
7547 }
7548 for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
7549 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7550 }
7551 }
7552 }
7553}
7554
7555/// Supervisor: wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) — same-host SHM
7556/// receive. Spawns **one dedicated receive thread per bound SHM consumer**, so
7557/// each blocks on its own segment futex ([`PosixShmTransport::recv`] →
7558/// `wait_for_frame`) and dispatches a sample the instant it lands.
7559///
7560/// History: the original single loop iterated all consumers round-robin and
7561/// called the blocking `recv()` on each in turn. With N consumers the
7562/// worst-case per-sample latency was `(N-1) × recv_timeout` (1 ms each, see
7563/// [`crate::same_host_shm::shm_config_for_pair`]) — a single thread cannot wait
7564/// on N futexes at once, so the active consumer's sample waited while the loop
7565/// sat in idle consumers' `recv()` timeouts. Fine for 1-2 same-host peers, but
7566/// it dominated at the many-endpoint scale ROS hits (user topics +
7567/// `ros_discovery_info` + parameter services + `rosout` = a dozen same-host SHM
7568/// consumers), turning a ~30 µs delivery into ~570 µs. The documented fix
7569/// ("multiple threads or epoll-style multiplexing") is realized here as one
7570/// thread per consumer. The supervisor only polls *membership* (discovery-rate,
7571/// not the data path) to spawn/reap workers.
7572#[cfg(feature = "same-host-shm")]
7573fn recv_user_shm_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7574 use crate::same_host::{Role, SameHostState};
7575 use zerodds_transport_shm::PosixShmTransport;
7576
7577 apply_thread_tuning(
7578 "recv-shm-sup",
7579 rt.config.recv_thread_priority,
7580 rt.config.recv_thread_cpus.as_deref(),
7581 );
7582 // segment-id → (per-worker stop flag, join handle).
7583 let mut workers: std::collections::HashMap<
7584 [u8; 16],
7585 (Arc<AtomicBool>, thread::JoinHandle<()>),
7586 > = std::collections::HashMap::new();
7587 // Membership poll is discovery-rate, NOT the data path: it only detects
7588 // newly-bound / vanished consumers to spawn / reap their worker thread.
7589 let membership_poll = Duration::from_millis(100);
7590 while !stop.load(Ordering::Relaxed) {
7591 let mut live: std::collections::HashSet<[u8; 16]> = std::collections::HashSet::new();
7592 for (w, r, state) in rt.same_host.snapshot() {
7593 let SameHostState::Bound { transport, role } = state else {
7594 continue;
7595 };
7596 if !matches!(role, Role::Consumer) {
7597 continue;
7598 }
7599 let Ok(consumer) = transport.downcast::<PosixShmTransport>() else {
7600 continue;
7601 };
7602 let key = crate::same_host::shm_segment_id_for_pair(w, r);
7603 live.insert(key);
7604 if workers.contains_key(&key) {
7605 continue;
7606 }
7607 let wstop = Arc::new(AtomicBool::new(false));
7608 let (rt_w, stop_w, wstop_w) = (Arc::clone(&rt), Arc::clone(&stop), Arc::clone(&wstop));
7609 if let Ok(h) = thread::Builder::new()
7610 .name(String::from("zdds-recv-shm-c"))
7611 .spawn(move || shm_consumer_recv_loop(rt_w, consumer, stop_w, wstop_w))
7612 {
7613 workers.insert(key, (wstop, h));
7614 }
7615 }
7616 // Reap workers whose consumer disappeared.
7617 let gone: Vec<[u8; 16]> = workers
7618 .keys()
7619 .filter(|k| !live.contains(*k))
7620 .copied()
7621 .collect();
7622 for k in gone {
7623 if let Some((wstop, h)) = workers.remove(&k) {
7624 wstop.store(true, Ordering::Relaxed);
7625 let _ = h.join();
7626 }
7627 }
7628 thread::sleep(membership_poll);
7629 }
7630 // Shutdown: stop + join every worker (each wakes within its 1 ms recv_timeout).
7631 for (_, (wstop, h)) in workers {
7632 wstop.store(true, Ordering::Relaxed);
7633 let _ = h.join();
7634 }
7635}
7636
7637/// One per-consumer SHM receive thread: blocks on this segment's futex and
7638/// dispatches each frame the instant it arrives — no cross-consumer
7639/// serialization. Exits when the runtime stops, the worker is reaped, or the
7640/// segment dies. See [`recv_user_shm_loop`].
7641#[cfg(feature = "same-host-shm")]
7642fn shm_consumer_recv_loop(
7643 rt: Arc<DcpsRuntime>,
7644 consumer: Arc<zerodds_transport_shm::PosixShmTransport>,
7645 stop: Arc<AtomicBool>,
7646 wstop: Arc<AtomicBool>,
7647) {
7648 use zerodds_transport::Transport;
7649 apply_thread_tuning(
7650 "recv-shm-c",
7651 rt.config.recv_thread_priority,
7652 rt.config.recv_thread_cpus.as_deref(),
7653 );
7654 while !stop.load(Ordering::Relaxed) && !wstop.load(Ordering::Relaxed) {
7655 match consumer.recv() {
7656 Ok(dg) => {
7657 let elapsed = rt.start_instant.elapsed();
7658 let sedp_now = Duration::from_secs(elapsed.as_secs())
7659 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7660 // Security gate (analogous to the UDP path). SHM is
7661 // same-host-only — if the policy allows plaintext, the
7662 // datagram comes through unchanged.
7663 #[cfg(feature = "security")]
7664 let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7665 #[cfg(not(feature = "security"))]
7666 let clear = secure_inbound_bytes(&rt, &dg.data);
7667 if let Some(clear) = clear {
7668 handle_user_datagram(&rt, &clear, sedp_now);
7669 }
7670 }
7671 // A timeout is normal — the 1 ms recv_timeout just lets the loop
7672 // re-check the stop flags; an empty segment is not an error.
7673 Err(zerodds_transport::RecvError::Timeout) => {}
7674 // Hard error (broken segment / peer crashed): drop this worker; the
7675 // supervisor respawns if the segment is re-bound, UDP stays fallback.
7676 Err(_) => break,
7677 }
7678 }
7679}
7680
7681/// Worker: blocks on the user-data unicast socket, dispatches
7682/// TypeLookup service replies + user-sample datagrams.
7683///
7684/// Int-1 (Spec `zerodds-zero-copy-1.0` §9): with the feature
7685/// `recvmmsg-batch` on Linux the loop uses `recv_batch_linux` and
7686/// fetches up to 32 datagrams per syscall — a 7-8x throughput boost.
7687/// On an empty batch the path falls back to single-recv() so
7688/// the recv thread does not spin in a busy loop at low traffic.
7689fn recv_user_data_loop(
7690 rt: Arc<DcpsRuntime>,
7691 socket: Arc<dyn Transport + Send + Sync>,
7692 stop: Arc<AtomicBool>,
7693) {
7694 apply_thread_tuning(
7695 "recv-user",
7696 rt.config.recv_thread_priority,
7697 rt.config.recv_thread_cpus.as_deref(),
7698 );
7699 // recvmmsg-batch (Linux + feature) needs the concrete UdpSocket
7700 // under the trait. With a trait-object transport this is not directly
7701 // accessible — we fall back to single-recv(). recvmmsg is
7702 // a UDP optimization; once TCP/SHM transports are to be mixed,
7703 // it is no longer worth it. For a pure UDPv4 user transport
7704 // this costs ~5-10% throughput in Linux batch mode (measured 2026-05).
7705 while !stop.load(Ordering::Relaxed) {
7706 let elapsed = rt.start_instant.elapsed();
7707 let sedp_now = Duration::from_secs(elapsed.as_secs())
7708 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7709 let Ok(dg) = socket.recv() else {
7710 continue;
7711 };
7712 dispatch_user_datagram(&rt, &dg, sedp_now);
7713 // D.5e Phase 3 — incoming user data may solicit an ACKNACK or advance a
7714 // reliable reader: wake the scheduler tick immediately (no 5 ms tail).
7715 rt.raise_tick_wake();
7716 }
7717}
7718
7719/// Helper: dispatches a single user datagram through the security gate +
7720/// TypeLookup + handle_user_datagram. Shared by the single-recv and the
7721/// recvmmsg batch path.
7722fn dispatch_user_datagram(
7723 rt: &Arc<DcpsRuntime>,
7724 dg: &zerodds_transport::ReceivedDatagram,
7725 sedp_now: Duration,
7726) {
7727 #[cfg(feature = "security")]
7728 let clear = secure_inbound_bytes(rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7729 #[cfg(not(feature = "security"))]
7730 let clear = secure_inbound_bytes(rt, &dg.data);
7731 if let Some(clear) = clear {
7732 // TypeLookup service first — if the frame is addressed to
7733 // TL_SVC_*_READER, it does not go to a
7734 // user reader. Other frames fall through.
7735 if !dispatch_type_lookup_datagram(rt, &clear, &dg.source) {
7736 handle_user_datagram(rt, &clear, sedp_now);
7737 }
7738 }
7739}
7740
7741/// Worker: periodic outbound tasks + per-interface inbound
7742/// (non-blocking) + housekeeping. Sleeps `tick_period` between
7743/// iterations.
7744fn tick_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7745 apply_thread_tuning(
7746 "tick",
7747 rt.config.tick_thread_priority,
7748 rt.config.tick_thread_cpus.as_deref(),
7749 );
7750 let mut st = TickState::new(&rt);
7751 while !stop.load(Ordering::Relaxed) {
7752 run_tick_iteration(Arc::clone(&rt), &mut st);
7753 // Housekeeping runs inline here in the classic fixed-period path,
7754 // exactly as before (every `tick_period`, same cadence).
7755 tick_housekeep(&rt, rt.start_instant.elapsed());
7756 std::thread::sleep(rt.config.tick_period);
7757 }
7758}
7759
7760/// D.5e Phase 3 — idle park cap for a discovery-only participant (no user
7761/// endpoints): how long the scheduler tick worker may sleep when nothing but
7762/// SPDP/WLP is pending. SPDP/WLP fire on their own (longer) periods, so this is
7763/// just a safety heartbeat — well above the 5 ms poll it replaces.
7764const SCHEDULER_IDLE_FLOOR: Duration = Duration::from_millis(250);
7765
7766/// Earliest instant the scheduler tick worker must next run `run_tick_iteration`
7767/// so no periodic work is delayed: never past the next SPDP announce, and —
7768/// while user endpoints exist — capped at `tick_period` so HEARTBEAT/ACKNACK/
7769/// deadline/lifespan/liveliness keep their current cadence (identical wire
7770/// behaviour). With no user endpoints, parks up to [`SCHEDULER_IDLE_FLOOR`].
7771/// Active traffic is handled out-of-band by `raise_tick_wake` (immediate).
7772fn next_tick_deadline(rt: &Arc<DcpsRuntime>, st: &TickState) -> Instant {
7773 let now = Instant::now();
7774 let fine_cap = if rt.has_user_endpoints() {
7775 rt.config.tick_period
7776 } else {
7777 SCHEDULER_IDLE_FLOOR
7778 };
7779 st.next_announce.min(now + fine_cap).max(now)
7780}
7781
7782/// D.5e Phase 3 B-2 — the kinds of work the deadline-heap scheduler fires as
7783/// distinct heap events, each re-armed at its own next deadline.
7784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7785enum TickEvent {
7786 /// Periodic SPDP announce + reliable outbound (SEDP / WLP / user HEARTBEAT /
7787 /// ACKNACK) + secondary inbound poll — the wire-producing tick
7788 /// ([`run_tick_iteration`]), re-armed at [`next_tick_deadline`].
7789 Tick,
7790 /// Deadline / lifespan / liveliness housekeeping ([`tick_housekeep`]),
7791 /// re-armed at the **exact** next QoS due-instant (no fixed quantum).
7792 Housekeep,
7793}
7794
7795/// D.5e Phase 3 — event-driven scheduler tick worker. Replaces the fixed-period
7796/// `tick_loop` sleep with a deadline-heap park. Two independent event streams:
7797/// [`TickEvent::Tick`] drives the **unchanged** `run_tick_iteration` (wire
7798/// output byte-identical to `tick_loop`), re-armed at [`next_tick_deadline`];
7799/// [`TickEvent::Housekeep`] runs the QoS checks, re-armed at their exact next
7800/// due-instant so a deadline/lifespan/liveliness fires on time instead of up to
7801/// one `tick_period` late, and an idle participant parks long. A write/recv
7802/// `raise_tick_wake` wakes **both** immediately, so freshly-armed QoS windows
7803/// are picked up without delay.
7804fn scheduler_tick_loop(
7805 rt: Arc<DcpsRuntime>,
7806 stop: Arc<AtomicBool>,
7807 mut scheduler: crate::scheduler::Scheduler<TickEvent>,
7808 handle: crate::scheduler::SchedulerHandle<TickEvent>,
7809) {
7810 apply_thread_tuning(
7811 "tick",
7812 rt.config.tick_thread_priority,
7813 rt.config.tick_thread_cpus.as_deref(),
7814 );
7815 let mut st = TickState::new(&rt);
7816 // Prime both event streams immediately.
7817 handle.raise_now(TickEvent::Tick);
7818 handle.raise_now(TickEvent::Housekeep);
7819 loop {
7820 let (due, stopped) = scheduler.park_due_batch();
7821 if stopped || stop.load(Ordering::Relaxed) {
7822 break;
7823 }
7824 if due.is_empty() {
7825 continue; // woken with nothing due yet — re-evaluate.
7826 }
7827 // Coalesce: a batch of wakes maps to at most ONE run of each kind.
7828 let mut do_tick = false;
7829 let mut do_housekeep = false;
7830 for ev in due {
7831 match ev {
7832 TickEvent::Tick => do_tick = true,
7833 TickEvent::Housekeep => do_housekeep = true,
7834 }
7835 }
7836 if do_tick {
7837 rt.tick_wake_pending.store(false, Ordering::Release);
7838 run_tick_iteration(Arc::clone(&rt), &mut st);
7839 if stop.load(Ordering::Relaxed) {
7840 break;
7841 }
7842 handle.raise_at(next_tick_deadline(&rt, &st), TickEvent::Tick);
7843 }
7844 if do_housekeep {
7845 let next = tick_housekeep(&rt, rt.start_instant.elapsed());
7846 if stop.load(Ordering::Relaxed) {
7847 break;
7848 }
7849 // Park exactly until the next QoS due-instant; nothing pending →
7850 // idle floor (a later write re-arms via `raise_tick_wake`).
7851 let deadline = match next {
7852 Some(due_nanos) => rt.start_instant + Duration::from_nanos(due_nanos),
7853 None => Instant::now() + SCHEDULER_IDLE_FLOOR,
7854 };
7855 handle.raise_at(deadline, TickEvent::Housekeep);
7856 }
7857 }
7858}
7859
7860/// Per-iteration mutable state of the runtime tick. Held across iterations so
7861/// the same body ([`run_tick_iteration`]) can be driven from either the
7862/// dedicated `zdds-tick` thread (default) or an external executor — tokio via
7863/// [`DcpsRuntime::tick_driver`] / async `spawn_in_tokio`
7864/// (zerodds-async-1.0 §4).
7865struct TickState {
7866 /// Multicast target locator to which we send SPDP beacons.
7867 mc_target: Locator,
7868 /// Next instant at which a periodic SPDP announce is due.
7869 next_announce: Instant,
7870 /// Number of SPDP announces already sent. Drives the C3 initial
7871 /// announcement burst: as long as `< initial_announce_count` **and** no
7872 /// peer discovered yet, announces happen at `initial_announce_period` cadence
7873 /// instead of the full `spdp_period` — so discovery over lossy/power-save WiFi
7874 /// does not fail on lost first beacons.
7875 announces_done: u32,
7876 /// FastDDS interop: count for the periodic secure-SPDP HEARTBEATs
7877 /// (0xff0101c2). Must increase, otherwise FastDDS' reader ignores follow-up HBs.
7878 #[cfg(feature = "security")]
7879 secure_hb_count: i32,
7880}
7881
7882impl TickState {
7883 fn new(rt: &Arc<DcpsRuntime>) -> Self {
7884 let mc_target = Locator {
7885 kind: LocatorKind::UdpV4,
7886 port: u32::from(
7887 u16::try_from(spdp_multicast_port(rt.domain_id as u32)).unwrap_or(7400),
7888 ),
7889 address: {
7890 let mut a = [0u8; 16];
7891 a[12..].copy_from_slice(&rt.config.spdp_multicast_group.octets());
7892 a
7893 },
7894 };
7895 Self {
7896 mc_target,
7897 next_announce: Instant::now(), // immediately at start
7898 announces_done: 0,
7899 #[cfg(feature = "security")]
7900 secure_hb_count: 0,
7901 }
7902 }
7903}
7904
7905/// One iteration of the runtime's **wire** tick: periodic SPDP announce,
7906/// SEDP/WLP ticks, per-user-writer + per-user-reader ticks, secondary inbound
7907/// poll. QoS housekeeping (deadline/lifespan/liveliness) is **not** part of this
7908/// — each driver calls [`tick_housekeep`] separately (D.5e Phase 3 B-2), so the
7909/// event-driven scheduler can fire it on its own exact-deadline schedule.
7910/// Mutable per-iteration state lives in `st`; the caller waits `tick_period`
7911/// between calls. Factored out of [`tick_loop`] so an external executor can
7912/// drive the tick without the dedicated thread (zerodds-async-1.0 §4).
7913fn run_tick_iteration(rt: Arc<DcpsRuntime>, st: &mut TickState) {
7914 // Monotonic clock relative to runtime start. Used by the SEDP,
7915 // WLP and user tick alike.
7916 let elapsed_since_start = rt.start_instant.elapsed();
7917 let sedp_now = Duration::from_secs(elapsed_since_start.as_secs())
7918 + Duration::from_nanos(u64::from(elapsed_since_start.subsec_nanos()));
7919
7920 // --- Periodic SPDP announce ---
7921 // FU2 cross-vendor (cyclone-trace-documented): a secured participant MUST
7922 // NOT announce before its security builtins are enabled — otherwise
7923 // a token-less/non-secure first beacon goes out, which foreign vendors
7924 // (cyclone: "Non secure remote ... not allowed by security") latch as
7925 // non-secure and, on the later token beacon, treat ONLY as a QoS update
7926 // (no security re-evaluation) → the handshake never starts.
7927 // `config.security.is_some()` = secured runtime; until
7928 // `enable_security_builtins*` installs the stack (snapshot Some) +
7929 // sets the token/security-info on the beacon, we hold the beacon
7930 // back. enable() triggers the first token-carrying beacon via
7931 // `announce_spdp_now()`. Plain runtimes (security None) announce
7932 // immediately as before.
7933 #[cfg(feature = "security")]
7934 let security_pending = rt.config.security.is_some() && rt.security_builtin_snapshot().is_none();
7935 #[cfg(not(feature = "security"))]
7936 let security_pending = false;
7937 if Instant::now() >= st.next_announce && !security_pending {
7938 let secured_beacon: Option<Vec<u8>> = {
7939 if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7940 beacon
7941 .serialize()
7942 .ok()
7943 .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7944 } else {
7945 None
7946 }
7947 };
7948 if let Some(secured) = secured_beacon {
7949 let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7950 // C1 multicast-free discovery: additionally to all configured
7951 // initial peers (ZERODDS_PEERS) — bootstrap without multicast.
7952 rt.send_spdp_to_initial_peers(&secured);
7953 // SPDP unicast fan-out to discovered peers (analogous to WLP-M-2/H-3-H-4):
7954 // codepit-LXC multicast is flaky; if it loses the tokened
7955 // secure beacon, the peer never discovers ZeroDDS as secure and
7956 // NEVER starts the auth handshake (cyclone→ZeroDDS responder hung
7957 // exactly here: HS_DISPATCH=0). From the metatraffic recv socket
7958 // (spdp_unicast), so the source port is correct.
7959 // Periodic directed unicast fan-out to discovered peers:
7960 // codepit-LXC multicast is flaky; if it loses the tokened
7961 // beacon, the peer never discovers ZeroDDS as secure and never starts
7962 // the auth handshake. The unicast refresh (every spdp_period) robustly
7963 // covers lost multicasts + late joiners. (Previously disabled for a
7964 // flaky-diag experiment — reactivated as a regular path,
7965 // complements the event-driven directed response in handle_spdp_datagram.)
7966 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7967 let _ = rt.spdp_unicast.send(&loc, &secured);
7968 }
7969 }
7970 // FastDDS interop: announce in parallel on the reliable secure-SPDP writer
7971 // (0xff0101c2). FastDDS announces its full secured
7972 // participant data over this channel and gates the crypto-token
7973 // reciprocation on it; without our secure SPDP it never sees ZeroDDS there
7974 // and reciprocates no datawriter/datareader tokens.
7975 #[cfg(feature = "security")]
7976 if rt.config.enable_secure_spdp {
7977 let secure_beacon: Option<Vec<u8>> = {
7978 if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7979 beacon
7980 .serialize_secure()
7981 .ok()
7982 .and_then(|d| protect_secure_spdp(&rt, &d))
7983 .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7984 } else {
7985 None
7986 }
7987 };
7988 if let Some(secured) = secure_beacon {
7989 let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7990 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7991 let _ = rt.spdp_unicast.send(&loc, &secured);
7992 }
7993 }
7994 // Secure-SPDP HEARTBEAT per peer (INFO_DST), so FastDDS' reader
7995 // — even as a late joiner — is solicited to a (preemptive) ACKNACK
7996 // and matches our 0xff0101c2 writer. Without a HEARTBEAT
7997 // FastDDS does not engage our writer (fastdds->zerodds: 0 ACKNACK).
7998 st.secure_hb_count = st.secure_hb_count.wrapping_add(1);
7999 for p in rt.discovered_participants() {
8000 let peer_prefix = p.data.guid.prefix;
8001 if let Some(hb) =
8002 build_secure_spdp_heartbeat(rt.guid_prefix, peer_prefix, st.secure_hb_count)
8003 {
8004 for loc in wlp_unicast_targets(core::slice::from_ref(&p)) {
8005 let _ = rt.spdp_unicast.send(&loc, &hb);
8006 }
8007 }
8008 }
8009 }
8010 // C3 WiFi robustness — initial announcement burst: as long as we have
8011 // not discovered a peer yet and the burst count is not exhausted,
8012 // announce at the fast `initial_announce_period` cadence. Over
8013 // lossy/power-save WiFi the first beacons often get lost in the cold-start
8014 // or sleep window; a single announce + 5s period
8015 // then leads to `participants=0`. The burst keeps the NIC awake through
8016 // frequent TX, keeps the stateful-firewall pinhole open and
8017 // elicits directed SPDP responses that arrive in the wake windows
8018 // — analogous to FastDDS `initial_announcements`. As soon as a peer
8019 // is discovered, the cadence falls back to the full `spdp_period`.
8020 st.announces_done = st.announces_done.saturating_add(1);
8021 rt.spdp_announce_seq.fetch_add(1, Ordering::Relaxed);
8022 let still_searching = st.announces_done < rt.config.initial_announce_count
8023 && rt.discovered_participants().is_empty();
8024 let period = if still_searching {
8025 rt.config.initial_announce_period
8026 } else {
8027 rt.config.spdp_period
8028 };
8029 st.next_announce = Instant::now() + period;
8030 }
8031
8032 // (SPDP multicast recv: now in `recv_spdp_multicast_loop`.)
8033
8034 // --- SEDP-Tick (outbound HEARTBEAT/Resend/ACKNACK) ---
8035 let sedp_outbound = {
8036 if let Ok(mut sedp) = rt.sedp.lock() {
8037 sedp.tick(sedp_now).unwrap_or_default()
8038 } else {
8039 Vec::new()
8040 }
8041 };
8042 for dg in sedp_outbound {
8043 // Protected discovery: SEC_*-protect secure-SEDP DATA/HEARTBEAT/GAP
8044 // (participant data key). Non-secure SEDP goes unchanged; on a
8045 // crypto error on secure SEDP it is dropped (no plaintext leak).
8046 #[cfg(feature = "security")]
8047 {
8048 if let Some(inner) = protect_sedp_outbound(&rt, &dg.bytes) {
8049 // discovery_protection has SEC-wrapped the secure SEDP per-submessage
8050 // (SEC_PREFIX/BODY/POSTFIX, per-endpoint key). Under
8051 // rtps_protection SRTPS MUST additionally go on top — BOTH layers,
8052 // like cyclone<->cyclone (reference pcap: 0x "clear submsg from
8053 // protected src"). send_discovery_datagram -> secure_outbound_bytes
8054 // would classify the SEC_PREFIX datagram as volatile-Kx (which is
8055 // RIGHTLY SRTPS-exempt, because its key only comes over the volatile
8056 // itself) and skip SRTPS -> cyclone would see the
8057 // secure SEDP clear, discard ACKNACK/HEARTBEAT as "clear submsg
8058 // from protected src" and never re-send the SubscriptionData ->
8059 // ZeroDDS' writer never matches cyclone's reader (wait_for_matched
8060 // timeout). Hence wrap SRTPS EXPLICITLY here instead of via the
8061 // generic exempt heuristic.
8062 let final_bytes: Option<Vec<u8>> = match &rt.config.security {
8063 Some(gate)
8064 if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
8065 != ProtectionLevel::None =>
8066 {
8067 gate.transform_outbound(&inner).ok()
8068 }
8069 _ => Some(inner),
8070 };
8071 if let Some(fb) = final_bytes {
8072 for t in dg.targets.iter() {
8073 if is_routable_user_locator(t) {
8074 let _ = rt.spdp_unicast.send(t, &fb);
8075 }
8076 }
8077 }
8078 }
8079 }
8080 #[cfg(not(feature = "security"))]
8081 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8082 }
8083
8084 // --- Security-Builtin-Tick ---
8085 // Volatile-Secure-Writer heartbeats + Volatile-Secure-Reader
8086 // ACKNACK/NACK_FRAG. Stateless hat keinen Tick (BestEffort).
8087 if let Some(stack) = rt.security_builtin_snapshot() {
8088 let outbound = {
8089 if let Ok(mut s) = stack.lock() {
8090 // `out` is only mutated under feature="security" (reassign +
8091 // extend in the cfg block below); otherwise unused_mut in the no-security build.
8092 #[allow(unused_mut)]
8093 let mut out = s.poll(sedp_now).unwrap_or_default();
8094 #[cfg(feature = "security")]
8095 if rt.config.security.is_some() {
8096 // STABLE peer list: `completed_peer_prefixes()` reads
8097 // `self.handshakes`, which is GC'd after handshake completion
8098 // → the LATE volatile RESENDS/HEARTBEATs (tick, long after
8099 // completion) would then find NO peer anymore (`peers.len()!=1`)
8100 // and go out CLEAR → cyclone discards them as "clear
8101 // submsg from protected src". The stabler `authenticated_peer_
8102 // prefixes()` (the installed Kx key stays) — identical to the
8103 // token-send tick further below.
8104 let peers: Vec<GuidPrefix> = rt
8105 .config
8106 .security
8107 .as_ref()
8108 .map(|g| {
8109 g.authenticated_peer_prefixes()
8110 .into_iter()
8111 .map(GuidPrefix::from_bytes)
8112 .collect()
8113 })
8114 .unwrap_or_default();
8115 // The reliable volatile submessages from poll() (DATA RESENDS
8116 // + HEARTBEAT + GAP) must — like the first send — be SEC_*-
8117 // protected (§8.4.2.4, all writer submessages incl.
8118 // HEARTBEAT). protect_volatile_datagram now protects all
8119 // is_protected_writer_submessage. With exactly one peer
8120 // (bench) with its Kx key.
8121 if peers.len() == 1 {
8122 let pk = peers[0].to_bytes();
8123 out = out
8124 .into_iter()
8125 .filter_map(|dg| {
8126 protect_volatile_datagram(&rt, &dg.bytes, &pk).map(|bytes| {
8127 zerodds_rtps::message_builder::OutboundDatagram {
8128 bytes,
8129 targets: dg.targets,
8130 }
8131 })
8132 })
8133 .collect();
8134 }
8135 // FU2 step 6b: send per-endpoint datawriter/datareader crypto
8136 // tokens to every authenticated peer as soon as the
8137 // local user endpoints exist.
8138 //
8139 // STABLE peer list instead of `completed_peer_prefixes()`: the
8140 // handshake entry is GC'd after completion, so a
8141 // late-matching user writer/reader (user endpoints match
8142 // AFTER the secure SEDP) would find no tick window in which
8143 // its per-endpoint token would go out — the peer could then never
8144 // decode ZeroDDS' user DATA (#29). `authenticated_peer_
8145 // prefixes()` (the installed data key) stays.
8146 let token_peers: Vec<GuidPrefix> = rt
8147 .config
8148 .security
8149 .as_ref()
8150 .map(|g| {
8151 g.authenticated_peer_prefixes()
8152 .into_iter()
8153 .map(GuidPrefix::from_bytes)
8154 .collect()
8155 })
8156 .unwrap_or_default();
8157 for prefix in token_peers {
8158 // Per-token dedup (#29): each per-endpoint token
8159 // exactly once — builtins early, user endpoints
8160 // as soon as they match. A per-peer guard would
8161 // block late-matched user endpoints forever.
8162 let already = rt
8163 .endpoint_tokens_sent
8164 .read()
8165 .map(|set| set.clone())
8166 .unwrap_or_default();
8167 let pending = pending_endpoint_tokens(
8168 prepare_endpoint_crypto_tokens(&rt, prefix),
8169 &already,
8170 );
8171 for ep_msg in pending {
8172 let key = endpoint_token_key(&ep_msg);
8173 out.extend(protect_volatile_outbound(
8174 &rt,
8175 prefix,
8176 s.volatile_writer
8177 .write_with_heartbeat(&ep_msg, sedp_now)
8178 .unwrap_or_default(),
8179 ));
8180 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
8181 set.insert(key);
8182 }
8183 }
8184 }
8185 }
8186 out
8187 } else {
8188 Vec::new()
8189 }
8190 };
8191 for dg in outbound {
8192 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8193 }
8194 }
8195
8196 // --- WLP-Tick (Writer-Liveliness-Protocol Heartbeats) ---
8197 //
8198 // RTPS 2.5 §8.4.13: WLP heartbeats are metatraffic.
8199 // Spec recommendation: multicast to all known peers, one
8200 // heartbeat per `lease_duration / 3`. We send via the
8201 // SPDP multicast sender — that is the same socket that
8202 // sends out the SPDP beacons, and it ensures that all
8203 // peers see the WLP pulses without the runtime having to
8204 // look up a unicast locator per peer.
8205 let wlp_outbound = {
8206 if let Ok(mut wlp) = rt.wlp.lock() {
8207 // Use the secure-WLP entity when liveliness_protection != NONE
8208 // (set idempotently per tick — follows the current governance).
8209 wlp.set_secure(wlp_liveliness_protected(&rt));
8210 wlp.tick(sedp_now).unwrap_or(None)
8211 } else {
8212 None
8213 }
8214 };
8215 if let Some(bytes) = wlp_outbound {
8216 // Under liveliness_protection != NONE the secure-WLP DATA is protected
8217 // with the participant key (§8.4.2.4); otherwise rtps-level/plaintext.
8218 if let Some(secured) = protect_wlp_outbound(&rt, &bytes) {
8219 // Multicast to all peers (spec recommendation §8.4.13)...
8220 let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
8221 // ...plus unicast to every discovered peer (M-2), so WLP also
8222 // arrives without multicast (container/cloud). From the metatraffic recv
8223 // socket (spdp_unicast), so the source port is correct (cf. H-3/H-4).
8224 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
8225 let _ = rt.spdp_unicast.send(&loc, &secured);
8226 }
8227 }
8228 }
8229
8230 // (Metatraffic unicast recv: now in `recv_metatraffic_loop`.)
8231
8232 // --- User-Writer-Tick (HEARTBEAT + Resends) ---
8233 //
8234 // Security: per-target serializer. A datagram can go to
8235 // multiple reader locators. Per target we pull it
8236 // individually through `secure_outbound_for_target`, so the
8237 // wire payload matches the protection class of the respective reader.
8238 let user_writer_outbound: Vec<(EntityId, _)> = {
8239 let mut all = Vec::new();
8240 for (eid, arc) in rt.writer_slots_snapshot() {
8241 if let Ok(mut slot) = arc.lock() {
8242 if let Ok(dgs) = slot.writer.tick(sedp_now) {
8243 for dg in dgs {
8244 all.push((eid, dg));
8245 }
8246 }
8247 }
8248 }
8249 all
8250 };
8251 for (writer_eid, dg) in user_writer_outbound {
8252 for t in dg.targets.iter() {
8253 if !is_routable_user_locator(t) {
8254 continue;
8255 }
8256 if let Some(secured) = secure_outbound_for_target(&rt, writer_eid, &dg.bytes, t) {
8257 send_on_best_interface(&rt, t, &secured);
8258 }
8259 }
8260 }
8261
8262 // --- User-Reader-Tick-Outbound (ACKNACK / NACK_FRAG) ---
8263 let user_reader_outbound: Vec<_> = {
8264 let mut all = Vec::new();
8265 for (_eid, arc) in rt.reader_slots_snapshot() {
8266 if let Ok(mut slot) = arc.lock() {
8267 if let Ok(dgs) = slot.reader.tick_outbound(sedp_now) {
8268 all.extend(dgs);
8269 }
8270 }
8271 }
8272 all
8273 };
8274 for dg in user_reader_outbound {
8275 if let Some(secured) = protect_user_reader_datagram(&rt, &dg.bytes) {
8276 for t in dg.targets.iter() {
8277 if is_routable_user_locator(t) {
8278 let _ = rt.user_unicast.send(t, &secured);
8279 }
8280 }
8281 }
8282 }
8283
8284 // (User-data unicast recv: now in `recv_user_data_loop`.)
8285
8286 // --- Per-interface inbound ---
8287 //
8288 // Each pool binding is polled non-blocking; the
8289 // received datagram goes through `secure_inbound_bytes` with
8290 // the matching NetInterface class. This lets the
8291 // PolicyEngine make interface-specific decisions
8292 // (e.g. accept loopback-plain on a protected domain).
8293 //
8294 // The non-blocking semantics are achieved by each socket
8295 // in `bind_all` holding a short read timeout — see
8296 // `OutboundSocketPool::bind_all`. Without a timeout the
8297 // event loop would hang on an empty binding per tick.
8298 #[cfg(feature = "security")]
8299 if let Some(pool) = &rt.outbound_pool {
8300 for binding in &pool.bindings {
8301 while let Ok(dg) = binding.socket.recv() {
8302 let iface = binding.spec.kind.clone();
8303 if let Some(clear) = secure_inbound_bytes(&rt, &dg.data, &iface) {
8304 // Try SPDP first (reverse beacons), then
8305 // SEDP, then user data — same dispatch as
8306 // for the legacy sockets.
8307 handle_spdp_datagram(&rt, &clear);
8308 let events = rt
8309 .sedp
8310 .lock()
8311 .ok()
8312 .and_then(|mut s| s.handle_datagram(&clear, sedp_now).ok());
8313 if let Some(ev) = events {
8314 if !ev.is_empty() {
8315 run_matching_pass(&rt);
8316 apply_sedp_removals(&rt, &ev);
8317 push_sedp_events_to_builtin_readers(&rt, &ev);
8318 }
8319 }
8320 if !dispatch_type_lookup_datagram(&rt, &clear, &dg.source) {
8321 handle_user_datagram(&rt, &clear, sedp_now);
8322 }
8323 // DDS-Security 1.2 §7.4.2 Builtin-Endpoints
8324 for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
8325 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8326 }
8327 }
8328 }
8329 }
8330 }
8331
8332 // Housekeeping (deadline/lifespan/liveliness) runs as a separate
8333 // `tick_housekeep` call of the respective driver (tick_loop /
8334 // tick_driver / scheduler_tick_loop) — see `tick_housekeep`.
8335
8336 // Diagnostic: mark this iteration complete so `tick_count()` advances
8337 // whether driven by the internal thread or an external executor.
8338 rt.tick_seq.fetch_add(1, Ordering::Relaxed);
8339}
8340
8341/// Min tracker for the earliest "next-due" instant (nanos in the runtime
8342/// `elapsed` time base) across multiple housekeeping sources.
8343struct NextDue(Option<u64>);
8344
8345impl NextDue {
8346 fn new() -> Self {
8347 Self(None)
8348 }
8349 fn note(&mut self, due_nanos: u64) {
8350 self.0 = Some(self.0.map_or(due_nanos, |e| e.min(due_nanos)));
8351 }
8352 fn into_inner(self) -> Option<u64> {
8353 self.0
8354 }
8355}
8356
8357/// D.5e Phase 3 B-2 — the time-driven housekeeping checks, factored out of
8358/// [`run_tick_iteration`], so the event-driven scheduler can fire them
8359/// as its own [`TickEvent::Housekeep`] heap event exactly at the next
8360/// due-instant (and `tick_loop`/`tick_driver` call them inline).
8361/// Pure reader/writer-side bookkeeping — **no** cross-vendor wire
8362/// output, the cadence is purely internal.
8363///
8364/// Return value: the earliest instant (nanos in the `elapsed` time base) at which
8365/// a check is due again, or `None` if nothing is currently pending
8366/// (no active deadline/lifespan/liveliness slot) — then the
8367/// scheduler parks until the idle floor resp. until a `raise_tick_wake` signals new
8368/// work.
8369fn tick_housekeep(rt: &Arc<DcpsRuntime>, elapsed: Duration) -> Option<u64> {
8370 let mut next_due = NextDue::new();
8371 // --- Deadline-Monitoring ---
8372 if let Some(d) = check_deadlines(rt, elapsed) {
8373 next_due.note(d);
8374 }
8375 // --- Lifespan-Expire ---
8376 if let Some(d) = expire_by_lifespan(rt, elapsed) {
8377 next_due.note(d);
8378 }
8379 // --- Liveliness lease check (reader side) ---
8380 if let Some(d) = check_liveliness(rt, elapsed) {
8381 next_due.note(d);
8382 }
8383 // --- Writer-side liveliness-lost check ---
8384 if let Some(d) = check_writer_liveliness(rt, elapsed) {
8385 next_due.note(d);
8386 }
8387 next_due.into_inner()
8388}
8389
8390impl DcpsRuntime {
8391 /// Number of completed tick iterations since `start()`. Advances once per
8392 /// tick regardless of whether the internal `zdds-tick` thread or an
8393 /// external executor ([`DcpsRuntime::tick_driver`]) drives it — a stalled
8394 /// value means the periodic tick stopped. Diagnostic only.
8395 #[must_use]
8396 pub fn tick_count(&self) -> u64 {
8397 self.tick_seq.load(Ordering::Relaxed)
8398 }
8399
8400 /// Number of SPDP announces emitted since `start()`. Diagnostic for the C3
8401 /// initial-announcement burst: a fresh participant with no discovered peer
8402 /// advances this at [`RuntimeConfig::initial_announce_period`] for the first
8403 /// [`RuntimeConfig::initial_announce_count`] announces, then slows to
8404 /// `spdp_period`.
8405 #[must_use]
8406 pub fn spdp_announce_count(&self) -> u64 {
8407 self.spdp_announce_seq.load(Ordering::Relaxed)
8408 }
8409
8410 /// Number of discovered topic inconsistencies (DDS 1.4 §2.2.4.2.4).
8411 /// Bumped during matching against the SEDP cache whenever a remote
8412 /// endpoint carries the same `topic_name` but a differing `type_name`
8413 /// than a local endpoint. A delta against the last poll snapshot
8414 /// triggers `on_inconsistent_topic`.
8415 #[must_use]
8416 pub fn inconsistent_topic_count(&self) -> u64 {
8417 self.inconsistent_topic_seq.load(Ordering::Relaxed)
8418 }
8419
8420 /// External tick driver (zerodds-async-1.0 §4). Only meaningful when the
8421 /// runtime was started with [`RuntimeConfig::external_tick`] = `true`,
8422 /// which suppresses the dedicated `zdds-tick` thread. Each
8423 /// [`DcpsTickDriver::tick`] call runs exactly one tick iteration; the
8424 /// caller schedules the next after [`DcpsTickDriver::tick_period`]. The
8425 /// async API's `spawn_in_tokio` uses this to multiplex many participants'
8426 /// tick loops onto a tokio runtime instead of one std::thread each.
8427 #[must_use]
8428 pub fn tick_driver(self: &Arc<Self>) -> DcpsTickDriver {
8429 DcpsTickDriver {
8430 st: TickState::new(self),
8431 rt: Arc::clone(self),
8432 }
8433 }
8434
8435 /// D.5e Phase 3 — wake the scheduler tick worker immediately (new work:
8436 /// a sample written, a HEARTBEAT/DATA/ACKNACK received). Coalesced: many
8437 /// raises between two worker passes collapse into a single wake, so a
8438 /// datagram storm does not flood the channel. No-op unless started with
8439 /// `scheduler_tick`.
8440 pub fn raise_tick_wake(&self) {
8441 // Only the first raiser since the last pass actually sends.
8442 if self.tick_wake_pending.swap(true, Ordering::AcqRel) {
8443 return;
8444 }
8445 if let Ok(guard) = self.tick_wake.lock() {
8446 if let Some(h) = guard.as_ref() {
8447 // Active traffic wakes the reliable tick AND re-evaluates
8448 // housekeeping, so a freshly-armed deadline/lifespan/liveliness
8449 // window is scheduled at once instead of waiting out the park.
8450 h.raise_now(TickEvent::Tick);
8451 h.raise_now(TickEvent::Housekeep);
8452 }
8453 }
8454 }
8455
8456 /// `true` if this participant has any user DataWriter or DataReader — i.e.
8457 /// the fine-grained periodic work (HEARTBEAT / ACKNACK / deadline / lifespan
8458 /// / liveliness) may be due and the scheduler keeps a fine cadence. A pure
8459 /// discovery-only participant parks long.
8460 fn has_user_endpoints(&self) -> bool {
8461 self.user_writers
8462 .read()
8463 .map(|m| !m.is_empty())
8464 .unwrap_or(true)
8465 || self
8466 .user_readers
8467 .read()
8468 .map(|m| !m.is_empty())
8469 .unwrap_or(true)
8470 }
8471}
8472
8473/// Drives a runtime's periodic tick from an external executor (tokio, an
8474/// embedded scheduler, a manual test loop). Obtained via
8475/// [`DcpsRuntime::tick_driver`]; only does useful work when the runtime was
8476/// started with [`RuntimeConfig::external_tick`] = `true`.
8477///
8478/// Typical loop (the async crate's `spawn_in_tokio` shape):
8479///
8480/// ```ignore
8481/// let mut driver = runtime.tick_driver();
8482/// let period = driver.tick_period();
8483/// while !driver.is_stopped() {
8484/// driver.tick();
8485/// tokio::time::sleep(period).await;
8486/// }
8487/// ```
8488pub struct DcpsTickDriver {
8489 rt: Arc<DcpsRuntime>,
8490 st: TickState,
8491}
8492
8493impl DcpsTickDriver {
8494 /// Period the caller should wait between consecutive [`Self::tick`] calls
8495 /// (mirrors the internal `zdds-tick` thread's `tick_period`).
8496 #[must_use]
8497 pub fn tick_period(&self) -> Duration {
8498 self.rt.config.tick_period
8499 }
8500
8501 /// `true` once the runtime is shutting down (set by `Drop`/`stop()`). The
8502 /// driving task must then stop calling [`Self::tick`] and return so the
8503 /// runtime can be dropped cleanly.
8504 #[must_use]
8505 pub fn is_stopped(&self) -> bool {
8506 self.rt.stop.load(Ordering::Relaxed)
8507 }
8508
8509 /// Run one tick iteration: periodic SPDP announce, SEDP/WLP ticks,
8510 /// per-user-writer ticks, deadline/lifespan/liveliness checks. Equivalent
8511 /// to one pass of the internal `zdds-tick` loop body.
8512 pub fn tick(&mut self) {
8513 run_tick_iteration(Arc::clone(&self.rt), &mut self.st);
8514 tick_housekeep(&self.rt, self.rt.start_instant.elapsed());
8515 }
8516}
8517
8518/// Writer-side liveliness-lost detection. Spec §2.2.4.2.10.
8519///
8520/// For all user writers: if a lease duration is set and more time
8521/// has elapsed since the last assert (Automatic = `last_write`, Manual =
8522/// `last_liveliness_assert`) than the
8523/// lease duration allows, the writer counts as
8524/// "not-alive" from the DDS view — `liveliness_lost_count++` and reset the window.
8525///
8526/// Note: with pure best-effort tests + `Automatic` the
8527/// counter typically does not advance — Automatic asserts with every
8528/// `write_user_sample`. Manual mode requires an explicit
8529/// `assert_liveliness` (comes with .4b — until then we already provide
8530/// the detection here, the hot-path trigger triggers it).
8531fn check_writer_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8532 let now_nanos = now.as_nanos() as u64;
8533 let mut next_due = NextDue::new();
8534 for (_eid, arc) in rt.writer_slots_snapshot() {
8535 let Ok(mut slot) = arc.lock() else { continue };
8536 if slot.liveliness_lease_nanos == 0 {
8537 continue;
8538 }
8539 let last = match slot.liveliness_kind {
8540 zerodds_qos::LivelinessKind::Automatic => slot.last_write,
8541 _ => slot.last_liveliness_assert,
8542 };
8543 let last_nanos = match last {
8544 Some(t) => t.as_nanos() as u64,
8545 None => continue,
8546 };
8547 if now_nanos.saturating_sub(last_nanos) >= slot.liveliness_lease_nanos {
8548 slot.liveliness_lost_count = slot.liveliness_lost_count.saturating_add(1);
8549 // Reset the window, so the same lease-window
8550 // overrun does not count in an infinite loop.
8551 // Spec §2.2.3.11: "lease has elapsed" — `>=` is boundary-
8552 // stable and avoids flakiness when tick_period == lease.
8553 slot.last_liveliness_assert = Some(now);
8554 slot.last_write = Some(now);
8555 next_due.note(now_nanos.saturating_add(slot.liveliness_lease_nanos));
8556 } else {
8557 next_due.note(last_nanos.saturating_add(slot.liveliness_lease_nanos));
8558 }
8559 }
8560 next_due.into_inner()
8561}
8562
8563/// Checks for all user readers whether the writer has delivered no sample
8564/// for longer than `lease_duration`. If so: transition
8565/// alive → not_alive, `not_alive_count++`.
8566///
8567/// Automatic liveliness (§2.2.3.11): every write is an implicit assert.
8568/// So we check the reader-side `last_sample_received`.
8569/// Manual kinds come with .4b (explicit assert messages).
8570fn check_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8571 let now_nanos = now.as_nanos() as u64;
8572 let mut next_due = NextDue::new();
8573 for (_eid, arc) in rt.reader_slots_snapshot() {
8574 let Ok(mut slot) = arc.lock() else { continue };
8575 if slot.liveliness_lease_nanos == 0 {
8576 continue;
8577 }
8578 // Until the first sample: consider it alive (optimistic).
8579 let last = match slot.last_sample_received {
8580 Some(t) => t.as_nanos() as u64,
8581 None => continue,
8582 };
8583 // Only a still-alive reader can transition; one already
8584 // not_alive stays so until a new sample arrives (event-driven
8585 // via the recv path) — so no re-schedule needed.
8586 if !slot.liveliness_alive {
8587 continue;
8588 }
8589 if now_nanos.saturating_sub(last) >= slot.liveliness_lease_nanos {
8590 slot.liveliness_alive = false;
8591 slot.liveliness_not_alive_count = slot.liveliness_not_alive_count.saturating_add(1);
8592 } else {
8593 next_due.note(last.saturating_add(slot.liveliness_lease_nanos));
8594 }
8595 }
8596 next_due.into_inner()
8597}
8598
8599/// For all user writers: remove samples from the HistoryCache whose
8600/// insert time + lifespan has elapsed. OMG DDS 1.4 §2.2.3.16:
8601/// "If the duration...elapses and the sample is still in the cache...
8602/// the sample is no longer available to any future DataReaders".
8603///
8604/// Implementation: `sample_insert_times` is a VecDeque, sorted
8605/// by insert time (= SN, because monotonic). Front-pop while expired;
8606/// the highest expired SN runs through via `cache.remove_up_to(sn + 1)`.
8607fn expire_by_lifespan(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8608 let now_nanos = now.as_nanos() as u64;
8609 let mut next_due = NextDue::new();
8610 for (_eid, arc) in rt.writer_slots_snapshot() {
8611 let Ok(mut slot) = arc.lock() else { continue };
8612 if slot.lifespan_nanos == 0 {
8613 continue;
8614 }
8615 let mut highest_expired = None;
8616 while let Some(&(sn, inserted)) = slot.sample_insert_times.front() {
8617 let inserted_nanos = inserted.as_nanos() as u64;
8618 if now_nanos.saturating_sub(inserted_nanos) >= slot.lifespan_nanos {
8619 highest_expired = Some(sn);
8620 slot.sample_insert_times.pop_front();
8621 } else {
8622 break;
8623 }
8624 }
8625 if let Some(sn) = highest_expired {
8626 let _removed = slot
8627 .writer
8628 .remove_samples_up_to(zerodds_rtps::wire_types::SequenceNumber(sn.0 + 1));
8629 }
8630 // Next lifespan due = expiry of the now-oldest sample still
8631 // remaining in the cache. Empty deque → nothing due,
8632 // until a new sample is written (raise_tick_wake covers that).
8633 if let Some(&(_sn, inserted)) = slot.sample_insert_times.front() {
8634 next_due.note((inserted.as_nanos() as u64).saturating_add(slot.lifespan_nanos));
8635 }
8636 }
8637 next_due.into_inner()
8638}
8639
8640/// Checks for all user writers + user readers whether the deadline period
8641/// has been exceeded since the last sample. Every exceedance
8642/// increments the corresponding missed counter by exactly 1
8643/// — regardless of how often `check_deadlines` is called within an
8644/// elapsed window, because we keep setting `last_*`
8645/// to "now" after we have counted.
8646///
8647/// **Init-state semantics:** as long as `last_write`/`last_sample_received`
8648/// is `None` (no real write/sample yet), the deadline
8649/// check does not count. Only after the first real data point does the
8650/// deadline window start. This prevents false misses due to slow
8651/// entity setup (Linux CI/container) before the app even issues a
8652/// write.
8653fn check_deadlines(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8654 let now_nanos = now.as_nanos() as u64;
8655 let mut next_due = NextDue::new();
8656 for (_eid, arc) in rt.writer_slots_snapshot() {
8657 let Ok(mut slot) = arc.lock() else { continue };
8658 if slot.deadline_nanos == 0 {
8659 continue;
8660 }
8661 let Some(last) = slot.last_write.map(|d| d.as_nanos() as u64) else {
8662 // Never written yet → deadline window not active.
8663 continue;
8664 };
8665 if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
8666 slot.offered_deadline_missed_count =
8667 slot.offered_deadline_missed_count.saturating_add(1);
8668 // Reset the window: the next deadline is counted relative
8669 // to the current tick. `>=` is boundary-stable
8670 // (Spec §2.2.3.7: "deadline has elapsed").
8671 slot.last_write = Some(now);
8672 next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
8673 } else {
8674 next_due.note(last.saturating_add(slot.deadline_nanos));
8675 }
8676 }
8677 for (_eid, arc) in rt.reader_slots_snapshot() {
8678 let Ok(mut slot) = arc.lock() else { continue };
8679 if slot.deadline_nanos == 0 {
8680 continue;
8681 }
8682 let Some(last) = slot.last_sample_received.map(|d| d.as_nanos() as u64) else {
8683 continue;
8684 };
8685 if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
8686 slot.requested_deadline_missed_count =
8687 slot.requested_deadline_missed_count.saturating_add(1);
8688 slot.last_sample_received = Some(now);
8689 next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
8690 } else {
8691 next_due.note(last.saturating_add(slot.deadline_nanos));
8692 }
8693 }
8694 next_due.into_inner()
8695}
8696
8697/// For all local writers + readers: matching against the current
8698/// SEDP cache. A cheap re-run when SEDP events came in — idempotent,
8699/// because ReliableWriter/Reader add_*_proxy are idempotent (same
8700/// GUID → replaced).
8701fn run_matching_pass(rt: &Arc<DcpsRuntime>) {
8702 let writer_ids: Vec<EntityId> = rt.writer_eids();
8703 for eid in writer_ids {
8704 rt.match_local_writer_against_cache(eid);
8705 }
8706 let reader_ids: Vec<EntityId> = rt.reader_eids();
8707 for eid in reader_ids {
8708 rt.match_local_reader_against_cache(eid);
8709 }
8710}
8711
8712/// Returns the default-unicast locator of a discovered remote
8713/// participant.
8714fn remote_user_locators(
8715 prefix: GuidPrefix,
8716 discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
8717) -> Vec<Locator> {
8718 match discovered.lock() {
8719 Ok(cache) => cache
8720 .get(&prefix)
8721 .and_then(|p| p.data.default_unicast_locator)
8722 .into_iter()
8723 .collect(),
8724 Err(_) => Vec::new(),
8725 }
8726}
8727
8728/// Determine the destination for user traffic to a remote endpoint.
8729///
8730/// DDSI-RTPS 2.5 §8.5.3.2/§8.5.3.3: the per-endpoint `unicastLocatorList`
8731/// from the SEDP announce is authoritative. §8.5.5: only when it is empty
8732/// does the sender fall back to the participant `DEFAULT_UNICAST_LOCATOR` from
8733/// SPDP.
8734///
8735/// Before this fix ZeroDDS *always* used the participant default — which
8736/// broke OpenDDS interop: OpenDDS stores only the
8737/// placeholder 127.0.0.1:12345 as the participant default and announces the real user locator
8738/// exclusively per-endpoint.
8739fn endpoint_or_default_locators(
8740 endpoint: &[Locator],
8741 prefix: GuidPrefix,
8742 discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
8743) -> Vec<Locator> {
8744 if !endpoint.is_empty() {
8745 return endpoint.to_vec();
8746 }
8747 remote_user_locators(prefix, discovered)
8748}
8749
8750/// Dispatches a received RTPS datagram to matching user readers.
8751/// Decides, based on the `reader_id` in DATA/DATA_FRAG/HEARTBEAT/GAP,
8752/// which local reader is responsible.
8753/// Strip the 4-byte encapsulation header off the received sample payload.
8754/// Returns `None` if the payload is < 4 bytes or carries an unknown
8755/// scheme (PL_CDR variants would not get here; they go via
8756/// SEDP — if we see such a thing on user endpoints, it is garbage).
8757/// Spec §3.2 zerodds-async-1.0: wakes a registered waker
8758/// after every `sample_tx.send`. `take` consumes the waker, to
8759/// avoid double wakeups — the caller registers a new one after
8760/// every `Pending` result.
8761fn wake_async_waker(slot: &alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>) {
8762 if let Ok(mut g) = slot.lock() {
8763 if let Some(w) = g.take() {
8764 w.wake();
8765 }
8766 }
8767}
8768
8769/// Converts a sample delivered by the ReliableReader into a
8770/// `UserSample` channel entry. For `ChangeKind::Alive` the
8771/// CDR encapsulation header is stripped; for lifecycle markers
8772/// the key hash is reconstructed from the bytes.
8773/// Inspect-endpoint tap dispatch for the DCPS receive path.
8774///
8775/// Called in `handle_user_datagram` when a sample is delivered to
8776/// a user reader. Only when the `inspect` feature is
8777/// on; without the feature no code, no branch.
8778#[cfg(feature = "inspect")]
8779fn dispatch_inspect_dcps_receive_tap(topic: &str, reader_id: EntityId, item: &UserSample) {
8780 let payload: Vec<u8> = match item {
8781 UserSample::Alive { payload, .. } => payload.to_vec(),
8782 UserSample::Lifecycle { key_hash, .. } => key_hash.to_vec(),
8783 };
8784 let ts_ns = std::time::SystemTime::now()
8785 .duration_since(std::time::UNIX_EPOCH)
8786 .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
8787 .unwrap_or(0);
8788 let mut corr: u64 = 0;
8789 for (i, byte) in reader_id.entity_key.iter().enumerate() {
8790 corr |= u64::from(*byte) << (i * 8);
8791 }
8792 corr |= u64::from(reader_id.entity_kind as u8) << 24;
8793 let frame = zerodds_inspect_endpoint::Frame::dcps(topic.to_owned(), ts_ns, corr, payload);
8794 zerodds_inspect_endpoint::tap::dispatch(&frame);
8795}
8796
8797fn delivered_to_user_sample(
8798 sample: &zerodds_rtps::reliable_reader::DeliveredSample,
8799 writer_strengths: &alloc::collections::BTreeMap<[u8; 16], i32>,
8800) -> Option<UserSample> {
8801 use zerodds_rtps::history_cache::ChangeKind;
8802 match sample.kind {
8803 ChangeKind::Alive | ChangeKind::AliveFiltered => {
8804 let writer_guid = sample.writer_guid.to_bytes();
8805 let writer_strength = writer_strengths.get(&writer_guid).copied().unwrap_or(0);
8806 // Encapsulation representation from byte[1] of the header
8807 // (RTPS 2.5 §10.5) — BEFORE stripping. 0x00–0x03 = XCDR1
8808 // (CDR/PL_CDR), 0x06–0x0b = XCDR2 (CDR2/D_CDR2/PL_CDR2).
8809 let representation = encap_representation(&sample.payload);
8810 let big_endian = encap_big_endian(&sample.payload);
8811 strip_user_encap_arc(&sample.payload).map(|payload| UserSample::Alive {
8812 payload,
8813 writer_guid,
8814 writer_strength,
8815 representation,
8816 big_endian,
8817 source_timestamp: sample.source_timestamp,
8818 // O2 P5: the wire path carries the writer's RTPS sequence number
8819 // straight off the DeliveredSample, so a durability service can
8820 // dedup this writer's history against its live stream.
8821 source_sequence_number: sample.sequence_number.0,
8822 })
8823 }
8824 ChangeKind::NotAliveDisposed
8825 | ChangeKind::NotAliveUnregistered
8826 | ChangeKind::NotAliveDisposedUnregistered => {
8827 // Lifecycle marker: Spec §9.6.4.8 + §9.6.3.9 requires
8828 // `PID_KEY_HASH` in the inline QoS — the reader reads it
8829 // and propagates it via `DeliveredSample.key_hash`.
8830 // Fallback: with non-spec-conformant writers the
8831 // hash falls back to the first 16 bytes of the key-only payload
8832 // (PLAIN_CDR2-BE key holder).
8833 let kh = sample.key_hash.unwrap_or_else(|| {
8834 let mut h = [0u8; 16];
8835 let n = sample.payload.len().min(16);
8836 h[..n].copy_from_slice(&sample.payload[..n]);
8837 h
8838 });
8839 Some(UserSample::Lifecycle {
8840 key_hash: kh,
8841 kind: sample.kind,
8842 })
8843 }
8844 }
8845}
8846
8847/// Returns the XCDR version from the 4-byte encapsulation header
8848/// (RTPS 2.5 §10.5): `0` = XCDR1 (CDR/PL_CDR, encap byte 0x00–0x05),
8849/// `1` = XCDR2 (CDR2/DELIMITED_CDR2/PL_CDR2, encap byte 0x06–0x0b).
8850/// Default `0` for a too-short payload — XCDR1 is the spec baseline.
8851fn encap_representation(payload: &[u8]) -> u8 {
8852 if payload.len() >= 2 && payload[1] >= 0x06 {
8853 1
8854 } else {
8855 0
8856 }
8857}
8858
8859/// Returns the byte order from the 4-byte encapsulation representation
8860/// identifier (RTPS 2.5 §10.5). The repr-id is a big-endian `uint16`; its low
8861/// bit selects the byte order — the `_BE` variants (CDR_BE 0x0000, PL_CDR_BE
8862/// 0x0002, CDR2_BE 0x0006, D_CDR2_BE 0x0008, PL_CDR2_BE 0x000a) are even, the
8863/// `_LE` variants odd. `true` ⇒ big-endian. A too-short / header-less payload
8864/// (e.g. the intra-runtime bare body) defaults to little-endian (`false`).
8865fn encap_big_endian(payload: &[u8]) -> bool {
8866 payload.len() >= 2 && (payload[1] & 0x01) == 0
8867}
8868
8869/// Checks whether `payload` has a known 4-byte encapsulation header.
8870/// Returns `Some(4)` if so (= offset behind the header), `None` if
8871/// no known scheme. Separated in use from [`strip_user_encap`]:
8872/// here only validation without allocation, for the listener zero-copy
8873/// path (lever E / Sprint D.5d).
8874fn validate_user_encap_offset(payload: &[u8]) -> Option<usize> {
8875 if payload.len() < 4 {
8876 return None;
8877 }
8878 // Accept all data-representation schemes (RTPS 2.5 §10.5,
8879 // table 10.3): byte0 = 0x00, byte1 in:
8880 // 0x00/0x01 CDR_BE/LE (XCDR1 PLAIN_CDR)
8881 // 0x02/0x03 PL_CDR_BE/LE (XCDR1 parameter list, key serial.)
8882 // 0x06/0x07 CDR2_BE/LE (XCDR2 PLAIN_CDR2)
8883 // 0x08/0x09 D_CDR2_BE/LE (XCDR2 DELIMITED_CDR2, @appendable)
8884 // 0x0a/0x0b PL_CDR2_BE/LE (XCDR2 PL_CDR2, @mutable)
8885 // Cyclone often sends XCDR1, OpenDDS/FastDDS XCDR2. We pass
8886 // all through; the typed decoder picks the correct alignment rule
8887 // based on the `representation` (see `encap_representation`).
8888 if payload[0] != 0x00 {
8889 return None;
8890 }
8891 match payload[1] {
8892 0x00..=0x03 | 0x06..=0x0b => Some(4),
8893 _ => None,
8894 }
8895}
8896
8897/// Zero-copy variant: strips the encap header via range slicing
8898/// on the refcounted `Arc<[u8]>` backing store. No heap alloc.
8899/// Spec: `docs/specs/zerodds-zero-copy-1.0.md` §6 wave 2.
8900fn strip_user_encap_arc(
8901 payload: &alloc::sync::Arc<[u8]>,
8902) -> Option<crate::sample_bytes::SampleBytes> {
8903 validate_user_encap_offset(payload).map(|off| {
8904 crate::sample_bytes::SampleBytes::from_arc_slice(
8905 alloc::sync::Arc::clone(payload),
8906 off..payload.len(),
8907 )
8908 })
8909}
8910
8911#[cfg(test)]
8912fn strip_user_encap(payload: &[u8]) -> Option<alloc::vec::Vec<u8>> {
8913 validate_user_encap_offset(payload).map(|off| payload[off..].to_vec())
8914}
8915
8916/// Bench-only phase-timing accumulators. Active with env
8917/// `ZERODDS_PHASE_TIMING=1`. With `ZERODDS_PHASE_DUMP=1` the
8918/// atexit hook prints the totals on drop of the first runtime.
8919#[doc(hidden)]
8920pub static PHASE_HANDLE_USER_NS: core::sync::atomic::AtomicU64 =
8921 core::sync::atomic::AtomicU64::new(0);
8922#[doc(hidden)]
8923pub static PHASE_HANDLE_USER_CALLS: core::sync::atomic::AtomicU64 =
8924 core::sync::atomic::AtomicU64::new(0);
8925#[doc(hidden)]
8926pub static PHASE_WRITE_USER_NS: core::sync::atomic::AtomicU64 =
8927 core::sync::atomic::AtomicU64::new(0);
8928#[doc(hidden)]
8929pub static PHASE_WRITE_USER_CALLS: core::sync::atomic::AtomicU64 =
8930 core::sync::atomic::AtomicU64::new(0);
8931
8932/// Sub-phases in the `handle_user_datagram` receive hot path:
8933/// 0=decode_datagram, 1=slot-lookup+lock, 2=reader.handle_data,
8934/// 3=delivered_to_user_sample, 4=listener+sender-dispatch.
8935/// Active under `ZERODDS_PHASE_TIMING=1`. Each `Instant::now()` bracket
8936/// costs ~50 ns; at a ~3 µs handle that is ~1.6% per sub-phase.
8937#[doc(hidden)]
8938pub static PHASE_HANDLE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8939 core::sync::atomic::AtomicU64::new(0),
8940 core::sync::atomic::AtomicU64::new(0),
8941 core::sync::atomic::AtomicU64::new(0),
8942 core::sync::atomic::AtomicU64::new(0),
8943 core::sync::atomic::AtomicU64::new(0),
8944];
8945
8946/// Sub-phases in `write_user_sample_borrowed` (sender hot path):
8947/// 0=lookup, 1=lock, 2=write_with_heartbeat, 3=send-loop, 4=reserved.
8948/// The detail drilldown into socket.send_to vs. inproc-peer dispatch was
8949/// done once for the connected-UDP lever (showed send_to as
8950/// 97% of the dispatch path); not permanent in the code, because per-phase
8951/// `Instant::now()` itself costs ~50 ns — at a 6 µs send that
8952/// would be 1% overhead and skews the calibrated measurement.
8953#[doc(hidden)]
8954pub static PHASE_WRITE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8955 core::sync::atomic::AtomicU64::new(0),
8956 core::sync::atomic::AtomicU64::new(0),
8957 core::sync::atomic::AtomicU64::new(0),
8958 core::sync::atomic::AtomicU64::new(0),
8959 core::sync::atomic::AtomicU64::new(0),
8960];
8961
8962fn phase_timing_enabled() -> bool {
8963 static CACHE: core::sync::atomic::AtomicI8 = core::sync::atomic::AtomicI8::new(-1);
8964 let v = CACHE.load(core::sync::atomic::Ordering::Relaxed);
8965 if v >= 0 {
8966 return v == 1;
8967 }
8968 let on = std::env::var("ZERODDS_PHASE_TIMING")
8969 .map(|s| s == "1")
8970 .unwrap_or(false);
8971 CACHE.store(
8972 if on { 1 } else { 0 },
8973 core::sync::atomic::Ordering::Relaxed,
8974 );
8975 on
8976}
8977
8978struct PhaseTimer {
8979 start: std::time::Instant,
8980 ns_acc: &'static core::sync::atomic::AtomicU64,
8981 calls_acc: &'static core::sync::atomic::AtomicU64,
8982}
8983
8984impl Drop for PhaseTimer {
8985 fn drop(&mut self) {
8986 let ns = self.start.elapsed().as_nanos() as u64;
8987 self.ns_acc
8988 .fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8989 self.calls_acc
8990 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
8991 }
8992}
8993
8994fn handle_user_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], now: Duration) {
8995 let _phase_guard = if phase_timing_enabled() {
8996 Some(PhaseTimer {
8997 start: std::time::Instant::now(),
8998 ns_acc: &PHASE_HANDLE_USER_NS,
8999 calls_acc: &PHASE_HANDLE_USER_CALLS,
9000 })
9001 } else {
9002 None
9003 };
9004 let pt_on = phase_timing_enabled();
9005 let pt_t0 = if pt_on {
9006 Some(std::time::Instant::now())
9007 } else {
9008 None
9009 };
9010 let parsed = match decode_datagram(bytes) {
9011 Ok(p) => p,
9012 Err(_) => return,
9013 };
9014 // DDSI-RTPS §8.3.4: the effective source of each writer submessage is the
9015 // sourceGuidPrefix from the RTPS header. The reader demux needs it to
9016 // distinguish writer proxies with the same EntityId but a different participant
9017 // (fan-in / multiple publishers on the same topic).
9018 let src_prefix = parsed.header.guid_prefix;
9019 if let (Some(t0), true) = (pt_t0, pt_on) {
9020 let ns = t0.elapsed().as_nanos() as u64;
9021 PHASE_HANDLE_SUB_NS[0].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9022 }
9023 // Per-submessage: take the matching slot mutex individually per
9024 // submessage — no global user_writers/user_readers lock anymore.
9025 // With per-submessage granularity, reader datagrams can be processed in parallel
9026 // to writer AckNacks.
9027 //
9028 // RTPS-F1 (DDSI-RTPS §8.3.4 ReceiverState.haveTimestamp): an INFO_TS
9029 // submessage sets the source timestamp applied to every following DATA in
9030 // the same message, until another INFO_TS (or an I-flag clears it). We
9031 // carry it forward and hand it to the reader so it lands in
9032 // `SampleInfo.source_timestamp`.
9033 let mut cur_source_ts: Option<zerodds_rtps::header_extension::HeTimestamp> = None;
9034 for sub in parsed.submessages {
9035 match sub {
9036 ParsedSubmessage::InfoTimestamp(its) => {
9037 cur_source_ts = if its.invalidate {
9038 None
9039 } else {
9040 Some(its.timestamp)
9041 };
9042 }
9043 ParsedSubmessage::Data(d) => {
9044 // Sprint D.5d lever B — collect-then-dispatch:
9045 // sample conversion + liveliness update inside slot.lock,
9046 // then listener fire + channel send + waker wake
9047 // OUTSIDE the lock.
9048 //
9049 // Cross-vendor fix 2026-05-19: when reader_id ==
9050 // ENTITYID_UNKNOWN (RTPS spec §8.3.7.2: "deliver to all
9051 // matched readers on this topic"), we iterate over
9052 // ALL reader slots and let `handle_data` filter by
9053 // writer_proxies. Cyclone DDS/FastDDS/RTI send
9054 // user DATA with reader_id=UNKNOWN; without this fan-out
9055 // ZeroDDS would drop every such DATA.
9056 let pt_t1 = if pt_on {
9057 Some(std::time::Instant::now())
9058 } else {
9059 None
9060 };
9061 let target_slots: Vec<ReaderSlotArc> = if d.reader_id == EntityId::UNKNOWN {
9062 let snap = rt.reader_slots_snapshot();
9063 let mut v = Vec::with_capacity(snap.len());
9064 v.extend(snap.into_iter().map(|(_, arc)| arc));
9065 v
9066 } else {
9067 let mut v = Vec::with_capacity(1);
9068 if let Some(arc) = rt.reader_slot(d.reader_id) {
9069 v.push(arc);
9070 }
9071 v
9072 };
9073 if let (Some(t1), true) = (pt_t1, pt_on) {
9074 let ns = t1.elapsed().as_nanos() as u64;
9075 PHASE_HANDLE_SUB_NS[1].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9076 }
9077 for arc in target_slots {
9078 // Lever E: alongside the UserSample we carry a
9079 // zero-copy view on the original `Arc<[u8]>` with
9080 // the encap offset — the listener can thereby read into
9081 // the payload without allocation.
9082 let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
9083 let listener;
9084 let waker;
9085 let sender;
9086 #[cfg(feature = "inspect")]
9087 let topic_name;
9088 let pt_t2 = if pt_on {
9089 Some(std::time::Instant::now())
9090 } else {
9091 None
9092 };
9093 {
9094 let Ok(mut slot) = arc.lock() else { continue };
9095 let hd_samples: Vec<_> = slot
9096 .reader
9097 .handle_data(src_prefix, &d, cur_source_ts)
9098 .into_iter()
9099 .collect();
9100 for sample in hd_samples {
9101 // A2 TIME_BASED_FILTER (§2.2.3.12): drop alive samples
9102 // that arrive within minimum_separation of the last
9103 // delivered sample of the same instance. No-op when
9104 // the filter is disabled (tbf_min_separation_nanos==0).
9105 if matches!(
9106 sample.kind,
9107 zerodds_rtps::history_cache::ChangeKind::Alive
9108 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9109 ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9110 {
9111 continue;
9112 }
9113 // Listener zero-copy view only for alive samples
9114 // with a valid encap header. Arc::clone is
9115 // an atomic refcount inc, no data copy.
9116 let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
9117 zerodds_rtps::history_cache::ChangeKind::Alive
9118 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
9119 validate_user_encap_offset(&sample.payload)
9120 .map(|off| (Arc::clone(&sample.payload), off))
9121 }
9122 _ => None,
9123 };
9124 if let Some(item) =
9125 delivered_to_user_sample(&sample, &slot.writer_strengths)
9126 {
9127 items.push((item, listener_view));
9128 }
9129 }
9130 if !items.is_empty() {
9131 slot.last_sample_received = Some(now);
9132 slot.samples_delivered_count = slot
9133 .samples_delivered_count
9134 .saturating_add(items.len() as u64);
9135 if !slot.liveliness_alive {
9136 slot.liveliness_alive = true;
9137 slot.liveliness_alive_count =
9138 slot.liveliness_alive_count.saturating_add(1);
9139 }
9140 }
9141 listener = slot.listener.clone();
9142 waker = Arc::clone(&slot.async_waker);
9143 sender = slot.sample_tx.clone();
9144 #[cfg(feature = "inspect")]
9145 {
9146 topic_name = slot.topic_name.clone();
9147 }
9148 }
9149 if let (Some(t2), true) = (pt_t2, pt_on) {
9150 let ns = t2.elapsed().as_nanos() as u64;
9151 PHASE_HANDLE_SUB_NS[2].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9152 }
9153 let pt_t3 = if pt_on {
9154 Some(std::time::Instant::now())
9155 } else {
9156 None
9157 };
9158 // --- Outside slot.lock: dispatch ---
9159 //
9160 // Listener and MPSC are exclusive: if a listener
9161 // (callback) is set, the consumer is on the
9162 // callback path — the additional `sender.send` +
9163 // `wake_async_waker` would be pure overhead AND
9164 // would grow the channel buffer unboundedly
9165 // (memory leak in callback-only apps). We
9166 // dispatch either the callback OR the MPSC, not
9167 // both. A caller (Rust API) that wants take()+listener
9168 // at the same time simply sets NO listener
9169 // and polls via take().
9170 for (item, listener_view) in items {
9171 let (item_repr, item_be) = if let UserSample::Alive {
9172 representation,
9173 big_endian,
9174 ..
9175 } = &item
9176 {
9177 (*representation, u8::from(*big_endian))
9178 } else {
9179 (0, 0)
9180 };
9181 #[cfg(feature = "inspect")]
9182 dispatch_inspect_dcps_receive_tap(&topic_name, d.reader_id, &item);
9183 if let Some(ref l) = listener {
9184 if let Some((arc_payload, off)) = listener_view {
9185 // Zero-copy: slice view into the original Arc.
9186 l(&arc_payload[off..], item_repr, item_be);
9187 }
9188 } else {
9189 let _ = sender.send(item);
9190 wake_async_waker(&waker);
9191 }
9192 }
9193 if let (Some(t3), true) = (pt_t3, pt_on) {
9194 let ns = t3.elapsed().as_nanos() as u64;
9195 PHASE_HANDLE_SUB_NS[4].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9196 }
9197 } // for arc in target_slots
9198 }
9199 ParsedSubmessage::DataFrag(df) => {
9200 // Lever B+E — see the Data arm above.
9201 // Cross-vendor: same UNKNOWN fan-out as for Data.
9202 let target_slots: Vec<ReaderSlotArc> = if df.reader_id == EntityId::UNKNOWN {
9203 rt.reader_slots_snapshot()
9204 .into_iter()
9205 .map(|(_, arc)| arc)
9206 .collect()
9207 } else {
9208 rt.reader_slot(df.reader_id).into_iter().collect()
9209 };
9210 for arc in target_slots {
9211 let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
9212 let listener;
9213 let waker;
9214 let sender;
9215 #[cfg(feature = "inspect")]
9216 let topic_name;
9217 {
9218 let Ok(mut slot) = arc.lock() else { continue };
9219 for sample in
9220 slot.reader
9221 .handle_data_frag(src_prefix, &df, now, cur_source_ts)
9222 {
9223 // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9224 if matches!(
9225 sample.kind,
9226 zerodds_rtps::history_cache::ChangeKind::Alive
9227 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9228 ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9229 {
9230 continue;
9231 }
9232 let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
9233 zerodds_rtps::history_cache::ChangeKind::Alive
9234 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
9235 validate_user_encap_offset(&sample.payload)
9236 .map(|off| (Arc::clone(&sample.payload), off))
9237 }
9238 _ => None,
9239 };
9240 if let Some(item) =
9241 delivered_to_user_sample(&sample, &slot.writer_strengths)
9242 {
9243 items.push((item, listener_view));
9244 }
9245 }
9246 if !items.is_empty() {
9247 slot.last_sample_received = Some(now);
9248 slot.samples_delivered_count = slot
9249 .samples_delivered_count
9250 .saturating_add(items.len() as u64);
9251 if !slot.liveliness_alive {
9252 slot.liveliness_alive = true;
9253 slot.liveliness_alive_count =
9254 slot.liveliness_alive_count.saturating_add(1);
9255 }
9256 }
9257 listener = slot.listener.clone();
9258 waker = Arc::clone(&slot.async_waker);
9259 sender = slot.sample_tx.clone();
9260 #[cfg(feature = "inspect")]
9261 {
9262 topic_name = slot.topic_name.clone();
9263 }
9264 }
9265 for (item, listener_view) in items {
9266 let (item_repr, item_be) = if let UserSample::Alive {
9267 representation,
9268 big_endian,
9269 ..
9270 } = &item
9271 {
9272 (*representation, u8::from(*big_endian))
9273 } else {
9274 (0, 0)
9275 };
9276 #[cfg(feature = "inspect")]
9277 dispatch_inspect_dcps_receive_tap(&topic_name, df.reader_id, &item);
9278 // See the Data arm: listener and MPSC are exclusive.
9279 if let Some(ref l) = listener {
9280 if let Some((arc_payload, off)) = listener_view {
9281 l(&arc_payload[off..], item_repr, item_be);
9282 }
9283 } else {
9284 let _ = sender.send(item);
9285 wake_async_waker(&waker);
9286 }
9287 }
9288 } // for arc in target_slots (DataFrag)
9289 }
9290 ParsedSubmessage::Heartbeat(h) => {
9291 // Lever B — collect-then-dispatch like the Data arm. An HB can
9292 // unlock samples that were waiting on a hole fill
9293 // (volatile skip, historic eviction).
9294 //
9295 // D.5e Phase-2: synchronous ACKNACK emit on HB receipt
9296 // instead of deferred-via-tick. With `heartbeat_response_delay=0`
9297 // (D.5e default) `tick_outbound(now)` flushes the
9298 // ACKNACK directly for all pending writer_proxies — the tick loop
9299 // no longer has to wait 5 ms.
9300 // Cross-vendor: a HEARTBEAT with reader_id=UNKNOWN is
9301 // "to all matched readers". Cyclone often packs this into
9302 // DATA+HB submessage bundles.
9303 let target_slots: Vec<ReaderSlotArc> = if h.reader_id == EntityId::UNKNOWN {
9304 rt.reader_slots_snapshot()
9305 .into_iter()
9306 .map(|(_, arc)| arc)
9307 .collect()
9308 } else {
9309 rt.reader_slot(h.reader_id).into_iter().collect()
9310 };
9311 for arc in target_slots {
9312 let mut items: Vec<UserSample> = Vec::new();
9313 let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
9314 Vec::new();
9315 let waker;
9316 let sender;
9317 {
9318 let Ok(mut slot) = arc.lock() else { continue };
9319 for sample in slot.reader.handle_heartbeat(src_prefix, &h, now) {
9320 // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9321 if matches!(
9322 sample.kind,
9323 zerodds_rtps::history_cache::ChangeKind::Alive
9324 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9325 ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9326 {
9327 continue;
9328 }
9329 if let Some(item) =
9330 delivered_to_user_sample(&sample, &slot.writer_strengths)
9331 {
9332 items.push(item);
9333 }
9334 }
9335 if !items.is_empty() {
9336 slot.last_sample_received = Some(now);
9337 slot.samples_delivered_count = slot
9338 .samples_delivered_count
9339 .saturating_add(items.len() as u64);
9340 if !slot.liveliness_alive {
9341 slot.liveliness_alive = true;
9342 slot.liveliness_alive_count =
9343 slot.liveliness_alive_count.saturating_add(1);
9344 }
9345 }
9346 // D.5e Phase-2: synchronous ACKNACK directly in the recv thread.
9347 if let Ok(dgs) = slot.reader.tick_outbound(now) {
9348 sync_outbound = dgs;
9349 }
9350 waker = Arc::clone(&slot.async_waker);
9351 sender = slot.sample_tx.clone();
9352 }
9353 for item in items {
9354 let _ = sender.send(item);
9355 wake_async_waker(&waker);
9356 }
9357 // Send ACKNACK datagrams synchronously — no tick-quantization tax.
9358 for dg in sync_outbound {
9359 if let Some(secured) = protect_user_reader_datagram(rt, &dg.bytes) {
9360 for t in dg.targets.iter() {
9361 if is_routable_user_locator(t) {
9362 let _ = rt.user_unicast.send(t, &secured);
9363 }
9364 }
9365 }
9366 }
9367 } // for arc in target_slots (Heartbeat)
9368 }
9369 ParsedSubmessage::Gap(g) => {
9370 // Cross-vendor: Gap with UNKNOWN reader → fan-out.
9371 let target_slots: Vec<ReaderSlotArc> = if g.reader_id == EntityId::UNKNOWN {
9372 rt.reader_slots_snapshot()
9373 .into_iter()
9374 .map(|(_, arc)| arc)
9375 .collect()
9376 } else {
9377 rt.reader_slot(g.reader_id).into_iter().collect()
9378 };
9379 for arc in target_slots {
9380 if let Ok(mut slot) = arc.lock() {
9381 for sample in slot.reader.handle_gap(src_prefix, &g) {
9382 // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9383 if matches!(
9384 sample.kind,
9385 zerodds_rtps::history_cache::ChangeKind::Alive
9386 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9387 ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9388 {
9389 continue;
9390 }
9391 if let Some(item) =
9392 delivered_to_user_sample(&sample, &slot.writer_strengths)
9393 {
9394 let _ = slot.sample_tx.send(item);
9395 wake_async_waker(&slot.async_waker);
9396 }
9397 }
9398 }
9399 }
9400 }
9401 ParsedSubmessage::AckNack(ack) => {
9402 if let Some(arc) = rt.writer_slot(ack.writer_id) {
9403 let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
9404 Vec::new();
9405 if let Ok(mut slot) = arc.lock() {
9406 let base = ack.reader_sn_state.bitmap_base;
9407 let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
9408 let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
9409 slot.writer.handle_acknack(src, base, requested);
9410 // D.5e Phase-2: synchronous resend on NACK receipt.
9411 // An ACKNACK may have listed requested SNs for resend;
9412 // tick delivers the resend datagrams directly in the recv thread.
9413 if let Ok(dgs) = slot.writer.tick(now) {
9414 sync_outbound = dgs;
9415 }
9416 }
9417 // ACK-Event-Cvar: wake `wait_for_acknowledgments`-waiters.
9418 rt.notify_ack_event();
9419 // Send sync resends (no more tick wait). FU2 S3:
9420 // per-target data_protection (a reliable resend of user DATA
9421 // must be encrypted just like the immediate send).
9422 for dg in sync_outbound {
9423 for t in dg.targets.iter() {
9424 if is_routable_user_locator(t) {
9425 if let Some(secured) =
9426 secure_outbound_for_target(rt, ack.writer_id, &dg.bytes, t)
9427 {
9428 let _ = rt.user_unicast.send(t, &secured);
9429 }
9430 }
9431 }
9432 }
9433 }
9434 }
9435 ParsedSubmessage::NackFrag(nf) => {
9436 if let Some(arc) = rt.writer_slot(nf.writer_id) {
9437 if let Ok(mut slot) = arc.lock() {
9438 let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
9439 slot.writer.handle_nackfrag(src, &nf);
9440 }
9441 }
9442 }
9443 _ => {}
9444 }
9445 }
9446}
9447
9448/// Test hook: allows a direct call of `handle_spdp_datagram` from
9449/// other modules without spinning up the whole event loop.
9450/// For internal tests only.
9451#[cfg(test)]
9452pub(crate) fn handle_spdp_datagram_for_test(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
9453 handle_spdp_datagram(rt, bytes);
9454}
9455
9456fn handle_spdp_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
9457 let parsed = match rt.spdp_reader.parse_datagram(bytes) {
9458 Ok(p) => p,
9459 Err(_) => return, // not SPDP or wire error — swallow
9460 };
9461 // Self-discovery filter: ignore our own beacons.
9462 if parsed.sender_prefix == rt.guid_prefix {
9463 return;
9464 }
9465 let is_new = {
9466 if let Ok(mut cache) = rt.discovered.lock() {
9467 cache.insert(parsed.clone())
9468 } else {
9469 false
9470 }
9471 };
9472 // On first discovery: wire the SEDP stack + send out initial
9473 // announcements.
9474 if is_new {
9475 // A1 discovery-server relay: bridge the newly-joined client to every
9476 // already-known client over a single well-known address. Forwards ONLY
9477 // raw SPDP (participant locators) — SEDP (endpoint discovery, incl. ROS-2
9478 // Action endpoints) then proceeds DIRECTLY peer-to-peer, which is exactly
9479 // why Actions keep working (unlike a SEDP-proxying discovery server).
9480 // Plain discovery only; secured relay is a follow-up.
9481 #[cfg(feature = "security")]
9482 let relay_plain = rt.config.security.is_none();
9483 #[cfg(not(feature = "security"))]
9484 let relay_plain = true;
9485 if rt.config.discovery_server && relay_plain {
9486 let new_client = wlp_unicast_targets(core::slice::from_ref(&parsed));
9487 let others: Vec<_> = rt
9488 .discovered_participants()
9489 .into_iter()
9490 .filter(|dp| dp.sender_prefix != parsed.sender_prefix)
9491 .collect();
9492 if let Ok(mut relay) = rt.spdp_relay_cache.lock() {
9493 // 1) tell the new client about every already-known client.
9494 for dp in &others {
9495 if let Some(raw) = relay.get(&dp.sender_prefix) {
9496 for loc in &new_client {
9497 let _ = rt.spdp_unicast.send(loc, raw);
9498 }
9499 }
9500 }
9501 // 2) tell every already-known client about the new client.
9502 for dp in &others {
9503 for loc in wlp_unicast_targets(core::slice::from_ref(dp)) {
9504 let _ = rt.spdp_unicast.send(&loc, bytes);
9505 }
9506 }
9507 // 3) remember the new client's SPDP for future joiners.
9508 relay.insert(parsed.sender_prefix, bytes.to_vec());
9509 }
9510 }
9511 if let Ok(mut sedp) = rt.sedp.lock() {
9512 sedp.on_participant_discovered(&parsed);
9513 }
9514 // Event-driven directed SPDP response (§8.5.3): send OUR own
9515 // SPDP IMMEDIATELY unicast to the newly discovered peer, instead of letting it
9516 // wait for our next periodic multicast beacon (spdp_period=5s, codepit-LXC
9517 // multicast flaky). A spec-conformant peer (OpenDDS)
9518 // processes our auth request ONLY once it has our identity_token from
9519 // our SPDP — without this directed response it waits up to
9520 // spdp_period (seconds latency → cross-vendor ping wait_for_matched
9521 // timeout). NO timeout band-aid: the seconds latency was the missing
9522 // discovery event. Token-less first beacons (security not yet enabled)
9523 // are NOT sent (see security_pending in the announce loop) — the
9524 // periodic/announce_spdp_now path catches up.
9525 #[cfg(feature = "security")]
9526 let beacon_ready =
9527 !(rt.config.security.is_some() && rt.security_builtin_snapshot().is_none());
9528 #[cfg(not(feature = "security"))]
9529 let beacon_ready = true;
9530 if beacon_ready {
9531 let targets = wlp_unicast_targets(core::slice::from_ref(&parsed));
9532 if !targets.is_empty() {
9533 if let Some(secured) = rt
9534 .spdp_beacon
9535 .lock()
9536 .ok()
9537 .and_then(|mut b| b.serialize().ok())
9538 .and_then(|d| secure_outbound_bytes(rt, &d).map(|c| c.to_vec()))
9539 {
9540 for loc in &targets {
9541 let _ = rt.spdp_unicast.send(loc, &secured);
9542 }
9543 }
9544 }
9545 }
9546 }
9547 // FU2: wire the security builtin stack + kick off the auth handshake.
9548 // On EVERY beacon (not only is_new): `handle_remote_endpoints` and
9549 // `begin_handshake_with` are idempotent. This also covers the case
9550 // that the peer was discovered before the auth plugin was active via
9551 // `enable_security_builtins_with_auth` — the next
9552 // beacon refresh then kicks off the handshake. No-op without a plugin,
9553 // without security bits or without an announced identity_token.
9554 if let Some(sec) = rt.security_builtin_snapshot() {
9555 let handshake_dgs = if let Ok(mut s) = sec.lock() {
9556 s.note_remote_vendor(parsed.sender_prefix, parsed.sender_vendor);
9557 s.handle_remote_endpoints(&parsed);
9558 match parsed.data.identity_token.as_ref() {
9559 Some(token) => s
9560 .begin_handshake_with(parsed.sender_prefix, parsed.data.guid.to_bytes(), token)
9561 .unwrap_or_default(),
9562 None => Vec::new(),
9563 }
9564 } else {
9565 Vec::new()
9566 };
9567 for dg in handshake_dgs {
9568 send_discovery_datagram(rt, &dg.targets, &dg.bytes);
9569 }
9570 }
9571 // Mirror the SPDP receive into the builtin DCPSParticipant reader.
9572 // We send on every beacon (also refresh) — Spec §2.2.5.1
9573 // allows it, take() returns the respective current
9574 // data to the user. A reader with KEEP_LAST(1) receives only the newest.
9575 if let Some(sinks) = rt.builtin_sinks_snapshot() {
9576 let dcps_sample =
9577 crate::builtin_topics::ParticipantBuiltinTopicData::from_wire(&parsed.data);
9578 // .7 §2.2.2.2.1.14: drop ignored participants before
9579 // they fall into the builtin reader.
9580 if let Some(filter) = rt.ignore_filter_snapshot() {
9581 let h = crate::instance_handle::InstanceHandle::from_guid(dcps_sample.key);
9582 if filter.is_participant_ignored(h) {
9583 return;
9584 }
9585 }
9586 let _ = sinks.push_participant(&dcps_sample);
9587 }
9588}
9589
9590/// Pushes SEDP events (new pubs/subs) into the 4 builtin-topic
9591/// readers. A new pub/sub produces **two** samples:
9592///
9593/// 1. a `DCPSPublication`/`DCPSSubscription` sample,
9594/// 2. a `DCPSTopic` sample (synthetic from topic name + type name).
9595///
9596/// The native SEDP-topics endpoints (RTPS 2.5 §9.3.2.12 bits 28/29)
9597/// are optional per Spec §8.5.4.4 and covered in ZeroDDS via this
9598/// synthetic derivation — see also
9599/// `endpoint_flag::ALL_STANDARD`, which deliberately omits the
9600/// topics bits. Cyclone/Fast-DDS peers that send their own topic
9601/// announces are ignored (no reader endpoint).
9602fn push_sedp_events_to_builtin_readers(
9603 rt: &Arc<DcpsRuntime>,
9604 events: &zerodds_discovery::sedp::SedpEvents,
9605) {
9606 let Some(sinks) = rt.builtin_sinks_snapshot() else {
9607 return;
9608 };
9609 let filter = rt.ignore_filter_snapshot();
9610 for w in &events.new_publications {
9611 let pub_sample = crate::builtin_topics::PublicationBuiltinTopicData::from_wire(w);
9612 let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_publication(w);
9613 // .7 §2.2.2.2.1.14/.16: consult the participant + publication +
9614 // topic ignore filters.
9615 if let Some(f) = &filter {
9616 let part_h = crate::instance_handle::InstanceHandle::from_guid(w.participant_key);
9617 let pub_h = crate::instance_handle::InstanceHandle::from_guid(w.key);
9618 let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
9619 if f.is_participant_ignored(part_h) || f.is_publication_ignored(pub_h) {
9620 continue;
9621 }
9622 let _ = sinks.push_publication(&pub_sample);
9623 if !f.is_topic_ignored(topic_h) {
9624 let _ = sinks.push_topic(&topic_sample);
9625 }
9626 } else {
9627 let _ = sinks.push_publication(&pub_sample);
9628 let _ = sinks.push_topic(&topic_sample);
9629 }
9630 }
9631 for r in &events.new_subscriptions {
9632 let sub_sample = crate::builtin_topics::SubscriptionBuiltinTopicData::from_wire(r);
9633 let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_subscription(r);
9634 if let Some(f) = &filter {
9635 let part_h = crate::instance_handle::InstanceHandle::from_guid(r.participant_key);
9636 let sub_h = crate::instance_handle::InstanceHandle::from_guid(r.key);
9637 let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
9638 if f.is_participant_ignored(part_h) || f.is_subscription_ignored(sub_h) {
9639 continue;
9640 }
9641 let _ = sinks.push_subscription(&sub_sample);
9642 if !f.is_topic_ignored(topic_h) {
9643 let _ = sinks.push_topic(&topic_sample);
9644 }
9645 } else {
9646 let _ = sinks.push_subscription(&sub_sample);
9647 let _ = sinks.push_topic(&topic_sample);
9648 }
9649 }
9650 // Endpoint deletion (SEDP dispose): mark the matching built-in instance
9651 // disposed so a DCPSPublication/DCPSSubscription observer sees the remote
9652 // endpoint vanish (DDSI-RTPS §8.5.4). The unmatch of local user endpoints
9653 // is driven separately via `apply_sedp_removals`.
9654 for g in &events.removed_publications {
9655 sinks.dispose_publication(*g);
9656 }
9657 for g in &events.removed_subscriptions {
9658 sinks.dispose_subscription(*g);
9659 }
9660}
9661
9662/// Drives the unmatch of local user endpoints when SEDP reported a remote
9663/// endpoint deletion: each removed remote publication is removed from every
9664/// local reader's proxy set, each removed remote subscription from every local
9665/// writer's. See [`DcpsRuntime::remove_remote_writer`] /
9666/// [`DcpsRuntime::remove_remote_reader`].
9667fn apply_sedp_removals(rt: &Arc<DcpsRuntime>, events: &zerodds_discovery::sedp::SedpEvents) {
9668 for g in &events.removed_publications {
9669 rt.remove_remote_writer(*g);
9670 }
9671 for g in &events.removed_subscriptions {
9672 rt.remove_remote_reader(*g);
9673 }
9674}
9675
9676/// Binary-property name of the crypto key material in the CryptoToken DataHolder
9677/// (DDS-Security §9.5.2.1.1, cyclone-verified: `dds.cryp.keymat`).
9678#[cfg(feature = "security")]
9679const CRYPTO_TOKEN_PROP: &str = "dds.cryp.keymat";
9680
9681/// CryptoToken `class_id` (§9.5.2.1: `DDS:Crypto:AES_GCM_GMAC` — underscores,
9682/// **not** the plugin-class string with hyphens).
9683#[cfg(feature = "security")]
9684const CRYPTO_TOKEN_CLASS_ID: &str = "DDS:Crypto:AES_GCM_GMAC";
9685
9686/// Builds the `PARTICIPANT_CRYPTO_TOKENS` VolatileSecure message with the
9687/// Kx-encrypted token as a binary property (FU2 S1.4).
9688#[cfg(feature = "security")]
9689fn build_crypto_token_message(
9690 rt: &DcpsRuntime,
9691 remote_prefix: GuidPrefix,
9692 kx_token: Vec<u8>,
9693) -> zerodds_security::generic_message::ParticipantGenericMessage {
9694 use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
9695 use zerodds_security::token::DataHolder;
9696 ParticipantGenericMessage {
9697 message_identity: MessageIdentity {
9698 source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
9699 sequence_number: 1,
9700 },
9701 related_message_identity: MessageIdentity::default(),
9702 destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
9703 destination_endpoint_key: [0; 16],
9704 source_endpoint_key: [0; 16],
9705 message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
9706 message_data: alloc::vec![
9707 DataHolder::new(CRYPTO_TOKEN_CLASS_ID)
9708 .with_binary_property(CRYPTO_TOKEN_PROP, kx_token)
9709 ],
9710 }
9711}
9712
9713/// FU2 S1.4 (send): after handshake completion Kx-encrypt the local data token
9714/// (`gate.local_token`) and send it as
9715/// `PARTICIPANT_CRYPTO_TOKENS` over VolatileSecure.
9716/// Registers the peer's Kx key in the gate beforehand. `None` without a gate
9717/// or on error (drop instead of leak).
9718#[cfg(feature = "security")]
9719fn prepare_crypto_token(
9720 rt: &DcpsRuntime,
9721 remote_prefix: GuidPrefix,
9722 remote_identity: zerodds_security::authentication::IdentityHandle,
9723 secret: zerodds_security::authentication::SharedSecretHandle,
9724) -> Option<zerodds_security::generic_message::ParticipantGenericMessage> {
9725 let gate = rt.config.security.as_ref()?;
9726 let peer_key = remote_prefix.to_bytes();
9727 // ALWAYS register the peer's Kx key — even with rtps=NONE: the per-endpoint
9728 // tokens (discovery_/data_protection) travel Kx-protected over the volatile,
9729 // protect_volatile_datagram needs this key.
9730 gate.register_remote_by_guid_from_secret(peer_key, remote_identity, secret)
9731 .ok()?;
9732 // BUT: send the ParticipantCryptoToken (= SRTPS keymat) ONLY when
9733 // rtps_protection != NONE. With rtps=NONE there is no SRTPS; OpenDDS rejects the
9734 // token (Spdp.cpp:1966 `crypto_handle_==NIL` -> "not configured for RTPS
9735 // Protection", logs `handle_participant_crypto_tokens failed`) and OpenDDS-self
9736 // also does NOT exchange it with rtps=NONE. None here = no participant
9737 // token send; the per-endpoint tokens continue over the separate path.
9738 if gate.rtps_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
9739 return None;
9740 }
9741 // Cross-vendor: the data token travels in PLAINTEXT in the
9742 // ParticipantGenericMessage — it becomes confidential only through the
9743 // SEC_PREFIX/BODY/POSTFIX submessage protection of the whole volatile
9744 // DATA (see protect_volatile_datagram). The `register_*` line above
9745 // created the peer's Kx key in the gate that this protection uses.
9746 let token = gate.local_token().ok()?;
9747 Some(build_crypto_token_message(rt, remote_prefix, token))
9748}
9749
9750/// Per-endpoint crypto handle for a local writer/reader (get-or-register).
9751/// DDS-Security §9.5.3.3: each endpoint has its OWN key material. Registration
9752/// under the write lock (race-free). `None` without an active gate.
9753#[cfg(feature = "security")]
9754fn local_endpoint_crypto_handle(
9755 rt: &DcpsRuntime,
9756 eid: EntityId,
9757 is_writer: bool,
9758) -> Option<zerodds_security::crypto::CryptoHandle> {
9759 let gate = rt.config.security.as_ref()?;
9760 {
9761 let map = rt.endpoint_crypto.read().ok()?;
9762 if let Some(h) = map.get(&eid) {
9763 return Some(*h);
9764 }
9765 }
9766 let mut map = rt.endpoint_crypto.write().ok()?;
9767 if let Some(h) = map.get(&eid) {
9768 return Some(*h);
9769 }
9770 let h = gate.register_local_endpoint(is_writer).ok()?;
9771 map.insert(eid, h);
9772 Some(h)
9773}
9774
9775/// Cross-vendor step 6b (send): per-endpoint `datawriter_crypto_tokens` (for
9776/// every local user writer) + `datareader_crypto_tokens` (for every local
9777/// user reader) to the peer. cyclone needs these to approve the user-endpoint
9778/// match and decode ZeroDDS' user DATA. `source_endpoint_key` = the
9779/// local endpoint GUID; the keymat is the local data key (one key per
9780/// participant in the bench). Empty list without a gate / without user endpoints.
9781#[cfg(feature = "security")]
9782fn prepare_endpoint_crypto_tokens(
9783 rt: &DcpsRuntime,
9784 remote_prefix: GuidPrefix,
9785) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
9786 use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
9787 use zerodds_security::token::DataHolder;
9788 let Some(gate) = rt.config.security.as_ref() else {
9789 return Vec::new();
9790 };
9791 let mut out = Vec::new();
9792 // cyclone associates a datawriter/datareader token via the pair
9793 // (source_endpoint, destination_endpoint). Hence per local endpoint ONE
9794 // token PER matched remote endpoint of **this** peer, with the concrete
9795 // remote GUID as destination_endpoint_key (dst=0 would make cyclone discard it).
9796 //
9797 // §9.5.3.3: the token carries the **per-endpoint** key material of the
9798 // `source_eid` (not the participant key) — the same key with which
9799 // ZeroDDS encodes this endpoint's submessages (protect_user_datagram).
9800 let build = |class: &str,
9801 source_eid: EntityId,
9802 dst: [u8; 16]|
9803 -> Option<ParticipantGenericMessage> {
9804 let is_writer = class == class_id::DATAWRITER_CRYPTO_TOKENS;
9805 let handle = local_endpoint_crypto_handle(rt, source_eid, is_writer)?;
9806 let token = gate.create_endpoint_token(handle).ok()?;
9807 // Dual key (metadata != data, meta-sign-data): cyclone expects
9808 // num_key_mat=2 — submessage keymat (metadata kind) + payload keymat
9809 // (data kind) as TWO DataHolders in this order. Single key
9810 // (all other profiles): only the submessage/endpoint keymat.
9811 let mut dhs = alloc::vec![
9812 DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, token)
9813 ];
9814 if let Some(pay) = gate.endpoint_payload_token(handle) {
9815 dhs.push(
9816 DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, pay),
9817 );
9818 }
9819 Some(ParticipantGenericMessage {
9820 message_identity: MessageIdentity {
9821 source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
9822 sequence_number: 1,
9823 },
9824 related_message_identity: MessageIdentity::default(),
9825 destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
9826 destination_endpoint_key: dst,
9827 source_endpoint_key: Guid::new(rt.guid_prefix, source_eid).to_bytes(),
9828 message_class_id: class.into(),
9829 message_data: dhs,
9830 })
9831 };
9832 // datawriter tokens: per local writer for every matched remote reader
9833 // of this peer (dst = reader GUID).
9834 for (weid, warc) in rt.writer_slots_snapshot() {
9835 if let Ok(slot) = warc.lock() {
9836 for proxy in slot.writer.reader_proxies() {
9837 if proxy.remote_reader_guid.prefix == remote_prefix {
9838 out.extend(build(
9839 class_id::DATAWRITER_CRYPTO_TOKENS,
9840 weid,
9841 proxy.remote_reader_guid.to_bytes(),
9842 ));
9843 }
9844 }
9845 }
9846 }
9847 // datareader tokens: per local reader for every matched remote writer
9848 // of this peer (dst = writer GUID).
9849 for (reid, rarc) in rt.reader_slots_snapshot() {
9850 if let Ok(slot) = rarc.lock() {
9851 for ws in slot.reader.writer_proxies() {
9852 if ws.proxy.remote_writer_guid.prefix == remote_prefix {
9853 out.extend(build(
9854 class_id::DATAREADER_CRYPTO_TOKENS,
9855 reid,
9856 ws.proxy.remote_writer_guid.to_bytes(),
9857 ));
9858 }
9859 }
9860 }
9861 }
9862 // Protected discovery (§8.4.2.4): the secure builtin SEDP endpoints
9863 // (DCPSPublications/SubscriptionsSecure) also need crypto tokens,
9864 // so the peer associates ZeroDDS' data key with them + decodes the secure-SEDP
9865 // submessages. cyclone exchanges these builtin-endpoint tokens
9866 // the same way over the volatile (ff0003c2/c7 + ff0004c2/c7).
9867 if gate
9868 .discovery_protection()
9869 .map(|l| l != ProtectionLevel::None)
9870 .unwrap_or(false)
9871 {
9872 let builtin_pairs = [
9873 (
9874 class_id::DATAWRITER_CRYPTO_TOKENS,
9875 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
9876 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
9877 ),
9878 (
9879 class_id::DATAREADER_CRYPTO_TOKENS,
9880 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
9881 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
9882 ),
9883 (
9884 class_id::DATAWRITER_CRYPTO_TOKENS,
9885 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
9886 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
9887 ),
9888 (
9889 class_id::DATAREADER_CRYPTO_TOKENS,
9890 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
9891 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
9892 ),
9893 ];
9894 for (class, src_eid, dst_eid) in builtin_pairs {
9895 out.extend(build(
9896 class,
9897 src_eid,
9898 Guid::new(remote_prefix, dst_eid).to_bytes(),
9899 ));
9900 }
9901 }
9902 // FastDDS interop: the reliable secure-SPDP builtin (DCPSParticipantsSecure,
9903 // ff0101c2/c7) needs per-endpoint crypto tokens when FastDDS SEC-encrypts the secure-
9904 // SPDP DATA under discovery_protection — otherwise the peer cannot
9905 // decode our secure SPDP -> no secure participant discovery ->
9906 // no token reciprocation. Gated on enable_secure_spdp.
9907 if rt.config.enable_secure_spdp {
9908 let spdp_pairs = [
9909 (
9910 class_id::DATAWRITER_CRYPTO_TOKENS,
9911 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
9912 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
9913 ),
9914 (
9915 class_id::DATAREADER_CRYPTO_TOKENS,
9916 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
9917 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
9918 ),
9919 ];
9920 for (class, src_eid, dst_eid) in spdp_pairs {
9921 out.extend(build(
9922 class,
9923 src_eid,
9924 Guid::new(remote_prefix, dst_eid).to_bytes(),
9925 ));
9926 }
9927 }
9928 // Liveliness protection (§8.4.2.4): the secure-WLP builtin endpoints
9929 // (BuiltinParticipantMessageSecure, ff0200c2/c7) also need per-
9930 // endpoint crypto tokens. cyclone gates the participant security approval
9931 // (and thus the user-endpoint connection) on it — without these tokens
9932 // "connect ... waiting for approval by security" stays hung.
9933 if gate
9934 .liveliness_protection()
9935 .map(|l| l != ProtectionLevel::None)
9936 .unwrap_or(false)
9937 {
9938 let wlp_pairs = [
9939 (
9940 class_id::DATAWRITER_CRYPTO_TOKENS,
9941 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
9942 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
9943 ),
9944 (
9945 class_id::DATAREADER_CRYPTO_TOKENS,
9946 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
9947 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
9948 ),
9949 ];
9950 for (class, src_eid, dst_eid) in wlp_pairs {
9951 out.extend(build(
9952 class,
9953 src_eid,
9954 Guid::new(remote_prefix, dst_eid).to_bytes(),
9955 ));
9956 }
9957 }
9958 out
9959}
9960
9961/// Dedup key of a per-endpoint crypto token: the pair
9962/// (source_endpoint, destination_endpoint). cyclone associates a
9963/// datawriter/datareader token via exactly this pair (§9.5.3.3), so it is
9964/// also the right granularity to remember which tokens have gone out.
9965#[cfg(feature = "security")]
9966fn endpoint_token_key(
9967 m: &zerodds_security::generic_message::ParticipantGenericMessage,
9968) -> [u8; 32] {
9969 let mut k = [0u8; 32];
9970 k[..16].copy_from_slice(&m.source_endpoint_key);
9971 k[16..].copy_from_slice(&m.destination_endpoint_key);
9972 k
9973}
9974
9975/// Filters out the per-endpoint tokens not yet sent. The previously
9976/// used **per-peer** once-guard was too coarse: it snapped shut as soon as the
9977/// participant/secure-SEDP builtin tokens were out — but user endpoints match
9978/// only later (after the secure SEDP). Their tokens then never went out,
9979/// and the peer could never decode ZeroDDS' user DATA. Per-token dedup
9980/// (peer+source+dest) sends each token exactly once — builtins early,
9981/// user endpoints as soon as they match.
9982#[cfg(feature = "security")]
9983fn pending_endpoint_tokens(
9984 msgs: Vec<zerodds_security::generic_message::ParticipantGenericMessage>,
9985 already_sent: &alloc::collections::BTreeSet<[u8; 32]>,
9986) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
9987 msgs.into_iter()
9988 .filter(|m| !already_sent.contains(&endpoint_token_key(m)))
9989 .collect()
9990}
9991
9992/// FU2 S1.4 (recv): Kx-decrypt an incoming `PARTICIPANT_CRYPTO_TOKENS` message
9993/// and install the peer's data key in the gate.
9994/// Afterwards secured user DATA round-trips with this peer.
9995#[cfg(feature = "security")]
9996fn install_crypto_token(
9997 rt: &DcpsRuntime,
9998 remote_prefix: GuidPrefix,
9999 msg: &zerodds_security::generic_message::ParticipantGenericMessage,
10000) {
10001 use zerodds_security::generic_message::class_id;
10002 // Cross-vendor: cyclone sends the data key both as
10003 // participant_crypto_tokens and per-endpoint as datawriter/
10004 // datareader_crypto_tokens. We install the keymat from all three
10005 // under the sender's participant slot (one user endpoint per participant
10006 // in the bench) — so decode_data_datawriter_from decodes the user DATA.
10007 if msg.message_class_id != class_id::PARTICIPANT_CRYPTO_TOKENS
10008 && msg.message_class_id != class_id::DATAWRITER_CRYPTO_TOKENS
10009 && msg.message_class_id != class_id::DATAREADER_CRYPTO_TOKENS
10010 {
10011 return;
10012 }
10013 let Some(gate) = rt.config.security.as_ref() else {
10014 return;
10015 };
10016 let peer_key = remote_prefix.to_bytes();
10017 // `message_data` is a sequence<DataHolder> (DDS-Security §7.4.4.3
10018 // ParticipantGenericMessage): cyclone packs MULTIPLE CryptoTokens (its own
10019 // key material per endpoint, different transformation_key_id) into ONE
10020 // message. Install ALL — taking only `.first()` lost the
10021 // endpoint keys (key_id 2..N) and the secure SEDP stayed undecodable.
10022 // Plaintext token (confidentiality was provided by the submessage protection of
10023 // the transporting volatile DATA, see unprotect_volatile_datagram).
10024 // DDS-Security §9.5.2 vs §9.5.3: the PARTICIPANT crypto token carries the
10025 // message-level key (SRTPS, decode_secured_rtps_message -> slots[peer]); the
10026 // datawriter/datareader tokens carry per-endpoint data keys that belong ONLY in
10027 // the key_id path (remote_by_key_id, decode_data_by_key_id). Putting both
10028 // into slots[peer] let the last-installed (datareader) overwrite the
10029 // participant key -> message-level SRTPS tag mismatch.
10030 let is_participant = msg.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS;
10031 for dh in &msg.message_data {
10032 if let Some(token) = dh.binary_property(CRYPTO_TOKEN_PROP) {
10033 let _ = if is_participant {
10034 gate.set_remote_data_token_by_guid(&peer_key, token)
10035 } else {
10036 gate.install_remote_endpoint_token(token)
10037 };
10038 }
10039 }
10040}
10041
10042// RTPS submessage IDs for the VolatileSecure submessage-protection surgery.
10043#[cfg(feature = "security")]
10044const SMID_DATA: u8 = 0x15;
10045#[cfg(feature = "security")]
10046const SMID_SEC_PREFIX: u8 = 0x31;
10047#[cfg(feature = "security")]
10048const SMID_SEC_POSTFIX: u8 = 0x32;
10049// Further writer submessage IDs (DDSI-RTPS 2.5 §8.3.7). Per DDS-Security
10050// §8.4.2.4 (is_submessage_protected=TRUE, DataWriter) ALL submessages sent by the
10051// writer — not only DATA — MUST be protected via encode_datawriter_submessage.
10052// HEARTBEAT is the critical one: without it the remote
10053// reader cannot NACK a missing sequence number (= no reliable recovery).
10054#[cfg(feature = "security")]
10055const SMID_HEARTBEAT: u8 = 0x07;
10056#[cfg(feature = "security")]
10057const SMID_GAP: u8 = 0x08;
10058#[cfg(feature = "security")]
10059const SMID_DATA_FRAG: u8 = 0x16;
10060#[cfg(feature = "security")]
10061const SMID_HEARTBEAT_FRAG: u8 = 0x13;
10062// Reader submessages (DDSI-RTPS 2.5 §8.3.7): under `metadata_protection_kind
10063// != NONE` to be protected via `encode_datareader_submessage` (§8.4.2.4) with the per-endpoint
10064// reader key — otherwise a spec-conformant remote writer
10065// (cyclone under discovery=ENCRYPT) discards the clear ACKNACK and never re-sends.
10066#[cfg(feature = "security")]
10067const SMID_ACKNACK: u8 = 0x06;
10068#[cfg(feature = "security")]
10069const SMID_NACK_FRAG: u8 = 0x12;
10070
10071/// `true` if the submessage ID is a submessage sent by the DataReader
10072/// (ACKNACK/NACK_FRAG) — datareader protection path.
10073#[cfg(feature = "security")]
10074fn is_protected_reader_submessage(id: u8) -> bool {
10075 matches!(id, SMID_ACKNACK | SMID_NACK_FRAG)
10076}
10077
10078/// Extracts the `reader_id` (sender) from an ACKNACK/NACK_FRAG submessage:
10079/// offset 4 (after header(4)), directly before the writer_id (offset 8).
10080#[cfg(feature = "security")]
10081fn reader_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
10082 if !is_protected_reader_submessage(id) {
10083 return None;
10084 }
10085 let raw: [u8; 4] = submsg.get(4..8)?.try_into().ok()?;
10086 Some(EntityId::from_bytes(raw))
10087}
10088
10089/// `true` if the submessage ID is a submessage sent by the DataWriter that,
10090/// under `metadata_protection_kind != NONE`, must be protected via `encode_datawriter_submessage`
10091/// (DDS-Security §8.4.2.4). ACKNACK/NACK_FRAG are
10092/// reader submessages (datareader path) and are excluded here.
10093#[cfg(feature = "security")]
10094fn is_protected_writer_submessage(id: u8) -> bool {
10095 matches!(
10096 id,
10097 SMID_DATA | SMID_DATA_FRAG | SMID_HEARTBEAT | SMID_HEARTBEAT_FRAG | SMID_GAP
10098 )
10099}
10100
10101/// Walks the submessages of an RTPS datagram from `offset` and returns
10102/// `(submessage_id, start, total_len)`. `octetsToNextHeader == 0` means
10103/// "to the end of the datagram" (RTPS §8.3.3.2.3).
10104#[cfg(feature = "security")]
10105fn walk_submessages(bytes: &[u8]) -> Vec<(u8, usize, usize)> {
10106 let mut out = Vec::new();
10107 let mut o = 20; // RTPS header
10108 while o + 4 <= bytes.len() {
10109 let id = bytes[o];
10110 let le = bytes[o + 1] & 0x01 != 0;
10111 let raw = if le {
10112 u16::from_le_bytes([bytes[o + 2], bytes[o + 3]])
10113 } else {
10114 u16::from_be_bytes([bytes[o + 2], bytes[o + 3]])
10115 } as usize;
10116 let body = if raw == 0 { bytes.len() - (o + 4) } else { raw };
10117 let total = 4 + body;
10118 if o + total > bytes.len() {
10119 break;
10120 }
10121 out.push((id, o, total));
10122 o += total;
10123 }
10124 out
10125}
10126
10127/// Cross-vendor VolatileSecure (send): replaces every DATA submessage in the
10128/// datagram with the cyclone-conformant `SEC_PREFIX`/`SEC_BODY`/`SEC_POSTFIX`
10129/// sequence (encrypted with the peer's Kx key). Other submessages
10130/// (INFO_DST/INFO_TS/HEARTBEAT) stay unchanged. Returns the datagram
10131/// unchanged if no DATA submessage is present (e.g. a pure
10132/// HEARTBEAT tick). `None` only on a crypto error (drop instead of leak).
10133#[cfg(feature = "security")]
10134fn protect_volatile_datagram(
10135 rt: &DcpsRuntime,
10136 bytes: &[u8],
10137 peer_key: &[u8; 12],
10138) -> Option<Vec<u8>> {
10139 let gate = rt.config.security.as_ref()?;
10140 if bytes.len() < 20 {
10141 return Some(bytes.to_vec());
10142 }
10143 let subs = walk_submessages(bytes);
10144 // DDS-Security §8.4.2.4: ParticipantVolatileMessageSecure is submessage-
10145 // protected — ALL submessages sent by the endpoint MUST be protected with the Kx key,
10146 // not only DATA. This holds for BOTH directions:
10147 // * writer submessages (DATA, DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP)
10148 // * reader submessages (ACKNACK, NACK_FRAG)
10149 // cyclone/FastDDS otherwise discard the WHOLE volatile sample with "clear
10150 // submsg from protected src" → the crypto-token exchange over the volatile
10151 // stalls. write_with_heartbeat bundles DATA+HEARTBEAT into ONE datagram; if
10152 // the HEARTBEAT stayed clear, the whole token sample was lost (cross-vendor
10153 // cyclone→ZeroDDS responder).
10154 // The reader ACKNACK: OpenDDS' RtpsUdpReceiveStrategy::check_encoded requires
10155 // protection for the volatile reader (ff0202c4, is_submessage_protected=TRUE) and
10156 // otherwise drops the clear ACKNACK ("Submessage requires protection") → its
10157 // volatile WRITER never gets an ACK → considers the token delivery
10158 // unacknowledged → zerodds NEVER sends the SRTPS-protected secure SEDP → no
10159 // user-endpoint match. The volatile channel uses ONE shared Kx session key
10160 // (KDF from the shared secret, §9.5.3.3.4.4), symmetric for both directions
10161 // → protect the ACKNACK with the same Kx key as the DATA.
10162 if !subs.iter().any(|(id, _, _)| {
10163 is_protected_writer_submessage(*id) || is_protected_reader_submessage(*id)
10164 }) {
10165 return Some(bytes.to_vec()); // no protection-worthy submessage -> unchanged
10166 }
10167 let mut out = Vec::with_capacity(bytes.len() + 64);
10168 out.extend_from_slice(&bytes[..20]);
10169 for (id, start, total) in subs {
10170 let submsg = &bytes[start..start + total];
10171 if is_protected_writer_submessage(id) || is_protected_reader_submessage(id) {
10172 match gate.encode_kx_datawriter_for(peer_key, submsg) {
10173 Ok(sec) => out.extend_from_slice(&sec),
10174 Err(_) => return None, // drop instead of plaintext leak
10175 }
10176 } else {
10177 out.extend_from_slice(submsg);
10178 }
10179 }
10180 Some(out)
10181}
10182
10183/// Cross-vendor VolatileSecure (recv): recognizes a `SEC_PREFIX`/`SEC_BODY`/
10184/// `SEC_POSTFIX` sequence, decodes it with the peer's Kx key to the
10185/// original DATA submessage and builds a plain RTPS datagram for the
10186/// `volatile_reader`. `None` if no SEC_* sequence is present (then the normal
10187/// path) or on a crypto error.
10188#[cfg(feature = "security")]
10189fn unprotect_volatile_datagram(
10190 rt: &DcpsRuntime,
10191 bytes: &[u8],
10192 peer_key: &[u8; 12],
10193) -> Option<Vec<u8>> {
10194 let gate = rt.config.security.as_ref()?;
10195 if bytes.len() < 20 {
10196 return None;
10197 }
10198 let subs = walk_submessages(bytes);
10199 // Cyclone/FastDDS bundle, via xpack, MULTIPLE SEC_*-protected volatile
10200 // submessages (all with the Kx key) into ONE datagram. So there can be
10201 // multiple SEC_PREFIX/BODY/POSTFIX triples — transform ALL back
10202 // (like unprotect_user_datagram). Decoding only the first block (an earlier
10203 // bug) left every bundled token sample after the first encrypted;
10204 // the VOLATILE writer does not retransmit them → deterministic
10205 // token loss (no "flaky" transport, all same-host). `None` if there is no
10206 // SEC_PREFIX at all (plaintext) or the Kx decode fails (= not a volatile datagram,
10207 // e.g. secure SEDP with a per-endpoint key).
10208 if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
10209 return None;
10210 }
10211 let mut out = Vec::with_capacity(bytes.len());
10212 out.extend_from_slice(&bytes[..20]);
10213 let mut i = 0;
10214 while i < subs.len() {
10215 let (id, start, total) = subs[i];
10216 if id == SMID_SEC_PREFIX {
10217 let postfix_idx = subs[i..]
10218 .iter()
10219 .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
10220 .map(|off| i + off)?;
10221 let (_, q_start, q_total) = subs[postfix_idx];
10222 let sec_wire = &bytes[start..q_start + q_total];
10223 let submsg = gate.decode_kx_datawriter_from(peer_key, sec_wire).ok()?;
10224 out.extend_from_slice(&submsg);
10225 i = postfix_idx + 1;
10226 } else {
10227 out.extend_from_slice(&bytes[start..start + total]);
10228 i += 1;
10229 }
10230 }
10231 Some(out)
10232}
10233
10234/// Protects a peer's volatile outbound datagrams (DATA -> SEC_*).
10235/// HEARTBEAT/ACKNACK datagrams (without DATA) stay unchanged; datagrams
10236/// with a crypto error are dropped.
10237#[cfg(feature = "security")]
10238fn protect_volatile_outbound(
10239 rt: &DcpsRuntime,
10240 remote_prefix: GuidPrefix,
10241 dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>,
10242) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
10243 let peer_key = remote_prefix.to_bytes();
10244 dgs.into_iter()
10245 .filter_map(|dg| {
10246 protect_volatile_datagram(rt, &dg.bytes, &peer_key).map(|bytes| {
10247 zerodds_rtps::message_builder::OutboundDatagram {
10248 bytes,
10249 targets: dg.targets,
10250 }
10251 })
10252 })
10253 .collect()
10254}
10255
10256/// Cross-vendor (send): replaces EVERY submessage sent by the DataWriter (DATA,
10257/// DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP) with the cyclone-conformant
10258/// SEC_PREFIX/BODY/POSTFIX sequence, encrypted with the **local data key**.
10259/// DDS-Security §8.4.2.4 (`is_submessage_protected=TRUE`, DataWriter): ALL
10260/// writer submessages MUST be protected via `encode_datawriter_submessage`
10261/// — in particular the HEARTBEAT, otherwise the remote reader cannot NACK missing
10262/// sequence numbers (no reliable recovery). Framing submessages
10263/// (INFO_TS/INFO_DST/...) stay unchanged; `None` on a crypto error.
10264#[cfg(feature = "security")]
10265fn protect_user_datagram(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10266 let gate = rt.config.security.as_ref()?;
10267 if bytes.len() < 20 {
10268 return Some(bytes.to_vec());
10269 }
10270 let subs = walk_submessages(bytes);
10271 if !subs
10272 .iter()
10273 .any(|(id, _, _)| is_protected_writer_submessage(*id))
10274 {
10275 return Some(bytes.to_vec());
10276 }
10277 // §9.5.3.3 per-endpoint key: all writer submessages of a datagram
10278 // come from the same writer. Take the writer_id from the first protected
10279 // submessage + look up the per-endpoint handle. No handle
10280 // (unregistered endpoint) → participant-key fallback.
10281 let endpoint_handle = subs
10282 .iter()
10283 .find(|(id, _, _)| is_protected_writer_submessage(*id))
10284 .and_then(|&(id, start, total)| writer_eid_in_submessage(&bytes[start..start + total], id))
10285 .and_then(|weid| local_endpoint_crypto_handle(rt, weid, true));
10286 let mut out = Vec::with_capacity(bytes.len() + 64);
10287 out.extend_from_slice(&bytes[..20]);
10288 for (id, start, total) in subs {
10289 let submsg = &bytes[start..start + total];
10290 if is_protected_writer_submessage(id) {
10291 let sec = match endpoint_handle {
10292 Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
10293 None => gate.encode_data_datawriter_local(submsg),
10294 };
10295 match sec {
10296 Ok(s) => out.extend_from_slice(&s),
10297 Err(_) => return None,
10298 }
10299 } else {
10300 out.extend_from_slice(submsg);
10301 }
10302 }
10303 Some(out)
10304}
10305
10306/// Extracts the `writer_id` from an RTPS writer submessage. DATA/DATA_FRAG:
10307/// offset 12 (header(4)+extraFlags(2)+octetsToInlineQos(2)+readerId(4));
10308/// HEARTBEAT/GAP/HEARTBEAT_FRAG: offset 8 (header(4)+readerId(4)).
10309#[cfg(feature = "security")]
10310fn writer_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
10311 let off = match id {
10312 SMID_DATA | SMID_DATA_FRAG => 12,
10313 SMID_HEARTBEAT | SMID_GAP | SMID_HEARTBEAT_FRAG => 8,
10314 _ => return None,
10315 };
10316 let raw: [u8; 4] = submsg.get(off..off + 4)?.try_into().ok()?;
10317 Some(EntityId::from_bytes(raw))
10318}
10319
10320/// Cross-vendor user DATA (recv): decodes the SEC_* sequence with the sender's
10321/// data key (`peer_key` = sender GuidPrefix) back to the DATA submessage.
10322/// `None` if no SEC_* sequence is present (normal path) or on a crypto error.
10323#[cfg(feature = "security")]
10324fn unprotect_user_datagram(rt: &DcpsRuntime, bytes: &[u8], peer_key: &[u8; 12]) -> Option<Vec<u8>> {
10325 let gate = rt.config.security.as_ref()?;
10326 if bytes.len() < 20 {
10327 return None;
10328 }
10329 let subs = walk_submessages(bytes);
10330 // §8.4.2.4: the peer SEC_*-wrapped EVERY writer submessage individually
10331 // (DATA, HEARTBEAT, GAP, ...). So there can be MULTIPLE SEC_PREFIX/BODY/
10332 // POSTFIX triples in the same datagram — transform them all back. `None`
10333 // only if there is no SEC_* sequence at all (normal/plaintext path).
10334 if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
10335 return None;
10336 }
10337 let mut out = Vec::with_capacity(bytes.len());
10338 out.extend_from_slice(&bytes[..20]);
10339 let mut i = 0;
10340 while i < subs.len() {
10341 let (id, start, total) = subs[i];
10342 if id == SMID_SEC_PREFIX {
10343 // Find the matching SEC_POSTFIX from i; the block is [prefix..postfix].
10344 let postfix_idx = subs[i..]
10345 .iter()
10346 .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
10347 .map(|off| i + off)?;
10348 let (_, q_start, q_total) = subs[postfix_idx];
10349 let sec_wire = &bytes[start..q_start + q_total];
10350 // key_id-based decode: the peer has, per endpoint (user +
10351 // secure-builtin discovery), its own key material; the correct
10352 // key is found via the transformation_key_id in the CryptoHeader.
10353 // Fallback for transformation_key_id=0: this is NOT a per-
10354 // endpoint token key, but the participant-level key derived from the
10355 // SharedSecret (DDS-Security Tab.73, AES256-GCM, sender_key_id
10356 // =0) — cyclone protects with it under rtps_protection. That one is decoded by the
10357 // Kx path (peer-prefix-indexed SharedSecret key).
10358 let mut submsg = gate
10359 .decode_data_by_key_id(sec_wire)
10360 .or_else(|_| gate.decode_data_datawriter_from(peer_key, sec_wire))
10361 .or_else(|_| gate.decode_kx_datawriter_from(peer_key, sec_wire))
10362 .ok()?;
10363 // Correct octetsToNextHeader to the real body length: cyclone
10364 // wraps every writer submessage INDIVIDUALLY; within its SEC_BODY
10365 // it is the last one -> octetsToNextHeader=0 ("to the end of the message").
10366 // When concatenating multiple decoded blocks (e.g. DATA + piggybacked
10367 // HEARTBEAT), otn=0 would make the strict decode_datagram swallow the following
10368 // submessage as payload -> the reader would never see the
10369 // HEARTBEAT and would block as a late joiner on the SN gap.
10370 if submsg.len() >= 4 {
10371 let le = submsg[1] & zerodds_rtps::FLAG_E_LITTLE_ENDIAN != 0;
10372 let otn = u16::try_from(submsg.len() - 4).unwrap_or(0);
10373 let b = if le {
10374 otn.to_le_bytes()
10375 } else {
10376 otn.to_be_bytes()
10377 };
10378 submsg[2] = b[0];
10379 submsg[3] = b[1];
10380 }
10381 out.extend_from_slice(&submsg);
10382 i = postfix_idx + 1;
10383 } else {
10384 out.extend_from_slice(&bytes[start..start + total]);
10385 i += 1;
10386 }
10387 }
10388 Some(out)
10389}
10390
10391/// §8.5.1.9.1 / §9.5.3.3.1 data_protection (send): encrypts ONLY the
10392/// SerializedPayload INSIDE each DATA submessage (payload layer). The
10393/// submessage header, octetsToInlineQos, InlineQoS and the flags (E/Q/D/K)
10394/// stay byte-identical; only the N-flag (NonStandardPayload, §8.3.8.2) is
10395/// set and octetsToNextHeader adjusted to the new payload length. This is
10396/// the spec-conformant + cyclone-interop form of data_protection (counterpart:
10397/// metadata_protection = whole submessage SEC_*-wrapped). Applied as the INNER
10398/// layer BEFORE the submessage/message protection. `None` on a
10399/// crypto error (drop instead of leak); a datagram without DATA stays unchanged.
10400#[cfg(feature = "security")]
10401fn protect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10402 use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
10403 use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
10404 let gate = rt.config.security.as_ref()?;
10405 if bytes.len() < 20 {
10406 return Some(bytes.to_vec());
10407 }
10408 let subs = walk_submessages(bytes);
10409 if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
10410 return Some(bytes.to_vec());
10411 }
10412 let mut out = Vec::with_capacity(bytes.len() + 64);
10413 out.extend_from_slice(&bytes[..20]);
10414 for (id, start, total) in subs {
10415 let submsg = &bytes[start..start + total];
10416 if id != SMID_DATA {
10417 out.extend_from_slice(submsg);
10418 continue;
10419 }
10420 let flags = submsg[1];
10421 let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
10422 // data_protection payload key: the **per-endpoint DataWriter key**
10423 // (§9.5.3.3.1). cyclone associates the DataWriter strictly with its
10424 // datawriter_crypto_handle and decodes the SerializedPayload ONLY with
10425 // this key — the participant key yields "Invalid Crypto
10426 // Handle" in cyclone. The key is sent to the peer as a datawriter_crypto_token;
10427 // the reader finds it via the transformation_key_id in the CryptoHeader.
10428 let handle = writer_eid_in_submessage(submsg, id)
10429 .and_then(|w| local_endpoint_crypto_handle(rt, w, true))?;
10430 // Payload boundary: read_body_with_flags returns serialized_payload as
10431 // an Arc of body[pos..] -> payload = the last plen bytes of the submessage.
10432 let body = &submsg[4..];
10433 let ds = DataSubmessage::read_body_with_flags(body, le, flags).ok()?;
10434 let plen = ds.serialized_payload.len();
10435 let payload_off = submsg.len() - plen;
10436 let enc = gate
10437 .encode_serialized_payload(handle, &ds.serialized_payload)
10438 .ok()?;
10439 let new_body_len = (payload_off - 4) + enc.len();
10440 if new_body_len > u16::MAX as usize {
10441 return None;
10442 }
10443 out.push(submsg[0]);
10444 out.push(flags | DATA_FLAG_NON_STANDARD);
10445 let otn = new_body_len as u16;
10446 if le {
10447 out.extend_from_slice(&otn.to_le_bytes());
10448 } else {
10449 out.extend_from_slice(&otn.to_be_bytes());
10450 }
10451 // Body prefix (extraFlags..InlineQoS) verbatim, then encrypted payload.
10452 out.extend_from_slice(&submsg[4..payload_off]);
10453 out.extend_from_slice(&enc);
10454 }
10455 Some(out)
10456}
10457
10458/// Result of the inner payload layer on receipt (§8.5.1.9.4).
10459#[cfg(feature = "security")]
10460enum PayloadDecode {
10461 /// No DATA submessage carries the N-flag — plaintext path, pass the datagram
10462 /// on unchanged.
10463 NotEncrypted,
10464 /// Successfully decrypted — use the plaintext datagram.
10465 Decoded(Vec<u8>),
10466 /// N-flag set, but decryption failed. The datagram MUST
10467 /// be discarded — passing an undecodable encrypted payload as
10468 /// ciphertext gives the reader garbage (§8.5: reject). The
10469 /// reliable re-send catches up on the sample once the key is installed
10470 /// resp. another (e.g. inproc/message-level) copy delivers it.
10471 Failed,
10472}
10473
10474/// `true` if the SerializedPayload begins with a CryptoHeader (§9.5.3.3.1):
10475/// the first 4 bytes are a CryptoTransformKind != NONE
10476/// (AES128_GMAC/GCM, AES256_GMAC/GCM = `[0,0,0,1..=4]`). A plaintext CDR
10477/// encapsulation carries either a different first byte pair (CDR_LE `[0,1]`,
10478/// XCDR2 `[0,6/7]`, PL_CDR `[0,2/3]`) or — for CDR_BE `[0,0]` — options
10479/// `[0,0]`, so it does not collide with the transform kinds 1..=4. Serves as
10480/// detection for vendors (cyclone) that encrypt the data_protection payload
10481/// without setting the N-flag of the DATA submessage.
10482#[cfg(feature = "security")]
10483fn payload_has_crypto_header(payload: &[u8]) -> bool {
10484 matches!(payload, [0, 0, 0, 1..=4, ..])
10485}
10486
10487/// §8.5.1.9.4 / §9.5.3.3.1 data_protection (recv): decrypts the
10488/// SerializedPayload of each DATA submessage whose payload begins with a CryptoHeader
10489/// — recognized by the set N-flag (zero↔zero, [`protect_user_payload`])
10490/// OR by the CryptoTransformKind signature (cyclone does not set the N-flag).
10491/// The tag verification of the GCM open IS the detection: if the decode fails
10492/// and the N-flag was not set, the submessage is passed through as plaintext
10493/// (false positive of the signature heuristic). The key is found via the
10494/// `transformation_key_id` (key_id), the sender prefix (peer slot) or — for
10495/// key_id=0 (participant/Kx key, cyclone) — the Kx key material.
10496/// `NotEncrypted` if no DATA submessage was decrypted; `Failed` only
10497/// on an N-flag decode error (§8.5: reject undecryptable).
10498#[cfg(feature = "security")]
10499fn unprotect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> PayloadDecode {
10500 use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
10501 use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
10502 let Some(gate) = rt.config.security.as_ref() else {
10503 return PayloadDecode::NotEncrypted;
10504 };
10505 if bytes.len() < 20 {
10506 return PayloadDecode::NotEncrypted;
10507 }
10508 // Sender prefix (RTPS header bytes[8..20]) as a fallback key index, if the
10509 // transformation_key_id in the CryptoHeader is not uniquely in the remote index
10510 // (zero↔zero indexed via the peer slot, cyclone strictly via key_id).
10511 let mut peer_key = [0u8; 12];
10512 peer_key.copy_from_slice(&bytes[8..20]);
10513 let subs = walk_submessages(bytes);
10514 let mut out = Vec::with_capacity(bytes.len());
10515 out.extend_from_slice(&bytes[..20]);
10516 let mut did_decode = false;
10517 for (id, start, total) in subs {
10518 let submsg = &bytes[start..start + total];
10519 if id != SMID_DATA {
10520 out.extend_from_slice(submsg);
10521 continue;
10522 }
10523 let flags = submsg[1];
10524 let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
10525 let nflag = flags & DATA_FLAG_NON_STANDARD != 0;
10526 let body = &submsg[4..];
10527 let Ok(ds) = DataSubmessage::read_body_with_flags(body, le, flags) else {
10528 // Parse error of a DATA marked as encrypted -> drop;
10529 // a pure plaintext DATA never made read_body_with_flags fail,
10530 // so a set N-flag is the only reason here.
10531 if nflag {
10532 return PayloadDecode::Failed;
10533 }
10534 out.extend_from_slice(submsg);
10535 continue;
10536 };
10537 // Only attempt when the payload is recognizable as encrypted:
10538 // N-flag (zero↔zero) or CryptoHeader signature (cyclone without an N-flag).
10539 if !nflag && !payload_has_crypto_header(&ds.serialized_payload) {
10540 out.extend_from_slice(submsg);
10541 continue;
10542 }
10543 let plen = ds.serialized_payload.len();
10544 let payload_off = submsg.len() - plen;
10545 let pdec = gate
10546 .decode_serialized_payload(&ds.serialized_payload)
10547 .or_else(|_| gate.decode_serialized_payload_from(&peer_key, &ds.serialized_payload))
10548 .or_else(|_| gate.decode_serialized_payload_kx(&peer_key, &ds.serialized_payload));
10549 let Ok(dec) = pdec else {
10550 // §8.5: if the N-flag was set, the payload is surely encrypted
10551 // and the reader would get garbage -> drop (reliable re-send catches it
10552 // up after key install). If only the signature heuristic was the trigger
10553 // (no N-flag), it is a plaintext CDR_BE payload whose options
10554 // happen to look like a TransformKind -> pass through unchanged.
10555 if nflag {
10556 return PayloadDecode::Failed;
10557 }
10558 out.extend_from_slice(submsg);
10559 continue;
10560 };
10561 let new_body_len = (payload_off - 4) + dec.len();
10562 if new_body_len > u16::MAX as usize {
10563 return PayloadDecode::Failed;
10564 }
10565 out.push(submsg[0]);
10566 out.push(flags & !DATA_FLAG_NON_STANDARD);
10567 let otn = new_body_len as u16;
10568 if le {
10569 out.extend_from_slice(&otn.to_le_bytes());
10570 } else {
10571 out.extend_from_slice(&otn.to_be_bytes());
10572 }
10573 out.extend_from_slice(&submsg[4..payload_off]);
10574 out.extend_from_slice(&dec);
10575 did_decode = true;
10576 }
10577 if did_decode {
10578 PayloadDecode::Decoded(out)
10579 } else {
10580 PayloadDecode::NotEncrypted
10581 }
10582}
10583
10584/// `true` if the EntityId is one of the four secure-SEDP discovery endpoints
10585/// (DCPSPublicationsSecure/DCPSSubscriptionsSecure, EntityIds ff0003c2/c7 +
10586/// ff0004c2/c7). Controls whether a SEDP datagram is protected-discovery traffic
10587/// and must be SEC_*-protected (DDS-Security §8.4.2.4).
10588#[cfg(feature = "security")]
10589fn is_secure_sedp_entity(e: EntityId) -> bool {
10590 e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER
10591 || e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER
10592 || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER
10593 || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER
10594}
10595
10596/// `true` if the datagram carries a submessage to/from a secure-SEDP endpoint
10597/// — then it is protected-discovery traffic.
10598#[cfg(feature = "security")]
10599fn is_secure_sedp_datagram(bytes: &[u8]) -> bool {
10600 let Ok(parsed) = decode_datagram(bytes) else {
10601 return false;
10602 };
10603 parsed.submessages.iter().any(|s| {
10604 let ids = match s {
10605 ParsedSubmessage::Data(d) => [Some(d.writer_id), Some(d.reader_id)],
10606 ParsedSubmessage::DataFrag(d) => [Some(d.writer_id), Some(d.reader_id)],
10607 ParsedSubmessage::Heartbeat(h) => [Some(h.writer_id), Some(h.reader_id)],
10608 ParsedSubmessage::Gap(g) => [Some(g.writer_id), Some(g.reader_id)],
10609 ParsedSubmessage::AckNack(a) => [Some(a.writer_id), Some(a.reader_id)],
10610 ParsedSubmessage::NackFrag(n) => [Some(n.writer_id), Some(n.reader_id)],
10611 _ => [None, None],
10612 };
10613 ids.into_iter().flatten().any(is_secure_sedp_entity)
10614 })
10615}
10616
10617/// Protected discovery (DDS-Security §8.4.2.4) send: secure-SEDP datagrams
10618/// (DATA/HEARTBEAT/GAP of the secure writers) are
10619/// `encode_datawriter_submessage`-protected with the participant data key — the same key the peer installs via
10620/// `participant_crypto_tokens`. Non-secure SEDP goes through unchanged.
10621/// `None` ⟹ crypto error on secure SEDP → drop the datagram instead of a
10622/// plaintext leak.
10623#[cfg(feature = "security")]
10624fn protect_sedp_outbound(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10625 let Some(gate) = rt.config.security.as_ref() else {
10626 return Some(bytes.to_vec());
10627 };
10628 if !is_secure_sedp_datagram(bytes) || bytes.len() < 20 {
10629 return Some(bytes.to_vec());
10630 }
10631 // Governance §8.4.2.4: discovery_protection_kind=NONE -> NO discovery
10632 // protection. Secure-SEDP entities (ff0003c7/ff0004c7) must then NOT
10633 // be per-endpoint-protected; otherwise their ACKNACKs leak as message-
10634 // level SEC_PREFIX with a never-exchanged per-endpoint key that a
10635 // peer (cyclone uses plain SEDP under discovery=NONE) discards as "Invalid Crypto
10636 // Handle". Pass through plain -> the outer rtps_protection
10637 // layer (SRTPS via secure_outbound_bytes) wraps the whole message correctly.
10638 if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
10639 return Some(bytes.to_vec());
10640 }
10641 // §8.4.2.4: protect BOTH directions — writer submessages (DATA/HEARTBEAT/
10642 // GAP) with the per-endpoint writer key (encode_datawriter_submessage), reader
10643 // submessages (ACKNACK/NACK_FRAG) with the per-endpoint reader key
10644 // (encode_datareader_submessage). A spec-conformant cyclone under
10645 // discovery=ENCRYPT discards a CLEAR ACKNACK of the secure-SEDP reader →
10646 // never re-sends the SubscriptionData → ZeroDDS never discovers the reader. The
10647 // per-endpoint key (same as in the sent datareader_crypto_token)
10648 // makes the ACKNACK decodable for cyclone.
10649 let subs = walk_submessages(bytes);
10650 let mut out = Vec::with_capacity(bytes.len() + 64);
10651 out.extend_from_slice(&bytes[..20]);
10652 for (id, start, total) in subs {
10653 let submsg = &bytes[start..start + total];
10654 let handle = if is_protected_writer_submessage(id) {
10655 writer_eid_in_submessage(submsg, id)
10656 .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
10657 } else if is_protected_reader_submessage(id) {
10658 reader_eid_in_submessage(submsg, id)
10659 .and_then(|r| local_endpoint_crypto_handle(rt, r, false))
10660 } else {
10661 // Framing submessage (INFO_TS/INFO_DST/...) — unchanged.
10662 out.extend_from_slice(submsg);
10663 continue;
10664 };
10665 let sec = match handle {
10666 Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
10667 // No per-endpoint handle (should not occur for secure SEDP)
10668 // → participant-key fallback, so no plaintext leak arises.
10669 None => gate.encode_data_datawriter_local(submsg),
10670 };
10671 match sec {
10672 Ok(s) => out.extend_from_slice(&s),
10673 Err(_) => return None,
10674 }
10675 }
10676 Some(out)
10677}
10678
10679/// Protects a user-reader outbound datagram (ACKNACK/NACK_FRAG) on the
10680/// send direction (DDS-Security §8.4.2.4). Counterpart to the writer-DATA layer:
10681/// under `metadata_protection != NONE` the reader submessage too MUST be protected with the
10682/// per-endpoint reader key, otherwise a spec-strict
10683/// peer writer (cyclone/FastDDS) discards the CLEAR ACKNACK → the SN gap is never
10684/// re-sent → permanent reliable stall. Only needed when
10685/// **rtps_protection** does NOT already wrap the message as an SRTPS whole; otherwise
10686/// (and with metadata=NONE) the function delegates to `secure_outbound_bytes`.
10687#[cfg(feature = "security")]
10688fn protect_user_reader_datagram<'a>(
10689 rt: &DcpsRuntime,
10690 bytes: &'a [u8],
10691) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10692 let Some(gate) = rt.config.security.as_ref() else {
10693 return Some(alloc::borrow::Cow::Borrowed(bytes));
10694 };
10695 let metadata = gate.metadata_protection().unwrap_or(ProtectionLevel::None);
10696 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
10697 // rtps != None → SRTPS wraps the whole message incl. ACKNACK; metadata ==
10698 // None → no submessage protection configured. secure_outbound_bytes
10699 // (transform_outbound) covers both cases correctly.
10700 if metadata == ProtectionLevel::None || rtps != ProtectionLevel::None || bytes.len() < 20 {
10701 return secure_outbound_bytes(rt, bytes);
10702 }
10703 let subs = walk_submessages(bytes);
10704 let mut out = Vec::with_capacity(bytes.len() + 64);
10705 out.extend_from_slice(&bytes[..20]);
10706 for (id, start, total) in subs {
10707 let submsg = &bytes[start..start + total];
10708 if is_protected_reader_submessage(id) {
10709 let handle = reader_eid_in_submessage(submsg, id)
10710 .and_then(|r| local_endpoint_crypto_handle(rt, r, false));
10711 match handle {
10712 Some(h) => match gate.encode_data_datawriter_by_handle(h, submsg) {
10713 Ok(s) => out.extend_from_slice(&s),
10714 Err(_) => return None,
10715 },
10716 // No per-endpoint reader key yet (the endpoint matches only after
10717 // secure SEDP) → pass through plaintext; the reader tick re-sends
10718 // the ACKNACK once the key is installed.
10719 None => out.extend_from_slice(submsg),
10720 }
10721 } else {
10722 // Framing submessage (INFO_DST/INFO_TS/...) — unchanged.
10723 out.extend_from_slice(submsg);
10724 }
10725 }
10726 Some(alloc::borrow::Cow::Owned(out))
10727}
10728
10729#[cfg(not(feature = "security"))]
10730fn protect_user_reader_datagram<'a>(
10731 rt: &DcpsRuntime,
10732 bytes: &'a [u8],
10733) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10734 secure_outbound_bytes(rt, bytes)
10735}
10736
10737/// `true` if `liveliness_protection != NONE` is configured — then WLP runs
10738/// over the secure entity + participant-key protection (§8.4.2.4).
10739#[cfg(feature = "security")]
10740fn wlp_liveliness_protected(rt: &DcpsRuntime) -> bool {
10741 rt.config.security.as_ref().is_some_and(|gate| {
10742 gate.liveliness_protection()
10743 .unwrap_or(ProtectionLevel::None)
10744 != ProtectionLevel::None
10745 })
10746}
10747
10748#[cfg(not(feature = "security"))]
10749fn wlp_liveliness_protected(_rt: &DcpsRuntime) -> bool {
10750 false
10751}
10752
10753/// Protects a WLP outbound datagram (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER
10754/// DATA) under `liveliness_protection != NONE` with the **participant data key**
10755/// (§8.4.2.4 / §7.4.7.1 Tab.7). WLP is participant-level (no per-endpoint key)
10756/// — analogous to the participant-key fallback in `protect_sedp_outbound`. If
10757/// `rtps_protection` already covers the message as SRTPS (or liveliness=NONE),
10758/// the function delegates to `secure_outbound_bytes`.
10759#[cfg(feature = "security")]
10760fn protect_wlp_outbound<'a>(
10761 rt: &DcpsRuntime,
10762 bytes: &'a [u8],
10763) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10764 let Some(gate) = rt.config.security.as_ref() else {
10765 return Some(alloc::borrow::Cow::Borrowed(bytes));
10766 };
10767 let live = gate
10768 .liveliness_protection()
10769 .unwrap_or(ProtectionLevel::None);
10770 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
10771 // liveliness=NONE: no inner SEC layer -> secure_outbound_bytes covers
10772 // rtps_protection (SRTPS) resp. passthrough. PREVIOUSLY this branch
10773 // also delegated with rtps!=None and thus left out the liveliness SEC -> cyclone
10774 // saw the WLP DATA "clear submsg from protected src" -> no liveliness.
10775 if live == ProtectionLevel::None || bytes.len() < 20 {
10776 return secure_outbound_bytes(rt, bytes);
10777 }
10778 let subs = walk_submessages(bytes);
10779 let mut out = Vec::with_capacity(bytes.len() + 64);
10780 out.extend_from_slice(&bytes[..20]);
10781 for (id, start, total) in subs {
10782 let submsg = &bytes[start..start + total];
10783 if id == SMID_DATA {
10784 // Protect the secure-WLP DATA with the per-endpoint key of the secure-WLP writer
10785 // (ff0200c2) — the same key ZeroDDS sends the peer via the
10786 // datawriter_crypto_token (prepare_endpoint_crypto_tokens
10787 // liveliness block). encode_data_datawriter_local took the participant
10788 // key, which cyclone does NOT associate with ff0200c2 -> undecodable ->
10789 // no liveliness -> peer approval of the user endpoints hangs.
10790 let sec = writer_eid_in_submessage(submsg, id)
10791 .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
10792 .and_then(|h| gate.encode_data_datawriter_by_handle(h, submsg).ok());
10793 match sec {
10794 Some(s) => out.extend_from_slice(&s),
10795 None => return None,
10796 }
10797 } else {
10798 out.extend_from_slice(submsg);
10799 }
10800 }
10801 // Under additional rtps_protection, message-level SRTPS MUST go around the
10802 // liveliness-SEC-wrapped WLP (both layers, like cyclone<->cyclone) —
10803 // otherwise cyclone would see only the SRTPS shell OR (with the old logic) the
10804 // clear DATA. First inner SEC (above), then SRTPS (here).
10805 if rtps != ProtectionLevel::None {
10806 return gate
10807 .transform_outbound(&out)
10808 .ok()
10809 .map(alloc::borrow::Cow::Owned);
10810 }
10811 Some(alloc::borrow::Cow::Owned(out))
10812}
10813
10814#[cfg(not(feature = "security"))]
10815fn protect_wlp_outbound<'a>(
10816 rt: &DcpsRuntime,
10817 bytes: &'a [u8],
10818) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10819 secure_outbound_bytes(rt, bytes)
10820}
10821
10822/// Wire demux for the security builtin topics. Routes an
10823/// incoming RTPS submessage sequence to the `SecurityBuiltinStack`,
10824/// if the stack is active. No-op if the datagram does not address a security
10825/// builtin reader or the plugin is not enabled.
10826///
10827/// Called by the metatraffic receive path — stateless +
10828/// VolatileSecure run over the SPDP unicast locators (PID 0x0032),
10829/// not over `user_unicast`.
10830fn dispatch_security_builtin_datagram(
10831 rt: &Arc<DcpsRuntime>,
10832 bytes: &[u8],
10833 now: Duration,
10834) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
10835 // `mut` only needed on the security path (the handshake reply is appended
10836 // there); without the feature the list stays empty.
10837 #[cfg(feature = "security")]
10838 let mut outbound = Vec::new();
10839 #[cfg(not(feature = "security"))]
10840 let outbound = Vec::new();
10841 let Some(stack) = rt.security_builtin_snapshot() else {
10842 return outbound;
10843 };
10844 // Cross-vendor VolatileSecure: cyclone protects the volatile DATA as a
10845 // SEC_PREFIX/SEC_BODY/SEC_POSTFIX sequence. Before the submessage parse,
10846 // transform the sequence with the sender's Kx key (GuidPrefix = RTPS header bytes[8..20])
10847 // back to the original DATA submessage. `None` = no SEC_*
10848 // sequence (normal path) resp. crypto error.
10849 #[cfg(feature = "security")]
10850 let unprotected: Option<Vec<u8>> = if bytes.len() >= 20 {
10851 let mut pk = [0u8; 12];
10852 pk.copy_from_slice(&bytes[8..20]);
10853 unprotect_volatile_datagram(rt, bytes, &pk)
10854 } else {
10855 None
10856 };
10857 #[cfg(feature = "security")]
10858 let bytes: &[u8] = unprotected.as_deref().unwrap_or(bytes);
10859 let Ok(parsed) = decode_datagram(bytes) else {
10860 return outbound;
10861 };
10862 // sourceGuidPrefix of the datagram (DDSI-RTPS §8.3.4) — reader demux key for
10863 // the volatile builtin readers. Used in both feature configs.
10864 let remote_prefix = parsed.header.guid_prefix;
10865 let Ok(mut s) = stack.lock() else {
10866 return outbound;
10867 };
10868 for sub in parsed.submessages {
10869 match sub {
10870 ParsedSubmessage::Data(d) => {
10871 if d.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
10872 || d.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
10873 {
10874 // FU2 Gap 5: decode the stateless auth and — with
10875 // an active auth plugin — drive the handshake.
10876 // `on_stateless_message` returns the next token
10877 // message (reply/final), which we send back to the peer.
10878 // Decode errors are swallowed (stateless
10879 // has no resend path, Spec §10.3.4.1). The
10880 // completion `(remote_identity, secret)` is stored in the stack
10881 // (peer_secret) — the gate registration +
10882 // crypto-token exchange follows in Gap 6.
10883 if let Ok(msg) = s.stateless_reader.handle_data(&d) {
10884 #[cfg(feature = "security")]
10885 s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
10886 #[cfg(feature = "security")]
10887 if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, &msg) {
10888 outbound.extend(out);
10889 // FU2 S1.4: handshake done → register Kx +
10890 // send the Kx-encrypted data token to the peer over Volatile-
10891 // Secure. (the pki lock is free here:
10892 // on_stateless_message released it.)
10893 if let Some((remote_identity, secret)) = completed {
10894 if let Some(token_msg) =
10895 prepare_crypto_token(rt, remote_prefix, remote_identity, secret)
10896 {
10897 outbound.extend(protect_volatile_outbound(
10898 rt,
10899 remote_prefix,
10900 s.volatile_writer
10901 .write_with_heartbeat(&token_msg, now)
10902 .unwrap_or_default(),
10903 ));
10904 }
10905 // Step 6b: per-endpoint datawriter/datareader
10906 // tokens (per-token dedup #29: the builtins go out
10907 // here exactly once + are marked).
10908 let already = rt
10909 .endpoint_tokens_sent
10910 .read()
10911 .map(|set| set.clone())
10912 .unwrap_or_default();
10913 let pending = pending_endpoint_tokens(
10914 prepare_endpoint_crypto_tokens(rt, remote_prefix),
10915 &already,
10916 );
10917 for ep_msg in pending {
10918 let key = endpoint_token_key(&ep_msg);
10919 outbound.extend(protect_volatile_outbound(
10920 rt,
10921 remote_prefix,
10922 s.volatile_writer
10923 .write_with_heartbeat(&ep_msg, now)
10924 .unwrap_or_default(),
10925 ));
10926 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10927 set.insert(key);
10928 }
10929 }
10930 }
10931 }
10932 #[cfg(not(feature = "security"))]
10933 let _ = msg;
10934 }
10935 } else if d.reader_id
10936 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10937 {
10938 // FU2 S1.4: VolatileSecure carries the crypto-token
10939 // exchange. Kx-decrypt the received PARTICIPANT_CRYPTO_TOKENS
10940 // message + install the data key in the gate.
10941 if let Ok(_msgs) = s.volatile_reader.handle_data(remote_prefix, &d) {
10942 #[cfg(feature = "security")]
10943 for m in &_msgs {
10944 install_crypto_token(rt, remote_prefix, m);
10945 }
10946 // Step 6b: now (peer ready) send our per-endpoint
10947 // tokens back. Per-token dedup (#29): builtins
10948 // go out early here, the later-matching user-
10949 // endpoint tokens are caught up by the tick path (no per-peer
10950 // guard that blocks them forever).
10951 #[cfg(feature = "security")]
10952 {
10953 let already = rt
10954 .endpoint_tokens_sent
10955 .read()
10956 .map(|set| set.clone())
10957 .unwrap_or_default();
10958 let pending = pending_endpoint_tokens(
10959 prepare_endpoint_crypto_tokens(rt, remote_prefix),
10960 &already,
10961 );
10962 for ep_msg in pending {
10963 let key = endpoint_token_key(&ep_msg);
10964 outbound.extend(protect_volatile_outbound(
10965 rt,
10966 remote_prefix,
10967 s.volatile_writer
10968 .write_with_heartbeat(&ep_msg, now)
10969 .unwrap_or_default(),
10970 ));
10971 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10972 set.insert(key);
10973 }
10974 }
10975 }
10976 // The peer now has our participant crypto token (can
10977 // decode our SRTPS/SEC SEDP): catch up the initially dropped
10978 // SEDP burst once (OpenDDS convergence).
10979 #[cfg(feature = "security")]
10980 rt.re_announce_sedp_to_peer(remote_prefix);
10981 }
10982 }
10983 }
10984 ParsedSubmessage::DataFrag(df) => {
10985 if df.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
10986 || df.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
10987 {
10988 // FU2 cross-vendor: cyclone/FastDDS RTPS-fragment the
10989 // large HandshakeReply/Final (cert + permissions over
10990 // MTU). Reassemble the fragments + drive them through the
10991 // handshake driver like a stateless DATA.
10992 if let Ok(msgs) = s.stateless_reader.handle_data_frag(&df) {
10993 #[cfg(feature = "security")]
10994 s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
10995 #[cfg(feature = "security")]
10996 for msg in &msgs {
10997 if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, msg)
10998 {
10999 outbound.extend(out);
11000 if let Some((remote_identity, secret)) = completed {
11001 if let Some(token_msg) = prepare_crypto_token(
11002 rt,
11003 remote_prefix,
11004 remote_identity,
11005 secret,
11006 ) {
11007 outbound.extend(protect_volatile_outbound(
11008 rt,
11009 remote_prefix,
11010 s.volatile_writer
11011 .write_with_heartbeat(&token_msg, now)
11012 .unwrap_or_default(),
11013 ));
11014 }
11015 let already = rt
11016 .endpoint_tokens_sent
11017 .read()
11018 .map(|set| set.clone())
11019 .unwrap_or_default();
11020 let pending = pending_endpoint_tokens(
11021 prepare_endpoint_crypto_tokens(rt, remote_prefix),
11022 &already,
11023 );
11024 for ep_msg in pending {
11025 let key = endpoint_token_key(&ep_msg);
11026 outbound.extend(protect_volatile_outbound(
11027 rt,
11028 remote_prefix,
11029 s.volatile_writer
11030 .write_with_heartbeat(&ep_msg, now)
11031 .unwrap_or_default(),
11032 ));
11033 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
11034 set.insert(key);
11035 }
11036 }
11037 }
11038 }
11039 }
11040 #[cfg(not(feature = "security"))]
11041 let _ = msgs;
11042 }
11043 } else if df.reader_id
11044 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
11045 {
11046 let _ = s.volatile_reader.handle_data_frag(remote_prefix, &df, now);
11047 }
11048 }
11049 ParsedSubmessage::Heartbeat(h) => {
11050 let to_volatile_reader = h.reader_id
11051 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
11052 || (h.reader_id == EntityId::UNKNOWN
11053 && h.writer_id
11054 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER);
11055 if to_volatile_reader {
11056 s.volatile_reader.handle_heartbeat(remote_prefix, &h, now);
11057 }
11058 }
11059 ParsedSubmessage::Gap(g) => {
11060 if g.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
11061 let _ = s.volatile_reader.handle_gap(remote_prefix, &g);
11062 }
11063 }
11064 ParsedSubmessage::AckNack(ack) => {
11065 if ack.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
11066 let base = ack.reader_sn_state.bitmap_base;
11067 let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
11068 let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
11069 s.volatile_writer.handle_acknack(src, base, requested);
11070 }
11071 }
11072 ParsedSubmessage::NackFrag(nf) => {
11073 if nf.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
11074 let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
11075 s.volatile_writer.handle_nackfrag(src, &nf);
11076 }
11077 }
11078 _ => {}
11079 }
11080 }
11081 outbound
11082}
11083
11084/// Dispatches a datagram addressed to the TypeLookup service endpoints
11085/// (XTypes 1.3 §7.6.3.3.4). Handles incoming
11086/// requests (to `TL_SVC_REQ_READER`), generates replies and sends
11087/// them back to the source locator; handles incoming replies
11088/// (to `TL_SVC_REPLY_READER`), correlates with the client.
11089///
11090/// Returns `true` if the datagram was accepted by the TypeLookup path
11091/// — the caller can then skip the user-reader path.
11092fn dispatch_type_lookup_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], source: &Locator) -> bool {
11093 use zerodds_cdr::{BufferReader, Endianness};
11094 use zerodds_rtps::inline_qos::{SampleIdentityBytes, find_related_sample_identity};
11095 use zerodds_types::type_lookup::{
11096 GetTypeDependenciesReply, GetTypeDependenciesRequest, GetTypesReply, GetTypesRequest,
11097 };
11098
11099 let Ok(parsed) = decode_datagram(bytes) else {
11100 return false;
11101 };
11102 // DDS-RPC §7.8.2: the request sample identity = (request writer GUID,
11103 // request SN). The server carries it as PID_RELATED_SAMPLE_IDENTITY in the
11104 // reply inline QoS, so a client (also cross-vendor) can correlate
11105 // without relying on the echoed writer_sn.
11106 let src_prefix = parsed.header.guid_prefix;
11107
11108 let mut accepted = false;
11109
11110 for sub in &parsed.submessages {
11111 let ParsedSubmessage::Data(d) = sub else {
11112 continue;
11113 };
11114 let payload: &[u8] = &d.serialized_payload;
11115 if payload.is_empty() {
11116 continue;
11117 }
11118 // Skip CDR-Encapsulation header (4 bytes) if present.
11119 let body: &[u8] = if payload.len() >= 4 && (payload[0] == 0x00 && payload[1] == 0x01) {
11120 &payload[4..]
11121 } else {
11122 payload
11123 };
11124
11125 // Inbound Request → Server.
11126 if d.reader_id == EntityId::TL_SVC_REQ_READER {
11127 accepted = true;
11128 // Request sample identity = (request writer GUID, request SN) — mirrored
11129 // as related_sample_identity into the reply inline QoS.
11130 let (sn_hi, sn_lo) = d.writer_sn.split();
11131 let req_sn = ((u64::from(sn_hi as u32)) << 32) | u64::from(sn_lo);
11132 let related =
11133 SampleIdentityBytes::new(Guid::new(src_prefix, d.writer_id).to_bytes(), req_sn);
11134 // Try GetTypes-Request first; fall back to
11135 // GetTypeDependenciesRequest if that fails.
11136 let mut r = BufferReader::new(body, Endianness::Little);
11137 if let Ok(req) = GetTypesRequest::decode_from(&mut r) {
11138 let reply = match rt.type_lookup_server.lock() {
11139 Ok(g) => g.handle_get_types(&req),
11140 Err(_) => continue,
11141 };
11142 let _ = send_type_lookup_reply(
11143 rt,
11144 source,
11145 TypeLookupReplyPayload::Types(reply),
11146 related,
11147 );
11148 continue;
11149 }
11150 let mut r = BufferReader::new(body, Endianness::Little);
11151 if let Ok(req) = GetTypeDependenciesRequest::decode_from(&mut r) {
11152 let reply = match rt.type_lookup_server.lock() {
11153 Ok(g) => g.handle_get_type_dependencies(&req),
11154 Err(_) => continue,
11155 };
11156 let _ = send_type_lookup_reply(
11157 rt,
11158 source,
11159 TypeLookupReplyPayload::Dependencies(reply),
11160 related,
11161 );
11162 continue;
11163 }
11164 }
11165
11166 // Inbound Reply → Client.
11167 if d.reader_id == EntityId::TL_SVC_REPLY_READER {
11168 accepted = true;
11169 // Correlation prefers PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2,
11170 // cross-vendor compatible); fallback to the echoed writer_sn for
11171 // peers/legacy replies without inline QoS.
11172 let request_id = d
11173 .inline_qos
11174 .as_ref()
11175 .and_then(|pl| find_related_sample_identity(pl, true).ok().flatten())
11176 .map(|sid| zerodds_discovery::type_lookup::RequestId::from_u64(sid.sequence_number))
11177 .unwrap_or_else(|| {
11178 let (sn_high, sn_low) = d.writer_sn.split();
11179 let sn_u64 = ((u64::from(sn_high as u32)) << 32) | u64::from(sn_low);
11180 zerodds_discovery::type_lookup::RequestId::from_u64(sn_u64)
11181 });
11182 let mut r = BufferReader::new(body, Endianness::Little);
11183 if let Ok(reply) = GetTypesReply::decode_from(&mut r) {
11184 if let Ok(mut client) = rt.type_lookup_client.lock() {
11185 client.handle_reply(request_id, TypeLookupReply::Types(reply));
11186 }
11187 continue;
11188 }
11189 // M-5: the getTypeDependencies reply carries a different element type
11190 // (TypeIdentifierWithSize list) — its own decode branch, otherwise the
11191 // dependencies callback never fires.
11192 let mut r = BufferReader::new(body, Endianness::Little);
11193 if let Ok(reply) = GetTypeDependenciesReply::decode_from(&mut r) {
11194 if let Ok(mut client) = rt.type_lookup_client.lock() {
11195 client.handle_reply(request_id, TypeLookupReply::Dependencies(reply));
11196 }
11197 continue;
11198 }
11199 }
11200 }
11201
11202 accepted
11203}
11204
11205/// Reply payload variants that the TypeLookup server can emit.
11206enum TypeLookupReplyPayload {
11207 Types(zerodds_types::type_lookup::GetTypesReply),
11208 Dependencies(zerodds_types::type_lookup::GetTypeDependenciesReply),
11209}
11210
11211/// Sends a TypeLookup reply to a peer locator as a
11212/// DATA datagram on the TL_SVC_REPLY_WRITER → peer's
11213/// TL_SVC_REPLY_READER. The sequence number echoes the request sequence
11214/// for correlation purposes (see XTypes §7.6.3.3.3 sample identity).
11215fn send_type_lookup_reply(
11216 rt: &Arc<DcpsRuntime>,
11217 target: &Locator,
11218 reply: TypeLookupReplyPayload,
11219 related: zerodds_rtps::inline_qos::SampleIdentityBytes,
11220) -> Result<()> {
11221 use alloc::sync::Arc as AllocArc;
11222 use core::sync::atomic::Ordering;
11223 use zerodds_cdr::{BufferWriter, Endianness};
11224 use zerodds_rtps::datagram::encode_data_datagram;
11225 use zerodds_rtps::header::RtpsHeader;
11226 use zerodds_rtps::submessages::DataSubmessage;
11227 use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber, VendorId};
11228
11229 // CDR-encode reply (PL_CDR_LE-Encapsulation).
11230 let mut w = BufferWriter::new(Endianness::Little);
11231 match reply {
11232 TypeLookupReplyPayload::Types(r) => {
11233 r.encode_into(&mut w)
11234 .map_err(|_| DdsError::PreconditionNotMet {
11235 reason: "type_lookup reply encode failed",
11236 })?;
11237 }
11238 TypeLookupReplyPayload::Dependencies(r) => {
11239 r.encode_into(&mut w)
11240 .map_err(|_| DdsError::PreconditionNotMet {
11241 reason: "type_lookup deps reply encode failed",
11242 })?;
11243 }
11244 }
11245 let body = w.into_bytes();
11246 let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
11247 payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
11248 payload.extend_from_slice(&body);
11249
11250 let header = RtpsHeader {
11251 protocol_version: ProtocolVersion::CURRENT,
11252 vendor_id: VendorId::ZERODDS,
11253 guid_prefix: rt.guid_prefix,
11254 };
11255 // Own monotonically increasing reply-writer SN (starting at 1) instead of a
11256 // request-SN echo — a reliable cross-vendor reply reader would otherwise see SN jumps.
11257 let reply_sn = rt
11258 .tl_reply_sn
11259 .fetch_add(1, Ordering::Relaxed)
11260 .wrapping_add(1);
11261 let writer_sn =
11262 SequenceNumber::from_high_low((reply_sn >> 32) as i32, (reply_sn & 0xFFFF_FFFF) as u32);
11263 let data = DataSubmessage {
11264 extra_flags: 0,
11265 reader_id: EntityId::TL_SVC_REPLY_READER,
11266 writer_id: EntityId::TL_SVC_REPLY_WRITER,
11267 writer_sn,
11268 // DDS-RPC §7.8.2: related_sample_identity couples the reply to the
11269 // request (cross-vendor correlation without a writer_sn echo).
11270 inline_qos: Some(zerodds_rtps::inline_qos::reply_inline_qos(related, true)),
11271 key_flag: false,
11272 non_standard_flag: false,
11273 serialized_payload: AllocArc::from(payload.into_boxed_slice()),
11274 };
11275 let datagram =
11276 encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
11277 reason: "type_lookup reply datagram encode failed",
11278 })?;
11279
11280 if is_routable_user_locator(target) {
11281 let _ = rt.user_unicast.send(target, &datagram);
11282 }
11283 Ok(())
11284}
11285
11286/// Sends a discovery datagram to all target locators. UDP-only
11287/// (TCPv4/SHM/UDS are not carried in discovery); non-UDP
11288/// locators are silently ignored.
11289fn send_discovery_datagram(rt: &Arc<DcpsRuntime>, targets: &[Locator], bytes: &[u8]) {
11290 let Some(secured) = secure_outbound_bytes(rt, bytes) else {
11291 return;
11292 };
11293 for t in targets {
11294 if !is_routable_user_locator(t) {
11295 continue;
11296 }
11297 // Send unicast metatraffic (SEDP responses, VolatileSecure, stateless auth)
11298 // from the **metatraffic recv socket** (`spdp_unicast`, = announced
11299 // metatraffic_unicast_locator), NOT from the ephemeral `spdp_mc_tx`.
11300 // Otherwise the peer sees a foreign source port and sends its
11301 // responses (e.g. cyclone's VolatileSecure ACKNACK to the source locator)
11302 // to a port ZeroDDS does not listen on → reliable resends stay
11303 // out (cross-vendor). `spdp_mc_tx` stays only for SPDP multicast.
11304 let _ = rt.spdp_unicast.send(t, &secured);
11305 }
11306}
11307
11308/// Default user-multicast locator for a DomainParticipant.
11309/// Not used in live mode 1 yet; SPDP-announced in B2.
11310#[must_use]
11311pub fn user_multicast_endpoint(domain_id: i32) -> SocketAddr {
11312 // Spec §9.6.1.4.1: user-multicast-port = PB + DG * d + d2
11313 // = 7400 + 250 * d + 1
11314 let port = 7400u16.saturating_add(250u16.saturating_mul(domain_id as u16).saturating_add(1));
11315 SocketAddr::from((Ipv4Addr::from([239, 255, 0, 1]), port))
11316}
11317
11318#[cfg(test)]
11319#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
11320mod tests {
11321 use super::*;
11322
11323 /// A's the last mile of the big-endian decode path: a big-endian
11324 /// encapsulation header on a received DATA sample must route the typed
11325 /// decode to `DdsType::decode_be`, not `decode`. Builds a CDR2_BE wire
11326 /// sample, runs it through `delivered_to_user_sample` (the real recv→sample
11327 /// conversion), and asserts the resulting `big_endian` flag drives a correct
11328 /// decode — while the little-endian `decode` on the same BE body is wrong.
11329 #[test]
11330 fn big_endian_encap_routes_to_decode_be() {
11331 use crate::dds_type::{DdsType, DecodeError, EncodeError};
11332 #[derive(Debug, PartialEq, Clone)]
11333 struct BeProbe {
11334 v: i32,
11335 }
11336 impl DdsType for BeProbe {
11337 const TYPE_NAME: &'static str = "BeProbe";
11338 fn encode(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
11339 let mut w = zerodds_cdr::BufferWriter::new(zerodds_cdr::Endianness::Little).xcdr2();
11340 <i32 as zerodds_cdr::CdrEncode>::encode(&self.v, &mut w)?;
11341 out.extend_from_slice(&w.into_bytes());
11342 Ok(())
11343 }
11344 fn encode_be(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
11345 let mut w = zerodds_cdr::BufferWriter::new(zerodds_cdr::Endianness::Big).xcdr2();
11346 <i32 as zerodds_cdr::CdrEncode>::encode(&self.v, &mut w)?;
11347 out.extend_from_slice(&w.into_bytes());
11348 Ok(())
11349 }
11350 fn decode(b: &[u8]) -> core::result::Result<Self, DecodeError> {
11351 let mut r =
11352 zerodds_cdr::BufferReader::new(b, zerodds_cdr::Endianness::Little).xcdr2();
11353 Ok(BeProbe {
11354 v: <i32 as zerodds_cdr::CdrDecode>::decode(&mut r)?,
11355 })
11356 }
11357 fn decode_be(b: &[u8]) -> core::result::Result<Self, DecodeError> {
11358 let mut r = zerodds_cdr::BufferReader::new(b, zerodds_cdr::Endianness::Big).xcdr2();
11359 Ok(BeProbe {
11360 v: <i32 as zerodds_cdr::CdrDecode>::decode(&mut r)?,
11361 })
11362 }
11363 }
11364
11365 // A value whose 4 LE bytes differ from its 4 BE bytes.
11366 let orig = BeProbe { v: 0x0102_0304 };
11367 let strengths = alloc::collections::BTreeMap::new();
11368
11369 let mk = |repr_lo: u8, body: Vec<u8>| {
11370 let mut wire = alloc::vec![0x00u8, repr_lo, 0x00, 0x00];
11371 wire.extend_from_slice(&body);
11372 zerodds_rtps::reliable_reader::DeliveredSample {
11373 writer_guid: Guid::new(GuidPrefix::from_bytes([0x11; 12]), EntityId::PARTICIPANT),
11374 sequence_number: zerodds_rtps::wire_types::SequenceNumber(1),
11375 payload: alloc::sync::Arc::from(wire.into_boxed_slice()),
11376 kind: zerodds_rtps::history_cache::ChangeKind::Alive,
11377 key_hash: None,
11378 source_timestamp: None,
11379 }
11380 };
11381
11382 // --- big-endian wire: CDR2_BE (repr low byte 0x06) ---
11383 let mut be_body = Vec::new();
11384 orig.encode_be(&mut be_body).unwrap();
11385 let us = delivered_to_user_sample(&mk(0x06, be_body), &strengths).expect("alive");
11386 let UserSample::Alive {
11387 payload,
11388 big_endian,
11389 representation,
11390 ..
11391 } = us
11392 else {
11393 panic!("expected Alive");
11394 };
11395 assert!(big_endian, "CDR2_BE encap must set big_endian");
11396 assert_eq!(representation, 1, "0x06 = XCDR2");
11397 // The subscriber dispatch: decode_be for a big-endian sample.
11398 let decoded = if big_endian {
11399 BeProbe::decode_be(&payload)
11400 } else {
11401 BeProbe::decode(&payload)
11402 }
11403 .unwrap();
11404 assert_eq!(decoded, orig, "BE wire decodes correctly via decode_be");
11405 // The dispatch matters: little-endian decode on the BE body is wrong.
11406 assert_ne!(BeProbe::decode(&payload).unwrap(), orig);
11407
11408 // --- little-endian control: CDR2_LE (repr low byte 0x07) ---
11409 let mut le_body = Vec::new();
11410 orig.encode(&mut le_body).unwrap();
11411 let us_le = delivered_to_user_sample(&mk(0x07, le_body), &strengths).expect("alive");
11412 let UserSample::Alive {
11413 payload: le_payload,
11414 big_endian: be_le,
11415 ..
11416 } = us_le
11417 else {
11418 panic!("expected Alive");
11419 };
11420 assert!(!be_le, "CDR2_LE encap must NOT set big_endian");
11421 assert_eq!(BeProbe::decode(&le_payload).unwrap(), orig);
11422 }
11423
11424 /// FU1 diagnosis: inject a REAL FastDDS-3.6 SPDP datagram (domain 205,
11425 /// codepit capture 2026-05-29) directly into handle_spdp_datagram
11426 /// — does the runtime register FastDDS as a peer? Separates the
11427 /// receive problem (socket) from the handle problem (parse/insert/filter).
11428 #[test]
11429 fn handle_spdp_registers_real_fastdds_participant() {
11430 fn hx(s: &str) -> Vec<u8> {
11431 (0..s.len())
11432 .step_by(2)
11433 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
11434 .collect()
11435 }
11436 const FASTDDS_SPDP: &str = "525450530203010f010fa72bbbebc90100000000090108006850196a57e882b41505b80100001000000100c7000100c2000000000100000000030000150004000203000016000400010f000000800400030601000f000400cd00000050001000010fa72bbbebc90100000000000001c107800400010000000380280021000000653830653630353335646339343432636231306537323239313038653135666600000000320018000100000024e50000000000000000000000000000c0a8b273310018000100000025e50000000000000000000000000000c0a8b273020008001400000000000000580004003ffc0f006200140010000000525450535061727469636970616e74005900cc0004000000110000005041525449434950414e545f54595045000000000700000053494d504c4500001b000000666173746464732e706879736963616c5f646174612e686f73740000210000006538306536303533356463393434326362313065373232393130386531356666000000001b000000666173746464732e706879736963616c5f646174612e75736572000005000000726f6f74000000001e000000666173746464732e706879736963616c5f646174612e70726f636573730000000600000036303334370000000100000080013800010000001ae50000000000000000000000000000efff00016850196a72ca8cb4020000000000000030040000000000000000000000000000";
11437 let bytes = hx(FASTDDS_SPDP);
11438 let prefix = GuidPrefix::from_bytes([0x99; 12]);
11439 let rt =
11440 Arc::new(DcpsRuntime::start(205, prefix, RuntimeConfig::default()).expect("rt start"));
11441 assert_eq!(rt.discovered_participants().len(), 0, "fresh: no peers");
11442 handle_spdp_datagram_for_test(&rt, &bytes);
11443 let n = rt.discovered_participants().len();
11444 assert_eq!(
11445 n, 1,
11446 "FastDDS must be registered after handle_spdp_datagram (got {n})"
11447 );
11448 }
11449
11450 #[test]
11451 fn select_user_transport_tcpv4_yields_tcpv4_locator() {
11452 let prefix = GuidPrefix::from_bytes([1u8; 12]);
11453 let (t, accept) =
11454 select_user_transport(UserTransportKind::TcpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
11455 .expect("TcpV4 transport");
11456 assert_eq!(t.local_locator().kind, LocatorKind::Tcpv4);
11457 assert!(accept.is_some(), "TCP needs an accept handle");
11458 }
11459
11460 #[test]
11461 fn select_user_transport_udpv4_default_kind() {
11462 let prefix = GuidPrefix::from_bytes([2u8; 12]);
11463 let (t, accept) =
11464 select_user_transport(UserTransportKind::UdpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
11465 .expect("UdpV4 transport");
11466 assert_eq!(t.local_locator().kind, LocatorKind::UdpV4);
11467 assert!(accept.is_none(), "UDP needs no accept handle");
11468 }
11469
11470 #[cfg(feature = "same-host-uds")]
11471 #[test]
11472 fn select_user_transport_uds_yields_uds_locator() {
11473 let prefix = GuidPrefix::from_bytes([3u8; 12]);
11474 let (t, accept) =
11475 select_user_transport(UserTransportKind::Uds, prefix, 0, Ipv4Addr::UNSPECIFIED)
11476 .expect("Uds transport");
11477 assert_eq!(t.local_locator().kind, LocatorKind::Uds);
11478 assert!(accept.is_none(), "UDS needs no accept handle");
11479 }
11480
11481 #[test]
11482 fn strip_user_encap_xcdr2_le() {
11483 let payload = [0x00, 0x07, 0x00, 0x00, 1, 2, 3];
11484 assert_eq!(strip_user_encap(&payload), Some(alloc::vec![1, 2, 3]));
11485 }
11486
11487 #[test]
11488 fn strip_user_encap_xcdr1_le() {
11489 // Cyclone default for simple types.
11490 let payload = [0x00, 0x01, 0x00, 0x00, 0xAA];
11491 assert_eq!(strip_user_encap(&payload), Some(alloc::vec![0xAA]));
11492 }
11493
11494 #[test]
11495 fn strip_user_encap_rejects_unknown_scheme() {
11496 let payload = [0xFF, 0xFF, 0x00, 0x00, 1];
11497 assert_eq!(strip_user_encap(&payload), None);
11498 }
11499
11500 #[test]
11501 fn strip_user_encap_rejects_short() {
11502 assert_eq!(strip_user_encap(&[0x00, 0x07]), None);
11503 }
11504
11505 #[test]
11506 fn user_payload_encap_is_cdr_le() {
11507 // CDR_LE (PLAIN_CDR / XCDR1, Little-Endian) — ehrliche
11508 // Declaration of the body encoding generated by codegen.
11509 assert_eq!(USER_PAYLOAD_ENCAP, [0x00, 0x01, 0x00, 0x00]);
11510 }
11511
11512 #[test]
11513 fn data_repr_offer_str_uses_spec_ids() {
11514 use zerodds_rtps::publication_data::data_representation as dr;
11515 // XCDR1 -> Spec-Id 0 (NICHT 1 = XML); XCDR2 -> 2.
11516 assert_eq!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XCDR]));
11517 assert_eq!(parse_data_repr_offer_str("XCDR2"), Some(vec![dr::XCDR2]));
11518 assert_eq!(parse_data_repr_offer_str("xcdr2"), Some(vec![dr::XCDR2]));
11519 assert_eq!(
11520 parse_data_repr_offer_str("XCDR2,XCDR1"),
11521 Some(vec![dr::XCDR2, dr::XCDR])
11522 );
11523 assert_eq!(parse_data_repr_offer_str("bogus"), None);
11524 assert_eq!(parse_data_repr_offer_str(""), None);
11525 // XCDR1 must NOT map to the XML id (1).
11526 assert_ne!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XML]));
11527 }
11528
11529 /// A DataReader announces every representation it can decode (XCDR2 + XCDR1)
11530 /// — XTypes 1.3 §7.6.2: the default reader policy accepts both. CycloneDDS
11531 /// (and legacy RTI / OpenDDS < 3.16) default their writers to XCDR1 for
11532 /// `@final` types; without XCDR1 in the reader's announced set those writers
11533 /// fail the DataRepresentation RxO check and never deliver. Regression for
11534 /// Bug DR1.
11535 #[test]
11536 fn reader_accept_repr_always_includes_both_representations() {
11537 use zerodds_rtps::publication_data::data_representation as dr;
11538 // Default writer offer [XCDR2] -> reader must also accept XCDR1.
11539 let widened = reader_accept_repr(&[dr::XCDR2]);
11540 assert!(widened.contains(&dr::XCDR2));
11541 assert!(widened.contains(&dr::XCDR));
11542 // XCDR2 stays first (the preferred / generated encoding).
11543 assert_eq!(widened[0], dr::XCDR2);
11544 // Already-both list is preserved (idempotent, order kept).
11545 assert_eq!(
11546 reader_accept_repr(&[dr::XCDR, dr::XCDR2]),
11547 alloc::vec![dr::XCDR, dr::XCDR2]
11548 );
11549 // Empty config still yields both.
11550 let from_empty = reader_accept_repr(&[]);
11551 assert!(from_empty.contains(&dr::XCDR2) && from_empty.contains(&dr::XCDR));
11552 }
11553
11554 #[test]
11555 fn user_payload_encap_maps_repr_and_extensibility() {
11556 use zerodds_rtps::publication_data::data_representation as dr;
11557 use zerodds_types::qos::ExtensibilityForRepr as Ext;
11558 // DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 Encapsulation-IDs
11559 // (2-byte repr-id BE + 2-byte options=0), little-endian variant:
11560 // XCDR1 final/appendable -> CDR_LE 0x0001
11561 // XCDR1 mutable -> PL_CDR_LE 0x0003
11562 // XCDR2 final -> PLAIN_CDR2_LE 0x0007
11563 // XCDR2 appendable -> D_CDR2_LE 0x0009
11564 // XCDR2 mutable -> PL_CDR2_LE 0x000b
11565 assert_eq!(
11566 user_payload_encap(dr::XCDR, Ext::Final, false),
11567 [0x00, 0x01, 0x00, 0x00]
11568 );
11569 assert_eq!(
11570 user_payload_encap(dr::XCDR, Ext::Appendable, false),
11571 [0x00, 0x01, 0x00, 0x00]
11572 );
11573 assert_eq!(
11574 user_payload_encap(dr::XCDR, Ext::Mutable, false),
11575 [0x00, 0x03, 0x00, 0x00]
11576 );
11577 assert_eq!(
11578 user_payload_encap(dr::XCDR2, Ext::Final, false),
11579 [0x00, 0x07, 0x00, 0x00]
11580 );
11581 assert_eq!(
11582 user_payload_encap(dr::XCDR2, Ext::Appendable, false),
11583 [0x00, 0x09, 0x00, 0x00]
11584 );
11585 assert_eq!(
11586 user_payload_encap(dr::XCDR2, Ext::Mutable, false),
11587 [0x00, 0x0b, 0x00, 0x00]
11588 );
11589 // The default const is exactly the (XCDR1, Final) case.
11590 assert_eq!(
11591 user_payload_encap(dr::XCDR, Ext::Final, false),
11592 USER_PAYLOAD_ENCAP
11593 );
11594 // Unknown/XML repr falls back safely to CDR_LE.
11595 assert_eq!(
11596 user_payload_encap(dr::XML, Ext::Final, false),
11597 [0x00, 0x01, 0x00, 0x00]
11598 );
11599 // big_endian=true selects the `_BE` variant (the even predecessor of
11600 // the odd `_LE` id): CDR_BE 0x00, PL_CDR_BE 0x02, PLAIN_CDR2_BE 0x06,
11601 // D_CDR2_BE 0x08, PL_CDR2_BE 0x0a (RTPS 2.5 §10.5). Used by the
11602 // durability service to replay a big-endian peer's stored sample.
11603 assert_eq!(
11604 user_payload_encap(dr::XCDR, Ext::Final, true),
11605 [0x00, 0x00, 0x00, 0x00]
11606 );
11607 assert_eq!(
11608 user_payload_encap(dr::XCDR, Ext::Mutable, true),
11609 [0x00, 0x02, 0x00, 0x00]
11610 );
11611 assert_eq!(
11612 user_payload_encap(dr::XCDR2, Ext::Final, true),
11613 [0x00, 0x06, 0x00, 0x00]
11614 );
11615 assert_eq!(
11616 user_payload_encap(dr::XCDR2, Ext::Appendable, true),
11617 [0x00, 0x08, 0x00, 0x00]
11618 );
11619 assert_eq!(
11620 user_payload_encap(dr::XCDR2, Ext::Mutable, true),
11621 [0x00, 0x0a, 0x00, 0x00]
11622 );
11623 }
11624
11625 #[test]
11626 fn observability_sink_records_writer_and_reader_creation() {
11627 // VecSink injizieren, Writer + Reader erzeugen,
11628 // check that both events arrive.
11629 use std::sync::Arc as StdArc;
11630 use zerodds_foundation::observability::{Component, Level, VecSink};
11631
11632 let sink = StdArc::new(VecSink::new());
11633 let cfg = RuntimeConfig {
11634 observability: sink.clone(),
11635 ..RuntimeConfig::default()
11636 };
11637 let rt =
11638 DcpsRuntime::start(7, GuidPrefix::from_bytes([0xAA; 12]), cfg).expect("start runtime");
11639 let _ = rt.register_user_writer(UserWriterConfig {
11640 topic_name: "ObsTopic".into(),
11641 type_name: "ObsType".into(),
11642 reliable: true,
11643 durability: zerodds_qos::DurabilityKind::Volatile,
11644 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11645 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11646 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11647 ownership: zerodds_qos::OwnershipKind::Shared,
11648 ownership_strength: 0,
11649 partition: alloc::vec![],
11650 user_data: alloc::vec![],
11651 topic_data: alloc::vec![],
11652 group_data: alloc::vec![],
11653 type_identifier: zerodds_types::TypeIdentifier::None,
11654 data_representation_offer: None,
11655 });
11656 let _ = rt.register_user_reader(UserReaderConfig {
11657 topic_name: "ObsTopic".into(),
11658 type_name: "ObsType".into(),
11659 reliable: true,
11660 durability: zerodds_qos::DurabilityKind::Volatile,
11661 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11662 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11663 ownership: zerodds_qos::OwnershipKind::Shared,
11664 partition: alloc::vec![],
11665 user_data: alloc::vec![],
11666 topic_data: alloc::vec![],
11667 group_data: alloc::vec![],
11668 type_identifier: zerodds_types::TypeIdentifier::None,
11669 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11670 data_representation_offer: None,
11671 });
11672 rt.shutdown();
11673
11674 let events = sink.snapshot();
11675 assert!(
11676 events.iter().any(|e| e.name == "user_writer.created"
11677 && e.component == Component::Dcps
11678 && e.level == Level::Info),
11679 "writer-event missing: got {:?}",
11680 events.iter().map(|e| e.name).collect::<Vec<_>>()
11681 );
11682 assert!(
11683 events
11684 .iter()
11685 .any(|e| e.name == "user_reader.created" && e.component == Component::Dcps),
11686 "reader-event missing"
11687 );
11688 // The topic attribute must hang on the writer.created event.
11689 let writer_event = events
11690 .iter()
11691 .find(|e| e.name == "user_writer.created")
11692 .expect("writer event");
11693 assert!(
11694 writer_event
11695 .attrs
11696 .iter()
11697 .any(|a| a.key == "topic" && a.value == "ObsTopic"),
11698 "topic attr missing"
11699 );
11700 }
11701
11702 #[test]
11703 fn user_endpoint_entity_kind_follows_keyedness() {
11704 // Regression (ROS-2 cross-vendor): the entityKind of a user
11705 // endpoint MUST follow the type keyedness (Spec §9.3.1.2). A
11706 // a keyless type yields NoKey (Writer 0x03 / Reader 0x04), a
11707 // keyed type WithKey (0x02 / 0x07). If this does not match the
11708 // peer, CycloneDDS/ROS 2 silently rejects the endpoint match
11709 // (DDS_INVALID_QOS_POLICY_ID, no log). create_datawriter/
11710 // create_datareader derive `is_keyed` from `DdsType::HAS_KEY`.
11711 use zerodds_rtps::wire_types::EntityKind;
11712 let rt = DcpsRuntime::start(
11713 11,
11714 GuidPrefix::from_bytes([0xBC; 12]),
11715 RuntimeConfig::default(),
11716 )
11717 .expect("start runtime");
11718 let mk_w = || UserWriterConfig {
11719 topic_name: "KindTopic".into(),
11720 type_name: "KindType".into(),
11721 reliable: true,
11722 durability: zerodds_qos::DurabilityKind::Volatile,
11723 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11724 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11725 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11726 ownership: zerodds_qos::OwnershipKind::Shared,
11727 ownership_strength: 0,
11728 partition: alloc::vec![],
11729 user_data: alloc::vec![],
11730 topic_data: alloc::vec![],
11731 group_data: alloc::vec![],
11732 type_identifier: zerodds_types::TypeIdentifier::None,
11733 data_representation_offer: None,
11734 };
11735 let mk_r = || UserReaderConfig {
11736 topic_name: "KindTopic".into(),
11737 type_name: "KindType".into(),
11738 reliable: true,
11739 durability: zerodds_qos::DurabilityKind::Volatile,
11740 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11741 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11742 ownership: zerodds_qos::OwnershipKind::Shared,
11743 partition: alloc::vec![],
11744 user_data: alloc::vec![],
11745 topic_data: alloc::vec![],
11746 group_data: alloc::vec![],
11747 type_identifier: zerodds_types::TypeIdentifier::None,
11748 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11749 data_representation_offer: None,
11750 };
11751 // keyless (HAS_KEY=false) -> NoKey
11752 let w_nokey = rt.register_user_writer_kind(mk_w(), false).expect("writer");
11753 assert_eq!(w_nokey.entity_kind, EntityKind::UserWriterNoKey);
11754 let (r_nokey, _) = rt.register_user_reader_kind(mk_r(), false).expect("reader");
11755 assert_eq!(r_nokey.entity_kind, EntityKind::UserReaderNoKey);
11756 // keyed (HAS_KEY=true) -> WithKey
11757 let w_key = rt.register_user_writer_kind(mk_w(), true).expect("writer");
11758 assert_eq!(w_key.entity_kind, EntityKind::UserWriterWithKey);
11759 let (r_key, _) = rt.register_user_reader_kind(mk_r(), true).expect("reader");
11760 assert_eq!(r_key.entity_kind, EntityKind::UserReaderWithKey);
11761 rt.shutdown();
11762 }
11763
11764 #[test]
11765 fn incompatible_qos_match_emits_loud_warning() {
11766 // C2 "loud instead of silent": an incompatible QoS match is logged as a
11767 // warn event with topic + policy, not silently discarded.
11768 // Setup: writer Volatile + reader TransientLocal on the same
11769 // Topic (reader requests more durability than the writer offers)
11770 // → intra-runtime match fails with policy DURABILITY.
11771 use std::sync::Arc as StdArc;
11772 use zerodds_foundation::observability::{Component, Level, VecSink};
11773
11774 let sink = StdArc::new(VecSink::new());
11775 let cfg_a = RuntimeConfig {
11776 observability: sink.clone(),
11777 tick_period: Duration::from_millis(5),
11778 ..RuntimeConfig::default()
11779 };
11780 let cfg_b = RuntimeConfig {
11781 tick_period: Duration::from_millis(5),
11782 ..RuntimeConfig::default()
11783 };
11784 // Two same-process runtimes, same domain → inproc discovery.
11785 let rt = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCE; 12]), cfg_a)
11786 .expect("start runtime a");
11787 let rt_b = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCF; 12]), cfg_b)
11788 .expect("start runtime b");
11789 let _w = rt
11790 .register_user_writer(UserWriterConfig {
11791 topic_name: "QT".into(),
11792 type_name: "QType".into(),
11793 reliable: false,
11794 durability: zerodds_qos::DurabilityKind::Volatile,
11795 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11796 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11797 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11798 ownership: zerodds_qos::OwnershipKind::Shared,
11799 ownership_strength: 0,
11800 partition: alloc::vec![],
11801 user_data: alloc::vec![],
11802 topic_data: alloc::vec![],
11803 group_data: alloc::vec![],
11804 type_identifier: zerodds_types::TypeIdentifier::None,
11805 data_representation_offer: None,
11806 })
11807 .expect("writer");
11808 let _r = rt_b
11809 .register_user_reader(UserReaderConfig {
11810 topic_name: "QT".into(),
11811 type_name: "QType".into(),
11812 reliable: false,
11813 durability: zerodds_qos::DurabilityKind::TransientLocal,
11814 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11815 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11816 ownership: zerodds_qos::OwnershipKind::Shared,
11817 partition: alloc::vec![],
11818 user_data: alloc::vec![],
11819 topic_data: alloc::vec![],
11820 group_data: alloc::vec![],
11821 type_identifier: zerodds_types::TypeIdentifier::None,
11822 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11823 data_representation_offer: None,
11824 })
11825 .expect("reader");
11826 // Await the match pass.
11827 let mut found = false;
11828 for _ in 0..40 {
11829 std::thread::sleep(Duration::from_millis(25));
11830 let events = sink.snapshot();
11831 if events.iter().any(|e| {
11832 (e.name == "qos.incompatible.offered" || e.name == "qos.incompatible.requested")
11833 && e.component == Component::Dcps
11834 && e.level == Level::Warn
11835 && e.attrs.iter().any(|a| a.key == "topic" && a.value == "QT")
11836 && e.attrs
11837 .iter()
11838 .any(|a| a.key == "policy" && a.value == "DURABILITY")
11839 }) {
11840 found = true;
11841 break;
11842 }
11843 }
11844 rt.shutdown();
11845 rt_b.shutdown();
11846 assert!(
11847 found,
11848 "expected a loud qos.incompatible warn event with policy DURABILITY"
11849 );
11850 }
11851
11852 #[test]
11853 fn spdp_unicast_port_follows_rtps_formula() {
11854 // Spec §9.6.1.4.1: PB + DG*domain + d1 + PG*pid = 7400+250*d+10+2*pid.
11855 assert_eq!(super::spdp_unicast_port(0, 0), 7410);
11856 assert_eq!(spdp_unicast_port(0, 1), 7412);
11857 assert_eq!(spdp_unicast_port(1, 0), 7660);
11858 assert_eq!(spdp_unicast_port(7, 0), 9160);
11859 }
11860
11861 #[test]
11862 fn announce_locator_pins_interface_over_route_probe() {
11863 // Interface pinning: a set interface takes precedence over the
11864 // route probe (multi-homed robustness, cf. Cyclone NetworkInterface).
11865 let udp = UdpTransport::bind_v4(Ipv4Addr::UNSPECIFIED, 0).expect("bind");
11866 let pin = Ipv4Addr::new(10, 11, 12, 13);
11867 let loc = super::announce_locator(&udp, pin);
11868 assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
11869 assert_eq!(loc.address[12..], [10, 11, 12, 13]);
11870 // Without a pin (UNSPECIFIED) → probe/fallback does NOT return the pin IP.
11871 let auto = super::announce_locator(&udp, Ipv4Addr::UNSPECIFIED);
11872 assert_ne!(auto.address[12..], [10, 11, 12, 13]);
11873 }
11874
11875 #[test]
11876 fn expand_initial_peer_ip_only_yields_well_known_port_range() {
11877 let m = super::INITIAL_PEER_MAX_PARTICIPANTS;
11878 let mut out = Vec::new();
11879 super::expand_initial_peer("127.0.0.1", 0, m, &mut out);
11880 assert_eq!(out.len(), m as usize);
11881 assert_eq!(out[0].port, 7410);
11882 assert_eq!(out[1].port, 7412);
11883 // Larger limit → more ports (C1 dense multi-robot scenarios).
11884 let mut wide = Vec::new();
11885 super::expand_initial_peer("127.0.0.1", 0, 30, &mut wide);
11886 assert_eq!(wide.len(), 30);
11887 assert_eq!(wide[29].port, 7410 + 2 * 29);
11888 // ip:port -> exactly one exact locator.
11889 let mut one = Vec::new();
11890 super::expand_initial_peer("10.0.0.5:7410", 0, m, &mut one);
11891 assert_eq!(one.len(), 1);
11892 assert_eq!(one[0].port, 7410);
11893 assert_eq!(one[0].address[12..], [10, 0, 0, 5]);
11894 // Garbage is ignored.
11895 let mut none = Vec::new();
11896 super::expand_initial_peer("not-an-ip", 0, m, &mut none);
11897 assert!(none.is_empty());
11898 }
11899
11900 #[test]
11901 #[ignore = "heavy multi-runtime scaling test (12 runtimes); explicit: cargo test -- --ignored"]
11902 #[allow(clippy::print_stdout)]
11903 fn multicast_free_discovery_scales_to_many_participants() {
11904 // C1 scaling: N participants, each with its own multicast group
11905 // (→ separate inproc buckets) AND multicast send off → pure
11906 // Unicast discovery via an explicit well-known-port peer list. Evidence,
11907 // that multicast-free all-to-all discovery works beyond 2 participants
11908 // (the "N²-multicast-storm" pain cluster, but unicast).
11909 // N via env (ZERODDS_SCALE_N, default 12) for >50 perf demos.
11910 let n: u32 = std::env::var("ZERODDS_SCALE_N")
11911 .ok()
11912 .and_then(|s| s.parse().ok())
11913 .unwrap_or(12)
11914 .clamp(2, 120);
11915 let domain = 21;
11916 let peers: Vec<Locator> = (0..n)
11917 .map(|pid| Locator::udp_v4([127, 0, 0, 1], super::spdp_unicast_port(domain, pid)))
11918 .collect();
11919 let mut rts = Vec::new();
11920 for i in 0..n {
11921 let cfg = RuntimeConfig {
11922 tick_period: Duration::from_millis(10),
11923 spdp_period: Duration::from_millis(40),
11924 // Own group per runtime → no inproc, no multicast.
11925 spdp_multicast_group: Ipv4Addr::new(239, 255, 21, (i + 1) as u8),
11926 spdp_multicast_send: false,
11927 initial_peers: peers.clone(),
11928 ..RuntimeConfig::default()
11929 };
11930 // Unique prefix even for n>47 (two-byte index).
11931 let mut pb = [0xD0u8; 12];
11932 pb[0] = (i & 0xff) as u8;
11933 pb[1] = (i >> 8) as u8;
11934 let prefix = GuidPrefix::from_bytes(pb);
11935 rts.push(DcpsRuntime::start(domain as i32, prefix, cfg).expect("start"));
11936 }
11937 // Wait until each participant has discovered all n-1 others.
11938 // Grosszuegiges Fenster: viele Runtimes konkurrieren um CPU; break-early.
11939 let started = std::time::Instant::now();
11940 let mut all_full = false;
11941 for _ in 0..1200 {
11942 std::thread::sleep(Duration::from_millis(25));
11943 if rts
11944 .iter()
11945 .all(|rt| rt.discovered_participants().len() >= (n as usize - 1))
11946 {
11947 all_full = true;
11948 break;
11949 }
11950 }
11951 let elapsed = started.elapsed();
11952 let min_seen = rts
11953 .iter()
11954 .map(|rt| rt.discovered_participants().len())
11955 .min()
11956 .unwrap_or(0);
11957 for rt in &rts {
11958 rt.shutdown();
11959 }
11960 println!(
11961 "C1-Scaling: {n} Participants multicast-frei all-to-all in {:.2}s (min={min_seen}/{})",
11962 elapsed.as_secs_f64(),
11963 n - 1
11964 );
11965 assert!(
11966 all_full,
11967 "multicast-free all-to-all discovery does not scale: min seen = {min_seen}/{}",
11968 n - 1
11969 );
11970 }
11971
11972 #[test]
11973 fn default_reassembly_cap_is_ros_realistic() {
11974 // C3 regression: the DCPS reassembly cap must be ROS-PointCloud2/
11975 // Image-capable (several MB), not the conservative
11976 // rtps 1-MiB default that silently discards large samples.
11977 let cfg = RuntimeConfig::default();
11978 assert!(
11979 cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024,
11980 "reassembly cap too small for ROS PointCloud2/Image: {}",
11981 cfg.max_reassembly_sample_bytes
11982 );
11983 }
11984
11985 #[test]
11986 fn ros_defaults_offers_xcdr1_for_ros_writers() {
11987 // C4: the ROS profile offers [XCDR1, XCDR2] (matches ROS/Cyclone
11988 // XCDR1 writer) + keeps the ROS-realistic reassembly cap.
11989 use zerodds_rtps::publication_data::data_representation as dr;
11990 let cfg = RuntimeConfig::ros_defaults();
11991 assert_eq!(
11992 cfg.data_representation_offer,
11993 alloc::vec![dr::XCDR, dr::XCDR2]
11994 );
11995 assert!(cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024);
11996 }
11997
11998 #[test]
11999 fn multicast_free_discovery_via_initial_peers() {
12000 // C1: two runtimes with DIFFERENT multicast groups lie
12001 // in different inproc buckets AND cannot see each other via
12002 // multicast — so they discover each other EXCLUSIVELY via
12003 // the unicast initial peers (well-known SPDP ports on 127.0.0.1).
12004 let domain = 7;
12005 let mut peers = Vec::new();
12006 super::expand_initial_peer(
12007 "127.0.0.1",
12008 domain as u32,
12009 super::INITIAL_PEER_MAX_PARTICIPANTS,
12010 &mut peers,
12011 );
12012 let mk = |group: [u8; 4]| RuntimeConfig {
12013 tick_period: Duration::from_millis(10),
12014 spdp_period: Duration::from_millis(40),
12015 spdp_multicast_group: Ipv4Addr::from(group),
12016 // Multicast send fully off → rigorous unicast-only proof.
12017 spdp_multicast_send: false,
12018 initial_peers: peers.clone(),
12019 ..RuntimeConfig::default()
12020 };
12021 let a = DcpsRuntime::start(
12022 domain,
12023 GuidPrefix::from_bytes([0xA1; 12]),
12024 mk([239, 255, 7, 1]),
12025 )
12026 .expect("a");
12027 let b = DcpsRuntime::start(
12028 domain,
12029 GuidPrefix::from_bytes([0xB2; 12]),
12030 mk([239, 255, 7, 2]),
12031 )
12032 .expect("b");
12033 let mut discovered = false;
12034 for _ in 0..160 {
12035 std::thread::sleep(Duration::from_millis(25));
12036 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12037 discovered = true;
12038 break;
12039 }
12040 }
12041 a.shutdown();
12042 b.shutdown();
12043 assert!(
12044 discovered,
12045 "multicast-freie Discovery via Unicast-Initial-Peers fehlgeschlagen"
12046 );
12047 }
12048
12049 #[test]
12050 fn multi_robot_profile_is_multicast_free_and_wan_tolerant() {
12051 // C6: the named profile must be unicast-only with ROS reprs and a
12052 // WAN-tolerant lease, independent of any env.
12053 let cfg = RuntimeConfig::multi_robot();
12054 assert!(
12055 !cfg.spdp_multicast_send,
12056 "multi_robot() must disable multicast send"
12057 );
12058 assert_eq!(
12059 cfg.data_representation_offer,
12060 alloc::vec![
12061 zerodds_rtps::publication_data::data_representation::XCDR,
12062 zerodds_rtps::publication_data::data_representation::XCDR2
12063 ],
12064 "multi_robot() must offer the ROS XCDR1+XCDR2 reprs"
12065 );
12066 assert_eq!(
12067 cfg.participant_lease_duration,
12068 Duration::from_secs(300),
12069 "multi_robot() must use the WAN-tolerant 300s lease"
12070 );
12071 }
12072
12073 #[test]
12074 fn multi_robot_profile_discovers_via_unicast() {
12075 // C6 e2e: two runtimes started from the `multi_robot()` profile (whose
12076 // `spdp_multicast_send = false` is the field under test) sit in
12077 // different multicast buckets and can ONLY find each other through the
12078 // unicast initial peers — proving the profile drives multicast-free
12079 // discovery end-to-end. Only test-timing + the peer list are
12080 // overridden; `spdp_multicast_send` comes from the profile.
12081 let domain = 9;
12082 let mut peers = Vec::new();
12083 super::expand_initial_peer(
12084 "127.0.0.1",
12085 domain as u32,
12086 super::INITIAL_PEER_MAX_PARTICIPANTS,
12087 &mut peers,
12088 );
12089 let mk = |group: [u8; 4]| RuntimeConfig {
12090 tick_period: Duration::from_millis(10),
12091 spdp_period: Duration::from_millis(40),
12092 spdp_multicast_group: Ipv4Addr::from(group),
12093 initial_peers: peers.clone(),
12094 ..RuntimeConfig::multi_robot()
12095 };
12096 let a = DcpsRuntime::start(
12097 domain,
12098 GuidPrefix::from_bytes([0xC6; 12]),
12099 mk([239, 255, 9, 1]),
12100 )
12101 .expect("a");
12102 let b = DcpsRuntime::start(
12103 domain,
12104 GuidPrefix::from_bytes([0xD7; 12]),
12105 mk([239, 255, 9, 2]),
12106 )
12107 .expect("b");
12108 let mut discovered = false;
12109 for _ in 0..160 {
12110 std::thread::sleep(Duration::from_millis(25));
12111 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12112 discovered = true;
12113 break;
12114 }
12115 }
12116 a.shutdown();
12117 b.shutdown();
12118 assert!(
12119 discovered,
12120 "multi_robot() profile failed to discover via unicast initial peers"
12121 );
12122 }
12123
12124 #[test]
12125 fn intra_runtime_writer_to_reader_loopback_delivers_sample() {
12126 // Bridge daemon use case: writer and reader in the SAME
12127 // DcpsRuntime, same topic+type. Before the same-runtime loopback
12128 // hook, a write() produced NO sample at the local reader,
12129 // because `inproc_announce_*` explicitly skips self and UDP multicast
12130 // loopback is not guaranteed.
12131 let rt = DcpsRuntime::start(
12132 17,
12133 GuidPrefix::from_bytes([0x42; 12]),
12134 RuntimeConfig::default(),
12135 )
12136 .expect("start runtime");
12137 let writer_eid = rt
12138 .register_user_writer(UserWriterConfig {
12139 topic_name: "IntraTopic".into(),
12140 type_name: "IntraType".into(),
12141 reliable: true,
12142 durability: zerodds_qos::DurabilityKind::Volatile,
12143 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12144 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12145 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12146 ownership: zerodds_qos::OwnershipKind::Shared,
12147 ownership_strength: 0,
12148 partition: alloc::vec![],
12149 user_data: alloc::vec![],
12150 topic_data: alloc::vec![],
12151 group_data: alloc::vec![],
12152 type_identifier: zerodds_types::TypeIdentifier::None,
12153 data_representation_offer: None,
12154 })
12155 .expect("register writer");
12156 let (_reader_eid, rx) = rt
12157 .register_user_reader(UserReaderConfig {
12158 topic_name: "IntraTopic".into(),
12159 type_name: "IntraType".into(),
12160 reliable: true,
12161 durability: zerodds_qos::DurabilityKind::Volatile,
12162 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12163 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12164 ownership: zerodds_qos::OwnershipKind::Shared,
12165 partition: alloc::vec![],
12166 user_data: alloc::vec![],
12167 topic_data: alloc::vec![],
12168 group_data: alloc::vec![],
12169 type_identifier: zerodds_types::TypeIdentifier::None,
12170 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12171 data_representation_offer: None,
12172 })
12173 .expect("register reader");
12174
12175 rt.write_user_sample(writer_eid, b"hello-intra-runtime".to_vec())
12176 .expect("write");
12177
12178 // Same-runtime loopback is synchronous in the write_user_sample_borrowed
12179 // path — `recv_timeout` needs only microseconds, not the
12180 // wire roundtrip.
12181 let sample = rx
12182 .recv_timeout(core::time::Duration::from_millis(100))
12183 .expect("intra-runtime reader should receive sample");
12184 match sample {
12185 UserSample::Alive { payload, .. } => {
12186 assert_eq!(payload.as_ref(), b"hello-intra-runtime");
12187 }
12188 other => panic!("expected Alive, got {other:?}"),
12189 }
12190 rt.shutdown();
12191 }
12192
12193 /// Bug R4 (#63): the same-runtime writer→reader loopback path
12194 /// (`intra_runtime_dispatch_alive`) used to hardcode the XCDR
12195 /// data-representation tag = `0`, so a DataWriter and DataReader sharing
12196 /// one `DcpsRuntime` lost the writer's real representation. Asserts the
12197 /// tag is carried through: default offer (`[XCDR2]`) → `1`, and an
12198 /// explicit `[XCDR1]` per-writer override → `0`. Also confirms a typed
12199 /// sample (XCDR2-framed body) round-trips intact alongside the tag.
12200 #[test]
12201 fn intra_runtime_loopback_preserves_representation_tag() {
12202 use zerodds_rtps::publication_data::data_representation as dr;
12203
12204 fn run_case(domain: i32, prefix: u8, offer: Option<Vec<i16>>, expected_rep: u8) {
12205 let rt = DcpsRuntime::start(
12206 domain,
12207 GuidPrefix::from_bytes([prefix; 12]),
12208 RuntimeConfig::default(),
12209 )
12210 .expect("start runtime");
12211 let writer_eid = rt
12212 .register_user_writer(UserWriterConfig {
12213 topic_name: "RepTopic".into(),
12214 type_name: "RepType".into(),
12215 reliable: true,
12216 durability: zerodds_qos::DurabilityKind::Volatile,
12217 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12218 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12219 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12220 ownership: zerodds_qos::OwnershipKind::Shared,
12221 ownership_strength: 0,
12222 partition: alloc::vec![],
12223 user_data: alloc::vec![],
12224 topic_data: alloc::vec![],
12225 group_data: alloc::vec![],
12226 type_identifier: zerodds_types::TypeIdentifier::None,
12227 data_representation_offer: offer,
12228 })
12229 .expect("register writer");
12230 let (_reader_eid, rx) = rt
12231 .register_user_reader(UserReaderConfig {
12232 topic_name: "RepTopic".into(),
12233 type_name: "RepType".into(),
12234 reliable: true,
12235 durability: zerodds_qos::DurabilityKind::Volatile,
12236 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12237 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12238 ownership: zerodds_qos::OwnershipKind::Shared,
12239 partition: alloc::vec![],
12240 user_data: alloc::vec![],
12241 topic_data: alloc::vec![],
12242 group_data: alloc::vec![],
12243 type_identifier: zerodds_types::TypeIdentifier::None,
12244 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12245 data_representation_offer: None,
12246 })
12247 .expect("register reader");
12248
12249 // Typed sample: a `struct { long seq; }` XCDR2-aligned body
12250 // (little-endian 4-byte long). The intra-runtime path carries the
12251 // RAW body (no encap header), so the representation tag is the
12252 // only carrier of the wire version — exactly the lost signal.
12253 let seq: i32 = 0x0A0B_0C0D;
12254 let typed_payload = seq.to_le_bytes().to_vec();
12255
12256 rt.write_user_sample(writer_eid, typed_payload.clone())
12257 .expect("write");
12258
12259 let sample = rx
12260 .recv_timeout(core::time::Duration::from_millis(100))
12261 .expect("intra-runtime reader should receive sample");
12262 match sample {
12263 UserSample::Alive {
12264 payload,
12265 representation,
12266 ..
12267 } => {
12268 assert_eq!(
12269 representation, expected_rep,
12270 "intra-runtime loopback must carry the writer's XCDR \
12271 version tag (offer→rep), not a hardcoded 0"
12272 );
12273 // Typed round-trip: the recovered body decodes to the
12274 // original long.
12275 assert_eq!(payload.as_ref(), typed_payload.as_slice());
12276 let recovered =
12277 i32::from_le_bytes(payload.as_ref()[..4].try_into().expect("4-byte long"));
12278 assert_eq!(recovered, seq, "typed sample must round-trip");
12279 }
12280 other => panic!("expected Alive, got {other:?}"),
12281 }
12282 rt.shutdown();
12283 }
12284
12285 // Default offer is `[XCDR2]` → tag `1`.
12286 run_case(19, 0x60, None, 1);
12287 // Explicit per-writer XCDR1 override → tag `0` (proves the value is
12288 // actually carried from the writer, not constant).
12289 run_case(20, 0x61, Some(alloc::vec![dr::XCDR]), 0);
12290 // Explicit per-writer XCDR2 override → tag `1`.
12291 run_case(21, 0x62, Some(alloc::vec![dr::XCDR2]), 1);
12292 }
12293
12294 #[test]
12295 fn intra_runtime_loopback_not_matched_on_different_topic() {
12296 // Negative test: writer on TopicA, reader on TopicB — no
12297 // intra-runtime match, no sample. Prevents the
12298 // routing table from topic-blindly merging everything.
12299 let rt = DcpsRuntime::start(
12300 18,
12301 GuidPrefix::from_bytes([0x43; 12]),
12302 RuntimeConfig::default(),
12303 )
12304 .expect("start runtime");
12305 let writer_eid = rt
12306 .register_user_writer(UserWriterConfig {
12307 topic_name: "TopicA".into(),
12308 type_name: "TypeA".into(),
12309 reliable: true,
12310 durability: zerodds_qos::DurabilityKind::Volatile,
12311 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12312 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12313 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12314 ownership: zerodds_qos::OwnershipKind::Shared,
12315 ownership_strength: 0,
12316 partition: alloc::vec![],
12317 user_data: alloc::vec![],
12318 topic_data: alloc::vec![],
12319 group_data: alloc::vec![],
12320 type_identifier: zerodds_types::TypeIdentifier::None,
12321 data_representation_offer: None,
12322 })
12323 .expect("register writer");
12324 let (_reader_eid, rx) = rt
12325 .register_user_reader(UserReaderConfig {
12326 topic_name: "TopicB".into(),
12327 type_name: "TypeB".into(),
12328 reliable: true,
12329 durability: zerodds_qos::DurabilityKind::Volatile,
12330 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12331 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12332 ownership: zerodds_qos::OwnershipKind::Shared,
12333 partition: alloc::vec![],
12334 user_data: alloc::vec![],
12335 topic_data: alloc::vec![],
12336 group_data: alloc::vec![],
12337 type_identifier: zerodds_types::TypeIdentifier::None,
12338 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12339 data_representation_offer: None,
12340 })
12341 .expect("register reader");
12342
12343 rt.write_user_sample(writer_eid, b"should-not-arrive".to_vec())
12344 .expect("write");
12345
12346 match rx.recv_timeout(core::time::Duration::from_millis(50)) {
12347 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { /* expected */ }
12348 other => panic!("reader on different topic must not receive: got {other:?}"),
12349 }
12350 rt.shutdown();
12351 }
12352
12353 #[test]
12354 fn runtime_starts_and_shuts_down_cleanly() {
12355 let rt = DcpsRuntime::start(
12356 42,
12357 GuidPrefix::from_bytes([7; 12]),
12358 RuntimeConfig::default(),
12359 )
12360 .expect("start runtime");
12361 assert_eq!(rt.domain_id, 42);
12362 // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): the SameHostTracker
12363 // must be initially empty and a same-host match (manually
12364 // simulated, without SEDP setup) must produce a `Pending`
12365 // entry. The real SEDP hook trigger is the job of the E2E
12366 // test in wave 4c — here only a smoke test of the wiring point.
12367 assert!(rt.same_host.is_empty(), "fresh runtime: no same-host pairs");
12368 let local_writer = zerodds_rtps::wire_types::Guid::new(
12369 rt.guid_prefix,
12370 zerodds_rtps::wire_types::EntityId::user_writer_with_key([1, 2, 3]),
12371 );
12372 let same_host_reader = zerodds_rtps::wire_types::Guid::new(
12373 rt.guid_prefix,
12374 zerodds_rtps::wire_types::EntityId::user_reader_with_key([4, 5, 6]),
12375 );
12376 rt.same_host
12377 .register_pending(local_writer, same_host_reader);
12378 assert_eq!(rt.same_host.len(), 1);
12379 assert!(matches!(
12380 rt.same_host.lookup(local_writer, same_host_reader),
12381 Some(crate::same_host::SameHostState::Pending)
12382 ));
12383 // Shutdown is idempotent.
12384 rt.shutdown();
12385 rt.shutdown();
12386 }
12387
12388 #[test]
12389 fn multicast_allowlist_empty_permits_all() {
12390 let cfg = RuntimeConfig::default();
12391 assert!(cfg.multicast_allowlist.is_empty());
12392 assert!(cfg.multicast_allowed(Ipv4Addr::new(239, 255, 0, 1)));
12393 assert!(cfg.multicast_allowed(Ipv4Addr::new(239, 1, 2, 3)));
12394 }
12395
12396 #[test]
12397 fn multicast_allowlist_restricts_to_members() {
12398 let cfg = RuntimeConfig {
12399 multicast_allowlist: vec![Ipv4Addr::new(239, 255, 0, 1)],
12400 ..RuntimeConfig::default()
12401 };
12402 assert!(cfg.multicast_allowed(Ipv4Addr::new(239, 255, 0, 1)));
12403 assert!(!cfg.multicast_allowed(Ipv4Addr::new(239, 9, 9, 9)));
12404 }
12405
12406 #[test]
12407 fn start_refuses_spdp_group_outside_allowlist() {
12408 // Opt-in allowlist that excludes the default SPDP group (239.255.0.1)
12409 // → start must refuse before it touches any multicast socket.
12410 let cfg = RuntimeConfig {
12411 multicast_allowlist: vec![Ipv4Addr::new(239, 1, 1, 1)],
12412 ..RuntimeConfig::default()
12413 };
12414 let res = DcpsRuntime::start(43, GuidPrefix::from_bytes([9; 12]), cfg);
12415 assert!(matches!(res, Err(DdsError::BadParameter { .. })));
12416 }
12417
12418 #[test]
12419 fn spdp_announces_standard_bits_by_default() {
12420 // Default config (without security): standard bits + WLP bits 10/11
12421 // + TypeLookup bits 12/13 must be announced along;
12422 // secure bits 16..27 + SEDP-topics bits 28/29 must NOT
12423 // be set. Topics bits are optional per RTPS 2.5 §8.5.4.4
12424 // — ZeroDDS does not implement the native topic endpoints
12425 // (synthetic DCPSTopic derivation from pub/sub covers the
12426 // end-user need), so we do not announce the capability
12427 // either.
12428 let rt = DcpsRuntime::start(
12429 5,
12430 GuidPrefix::from_bytes([0xC; 12]),
12431 RuntimeConfig::default(),
12432 )
12433 .expect("start");
12434 let mask = rt.announced_builtin_endpoint_set();
12435 // Standard bits + WLP + TypeLookup.
12436 assert_ne!(mask & endpoint_flag::PARTICIPANT_ANNOUNCER, 0);
12437 assert_ne!(mask & endpoint_flag::PARTICIPANT_DETECTOR, 0);
12438 assert_ne!(mask & endpoint_flag::PUBLICATIONS_ANNOUNCER, 0);
12439 assert_ne!(mask & endpoint_flag::SUBSCRIPTIONS_DETECTOR, 0);
12440 assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_WRITER, 0);
12441 assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_READER, 0);
12442 assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REQUEST, 0);
12443 assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REPLY, 0);
12444 // Do NOT set the SEDP-topics bits — covered synthetically.
12445 assert_eq!(mask & endpoint_flag::TOPICS_ANNOUNCER, 0);
12446 assert_eq!(mask & endpoint_flag::TOPICS_DETECTOR, 0);
12447 // No secure bits without explicit announce_secure_endpoints.
12448 assert_eq!(mask & endpoint_flag::ALL_SECURE, 0);
12449 }
12450
12451 #[test]
12452 fn spdp_announces_secure_bits_when_configured() {
12453 // With announce_secure_endpoints=true all 12 secure
12454 // bits (16..27) must be set.
12455 let config = RuntimeConfig {
12456 announce_secure_endpoints: true,
12457 ..Default::default()
12458 };
12459 let rt = DcpsRuntime::start(6, GuidPrefix::from_bytes([0xD; 12]), config).expect("start");
12460 let mask = rt.announced_builtin_endpoint_set();
12461 for bit in 16u32..=27 {
12462 assert!(
12463 mask & (1u32 << bit) != 0,
12464 "secure bit {bit} missing in the SPDP announce"
12465 );
12466 }
12467 // Standard bits must still be set.
12468 assert_eq!(
12469 mask & endpoint_flag::ALL_STANDARD,
12470 endpoint_flag::ALL_STANDARD
12471 );
12472 }
12473
12474 #[test]
12475 fn spdp_lease_duration_is_configurable() {
12476 // Default 100 s (spec). The override of 17 s must arrive in the beacon.
12477 let config = RuntimeConfig {
12478 participant_lease_duration: Duration::from_secs(17),
12479 ..Default::default()
12480 };
12481 let rt = DcpsRuntime::start(7, GuidPrefix::from_bytes([0xE; 12]), config).expect("start");
12482 let secs = rt
12483 .spdp_beacon
12484 .lock()
12485 .map(|b| b.data.lease_duration.seconds)
12486 .unwrap_or(0);
12487 assert_eq!(secs, 17);
12488 }
12489
12490 #[test]
12491 fn user_locator_is_udp_v4_127_0_0_x() {
12492 let rt = DcpsRuntime::start(
12493 0,
12494 GuidPrefix::from_bytes([0xA; 12]),
12495 RuntimeConfig::default(),
12496 )
12497 .expect("start");
12498 let loc = rt.user_locator();
12499 assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
12500 // Port > 0 (ephemeral).
12501 assert!(loc.port > 0);
12502 }
12503
12504 #[test]
12505 fn two_runtimes_on_same_domain_can_coexist() {
12506 // The SPDP multicast port is SO_REUSE in our bind.
12507 let a = DcpsRuntime::start(
12508 3,
12509 GuidPrefix::from_bytes([0xA; 12]),
12510 RuntimeConfig::default(),
12511 )
12512 .expect("a");
12513 let b = DcpsRuntime::start(
12514 3,
12515 GuidPrefix::from_bytes([0xB; 12]),
12516 RuntimeConfig::default(),
12517 )
12518 .expect("b");
12519 assert_eq!(a.domain_id, b.domain_id);
12520 }
12521
12522 #[test]
12523 fn peer_capabilities_unknown_peer_returns_none() {
12524 let rt = DcpsRuntime::start(
12525 10,
12526 GuidPrefix::from_bytes([0x60; 12]),
12527 RuntimeConfig::default(),
12528 )
12529 .expect("start");
12530 // A fresh runtime has discovered no peer.
12531 let caps = rt.peer_capabilities(&GuidPrefix::from_bytes([0xEE; 12]));
12532 assert!(caps.is_none());
12533 }
12534
12535 #[test]
12536 fn assert_liveliness_enqueues_wlp_pulse_without_panic() {
12537 // Smoke test: assert_liveliness() must not poison the lock
12538 // and must return synchronously.
12539 //
12540 // Isolate discovery: a UNIQUE multicast group (239.255.88.1, used by no
12541 // other test) + multicast send off, so no co-running test's participant
12542 // can announce itself into this group. Using RuntimeConfig::default()
12543 // put this runtime on the shared spec group (239.255.0.1), where a
12544 // parallel test's participant leaked in and made peer_count flaky under
12545 // the coverage run — a real test-isolation defect, following the same
12546 // unique-group convention the other multi-runtime tests already use.
12547 let cfg = RuntimeConfig {
12548 spdp_multicast_group: Ipv4Addr::new(239, 255, 88, 1),
12549 spdp_multicast_send: false,
12550 ..RuntimeConfig::default()
12551 };
12552 let rt = DcpsRuntime::start(8, GuidPrefix::from_bytes([0xF; 12]), cfg).expect("start");
12553 rt.assert_liveliness();
12554 rt.assert_writer_liveliness(alloc::vec![0xDE, 0xAD]);
12555 // Genuinely isolated now → no peer, and the lock stays usable.
12556 let count = rt.wlp.lock().map(|w| w.peer_count()).unwrap_or(usize::MAX);
12557 assert_eq!(count, 0, "isolated runtime: no peer announced itself → 0");
12558 }
12559
12560 #[test]
12561 fn wlp_period_default_is_lease_over_three() {
12562 // With the default lease of 100 s → wlp_period = 33.33 s.
12563 let rt = DcpsRuntime::start(
12564 9,
12565 GuidPrefix::from_bytes([0x10; 12]),
12566 RuntimeConfig::default(),
12567 )
12568 .expect("start");
12569 // We cannot read the value directly; but we
12570 // know: tick_period > 30 s means the default lease was
12571 // used. Enqueue a pulse and tick — it must fire,
12572 // the next AUTOMATIC comes only in 33 s.
12573 let mut wlp = rt.wlp.lock().unwrap();
12574 wlp.assert_participant();
12575 let now0 = Duration::from_secs(0);
12576 let dg = wlp.tick(now0).unwrap();
12577 assert!(dg.is_some(), "pulse is emitted immediately");
12578 }
12579
12580 // Multicast loopback is unreliable on macOS (no auto-
12581 // interface-join with bind_multicast_v4(0.0.0.0)). On Linux
12582 // it works out of the box; there the test will run in CI.
12583 #[cfg(target_os = "linux")]
12584 #[test]
12585 fn two_runtimes_exchange_wlp_heartbeat_via_multicast() {
12586 // .D-e: A sends periodic WLP heartbeats. B must
12587 // know its own WLP endpoint with A's prefix as a peer
12588 // within ~3 tick periods.
12589 let cfg = RuntimeConfig {
12590 tick_period: Duration::from_millis(20),
12591 spdp_period: Duration::from_millis(100),
12592 // Aggressive WLP period for fast tests.
12593 wlp_period: Duration::from_millis(80),
12594 participant_lease_duration: Duration::from_millis(240),
12595 ..RuntimeConfig::default()
12596 };
12597 let _a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x40; 12]), cfg.clone()).expect("a");
12598 let _b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x41; 12]), cfg).expect("b");
12599
12600 let a_prefix = GuidPrefix::from_bytes([0x40; 12]);
12601 for _ in 0..60 {
12602 thread::sleep(Duration::from_millis(50));
12603 if _b.peer_liveliness_last_seen(&a_prefix).is_some() {
12604 return;
12605 }
12606 }
12607 panic!("B did not see A's WLP heartbeat within 3 s");
12608 }
12609
12610 #[cfg(target_os = "linux")]
12611 #[test]
12612 fn two_runtimes_assert_liveliness_reaches_peer() {
12613 // The Manual-By-Participant pulse must arrive at the peer, the
12614 // last-seen timestamp must reset compared to purely Automatic
12615 // beats. Since the pulse goes out synchronously on the next
12616 // tick, a short wait suffices.
12617 let cfg = RuntimeConfig {
12618 tick_period: Duration::from_millis(20),
12619 spdp_period: Duration::from_millis(100),
12620 // WLP period large enough that no AUTOMATIC beat comes
12621 // in between within the test. The manual pulse queue
12622 // is processed before the AUTOMATIC slot.
12623 wlp_period: Duration::from_secs(3600),
12624 ..RuntimeConfig::default()
12625 };
12626 let a = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x50; 12]), cfg.clone()).expect("a");
12627 let b = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x51; 12]), cfg).expect("b");
12628
12629 a.assert_liveliness();
12630 let a_prefix = GuidPrefix::from_bytes([0x50; 12]);
12631 for _ in 0..60 {
12632 thread::sleep(Duration::from_millis(50));
12633 if b.peer_liveliness_last_seen(&a_prefix).is_some() {
12634 return;
12635 }
12636 }
12637 // In case of multicast-loopback problems, at least check A's
12638 // own pulse counter.
12639 panic!("B did not see A's manual liveliness assert within 3 s");
12640 }
12641
12642 #[cfg(target_os = "linux")]
12643 #[test]
12644 fn two_runtimes_exchange_sedp_publication_announce() {
12645 // E2E smoke: A announces a publication, B sees it
12646 // via SEDP. Assumes SPDP works (so that
12647 // the SEDP peer proxies get wired).
12648 use zerodds_qos::{DurabilityKind, ReliabilityKind};
12649 use zerodds_rtps::publication_data::PublicationBuiltinTopicData;
12650
12651 let cfg = RuntimeConfig {
12652 tick_period: Duration::from_millis(20),
12653 spdp_period: Duration::from_millis(100),
12654 ..RuntimeConfig::default()
12655 };
12656 // Own domain, so the test does not collide with the SPDP-only test
12657 // on domain 0 over the multicast port.
12658 let a = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xCC; 12]), cfg.clone()).expect("a");
12659 let b = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xDD; 12]), cfg).expect("b");
12660
12661 // Wait until both see each other via SPDP.
12662 for _ in 0..40 {
12663 thread::sleep(Duration::from_millis(50));
12664 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12665 break;
12666 }
12667 }
12668 assert!(
12669 !a.discovered_participants().is_empty(),
12670 "no SPDP discovery a"
12671 );
12672
12673 // A announces a publication for topic "Chatter" with type "RawBytes".
12674 let pub_data = PublicationBuiltinTopicData {
12675 key: Guid::new(
12676 a.guid_prefix,
12677 EntityId::user_writer_with_key([0x01, 0x02, 0x03]),
12678 ),
12679 participant_key: Guid::new(a.guid_prefix, EntityId::PARTICIPANT),
12680 topic_name: "Chatter".into(),
12681 type_name: "zerodds::RawBytes".into(),
12682 durability: DurabilityKind::Volatile,
12683 reliability: zerodds_qos::ReliabilityQosPolicy {
12684 kind: ReliabilityKind::Reliable,
12685 max_blocking_time: QosDuration::from_millis(100_i32),
12686 },
12687 ownership: zerodds_qos::OwnershipKind::Shared,
12688 ownership_strength: 0,
12689 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12690 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12691 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12692 partition: Vec::new(),
12693 user_data: Vec::new(),
12694 topic_data: Vec::new(),
12695 group_data: Vec::new(),
12696 type_information: None,
12697 data_representation: Vec::new(),
12698 security_info: None,
12699 service_instance_name: None,
12700 related_entity_guid: None,
12701 topic_aliases: None,
12702 type_identifier: zerodds_types::TypeIdentifier::None,
12703 unicast_locators: Vec::new(),
12704 multicast_locators: Vec::new(),
12705 };
12706 a.announce_publication(&pub_data).expect("announce");
12707
12708 // B should have the publication in the cache within ~3 s.
12709 // CI on shared runners has more jitter, 1 s was too tight.
12710 for _ in 0..60 {
12711 thread::sleep(Duration::from_millis(50));
12712 if b.discovered_publications_count() > 0 {
12713 return;
12714 }
12715 }
12716 panic!(
12717 "B did not receive SEDP publication within 3 s (pub_count={})",
12718 b.discovered_publications_count()
12719 );
12720 }
12721
12722 #[cfg(target_os = "linux")]
12723 #[test]
12724 fn two_runtimes_e2e_user_data_match_and_transfer() {
12725 // E2E smoke: full path
12726 // Runtime-A register_user_writer(topic, type)
12727 // Runtime-B register_user_reader(topic, type)
12728 // SEDP match, writer add_reader_proxy, reader add_writer_proxy
12729 // A.write_user_sample(payload) → UDP → B's mpsc::Receiver
12730 //
12731 // Eigene Domain (2) um Kollisionen zu vermeiden.
12732 let cfg = RuntimeConfig {
12733 tick_period: Duration::from_millis(20),
12734 spdp_period: Duration::from_millis(100),
12735 ..RuntimeConfig::default()
12736 };
12737 let a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xEE; 12]), cfg.clone()).expect("a");
12738 let b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xFF; 12]), cfg).expect("b");
12739
12740 // SPDP mutual — 3 s Budget.
12741 let mut spdp_ok = false;
12742 for _ in 0..60 {
12743 thread::sleep(Duration::from_millis(50));
12744 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12745 spdp_ok = true;
12746 break;
12747 }
12748 }
12749 assert!(spdp_ok, "SPDP mutual discovery did not complete in 3 s");
12750
12751 // Register endpoints. A publish, B subscribe.
12752 let wid = a
12753 .register_user_writer(UserWriterConfig {
12754 topic_name: "Chatter".into(),
12755 type_name: "zerodds::RawBytes".into(),
12756 reliable: true,
12757 durability: zerodds_qos::DurabilityKind::Volatile,
12758 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12759 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12760 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12761 ownership: zerodds_qos::OwnershipKind::Shared,
12762 ownership_strength: 0,
12763 partition: Vec::new(),
12764 user_data: Vec::new(),
12765 topic_data: Vec::new(),
12766 group_data: Vec::new(),
12767 type_identifier: zerodds_types::TypeIdentifier::None,
12768 data_representation_offer: None,
12769 })
12770 .expect("wid");
12771 let (_rid, rx) = b
12772 .register_user_reader(UserReaderConfig {
12773 topic_name: "Chatter".into(),
12774 type_name: "zerodds::RawBytes".into(),
12775 reliable: true,
12776 durability: zerodds_qos::DurabilityKind::Volatile,
12777 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12778 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12779 ownership: zerodds_qos::OwnershipKind::Shared,
12780 partition: Vec::new(),
12781 user_data: Vec::new(),
12782 topic_data: Vec::new(),
12783 group_data: Vec::new(),
12784 type_identifier: zerodds_types::TypeIdentifier::None,
12785 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12786 data_representation_offer: None,
12787 })
12788 .expect("rid");
12789
12790 // SEDP match + User-Data-Flow. `add_reader_proxy` triggert
12791 // a heartbeat immediately (RTPS §8.4.15.4), so ~tick_period
12792 // (20 ms) + response-delay (200 ms) + resend ≈ 300 ms in
12793 // idle state. A 4 s budget suffices even with CI jitter.
12794 let mut attempts = 0;
12795 loop {
12796 thread::sleep(Duration::from_millis(50));
12797 let _ = a.write_user_sample(wid, alloc::vec![0xAA, 0xBB, 0xCC]);
12798 if let Ok(sample) = rx.recv_timeout(Duration::from_millis(50)) {
12799 match sample {
12800 UserSample::Alive { payload, .. } => {
12801 assert_eq!(payload.as_slice(), &[0xAA, 0xBB, 0xCC][..]);
12802 return;
12803 }
12804 other => panic!("expected Alive sample, got {other:?}"),
12805 }
12806 }
12807 attempts += 1;
12808 if attempts > 80 {
12809 panic!("no sample delivered within 4 s");
12810 }
12811 }
12812 }
12813
12814 #[cfg(target_os = "linux")]
12815 #[test]
12816 fn two_runtimes_discover_each_other_via_spdp() {
12817 // We use a tight SPDP period so the test does not wait 5 s.
12818 let cfg = RuntimeConfig {
12819 tick_period: Duration::from_millis(20),
12820 spdp_period: Duration::from_millis(100),
12821 ..RuntimeConfig::default()
12822 };
12823 // Eigene Domain 3 (SEDP=1, E2E=2) um Cross-Test-Kollision zu vermeiden.
12824 let a = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xAA; 12]), cfg.clone()).expect("a");
12825 let b = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xBB; 12]), cfg).expect("b");
12826
12827 // Give the loop time for 2-3 beacon rounds. Multicast on
12828 // loopback is somewhat timing-sensitive when parallel tests
12829 // share the multicast group — hence 60 iterations of 50 ms
12830 // = 3 s budget instead of 1 s.
12831 for _ in 0..60 {
12832 thread::sleep(Duration::from_millis(50));
12833 let a_sees_b = a
12834 .discovered_participants()
12835 .iter()
12836 .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xBB; 12]));
12837 let b_sees_a = b
12838 .discovered_participants()
12839 .iter()
12840 .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xAA; 12]));
12841 if a_sees_b && b_sees_a {
12842 return;
12843 }
12844 }
12845 panic!(
12846 "mutual SPDP discovery failed within 3 s (a={} b={})",
12847 a.discovered_participants().len(),
12848 b.discovered_participants().len()
12849 );
12850 }
12851
12852 // =======================================================================
12853 // Security: Writer-Side Per-Reader-Serializer
12854 // =======================================================================
12855
12856 #[cfg(feature = "security")]
12857 #[test]
12858 fn per_target_serializer_produces_different_wire_per_reader() {
12859 use zerodds_security_crypto::AesGcmCryptoPlugin;
12860 use zerodds_security_permissions::parse_governance_xml;
12861 use zerodds_security_runtime::{
12862 PeerCapabilities, ProtectionLevel as SecProtectionLevel, SharedSecurityGate,
12863 };
12864
12865 // The governance enforces ENCRYPT on domain 0 — the default
12866 // path (transform_outbound) wraps too. A per-reader override
12867 // can still deliver plaintext if the reader is legacy.
12868 const GOV: &str = r#"
12869<domain_access_rules>
12870 <domain_rule>
12871 <domains><id>0</id></domains>
12872 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12873 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12874 </domain_rule>
12875</domain_access_rules>
12876"#;
12877 let gate = SharedSecurityGate::new(
12878 0,
12879 parse_governance_xml(GOV).unwrap(),
12880 Box::new(AesGcmCryptoPlugin::new()),
12881 );
12882
12883 let cfg = RuntimeConfig {
12884 security: Some(std::sync::Arc::new(gate)),
12885 ..RuntimeConfig::default()
12886 };
12887 let rt =
12888 DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE4; 12]), cfg).expect("start runtime");
12889
12890 let wid = rt
12891 .register_user_writer(UserWriterConfig {
12892 topic_name: "HeteroTopic".into(),
12893 type_name: "zerodds::RawBytes".into(),
12894 reliable: true,
12895 durability: zerodds_qos::DurabilityKind::Volatile,
12896 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12897 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12898 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12899 ownership: zerodds_qos::OwnershipKind::Shared,
12900 ownership_strength: 0,
12901 partition: Vec::new(),
12902 user_data: Vec::new(),
12903 topic_data: Vec::new(),
12904 group_data: Vec::new(),
12905 type_identifier: zerodds_types::TypeIdentifier::None,
12906 data_representation_offer: None,
12907 })
12908 .expect("register writer");
12909
12910 // Drei fiktive Reader-Targets — eines pro Protection-Klasse.
12911 let legacy_loc = Locator::udp_v4([127, 0, 0, 11], 40001);
12912 let fast_loc = Locator::udp_v4([127, 0, 0, 12], 40002);
12913 let secure_loc = Locator::udp_v4([127, 0, 0, 13], 40003);
12914 let legacy_peer: [u8; 12] = [0x11; 12];
12915 let fast_peer: [u8; 12] = [0x22; 12];
12916 let secure_peer: [u8; 12] = [0x33; 12];
12917
12918 // Simulates the SEDP match: populate the writer-slot maps.
12919 {
12920 let arc = rt.writer_slot(wid).unwrap();
12921 let mut slot = arc.lock().unwrap();
12922 slot.reader_protection
12923 .insert(legacy_peer, SecProtectionLevel::None);
12924 slot.reader_protection
12925 .insert(fast_peer, SecProtectionLevel::Sign);
12926 slot.reader_protection
12927 .insert(secure_peer, SecProtectionLevel::Encrypt);
12928 slot.locator_to_peer.insert(legacy_loc, legacy_peer);
12929 slot.locator_to_peer.insert(fast_loc, fast_peer);
12930 slot.locator_to_peer.insert(secure_loc, secure_peer);
12931 }
12932
12933 // Fiktive Writer-Datagram-Bytes (RTPS-Header + User-Payload).
12934 let mut msg = Vec::new();
12935 msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12936 msg.extend_from_slice(&[0xE4; 12]); // GuidPrefix
12937 msg.extend_from_slice(b"HELLO-HETERO");
12938
12939 let wire_legacy =
12940 secure_outbound_for_target(&rt, wid, &msg, &legacy_loc).expect("legacy path");
12941 let wire_fast = secure_outbound_for_target(&rt, wid, &msg, &fast_loc).expect("fast path");
12942 let wire_secure =
12943 secure_outbound_for_target(&rt, wid, &msg, &secure_loc).expect("secure path");
12944
12945 // Spec §8.4.2.4: under rtps_protection_kind=ENCRYPT EVERY message MUST
12946 // be SRTPS-wrapped — even a legacy reader (data-level None) may
12947 // get NO plaintext, otherwise user DATA leaks on a protected
12948 // domain. The per-reader data level only controls the inner payload/
12949 // submessage layer, not the outer rtps_protection.
12950 assert_ne!(
12951 wire_legacy, msg,
12952 "legacy under rtps_protection=ENCRYPT MUST be SRTPS-wrapped (no plaintext leak)"
12953 );
12954 assert_ne!(wire_fast, msg, "fast reader must be protected");
12955 assert_ne!(wire_secure, msg, "secure reader must be protected");
12956
12957 // Heterogeneity proof: the three wires are pairwise
12958 // different (each with its own nonce/session counter in SRTPS).
12959 assert_ne!(wire_legacy, wire_fast);
12960 assert_ne!(wire_legacy, wire_secure);
12961 assert_ne!(wire_fast, wire_secure);
12962
12963 // Without a locator match the fallback must take the domain-rule path
12964 // — this governance requires ENCRYPT, so SRTPS-wrapped.
12965 let unknown_loc = Locator::udp_v4([127, 0, 0, 99], 40099);
12966 let wire_unknown =
12967 secure_outbound_for_target(&rt, wid, &msg, &unknown_loc).expect("fallback path");
12968 assert_ne!(
12969 wire_unknown, msg,
12970 "unknown target should be protected via the domain rule"
12971 );
12972
12973 // The absence of the PeerCapabilities type is a compile check:
12974 // the import shows that the entire per-reader structure
12975 // is available in the dcps integration.
12976 let _unused: PeerCapabilities = PeerCapabilities::default();
12977
12978 rt.shutdown();
12979 }
12980
12981 // =======================================================================
12982 // Security: Reader-Side Per-Writer-Validator + Logging
12983 // =======================================================================
12984
12985 #[cfg(feature = "security")]
12986 #[derive(Default, Clone)]
12987 struct CapturingLogger {
12988 inner: std::sync::Arc<
12989 std::sync::Mutex<Vec<(zerodds_security_runtime::LogLevel, String, String)>>,
12990 >,
12991 }
12992
12993 #[cfg(feature = "security")]
12994 impl CapturingLogger {
12995 fn events(&self) -> Vec<(zerodds_security_runtime::LogLevel, String, String)> {
12996 self.inner.lock().map(|g| g.clone()).unwrap_or_default()
12997 }
12998 }
12999
13000 #[cfg(feature = "security")]
13001 impl zerodds_security_runtime::LoggingPlugin for CapturingLogger {
13002 fn log(
13003 &self,
13004 level: zerodds_security_runtime::LogLevel,
13005 _participant: [u8; 16],
13006 category: &str,
13007 message: &str,
13008 ) {
13009 if let Ok(mut g) = self.inner.lock() {
13010 g.push((level, category.to_string(), message.to_string()));
13011 }
13012 }
13013 fn plugin_class_id(&self) -> &str {
13014 "zerodds.test.capturing_logger"
13015 }
13016 }
13017
13018 #[cfg(feature = "security")]
13019 fn build_runtime_with(
13020 gov_xml: &str,
13021 logger: std::sync::Arc<CapturingLogger>,
13022 ) -> std::sync::Arc<DcpsRuntime> {
13023 use zerodds_security_crypto::AesGcmCryptoPlugin;
13024 use zerodds_security_permissions::parse_governance_xml;
13025 use zerodds_security_runtime::{LoggingPlugin, SharedSecurityGate};
13026 let gate = SharedSecurityGate::new(
13027 0,
13028 parse_governance_xml(gov_xml).unwrap(),
13029 Box::new(AesGcmCryptoPlugin::new()),
13030 );
13031 let logger_dyn: std::sync::Arc<dyn LoggingPlugin> = logger;
13032 let cfg = RuntimeConfig {
13033 security: Some(std::sync::Arc::new(gate)),
13034 security_logger: Some(logger_dyn),
13035 ..RuntimeConfig::default()
13036 };
13037 DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE7; 12]), cfg).expect("start rt")
13038 }
13039
13040 #[cfg(feature = "security")]
13041 #[test]
13042 fn inbound_plain_on_encrypt_domain_drops_with_error_event() {
13043 // DoD plan §stage 5: writer sends plain, policy expects
13044 // ENCRYPT → Reader droppt. Ohne allow_unauthenticated ist
13045 // this a "LegacyBlocked" → error level (not warning) per
13046 // the plan spec "missing-caps = Error".
13047 const GOV_ENCRYPT: &str = r#"
13048<domain_access_rules>
13049 <domain_rule>
13050 <domains><id>0</id></domains>
13051 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13052 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13053 </domain_rule>
13054</domain_access_rules>
13055"#;
13056 let logger = std::sync::Arc::new(CapturingLogger::default());
13057 let rt = build_runtime_with(GOV_ENCRYPT, std::sync::Arc::clone(&logger));
13058
13059 // Plain-RTPS-Datagram (header + body).
13060 let mut plain = Vec::new();
13061 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13062 plain.extend_from_slice(&[0x77; 12]); // attacker guid_prefix
13063 plain.extend_from_slice(b"plaintext-on-encrypted-domain");
13064
13065 let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan);
13066 assert!(out.is_none(), "tampering packet must be dropped");
13067
13068 let events = logger.events();
13069 assert_eq!(events.len(), 1, "exactly one log event expected");
13070 let (level, category, _msg) = &events[0];
13071 assert_eq!(
13072 *level,
13073 zerodds_security_runtime::LogLevel::Error,
13074 "plain-on-protected-domain without allow_unauth = Error (LegacyBlocked)"
13075 );
13076 assert_eq!(category, "inbound.legacy_blocked");
13077 rt.shutdown();
13078 }
13079
13080 #[cfg(feature = "security")]
13081 #[test]
13082 fn inbound_legacy_peer_accepted_when_governance_allows_unauth() {
13083 // DoD plan §stage 5: the legacy peer can keep talking to the reader,
13084 // when the governance sets allow_unauthenticated_participants=true.
13085 const GOV: &str = r#"
13086<domain_access_rules>
13087 <domain_rule>
13088 <domains><id>0</id></domains>
13089 <allow_unauthenticated_participants>TRUE</allow_unauthenticated_participants>
13090 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13091 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13092 </domain_rule>
13093</domain_access_rules>
13094"#;
13095 let logger = std::sync::Arc::new(CapturingLogger::default());
13096 let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
13097
13098 let mut plain = Vec::new();
13099 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13100 plain.extend_from_slice(&[0x88; 12]);
13101 plain.extend_from_slice(b"legacy-but-allowed");
13102
13103 let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan)
13104 .expect("legacy peer must be accepted");
13105 assert_eq!(out, plain, "output is byte-identical (no crypto unwrap)");
13106 assert!(
13107 logger.events().is_empty(),
13108 "no log event on the accept path"
13109 );
13110 rt.shutdown();
13111 }
13112
13113 #[cfg(feature = "security")]
13114 #[test]
13115 fn inbound_malformed_drops_and_logs_error() {
13116 const GOV: &str = r#"
13117<domain_access_rules>
13118 <domain_rule>
13119 <domains><id>0</id></domains>
13120 <rtps_protection_kind>NONE</rtps_protection_kind>
13121 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13122 </domain_rule>
13123</domain_access_rules>
13124"#;
13125 let logger = std::sync::Arc::new(CapturingLogger::default());
13126 let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
13127
13128 let out = secure_inbound_bytes(&rt, &[1, 2, 3, 4], &NetInterface::Wan);
13129 assert!(out.is_none());
13130 let events = logger.events();
13131 assert_eq!(events.len(), 1);
13132 assert_eq!(events[0].0, zerodds_security_runtime::LogLevel::Error);
13133 assert_eq!(events[0].1, "inbound.malformed");
13134 rt.shutdown();
13135 }
13136
13137 #[cfg(feature = "security")]
13138 #[test]
13139 fn inbound_without_security_gate_bypasses_classify_and_logger() {
13140 // Without a security gate: passthrough, no log event.
13141 let logger = std::sync::Arc::new(CapturingLogger::default());
13142 let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
13143 std::sync::Arc::clone(&logger) as _;
13144 let cfg = RuntimeConfig {
13145 security_logger: Some(logger_dyn),
13146 ..RuntimeConfig::default()
13147 };
13148 let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE8; 12]), cfg).unwrap();
13149 let msg = vec![0xAAu8; 40];
13150 let out = secure_inbound_bytes(&rt, &msg, &NetInterface::Wan).unwrap();
13151 assert_eq!(out, msg);
13152 assert!(
13153 logger.events().is_empty(),
13154 "the logger must NOT be called without a gate"
13155 );
13156 rt.shutdown();
13157 }
13158
13159 // =======================================================================
13160 // Security: Interface-Routing (Multi-Socket-Binding)
13161 // =======================================================================
13162
13163 #[cfg(feature = "security")]
13164 fn lo_range(third: u8) -> zerodds_security_runtime::IpRange {
13165 zerodds_security_runtime::IpRange {
13166 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, third)),
13167 prefix_len: 32,
13168 }
13169 }
13170
13171 #[cfg(feature = "security")]
13172 #[test]
13173 fn outbound_pool_routes_target_to_matching_binding() {
13174 let specs = vec![
13175 InterfaceBindingSpec {
13176 name: "lo-a".into(),
13177 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13178 bind_port: 0,
13179 kind: zerodds_security_runtime::NetInterface::Loopback,
13180 subnet: lo_range(11),
13181 default: false,
13182 },
13183 InterfaceBindingSpec {
13184 name: "lo-b".into(),
13185 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13186 bind_port: 0,
13187 kind: zerodds_security_runtime::NetInterface::Wan,
13188 subnet: lo_range(22),
13189 default: true,
13190 },
13191 ];
13192 let pool = OutboundSocketPool::bind_all(&specs).expect("pool");
13193
13194 // Exact match on the first subnet -> lo-a.
13195 let t1 = Locator::udp_v4([127, 0, 0, 11], 40000);
13196 let (sock1, iface1) = pool.route(&t1).expect("route 1");
13197 assert_eq!(iface1, zerodds_security_runtime::NetInterface::Loopback);
13198
13199 // Exact match on the second subnet -> lo-b.
13200 let t2 = Locator::udp_v4([127, 0, 0, 22], 40000);
13201 let (sock2, iface2) = pool.route(&t2).expect("route 2");
13202 assert_eq!(iface2, zerodds_security_runtime::NetInterface::Wan);
13203
13204 // The two sockets must have different local ports.
13205 let p1 = sock1.local_locator().port;
13206 let p2 = sock2.local_locator().port;
13207 assert_ne!(p1, p2);
13208 }
13209
13210 #[cfg(feature = "security")]
13211 #[test]
13212 fn outbound_pool_falls_back_to_default_when_no_subnet_matches() {
13213 let specs = vec![
13214 InterfaceBindingSpec {
13215 name: "lo-specific".into(),
13216 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13217 bind_port: 0,
13218 kind: zerodds_security_runtime::NetInterface::Loopback,
13219 subnet: lo_range(33),
13220 default: false,
13221 },
13222 InterfaceBindingSpec {
13223 name: "wan-default".into(),
13224 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13225 bind_port: 0,
13226 kind: zerodds_security_runtime::NetInterface::Wan,
13227 subnet: zerodds_security_runtime::IpRange {
13228 base: core::net::IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED),
13229 prefix_len: 0,
13230 },
13231 default: true,
13232 },
13233 ];
13234 let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13235 let unknown = Locator::udp_v4([192, 168, 7, 7], 12345);
13236 let (_sock, iface) = pool.route(&unknown).expect("default fallback");
13237 assert_eq!(iface, zerodds_security_runtime::NetInterface::Wan);
13238 }
13239
13240 #[cfg(feature = "security")]
13241 #[test]
13242 fn outbound_pool_returns_none_when_no_match_and_no_default() {
13243 let specs = vec![InterfaceBindingSpec {
13244 name: "only-lo".into(),
13245 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13246 bind_port: 0,
13247 kind: zerodds_security_runtime::NetInterface::Loopback,
13248 subnet: lo_range(44),
13249 default: false,
13250 }];
13251 let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13252 assert!(pool.route(&Locator::udp_v4([8, 8, 8, 8], 53)).is_none());
13253 }
13254
13255 #[cfg(feature = "security")]
13256 #[test]
13257 fn outbound_pool_skips_non_v4_locators() {
13258 let specs = vec![InterfaceBindingSpec {
13259 name: "lo".into(),
13260 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13261 bind_port: 0,
13262 kind: zerodds_security_runtime::NetInterface::Loopback,
13263 subnet: lo_range(55),
13264 default: true,
13265 }];
13266 let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13267 // SHM locator (no IPv4) → no match; without a default it would be None,
13268 // here default=true and subnet-contains does not apply
13269 // because ipv4_from_locator returns None.
13270 let shm = Locator {
13271 kind: zerodds_rtps::wire_types::LocatorKind::Shm,
13272 port: 0,
13273 address: [0u8; 16],
13274 };
13275 assert!(pool.route(&shm).is_none());
13276 }
13277
13278 #[cfg(feature = "security")]
13279 #[test]
13280 fn dod_plaintext_lo_vs_srtps_wan_via_sniffer() {
13281 // Spec §8.4.2.4 (spec wins vs DoD loopback plaintext): under
13282 // rtps_protection_kind=ENCRYPT means bytes are SRTPS-wrapped on EVERY
13283 // interface — including loopback. The test proves that the
13284 // per-interface routing serves both targets AND both outputs
13285 // are spec-conformantly protected (no plaintext leak, regardless of which
13286 // binding).
13287 //
13288 // Setup:
13289 // * 2 sniffer UDP sockets, one simulates a legacy
13290 // loopback peer (expects plaintext), the other a
13291 // WAN secure peer (expects SRTPS).
13292 // * DcpsRuntime with a security gate (governance = ENCRYPT) and
13293 // two interface bindings: lo-binding on 127.0.0.100,
13294 // wan-binding auf 127.0.0.200.
13295 // * 1 writer, 2 matched_readers with different protection
13296 // (Legacy=None, Secure=Encrypt) and the respective sniffer
13297 // Socket address as the locator_to_peer target.
13298 // * `send_on_best_interface(rt, target, bytes)` is triggered
13299 // manually; the sniffer per target receives and checks
13300 // the wire format.
13301 use std::net::{SocketAddrV4, UdpSocket};
13302 use zerodds_security_crypto::AesGcmCryptoPlugin;
13303 use zerodds_security_permissions::parse_governance_xml;
13304 use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
13305
13306 const GOV: &str = r#"
13307<domain_access_rules>
13308 <domain_rule>
13309 <domains><id>0</id></domains>
13310 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13311 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13312 </domain_rule>
13313</domain_access_rules>
13314"#;
13315 // Two sniffer sockets on ephemeral loopback ports (independent
13316 // from our bindings; they act as "peer receivers").
13317 let lo_sniffer =
13318 UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).expect("lo sniffer");
13319 lo_sniffer
13320 .set_read_timeout(Some(Duration::from_millis(250)))
13321 .unwrap();
13322 let wan_sniffer = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0))
13323 .expect("wan sniffer");
13324 wan_sniffer
13325 .set_read_timeout(Some(Duration::from_millis(250)))
13326 .unwrap();
13327 let lo_port = lo_sniffer.local_addr().unwrap().port();
13328 let wan_port = wan_sniffer.local_addr().unwrap().port();
13329 let lo_target = Locator::udp_v4([127, 0, 0, 1], u32::from(lo_port));
13330 let wan_target = Locator::udp_v4([127, 0, 0, 1], u32::from(wan_port));
13331
13332 // Two bindings, subnet-matched to exactly these ports. Since
13333 // IpRange currently matches only on IP, we use two
13334 // different /32 host ranges as a trick:
13335 // we set both bindings to the same IP/32, but because
13336 // `route` takes the first subnet match, I list them such
13337 // that "lo-bind" comes first and then the default.
13338 //
13339 // Correct: both sniffers share 127.0.0.1/32 and the pool would
13340 // pick the first binding. To distinguish cleanly, we map
13341 // the binding decision by *target port* — that works
13342 // not today. So: we work around this subtlety by
13343 // calling `send_on_best_interface` directly for different targets
13344 // and assigning the binding by IP range —
13345 // the DoD checks the routing at the binding level, not the
13346 // socket layer.
13347 //
13348 // Pragmatically: we test end-to-end that the pool actually
13349 // picks the right interface socket for the target and
13350 // processes the bytes differently (plain vs SRTPS).
13351 // The target locators differ only in the port, but
13352 // `send_on_best_interface` gets them separately each. The
13353 // decisive point is: both bindings send **and** the
13354 // sniffer socket receives — proving the routing in combination
13355 // with the per-reader serializer from stage 4.
13356
13357 let bindings = vec![InterfaceBindingSpec {
13358 name: "lo-for-legacy".into(),
13359 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13360 bind_port: 0,
13361 kind: SecIf::Loopback,
13362 subnet: zerodds_security_runtime::IpRange {
13363 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 1)),
13364 prefix_len: 32,
13365 },
13366 default: true,
13367 }];
13368 let gate = SharedSecurityGate::new(
13369 0,
13370 parse_governance_xml(GOV).unwrap(),
13371 Box::new(AesGcmCryptoPlugin::new()),
13372 );
13373 let cfg = RuntimeConfig {
13374 security: Some(std::sync::Arc::new(gate)),
13375 interface_bindings: bindings,
13376 ..RuntimeConfig::default()
13377 };
13378 let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF0; 12]), cfg).expect("rt");
13379
13380 let wid = rt
13381 .register_user_writer(UserWriterConfig {
13382 topic_name: "HeteroRouting".into(),
13383 type_name: "zerodds::RawBytes".into(),
13384 reliable: true,
13385 durability: zerodds_qos::DurabilityKind::Volatile,
13386 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13387 lifespan: zerodds_qos::LifespanQosPolicy::default(),
13388 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
13389 ownership: zerodds_qos::OwnershipKind::Shared,
13390 ownership_strength: 0,
13391 partition: Vec::new(),
13392 user_data: Vec::new(),
13393 topic_data: Vec::new(),
13394 group_data: Vec::new(),
13395 type_identifier: zerodds_types::TypeIdentifier::None,
13396 data_representation_offer: None,
13397 })
13398 .unwrap();
13399
13400 // Peer protection setup: Legacy=None for lo_target,
13401 // Encrypt for wan_target.
13402 let legacy_peer: [u8; 12] = [0x01; 12];
13403 let secure_peer: [u8; 12] = [0x02; 12];
13404 {
13405 let arc = rt.writer_slot(wid).unwrap();
13406 let mut slot = arc.lock().unwrap();
13407 slot.reader_protection
13408 .insert(legacy_peer, ProtectionLevel::None);
13409 slot.reader_protection
13410 .insert(secure_peer, ProtectionLevel::Encrypt);
13411 slot.locator_to_peer.insert(lo_target, legacy_peer);
13412 slot.locator_to_peer.insert(wan_target, secure_peer);
13413 }
13414
13415 // Fiktives Datagram.
13416 let mut msg = Vec::new();
13417 msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13418 msg.extend_from_slice(&[0xF0; 12]);
13419 msg.extend_from_slice(b"DOD-ROUTING-PAYLOAD");
13420
13421 // Generate the per-target wire + route via send_on_best_interface.
13422 let plain_wire = secure_outbound_for_target(&rt, wid, &msg, &lo_target).unwrap();
13423 let secure_wire = secure_outbound_for_target(&rt, wid, &msg, &wan_target).unwrap();
13424 assert_ne!(
13425 plain_wire, msg,
13426 "lo-target under rtps_protection=ENCRYPT also SRTPS (no plaintext leak)"
13427 );
13428 assert_ne!(secure_wire, msg, "wan-target: SRTPS-wrapped");
13429
13430 send_on_best_interface(&rt, &lo_target, &plain_wire);
13431 send_on_best_interface(&rt, &wan_target, &secure_wire);
13432
13433 // sniffer receive and compare.
13434 let mut buf = [0u8; 4096];
13435 let (n1, _) = lo_sniffer.recv_from(&mut buf).expect("lo snif got");
13436 assert_ne!(
13437 &buf[..n1],
13438 &msg[..],
13439 "loopback sniffer must see SRTPS (spec wins, no plaintext on a protected domain)"
13440 );
13441 assert_eq!(buf[20], 0x33, "lo output must begin with SRTPS_PREFIX");
13442 let (n2, _) = wan_sniffer.recv_from(&mut buf).expect("wan snif got");
13443 assert_ne!(&buf[..n2], &msg[..], "WAN sniffer must see SRTPS-wrapped");
13444 // Additionally: SRTPS marker at the 20th byte (after the RTPS header).
13445 // SRTPS_PREFIX-Submessage-Id = 0x33 (Spec §7.3.6.3).
13446 assert_eq!(
13447 buf[20], 0x33,
13448 "WAN output must begin with an SRTPS_PREFIX submessage"
13449 );
13450
13451 rt.shutdown();
13452 }
13453
13454 #[cfg(feature = "security")]
13455 #[test]
13456 fn inbound_loopback_accepts_plain_on_protected_domain() {
13457 // Plan §stage 6: the inbound dispatcher should accept plaintext
13458 // for loopback packets even on a protected domain
13459 // (bytes do not leave the host). That is
13460 // exactly the `NetInterface` consultation in classify_inbound.
13461 use zerodds_security_runtime::NetInterface as SecIf;
13462 const GOV: &str = r#"
13463<domain_access_rules>
13464 <domain_rule>
13465 <domains><id>0</id></domains>
13466 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13467 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13468 </domain_rule>
13469</domain_access_rules>
13470"#;
13471 let logger = std::sync::Arc::new(CapturingLogger::default());
13472 let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
13473
13474 let mut plain = Vec::new();
13475 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13476 plain.extend_from_slice(&[0x99; 12]);
13477 plain.extend_from_slice(b"loopback-plain-is-ok");
13478
13479 // Accepted on loopback — no log event.
13480 let out = secure_inbound_bytes(&rt, &plain, &SecIf::Loopback)
13481 .expect("loopback plain must be accepted");
13482 assert_eq!(out, plain);
13483 assert!(logger.events().is_empty());
13484
13485 // On WAN the same content → drop + error event.
13486 let out_wan = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
13487 assert!(out_wan.is_none());
13488 let evs = logger.events();
13489 assert_eq!(evs.len(), 1);
13490 assert_eq!(evs[0].0, zerodds_security_runtime::LogLevel::Error);
13491 assert!(
13492 evs[0].2.contains("iface=Wan"),
13493 "log message must carry iface"
13494 );
13495 rt.shutdown();
13496 }
13497
13498 #[cfg(feature = "security")]
13499 #[test]
13500 fn dod_inbound_per_interface_receive_via_pool_socket() {
13501 // Plan §stage 6 inbound DoD: each pool binding has its
13502 // own receive path, and the NetInterface class is
13503 // reflected in the log event (iface=<class>).
13504 //
13505 // Setup:
13506 // * DcpsRuntime with 1 InterfaceBinding (kind=Loopback,
13507 // subnet=127.0.0.0/8)
13508 // * Protected Governance + CapturingLogger
13509 // * We bind an external UDP socket and send two
13510 // plain packets:
13511 // a) to the pool socket (the event loop polls it and
13512 // classifies as loopback → accept without log)
13513 // b) we trigger secure_inbound_bytes directly with Wan
13514 // → error log with iface=Wan
13515 //
13516 // This proves that the per-interface receive path
13517 // exists and the iface class flows through the decision.
13518 use std::net::{SocketAddrV4, UdpSocket};
13519 use zerodds_security_crypto::AesGcmCryptoPlugin;
13520 use zerodds_security_permissions::parse_governance_xml;
13521 use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
13522
13523 const GOV: &str = r#"
13524<domain_access_rules>
13525 <domain_rule>
13526 <domains><id>0</id></domains>
13527 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13528 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13529 </domain_rule>
13530</domain_access_rules>
13531"#;
13532 let logger = std::sync::Arc::new(CapturingLogger::default());
13533 let gate = SharedSecurityGate::new(
13534 0,
13535 parse_governance_xml(GOV).unwrap(),
13536 Box::new(AesGcmCryptoPlugin::new()),
13537 );
13538 let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
13539 std::sync::Arc::clone(&logger) as _;
13540 let bindings = vec![InterfaceBindingSpec {
13541 name: "lo".into(),
13542 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13543 bind_port: 0,
13544 kind: SecIf::Loopback,
13545 subnet: zerodds_security_runtime::IpRange {
13546 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 0)),
13547 prefix_len: 8,
13548 },
13549 default: true,
13550 }];
13551 let cfg = RuntimeConfig {
13552 security: Some(std::sync::Arc::new(gate)),
13553 security_logger: Some(logger_dyn),
13554 interface_bindings: bindings,
13555 ..RuntimeConfig::default()
13556 };
13557 let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF1; 12]), cfg).expect("rt");
13558
13559 // Read the port of the pool binding (ephemeral).
13560 let pool_port = rt.outbound_pool.as_ref().unwrap().bindings[0]
13561 .socket
13562 .local_locator()
13563 .port as u16;
13564 assert!(pool_port > 0);
13565
13566 // An external socket sends a plain packet to the pool socket.
13567 let sender = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).unwrap();
13568 let mut plain = Vec::new();
13569 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13570 plain.extend_from_slice(&[0xAB; 12]);
13571 plain.extend_from_slice(b"loopback-dispatch");
13572 sender
13573 .send_to(
13574 &plain,
13575 SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), pool_port),
13576 )
13577 .unwrap();
13578
13579 // The event loop needs a few ticks to poll the packet.
13580 // The default tick_period is 50 ms; we wait a few of them.
13581 std::thread::sleep(Duration::from_millis(300));
13582
13583 // The pool packet, through classify_inbound with iface=Loopback,
13584 // ran → accept, no log events from this path.
13585 let pool_events = logger.events();
13586
13587 // Comparison test: the same packet through secure_inbound_bytes
13588 // with iface=Wan → error event with an iface=Wan marker.
13589 let _ = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
13590 let after = logger.events();
13591 assert!(
13592 after.len() > pool_events.len(),
13593 "the Wan path must produce a new log event"
13594 );
13595 let new_ev = &after[after.len() - 1];
13596 assert_eq!(new_ev.0, zerodds_security_runtime::LogLevel::Error);
13597 assert!(
13598 new_ev.2.contains("iface=Wan"),
13599 "log message carries the iface marker: got={:?}",
13600 new_ev.2
13601 );
13602
13603 // Log events from the pool path must NOT carry the error level
13604 // (because classify_inbound returns accept on loopback).
13605 for (lvl, cat, msg) in &pool_events {
13606 assert_ne!(
13607 *lvl,
13608 zerodds_security_runtime::LogLevel::Error,
13609 "the loopback path must not produce an error event: cat={cat} msg={msg}"
13610 );
13611 }
13612 rt.shutdown();
13613 }
13614
13615 #[cfg(feature = "security")]
13616 #[test]
13617 fn per_target_without_security_gate_is_passthrough() {
13618 // Without a `security` config in RuntimeConfig, the per-target
13619 // path is a pure passthrough. Important so that we do not
13620 // break the v1.4 backward compat.
13621 let rt = DcpsRuntime::start(
13622 0,
13623 GuidPrefix::from_bytes([0xE5; 12]),
13624 RuntimeConfig::default(),
13625 )
13626 .expect("rt");
13627 let wid = rt
13628 .register_user_writer(UserWriterConfig {
13629 topic_name: "T".into(),
13630 type_name: "zerodds::RawBytes".into(),
13631 reliable: true,
13632 durability: zerodds_qos::DurabilityKind::Volatile,
13633 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13634 lifespan: zerodds_qos::LifespanQosPolicy::default(),
13635 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
13636 ownership: zerodds_qos::OwnershipKind::Shared,
13637 ownership_strength: 0,
13638 partition: Vec::new(),
13639 user_data: Vec::new(),
13640 topic_data: Vec::new(),
13641 group_data: Vec::new(),
13642 type_identifier: zerodds_types::TypeIdentifier::None,
13643 data_representation_offer: None,
13644 })
13645 .unwrap();
13646 let tgt = Locator::udp_v4([127, 0, 0, 1], 40000);
13647 let msg = b"raw-plaintext".to_vec();
13648 let out = secure_outbound_for_target(&rt, wid, &msg, &tgt).unwrap();
13649 assert_eq!(out, msg, "without a gate it must be passthrough");
13650 rt.shutdown();
13651 }
13652
13653 // ---- Builtin-Topic-Reader Discovery-Hook (DDS 1.4 §2.2.5) ----
13654
13655 /// Helper: constructs a synthetic SPDP beacon
13656 /// for a remote participant, so that `handle_spdp_datagram`
13657 /// accepts it.
13658 fn make_remote_spdp_beacon(remote_prefix: GuidPrefix) -> Vec<u8> {
13659 use zerodds_discovery::spdp::SpdpBeacon;
13660 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
13661 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
13662 let data = ParticipantBuiltinTopicData {
13663 guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
13664 protocol_version: ProtocolVersion::V2_5,
13665 vendor_id: VendorId::ZERODDS,
13666 default_unicast_locator: None,
13667 default_multicast_locator: None,
13668 metatraffic_unicast_locator: None,
13669 metatraffic_multicast_locator: None,
13670 domain_id: Some(0),
13671 builtin_endpoint_set: 0,
13672 lease_duration: QosDuration::from_secs(100),
13673 user_data: alloc::vec::Vec::new(),
13674 properties: Default::default(),
13675 identity_token: None,
13676 permissions_token: None,
13677 identity_status_token: None,
13678 sig_algo_info: None,
13679 kx_algo_info: None,
13680 sym_cipher_algo_info: None,
13681 participant_security_info: None,
13682 };
13683 let mut beacon = SpdpBeacon::new(data);
13684 beacon.serialize().expect("serialize")
13685 }
13686
13687 #[test]
13688 fn handle_spdp_datagram_pushes_into_builtin_participant_reader() {
13689 let rt = DcpsRuntime::start(
13690 41,
13691 GuidPrefix::from_bytes([0x21; 12]),
13692 RuntimeConfig::default(),
13693 )
13694 .expect("start");
13695 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13696 rt.attach_builtin_sinks(bs.sinks());
13697
13698 let remote = GuidPrefix::from_bytes([0x99; 12]);
13699 let dg = make_remote_spdp_beacon(remote);
13700 // A direct hook call simulates an SPDP receive without multicast.
13701 handle_spdp_datagram(&rt, &dg);
13702
13703 let reader = bs
13704 .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13705 "DCPSParticipant",
13706 )
13707 .unwrap();
13708 let samples = reader.take().unwrap();
13709 assert_eq!(samples.len(), 1, "exactly 1 sample for 1 SPDP beacon");
13710 assert_eq!(samples[0].key.prefix, remote);
13711 rt.shutdown();
13712 }
13713
13714 #[test]
13715 fn handle_spdp_datagram_skips_self_beacon() {
13716 let prefix = GuidPrefix::from_bytes([0x22; 12]);
13717 let rt = DcpsRuntime::start(42, prefix, RuntimeConfig::default()).expect("start");
13718 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13719 rt.attach_builtin_sinks(bs.sinks());
13720
13721 // Beacon from our own prefix → must be ignored (Spec
13722 // §8.5.4 self-discovery filter).
13723 let dg = make_remote_spdp_beacon(prefix);
13724 handle_spdp_datagram(&rt, &dg);
13725
13726 let reader = bs
13727 .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13728 "DCPSParticipant",
13729 )
13730 .unwrap();
13731 let samples = reader.take().unwrap();
13732 assert!(samples.is_empty(), "own beacon must not be logged");
13733 rt.shutdown();
13734 }
13735
13736 #[test]
13737 fn sedp_event_push_populates_publication_and_topic_readers() {
13738 use crate::builtin_topics as bt;
13739 use zerodds_discovery::sedp::SedpEvents;
13740 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13741 let rt = DcpsRuntime::start(
13742 43,
13743 GuidPrefix::from_bytes([0x23; 12]),
13744 RuntimeConfig::default(),
13745 )
13746 .expect("start");
13747 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13748 rt.attach_builtin_sinks(bs.sinks());
13749
13750 let mut events = SedpEvents::default();
13751 events.new_publications.push(
13752 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13753 key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
13754 participant_key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
13755 topic_name: "WireT".into(),
13756 type_name: "WireType".into(),
13757 durability: zerodds_qos::DurabilityKind::Volatile,
13758 reliability: ReliabilityQosPolicy::default(),
13759 ownership: zerodds_qos::OwnershipKind::Shared,
13760 ownership_strength: 0,
13761 liveliness: LivelinessQosPolicy::default(),
13762 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13763 lifespan: zerodds_qos::LifespanQosPolicy::default(),
13764 partition: Vec::new(),
13765 user_data: Vec::new(),
13766 topic_data: Vec::new(),
13767 group_data: Vec::new(),
13768 type_information: None,
13769 data_representation: Vec::new(),
13770 security_info: None,
13771 service_instance_name: None,
13772 related_entity_guid: None,
13773 topic_aliases: None,
13774 type_identifier: zerodds_types::TypeIdentifier::None,
13775 unicast_locators: Vec::new(),
13776 multicast_locators: Vec::new(),
13777 },
13778 );
13779
13780 push_sedp_events_to_builtin_readers(&rt, &events);
13781
13782 let pub_reader = bs
13783 .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
13784 .unwrap();
13785 let pub_samples = pub_reader.take().unwrap();
13786 assert_eq!(pub_samples.len(), 1);
13787 assert_eq!(pub_samples[0].topic_name, "WireT");
13788
13789 let topic_reader = bs
13790 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13791 .unwrap();
13792 let topic_samples = topic_reader.take().unwrap();
13793 assert_eq!(topic_samples.len(), 1);
13794 assert_eq!(topic_samples[0].name, "WireT");
13795 rt.shutdown();
13796 }
13797
13798 #[test]
13799 fn sedp_event_push_populates_subscription_reader() {
13800 use crate::builtin_topics as bt;
13801 use zerodds_discovery::sedp::SedpEvents;
13802 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13803 let rt = DcpsRuntime::start(
13804 44,
13805 GuidPrefix::from_bytes([0x24; 12]),
13806 RuntimeConfig::default(),
13807 )
13808 .expect("start");
13809 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13810 rt.attach_builtin_sinks(bs.sinks());
13811
13812 let mut events = SedpEvents::default();
13813 events.new_subscriptions.push(
13814 zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
13815 key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
13816 participant_key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
13817 topic_name: "SubT".into(),
13818 type_name: "SubType".into(),
13819 durability: zerodds_qos::DurabilityKind::Volatile,
13820 reliability: ReliabilityQosPolicy::default(),
13821 ownership: zerodds_qos::OwnershipKind::Shared,
13822 liveliness: LivelinessQosPolicy::default(),
13823 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13824 partition: Vec::new(),
13825 user_data: Vec::new(),
13826 topic_data: Vec::new(),
13827 group_data: Vec::new(),
13828 type_information: None,
13829 data_representation: Vec::new(),
13830 content_filter: None,
13831 security_info: None,
13832 service_instance_name: None,
13833 related_entity_guid: None,
13834 topic_aliases: None,
13835 type_identifier: zerodds_types::TypeIdentifier::None,
13836 unicast_locators: Vec::new(),
13837 multicast_locators: Vec::new(),
13838 },
13839 );
13840
13841 push_sedp_events_to_builtin_readers(&rt, &events);
13842
13843 let sub_reader = bs
13844 .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
13845 .unwrap();
13846 let sub_samples = sub_reader.take().unwrap();
13847 assert_eq!(sub_samples.len(), 1);
13848 assert_eq!(sub_samples[0].topic_name, "SubT");
13849
13850 // The topic reader gets a synthetic topic sample also from
13851 // Subscription.
13852 let topic_reader = bs
13853 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13854 .unwrap();
13855 let topic_samples = topic_reader.take().unwrap();
13856 assert_eq!(topic_samples.len(), 1);
13857 assert_eq!(topic_samples[0].name, "SubT");
13858 rt.shutdown();
13859 }
13860
13861 #[test]
13862 fn push_sedp_events_to_builtin_readers_is_noop_without_sinks() {
13863 use zerodds_discovery::sedp::SedpEvents;
13864 let rt = DcpsRuntime::start(
13865 45,
13866 GuidPrefix::from_bytes([0x25; 12]),
13867 RuntimeConfig::default(),
13868 )
13869 .expect("start");
13870 // No attach_builtin_sinks → push must stay silent, not
13871 // panic.
13872 let events = SedpEvents::default();
13873 push_sedp_events_to_builtin_readers(&rt, &events);
13874 rt.shutdown();
13875 }
13876
13877 // ---- Ignore-Filter im Discovery-Hot-Path -------------
13878
13879 #[test]
13880 fn handle_spdp_datagram_drops_ignored_participant_beacon() {
13881 // Spec §2.2.2.2.1.14: ein einmal ignorierter Participant
13882 // taucht in keinem nachfolgenden Builtin-Sample mehr auf.
13883 let rt = DcpsRuntime::start(
13884 46,
13885 GuidPrefix::from_bytes([0x26; 12]),
13886 RuntimeConfig::default(),
13887 )
13888 .expect("start");
13889 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13890 rt.attach_builtin_sinks(bs.sinks());
13891 let filter = crate::participant::IgnoreFilter::default();
13892 rt.attach_ignore_filter(filter.clone());
13893
13894 let remote = GuidPrefix::from_bytes([0xAA; 12]);
13895 // Derive the ignore handle from the future beacon — we
13896 // know that the builtin sample key is the GUID of the remote
13897 // participant (=prefix + EntityId::PARTICIPANT).
13898 let key = Guid::new(remote, EntityId::PARTICIPANT);
13899 let h = crate::instance_handle::InstanceHandle::from_guid(key);
13900 if let Ok(mut s) = filter.inner.participants.lock() {
13901 s.insert(h);
13902 }
13903 let dg = make_remote_spdp_beacon(remote);
13904 handle_spdp_datagram(&rt, &dg);
13905
13906 let reader = bs
13907 .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13908 "DCPSParticipant",
13909 )
13910 .unwrap();
13911 assert!(
13912 reader.take().unwrap().is_empty(),
13913 "an ignored participant must not land in DCPSParticipant"
13914 );
13915 rt.shutdown();
13916 }
13917
13918 #[test]
13919 fn sedp_event_push_filters_ignored_publication() {
13920 use crate::builtin_topics as bt;
13921 use zerodds_discovery::sedp::SedpEvents;
13922 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13923 let rt = DcpsRuntime::start(
13924 47,
13925 GuidPrefix::from_bytes([0x27; 12]),
13926 RuntimeConfig::default(),
13927 )
13928 .expect("start");
13929 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13930 rt.attach_builtin_sinks(bs.sinks());
13931 let filter = crate::participant::IgnoreFilter::default();
13932 rt.attach_ignore_filter(filter.clone());
13933
13934 let pub_key = Guid::new(GuidPrefix::from_bytes([0x33; 12]), EntityId::PARTICIPANT);
13935 let h_pub = crate::instance_handle::InstanceHandle::from_guid(pub_key);
13936 if let Ok(mut s) = filter.inner.publications.lock() {
13937 s.insert(h_pub);
13938 }
13939
13940 let mut events = SedpEvents::default();
13941 events.new_publications.push(
13942 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13943 key: pub_key,
13944 participant_key: Guid::new(
13945 GuidPrefix::from_bytes([0x33; 12]),
13946 EntityId::PARTICIPANT,
13947 ),
13948 topic_name: "Filtered".into(),
13949 type_name: "T".into(),
13950 durability: zerodds_qos::DurabilityKind::Volatile,
13951 reliability: ReliabilityQosPolicy::default(),
13952 ownership: zerodds_qos::OwnershipKind::Shared,
13953 ownership_strength: 0,
13954 liveliness: LivelinessQosPolicy::default(),
13955 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13956 lifespan: zerodds_qos::LifespanQosPolicy::default(),
13957 partition: Vec::new(),
13958 user_data: Vec::new(),
13959 topic_data: Vec::new(),
13960 group_data: Vec::new(),
13961 type_information: None,
13962 data_representation: Vec::new(),
13963 security_info: None,
13964 service_instance_name: None,
13965 related_entity_guid: None,
13966 topic_aliases: None,
13967 type_identifier: zerodds_types::TypeIdentifier::None,
13968 unicast_locators: Vec::new(),
13969 multicast_locators: Vec::new(),
13970 },
13971 );
13972
13973 push_sedp_events_to_builtin_readers(&rt, &events);
13974
13975 let pub_reader = bs
13976 .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
13977 .unwrap();
13978 assert!(
13979 pub_reader.take().unwrap().is_empty(),
13980 "an ignored publication must not land in DCPSPublication"
13981 );
13982 // The synthetic DCPSTopic sample too must not be
13983 // forwarded, because the publication is completely
13984 // discarded.
13985 let topic_reader = bs
13986 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13987 .unwrap();
13988 assert!(topic_reader.take().unwrap().is_empty());
13989 rt.shutdown();
13990 }
13991
13992 #[test]
13993 fn sedp_event_push_filters_ignored_subscription() {
13994 use crate::builtin_topics as bt;
13995 use zerodds_discovery::sedp::SedpEvents;
13996 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13997 let rt = DcpsRuntime::start(
13998 48,
13999 GuidPrefix::from_bytes([0x28; 12]),
14000 RuntimeConfig::default(),
14001 )
14002 .expect("start");
14003 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
14004 rt.attach_builtin_sinks(bs.sinks());
14005 let filter = crate::participant::IgnoreFilter::default();
14006 rt.attach_ignore_filter(filter.clone());
14007
14008 let sub_key = Guid::new(GuidPrefix::from_bytes([0x44; 12]), EntityId::PARTICIPANT);
14009 let h_sub = crate::instance_handle::InstanceHandle::from_guid(sub_key);
14010 if let Ok(mut s) = filter.inner.subscriptions.lock() {
14011 s.insert(h_sub);
14012 }
14013
14014 let mut events = SedpEvents::default();
14015 events.new_subscriptions.push(
14016 zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
14017 key: sub_key,
14018 participant_key: Guid::new(
14019 GuidPrefix::from_bytes([0x44; 12]),
14020 EntityId::PARTICIPANT,
14021 ),
14022 topic_name: "FilteredSub".into(),
14023 type_name: "T".into(),
14024 durability: zerodds_qos::DurabilityKind::Volatile,
14025 reliability: ReliabilityQosPolicy::default(),
14026 ownership: zerodds_qos::OwnershipKind::Shared,
14027 liveliness: LivelinessQosPolicy::default(),
14028 deadline: zerodds_qos::DeadlineQosPolicy::default(),
14029 partition: Vec::new(),
14030 user_data: Vec::new(),
14031 topic_data: Vec::new(),
14032 group_data: Vec::new(),
14033 type_information: None,
14034 data_representation: Vec::new(),
14035 content_filter: None,
14036 security_info: None,
14037 service_instance_name: None,
14038 related_entity_guid: None,
14039 topic_aliases: None,
14040 type_identifier: zerodds_types::TypeIdentifier::None,
14041 unicast_locators: Vec::new(),
14042 multicast_locators: Vec::new(),
14043 },
14044 );
14045
14046 push_sedp_events_to_builtin_readers(&rt, &events);
14047
14048 let sub_reader = bs
14049 .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
14050 .unwrap();
14051 assert!(sub_reader.take().unwrap().is_empty());
14052 rt.shutdown();
14053 }
14054
14055 #[test]
14056 fn sedp_event_push_filters_ignored_topic_only() {
14057 // If only the topic is ignored, DCPSPublication should
14058 // still be pushed — only the DCPSTopic sample falls
14059 // away.
14060 use crate::builtin_topics as bt;
14061 use zerodds_discovery::sedp::SedpEvents;
14062 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
14063 let rt = DcpsRuntime::start(
14064 49,
14065 GuidPrefix::from_bytes([0x29; 12]),
14066 RuntimeConfig::default(),
14067 )
14068 .expect("start");
14069 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
14070 rt.attach_builtin_sinks(bs.sinks());
14071 let filter = crate::participant::IgnoreFilter::default();
14072 rt.attach_ignore_filter(filter.clone());
14073
14074 let topic_key =
14075 crate::builtin_topics::TopicBuiltinTopicData::synthesize_key("OnlyTopic", "T");
14076 let h_topic = crate::instance_handle::InstanceHandle::from_guid(topic_key);
14077 if let Ok(mut s) = filter.inner.topics.lock() {
14078 s.insert(h_topic);
14079 }
14080
14081 let mut events = SedpEvents::default();
14082 events.new_publications.push(
14083 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
14084 key: Guid::new(GuidPrefix::from_bytes([0x55; 12]), EntityId::PARTICIPANT),
14085 participant_key: Guid::new(
14086 GuidPrefix::from_bytes([0x55; 12]),
14087 EntityId::PARTICIPANT,
14088 ),
14089 topic_name: "OnlyTopic".into(),
14090 type_name: "T".into(),
14091 durability: zerodds_qos::DurabilityKind::Volatile,
14092 reliability: ReliabilityQosPolicy::default(),
14093 ownership: zerodds_qos::OwnershipKind::Shared,
14094 ownership_strength: 0,
14095 liveliness: LivelinessQosPolicy::default(),
14096 deadline: zerodds_qos::DeadlineQosPolicy::default(),
14097 lifespan: zerodds_qos::LifespanQosPolicy::default(),
14098 partition: Vec::new(),
14099 user_data: Vec::new(),
14100 topic_data: Vec::new(),
14101 group_data: Vec::new(),
14102 type_information: None,
14103 data_representation: Vec::new(),
14104 security_info: None,
14105 service_instance_name: None,
14106 related_entity_guid: None,
14107 topic_aliases: None,
14108 type_identifier: zerodds_types::TypeIdentifier::None,
14109 unicast_locators: Vec::new(),
14110 multicast_locators: Vec::new(),
14111 },
14112 );
14113
14114 push_sedp_events_to_builtin_readers(&rt, &events);
14115
14116 let pub_reader = bs
14117 .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
14118 .unwrap();
14119 assert_eq!(pub_reader.take().unwrap().len(), 1);
14120 let topic_reader = bs
14121 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
14122 .unwrap();
14123 assert!(
14124 topic_reader.take().unwrap().is_empty(),
14125 "an ignored topic may block the synthetic DCPSTopic sample"
14126 );
14127 rt.shutdown();
14128 }
14129
14130 // -------- Security-Builtin-Endpoint-Wiring --------
14131
14132 /// Creates an SPDP beacon with configurable BuiltinEndpoint
14133 /// bits. Extension of [`make_remote_spdp_beacon`] with
14134 /// flag-Argument (Security-Bits 22..25).
14135 fn make_remote_spdp_beacon_with_flags(remote_prefix: GuidPrefix, endpoint_set: u32) -> Vec<u8> {
14136 use zerodds_discovery::spdp::SpdpBeacon;
14137 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
14138 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
14139 let data = ParticipantBuiltinTopicData {
14140 guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
14141 protocol_version: ProtocolVersion::V2_5,
14142 vendor_id: VendorId::ZERODDS,
14143 default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
14144 default_multicast_locator: None,
14145 metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
14146 metatraffic_multicast_locator: None,
14147 domain_id: Some(0),
14148 builtin_endpoint_set: endpoint_set,
14149 lease_duration: QosDuration::from_secs(100),
14150 user_data: alloc::vec::Vec::new(),
14151 properties: Default::default(),
14152 identity_token: None,
14153 permissions_token: None,
14154 identity_status_token: None,
14155 sig_algo_info: None,
14156 kx_algo_info: None,
14157 sym_cipher_algo_info: None,
14158 participant_security_info: None,
14159 };
14160 let mut beacon = SpdpBeacon::new(data);
14161 beacon.serialize().expect("serialize")
14162 }
14163
14164 fn dp_with_locators(
14165 prefix: GuidPrefix,
14166 metatraffic: Option<Locator>,
14167 default: Option<Locator>,
14168 ) -> zerodds_discovery::spdp::DiscoveredParticipant {
14169 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
14170 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
14171 zerodds_discovery::spdp::DiscoveredParticipant {
14172 sender_prefix: prefix,
14173 sender_vendor: VendorId::ZERODDS,
14174 data: ParticipantBuiltinTopicData {
14175 guid: Guid::new(prefix, EntityId::PARTICIPANT),
14176 protocol_version: ProtocolVersion::V2_5,
14177 vendor_id: VendorId::ZERODDS,
14178 default_unicast_locator: default,
14179 default_multicast_locator: None,
14180 metatraffic_unicast_locator: metatraffic,
14181 metatraffic_multicast_locator: None,
14182 domain_id: Some(0),
14183 builtin_endpoint_set: 0,
14184 lease_duration: QosDuration::from_secs(100),
14185 user_data: alloc::vec::Vec::new(),
14186 properties: Default::default(),
14187 identity_token: None,
14188 permissions_token: None,
14189 identity_status_token: None,
14190 sig_algo_info: None,
14191 kx_algo_info: None,
14192 sym_cipher_algo_info: None,
14193 participant_security_info: None,
14194 },
14195 }
14196 }
14197
14198 #[test]
14199 fn wlp_unicast_targets_prefers_metatraffic_then_default() {
14200 // M-2: WLP-Unicast-Fan-out waehlt pro Peer metatraffic_unicast (bevorzugt),
14201 // otherwise default_unicast; peers without a routable locator fall out.
14202 let meta = Locator::udp_v4([127, 0, 0, 1], 7501);
14203 let deflt = Locator::udp_v4([127, 0, 0, 2], 7500);
14204 let peers = alloc::vec![
14205 // (a) has metatraffic → metatraffic wins
14206 dp_with_locators(GuidPrefix::from_bytes([1; 12]), Some(meta), Some(deflt)),
14207 // (b) only default → default
14208 dp_with_locators(GuidPrefix::from_bytes([2; 12]), None, Some(deflt)),
14209 // (c) none at all → no target
14210 dp_with_locators(GuidPrefix::from_bytes([3; 12]), None, None),
14211 ];
14212 let targets = wlp_unicast_targets(&peers);
14213 assert_eq!(targets, alloc::vec![meta, deflt]);
14214 }
14215
14216 /// Like [`make_remote_spdp_beacon_with_flags`], but with a set
14217 /// `identity_token` (FU2 Gap 7d — triggers the auth handshake).
14218 #[cfg(feature = "security")]
14219 fn make_secure_beacon_with_identity_token(
14220 remote_prefix: GuidPrefix,
14221 endpoint_set: u32,
14222 identity_token: Vec<u8>,
14223 ) -> Vec<u8> {
14224 use zerodds_discovery::spdp::SpdpBeacon;
14225 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
14226 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
14227 let data = ParticipantBuiltinTopicData {
14228 guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
14229 protocol_version: ProtocolVersion::V2_5,
14230 vendor_id: VendorId::ZERODDS,
14231 default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
14232 default_multicast_locator: None,
14233 metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
14234 metatraffic_multicast_locator: None,
14235 domain_id: Some(0),
14236 builtin_endpoint_set: endpoint_set,
14237 lease_duration: QosDuration::from_secs(100),
14238 user_data: alloc::vec::Vec::new(),
14239 properties: Default::default(),
14240 identity_token: Some(identity_token),
14241 permissions_token: None,
14242 identity_status_token: None,
14243 sig_algo_info: None,
14244 kx_algo_info: None,
14245 sym_cipher_algo_info: None,
14246 participant_security_info: None,
14247 };
14248 let mut beacon = SpdpBeacon::new(data);
14249 beacon.serialize().expect("serialize")
14250 }
14251
14252 /// Minimal auth plugin for the FU2 wiring tests (Gap 4/7).
14253 /// Crypto correctness is verified in the stack.rs driver test; here
14254 /// it is only about the runtime wiring path.
14255 #[cfg(feature = "security")]
14256 struct FakeAuth;
14257 #[cfg(feature = "security")]
14258 impl zerodds_security::authentication::AuthenticationPlugin for FakeAuth {
14259 fn validate_local_identity(
14260 &mut self,
14261 _: &zerodds_security::properties::PropertyList,
14262 _: [u8; 16],
14263 ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
14264 {
14265 Ok(zerodds_security::authentication::IdentityHandle(1))
14266 }
14267 fn validate_remote_identity(
14268 &mut self,
14269 _: zerodds_security::authentication::IdentityHandle,
14270 _: [u8; 16],
14271 _: &[u8],
14272 ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
14273 {
14274 Ok(zerodds_security::authentication::IdentityHandle(2))
14275 }
14276 fn begin_handshake_request(
14277 &mut self,
14278 _: zerodds_security::authentication::IdentityHandle,
14279 _: zerodds_security::authentication::IdentityHandle,
14280 ) -> zerodds_security::error::SecurityResult<(
14281 zerodds_security::authentication::HandshakeHandle,
14282 zerodds_security::authentication::HandshakeStepOutcome,
14283 )> {
14284 Ok((
14285 zerodds_security::authentication::HandshakeHandle(1),
14286 zerodds_security::authentication::HandshakeStepOutcome::SendMessage {
14287 token: zerodds_security::token::DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")
14288 .to_cdr_le(),
14289 },
14290 ))
14291 }
14292 fn begin_handshake_reply(
14293 &mut self,
14294 _: zerodds_security::authentication::IdentityHandle,
14295 _: zerodds_security::authentication::IdentityHandle,
14296 _: &[u8],
14297 ) -> zerodds_security::error::SecurityResult<(
14298 zerodds_security::authentication::HandshakeHandle,
14299 zerodds_security::authentication::HandshakeStepOutcome,
14300 )> {
14301 Ok((
14302 zerodds_security::authentication::HandshakeHandle(2),
14303 zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer,
14304 ))
14305 }
14306 fn process_handshake(
14307 &mut self,
14308 _: zerodds_security::authentication::HandshakeHandle,
14309 _: &[u8],
14310 ) -> zerodds_security::error::SecurityResult<
14311 zerodds_security::authentication::HandshakeStepOutcome,
14312 > {
14313 Ok(zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer)
14314 }
14315 fn shared_secret(
14316 &self,
14317 _: zerodds_security::authentication::HandshakeHandle,
14318 ) -> zerodds_security::error::SecurityResult<
14319 zerodds_security::authentication::SharedSecretHandle,
14320 > {
14321 Err(zerodds_security::error::SecurityError::new(
14322 zerodds_security::error::SecurityErrorKind::BadArgument,
14323 "fake: handshake not complete",
14324 ))
14325 }
14326 fn plugin_class_id(&self) -> &str {
14327 "FAKE:Auth:1.0"
14328 }
14329 fn get_identity_token(
14330 &self,
14331 _: zerodds_security::authentication::IdentityHandle,
14332 ) -> zerodds_security::error::SecurityResult<Vec<u8>> {
14333 // Non-empty Token (Format irrelevant — FakeAuth.validate_remote_
14334 // identity accepts everything); only so the beacon-populate path
14335 // (Gap 7c) has something to announce.
14336 Ok(alloc::vec![0xAB, 0xCD, 0xEF, 0x01])
14337 }
14338 fn get_permissions_token(&self) -> Vec<u8> {
14339 // Non-empty PermissionsToken, so the beacon-populate path
14340 // (S4 point 1) has something to announce (format irrelevant).
14341 zerodds_security::token::DataHolder::new("DDS:Access:Permissions:1.0").to_cdr_le()
14342 }
14343 }
14344
14345 /// Consolidated test for the wiring. A single
14346 /// runtime walks all paths — snapshot API, idempotency of
14347 /// `enable_security_builtins`, SPDP hot path with security bits,
14348 /// without bits, plus the wire-demux hook. We bundle this into one
14349 /// test body, because each `DcpsRuntime::start` binds a multicast socket
14350 /// and parallel tests could brush against the OS resource caps.
14351 #[test]
14352 fn c34c_security_builtin_wiring_end_to_end() {
14353 use zerodds_discovery::security::SecurityBuiltinStack;
14354 use zerodds_security::generic_message::{
14355 MessageIdentity, ParticipantGenericMessage, class_id,
14356 };
14357 use zerodds_security::token::DataHolder;
14358
14359 let local_prefix = GuidPrefix::from_bytes([0x75; 12]);
14360 let rt = DcpsRuntime::start(75, local_prefix, RuntimeConfig::default()).expect("start");
14361
14362 // 1. Snapshot is None before enable
14363 assert!(rt.security_builtin_snapshot().is_none());
14364
14365 // 2. enable ist idempotent
14366 let h1 = rt.enable_security_builtins(VendorId::ZERODDS);
14367 let h2 = rt.enable_security_builtins(VendorId::ZERODDS);
14368 assert!(Arc::ptr_eq(&h1, &h2));
14369 assert!(rt.security_builtin_snapshot().is_some());
14370
14371 // 3. SPDP beacon with all security-builtin bits → the stack has
14372 // four proxies
14373 let remote_a = GuidPrefix::from_bytes([0x99; 12]);
14374 let flags_all = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14375 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
14376 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
14377 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
14378 handle_spdp_datagram(
14379 &rt,
14380 &make_remote_spdp_beacon_with_flags(remote_a, flags_all),
14381 );
14382 {
14383 let s = h1.lock().unwrap();
14384 assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
14385 assert_eq!(s.stateless_reader.writer_proxy_count(), 1);
14386 assert_eq!(s.volatile_writer.reader_proxy_count(), 1);
14387 assert_eq!(s.volatile_reader.writer_proxy_count(), 1);
14388 }
14389
14390 // 4. SPDP beacon without security bits → the stack stays unchanged
14391 let remote_b = GuidPrefix::from_bytes([0x88; 12]);
14392 handle_spdp_datagram(
14393 &rt,
14394 &make_remote_spdp_beacon_with_flags(remote_b, endpoint_flag::ALL_STANDARD),
14395 );
14396 {
14397 let s = h1.lock().unwrap();
14398 assert_eq!(
14399 s.stateless_writer.reader_proxy_count(),
14400 1,
14401 "a peer without security bits must not touch existing proxies"
14402 );
14403 }
14404
14405 // 5. Wire-demux hook with a valid stateless DATA: remote-stack
14406 // mirror sends a message → the demux hook routes it through
14407 // the local reader without panic.
14408 let mut remote_stack = SecurityBuiltinStack::new(remote_a, VendorId::ZERODDS);
14409 let local_peer = make_remote_spdp_beacon_with_flags(local_prefix, flags_all);
14410 let parsed_local = zerodds_discovery::spdp::SpdpReader::new()
14411 .parse_datagram(&local_peer)
14412 .unwrap();
14413 remote_stack.handle_remote_endpoints(&parsed_local);
14414 let msg = ParticipantGenericMessage {
14415 message_identity: MessageIdentity {
14416 source_guid: [0xCD; 16],
14417 sequence_number: 1,
14418 },
14419 related_message_identity: MessageIdentity::default(),
14420 destination_participant_key: [0xEF; 16],
14421 destination_endpoint_key: [0; 16],
14422 source_endpoint_key: [0xFE; 16],
14423 message_class_id: class_id::AUTH_REQUEST.into(),
14424 message_data: alloc::vec![DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")],
14425 };
14426 let dgs = remote_stack.stateless_writer.write(&msg).unwrap();
14427 assert_eq!(dgs.len(), 1);
14428 dispatch_security_builtin_datagram(&rt, &dgs[0].bytes, Duration::from_secs(1));
14429
14430 // 6. The demux hook does not panic on garbage bytes
14431 dispatch_security_builtin_datagram(&rt, &[0u8; 32], Duration::from_secs(1));
14432
14433 rt.shutdown();
14434 }
14435
14436 /// FU2 Gap 4: `enable_security_builtins_with_auth` builds the stack with
14437 /// an active handshake driver — `begin_handshake_with` sends, as
14438 /// the initiator actually sends an AUTH_REQUEST (instead of a no-op like with
14439 /// the auth-less `enable_security_builtins`).
14440 #[cfg(feature = "security")]
14441 #[test]
14442 fn enable_security_builtins_with_auth_activates_handshake_driver() {
14443 use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14444
14445 let local_prefix = GuidPrefix::from_bytes([0x40; 12]);
14446 let rt = DcpsRuntime::start(40, local_prefix, RuntimeConfig::default()).expect("start");
14447
14448 let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14449 let stack =
14450 rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
14451
14452 // Discover a peer with stateless bits (WITHOUT identity_token → the
14453 // discovery trigger starts no handshake yet) → proxies
14454 // are wired. The remote prefix is LARGER than local ([0x40]),
14455 // so that local is the initiator under the cyclone convention (smaller GUID
14456 // initiates) and actually sends.
14457 let remote = GuidPrefix::from_bytes([0x99; 12]);
14458 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14459 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14460 handle_spdp_datagram(&rt, &make_remote_spdp_beacon_with_flags(remote, flags));
14461
14462 let dgs = {
14463 let mut s = stack.lock().unwrap();
14464 let remote_guid = Guid::new(remote, EntityId::PARTICIPANT).to_bytes();
14465 s.begin_handshake_with(remote, remote_guid, b"fake-remote-cert-der")
14466 .expect("begin_handshake_with")
14467 };
14468 assert_eq!(
14469 dgs.len(),
14470 1,
14471 "auth driver active → the initiator sends exactly one AUTH_REQUEST"
14472 );
14473
14474 rt.shutdown();
14475 }
14476
14477 /// FU2 Gap 7c/d: `enable_security_builtins_with_auth` announces the
14478 /// local `identity_token` in the SPDP beacon (+ stateless/volatile bits),
14479 /// and an incoming peer beacon WITH an `identity_token` kicks off the
14480 /// Auth-Handshake an (Discovery-Trigger).
14481 #[cfg(feature = "security")]
14482 #[test]
14483 fn spdp_beacon_announces_identity_token_and_discovery_triggers_handshake() {
14484 use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14485
14486 let local_prefix = GuidPrefix::from_bytes([0x41; 12]);
14487 let rt = DcpsRuntime::start(41, local_prefix, RuntimeConfig::default()).expect("start");
14488 let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14489 let stack =
14490 rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
14491
14492 // Gap 7c: the beacon now announces identity_token + secure bits.
14493 let beacon_bytes = rt.spdp_beacon.lock().unwrap().serialize().unwrap();
14494 let parsed = zerodds_discovery::spdp::SpdpReader::new()
14495 .parse_datagram(&beacon_bytes)
14496 .unwrap();
14497 assert!(
14498 parsed.data.identity_token.is_some(),
14499 "the beacon must announce PID_IDENTITY_TOKEN"
14500 );
14501 // Cross-vendor: secure vendors validate a remote only when
14502 // SPDP carries **both** tokens. Without PID_PERMISSIONS_TOKEN they treat
14503 // cyclone treats us as non-secure and never starts validate_remote_identity.
14504 assert!(
14505 parsed.data.permissions_token.is_some(),
14506 "the beacon must announce PID_PERMISSIONS_TOKEN (cross-vendor mandatory)"
14507 );
14508 assert_ne!(
14509 parsed.data.builtin_endpoint_set & endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER,
14510 0,
14511 "the beacon must announce the stateless-auth bit"
14512 );
14513
14514 // Gap 7d: peer beacon WITH identity_token + stateless bits → the
14515 // discovery path kicks off begin_handshake_with.
14516 let remote = GuidPrefix::from_bytes([0x99; 12]);
14517 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14518 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14519 let peer_beacon =
14520 make_secure_beacon_with_identity_token(remote, flags, alloc::vec![0x11, 0x22, 0x33]);
14521 handle_spdp_datagram(&rt, &peer_beacon);
14522
14523 // Proof that the discovery trigger fired: the peer is now
14524 // registered in the stack's handshake state. (The earlier length
14525 // probe via a repeated begin_handshake_with no longer applies since the resend path
14526 // resends as the initiator on a repeated call.)
14527 let started = {
14528 let s = stack.lock().unwrap();
14529 s.handshake_peer_count()
14530 };
14531 assert_eq!(
14532 started, 1,
14533 "the discovery trigger must have started the handshake (peer registered)"
14534 );
14535
14536 rt.shutdown();
14537 }
14538
14539 /// FU2 S3: two secure runtimes in the same process MUST find each other via
14540 /// in-process participant discovery and kick off the auth handshake
14541 /// — WITHOUT a single multicast beacon. That was exactly missing:
14542 /// `inproc_inject_publication`/`_subscription` inject only SEDP, the
14543 /// SPDP participant discovery (identity_token + `begin_handshake_with`)
14544 /// ran exclusively over the flaky multicast path.
14545 #[cfg(feature = "security")]
14546 #[test]
14547 fn inproc_participant_discovery_triggers_handshake_without_multicast() {
14548 use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14549
14550 let a_prefix = GuidPrefix::from_bytes([0x4A; 12]);
14551 let b_prefix = GuidPrefix::from_bytes([0x4B; 12]);
14552 let rt_a = DcpsRuntime::start(47, a_prefix, RuntimeConfig::default()).expect("start a");
14553 let rt_b = DcpsRuntime::start(47, b_prefix, RuntimeConfig::default()).expect("start b");
14554 let auth_a: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14555 let auth_b: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14556 let stack_a =
14557 rt_a.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_a, IdentityHandle(1));
14558 let stack_b =
14559 rt_b.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_b, IdentityHandle(1));
14560
14561 // KEIN handle_spdp_datagram / Multicast — rein in-process.
14562 let a_peers = stack_a.lock().unwrap().handshake_peer_count();
14563 let b_peers = stack_b.lock().unwrap().handshake_peer_count();
14564 assert!(
14565 a_peers >= 1,
14566 "A must have discovered B in-process + started the handshake (got {a_peers})"
14567 );
14568 assert!(
14569 b_peers >= 1,
14570 "B must have discovered A in-process + started the handshake (got {b_peers})"
14571 );
14572
14573 rt_a.shutdown();
14574 rt_b.shutdown();
14575 }
14576
14577 /// Mints a shared CA + two leaf identities (PEM) for the
14578 /// FU2-Handshake-e2e-Test.
14579 #[cfg(feature = "security")]
14580 #[allow(clippy::type_complexity)]
14581 fn mint_handshake_identities() -> ((Vec<u8>, Vec<u8>), (Vec<u8>, Vec<u8>)) {
14582 use rcgen::{CertificateParams, KeyPair};
14583 let mut ca_params =
14584 CertificateParams::new(alloc::vec![alloc::string::String::from("FU2 CA")]).unwrap();
14585 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
14586 let ca_key = KeyPair::generate().unwrap();
14587 let ca_cert = ca_params.self_signed(&ca_key).unwrap();
14588 let ca_pem = ca_cert.pem().into_bytes();
14589 let mint = |name: &str| -> (Vec<u8>, Vec<u8>) {
14590 let mut p =
14591 CertificateParams::new(alloc::vec![alloc::string::String::from(name)]).unwrap();
14592 p.is_ca = rcgen::IsCa::NoCa;
14593 let k = KeyPair::generate().unwrap();
14594 let c = p.signed_by(&k, &ca_cert, &ca_key).unwrap();
14595 (c.pem().into_bytes(), k.serialize_pem().into_bytes())
14596 };
14597 let alice = {
14598 let (cert, key) = mint("alice");
14599 (cert, key)
14600 };
14601 let bob = {
14602 let (cert, key) = mint("bob");
14603 (cert, key)
14604 };
14605 // attach ca_pem to both, so the caller has the trust anchor.
14606 (
14607 ([alice.0, b"\n".to_vec(), ca_pem.clone()].concat(), alice.1),
14608 ([bob.0, b"\n".to_vec(), ca_pem].concat(), bob.1),
14609 )
14610 }
14611
14612 /// FU2 Gap 5 (e2e): a runtime replier (A) and an in-test initiator
14613 /// stack (B) complete a real PKI 3-round handshake via the dispatch path
14614 /// and BOTH derive the same SharedSecret.
14615 /// Verifies the dispatch wiring (`on_stateless_message` →
14616 /// reply/final → completion) in the real runtime context.
14617 #[cfg(feature = "security")]
14618 #[test]
14619 fn handshake_completes_through_runtime_dispatch_e2e() {
14620 use zerodds_discovery::security::SecurityBuiltinStack;
14621 use zerodds_security::authentication::AuthenticationPlugin;
14622 use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
14623
14624 // cert_pem here contains Leaf || CA (mint_handshake_identities),
14625 // identity_ca_pem = the same bundle (CA is included).
14626 let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
14627
14628 // A = Runtime (Replier, HOEHERER Prefix). B = in-test Stack
14629 // (initiator, LOWER prefix) — cyclone convention: smaller
14630 // GUID initiiert.
14631 let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
14632 let b_prefix = GuidPrefix::from_bytes([0x10; 12]);
14633 let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
14634 let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
14635
14636 // --- A: runtime with a real PKI plugin ---
14637 let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14638 let a_local = a_pki
14639 .lock()
14640 .unwrap()
14641 .validate_with_config(
14642 IdentityConfig {
14643 identity_cert_pem: a_cert.clone(),
14644 identity_ca_pem: a_cert.clone(),
14645 identity_key_pem: Some(a_key),
14646 },
14647 a_guid,
14648 )
14649 .unwrap();
14650 let a_token = a_pki.lock().unwrap().get_identity_token(a_local).unwrap();
14651 let rt = DcpsRuntime::start(42, a_prefix, RuntimeConfig::default()).expect("start");
14652 let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
14653 let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
14654
14655 // --- B: in-test initiator stack with a real PKI plugin ---
14656 let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14657 let b_local = b_pki
14658 .lock()
14659 .unwrap()
14660 .validate_with_config(
14661 IdentityConfig {
14662 identity_cert_pem: b_cert.clone(),
14663 identity_ca_pem: b_cert.clone(),
14664 identity_key_pem: Some(b_key),
14665 },
14666 b_guid,
14667 )
14668 .unwrap();
14669 let b_token = b_pki.lock().unwrap().get_identity_token(b_local).unwrap();
14670 let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
14671 let mut b_stack =
14672 SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
14673
14674 let stateless = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14675 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14676
14677 // B discovers A (wired proxies) — via the parsed A beacon.
14678 let a_beacon = make_secure_beacon_with_identity_token(a_prefix, stateless, a_token.clone());
14679 let a_parsed = zerodds_discovery::spdp::SpdpReader::new()
14680 .parse_datagram(&a_beacon)
14681 .unwrap();
14682 b_stack.handle_remote_endpoints(&a_parsed);
14683
14684 // A discovers B → the discovery trigger creates A's peer state (A is
14685 // the replier, sends nothing).
14686 let b_beacon = make_secure_beacon_with_identity_token(b_prefix, stateless, b_token);
14687 handle_spdp_datagram(&rt, &b_beacon);
14688
14689 // B (initiator) starts → AUTH_REQUEST.
14690 let req = b_stack
14691 .begin_handshake_with(a_prefix, a_guid, &a_token)
14692 .unwrap();
14693 assert_eq!(req.len(), 1, "B sends AUTH_REQUEST");
14694
14695 // Pump: REQUEST → A.dispatch → REPLY.
14696 let reply = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
14697 assert_eq!(reply.len(), 1, "A (replier) answers with AUTH reply");
14698
14699 // REPLY → B verarbeitet → FINAL (+ B erreicht Complete).
14700 let b_msgs = b_stack
14701 .stateless_reader
14702 .handle_datagram(&reply[0].bytes)
14703 .unwrap();
14704 assert_eq!(b_msgs.len(), 1);
14705 let (final_dgs, _b_complete) = b_stack.on_stateless_message(a_prefix, &b_msgs[0]).unwrap();
14706 assert_eq!(final_dgs.len(), 1, "B sends AUTH-Final");
14707
14708 // FINAL → A.dispatch → A erreicht Complete.
14709 let _ =
14710 dispatch_security_builtin_datagram(&rt, &final_dgs[0].bytes, Duration::from_secs(1));
14711
14712 // Both sides must now have derived the same SharedSecret.
14713 let a_secret = {
14714 let s = a_stack.lock().unwrap();
14715 s.peer_secret(b_prefix)
14716 .expect("A must have authenticated B")
14717 };
14718 let b_secret = b_stack
14719 .peer_secret(a_prefix)
14720 .expect("B must have authenticated A");
14721 let a_bytes = a_pki
14722 .lock()
14723 .unwrap()
14724 .secret_bytes(a_secret)
14725 .unwrap()
14726 .to_vec();
14727 let b_bytes = b_pki
14728 .lock()
14729 .unwrap()
14730 .secret_bytes(b_secret)
14731 .unwrap()
14732 .to_vec();
14733 assert_eq!(a_bytes.len(), 32);
14734 assert_eq!(
14735 a_bytes, b_bytes,
14736 "runtime dispatch + in-test stack derive the same secret"
14737 );
14738
14739 rt.shutdown();
14740 }
14741
14742 /// FU2 S1.5 (e2e): after the auth handshake the runtime dispatch
14743 /// (A, replier) and a reference peer (B, stack+gate, initiator) over
14744 /// the Kx-protected VolatileSecure channel automatically exchange their data
14745 /// crypto tokens — afterwards secured user DATA round-trips in BOTH
14746 /// directions. **The secured-DATA proof via the runtime dispatch.**
14747 #[cfg(feature = "security")]
14748 #[test]
14749 #[serial_test::serial(dcps_security_e2e)]
14750 fn secured_data_round_trips_through_runtime_dispatch_e2e() {
14751 use zerodds_discovery::security::SecurityBuiltinStack;
14752 use zerodds_security::authentication::{AuthenticationPlugin, SharedSecretProvider};
14753 use zerodds_security::generic_message::{
14754 MessageIdentity, ParticipantGenericMessage, class_id,
14755 };
14756 use zerodds_security::token::DataHolder;
14757 use zerodds_security_crypto::{AesGcmCryptoPlugin, Suite};
14758 use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
14759 use zerodds_security_runtime::{ProtectionLevel, SharedSecurityGate};
14760
14761 // Couples the pki plugin (behind a mutex) as the SharedSecretProvider to
14762 // the crypto plugin — like SecurityProfile in the FFI (Gap 1).
14763 struct PkiProvider(Arc<Mutex<PkiAuthenticationPlugin>>);
14764 impl SharedSecretProvider for PkiProvider {
14765 fn get_shared_secret(
14766 &self,
14767 h: zerodds_security::authentication::SharedSecretHandle,
14768 ) -> Option<Vec<u8>> {
14769 self.0.lock().ok()?.get_shared_secret(h)
14770 }
14771 }
14772 const GOV: &str = r#"<domain_access_rules><domain_rule><domains><id>0</id></domains><rtps_protection_kind>ENCRYPT</rtps_protection_kind><topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules></domain_rule></domain_access_rules>"#;
14773 let gov = || zerodds_security_permissions::parse_governance_xml(GOV).unwrap();
14774 let gate_with = |pki: &Arc<Mutex<PkiAuthenticationPlugin>>| {
14775 SharedSecurityGate::new(
14776 0,
14777 gov(),
14778 Box::new(AesGcmCryptoPlugin::with_secret_provider(
14779 Suite::Aes128Gcm,
14780 Arc::new(PkiProvider(pki.clone())) as Arc<dyn SharedSecretProvider>,
14781 )),
14782 )
14783 };
14784 let fake_rtps = |prefix: GuidPrefix, body: &[u8]| -> Vec<u8> {
14785 let mut m = Vec::new();
14786 m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
14787 m.extend_from_slice(&prefix.to_bytes());
14788 m.extend_from_slice(body);
14789 m
14790 };
14791
14792 let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
14793 let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
14794 let b_prefix = GuidPrefix::from_bytes([0x10; 12]); // B < A → B initiator (cyclone convention)
14795 let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
14796 let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
14797 let a_key_pk = a_prefix.to_bytes();
14798 let b_key_pk = b_prefix.to_bytes();
14799
14800 // --- A: runtime with auth + gate (sharing pki_a) ---
14801 let pki_a = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14802 let a_local = pki_a
14803 .lock()
14804 .unwrap()
14805 .validate_with_config(
14806 IdentityConfig {
14807 identity_cert_pem: a_cert.clone(),
14808 identity_ca_pem: a_cert.clone(),
14809 identity_key_pem: Some(a_key),
14810 },
14811 a_guid,
14812 )
14813 .unwrap();
14814 let a_token = pki_a.lock().unwrap().get_identity_token(a_local).unwrap();
14815 let gate_a = Arc::new(gate_with(&pki_a));
14816 let rt = DcpsRuntime::start(
14817 43,
14818 a_prefix,
14819 RuntimeConfig {
14820 security: Some(gate_a.clone()),
14821 ..RuntimeConfig::default()
14822 },
14823 )
14824 .expect("start");
14825 let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_a.clone();
14826 let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
14827
14828 // --- B: in-test Stack + Gate (sharing pki_b), Initiator ---
14829 let pki_b = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14830 let b_local = pki_b
14831 .lock()
14832 .unwrap()
14833 .validate_with_config(
14834 IdentityConfig {
14835 identity_cert_pem: b_cert.clone(),
14836 identity_ca_pem: b_cert.clone(),
14837 identity_key_pem: Some(b_key),
14838 },
14839 b_guid,
14840 )
14841 .unwrap();
14842 let b_token = pki_b.lock().unwrap().get_identity_token(b_local).unwrap();
14843 let gate_b = gate_with(&pki_b);
14844 let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_b.clone();
14845 let mut stack_b =
14846 SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
14847
14848 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14849 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
14850 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
14851 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
14852 let a_beacon = make_secure_beacon_with_identity_token(a_prefix, flags, a_token.clone());
14853 stack_b.handle_remote_endpoints(
14854 &zerodds_discovery::spdp::SpdpReader::new()
14855 .parse_datagram(&a_beacon)
14856 .unwrap(),
14857 );
14858 // Wire A's stack deterministically (no handle_spdp_datagram —
14859 // a running runtime + trigger otherwise produces non-deterministic
14860 // proxy wirings via parallel/loopback beacons). A is the replier:
14861 // begin_handshake_with only sets up the peer state.
14862 let b_beacon = make_secure_beacon_with_identity_token(b_prefix, flags, b_token.clone());
14863 let b_parsed = zerodds_discovery::spdp::SpdpReader::new()
14864 .parse_datagram(&b_beacon)
14865 .unwrap();
14866 {
14867 let mut s = a_stack.lock().unwrap();
14868 s.handle_remote_endpoints(&b_parsed);
14869 s.begin_handshake_with(b_prefix, b_guid, &b_token).unwrap();
14870 }
14871
14872 // --- Stateless-Handshake pumpen (B initiiert) ---
14873 // A is the replier and derives the secret already at begin_handshake_
14874 // reply → A's response to the request contains BOTH: the
14875 // AUTH reply (stateless) AND A's Kx-encrypted crypto token
14876 // (volatile, automatically via the dispatch).
14877 let decode_route = |dgs: &[zerodds_rtps::message_builder::OutboundDatagram]| {
14878 let mut stateless = Vec::new();
14879 let mut volatile = Vec::new();
14880 for dg in dgs {
14881 let parsed = zerodds_rtps::datagram::decode_datagram(&dg.bytes).unwrap();
14882 let is_vol = parsed.submessages.iter().any(|sub| {
14883 // Cleartext path (unprotected): DATA to the VolatileSecure reader.
14884 matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Data(d)
14885 if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER)
14886 // Cross-vendor path (protected): the volatile crypto-token DATA
14887 // is SEC_*-protected (protect_volatile_outbound) -> the inner
14888 // DATA is encrypted and recognizable only by the prepended SEC_PREFIX
14889 // submessage (id 0x31). Stateless AUTH stays plaintext.
14890 || matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Unknown { id: 0x31, .. })
14891 });
14892 if is_vol {
14893 volatile.push(dg.bytes.clone());
14894 } else {
14895 stateless.push(dg.bytes.clone());
14896 }
14897 }
14898 (stateless, volatile)
14899 };
14900
14901 let req = stack_b
14902 .begin_handshake_with(a_prefix, a_guid, &a_token)
14903 .unwrap();
14904 let a_resp = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
14905 let (a_stateless, a_volatile) = decode_route(&a_resp);
14906 assert!(
14907 !a_volatile.is_empty(),
14908 "A dispatch must send A's crypto token"
14909 );
14910
14911 // B verarbeitet A's AUTH-Reply → Final + B completes.
14912 let mut b_remote_id = None;
14913 let mut b_secret = None;
14914 let mut b_final = Vec::new();
14915 for sl in &a_stateless {
14916 for m in stack_b.stateless_reader.handle_datagram(sl).unwrap() {
14917 let (out, comp) = stack_b.on_stateless_message(a_prefix, &m).unwrap();
14918 b_final.extend(out);
14919 if let Some((id, sec)) = comp {
14920 b_remote_id = Some(id);
14921 b_secret = Some(sec);
14922 }
14923 }
14924 }
14925 let b_remote_id = b_remote_id.expect("B remote identity");
14926 let b_secret = b_secret.expect("B completes");
14927
14928 // B registers A's Kx, installs A's crypto token (from a_volatile).
14929 gate_b
14930 .register_remote_by_guid_from_secret(a_key_pk, b_remote_id, b_secret)
14931 .unwrap();
14932 // A's volatile crypto token is cross-vendor SEC_*-protected
14933 // (protect_volatile_outbound). B must decrypt the SEC_PREFIX/BODY/POSTFIX sequence
14934 // with A's Kx key to the inner DATA submessage before the
14935 // volatile_reader can process it — mirrors unprotect_volatile_
14936 // datagram im Live-Dispatch.
14937 let unprotect_vol_b = |bytes: &[u8]| -> Option<Vec<u8>> {
14938 let subs = walk_submessages(bytes);
14939 let prefix_pos = subs.iter().position(|(id, _, _)| *id == SMID_SEC_PREFIX)?;
14940 let postfix_idx = subs[prefix_pos..]
14941 .iter()
14942 .position(|(id, _, _)| *id == SMID_SEC_POSTFIX)
14943 .map(|i| prefix_pos + i)?;
14944 let (_, p_start, _) = subs[prefix_pos];
14945 let (_, q_start, q_total) = subs[postfix_idx];
14946 let data_submsg = gate_b
14947 .decode_kx_datawriter_from(&a_key_pk, &bytes[p_start..q_start + q_total])
14948 .ok()?;
14949 let mut out = Vec::with_capacity(bytes.len());
14950 out.extend_from_slice(&bytes[..20]);
14951 for (i, &(_, start, total)) in subs.iter().enumerate() {
14952 if i < prefix_pos || i > postfix_idx {
14953 out.extend_from_slice(&bytes[start..start + total]);
14954 } else if i == prefix_pos {
14955 out.extend_from_slice(&data_submsg);
14956 }
14957 }
14958 Some(out)
14959 };
14960 let mut b_installed = 0;
14961 for vol in &a_volatile {
14962 let vol_plain = unprotect_vol_b(vol).unwrap_or_else(|| vol.clone());
14963 let parsed = zerodds_rtps::datagram::decode_datagram(&vol_plain).unwrap();
14964 let vol_src = parsed.header.guid_prefix;
14965 for sub in parsed.submessages {
14966 if let zerodds_rtps::datagram::ParsedSubmessage::Data(d) = sub {
14967 if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
14968 for m in stack_b.volatile_reader.handle_data(vol_src, &d).unwrap() {
14969 if m.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS {
14970 // plaintext keymat (confidentiality was provided by the SEC_*
14971 // protection of the volatile DATA, decrypted above) —
14972 // install directly, no transform_kx_inbound.
14973 let token = m.message_data[0]
14974 .binary_property(CRYPTO_TOKEN_PROP)
14975 .unwrap();
14976 gate_b
14977 .set_remote_data_token_by_guid(&a_key_pk, token)
14978 .unwrap();
14979 b_installed += 1;
14980 }
14981 }
14982 }
14983 }
14984 }
14985 }
14986 assert!(b_installed >= 1, "B must install A's crypto token");
14987
14988 // B builds + sends its crypto token — plaintext keymat in the
14989 // ParticipantGenericMessage (cross-vendor: confidentiality via SEC_*
14990 // protection of the transporting volatile DATA, not via token-internal
14991 // Kx encryption).
14992 let b_data_token = gate_b.local_token().unwrap();
14993 let b_crypto_msg = ParticipantGenericMessage {
14994 message_identity: MessageIdentity {
14995 source_guid: b_guid,
14996 sequence_number: 1,
14997 },
14998 related_message_identity: MessageIdentity::default(),
14999 destination_participant_key: a_guid,
15000 destination_endpoint_key: [0; 16],
15001 source_endpoint_key: [0; 16],
15002 message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
15003 message_data: alloc::vec![
15004 DataHolder::new("DDS:Crypto:AES_GCM_GMAC")
15005 .with_binary_property(CRYPTO_TOKEN_PROP, b_data_token)
15006 ],
15007 };
15008 let b_volatile = stack_b.volatile_writer.write(&b_crypto_msg).unwrap();
15009 // SEC_* submessage protection with A's Kx key (mirrors protect_volatile_
15010 // datagram in the live path): B encrypts the DATA submessage, A's
15011 // dispatch decrypts it via unprotect_volatile_datagram.
15012 let protect_vol_b = |bytes: &[u8]| -> Vec<u8> {
15013 let subs = walk_submessages(bytes);
15014 if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
15015 return bytes.to_vec();
15016 }
15017 let mut out = Vec::with_capacity(bytes.len() + 64);
15018 out.extend_from_slice(&bytes[..20]);
15019 for (id, start, total) in subs {
15020 let submsg = &bytes[start..start + total];
15021 if id == SMID_DATA {
15022 out.extend_from_slice(
15023 &gate_b.encode_kx_datawriter_for(&a_key_pk, submsg).unwrap(),
15024 );
15025 } else {
15026 out.extend_from_slice(submsg);
15027 }
15028 }
15029 out
15030 };
15031 let b_vol_protected = protect_vol_b(&b_volatile[0].bytes);
15032
15033 // B's Final + B's Crypto-Token an A's Dispatch: A installiert B's
15034 // Data token (automatically via install_crypto_token).
15035 for f in &b_final {
15036 dispatch_security_builtin_datagram(&rt, &f.bytes, Duration::from_secs(1));
15037 }
15038 dispatch_security_builtin_datagram(&rt, &b_vol_protected, Duration::from_secs(1));
15039
15040 // --- Secured DATA in both directions ---
15041 let msg_ab = fake_rtps(a_prefix, b"[A->B secured payload]");
15042 let wire_ab = gate_a
15043 .transform_outbound_for(&b_key_pk, &msg_ab, ProtectionLevel::Encrypt)
15044 .unwrap();
15045 assert_eq!(
15046 gate_b.transform_inbound_from(&a_key_pk, &wire_ab).unwrap(),
15047 msg_ab,
15048 "A->B secured DATA must round-trip"
15049 );
15050 let msg_ba = fake_rtps(b_prefix, b"[B->A secured payload]");
15051 let wire_ba = gate_b
15052 .transform_outbound_for(&a_key_pk, &msg_ba, ProtectionLevel::Encrypt)
15053 .unwrap();
15054 assert_eq!(
15055 gate_a.transform_inbound_from(&b_key_pk, &wire_ba).unwrap(),
15056 msg_ba,
15057 "B->A secured DATA must round-trip (A's dispatch installed B's token)"
15058 );
15059
15060 rt.shutdown();
15061 }
15062
15063 #[test]
15064 fn c34c_enable_security_builtins_replays_known_peers() {
15065 // Order reversed: SPDP discovery first, plugin-
15066 // activation afterward. enable_security_builtins must catch up on already-
15067 // known peers. Plus: demux without a plugin (before enable)
15068 // is a no-op + does not panic.
15069 let rt = DcpsRuntime::start(
15070 76,
15071 GuidPrefix::from_bytes([0x76; 12]),
15072 RuntimeConfig::default(),
15073 )
15074 .expect("start");
15075
15076 // Demux without a plugin: silent no-op
15077 dispatch_security_builtin_datagram(&rt, &[0u8; 16], Duration::from_secs(1));
15078
15079 let remote = GuidPrefix::from_bytes([0x77; 12]);
15080 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
15081 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
15082 let dg = make_remote_spdp_beacon_with_flags(remote, flags);
15083 handle_spdp_datagram(&rt, &dg);
15084
15085 let stack = rt.enable_security_builtins(VendorId::ZERODDS);
15086 {
15087 let s = stack.lock().unwrap();
15088 assert_eq!(
15089 s.stateless_writer.reader_proxy_count(),
15090 1,
15091 "late plugin activation must catch up on known peers"
15092 );
15093 }
15094
15095 rt.shutdown();
15096 }
15097
15098 /// #29 regression: the earlier per-peer once-guard blocked late-matched
15099 /// user-endpoint tokens. `pending_endpoint_tokens` must, with already-sent
15100 /// builtin tokens, let through EXACTLY the new user token — not treat the whole
15101 /// peer as "done".
15102 #[cfg(feature = "security")]
15103 #[test]
15104 fn pending_endpoint_tokens_keeps_late_user_token_after_builtins_sent() {
15105 use zerodds_security::generic_message::ParticipantGenericMessage;
15106 // An early-sent builtin token (secure-SEDP) ...
15107 let builtin = ParticipantGenericMessage {
15108 source_endpoint_key: [0xff; 16],
15109 destination_endpoint_key: [0xfe; 16],
15110 ..Default::default()
15111 };
15112 // ... and a late-matched user-endpoint token.
15113 let user = ParticipantGenericMessage {
15114 source_endpoint_key: [0x03; 16],
15115 destination_endpoint_key: [0x04; 16],
15116 ..Default::default()
15117 };
15118 let mut sent = alloc::collections::BTreeSet::new();
15119 sent.insert(endpoint_token_key(&builtin));
15120
15121 let pending = pending_endpoint_tokens(vec![builtin.clone(), user.clone()], &sent);
15122
15123 assert_eq!(pending.len(), 1, "only the new user token may be pending");
15124 assert_eq!(
15125 pending[0].source_endpoint_key, user.source_endpoint_key,
15126 "the let-through token must be the user-endpoint token"
15127 );
15128 // Idempotency: after sending, nothing is pending anymore.
15129 let mut sent2 = sent.clone();
15130 sent2.insert(endpoint_token_key(&user));
15131 assert!(
15132 pending_endpoint_tokens(vec![builtin, user], &sent2).is_empty(),
15133 "already-sent tokens must not become pending again"
15134 );
15135 }
15136
15137 /// QT (#76, FOUNDATIONAL): a writer and reader of the SAME type whose
15138 /// TypeIdentifier is a *complete* hash (EquivalenceHashComplete) absent from
15139 /// any registry must still match and exchange data. Before the fix the
15140 /// runtime type-consistency check resolved against a fresh empty registry
15141 /// and rejected the match with TYPE_CONSISTENCY_ENFORCEMENT.
15142 #[test]
15143 fn qt_same_complete_type_identifier_matches_and_exchanges() {
15144 let rt = DcpsRuntime::start(
15145 60,
15146 GuidPrefix::from_bytes([0x60; 12]),
15147 RuntimeConfig::default(),
15148 )
15149 .expect("start");
15150 let complete = zerodds_types::TypeIdentifier::EquivalenceHashComplete(
15151 zerodds_types::type_identifier::EquivalenceHash([0xC0; 14]),
15152 );
15153 let mut w_cfg = qr_writer_cfg(
15154 "QtTopic",
15155 zerodds_qos::DurabilityKind::Volatile,
15156 alloc::vec![],
15157 zerodds_qos::LivelinessKind::Automatic,
15158 );
15159 w_cfg.type_identifier = complete.clone();
15160 let mut r_cfg = qr_reader_cfg(
15161 "QtTopic",
15162 zerodds_qos::DurabilityKind::Volatile,
15163 alloc::vec![],
15164 zerodds_qos::LivelinessKind::Automatic,
15165 );
15166 r_cfg.type_identifier = complete;
15167 let w = rt.register_user_writer(w_cfg).expect("writer");
15168 let (_r, rx) = rt.register_user_reader(r_cfg).expect("reader");
15169 rt.write_user_sample(w, b"complete-typed".to_vec())
15170 .expect("write");
15171 let s = rx
15172 .recv_timeout(core::time::Duration::from_millis(200))
15173 .expect("complete-TypeIdentifier writer+reader must match + exchange");
15174 match s {
15175 UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"complete-typed"),
15176 other => panic!("expected Alive, got {other:?}"),
15177 }
15178 rt.shutdown();
15179 }
15180
15181 // ===================================================================
15182 // QR-cluster (#77) — same-runtime QoS behavioral regression tests.
15183 // ===================================================================
15184
15185 fn qr_writer_cfg(
15186 topic: &str,
15187 durability: zerodds_qos::DurabilityKind,
15188 partition: Vec<String>,
15189 liveliness: zerodds_qos::LivelinessKind,
15190 ) -> UserWriterConfig {
15191 UserWriterConfig {
15192 topic_name: topic.into(),
15193 type_name: "QrType".into(),
15194 reliable: true,
15195 durability,
15196 deadline: zerodds_qos::DeadlineQosPolicy::default(),
15197 lifespan: zerodds_qos::LifespanQosPolicy::default(),
15198 liveliness: zerodds_qos::LivelinessQosPolicy {
15199 kind: liveliness,
15200 lease_duration: QosDuration::INFINITE,
15201 },
15202 ownership: zerodds_qos::OwnershipKind::Shared,
15203 ownership_strength: 0,
15204 partition,
15205 user_data: alloc::vec![],
15206 topic_data: alloc::vec![],
15207 group_data: alloc::vec![],
15208 type_identifier: zerodds_types::TypeIdentifier::None,
15209 data_representation_offer: None,
15210 }
15211 }
15212
15213 fn qr_reader_cfg(
15214 topic: &str,
15215 durability: zerodds_qos::DurabilityKind,
15216 partition: Vec<String>,
15217 liveliness: zerodds_qos::LivelinessKind,
15218 ) -> UserReaderConfig {
15219 UserReaderConfig {
15220 topic_name: topic.into(),
15221 type_name: "QrType".into(),
15222 reliable: true,
15223 durability,
15224 deadline: zerodds_qos::DeadlineQosPolicy::default(),
15225 liveliness: zerodds_qos::LivelinessQosPolicy {
15226 kind: liveliness,
15227 lease_duration: QosDuration::INFINITE,
15228 },
15229 ownership: zerodds_qos::OwnershipKind::Shared,
15230 partition,
15231 user_data: alloc::vec![],
15232 topic_data: alloc::vec![],
15233 group_data: alloc::vec![],
15234 type_identifier: zerodds_types::TypeIdentifier::None,
15235 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
15236 data_representation_offer: None,
15237 }
15238 }
15239
15240 /// QR (a) HISTORY KeepLast: a TransientLocal writer with depth=2 retains
15241 /// only the last 2 samples per instance; a late-joining reader replays
15242 /// exactly those 2 (not all 3 written).
15243 #[test]
15244 fn qr_history_keep_last_depth_enforced_on_replay() {
15245 let rt = DcpsRuntime::start(
15246 61,
15247 GuidPrefix::from_bytes([0x61; 12]),
15248 RuntimeConfig::default(),
15249 )
15250 .expect("start");
15251 let w = rt
15252 .register_user_writer(qr_writer_cfg(
15253 "QrHistory",
15254 zerodds_qos::DurabilityKind::TransientLocal,
15255 alloc::vec![],
15256 zerodds_qos::LivelinessKind::Automatic,
15257 ))
15258 .expect("writer");
15259 rt.set_user_writer_history_depth(w, 2).expect("set depth");
15260
15261 // Three writes BEFORE any reader exists.
15262 rt.write_user_sample(w, b"s1".to_vec()).expect("w1");
15263 rt.write_user_sample(w, b"s2".to_vec()).expect("w2");
15264 rt.write_user_sample(w, b"s3".to_vec()).expect("w3");
15265 assert_eq!(
15266 rt.user_writer_retained_len(w),
15267 2,
15268 "KeepLast(2) must retain only the 2 most recent samples"
15269 );
15270
15271 // Late-joining reader replays exactly the last 2 (s2, s3).
15272 let (_r, rx) = rt
15273 .register_user_reader(qr_reader_cfg(
15274 "QrHistory",
15275 zerodds_qos::DurabilityKind::TransientLocal,
15276 alloc::vec![],
15277 zerodds_qos::LivelinessKind::Automatic,
15278 ))
15279 .expect("reader");
15280
15281 let mut got: Vec<Vec<u8>> = Vec::new();
15282 while let Ok(s) = rx.recv_timeout(core::time::Duration::from_millis(200)) {
15283 if let UserSample::Alive { payload, .. } = s {
15284 got.push(payload.as_ref().to_vec());
15285 }
15286 if got.len() == 2 {
15287 break;
15288 }
15289 }
15290 assert_eq!(got, alloc::vec![b"s2".to_vec(), b"s3".to_vec()]);
15291 rt.shutdown();
15292 }
15293
15294 /// QR (b) DURABILITY TRANSIENT_LOCAL: a late-joining reader receives the
15295 /// retained sample written before it matched. A VOLATILE writer replays
15296 /// nothing.
15297 #[test]
15298 fn qr_transient_local_late_join_replay_vs_volatile() {
15299 // TransientLocal: late joiner sees the prior sample.
15300 let rt = DcpsRuntime::start(
15301 62,
15302 GuidPrefix::from_bytes([0x62; 12]),
15303 RuntimeConfig::default(),
15304 )
15305 .expect("start");
15306 let w = rt
15307 .register_user_writer(qr_writer_cfg(
15308 "QrTL",
15309 zerodds_qos::DurabilityKind::TransientLocal,
15310 alloc::vec![],
15311 zerodds_qos::LivelinessKind::Automatic,
15312 ))
15313 .expect("writer");
15314 rt.write_user_sample(w, b"retained".to_vec())
15315 .expect("write");
15316 let (_r, rx) = rt
15317 .register_user_reader(qr_reader_cfg(
15318 "QrTL",
15319 zerodds_qos::DurabilityKind::TransientLocal,
15320 alloc::vec![],
15321 zerodds_qos::LivelinessKind::Automatic,
15322 ))
15323 .expect("reader");
15324 let s = rx
15325 .recv_timeout(core::time::Duration::from_millis(200))
15326 .expect("TransientLocal late joiner must replay the retained sample");
15327 match s {
15328 UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"retained"),
15329 other => panic!("expected Alive, got {other:?}"),
15330 }
15331 rt.shutdown();
15332
15333 // Volatile: late joiner gets nothing for the pre-match write.
15334 let rt2 = DcpsRuntime::start(
15335 63,
15336 GuidPrefix::from_bytes([0x63; 12]),
15337 RuntimeConfig::default(),
15338 )
15339 .expect("start");
15340 let w2 = rt2
15341 .register_user_writer(qr_writer_cfg(
15342 "QrVol",
15343 zerodds_qos::DurabilityKind::Volatile,
15344 alloc::vec![],
15345 zerodds_qos::LivelinessKind::Automatic,
15346 ))
15347 .expect("writer");
15348 rt2.write_user_sample(w2, b"lost".to_vec()).expect("write");
15349 assert_eq!(
15350 rt2.user_writer_retained_len(w2),
15351 0,
15352 "Volatile retains nothing"
15353 );
15354 let (_r2, rx2) = rt2
15355 .register_user_reader(qr_reader_cfg(
15356 "QrVol",
15357 zerodds_qos::DurabilityKind::Volatile,
15358 alloc::vec![],
15359 zerodds_qos::LivelinessKind::Automatic,
15360 ))
15361 .expect("reader");
15362 assert!(
15363 rx2.recv_timeout(core::time::Duration::from_millis(120))
15364 .is_err(),
15365 "Volatile late joiner must NOT replay a pre-match sample"
15366 );
15367 rt2.shutdown();
15368 }
15369
15370 /// QR (c) PARTITION: a writer in partition ["A"] and a reader in ["B"]
15371 /// must NOT match (no intra-runtime route); a matching partition delivers.
15372 #[test]
15373 fn qr_partition_gates_intra_runtime_match() {
15374 let rt = DcpsRuntime::start(
15375 64,
15376 GuidPrefix::from_bytes([0x64; 12]),
15377 RuntimeConfig::default(),
15378 )
15379 .expect("start");
15380 // Mismatched partitions.
15381 let w = rt
15382 .register_user_writer(qr_writer_cfg(
15383 "QrPart",
15384 zerodds_qos::DurabilityKind::Volatile,
15385 alloc::vec!["A".into()],
15386 zerodds_qos::LivelinessKind::Automatic,
15387 ))
15388 .expect("writer");
15389 let (_r_mismatch, rx_mismatch) = rt
15390 .register_user_reader(qr_reader_cfg(
15391 "QrPart",
15392 zerodds_qos::DurabilityKind::Volatile,
15393 alloc::vec!["B".into()],
15394 zerodds_qos::LivelinessKind::Automatic,
15395 ))
15396 .expect("reader");
15397 rt.write_user_sample(w, b"x".to_vec()).expect("write");
15398 assert!(
15399 rx_mismatch
15400 .recv_timeout(core::time::Duration::from_millis(120))
15401 .is_err(),
15402 "partitions [A] vs [B] must not match"
15403 );
15404
15405 // Matching partition reader added → now delivers.
15406 let (_r_match, rx_match) = rt
15407 .register_user_reader(qr_reader_cfg(
15408 "QrPart",
15409 zerodds_qos::DurabilityKind::Volatile,
15410 alloc::vec!["A".into()],
15411 zerodds_qos::LivelinessKind::Automatic,
15412 ))
15413 .expect("reader");
15414 rt.write_user_sample(w, b"y".to_vec()).expect("write");
15415 let s = rx_match
15416 .recv_timeout(core::time::Duration::from_millis(200))
15417 .expect("partition [A] vs [A] must match");
15418 match s {
15419 UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"y"),
15420 other => panic!("expected Alive, got {other:?}"),
15421 }
15422 rt.shutdown();
15423 }
15424
15425 /// QR (d) KEYED LIFECYCLE: dispose(key) delivers a Lifecycle marker to a
15426 /// matched same-runtime reader; a later late joiner observes the terminal
15427 /// NOT_ALIVE_DISPOSED state.
15428 #[test]
15429 fn qr_dispose_delivers_lifecycle_to_intra_reader() {
15430 use zerodds_rtps::history_cache::ChangeKind;
15431 use zerodds_rtps::inline_qos::status_info;
15432 let rt = DcpsRuntime::start(
15433 65,
15434 GuidPrefix::from_bytes([0x65; 12]),
15435 RuntimeConfig::default(),
15436 )
15437 .expect("start");
15438 let w = rt
15439 .register_user_writer_kind(
15440 qr_writer_cfg(
15441 "QrLifecycle",
15442 zerodds_qos::DurabilityKind::TransientLocal,
15443 alloc::vec![],
15444 zerodds_qos::LivelinessKind::Automatic,
15445 ),
15446 true,
15447 )
15448 .expect("writer");
15449 let (_r, rx) = rt
15450 .register_user_reader_kind(
15451 qr_reader_cfg(
15452 "QrLifecycle",
15453 zerodds_qos::DurabilityKind::TransientLocal,
15454 alloc::vec![],
15455 zerodds_qos::LivelinessKind::Automatic,
15456 ),
15457 true,
15458 )
15459 .expect("reader");
15460
15461 let key = [0xAB_u8; 16];
15462 rt.write_user_sample_keyed(w, b"alive", key, None)
15463 .expect("write");
15464 // First the alive sample.
15465 let first = rx
15466 .recv_timeout(core::time::Duration::from_millis(200))
15467 .expect("alive sample");
15468 assert!(matches!(first, UserSample::Alive { .. }));
15469
15470 // dispose(key) → Lifecycle marker NOT_ALIVE_DISPOSED.
15471 rt.write_user_lifecycle(w, key, status_info::DISPOSED)
15472 .expect("dispose");
15473 let life = rx
15474 .recv_timeout(core::time::Duration::from_millis(200))
15475 .expect("dispose must deliver a Lifecycle marker to the matched reader");
15476 match life {
15477 UserSample::Lifecycle { key_hash, kind } => {
15478 assert_eq!(key_hash, key);
15479 assert_eq!(kind, ChangeKind::NotAliveDisposed);
15480 }
15481 other => panic!("expected Lifecycle, got {other:?}"),
15482 }
15483
15484 // A brand-new late joiner replays the alive sample AND the terminal
15485 // disposed marker, so it learns the instance is NOT_ALIVE_DISPOSED.
15486 let (_r2, rx2) = rt
15487 .register_user_reader_kind(
15488 qr_reader_cfg(
15489 "QrLifecycle",
15490 zerodds_qos::DurabilityKind::TransientLocal,
15491 alloc::vec![],
15492 zerodds_qos::LivelinessKind::Automatic,
15493 ),
15494 true,
15495 )
15496 .expect("reader2");
15497 let mut saw_disposed = false;
15498 while let Ok(s) = rx2.recv_timeout(core::time::Duration::from_millis(200)) {
15499 if let UserSample::Lifecycle { kind, .. } = s {
15500 if kind == ChangeKind::NotAliveDisposed {
15501 saw_disposed = true;
15502 break;
15503 }
15504 }
15505 }
15506 assert!(
15507 saw_disposed,
15508 "late joiner must observe the terminal NOT_ALIVE_DISPOSED state"
15509 );
15510 rt.shutdown();
15511 }
15512
15513 /// QR (d) KEYED LIFECYCLE — unregister(key) maps to NOT_ALIVE (NO_WRITERS).
15514 #[test]
15515 fn qr_unregister_delivers_no_writers_lifecycle() {
15516 use zerodds_rtps::history_cache::ChangeKind;
15517 use zerodds_rtps::inline_qos::status_info;
15518 let rt = DcpsRuntime::start(
15519 66,
15520 GuidPrefix::from_bytes([0x66; 12]),
15521 RuntimeConfig::default(),
15522 )
15523 .expect("start");
15524 let w = rt
15525 .register_user_writer_kind(
15526 qr_writer_cfg(
15527 "QrUnreg",
15528 zerodds_qos::DurabilityKind::Volatile,
15529 alloc::vec![],
15530 zerodds_qos::LivelinessKind::Automatic,
15531 ),
15532 true,
15533 )
15534 .expect("writer");
15535 let (_r, rx) = rt
15536 .register_user_reader_kind(
15537 qr_reader_cfg(
15538 "QrUnreg",
15539 zerodds_qos::DurabilityKind::Volatile,
15540 alloc::vec![],
15541 zerodds_qos::LivelinessKind::Automatic,
15542 ),
15543 true,
15544 )
15545 .expect("reader");
15546 let key = [0x11_u8; 16];
15547 rt.write_user_lifecycle(w, key, status_info::UNREGISTERED)
15548 .expect("unregister");
15549 let life = rx
15550 .recv_timeout(core::time::Duration::from_millis(200))
15551 .expect("unregister must deliver a Lifecycle marker");
15552 match life {
15553 UserSample::Lifecycle { key_hash, kind } => {
15554 assert_eq!(key_hash, key);
15555 assert_eq!(kind, ChangeKind::NotAliveUnregistered);
15556 }
15557 other => panic!("expected Lifecycle, got {other:?}"),
15558 }
15559 rt.shutdown();
15560 }
15561
15562 /// QR (e) LIVELINESS AUTOMATIC: the reader's liveliness_changed alive_count
15563 /// tracks a live matched writer on the same-runtime path.
15564 #[test]
15565 fn qr_liveliness_automatic_bumps_reader_alive_count() {
15566 let rt = DcpsRuntime::start(
15567 67,
15568 GuidPrefix::from_bytes([0x67; 12]),
15569 RuntimeConfig::default(),
15570 )
15571 .expect("start");
15572 let w = rt
15573 .register_user_writer(qr_writer_cfg(
15574 "QrLive",
15575 zerodds_qos::DurabilityKind::Volatile,
15576 alloc::vec![],
15577 zerodds_qos::LivelinessKind::Automatic,
15578 ))
15579 .expect("writer");
15580 let (r, rx) = rt
15581 .register_user_reader(qr_reader_cfg(
15582 "QrLive",
15583 zerodds_qos::DurabilityKind::Volatile,
15584 alloc::vec![],
15585 zerodds_qos::LivelinessKind::Automatic,
15586 ))
15587 .expect("reader");
15588
15589 let (_alive0, count0, _na0) = rt.user_reader_liveliness_status(r);
15590 assert_eq!(count0, 0, "no writer has delivered yet");
15591
15592 rt.write_user_sample(w, b"beat".to_vec()).expect("write");
15593 let _ = rx.recv_timeout(core::time::Duration::from_millis(200));
15594
15595 let (alive, count, _na) = rt.user_reader_liveliness_status(r);
15596 assert!(alive, "AUTOMATIC writer keeps the reader's match alive");
15597 assert_eq!(
15598 count, 1,
15599 "alive_count must bump to 1 for the live matched AUTOMATIC writer"
15600 );
15601
15602 // A second write from the same writer does NOT double-count.
15603 rt.write_user_sample(w, b"beat2".to_vec()).expect("write");
15604 let _ = rx.recv_timeout(core::time::Duration::from_millis(200));
15605 let (_a, count2, _n) = rt.user_reader_liveliness_status(r);
15606 assert_eq!(count2, 1, "same writer must not bump alive_count twice");
15607 rt.shutdown();
15608 }
15609}