Skip to main content

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) -> Result<Vec<zerodds_rtps::message_builder::OutboundDatagram>> {
378    let mut frame = zerodds_foundation::PoolBuffer::<SMALL_FRAME_CAP>::new();
379    frame
380        .extend_from_slice(encap)
381        .map_err(|_| DdsError::WireError {
382            message: String::from("user encap framing"),
383        })?;
384    frame
385        .extend_from_slice(payload)
386        .map_err(|_| DdsError::WireError {
387            message: String::from("user payload framing"),
388        })?;
389    // Hot path: only DATA, NO HEARTBEAT. Cyclone DDS rate-limits the
390    // HB piggyback (≥100 µs spacing, or a packet boundary) — so it does
391    // NOT send an HB per write. At 14k writes/sec this would fire 14k
392    // unnecessary submessages and (with an unaligned payload) 14k extra
393    // sendto syscalls. Periodic HBs are handled by the tick loop (every
394    // `heartbeat_period` ms, default 100 ms); we no longer attach `_now`
395    // to `last_heartbeat`, because we emit nothing.
396    let _ = now;
397    // RTPS-F1: attach the wall-clock source timestamp so the peer can populate
398    // SampleInfo.source_timestamp + honour DESTINATION_ORDER = BY_SOURCE_TIMESTAMP
399    // (DDSI-RTPS §8.7.3). The writer prepends an INFO_TS before the DATA.
400    let source_ts = crate::time::time_to_he_timestamp(crate::time::get_current_time());
401    writer
402        .write_stamped(frame.as_slice(), Some(source_ts))
403        .map_err(|_| DdsError::WireError {
404            message: String::from("user writer encode"),
405        })
406}
407
408/// Choice of transport for DCPS user traffic. Discovery (SPDP/SEDP)
409/// remains UDPv4 multicast independently of this.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411#[non_exhaustive]
412pub enum UserTransportKind {
413    /// UDP IPv4 (default).
414    UdpV4,
415    /// UDP IPv6.
416    UdpV6,
417    /// TCP IPv4 (DDS-TCP-PSM `LOCATOR_KIND_TCPV4`).
418    TcpV4,
419    /// TCP IPv6 (DDS-TCP-PSM `LOCATOR_KIND_TCPV6`).
420    TcpV6,
421    /// POSIX shared memory (same-host). Only with the `same-host-shm`
422    /// feature.
423    #[cfg(feature = "same-host-shm")]
424    Shm,
425    /// Unix domain socket (same-host, container-friendly). Only with the
426    /// `same-host-uds` feature.
427    #[cfg(feature = "same-host-uds")]
428    Uds,
429    /// TSN L2 transport (AF_PACKET, RTPS direct on Ethernet, EtherType
430    /// 0x88B5). Only with the `tsn-live` feature on Linux. Interface/VLAN/
431    /// PCP via the env vars `ZERODDS_TSN_IFACE`/`_VLAN`/`_PCP`.
432    #[cfg(all(feature = "tsn-live", target_os = "linux"))]
433    Tsn,
434}
435
436/// Maps the `ZERODDS_USER_TRANSPORT` env var to a [`UserTransportKind`].
437/// `None` if unset or unknown — the caller then falls back to UDPv4.
438fn parse_user_transport_env() -> Option<UserTransportKind> {
439    match std::env::var("ZERODDS_USER_TRANSPORT").ok()?.as_str() {
440        "UDPv4" => Some(UserTransportKind::UdpV4),
441        "UDPv6" => Some(UserTransportKind::UdpV6),
442        "TCPv4" => Some(UserTransportKind::TcpV4),
443        "TCPv6" => Some(UserTransportKind::TcpV6),
444        #[cfg(feature = "same-host-shm")]
445        "SHM" => Some(UserTransportKind::Shm),
446        #[cfg(feature = "same-host-uds")]
447        "UDS" => Some(UserTransportKind::Uds),
448        #[cfg(all(feature = "tsn-live", target_os = "linux"))]
449        "TSN" => Some(UserTransportKind::Tsn),
450        _ => None,
451    }
452}
453
454/// Result of [`select_user_transport`]: the user-traffic transport plus
455/// an optional `TcpTransport` accept handle (only for TCP).
456type UserTransportSelection = (
457    Arc<dyn Transport + Send + Sync>,
458    Option<Arc<zerodds_transport_tcp::TcpTransport>>,
459);
460
461/// Binds the user-traffic transport for the selected
462/// [`UserTransportKind`]. Discovery (SPDP/SEDP) runs separately over
463/// UDPv4 multicast; this transport carries only the DCPS user traffic.
464///
465/// Additionally returns an optional `TcpTransport` accept handle: TCP has
466/// no implicit accept thread in the constructor, so the caller starts an
467/// `accept_one` worker for it.
468#[cfg_attr(
469    not(any(feature = "same-host-shm", feature = "same-host-uds")),
470    allow(unused_variables)
471)]
472fn select_user_transport(
473    kind: UserTransportKind,
474    guid_prefix: GuidPrefix,
475    domain_id: i32,
476    pinned: Ipv4Addr,
477) -> Result<UserTransportSelection> {
478    match kind {
479        UserTransportKind::UdpV4 => {
480            // Interface pinning: bind to the pinned IPv4 (egress + receive
481            // on exactly this interface), otherwise `0.0.0.0` (auto).
482            let udp = UdpTransport::bind_v4(pinned, 0)
483                .map_err(|_| DdsError::TransportError {
484                    label: "user unicast bind (UDPv4)",
485                })?
486                .with_timeout(Some(Duration::from_secs(1)))
487                .map_err(|_| DdsError::TransportError {
488                    label: "user unicast set_timeout (UDPv4)",
489                })?;
490            Ok((Arc::new(udp), None))
491        }
492        UserTransportKind::UdpV6 => {
493            let udp = UdpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
494                .map_err(|_| DdsError::TransportError {
495                    label: "user unicast bind (UDPv6)",
496                })?
497                .with_timeout(Some(Duration::from_secs(1)))
498                .map_err(|_| DdsError::TransportError {
499                    label: "user unicast set_timeout (UDPv6)",
500                })?;
501            Ok((Arc::new(udp), None))
502        }
503        UserTransportKind::TcpV4 => {
504            // Interface pinning analogous to UDPv4.
505            let tcp = zerodds_transport_tcp::TcpTransport::bind_v4(pinned, 0).map_err(|_| {
506                DdsError::TransportError {
507                    label: "user unicast bind (TCPv4)",
508                }
509            })?;
510            let arc = Arc::new(tcp);
511            let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
512            Ok((dynamic, Some(arc)))
513        }
514        UserTransportKind::TcpV6 => {
515            let tcp =
516                zerodds_transport_tcp::TcpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
517                    .map_err(|_| DdsError::TransportError {
518                        label: "user unicast bind (TCPv6)",
519                    })?;
520            let arc = Arc::new(tcp);
521            let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
522            Ok((dynamic, Some(arc)))
523        }
524        #[cfg(feature = "same-host-shm")]
525        UserTransportKind::Shm => {
526            // local_id = guid_prefix (12 bytes) + 4-byte domain id so that
527            // separate domains get separate segments (no cross-domain
528            // collisions).
529            let mut local_id = [0u8; 16];
530            local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
531            local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
532            let shm = crate::shm_user::ShmUserTransport::new(
533                local_id,
534                zerodds_transport_shm::posix::ShmConfig::default(),
535            );
536            Ok((Arc::new(shm), None))
537        }
538        #[cfg(feature = "same-host-uds")]
539        UserTransportKind::Uds => {
540            // local_id = guid_prefix (12 bytes) + 4-byte domain id — the
541            // peer resolves this id from the announced UDS locator into the
542            // same socket path.
543            let mut local_id = [0u8; 16];
544            local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
545            local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
546            // recv_timeout analogous to the UDP path: the recv loop must
547            // periodically check the stop flag (otherwise a thread hang on
548            // shutdown on a blocking recv).
549            let uds_cfg = zerodds_transport_uds::UdsConfig {
550                recv_timeout: Some(Duration::from_secs(1)),
551                ..zerodds_transport_uds::UdsConfig::default()
552            };
553            let uds =
554                zerodds_transport_uds::UdsTransport::bind(local_id, uds_cfg).map_err(|_| {
555                    DdsError::TransportError {
556                        label: "user unicast bind (UDS)",
557                    }
558                })?;
559            Ok((Arc::new(uds), None))
560        }
561        #[cfg(all(feature = "tsn-live", target_os = "linux"))]
562        UserTransportKind::Tsn => {
563            // Interface/VLAN/PCP via env (TSN needs a concrete interface;
564            // not bindable to 0.0.0.0 like UDP/TCP). recv_timeout 1s
565            // analogous to UDP for the stop-flag check.
566            let iface =
567                std::env::var("ZERODDS_TSN_IFACE").map_err(|_| DdsError::TransportError {
568                    label: "ZERODDS_TSN_IFACE not set (TSN transport)",
569                })?;
570            let vlan = std::env::var("ZERODDS_TSN_VLAN")
571                .ok()
572                .and_then(|s| s.parse::<u16>().ok())
573                .unwrap_or(0);
574            let pcp = std::env::var("ZERODDS_TSN_PCP")
575                .ok()
576                .and_then(|s| s.parse::<u8>().ok())
577                .unwrap_or(0);
578            let tsn = zerodds_transport_tsn::socket::TsnTransport::bind(
579                &iface,
580                vlan,
581                pcp,
582                Some(Duration::from_secs(1)),
583            )
584            .map_err(|_| DdsError::TransportError {
585                label: "user unicast bind (TSN)",
586            })?;
587            Ok((Arc::new(tsn), None))
588        }
589    }
590}
591
592/// Configuration for the runtime. Exposed via DomainParticipant factory
593/// methods.
594#[derive(Clone)]
595pub struct RuntimeConfig {
596    /// Tick period of the event loop. Default 50 ms.
597    pub tick_period: Duration,
598    /// SPDP announce period. Default 5 s.
599    pub spdp_period: Duration,
600    /// C3 WiFi-robust discovery — number of initial SPDP announces sent at the
601    /// fast [`Self::initial_announce_period`] cadence (instead of `spdp_period`)
602    /// while no peer is yet discovered. Default
603    /// [`DEFAULT_INITIAL_ANNOUNCE_COUNT`]. `0` disables the burst (legacy
604    /// single-announce-then-`spdp_period` behaviour).
605    pub initial_announce_count: u32,
606    /// Period between initial-announcement-burst SPDP sends. Default
607    /// [`DEFAULT_INITIAL_ANNOUNCE_PERIOD`].
608    pub initial_announce_period: Duration,
609    /// SPDP multicast group (IPv4). Default 239.255.0.1 (Spec §9.6.1.4.1).
610    pub spdp_multicast_group: Ipv4Addr,
611    /// Interface address for the multicast join. Default 0.0.0.0 (the
612    /// kernel picks the default interface).
613    pub multicast_interface: Ipv4Addr,
614
615    /// C1: whether SPDP beacons are sent via multicast. Default `true`
616    /// (spec behavior). `false` (env `ZERODDS_NO_MULTICAST`) → pure
617    /// unicast discovery via [`Self::initial_peers`], not a single
618    /// multicast packet — for networks that drop multicast (WiFi/cloud
619    /// VPC), and for a rigorous multicast-free discovery proof.
620    pub spdp_multicast_send: bool,
621
622    /// A1: **discovery-server** mode. When `true`, this participant relays the
623    /// raw SPDP (participant-locator) announcements between the clients that
624    /// point their [`Self::initial_peers`] at it — so N clients discover each
625    /// other through one well-known address instead of an O(N²) peer list or
626    /// multicast. Crucially it relays **only SPDP** (participant discovery);
627    /// SEDP (endpoint discovery, incl. dynamically-created ROS-2 Action
628    /// endpoints) then happens **directly peer-to-peer** between the real
629    /// participants — which is exactly why ROS-2 Actions work over it, unlike a
630    /// SEDP-proxying discovery server. Default `false`. Plain discovery only
631    /// (DDS-Security secured discovery-server relay is a follow-up).
632    pub discovery_server: bool,
633
634    /// C3: max reassemblable sample size (DoS cap of the fragment
635    /// assembler). Larger samples are silently discarded. The rtps
636    /// default was 1 MiB (the phase-1 assumption "large images = no
637    /// use case") — too small for ROS PointCloud2/Image (often several
638    /// MB). Default here 16 MiB; env `ZERODDS_MAX_SAMPLE_BYTES` (bytes)
639    /// overrides. Still a deliberate DoS guard, just ROS-realistic.
640    pub max_reassembly_sample_bytes: usize,
641
642    /// C1 multicast-free discovery: unicast initial-peer locators to
643    /// which SPDP beacons are sent **in addition** to multicast. Default
644    /// empty (= pure multicast behavior as before). Populated via
645    /// [`RuntimeConfig::default`] from the env `ZERODDS_PEERS` (comma
646    /// list of `ip` or `ip:port`). An `ip` without a port is expanded to
647    /// the well-known SPDP unicast ports of participant indices 0..N
648    /// (see [`expand_initial_peer`]).
649    pub initial_peers: Vec<Locator>,
650
651    /// Transport for DCPS user traffic. `None` (default) → fall back to
652    /// the env var `ZERODDS_USER_TRANSPORT`, otherwise UDPv4. Discovery
653    /// (SPDP/SEDP) remains UDPv4 multicast independently of this. Ignored when
654    /// [`Self::user_transports`] is non-empty.
655    pub user_transport: Option<UserTransportKind>,
656
657    /// Preference-ordered set of transports for DCPS user traffic. When
658    /// non-empty, the runtime builds a [`LayeredUserTransport`](crate::layered_transport::LayeredUserTransport)
659    /// over all of them: each datagram is routed to the first transport whose
660    /// locator kind matches the destination (so list the fast/local transport
661    /// first — e.g. `[Shm, UdpV4]` — and the fallback last), and receives are
662    /// multiplexed from all of them. Empty (default) → single-transport via
663    /// [`Self::user_transport`].
664    pub user_transports: alloc::vec::Vec<UserTransportKind>,
665
666    /// Optional security gate. Active only with the `security` feature.
667    /// When set, UDP outbound messages are pulled through
668    /// [`SharedSecurityGate::transform_outbound`], and inbound messages
669    /// through [`SharedSecurityGate::transform_inbound_from`] (peer key
670    /// from RTPS header bytes 8..20).
671    #[cfg(feature = "security")]
672    pub security: Option<std::sync::Arc<zerodds_security_runtime::SharedSecurityGate>>,
673    /// Optional LoggingPlugin for security events. Called by the inbound
674    /// path when packets are dropped due to a policy violation, tampering
675    /// or a legacy block.
676    #[cfg(feature = "security")]
677    pub security_logger: Option<std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin>>,
678
679    /// Multi-interface bindings. Empty → `user_unicast` is the only
680    /// outbound socket (legacy behavior). Non-empty →
681    /// `DcpsRuntime::start` builds a dedicated UDP socket per spec and the
682    /// writer tick loop routes to the matching socket per destination
683    /// locator.
684    #[cfg(feature = "security")]
685    pub interface_bindings: Vec<InterfaceBindingSpec>,
686
687    /// `true` → the SPDP beacon additionally announces the 12 secure
688    /// discovery bits (16..27, DDS-Security 1.2 §7.4.7.1). Default
689    /// `false` — only standard bits are announced. Set by the DCPS
690    /// factory once a PolicyEngine is configured. This flag is available
691    /// even without the `security` feature, so that tests can check bit
692    /// presence without activating the whole crypto crate.
693    pub announce_secure_endpoints: bool,
694
695    /// FastDDS interop: run the reliable secure SPDP channel (0xff0101c2/c7,
696    /// `ENTITYID_SPDP_RELIABLE_BUILTIN_PARTICIPANT_SECURE_*`). FastDDS announces
697    /// its full secured participant data (identity_token/security_info) over
698    /// this channel and gates the crypto-token reciprocation/endpoint matching
699    /// on it; cyclone does NOT need it (cyclone↔zerodds runs without). Default off
700    /// — enable only for FastDDS cross-vendor.
701    pub enable_secure_spdp: bool,
702
703    /// WLP-Tick-Periode (Writer-Liveliness-Protocol, RTPS 2.5 §8.4.13).
704    /// `Duration::ZERO` → default `participant_lease_duration / 3`
705    /// (spec recommendation: three misses before the reader marks the
706    /// writer as not-alive). A direct override enables aggressive
707    /// tests.
708    pub wlp_period: Duration,
709
710    /// Lease duration announced in the SPDP beacon as
711    /// `PARTICIPANT_LEASE_DURATION` (spec default 100 s). Also used as the
712    /// basis for the AUTOMATIC WLP tick (`wlp_period =
713    /// participant_lease_duration / 3` if `wlp_period == Duration::ZERO`).
714    pub participant_lease_duration: Duration,
715
716    /// USER_DATA bytes of the participant (DDS 1.4 §2.2.3.1
717    /// `UserDataQosPolicy`). Announced in the SPDP beacon as PID_USER_DATA
718    /// (DDSI-RTPS §9.6.3.2) and exposed on the receiver side in
719    /// `ParticipantBuiltinTopicData.user_data`. Default empty.
720    pub user_data: Vec<u8>,
721
722    /// Observability sink. Default is `null_sink()` — each event emit is
723    /// then a direct return without allocation on the consumer side.
724    /// Consumers inject e.g.
725    /// [`zerodds_foundation::observability::StderrJsonSink`] (JSON lines
726    /// for Vector/fluentd/Datadog) or their own OTLP bridge.
727    pub observability: zerodds_foundation::observability::SharedSink,
728
729    /// Sprint D.5d lever C — RT pinning + priority. Linux-only; on
730    /// macOS/Windows the hooks are no-ops.
731    ///
732    /// SCHED_FIFO priority (1-99) for the three recv workers (SPDP MC,
733    /// metatraffic, user data). `None` = default scheduler (CFS).
734    /// `Some(80)` is the spec recommendation for real-time paths. Requires
735    /// `CAP_SYS_NICE` or an `RLIMIT_RTPRIO`-permitted user.
736    pub recv_thread_priority: Option<i32>,
737
738    /// Like [`Self::recv_thread_priority`], but for the tick worker.
739    pub tick_thread_priority: Option<i32>,
740
741    /// CPU affinity mask for the recv workers. `None` = no affinity (the
742    /// kernel schedules freely). A list of CPU indices, e.g.
743    /// `vec![2, 3]` for cores 2+3. Set via `sched_setaffinity`; all three
744    /// recv threads share the same mask.
745    pub recv_thread_cpus: Option<Vec<usize>>,
746
747    /// Like [`Self::recv_thread_cpus`], but for the tick worker.
748    pub tick_thread_cpus: Option<Vec<usize>>,
749
750    /// Opt-3 (Spec `zerodds-zero-copy-1.0` §9): number of additional
751    /// user-data recv workers that listen on the same port as
752    /// `user_unicast` via `SO_REUSEPORT`. `0` (default) = only the primary
753    /// `recv_user_data_loop` worker. Under high recv load the pool scales
754    /// linearly with cores (kernel flow hashing distributes incoming
755    /// datagrams). Recommended values: 1-3 additional workers per CPU
756    /// core.
757    pub extra_recv_threads: usize,
758
759    /// D.5g — default DataRepresentation list announced in SEDP
760    /// PublicationData and SEDP SubscriptionData, when not overridden
761    /// per-writer/reader (UserWriterConfig/UserReaderConfig).
762    ///
763    /// **Important**: per strict spec (XTypes 1.3 §7.6.3.1.2) the first
764    /// element is the writer's "offered" and must be in the reader's
765    /// "accepted" list for a match to happen. Default `[XCDR1, XCDR2]` =
766    /// legacy-first → max interop with the RTI Connext Shapes Demo
767    /// (XCDR1-only). Pure-XCDR2 deployments can switch this to `[XCDR2]`
768    /// or `[XCDR2, XCDR1]` for bandwidth efficiency and
769    /// @appendable/@mutable support.
770    ///
771    /// Empty (`vec![]`) is interpreted per spec as `[XCDR1]`.
772    pub data_representation_offer: Vec<i16>,
773
774    /// D.5g — default match mode for DataRepresentation negotiation.
775    ///
776    /// `Strict` (XTypes 1.3 §7.6.3.1.2 normative): writer.first ∈
777    /// reader.list = match. `Tolerant` (industry norm): any overlap =
778    /// match, picks the first overlap as the wire format.
779    ///
780    /// Default `Tolerant` because Cyclone DDS and FastDDS match this way —
781    /// maximizes interop. The strict setting is only meaningful for
782    /// formal spec-compliance tests.
783    pub data_rep_match_mode: zerodds_rtps::publication_data::data_representation::DataRepMatchMode,
784
785    /// zerodds-async-1.0 §4 — when `true`, `start()` does **not** spawn the
786    /// dedicated `zdds-tick` std::thread. The periodic tick (SPDP announce,
787    /// SEDP/WLP, deadline/lifespan/liveliness) must then be driven externally
788    /// via [`DcpsRuntime::tick_driver`]. Used by the async API's
789    /// `spawn_in_tokio`, which multiplexes many participants' tick loops onto
790    /// a tokio runtime instead of one thread each. Default `false` (internal
791    /// thread, unchanged behaviour). The recv worker threads are unaffected —
792    /// they block on socket recv and stay regardless.
793    pub external_tick: bool,
794
795    /// D.5e Phase 3 — when `true`, `start()` drives the periodic tick via the
796    /// event-driven deadline scheduler ([`crate::scheduler`]) instead of the
797    /// fixed-`tick_period` poll: the worker parks until the next due deadline
798    /// (SPDP announce, or a fine floor while user endpoints/QoS timers are
799    /// active) or until a write/recv `raise` wakes it — no busy-poll, lower idle
800    /// CPU, lower tail latency. The work done per wake is the **unchanged**
801    /// `run_tick_iteration` (identical wire output + cadence — cross-vendor
802    /// safe). **Default `true`** since D.5e Phase C (2026-06-14) — set
803    /// `ZERODDS_SCHEDULER_TICK=0` or this field to `false` for the classic
804    /// fixed-period `tick_loop`. Mutually exclusive with `external_tick`
805    /// (external wins).
806    pub scheduler_tick: bool,
807}
808
809/// Configuration entry for a physical or logical network interface.
810///
811/// A binding describes an outbound socket: which IP/port it binds to,
812/// which `NetInterface` class the interface represents, and which IP
813/// range counts as "associated peers" (routing match).
814#[cfg(feature = "security")]
815#[derive(Clone, Debug)]
816pub struct InterfaceBindingSpec {
817    /// Name for diagnostics + log attribution (e.g. `"eth0"`, `"tun0"`,
818    /// `"lo"`).
819    pub name: String,
820    /// Bind address. `0.0.0.0` leaves the interface to the kernel.
821    pub bind_addr: Ipv4Addr,
822    /// Bind port. `0` = ephemeral.
823    pub bind_port: u16,
824    /// Interface class — feeds into the PolicyEngine context.
825    pub kind: NetInterface,
826    /// Destination IP range this binding is responsible for. Example:
827    /// `127.0.0.0/8` for loopback. A target whose IP lies in this range is
828    /// routed to this binding.
829    pub subnet: IpRange,
830    /// If `true`: this binding is used when **no** other subnet match
831    /// applies. Exactly one entry should have `default = true` (usually
832    /// the WAN binding).
833    pub default: bool,
834}
835
836/// Fully bound interface with its UDP socket.
837#[cfg(feature = "security")]
838struct InterfaceBinding {
839    spec: InterfaceBindingSpec,
840    socket: Arc<UdpTransport>,
841}
842
843/// Pool of per-interface UDP sockets with target-based routing.
844///
845/// Decision:
846/// 1. Iterates over all bindings; the first whose subnet contains the
847///    target wins.
848/// 2. If no match and a default binding exists → default path.
849/// 3. No match + no default → `None`, the caller drops.
850#[cfg(feature = "security")]
851struct OutboundSocketPool {
852    bindings: Vec<InterfaceBinding>,
853    default_idx: Option<usize>,
854}
855
856#[cfg(feature = "security")]
857impl OutboundSocketPool {
858    fn bind_all(specs: &[InterfaceBindingSpec]) -> Result<Self> {
859        let mut bindings = Vec::with_capacity(specs.len());
860        for spec in specs {
861            let socket = UdpTransport::bind_v4(spec.bind_addr, spec.bind_port).map_err(|_| {
862                DdsError::TransportError {
863                    label: "interface-binding bind_v4 failed",
864                }
865            })?;
866            // Short read timeout so that the per-interface inbound poll in
867            // the event loop becomes non-blocking. 5 ms is small enough not
868            // to create latency elsewhere (the tick period defaults to
869            // 50 ms), but large enough to amortize context switches.
870            let socket = socket
871                .with_timeout(Some(Duration::from_millis(5)))
872                .map_err(|_| DdsError::TransportError {
873                    label: "interface-binding set_timeout failed",
874                })?;
875            bindings.push(InterfaceBinding {
876                spec: spec.clone(),
877                socket: Arc::new(socket),
878            });
879        }
880        let default_idx = bindings.iter().position(|b| b.spec.default);
881        Ok(Self {
882            bindings,
883            default_idx,
884        })
885    }
886
887    /// Returns `(socket, NetInterface class)` for a destination locator.
888    /// `None` if neither a subnet match nor a default binding exists.
889    fn route(&self, target: &Locator) -> Option<(&Arc<UdpTransport>, NetInterface)> {
890        let ip = ipv4_from_locator(target)?;
891        let addr = core::net::IpAddr::V4(core::net::Ipv4Addr::from(ip));
892        for b in &self.bindings {
893            if b.spec.subnet.contains(&addr) {
894                return Some((&b.socket, b.spec.kind.clone()));
895            }
896        }
897        let idx = self.default_idx?;
898        let b = self.bindings.get(idx)?;
899        Some((&b.socket, b.spec.kind.clone()))
900    }
901}
902
903/// True if the locator is routable over the user-data transport
904/// (trait object). Accepts UDPv4, UDPv6, TCPv4, Shm. The concrete
905/// transport (UdpTransport/TcpTransport/ShmUserTransport) then returns
906/// `UnsupportedLocator` for kinds it does not itself speak;
907/// the filter here only prevents sending to clearly non-IP/SHM
908/// locators like UDS (for which we have no transport plugin).
909fn is_routable_user_locator(loc: &Locator) -> bool {
910    matches!(
911        loc.kind,
912        LocatorKind::UdpV4
913            | LocatorKind::UdpV6
914            | LocatorKind::Tcpv4
915            | LocatorKind::Tcpv6
916            | LocatorKind::Shm
917            | LocatorKind::Uds
918            | LocatorKind::Tsn
919    )
920}
921
922/// Computes the user-endpoint `EndpointSecurityInfo` mask from the governance
923/// protection kinds (DDS-Security 1.2 §10.4.1.2.6 / §9.4.2.4). The wire mask
924/// MUST match cyclone/FastDDS/OpenDDS byte-exactly, otherwise the peer rejects
925/// the endpoint match with "security_attributes mismatch".
926///
927/// - metadata=SIGN/ENCRYPT → IS_SUBMESSAGE_PROTECTED (+ plugin SUBMESSAGE_ENCRYPTED on ENCRYPT)
928/// - data=SIGN    → IS_PAYLOAD_PROTECTED
929/// - data=ENCRYPT → IS_PAYLOAD_PROTECTED | **IS_KEY_PROTECTED** (+ plugin PAYLOAD_ENCRYPTED)
930/// - liveliness=SIGN/ENCRYPT → **IS_LIVELINESS_PROTECTED** (§9.4.1.3: per-endpoint!)
931/// - topic enable_discovery_protection → IS_DISCOVERY_PROTECTED
932///
933/// is_key_protected follows §10.4.1.2.6 exclusively from the **DATA** protection
934/// and only on ENCRYPT — NOT from the metadata protection. is_liveliness_protected
935/// in contrast MUST be on every user endpoint as soon as liveliness_protection is active;
936/// cyclone compares the mask at endpoint match and otherwise rejects with
937/// "security_attributes mismatch" (0x..30 vs 0x..70).
938#[cfg(feature = "security")]
939fn compute_user_endpoint_attrs(
940    meta: ProtectionLevel,
941    data: ProtectionLevel,
942    discovery_protected: bool,
943    liveliness_protected: bool,
944    read_protected: bool,
945    write_protected: bool,
946) -> zerodds_rtps::endpoint_security_info::EndpointSecurityInfo {
947    use zerodds_rtps::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
948    let mut a = attrs::IS_VALID;
949    let mut p = plugin_attrs::IS_VALID;
950    if read_protected {
951        a |= attrs::IS_READ_PROTECTED;
952    }
953    if write_protected {
954        a |= attrs::IS_WRITE_PROTECTED;
955    }
956    if meta != ProtectionLevel::None {
957        a |= attrs::IS_SUBMESSAGE_PROTECTED;
958    }
959    if meta == ProtectionLevel::Encrypt {
960        p |= plugin_attrs::IS_SUBMESSAGE_ENCRYPTED;
961    }
962    if data != ProtectionLevel::None {
963        a |= attrs::IS_PAYLOAD_PROTECTED;
964    }
965    if data == ProtectionLevel::Encrypt {
966        a |= attrs::IS_KEY_PROTECTED;
967        p |= plugin_attrs::IS_PAYLOAD_ENCRYPTED;
968    }
969    if discovery_protected {
970        a |= attrs::IS_DISCOVERY_PROTECTED;
971    }
972    if liveliness_protected {
973        a |= attrs::IS_LIVELINESS_PROTECTED;
974    }
975    EndpointSecurityInfo {
976        endpoint_security_attributes: a,
977        plugin_endpoint_security_attributes: p,
978    }
979}
980
981#[cfg(all(test, feature = "security"))]
982mod endpoint_attr_tests {
983    use super::compute_user_endpoint_attrs;
984    use zerodds_rtps::endpoint_security_info::attrs;
985    use zerodds_security_runtime::ProtectionLevel;
986
987    fn mask(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
988        compute_user_endpoint_attrs(meta, data, false, false, false, false)
989            .endpoint_security_attributes
990    }
991
992    fn mask_liv(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
993        compute_user_endpoint_attrs(meta, data, false, true, false, false)
994            .endpoint_security_attributes
995    }
996
997    #[test]
998    fn liveliness_protected_sets_0x40_per_spec_9_4_1_3() {
999        use ProtectionLevel::{Encrypt, None};
1000        let v = attrs::IS_VALID;
1001        let pay = attrs::IS_PAYLOAD_PROTECTED;
1002        let key = attrs::IS_KEY_PROTECTED;
1003        let liv = attrs::IS_LIVELINESS_PROTECTED;
1004        // liveliness=ENCRYPT + data=ENCRYPT → 0x..70 (cyclone's value at match).
1005        assert_eq!(mask_liv(None, Encrypt), v | pay | key | liv);
1006        // without liveliness → 0x..30, NO 0x40.
1007        assert_eq!(mask(None, Encrypt), v | pay | key);
1008        assert_eq!(mask_liv(None, None), v | liv);
1009    }
1010
1011    #[test]
1012    fn key_protected_follows_data_encrypt_per_spec_10_4_1_2_6() {
1013        use ProtectionLevel::{Encrypt, None, Sign};
1014        let v = attrs::IS_VALID;
1015        let sub = attrs::IS_SUBMESSAGE_PROTECTED;
1016        let pay = attrs::IS_PAYLOAD_PROTECTED;
1017        let key = attrs::IS_KEY_PROTECTED;
1018        // §10.4.1.2.6: is_key_protected follows ONLY data=ENCRYPT.
1019        // data=ENCRYPT → PAYLOAD|KEY (= cyclone's 0x30 in the common subset).
1020        assert_eq!(mask(None, Encrypt), v | pay | key);
1021        // data=SIGN → PAYLOAD, NO KEY.
1022        assert_eq!(mask(None, Sign), v | pay);
1023        // data=NONE → no payload/key bits.
1024        assert_eq!(mask(None, None), v);
1025        // KEY does NOT depend on metadata: meta=ENCRYPT/data=NONE → only SUBMESSAGE.
1026        assert_eq!(mask(Encrypt, None), v | sub);
1027        // meta=ENCRYPT + data=ENCRYPT → SUBMESSAGE|PAYLOAD|KEY (0x38).
1028        assert_eq!(mask(Encrypt, Encrypt), v | sub | pay | key);
1029    }
1030}
1031
1032/// Unicast targets for the WLP heartbeat fan-out (M-2): per discovered peer the
1033/// `metatraffic_unicast_locator` (fallback `default_unicast_locator`), filtered
1034/// to routable kinds. WLP is metatraffic (DDSI-RTPS §8.4.13); in multicast-
1035/// free environments (container/cloud) the pure multicast pulse never reaches the
1036/// peer reader → the lease expires although the peer is alive. The additional
1037/// unicast fan-out follows the SEDP locator model.
1038fn wlp_unicast_targets(peers: &[zerodds_discovery::spdp::DiscoveredParticipant]) -> Vec<Locator> {
1039    peers
1040        .iter()
1041        .filter_map(|dp| {
1042            dp.data
1043                .metatraffic_unicast_locator
1044                .or(dp.data.default_unicast_locator)
1045        })
1046        .filter(is_routable_user_locator)
1047        .collect()
1048}
1049
1050/// Extracts the IPv4 address from a `Locator` (UDP-V4).
1051/// `None` for SHM/UDS/IPv6.
1052#[cfg(feature = "security")]
1053fn ipv4_from_locator(loc: &Locator) -> Option<[u8; 4]> {
1054    if loc.kind != LocatorKind::UdpV4 {
1055        return None;
1056    }
1057    Some([
1058        loc.address[12],
1059        loc.address[13],
1060        loc.address[14],
1061        loc.address[15],
1062    ])
1063}
1064
1065impl core::fmt::Debug for RuntimeConfig {
1066    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1067        let mut dbg = f.debug_struct("RuntimeConfig");
1068        dbg.field("tick_period", &self.tick_period)
1069            .field("spdp_period", &self.spdp_period)
1070            .field("spdp_multicast_group", &self.spdp_multicast_group)
1071            .field("multicast_interface", &self.multicast_interface);
1072        #[cfg(feature = "security")]
1073        {
1074            dbg.field("security", &self.security.as_ref().map(|_| "<gate>"));
1075            dbg.field(
1076                "security_logger",
1077                &self.security_logger.as_ref().map(|_| "<logger>"),
1078            );
1079        }
1080        dbg.finish()
1081    }
1082}
1083
1084impl Default for RuntimeConfig {
1085    fn default() -> Self {
1086        // Env hook for bench tuning: ZERODDS_TICK_PERIOD_MS=N → overrides
1087        // the 5ms default. High (e.g. 1000) relieves the write hot path of
1088        // the periodic HB/tick overhead and makes spread spikes from tick
1089        // preemption visible. Production: do not set; the default 5 ms is
1090        // spec-compliant.
1091        let tick = std::env::var("ZERODDS_TICK_PERIOD_MS")
1092            .ok()
1093            .and_then(|s| s.parse::<u64>().ok())
1094            .map(Duration::from_millis)
1095            .unwrap_or(DEFAULT_TICK_PERIOD);
1096        // C3 WiFi-robust discovery — initial-announcement burst. Env overrides:
1097        // `ZERODDS_INITIAL_ANNOUNCE_COUNT` (0 disables) +
1098        // `ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS`.
1099        let initial_announce_count = std::env::var("ZERODDS_INITIAL_ANNOUNCE_COUNT")
1100            .ok()
1101            .and_then(|s| s.parse::<u32>().ok())
1102            .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_COUNT);
1103        let initial_announce_period = std::env::var("ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS")
1104            .ok()
1105            .and_then(|s| s.parse::<u64>().ok())
1106            .map(Duration::from_millis)
1107            .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_PERIOD);
1108        Self {
1109            tick_period: tick,
1110            spdp_period: DEFAULT_SPDP_PERIOD,
1111            initial_announce_count,
1112            initial_announce_period,
1113            // Env override `ZERODDS_SPDP_MC_GROUP` (IPv4) of the SPDP
1114            // multicast group. Two processes with different groups do NOT
1115            // see each other via multicast → enables a multicast-free C1
1116            // e2e proof (discovery then only via ZERODDS_PEERS). Default is
1117            // the spec group.
1118            spdp_multicast_group: std::env::var("ZERODDS_SPDP_MC_GROUP")
1119                .ok()
1120                .and_then(|s| s.parse::<Ipv4Addr>().ok())
1121                .unwrap_or_else(|| Ipv4Addr::from(SPDP_DEFAULT_MULTICAST_ADDRESS)),
1122            // Interface pinning (Cyclone `NetworkInterface`/FastDDS
1123            // whitelist equivalent): `ZERODDS_INTERFACE=<ipv4>` forces
1124            // announce + bind on this interface. Default UNSPECIFIED = auto
1125            // (route probe). Critical on multi-homed hosts (VPN/Docker/
1126            // macOS bridge100), where the auto choice may announce the
1127            // wrong interface.
1128            multicast_interface: std::env::var("ZERODDS_INTERFACE")
1129                .ok()
1130                .and_then(|s| s.parse::<Ipv4Addr>().ok())
1131                .unwrap_or(Ipv4Addr::UNSPECIFIED),
1132            // Multicast send on by default; `ZERODDS_NO_MULTICAST` (any
1133            // non-empty value) turns it off → pure unicast discovery.
1134            spdp_multicast_send: std::env::var("ZERODDS_NO_MULTICAST")
1135                .map(|v| v.is_empty())
1136                .unwrap_or(true),
1137            // A1: off by default; env `ZERODDS_DISCOVERY_SERVER` (any non-empty
1138            // value) or `RuntimeConfig::discovery_server()` turns the relay on.
1139            discovery_server: std::env::var("ZERODDS_DISCOVERY_SERVER")
1140                .map(|v| !v.is_empty())
1141                .unwrap_or(false),
1142            // C3: 16 MiB default (suitable for ROS PointCloud2/Image),
1143            // env override `ZERODDS_MAX_SAMPLE_BYTES`.
1144            max_reassembly_sample_bytes: std::env::var("ZERODDS_MAX_SAMPLE_BYTES")
1145                .ok()
1146                .and_then(|s| s.parse::<usize>().ok())
1147                .unwrap_or(16 * 1024 * 1024),
1148            // Programmatic default empty. The env `ZERODDS_PEERS` is
1149            // expanded domain-aware only in `DcpsRuntime::start` and merged
1150            // with this field into the effective peer list.
1151            initial_peers: Vec::new(),
1152            user_transport: None,
1153            user_transports: alloc::vec::Vec::new(),
1154            #[cfg(feature = "security")]
1155            security: None,
1156            #[cfg(feature = "security")]
1157            security_logger: None,
1158            #[cfg(feature = "security")]
1159            interface_bindings: Vec::new(),
1160            announce_secure_endpoints: false,
1161            // Env hook for bench/FastDDS interop: ZERODDS_SECURE_SPDP=1 turns
1162            // on the reliable secure SPDP channel (0xff0101). Production sets this
1163            // explicitly via the SecurityProfile/config.
1164            enable_secure_spdp: std::env::var("ZERODDS_SECURE_SPDP").ok().as_deref() == Some("1"),
1165            wlp_period: Duration::ZERO,
1166            participant_lease_duration: Duration::from_secs(100),
1167            user_data: Vec::new(),
1168            observability: zerodds_foundation::observability::null_sink(),
1169            recv_thread_priority: None,
1170            tick_thread_priority: None,
1171            recv_thread_cpus: None,
1172            tick_thread_cpus: None,
1173            extra_recv_threads: 0,
1174            // D.5g — default `[XCDR1, XCDR2]` (legacy-first, max interop).
1175            // Env-var override `ZERODDS_DATA_REPR_OFFER` as a comma list
1176            // ("XCDR1", "XCDR2", "XCDR1,XCDR2", "XCDR2,XCDR1"). Cross-vendor
1177            // benches against strict-matching vendors (RTI) need XCDR2-only
1178            // so that every wire match happens.
1179            data_representation_offer: parse_data_repr_offer_env().unwrap_or_else(|| {
1180                zerodds_rtps::publication_data::data_representation::DEFAULT_OFFER.to_vec()
1181            }),
1182            data_rep_match_mode:
1183                zerodds_rtps::publication_data::data_representation::DataRepMatchMode::default(),
1184            external_tick: false,
1185            // D.5e Phase 3 — the event-driven deadline-heap scheduler is the
1186            // DEFAULT tick (Phase C, 2026-06-14): it parks until the next due
1187            // deadline / a write-recv raise instead of polling every 5 ms (~17×
1188            // fewer idle iterations, lower tail latency, identical wire output).
1189            // Verified cross-vendor secured (data-enc + rtps-enc all pairs) +
1190            // same_host_e2e + latency_assertions on codepit. Escape hatch:
1191            // `ZERODDS_SCHEDULER_TICK=0` restores the classic fixed-period
1192            // `tick_loop`.
1193            scheduler_tick: std::env::var("ZERODDS_SCHEDULER_TICK")
1194                .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
1195                .unwrap_or(true),
1196        }
1197    }
1198}
1199
1200impl RuntimeConfig {
1201    /// Apply a [`SecurityBundle`](zerodds_security_runtime::SecurityBundle):
1202    /// wires its security-event logger into [`Self::security_logger`] and, if
1203    /// the bundle carries a [`SecurityProfile`](zerodds_security_runtime::SecurityProfile),
1204    /// its gate into [`Self::security`]. Convenience for the common
1205    /// `SecurityBundle::builder()…build()` flow so callers don't have to set
1206    /// the two fields by hand.
1207    #[cfg(feature = "security")]
1208    #[must_use]
1209    pub fn with_security_bundle(
1210        mut self,
1211        bundle: &zerodds_security_runtime::SecurityBundle,
1212    ) -> Self {
1213        if let Some(logger) = bundle.logging_plugin() {
1214            self.security_logger = Some(logger);
1215        }
1216        if let Some(profile) = bundle.security_profile() {
1217            self.security = Some(profile.gate.clone());
1218        }
1219        self
1220    }
1221
1222    /// Materialize a security-event logger from `dds.sec.log.*` properties and
1223    /// wire it into [`Self::security_logger`]. This is the DDS-Security
1224    /// spec-style alternative to handing a logger object in directly (see
1225    /// [`Self::with_security_bundle`]): the participant carries
1226    /// `dds.sec.log.plugin = "stderr,jsonl"` (+ `dds.sec.log.*` parameters) on
1227    /// its [`PropertyQosPolicy`](zerodds_qos::PropertyQosPolicy), and the
1228    /// runtime builds the fan-out logger from them.
1229    ///
1230    /// No-op when `dds.sec.log.plugin` is absent. Errors if a selected sink is
1231    /// misconfigured (e.g. `jsonl` without `dds.sec.log.jsonl.path`).
1232    #[cfg(feature = "security")]
1233    pub fn with_security_log_properties(
1234        mut self,
1235        property: &zerodds_qos::PropertyQosPolicy,
1236    ) -> core::result::Result<Self, zerodds_security_logging::LogConfigError> {
1237        let pairs: alloc::vec::Vec<(&str, &str)> = property.iter().collect();
1238        if let Some(logger) = zerodds_security_logging::logging_plugin_from_properties(&pairs)? {
1239            self.security_logger = Some(alloc::sync::Arc::from(logger));
1240        }
1241        Ok(self)
1242    }
1243
1244    /// C4: robotics-capable defaults for **out-of-the-box ROS-2 interop**.
1245    /// Saves the manual env tuning otherwise needed for real ROS-2 nodes.
1246    /// Specifically, compared to [`RuntimeConfig::default`]:
1247    /// - **`data_representation_offer = [XCDR1, XCDR2]`**: `rmw_cyclonedds`/
1248    ///   `rmw_fastrtps` write XCDR1 for final/simple types (e.g.
1249    ///   `std_msgs/String`). An XCDR2-only reader does not match an XCDR1
1250    ///   writer — so the ROS reader here offers both legacy-first
1251    ///   (tolerant match is already the default). This is the clean,
1252    ///   ROS-specific variant of the `ZERODDS_DATA_REPR_OFFER` env
1253    ///   workaround, WITHOUT changing the global `DEFAULT_OFFER`
1254    ///   (XCDR2-only, deliberately for FastDDS/OpenDDS XCDR2 readers).
1255    ///
1256    /// The ROS-realistic reassembly cap (16 MiB, PointCloud2/Image) is
1257    /// already the global default and is carried over here.
1258    #[must_use]
1259    pub fn ros_defaults() -> Self {
1260        use zerodds_rtps::publication_data::data_representation as dr;
1261        Self {
1262            data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1263            ..Self::default()
1264        }
1265    }
1266
1267    /// C6 multi-robot / WAN / cross-subnet profile.
1268    ///
1269    /// A named profile for fleets that span subnets, the cloud, or WiFi —
1270    /// environments that drop IP multicast, so SPDP discovery cannot rely on
1271    /// the multicast beacon. It is the [`ros_defaults`](Self::ros_defaults)
1272    /// representation offer **plus**:
1273    ///
1274    /// - **Multicast-free discovery** (`spdp_multicast_send = false`):
1275    ///   participants find each other purely through unicast initial peers,
1276    ///   regardless of the `ZERODDS_NO_MULTICAST` env. Set the peers via
1277    ///   `ZERODDS_PEERS` (a comma list of `ip` or `ip:port`); a port-less
1278    ///   `ip` is expanded to the well-known SPDP unicast ports of the first
1279    ///   N participant indices (`ZERODDS_MAX_PEER_PARTICIPANTS`).
1280    /// - **WAN-tolerant liveliness**: a longer participant lease (300 s vs
1281    ///   the 100 s spec default) so transient cross-subnet RTT spikes or
1282    ///   brief link drops do not trigger a false liveliness loss.
1283    ///
1284    /// **Domain isolation** is the caller's lever: pass a fleet-dedicated
1285    /// `domain_id` to [`DcpsRuntime::start`] to keep robots off the default
1286    /// domain 0. The profile deliberately does not pick a domain for you.
1287    ///
1288    /// ```
1289    /// use zerodds_dcps::runtime::RuntimeConfig;
1290    /// let cfg = RuntimeConfig::multi_robot();
1291    /// assert!(!cfg.spdp_multicast_send); // unicast-only discovery
1292    /// ```
1293    pub fn multi_robot() -> Self {
1294        use zerodds_rtps::publication_data::data_representation as dr;
1295        Self {
1296            data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1297            spdp_multicast_send: false,
1298            participant_lease_duration: Duration::from_secs(300),
1299            ..Self::default()
1300        }
1301    }
1302
1303    /// A1 — **discovery-server** profile (the server side). Multicast-free, with
1304    /// the SPDP relay on ([`Self::discovery_server`]): clients point their
1305    /// `initial_peers`/`ZERODDS_PEERS` at this one well-known address and the
1306    /// server bridges their participant discovery, so N clients find each other
1307    /// without an O(N²) peer list or multicast. SEDP (incl. ROS-2 Action
1308    /// endpoints) stays direct peer-to-peer — no SEDP proxy, no Action breakage.
1309    ///
1310    /// Clients run with [`RuntimeConfig::multi_robot`] (or any multicast-free
1311    /// config) and set `ZERODDS_PEERS` to the server's address.
1312    ///
1313    /// ```
1314    /// use zerodds_dcps::runtime::RuntimeConfig;
1315    /// let server = RuntimeConfig::discovery_server();
1316    /// assert!(server.discovery_server);
1317    /// assert!(!server.spdp_multicast_send); // unicast-only
1318    /// ```
1319    pub fn discovery_server() -> Self {
1320        Self {
1321            discovery_server: true,
1322            ..Self::multi_robot()
1323        }
1324    }
1325}
1326
1327/// Parse the `ZERODDS_DATA_REPR_OFFER` env var. Values: "XCDR1", "XCDR2",
1328/// or a comma list. None if the env var is missing or invalid.
1329fn parse_data_repr_offer_env() -> Option<Vec<i16>> {
1330    let s = std::env::var("ZERODDS_DATA_REPR_OFFER").ok()?;
1331    parse_data_repr_offer_str(&s)
1332}
1333
1334/// Computes the **well-known** SPDP unicast discovery port for a
1335/// domain + participant index. Formula (DDSI-RTPS 2.5 §9.6.1.4.1):
1336///   port = PB + DG·domain + d1 + PG·pid = 7400 + 250·domain + 10 + 2·pid
1337///
1338/// This lets a configured unicast initial peer (multicast-free discovery)
1339/// reach a participant deterministically WITHOUT having found it via
1340/// multicast first. Defined locally in `dcps` to avoid touching
1341/// `crates/rtps` (spec constants as literals).
1342#[must_use]
1343fn spdp_unicast_port(domain_id: u32, participant_id: u32) -> u32 {
1344    7400 + 250 * domain_id + 10 + 2 * participant_id
1345}
1346
1347/// Default number of participant indices a port-less initial peer is
1348/// expanded to (Cyclone equivalent: `MaxAutoParticipantIndex`). The
1349/// beacon thereby reaches the first N participants of the peer host via
1350/// their well-known SPDP unicast ports. Overridable via the env
1351/// `ZERODDS_MAX_PEER_PARTICIPANTS` (e.g. for dense multi-robot / >10
1352/// participants-per-host scenarios). Cap 120 (= the well-known-port
1353/// allocation window).
1354const INITIAL_PEER_MAX_PARTICIPANTS: u32 = 10;
1355
1356/// Effective peer-expansion limit: env `ZERODDS_MAX_PEER_PARTICIPANTS`
1357/// or [`INITIAL_PEER_MAX_PARTICIPANTS`], clamped to 1..=120.
1358fn initial_peer_max_participants() -> u32 {
1359    std::env::var("ZERODDS_MAX_PEER_PARTICIPANTS")
1360        .ok()
1361        .and_then(|s| s.parse::<u32>().ok())
1362        .unwrap_or(INITIAL_PEER_MAX_PARTICIPANTS)
1363        .clamp(1, 120)
1364}
1365
1366/// C1 multicast-free discovery: parses the env `ZERODDS_PEERS` (comma
1367/// list of `ip` or `ip:port`) into SPDP unicast initial-peer locators for
1368/// `domain_id`. Empty/invalid → empty list.
1369fn parse_initial_peers_env(domain_id: u32) -> Vec<Locator> {
1370    let mut out = Vec::new();
1371    let max = initial_peer_max_participants();
1372    if let Ok(s) = std::env::var("ZERODDS_PEERS") {
1373        for entry in s.split(',') {
1374            expand_initial_peer(entry.trim(), domain_id, max, &mut out);
1375        }
1376    }
1377    out
1378}
1379
1380/// Expands a single peer spec into locator(s) and appends them to `out`.
1381/// `ip:port` → exact locator. Just `ip` → well-known SPDP unicast ports
1382/// of participant indices `0..max_participants` (Spec §9.6.1.4.1).
1383/// Invalid specs are ignored.
1384fn expand_initial_peer(spec: &str, domain_id: u32, max_participants: u32, out: &mut Vec<Locator>) {
1385    if spec.is_empty() {
1386        return;
1387    }
1388    if let Some((ip_s, port_s)) = spec.rsplit_once(':') {
1389        if let (Ok(ip), Ok(port)) = (ip_s.parse::<Ipv4Addr>(), port_s.parse::<u16>()) {
1390            out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1391            return;
1392        }
1393    }
1394    if let Ok(ip) = spec.parse::<Ipv4Addr>() {
1395        for pid in 0..max_participants {
1396            if let Ok(port) = u16::try_from(spdp_unicast_port(domain_id, pid)) {
1397                out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1398            }
1399        }
1400    }
1401}
1402
1403/// Pure parser for the `ZERODDS_DATA_REPR_OFFER` syntax (testable without
1404/// env). Returns the DataRepresentationId list with the **spec values**
1405/// `XCDR=0`, `XCDR2=2` (XTypes 1.3 §7.6.3.1.2) — NOT version numbers.
1406/// `None` on empty/invalid input.
1407fn parse_data_repr_offer_str(s: &str) -> Option<Vec<i16>> {
1408    use zerodds_rtps::publication_data::data_representation as dr;
1409    let mut out = Vec::new();
1410    for tok in s.split(',').map(str::trim) {
1411        let v = match tok.to_ascii_uppercase().as_str() {
1412            "XCDR1" | "XCDR" | "1" => dr::XCDR,
1413            "XCDR2" | "2" => dr::XCDR2,
1414            _ => return None,
1415        };
1416        out.push(v);
1417    }
1418    if out.is_empty() { None } else { Some(out) }
1419}
1420
1421// ---------------------------------------------------------------------------
1422// Security-gate helpers
1423// ---------------------------------------------------------------------------
1424
1425/// Pull outbound UDP bytes through the security gate (when configured).
1426/// Without the `security` feature or without a gate: pass-through (clone
1427/// as Vec).
1428///
1429/// Errors in the gate are logged silently and the packet is **not** sent —
1430/// better to drop than leak plaintext.
1431/// DDS-Security 8.4.2.4: the RTPS message protection (message-level SRTPS)
1432/// does NOT apply to bootstrap traffic that must flow BEFORE the participant
1433/// crypto-key exchange: SPDP (participant discovery, to everyone) and the
1434/// ParticipantStatelessMessage (auth handshake). Wrapping them in SRTPS would
1435/// mean a not-yet-authenticated peer could not decrypt them
1436/// (no key) -> discovery/auth breaks (match timeout pub=0 sub=0). Detection
1437/// via the writer EntityId of the DATA/DATA_FRAG submessages.
1438#[cfg(feature = "security")]
1439fn rtps_message_protection_exempt(
1440    bytes: &[u8],
1441    discovery_plain: bool,
1442    liveliness_plain: bool,
1443) -> bool {
1444    use zerodds_rtps::wire_types::EntityId;
1445    // Bootstrap endpoints (§8.4.2.4): SPDP/Stateless/Volatile ALWAYS flow
1446    // plain (before/during key exchange resp. their own submessage protection).
1447    // Discovery plane (SEDP pub/sub, TypeLookup) is plain when discovery_
1448    // protection_kind=NONE; WLP (ParticipantMessage) plain when liveliness_
1449    // protection_kind=NONE. cyclone<->cyclone reference capture: under rtps_
1450    // protection=ENCRYPT + discovery=NONE cyclone sends the ENTIRE discovery
1451    // plane (DATA+HEARTBEAT+ACKNACK) PLAINTEXT — only user DATA is SRTPS-
1452    // wrapped. ZeroDDS must mirror this, otherwise it drops cyclone's plain
1453    // SubscriptionData as legacy_blocked -> no user-endpoint match.
1454    let entity_exempt = |e: EntityId| -> bool {
1455        matches!(
1456            e,
1457            EntityId::SPDP_BUILTIN_PARTICIPANT_WRITER
1458                | EntityId::SPDP_BUILTIN_PARTICIPANT_READER
1459                | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
1460                | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
1461                | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
1462                | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
1463        ) || (discovery_plain
1464            && matches!(
1465                e,
1466                EntityId::SEDP_BUILTIN_PUBLICATIONS_WRITER
1467                    | EntityId::SEDP_BUILTIN_PUBLICATIONS_READER
1468                    | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_WRITER
1469                    | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_READER
1470                    | EntityId::TL_SVC_REQ_WRITER
1471                    | EntityId::TL_SVC_REQ_READER
1472                    | EntityId::TL_SVC_REPLY_WRITER
1473                    | EntityId::TL_SVC_REPLY_READER
1474            ))
1475            || (liveliness_plain
1476                && matches!(
1477                    e,
1478                    EntityId::BUILTIN_PARTICIPANT_MESSAGE_WRITER
1479                        | EntityId::BUILTIN_PARTICIPANT_MESSAGE_READER
1480                ))
1481    };
1482    let Ok(parsed) = decode_datagram(bytes) else {
1483        return false;
1484    };
1485    // Datagram exempt if it has at least one relevant submessage AND
1486    // ALL relevant ones are exempt (.all) — otherwise a bundled
1487    // exempt+non-exempt datagram leaks the protection-worthy submessage.
1488    let relevant: alloc::vec::Vec<bool> = parsed
1489        .submessages
1490        .iter()
1491        .filter_map(|sm| match sm {
1492            ParsedSubmessage::Data(d) => {
1493                Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1494            }
1495            ParsedSubmessage::DataFrag(d) => {
1496                Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1497            }
1498            ParsedSubmessage::Heartbeat(h) => {
1499                Some(entity_exempt(h.reader_id) || entity_exempt(h.writer_id))
1500            }
1501            ParsedSubmessage::AckNack(a) => {
1502                Some(entity_exempt(a.reader_id) || entity_exempt(a.writer_id))
1503            }
1504            ParsedSubmessage::Gap(g) => {
1505                Some(entity_exempt(g.reader_id) || entity_exempt(g.writer_id))
1506            }
1507            ParsedSubmessage::NackFrag(n) => {
1508                Some(entity_exempt(n.reader_id) || entity_exempt(n.writer_id))
1509            }
1510            // SEC_PREFIX (Kx-Volatile, inner writer-id encrypted) -> exempt.
1511            ParsedSubmessage::Unknown { id: 0x31, .. } => Some(true),
1512            // Framing (INFO_DST/INFO_TS/...) -> neutral.
1513            _ => None,
1514        })
1515        .collect();
1516    !relevant.is_empty() && relevant.iter().all(|&b| b)
1517}
1518
1519#[cfg(feature = "security")]
1520fn secure_outbound_bytes<'a>(
1521    rt: &DcpsRuntime,
1522    bytes: &'a [u8],
1523) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1524    match &rt.config.security {
1525        // OUTBOUND is spec-strict (DDS-Security 8.4.2.4 Table 27 is_rtps_protected):
1526        // under rtps_protection the ENTIRE RTPS message is SRTPS-wrapped; ONLY the
1527        // "separate messages" (SPDP/Stateless/Volatile) flow plain. SEDP/WLP/
1528        // TypeLookup are NOT among them and must be wrapped — independent
1529        // of discovery_/liveliness_protection (those are orthogonal submessage layers).
1530        // -> discovery_plain=false, liveliness_plain=false forces the wrap.
1531        // OpenDDS' RtpsUdpReceiveStrategy::check_encoded otherwise drops every plain SEDP
1532        // as "Full message requires protection". cyclone does take the shortcut
1533        // (sends SEDP plain), but accepts wrapped SEDP inbound without issue.
1534        // The INBOUND path (secure_inbound_bytes) deliberately stays lenient and still
1535        // accepts cyclone's plain SEDP — the asymmetry is intentional.
1536        Some(gate) if rtps_message_protection_exempt(bytes, false, false) => {
1537            let _ = gate;
1538            Some(alloc::borrow::Cow::Borrowed(bytes))
1539        }
1540        Some(gate) => gate
1541            .transform_outbound(bytes)
1542            .ok()
1543            .map(alloc::borrow::Cow::Owned),
1544        None => Some(alloc::borrow::Cow::Borrowed(bytes)),
1545    }
1546}
1547
1548// Security off: no clone — the caller borrows the datagram bytes
1549// directly (copy 6 of the zero-copy audit eliminated).
1550#[cfg(not(feature = "security"))]
1551fn secure_outbound_bytes<'a>(
1552    _rt: &DcpsRuntime,
1553    bytes: &'a [u8],
1554) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1555    Some(alloc::borrow::Cow::Borrowed(bytes))
1556}
1557
1558/// Pull inbound UDP bytes through the security gate.
1559///
1560/// Expects an RTPS header with the GuidPrefix at bytes 8..20.
1561/// `None` → drop the packet.
1562///
1563/// Security: drop reasons are forwarded, differentiated, to the
1564/// configured `LoggingPlugin`:
1565/// * `Malformed`       → `Error`
1566/// * `LegacyBlocked`   → `Error`
1567/// * `PolicyViolation` → `Warning` (possible tampering)
1568/// * `CryptoError`     → `Warning` (tag mismatch, replay etc.)
1569#[cfg(feature = "security")]
1570fn secure_inbound_bytes<'a>(
1571    rt: &DcpsRuntime,
1572    bytes: &'a [u8],
1573    iface: &NetInterface,
1574) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1575    use zerodds_security_runtime::{InboundVerdict, LogLevel};
1576    let Some(gate) = &rt.config.security else {
1577        return Some(alloc::borrow::Cow::Borrowed(bytes));
1578    };
1579    // DDS-Security 8.4.2.4 (symmetric to outbound): SPDP/Stateless are
1580    // message-protection-exempt and ALWAYS arrive plain (also from cyclone). Without
1581    // this exception classify_inbound discards plain SPDP on the WAN iface under
1582    // rtps_protection as LegacyBlocked -> no discovery (match timeout).
1583    {
1584        let looks_srtps = bytes.len() > 20usize && bytes[20usize] == 0x33;
1585        if !looks_srtps
1586            && rtps_message_protection_exempt(
1587                bytes,
1588                gate.discovery_protection().unwrap_or(ProtectionLevel::None)
1589                    == ProtectionLevel::None,
1590                gate.liveliness_protection()
1591                    .unwrap_or(ProtectionLevel::None)
1592                    == ProtectionLevel::None,
1593            )
1594        {
1595            // SRTPS-exempt. BUT metadata_protection user DATA carries per-submessage
1596            // SEC_PREFIX/BODY/POSTFIX (§9.5.3.3, NO SRTPS) — that must still be
1597            // decrypted per-endpoint here, otherwise the reader gets the
1598            // SEC wrapper instead of the DATA. Volatile-Kx-SEC fails with None
1599            // (key_id not in user-remote_by_key_id) -> unchanged for the
1600            // Volatile handler in the metatraffic loop.
1601            if walk_submessages(bytes)
1602                .iter()
1603                .any(|(id, _, _)| *id == SMID_SEC_PREFIX)
1604            {
1605                let mut pk = [0u8; 12];
1606                pk.copy_from_slice(&bytes[8..20]);
1607                if let Some(mut dg) = unprotect_user_datagram(rt, bytes, &pk) {
1608                    match unprotect_user_payload(rt, &dg) {
1609                        PayloadDecode::Decoded(clear) => dg = clear,
1610                        PayloadDecode::Failed => return None,
1611                        PayloadDecode::NotEncrypted => {}
1612                    }
1613                    return Some(alloc::borrow::Cow::Owned(dg));
1614                }
1615            }
1616            return Some(alloc::borrow::Cow::Borrowed(bytes));
1617        }
1618    }
1619    let verdict = gate.classify_inbound(bytes, iface);
1620    let category = verdict.category();
1621    let (level, message): (LogLevel, String) = match &verdict {
1622        InboundVerdict::Accept(out) => {
1623            // Cross-vendor user DATA: cyclone protects the DATA submessage as a
1624            // SEC_PREFIX/BODY/POSTFIX sequence (metadata_protection=ENCRYPT). Before
1625            // the submessage parse, transform it back with the sender's data key
1626            // (GuidPrefix = bytes[8..20]). `unprotect_user_datagram` returns
1627            // `None` when no SEC_* sequence is present → normal accept path.
1628            // OUTER layer first (metadata_protection, SEC_PREFIX/BODY/
1629            // POSTFIX), then the INNER one (data_protection, encrypted
1630            // SerializedPayload §9.5.3.3.1). Both can be active at once
1631            // (full secure profile); each returns `None` when its layer
1632            // is not present -> then the datagram stays unchanged.
1633            let mut dg: alloc::vec::Vec<u8> = out.clone();
1634            if dg.len() >= 20 {
1635                let mut pk = [0u8; 12];
1636                pk.copy_from_slice(&dg[8..20]);
1637                if let Some(clear) = unprotect_user_datagram(rt, &dg, &pk) {
1638                    dg = clear;
1639                }
1640            }
1641            match unprotect_user_payload(rt, &dg) {
1642                PayloadDecode::Decoded(clear) => dg = clear,
1643                // Undecodable encrypted payload -> discard the datagram
1644                // (no ciphertext garbage to the reader; reliable re-send resp. another
1645                // copy delivers the sample later).
1646                PayloadDecode::Failed => return None,
1647                PayloadDecode::NotEncrypted => {}
1648            }
1649            return Some(alloc::borrow::Cow::Owned(dg));
1650        }
1651        InboundVerdict::Malformed => (
1652            LogLevel::Error,
1653            alloc::format!(
1654                "inbound datagram too short ({} bytes, iface={:?})",
1655                bytes.len(),
1656                iface
1657            ),
1658        ),
1659        InboundVerdict::LegacyBlocked => (
1660            LogLevel::Error,
1661            alloc::format!(
1662                "legacy plaintext peer on protected domain \
1663                 (iface={iface:?}, allow_unauthenticated_participants=false)"
1664            ),
1665        ),
1666        InboundVerdict::PolicyViolation(msg) => {
1667            (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1668        }
1669        InboundVerdict::CryptoError(msg) => {
1670            (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1671        }
1672    };
1673    if let Some(logger) = &rt.config.security_logger {
1674        // Participant ident: GuidPrefix (or 0-padding for Malformed).
1675        let mut participant = [0u8; 16];
1676        if bytes.len() >= 20 {
1677            participant[..12].copy_from_slice(&bytes[8..20]);
1678        }
1679        logger.log(level, participant, category, &message);
1680    }
1681    None
1682}
1683
1684#[cfg(not(feature = "security"))]
1685fn secure_inbound_bytes<'a>(
1686    _rt: &DcpsRuntime,
1687    bytes: &'a [u8],
1688) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1689    Some(alloc::borrow::Cow::Borrowed(bytes))
1690}
1691
1692/// Default interface class for inbound dispatch when the socket does not
1693/// belong to the `outbound_pool`. In the v1.4 setup (without
1694/// `interface_bindings`), all packets run through `user_unicast` and are
1695/// classified as `Wan` — the most conservative assumption (protection
1696/// rules apply as in the single-interface case).
1697#[cfg(feature = "security")]
1698const DEFAULT_INBOUND_IFACE: NetInterface = NetInterface::Wan;
1699
1700/// Per-reader outbound transform.
1701///
1702/// Looks up in the writer slot which `ProtectionLevel` the matched reader
1703/// expects at the given `target` locator, then pulls the datagram through
1704/// the security gate individually. This way each reader gets a wire
1705/// payload matching its security profile (Legacy=plain, Fast=Sign,
1706/// Secure=Encrypt).
1707///
1708/// Fallback paths:
1709/// * No security gate configured → passthrough.
1710/// * No `locator_to_peer` entry (reader not yet matched via SEDP) →
1711///   `transform_outbound` with the domain rule — that is the homogeneous
1712///   v1.4 path.
1713/// * The gate returns an error → `None` (the caller drops — better no
1714///   plaintext leak).
1715#[cfg(feature = "security")]
1716fn secure_outbound_for_target(
1717    rt: &DcpsRuntime,
1718    writer_eid: EntityId,
1719    bytes: &[u8],
1720    target: &Locator,
1721) -> Option<Vec<u8>> {
1722    let Some(gate) = &rt.config.security else {
1723        return Some(bytes.to_vec());
1724    };
1725    // FU2 S3: fallback level from our own governance (data_protection_
1726    // kind), in case the matched reader did not announce an explicit SEDP
1727    // security_info level. This way user data to an authenticated peer is
1728    // encrypted per our own governance, while SPDP/SEDP metatraffic
1729    // bootstraps plaintext over rtps_protection_kind=NONE.
1730    // Governance `data_protection` is a FLOOR, not a mere fallback: a
1731    // per-reader level can only STRENGTHEN (e.g. legacy plaintext is only
1732    // allowed if the domain policy itself permits plaintext), never fall
1733    // below the domain policy. Otherwise a matched-but-not-authenticated
1734    // peer (foreign CA, SEDP match over plaintext discovery,
1735    // reader_protection=None) leaks plaintext user data.
1736    let gov_data_level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1737    // metadata_protection (§8.4.2.4 / §9.5.3.3): EVERY writer submessage (DATA,
1738    // HEARTBEAT, GAP) is SEC_PREFIX/BODY/POSTFIX-wrapped per-submessage —
1739    // TARGET-INDEPENDENT, since the per-endpoint writer key is local (the peer fetches
1740    // it via datawriter_crypto_token). Must take effect BEFORE the locator-based reader
1741    // resolution: otherwise tick HEARTBEATs/GAPs to not-yet-locator-
1742    // matched targets fall into the None branch -> with rtps=NONE PLAIN -> leak + no
1743    // reliable recovery (breaks already zero<->zero). data_protection (inner
1744    // payload layer) first, then the outer submessage layer.
1745    if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1746        let inner = if gov_data_level != ProtectionLevel::None {
1747            protect_user_payload(rt, bytes)?
1748        } else {
1749            bytes.to_vec()
1750        };
1751        let meta_sec = protect_user_datagram(rt, &inner)?;
1752        // Under rtps_protection message-level SRTPS MUST additionally go on top —
1753        // BOTH layers, like cyclone<->cyclone. Without it the peer would see the
1754        // metadata-SEC-DATA as "clear submsg from protected src" and discard it.
1755        if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1756            return gate.transform_outbound(&meta_sec).ok();
1757        }
1758        return Some(meta_sec);
1759    }
1760    let resolved = rt.writer_slot(writer_eid).and_then(|arc| {
1761        arc.lock().ok().and_then(|slot| {
1762            let pk = slot.locator_to_peer.get(target).copied()?;
1763            // An EXPLICITLY negotiated per-reader level is respected: a
1764            // legacy-v1.4 reader has reader_protection=None and MUST get plaintext,
1765            // otherwise it cannot decode (heterogeneous domain). Only
1766            // when NO entry exists (matched via plaintext discovery, but
1767            // no level negotiated -> potentially unauthenticated) does the
1768            // governance data_protection FLOOR apply as leak protection.
1769            // Governance data_protection is a FLOOR (§8.4.2.4, memory-documented):
1770            // a per-reader level can only STRENGTHEN, never fall below the domain
1771            // policy. A reader discovered via secure SEDP whose security_info parses
1772            // to `Some(None)` (no is_payload_protected bit detected, discovery=
1773            // ENCRYPT) would otherwise yield level=None -> Some(None) arm -> PLAINTEXT
1774            // leak, although the domain requires data_protection=ENCRYPT (disc-data-
1775            // enc: zerodds sent user DATA without the N-flag -> OpenDDS decode_serialized_
1776            // payload=0 -> no echo). `.max` enforces at least the governance FLOOR.
1777            // With gov=None legacy plaintext (reader_lv) stays allowed.
1778            let level = match slot.reader_protection.get(&pk).copied() {
1779                Some(reader_lv) => reader_lv.max(gov_data_level),
1780                None => gov_data_level,
1781            };
1782            Some((pk, level))
1783        })
1784    });
1785    match resolved {
1786        // Matched reader with Sign/Encrypt: cyclone-conformant SUBMESSAGE
1787        // protection (SEC_PREFIX/BODY/POSTFIX around the DATA submessage, local
1788        // data key) instead of message-level SRTPS — `metadata_protection_kind=
1789        // ENCRYPT`, §9.5.3.3. cyclone decodes with the key sent via datawriter_crypto_
1790        // tokens. `None` level = byte-identical passthrough.
1791        Some((peer_key, level)) if level != ProtectionLevel::None => {
1792            // Layer choice per governance (DDS-Security §8.4.2.4 vs §7.3.7):
1793            //  * metadata_protection_kind != NONE -> per-submessage protection
1794            //    (`encode_datawriter_submessage`, SEC_PREFIX/BODY/POSTFIX) for
1795            //    EVERY writer submessage (DATA, HEARTBEAT, GAP, ...). This is the
1796            //    cyclone interop path: cyclone expects HEARTBEAT/GAP SEC_*-
1797            //    wrapped too, otherwise its reader never NACKs (no reliable recovery).
1798            //  * otherwise (only rtps_protection_kind != NONE) -> message-level SRTPS
1799            //    via `transform_outbound_for` (whole message, §7.3.7).
1800            // INNER layer (§9.5.3.3.1): data_protection encrypts ONLY the
1801            // SerializedPayload of each DATA submessage. Applied BEFORE the outer
1802            // submessage/message layer — cyclone-conformant
1803            // nesting (§9.5.3.3): data_protection (inner) + metadata_
1804            // protection (outer). With pure data_protection this is the
1805            // only + complete protection.
1806            let inner: Vec<u8> = if gov_data_level != ProtectionLevel::None {
1807                // Crypto error -> drop instead of leak (None propagated via `?`).
1808                protect_user_payload(rt, bytes)?
1809            } else {
1810                bytes.to_vec()
1811            };
1812            // OUTER layer choice (DDS-Security §8.4.2.4 / §7.3.7):
1813            if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None
1814            {
1815                // metadata_protection -> per-submessage protection (DATA, HEARTBEAT,
1816                // GAP, ...) with the per-endpoint writer key (cyclone interop path).
1817                // Under additional rtps_protection message-level SRTPS MUST go on top
1818                // (both layers) — otherwise "clear submsg from protected src".
1819                match protect_user_datagram(rt, &inner) {
1820                    Some(ms)
1821                        if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1822                            != ProtectionLevel::None =>
1823                    {
1824                        gate.transform_outbound(&ms).ok()
1825                    }
1826                    other => other,
1827                }
1828            } else if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1829                != ProtectionLevel::None
1830            {
1831                // rtps_protection -> message-level SRTPS (whole message, §7.3.7),
1832                // per-reader key.
1833                gate.transform_outbound_for(&peer_key, &inner, level).ok()
1834            } else {
1835                // only data_protection -> the payload layer is already the
1836                // complete protection (§9.5.3.3.1). Header/InlineQoS stay
1837                // plaintext, the encrypted payload carries the N-flag.
1838                Some(inner)
1839            }
1840        }
1841        // Matched reader with level None: a legacy-v1.4 reader (explicit
1842        // SEDP legacy or NONE governance) gets byte-identical plaintext —
1843        // message-level SRTPS would make it undecryptable.
1844        Some(_) => {
1845            // Matched reader with data level None: under rtps_protection the
1846            // message MUST still be message-level-SRTPS-wrapped (§8.4.2.4) —
1847            // the data_protection level only controls the payload/submessage layer.
1848            // Without it user DATA/HEARTBEAT leaks plain, although the domain
1849            // requires rtps_protection=ENCRYPT (the peer discards it as legacy).
1850            if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1851                gate.transform_outbound(bytes).ok()
1852            } else {
1853                Some(bytes.to_vec())
1854            }
1855        }
1856        // No locator-resolved reader: multicast/meta bootstrap OR a
1857        // user reader whose locator is (not yet) in `locator_to_peer`
1858        // (e.g. discovered via secure SEDP, discovery_protection=ENCRYPT). The
1859        // data_protection (inner payload layer §9.5.3.3.1) is TARGET-INDEPENDENT
1860        // (local writer key) and MUST still apply for a user writer —
1861        // otherwise under data_protection=ENCRYPT the user DATA leaks PLAINTEXT (N-flag
1862        // missing -> a spec-conformant remote reader never calls `decode_serialized_payload`
1863        // -> no sample, no echo; disc-data-enc stall, source-documented: OpenDDS
1864        // decode_serialized_payload=0). ONLY for user writers — SPDP/SEDP builtin DATA
1865        // must bootstrap plaintext (otherwise undecodable before key exchange).
1866        None => {
1867            use zerodds_rtps::wire_types::EntityKind;
1868            let is_user_writer = matches!(
1869                writer_eid.entity_kind,
1870                EntityKind::UserWriterWithKey | EntityKind::UserWriterNoKey
1871            );
1872            if is_user_writer && gov_data_level != ProtectionLevel::None {
1873                let inner = protect_user_payload(rt, bytes)?;
1874                gate.transform_outbound(&inner).ok()
1875            } else {
1876                gate.transform_outbound(bytes).ok()
1877            }
1878        }
1879    }
1880}
1881
1882#[cfg(not(feature = "security"))]
1883fn secure_outbound_for_target(
1884    _rt: &DcpsRuntime,
1885    _writer_eid: EntityId,
1886    bytes: &[u8],
1887    _target: &Locator,
1888) -> Option<Vec<u8>> {
1889    Some(bytes.to_vec())
1890}
1891
1892/// FU2 S3: data_protection-aware user DATA outbound. Encrypts the
1893/// datagram with the governance `data_protection` level. `transform_outbound_
1894/// for` ignores the `peer_key` and uses the local key — the ciphertext
1895/// is decryptable for EVERY authenticated peer (with our token),
1896/// non-authenticated peers cannot read it. A `None` level falls
1897/// back to message-level (`rtps_protection` resp. passthrough). Used for
1898/// UDP + in-process fastpath + SHM UNIFORMLY, so the
1899/// inproc path is secured too.
1900#[cfg(feature = "security")]
1901fn secure_user_outbound<'a>(
1902    rt: &DcpsRuntime,
1903    bytes: &'a [u8],
1904) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1905    let Some(gate) = &rt.config.security else {
1906        return Some(alloc::borrow::Cow::Borrowed(bytes));
1907    };
1908    let level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1909    if matches!(level, ProtectionLevel::None) {
1910        gate.transform_outbound(bytes)
1911            .ok()
1912            .map(alloc::borrow::Cow::Owned)
1913    } else {
1914        gate.transform_outbound_for(&[0u8; 12], bytes, level)
1915            .ok()
1916            .map(alloc::borrow::Cow::Owned)
1917    }
1918}
1919
1920#[cfg(not(feature = "security"))]
1921fn secure_user_outbound<'a>(
1922    _rt: &DcpsRuntime,
1923    bytes: &'a [u8],
1924) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1925    Some(alloc::borrow::Cow::Borrowed(bytes))
1926}
1927
1928/// Sends `bytes` to `target` on the matching interface socket.
1929/// Falls back to `rt.user_unicast` if no
1930/// pool is configured or no binding matches the target range
1931/// and no default binding is set either.
1932#[cfg(feature = "security")]
1933fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1934    if let Some(pool) = &rt.outbound_pool {
1935        if let Some((socket, _iface)) = pool.route(target) {
1936            let _ = socket.send(target, bytes);
1937            return;
1938        }
1939    }
1940    let _ = rt.user_unicast.send(target, bytes);
1941}
1942
1943#[cfg(not(feature = "security"))]
1944fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1945    let _ = rt.user_unicast.send(target, bytes);
1946}
1947
1948/// User-writer slot in the runtime. Carries ReliableWriter + topic meta +
1949/// fragment size (from QoS).
1950struct UserWriterSlot {
1951    writer: ReliableWriter,
1952    topic_name: String,
1953    type_name: String,
1954    reliable: bool,
1955    durability: zerodds_qos::DurabilityKind,
1956    /// Deadline period in nanoseconds (0 == INFINITE, no monitoring).
1957    deadline_nanos: u64,
1958    /// Last successful `write` relative to `DcpsRuntime::start_instant`.
1959    last_write: Option<Duration>,
1960    /// Counter for missed deadlines (Spec §2.2.4.2.9).
1961    offered_deadline_missed_count: u64,
1962    /// Counter for LivelinessLost detections from the writer's view
1963    /// (Spec §2.2.4.2.10). Incremented in `check_writer_liveliness` on
1964    /// manual-lease overrun. 0 == not monitored.
1965    liveliness_lost_count: u64,
1966    /// Last assert time (manual liveliness). `None` == never.
1967    last_liveliness_assert: Option<Duration>,
1968    /// Per-policy counter for offered_incompatible_qos. Spec
1969    /// §2.2.4.2.4.2 — writer side. Incremented on
1970    /// `wire_writer_to_remote_reader` reject.
1971    offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus,
1972    /// Lifespan duration in nanoseconds (0 == INFINITE, no expiry).
1973    lifespan_nanos: u64,
1974    /// Per sample SN the insert time (relative to start_instant).
1975    /// Removed from front on expiry — SNs are monotonic, lifespan
1976    /// is constant, so the expiry prefix is always front.
1977    sample_insert_times:
1978        alloc::collections::VecDeque<(zerodds_rtps::wire_types::SequenceNumber, Duration)>,
1979    /// Liveliness kind (Automatic / ManualByParticipant / ManualByTopic).
1980    liveliness_kind: zerodds_qos::LivelinessKind,
1981    /// Lease duration in nanoseconds (0 == INFINITE).
1982    liveliness_lease_nanos: u64,
1983    /// Ownership mode.
1984    ownership: zerodds_qos::OwnershipKind,
1985    /// Ownership strength (Spec §2.2.3.2). Mirrored in the same-runtime
1986    /// dispatch into `UserSample::Alive.writer_strength`, so that
1987    /// EXCLUSIVE ownership logic in the reader also works for intra-process
1988    /// loopback.
1989    ownership_strength: i32,
1990    /// Partition list.
1991    partition: Vec<String>,
1992    /// Per-matched-reader ProtectionLevel. Derived at the
1993    /// SEDP match from `sub.security_info`. `None` entries
1994    /// for legacy readers. Empty for writers without matched
1995    /// security peers — then the hot path is unchanged.
1996    #[cfg(feature = "security")]
1997    reader_protection: BTreeMap<[u8; 12], ProtectionLevel>,
1998    /// Mapping Locator → GuidPrefix for the writer tick loop, so that
1999    /// `secure_outbound_for_target` can look up the protection per target
2000    /// without breaking the writer-tick API (`dg.targets` are
2001    /// locator lists today).
2002    #[cfg(feature = "security")]
2003    locator_to_peer: BTreeMap<Locator, [u8; 12]>,
2004    /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the writer type
2005    /// (from `T::TYPE_IDENTIFIER` in `UserWriterConfig`).
2006    type_identifier: zerodds_types::TypeIdentifier,
2007    /// D.5g — per-writer override for the DataRepresentation offer.
2008    /// `None` = runtime default. `Some(vec)` = hardcoded per writer.
2009    data_rep_offer_override: Option<Vec<i16>>,
2010    /// Type extensibility of the writer type (FINAL/APPENDABLE/MUTABLE).
2011    /// Together with the offer `first` element it determines the
2012    /// encapsulation header of the user payload (see
2013    /// [`user_payload_encap`]). Default `Final`; set by codegen/FFI via
2014    /// `set_user_writer_wire_extensibility` when the type
2015    /// is appendable/mutable (relevant for XCDR2 wire: D_CDR2/PL_CDR2).
2016    wire_extensibility: zerodds_types::qos::ExtensibilityForRepr,
2017    /// Emit the big-endian encapsulation variant (`_BE`, RTPS 2.5 §10.5)
2018    /// instead of the little-endian default. Set by the durability service
2019    /// replay path so a big-endian peer's stored sample is re-published with a
2020    /// matching BE encap header (the body bytes are already big-endian). `false`
2021    /// = little-endian (the canonical wire for a normal writer).
2022    big_endian_override: bool,
2023    /// Spec §2.2.3.5 DurabilityService — with Durability=Transient/
2024    /// Persistent the backend holds in addition to the writer's own
2025    /// HistoryCache. On the first late-joiner match in
2026    /// `wire_writer_to_remote_reader` the backend samples are
2027    /// (re-)injected into the HistoryCache, so that the RTPS reliable
2028    /// path delivers them to the reader. `None` for Volatile/
2029    /// TransientLocal (the cache suffices).
2030    durability_backend: Option<alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>>,
2031    /// `true` as soon as the backend has been replayed once into the
2032    /// HistoryCache. Prevents repeated re-injection on further matches.
2033    backend_primed: bool,
2034    /// HISTORY KeepLast depth (DDS 1.4 §2.2.3.18). Per-instance retained-sample
2035    /// depth for the same-runtime durability replay path. Default
2036    /// [`DEFAULT_INTRA_HISTORY_DEPTH`]. KeepAll is modelled as `usize::MAX`.
2037    /// Settable via [`DcpsRuntime::set_user_writer_history_depth`].
2038    history_depth: usize,
2039    /// TRANSIENT_LOCAL retained samples (DDS 1.4 §2.2.3.4) for the
2040    /// same-runtime late-joiner replay path. Holds the *most recent*
2041    /// `history_depth` Alive samples **per instance key** plus any
2042    /// terminal lifecycle marker for an instance. A reader that joins an
2043    /// intra-runtime route AFTER these writes replays this buffer so it sees
2044    /// the retained history (the wire/SEDP path is separate, see
2045    /// `wire_writer_to_remote_reader`). Empty unless durability is
2046    /// TransientLocal or stronger.
2047    retained: alloc::collections::VecDeque<RetainedSample>,
2048    /// Set of intra-runtime reader EntityIds that have already received the
2049    /// TransientLocal retained-sample replay, so a route recompute does not
2050    /// replay the same history twice to the same reader.
2051    intra_replayed_readers: alloc::collections::BTreeSet<EntityId>,
2052}
2053
2054/// Default same-runtime HISTORY KeepLast depth when the user has not called
2055/// [`DcpsRuntime::set_user_writer_history_depth`]. Mirrors the DDS spec default
2056/// of `depth = 1` for KEEP_LAST (DDS 1.4 §2.2.3.18 Table).
2057const DEFAULT_INTRA_HISTORY_DEPTH: usize = 1;
2058
2059/// One retained sample for the same-runtime TransientLocal replay path.
2060#[derive(Debug, Clone)]
2061struct RetainedSample {
2062    /// Instance key hash (16 byte). All-zero for NoKey topics / unknown key.
2063    key_hash: [u8; 16],
2064    /// CDR body without encapsulation header.
2065    payload: Vec<u8>,
2066    /// XCDR version tag (`0` = XCDR1, `1` = XCDR2).
2067    representation: u8,
2068    /// Writer ownership strength at write time.
2069    strength: i32,
2070    /// `Some(kind)` if this entry is a terminal lifecycle marker
2071    /// (dispose / unregister) rather than an alive sample.
2072    lifecycle: Option<zerodds_rtps::history_cache::ChangeKind>,
2073}
2074
2075/// The listener dispatch carries, alongside the `UserSample`, a
2076/// zero-copy view on the original `Arc<[u8]>` with an encap offset
2077/// (lever-E zero-copy path).
2078pub type UserSampleWithEncap = (UserSample, Option<(Arc<[u8]>, usize)>);
2079
2080/// Sample channel item: either data payload or lifecycle marker.
2081/// Lifecycle is reconstructed by the wire path as `key_hash + ChangeKind` from
2082/// the PID_STATUS_INFO header; the DataReader DCPS layer
2083/// translates that into `__push_lifecycle`.
2084#[derive(Debug, Clone)]
2085pub enum UserSample {
2086    /// Normal sample with payload (CDR-encoded application type).
2087    /// `writer_guid` is the 16-byte GUID of the emitting writer
2088    /// — needed by the subscriber for exclusive-ownership resolution
2089    /// (DDS 1.4 §2.2.3.23 / §2.2.2.5.5).
2090    Alive {
2091        /// CDR payload (without encapsulation header). Zero-copy container:
2092        /// typically holds an `Arc<[u8]>` slice into the RTPS wire datagram
2093        /// without a heap re-alloc. See `docs/specs/zerodds-zero-copy-1.0.md`.
2094        payload: crate::sample_bytes::SampleBytes,
2095        /// Writer GUID — for strongest-writer selection.
2096        writer_guid: [u8; 16],
2097        /// Writer `ownership_strength` at the time of receipt.
2098        /// `0` if the writer is not yet known via discovery
2099        /// (the reader treats this as default strength = spec-conformant
2100        /// for shared-ownership topics; for exclusive the
2101        /// reader filters the real strength against the current owner).
2102        writer_strength: i32,
2103        /// XCDR version of the `payload` — extracted from the encapsulation
2104        /// header of the wire sample (RTPS 2.5 §10.5) BEFORE the
2105        /// header was stripped: `0` = XCDR1 (CDR/PL_CDR), `1` =
2106        /// XCDR2 (CDR2/D_CDR2/PL_CDR2). The typed consumer
2107        /// needs this to decode the body with the correct alignment rule
2108        /// (XTypes 1.3 §7.4.3.4.2).
2109        representation: u8,
2110        /// Byte order of the `payload` — extracted from the encapsulation
2111        /// representation identifier's low bit (RTPS 2.5 §10.5: the `_BE`
2112        /// variants 0x0000/0x0002/0x0006/0x0008/0x000a are even, the `_LE`
2113        /// variants odd). `false` = little-endian (the canonical wire and the
2114        /// intra-runtime default), `true` = big-endian. The typed consumer
2115        /// dispatches `DdsType::decode` vs `decode_be` on this.
2116        big_endian: bool,
2117        /// Source timestamp from the writer's INFO_TS submessage (DDSI-RTPS
2118        /// §8.7.3), if any. Threaded into `SampleInfo.source_timestamp` and the
2119        /// `DESTINATION_ORDER = BY_SOURCE_TIMESTAMP` decision. `None` ⇒ the
2120        /// reader uses reception order.
2121        source_timestamp: Option<zerodds_rtps::header_extension::HeTimestamp>,
2122    },
2123    /// Lifecycle marker (dispose / unregister) — the reader sets
2124    /// InstanceState accordingly.
2125    Lifecycle {
2126        /// Key hash of the affected instance (16 byte).
2127        key_hash: [u8; 16],
2128        /// `NotAliveDisposed` / `NotAliveUnregistered` /
2129        /// `NotAliveDisposedUnregistered`.
2130        kind: zerodds_rtps::history_cache::ChangeKind,
2131    },
2132}
2133
2134/// User-reader slot. ReliableReader + topic meta + channel to the
2135/// DataReader (DCPS API side).
2136/// Listener callback for sample arrival.
2137///
2138/// Fired synchronously by `recv_user_data_loop` in the recv-thread
2139/// context as soon as an alive sample lands in the reader HistoryCache.
2140/// Eliminates the polling latency of `zerodds_reader_take()` —
2141/// the listener path typically saves 50-100 µs per side.
2142///
2143/// **Contract** (analogous to DDS spec §2.2.4.4 listener semantics):
2144/// * The callback runs on the recv thread, NOT the user thread.
2145/// * Short and non-blocking. No I/O, no locks, no
2146///   ZeroDDS API calls inside.
2147/// * `bytes` points to the CDR payload of the alive sample (without
2148///   encapsulation header). Lifetime only for the duration of the
2149///   callback; copy if needed beyond the call.
2150/// * Disposed/unregistered lifecycle events do NOT fire the listener
2151///   (only `Alive` samples) — for lifecycle tracking
2152///   keep using `zerodds_reader_take()` or add a
2153///   lifecycle-listener API.
2154///
2155/// Data-available listener. Arguments: CDR body (without encapsulation
2156/// header) and the XCDR version of the sample (`0` = XCDR1, `1` = XCDR2)
2157/// — the typed consumer needs the latter for the alignment
2158/// rule on decode (XTypes 1.3 §7.4.3.4.2).
2159pub type UserReaderListener = alloc::boxed::Box<dyn Fn(&[u8], u8, u8) + Send + Sync + 'static>;
2160
2161struct UserReaderSlot {
2162    reader: ReliableReader,
2163    topic_name: String,
2164    type_name: String,
2165    sample_tx: mpsc::Sender<UserSample>,
2166    /// Spec §3 zerodds-async-1.0: async waker slot. Registered by the
2167    /// async reader; on `sample_tx.send` we call
2168    /// `waker.wake()`. `None` if no async reader is active.
2169    async_waker: alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>,
2170    /// Listener callback for alive samples.
2171    /// Fired synchronously by `recv_user_data_loop`. `None` =
2172    /// no listener registered (the user polls via
2173    /// `zerodds_reader_take()`). Arc, so the recv thread can
2174    /// execute the callback cloned without another lock (minimize lock
2175    /// hold time).
2176    listener: Option<alloc::sync::Arc<UserReaderListener>>,
2177    durability: zerodds_qos::DurabilityKind,
2178    /// Deadline period in nanoseconds (0 == INFINITE).
2179    deadline_nanos: u64,
2180    /// Time of the last received sample relative to runtime start.
2181    last_sample_received: Option<Duration>,
2182    /// Counter for missed deadline expectations (Spec §2.2.4.2.11).
2183    requested_deadline_missed_count: u64,
2184    /// Per-policy counter for requested_incompatible_qos. Spec
2185    /// §2.2.4.2.6.5 — reader side. Incremented on
2186    /// `wire_reader_to_remote_writer` reject.
2187    requested_incompatible_qos: crate::status::RequestedIncompatibleQosStatus,
2188    /// Sample-lost counter (Spec §2.2.4.2.6.2). Incremented
2189    /// by `record_sample_lost`.
2190    sample_lost_count: u64,
2191    /// Sample-rejected counter (Spec §2.2.4.2.6.3). Incremented
2192    /// by `record_sample_rejected`.
2193    sample_rejected: crate::status::SampleRejectedStatus,
2194    /// Monotonically increasing count of alive samples delivered to the
2195    /// user. Serves as a non-consuming data-availability detector for
2196    /// `on_data_available` (DDS 1.4 §2.2.4.2.6.1) — unlike
2197    /// `last_sample_received`, this counter is only bumped on real sample
2198    /// delivery, never by the deadline path. Read via
2199    /// [`DcpsRuntime::user_reader_samples_delivered`].
2200    samples_delivered_count: u64,
2201    /// Reader-side requested liveliness lease (0 == INFINITE).
2202    liveliness_lease_nanos: u64,
2203    /// Reader-side requested liveliness kind.
2204    liveliness_kind: zerodds_qos::LivelinessKind,
2205    /// Counter: how often the writer was marked "alive"
2206    /// (Spec §2.2.4.2.14 alive_count).
2207    liveliness_alive_count: u64,
2208    /// Counter: how often it was marked "not_alive" (lease expired).
2209    liveliness_not_alive_count: u64,
2210    /// Current "alive/not-alive" state from the reader's view.
2211    liveliness_alive: bool,
2212    /// QR-cluster (e): set of writer GUIDs the reader currently considers alive
2213    /// via an AUTOMATIC-liveliness same-runtime match. Used to bump
2214    /// `liveliness_alive_count` exactly once per writer-alive transition on the
2215    /// intra-runtime path (the wire DATA path tracks this via reader proxies).
2216    liveliness_alive_writers: alloc::collections::BTreeSet<[u8; 16]>,
2217    /// Ownership.
2218    ownership: zerodds_qos::OwnershipKind,
2219    /// Partition.
2220    partition: Vec<String>,
2221    /// Per-writer strength cache for exclusive-ownership resolution
2222    /// (DDS 1.4 §2.2.3.23). Filled by `wire_reader_to_remote_writer`
2223    /// from each `PublicationBuiltinTopicData.ownership_strength`;
2224    /// `delivered_to_user_sample` looks it up here to pack the
2225    /// strength into `UserSample::Alive`.
2226    writer_strengths: alloc::collections::BTreeMap<[u8; 16], i32>,
2227    /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the reader type
2228    /// (from `T::TYPE_IDENTIFIER` in `UserReaderConfig`). Default
2229    /// `TypeIdentifier::None` signals "no TypeIdentifier" —
2230    /// the match falls back to a pure `type_name` comparison
2231    /// (DDS 1.4 §2.2.3 default path).
2232    type_identifier: zerodds_types::TypeIdentifier,
2233    /// XTypes 1.3 §7.6.3.7 — TCE policy controlling the strictness
2234    /// of the XTypes match path.
2235    type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2236    /// A2 — TIME_BASED_FILTER `minimum_separation` (DDS 1.4 §2.2.3.12), in
2237    /// nanoseconds, for the runtime/C-FFI delivery path. `0` (default) = off.
2238    /// Set via [`DcpsRuntime::set_user_reader_time_based_filter`] (the
2239    /// `rmw_zerodds` / C-FFI path; the typed entity reader enforces TBF on its
2240    /// own QoS). See [`UserReaderSlot::tbf_should_deliver`].
2241    tbf_min_separation_nanos: u128,
2242    /// Per-instance last-delivered timestamp (nanoseconds since runtime start),
2243    /// keyed by the sample KeyHash (keyless types share the all-zero key). Only
2244    /// populated when `tbf_min_separation_nanos > 0`.
2245    tbf_last_delivered: alloc::collections::BTreeMap<[u8; 16], u128>,
2246}
2247
2248impl UserReaderSlot {
2249    /// A2 — TIME_BASED_FILTER gate (DDS 1.4 §2.2.3.12) for the runtime delivery
2250    /// path: returns `true` if a sample of the given instance may be delivered,
2251    /// i.e. at least `minimum_separation` has elapsed since the last delivered
2252    /// sample of that instance. The first sample of an instance always passes.
2253    /// `key_hash = None` (keyless type) collapses to a single instance. A
2254    /// `minimum_separation` of 0 disables the filter (always `true`).
2255    fn tbf_should_deliver(&mut self, key_hash: Option<[u8; 16]>, now_nanos: u128) -> bool {
2256        if self.tbf_min_separation_nanos == 0 {
2257            return true;
2258        }
2259        let inst = key_hash.unwrap_or([0u8; 16]);
2260        match self.tbf_last_delivered.get(&inst) {
2261            Some(&last) if now_nanos.saturating_sub(last) < self.tbf_min_separation_nanos => false,
2262            _ => {
2263                self.tbf_last_delivered.insert(inst, now_nanos);
2264                true
2265            }
2266        }
2267    }
2268}
2269
2270/// Helper struct for announcing a local publication/subscription
2271/// as SEDP BuiltinTopicData. The caller creates it once per
2272/// writer/reader registration and passes it to SedpStack.
2273/// QoS config for registering a user writer with the runtime.
2274/// Bundles all policies that go on the wire via SEDP plus the local
2275/// Per-endpoint discovery info for ROS 2 endpoint-info-by-topic introspection
2276/// (`rmw_get_publishers_info_by_topic` / `rmw_get_subscriptions_info_by_topic`,
2277/// the data behind `ros2 topic info -v`). Covers local user endpoints plus
2278/// remote SEDP-discovered ones. QoS is best-effort from what discovery carries
2279/// (history/depth are not on the wire, so the consumer fills rmw defaults).
2280#[derive(Debug, Clone)]
2281pub struct DiscoveredEndpointInfo {
2282    /// DDS topic name (raw, un-demangled).
2283    pub topic_name: String,
2284    /// IDL type name (raw).
2285    pub type_name: String,
2286    /// 16-byte endpoint GUID: 12-byte participant prefix + 4-byte entity id.
2287    /// Bytes 0..12 identify the owning participant (for node-name lookup).
2288    pub endpoint_guid: [u8; 16],
2289    /// RELIABLE (`true`) vs BEST_EFFORT (`false`).
2290    pub reliable: bool,
2291    /// TRANSIENT_LOCAL or stronger (`true`) vs VOLATILE (`false`).
2292    pub transient_local: bool,
2293    /// Deadline period in whole seconds (0 == INFINITE).
2294    pub deadline_seconds: i32,
2295    /// Lifespan in whole seconds (0 == INFINITE; always 0 for subscriptions).
2296    pub lifespan_seconds: i32,
2297    /// Liveliness lease in whole seconds (0 == INFINITE).
2298    pub liveliness_lease_seconds: i32,
2299}
2300
2301/// Packs an RTPS [`Guid`] into the 16-byte wire form (prefix ++ entity id).
2302fn guid_to_16(g: Guid) -> [u8; 16] {
2303    let mut b = [0u8; 16];
2304    b[..12].copy_from_slice(&g.prefix.to_bytes());
2305    b[12..].copy_from_slice(&g.entity_id.to_bytes());
2306    b
2307}
2308
2309/// monitoring. Avoids 10+-argument functions.
2310#[derive(Debug, Clone)]
2311pub struct UserWriterConfig {
2312    /// Topic name (DDS topic).
2313    pub topic_name: String,
2314    /// IDL type name.
2315    pub type_name: String,
2316    /// `true` = RELIABLE, `false` = BEST_EFFORT.
2317    pub reliable: bool,
2318    /// Durability.
2319    pub durability: zerodds_qos::DurabilityKind,
2320    /// Deadline period (offered).
2321    pub deadline: zerodds_qos::DeadlineQosPolicy,
2322    /// Lifespan duration (writer-only).
2323    pub lifespan: zerodds_qos::LifespanQosPolicy,
2324    /// Liveliness (offered).
2325    pub liveliness: zerodds_qos::LivelinessQosPolicy,
2326    /// Ownership mode (Shared / Exclusive).
2327    pub ownership: zerodds_qos::OwnershipKind,
2328    /// Strength for Exclusive (ignored for Shared).
2329    pub ownership_strength: i32,
2330    /// Partition list. Empty == default partition (`""`).
2331    pub partition: Vec<String>,
2332    /// UserData QoS (Spec §2.2.3.1) — opaque `sequence<octet>`, propagated
2333    /// via discovery.
2334    pub user_data: Vec<u8>,
2335    /// TopicData QoS (Spec §2.2.3.3).
2336    pub topic_data: Vec<u8>,
2337    /// GroupData QoS (Spec §2.2.3.2).
2338    pub group_data: Vec<u8>,
2339    /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up). Default
2340    /// `TypeIdentifier::None` for the `T::TYPE_IDENTIFIER` default.
2341    pub type_identifier: zerodds_types::TypeIdentifier,
2342
2343    /// D.5g — per-writer override of the DataRepresentation offer list.
2344    /// `None` = use `RuntimeConfig::data_representation_offer`.
2345    /// `Some(vec)` = overridden per writer (e.g. `[XCDR2]` for
2346    /// a modern-only pub).
2347    pub data_representation_offer: Option<Vec<i16>>,
2348}
2349
2350/// QoS config for registering a user reader.
2351#[derive(Debug, Clone)]
2352pub struct UserReaderConfig {
2353    /// Topic name.
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 (requested).
2360    pub durability: zerodds_qos::DurabilityKind,
2361    /// Deadline (requested).
2362    pub deadline: zerodds_qos::DeadlineQosPolicy,
2363    /// Liveliness (requested).
2364    pub liveliness: zerodds_qos::LivelinessQosPolicy,
2365    /// Ownership.
2366    pub ownership: zerodds_qos::OwnershipKind,
2367    /// Partition.
2368    pub partition: Vec<String>,
2369    /// UserData QoS (Spec §2.2.3.1).
2370    pub user_data: Vec<u8>,
2371    /// TopicData QoS (Spec §2.2.3.3).
2372    pub topic_data: Vec<u8>,
2373    /// GroupData QoS (Spec §2.2.3.2).
2374    pub group_data: Vec<u8>,
2375    /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up).
2376    pub type_identifier: zerodds_types::TypeIdentifier,
2377    /// TypeConsistencyEnforcement (XTypes §7.6.3.7) — controls how strictly
2378    /// the reader match checks XTypes compatibility.
2379    pub type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2380
2381    /// D.5g — per-reader override of the DataRepresentation accept list.
2382    /// `None` = use `RuntimeConfig::data_representation_offer`.
2383    /// `Some(vec)` = overridden per reader (e.g. `[XCDR1]` for
2384    /// a reader that accepts only legacy XCDR1 wire).
2385    pub data_representation_offer: Option<Vec<i16>>,
2386}
2387
2388fn build_publication_data(
2389    owner_prefix: GuidPrefix,
2390    writer_eid: EntityId,
2391    cfg: &UserWriterConfig,
2392    runtime_offer: &[i16],
2393    user_locator: Locator,
2394) -> zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2395    use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2396    zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2397        key: Guid::new(owner_prefix, writer_eid),
2398        participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2399        topic_name: cfg.topic_name.clone(),
2400        type_name: cfg.type_name.clone(),
2401        durability: cfg.durability,
2402        reliability: ReliabilityQosPolicy {
2403            kind: if cfg.reliable {
2404                ReliabilityKind::Reliable
2405            } else {
2406                ReliabilityKind::BestEffort
2407            },
2408            max_blocking_time: QosDuration::from_millis(100_i32),
2409        },
2410        ownership: cfg.ownership,
2411        ownership_strength: cfg.ownership_strength,
2412        liveliness: cfg.liveliness,
2413        deadline: cfg.deadline,
2414        lifespan: cfg.lifespan,
2415        partition: cfg.partition.clone(),
2416        user_data: cfg.user_data.clone(),
2417        topic_data: cfg.topic_data.clone(),
2418        group_data: cfg.group_data.clone(),
2419        type_information: None,
2420        // D.5g — PID_DATA_REPRESENTATION (XTypes 1.3 §7.6.3.1.1, RTPS 2.5
2421        // PID 0x0073). Per-Writer-Override (cfg.data_representation_offer)
2422        // overrides the RuntimeConfig default.
2423        data_representation: cfg
2424            .data_representation_offer
2425            .clone()
2426            .unwrap_or_else(|| runtime_offer.to_vec()),
2427        // Security: the PolicyEngine fills this later. Default
2428        // None = legacy behavior (no EndpointSecurityInfo PID).
2429        security_info: None,
2430        // .B — RPC discovery PIDs. Default None: no RPC endpoint;
2431        // the RpcEndpoint builder fills these fields.
2432        service_instance_name: None,
2433        related_entity_guid: None,
2434        topic_aliases: None,
2435        // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2436        type_identifier: cfg.type_identifier.clone(),
2437        // DDSI-RTPS 2.5 §8.5.3.3: endpoint locator. All user endpoints
2438        // share the one `user_unicast` socket — hence the
2439        // endpoint locator equals the resolved participant locator.
2440        unicast_locators: alloc::vec![user_locator],
2441        multicast_locators: Vec::new(),
2442    }
2443}
2444
2445/// The `DataRepresentation` set a **DataReader** announces (PID_DATA_REPRESENTATION
2446/// in its SEDP subscription). Per OMG XTypes 1.3 §7.6.2, a reader with the
2447/// default (empty) policy accepts **both** XCDR1 and XCDR2 — and ZeroDDS decodes
2448/// both (the read path dispatches on the per-sample encapsulation id). So the
2449/// reader advertises every representation it can decode, not just the writer's
2450/// preferred one.
2451///
2452/// This matters cross-vendor: CycloneDDS (and legacy RTI / OpenDDS < 3.16)
2453/// default their *writers* to **XCDR1** for `@final` types (non-XTypes backward
2454/// compat). A reader that only announces XCDR2 makes those writers fail the
2455/// `DataRepresentation` RxO check — the writer never forms a connection, and the
2456/// samples are dropped before any are sent. We therefore start from the
2457/// configured offer (which fixes the *preferred* order) and ensure both XCDR2
2458/// and XCDR1 are present. A WRITER keeps the narrow offer (`build_publication_data`),
2459/// because the generated encoder emits one representation.
2460fn reader_accept_repr(configured_offer: &[i16]) -> Vec<i16> {
2461    use zerodds_rtps::publication_data::data_representation as dr;
2462    let mut out: Vec<i16> = configured_offer.to_vec();
2463    for id in [dr::XCDR2, dr::XCDR] {
2464        if !out.contains(&id) {
2465            out.push(id);
2466        }
2467    }
2468    out
2469}
2470
2471fn build_subscription_data(
2472    owner_prefix: GuidPrefix,
2473    reader_eid: EntityId,
2474    cfg: &UserReaderConfig,
2475    runtime_offer: &[i16],
2476    user_locator: Locator,
2477) -> zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2478    use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2479    zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2480        key: Guid::new(owner_prefix, reader_eid),
2481        participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2482        topic_name: cfg.topic_name.clone(),
2483        type_name: cfg.type_name.clone(),
2484        durability: cfg.durability,
2485        reliability: ReliabilityQosPolicy {
2486            kind: if cfg.reliable {
2487                ReliabilityKind::Reliable
2488            } else {
2489                ReliabilityKind::BestEffort
2490            },
2491            max_blocking_time: QosDuration::from_millis(100_i32),
2492        },
2493        ownership: cfg.ownership,
2494        liveliness: cfg.liveliness,
2495        deadline: cfg.deadline,
2496        partition: cfg.partition.clone(),
2497        user_data: cfg.user_data.clone(),
2498        topic_data: cfg.topic_data.clone(),
2499        group_data: cfg.group_data.clone(),
2500        type_information: None,
2501        // D.5g — PID_DATA_REPRESENTATION (see build_publication_data).
2502        // A per-reader override overrides the RuntimeConfig default.
2503        data_representation: cfg
2504            .data_representation_offer
2505            .clone()
2506            .unwrap_or_else(|| runtime_offer.to_vec()),
2507        content_filter: None,
2508        security_info: None,
2509        service_instance_name: None,
2510        related_entity_guid: None,
2511        topic_aliases: None,
2512        // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2513        type_identifier: cfg.type_identifier.clone(),
2514        // DDSI-RTPS 2.5 §8.5.3.2: endpoint locator (see
2515        // build_publication_data).
2516        unicast_locators: alloc::vec![user_locator],
2517        multicast_locators: Vec::new(),
2518    }
2519}
2520
2521/// The runtime of a `DomainParticipant`. Hosts all background
2522/// threads and UDP sockets.
2523pub struct DcpsRuntime {
2524    /// Participant GUID prefix (12-byte identifier, random per instance).
2525    pub guid_prefix: GuidPrefix,
2526    /// Domain id.
2527    pub domain_id: i32,
2528    /// SPDP multicast receiver socket.
2529    pub spdp_multicast_rx: Arc<UdpTransport>,
2530    /// SPDP unicast socket (for bidirectional SPDP, B2).
2531    pub spdp_unicast: Arc<UdpTransport>,
2532    /// User-data unicast transport (default user unicast, where peers
2533    /// send matched samples). Trait object: can be UDP/v4 or /v6,
2534    /// and in phase C additionally TCP or SHM (env var
2535    /// `ZERODDS_USER_TRANSPORT`). Discovery (SPDP/SEDP) stays UDP-only.
2536    pub user_unicast: Arc<dyn Transport + Send + Sync>,
2537    /// Resolved user-unicast locator (routable interface address,
2538    /// not `0.0.0.0`). Written as `PID_UNICAST_LOCATOR` into EVERY
2539    /// SEDP pub/sub announce (DDSI-RTPS 2.5 §8.5.3.2/3)
2540    /// and as the participant `DEFAULT_UNICAST_LOCATOR` in SPDP. Precomputed
2541    /// via `announce_locator`, so the endpoint and participant locators
2542    /// are guaranteed identical.
2543    pub user_announce_locator: Locator,
2544    /// Sender socket for the SPDP multicast announce (separate UdpSocket
2545    /// without SO_REUSE/SO_BIND_IP_MULTICAST, so send_to routes cleanly).
2546    spdp_mc_tx: Arc<UdpTransport>,
2547    /// SPDP beacon (sends periodic announces).
2548    spdp_beacon: Mutex<SpdpBeacon>,
2549    /// Own participant data (SPDP self-view). Handed by the in-process
2550    /// discovery fastpath as a `DiscoveredParticipant` to same-process
2551    /// peers (see [`crate::inproc`]).
2552    participant_data: ParticipantBuiltinTopicData,
2553    /// Stash of all locally announced publications/subscriptions —
2554    /// so a peer runtime starting later in the same process
2555    /// can pull our endpoints via `inproc_snapshot`
2556    /// (pull-on-creation of the in-process discovery fastpath).
2557    /// Append-only; a future patch for endpoint deletion would
2558    /// remove by GUID here.
2559    announced_pubs: Mutex<Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>>,
2560    announced_subs: Mutex<Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>>,
2561    /// SPDP reader (parses incoming beacons).
2562    spdp_reader: SpdpReader,
2563    /// Discovered remote participants (prefix → data).
2564    discovered: Arc<Mutex<DiscoveredParticipantsCache>>,
2565    /// A1 discovery-server relay: cache of the last raw SPDP datagram per
2566    /// discovered participant prefix. Only populated in `discovery_server` mode;
2567    /// used to forward a newly-joined client the SPDP of every already-known
2568    /// client (and vice versa). Empty otherwise.
2569    spdp_relay_cache: Mutex<alloc::collections::BTreeMap<GuidPrefix, Vec<u8>>>,
2570    /// SEDP stack for publication/subscription announce + discovery.
2571    pub sedp: Arc<Mutex<SedpStack>>,
2572    /// TypeLookup-Service Builtin-Endpoint-GUIDs (XTypes 1.3 §7.6.3.3.4).
2573    pub type_lookup_endpoints: TypeLookupEndpoints,
2574    /// TypeLookup server (server-side handler over the local
2575    /// TypeRegistry).
2576    pub type_lookup_server: Arc<Mutex<TypeLookupServer>>,
2577    /// TypeLookup client (client-side correlation table for outstanding
2578    /// requests).
2579    pub type_lookup_client: Arc<Mutex<TypeLookupClient>>,
2580    /// Monotonically increasing sequence number of the TL_SVC_REPLY_WRITER. Reply DATA
2581    /// carry their OWN writer_sn (instead of echoing the request SN) — the
2582    /// correlation runs via PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2),
2583    /// so a reliable cross-vendor reply reader sees no SN jumps.
2584    tl_reply_sn: core::sync::atomic::AtomicU64,
2585    /// Security builtin endpoint stack
2586    /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
2587    /// MessageSecure`). `None` as long as no security plugin is active
2588    /// — the hot path then skips any security-builtin
2589    /// demux. `Some` is set via [`DcpsRuntime::enable_security_builtins`]
2590    /// as soon as the factory has registered a plugin.
2591    pub security_builtin: Mutex<Option<Arc<Mutex<SecurityBuiltinStack>>>>,
2592    /// Monotonic "start time" — for SEDP tick clocks.
2593    start_instant: Instant,
2594    /// Local user-writer registry (EntityId → writer state).
2595    user_writers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserWriterSlot>>>>>,
2596    /// ADR-0006 side map: per user writer an optional ShmLocator bytes
2597    /// value (PID_SHM_LOCATOR in the SEDP sample). `None` = no
2598    /// same-host backend attached. The wire encoder consults
2599    /// this map on the SEDP push.
2600    shm_locators: Arc<RwLock<BTreeMap<EntityId, Vec<u8>>>>,
2601    /// Wave 4 (Spec `zerodds-zero-copy-1.0` §6): tracker for
2602    /// same-host (writer, reader) pairs. The SEDP match hook registers
2603    /// here every pair whose remote prefix carries the same host-id prefix
2604    /// as the local participant. The hot-path send consults
2605    /// the tracker and routes over SHM instead of UDP in the `Bound` state.
2606    pub same_host: Arc<crate::same_host::SameHostTracker>,
2607    /// Local user-reader registry (EntityId → reader state).
2608    user_readers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserReaderSlot>>>>>,
2609    /// Cross-vendor step 6b: peers to whom we have already sent per-endpoint
2610    /// crypto tokens (datawriter/datareader). Prevents spam on the
2611    /// repeated receipt of cyclone's tokens; sending happens only once the
2612    /// user endpoints exist (the bench creates them after handshake start).
2613    #[cfg(feature = "security")]
2614    /// Already-sent per-endpoint crypto tokens, per dedup key
2615    /// (source_endpoint ++ destination_endpoint, see `endpoint_token_key`).
2616    /// Per-token instead of per-peer, so late-matched user endpoints still
2617    /// get their tokens (#29).
2618    endpoint_tokens_sent: Arc<RwLock<alloc::collections::BTreeSet<[u8; 32]>>>,
2619    /// Peers (prefix) to whom our SEDP endpoint records have already been
2620    /// re-announced after a completed crypto-token exchange. Under rtps_/discovery_
2621    /// protection the initial SEDP burst is discarded by the peer (no key), until
2622    /// the participant crypto token arrives via Volatile; a one-time
2623    /// re-announce from that moment (the peer can now decode) brings the
2624    /// dropped SEDP up (OpenDDS flow; cyclone/FastDDS converge anyway).
2625    #[cfg(feature = "security")]
2626    sedp_reannounced: Arc<RwLock<alloc::collections::BTreeSet<[u8; 12]>>>,
2627    /// Per-endpoint crypto (DDS-Security §9.5.3.3): per local writer/reader
2628    /// EntityId its own crypto slot handle (its own key material, not the
2629    /// participant key). Used for the per-endpoint token (prepare_endpoint_
2630    /// crypto_tokens) AND the per-endpoint encode (protect_user_datagram)
2631    /// — the same key on both sides. Get-or-register lazily via
2632    /// `local_endpoint_crypto_handle`.
2633    #[cfg(feature = "security")]
2634    endpoint_crypto:
2635        Arc<RwLock<alloc::collections::BTreeMap<EntityId, zerodds_security::crypto::CryptoHandle>>>,
2636    /// Same-runtime writer→reader routes: per local writer the list
2637    /// of local readers subscribed to the same topic+type.
2638    /// Rebuilt in `recompute_intra_runtime_routes` on every
2639    /// register/unregister. Looked up in the write hot path,
2640    /// to push samples directly into the reader slot's `sample_tx`
2641    /// (intra-process loopback without an RTPS roundtrip, in parallel to the
2642    /// inproc peer path that only serves cross-runtime peers).
2643    intra_runtime_routes: Arc<RwLock<BTreeMap<EntityId, Vec<EntityId>>>>,
2644    /// Entity key counter (3 byte, incrementing). User writers use
2645    /// `0xC2` (with-key, user), user readers `0xC7`.
2646    entity_counter: AtomicU32,
2647    /// Configuration (cloned from RuntimeConfig).
2648    pub config: RuntimeConfig,
2649    /// Per-interface outbound socket pool. `None`
2650    /// when `config.interface_bindings` is empty — then
2651    /// `user_unicast` stays the only outbound socket (v1.4 path).
2652    #[cfg(feature = "security")]
2653    outbound_pool: Option<Arc<OutboundSocketPool>>,
2654    /// Writer-Liveliness-Protocol endpoint (RTPS 2.5 §8.4.13).
2655    /// Sends periodic `ParticipantMessageData` heartbeats and
2656    /// tracks last-seen per remote participant.
2657    pub wlp: Arc<Mutex<crate::wlp::WlpEndpoint>>,
2658    /// Builtin-topic reader sinks (DDS 1.4 §2.2.5). Set by the
2659    /// `DomainParticipant` constructor via `attach_builtin_sinks`;
2660    /// before that this is `None` and the discovery hot path
2661    /// drops samples silently (e.g. when the runtime is
2662    /// started directly for internal tests, without a participant).
2663    builtin_sinks: Mutex<Option<crate::builtin_subscriber::BuiltinSinks>>,
2664    /// Ignore filter (DDS 1.4 §2.2.2.2.1.14-17). Set by the
2665    /// `DomainParticipant` constructor via `attach_ignore_filter`.
2666    /// `None` means: no participant hook → no
2667    /// filtering.
2668    ignore_filter: Mutex<Option<crate::participant::IgnoreFilter>>,
2669    /// Stop flag for all worker threads (recv loops + tick loop).
2670    stop: Arc<AtomicBool>,
2671    /// Monotonic count of completed tick iterations. Incremented once per
2672    /// [`run_tick_iteration`], regardless of whether the tick is driven by the
2673    /// internal `zdds-tick` thread or an external executor (zerodds-async-1.0
2674    /// §4 `spawn_in_tokio`). Diagnostic: a stalled count means the tick loop
2675    /// stopped advancing. Read via [`DcpsRuntime::tick_count`].
2676    tick_seq: AtomicU64,
2677    /// Total SPDP announces emitted (multicast + unicast fan-out count as one).
2678    /// Diagnostic for the C3 initial-announcement burst — a fresh, peer-less
2679    /// participant should advance this fast initially. Read via
2680    /// [`DcpsRuntime::spdp_announce_count`].
2681    spdp_announce_seq: AtomicU64,
2682    /// Inconsistent-topic counter (DDS 1.4 §2.2.4.2.4). Incremented when
2683    /// matching discovers a remote endpoint carrying the same `topic_name`
2684    /// but a differing `type_name` in the SEDP cache. Read via
2685    /// [`DcpsRuntime::inconsistent_topic_count`].
2686    inconsistent_topic_seq: AtomicU64,
2687    /// D.5e Phase 3 — wake handle for the event-driven scheduler tick. `Some`
2688    /// only when started with `scheduler_tick`. Recv loops + the write path call
2689    /// [`DcpsRuntime::raise_tick_wake`] to wake the worker immediately on new
2690    /// work (so HEARTBEAT/ACKNACK/HB processing does not wait for a deadline).
2691    tick_wake: Mutex<Option<crate::scheduler::SchedulerHandle<TickEvent>>>,
2692    /// Coalesces wake raises: many incoming datagrams collapse into one wake.
2693    tick_wake_pending: AtomicBool,
2694    /// Worker thread JoinHandles. Per-socket recv threads + tick thread,
2695    /// all terminated together via `stop` (Sprint D.5b — previously
2696    /// a single single-threaded `event_loop`).
2697    handles: Mutex<Vec<JoinHandle<()>>>,
2698    /// Match-event notifier (D.5e Phase-1 quick win). Notified by the
2699    /// SEDP match path after `add_reader_proxy` / `add_writer_proxy`;
2700    /// `wait_for_matched_*` parks on it instead of polling every 20 ms.
2701    /// The mutex content is only a lock anchor for the Condvar API; there is
2702    /// no state protected by it (the count is read independently
2703    /// via `user_*_matched_count`).
2704    match_event: Arc<(Mutex<()>, Condvar)>,
2705    /// Acknowledgments event notifier. Notified when a writer
2706    /// receives an ACKNACK that advances its acked-base.
2707    /// `wait_for_acknowledgments` parks on it instead of polling every 50 ms.
2708    ack_event: Arc<(Mutex<()>, Condvar)>,
2709}
2710
2711impl core::fmt::Debug for DcpsRuntime {
2712    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2713        f.debug_struct("DcpsRuntime")
2714            .field("domain_id", &self.domain_id)
2715            .field("guid_prefix", &self.guid_prefix)
2716            .field("spdp_group", &self.config.spdp_multicast_group)
2717            .finish_non_exhaustive()
2718    }
2719}
2720
2721/// Type alias: Arc-shared slot handles from the per-slot mutex
2722/// architecture.
2723type WriterSlotArc = Arc<Mutex<UserWriterSlot>>;
2724type ReaderSlotArc = Arc<Mutex<UserReaderSlot>>;
2725
2726impl DcpsRuntime {
2727    /// The 16-byte RTPS GUID of a local writer with EntityId `eid` (this
2728    /// participant's GUID prefix ++ the entity id). Used by the cross-vendor
2729    /// iceoryx-cyclone bridge to stamp the PSMX chunk with the writer's real
2730    /// GUID so a peer that discovered the writer over RTPS SEDP associates the
2731    /// shared-memory sample with it.
2732    #[must_use]
2733    pub fn writer_guid(&self, eid: EntityId) -> [u8; 16] {
2734        Guid::new(self.guid_prefix, eid).to_bytes()
2735    }
2736
2737    // ========================================================================
2738    // --- Per-Slot-Mutex-Helpers
2739    //
2740    // The `user_writers`/`user_readers` registry is `RwLock<BTreeMap<EntityId,
2741    // Arc<Mutex<Slot>>>>`. Hot-path accesses take the read lock briefly, clone
2742    // the slot Arc and release the read lock before taking the per-slot mutex.
2743    // Parallel writes to **different** slots thereby run
2744    // without global contention.
2745    //
2746    // Slot creation/deletion takes the write lock; that is rare and
2747    // amortizes out.
2748    // ========================================================================
2749
2750    /// Returns the slot Arc for a user writer, if present.
2751    /// Hot-path form: a single read lock + Arc clone, no
2752    /// per-slot mutex. The caller takes the mutex itself.
2753    fn writer_slot(&self, eid: EntityId) -> Option<WriterSlotArc> {
2754        self.user_writers
2755            .read()
2756            .ok()
2757            .and_then(|w| w.get(&eid).cloned())
2758    }
2759
2760    /// Returns the slot Arc for a user reader, if present.
2761    fn reader_slot(&self, eid: EntityId) -> Option<ReaderSlotArc> {
2762        self.user_readers
2763            .read()
2764            .ok()
2765            .and_then(|r| r.get(&eid).cloned())
2766    }
2767
2768    /// Snapshot of all writer slots as `Vec<(EntityId, Arc)>`. Allows
2769    /// iteration without holding the registry read lock — e.g. for
2770    /// the heartbeat tick or liveliness sweep, where we potentially take every
2771    /// slot's mutex.
2772    fn writer_slots_snapshot(&self) -> Vec<(EntityId, WriterSlotArc)> {
2773        match self.user_writers.read() {
2774            Ok(w) => w.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2775            Err(_) => Vec::new(),
2776        }
2777    }
2778
2779    /// Snapshot of all reader slots — symmetric to writer_slots_snapshot.
2780    fn reader_slots_snapshot(&self) -> Vec<(EntityId, ReaderSlotArc)> {
2781        match self.user_readers.read() {
2782            Ok(r) => r.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2783            Err(_) => Vec::new(),
2784        }
2785    }
2786
2787    /// Returns the list of EntityIds of all registered writers.
2788    /// Very lightweight — no slot-Arc clone, just EntityIds.
2789    fn writer_eids(&self) -> Vec<EntityId> {
2790        match self.user_writers.read() {
2791            Ok(w) => w.keys().copied().collect(),
2792            Err(_) => Vec::new(),
2793        }
2794    }
2795
2796    /// Returns the list of EntityIds of all registered readers.
2797    fn reader_eids(&self) -> Vec<EntityId> {
2798        match self.user_readers.read() {
2799            Ok(r) => r.keys().copied().collect(),
2800            Err(_) => Vec::new(),
2801        }
2802    }
2803
2804    /// Starts a new runtime for a participant.
2805    ///
2806    /// # Errors
2807    /// `TransportError` if one of the 3 UDP sockets fails to bind
2808    /// (e.g. a port collision on the SPDP multicast port in another
2809    /// SO_REUSE-less DDS instance).
2810    pub fn start(
2811        domain_id: i32,
2812        guid_prefix: GuidPrefix,
2813        mut config: RuntimeConfig,
2814    ) -> Result<Arc<Self>> {
2815        // C1 multicast-free discovery: merge the domain-aware env `ZERODDS_PEERS`
2816        // into the (programmatic) `config.initial_peers`. Default
2817        // is both empty → pure multicast behavior.
2818        config
2819            .initial_peers
2820            .extend(parse_initial_peers_env(domain_id as u32));
2821        // SPDP multicast receiver on the spec port.
2822        // u32 → u16 enforcing, the spec port is always < 65536.
2823        let spdp_port = u16::try_from(spdp_multicast_port(domain_id as u32)).map_err(|_| {
2824            DdsError::BadParameter {
2825                what: "domain_id too large for SPDP port mapping",
2826            }
2827        })?;
2828        let spdp_mc = UdpTransport::bind_multicast_v4(
2829            config.spdp_multicast_group,
2830            spdp_port,
2831            config.multicast_interface,
2832        )
2833        .map_err(|_| DdsError::TransportError {
2834            label: "spdp multicast bind",
2835        })?
2836        // Sprint D.5b: recv sockets have their own thread that
2837        // blocks waiting for data. Timeout 1 s = stop-flag polling
2838        // granularity at shutdown, NOT the tick rhythm.
2839        .with_timeout(Some(Duration::from_secs(1)))
2840        .map_err(|_| DdsError::TransportError {
2841            label: "spdp multicast set_timeout",
2842        })?;
2843
2844        // SPDP unicast: bind to the **well-known** RTPS port
2845        // (7400+250*domain+10+2*pid, Spec §9.6.1.4.1), so a
2846        // configured unicast initial peer can reach this participant
2847        // WITHOUT prior multicast (C1 multicast-free
2848        // discovery). Participant index 0,1,2,… until a free port
2849        // is found (multiple participants per host, also alongside
2850        // Cyclone/FastDDS). Fallback ephemeral if all well-known
2851        // ports are taken (then multicast discovery only).
2852        // Interface pinning (ZERODDS_INTERFACE): UNSPECIFIED = auto. If
2853        // set, ALL IP sockets bind (SPDP-uc, SPDP-mc-tx, user UDP/TCP)
2854        // to this IP → announce + egress + receive on exactly this
2855        // interface (multi-homed robustness, cf. Cyclone `NetworkInterface`).
2856        let pinned = config.multicast_interface;
2857        let (spdp_uc_raw, _spdp_participant_id) = {
2858            let mut bound = None;
2859            for pid in 0u32..120 {
2860                let Ok(port) = u16::try_from(spdp_unicast_port(domain_id as u32, pid)) else {
2861                    break;
2862                };
2863                if let Ok(sock) = UdpTransport::bind_v4(pinned, port) {
2864                    bound = Some((sock, pid));
2865                    break;
2866                }
2867            }
2868            match bound {
2869                Some(b) => b,
2870                None => (
2871                    UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2872                        label: "spdp unicast bind",
2873                    })?,
2874                    u32::MAX,
2875                ),
2876            }
2877        };
2878        let spdp_uc = spdp_uc_raw
2879            .with_timeout(Some(Duration::from_secs(1)))
2880            .map_err(|_| DdsError::TransportError {
2881                label: "spdp unicast set_timeout",
2882            })?;
2883
2884        // User-data unicast (ephemeral port). Transport choice primarily via
2885        // `RuntimeConfig::user_transport`, fallback to the env var
2886        // `ZERODDS_USER_TRANSPORT` (bench binaries), otherwise UDPv4.
2887        // SPDP multicast stays UDPv4 — the DDSI-RTPS spec mandates
2888        // 239.255.0.1 for cross-vendor discovery; v6-only hosts
2889        // cannot discover cross-vendor (its own sprint).
2890        let (user_uc, tcp_accept_handle): (Arc<dyn Transport + Send + Sync>, _) =
2891            if !config.user_transports.is_empty() {
2892                // Multi-transport: build each kind and layer them. Preference =
2893                // config order (first match by destination locator kind wins).
2894                let mut legs: alloc::vec::Vec<Arc<dyn Transport + Send + Sync>> =
2895                    alloc::vec::Vec::new();
2896                let mut tcp_handle = None;
2897                for kind in &config.user_transports {
2898                    let (leg, tcp) = select_user_transport(*kind, guid_prefix, domain_id, pinned)?;
2899                    legs.push(leg);
2900                    if tcp.is_some() {
2901                        tcp_handle = tcp;
2902                    }
2903                }
2904                let layered = Arc::new(crate::layered_transport::LayeredUserTransport::new(legs));
2905                (layered, tcp_handle)
2906            } else {
2907                let user_transport_kind = config
2908                    .user_transport
2909                    .or_else(parse_user_transport_env)
2910                    .unwrap_or(UserTransportKind::UdpV4);
2911                select_user_transport(user_transport_kind, guid_prefix, domain_id, pinned)?
2912            };
2913
2914        // Separate sender socket for the SPDP announce. Ephemeral port; with
2915        // interface pinning it binds to the pinned IP (egress source), otherwise
2916        // `0.0.0.0` (the kernel picks the outgoing interface per route).
2917        let spdp_mc_tx =
2918            UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2919                label: "spdp mc-tx bind",
2920            })?;
2921
2922        let stop = Arc::new(AtomicBool::new(false));
2923
2924        // Materialize beacon locators for cross-host interop:
2925        // with a `0.0.0.0` bind address (UNSPECIFIED) the peer would
2926        // otherwise learn a non-routable address. We resolve UNSPECIFIED
2927        // via a UDP connect probe to a non-routable IP
2928        // (no traffic, just the routing table) and announce the
2929        // resulting local interface address — cross-host-capable
2930        // without an external crate dependency.
2931        let user_locator = announce_locator(&*user_uc, config.multicast_interface);
2932        let spdp_uc_locator = announce_locator(&spdp_uc, config.multicast_interface);
2933        let participant_data = ParticipantBuiltinTopicData {
2934            guid: Guid::new(guid_prefix, EntityId::PARTICIPANT),
2935            protocol_version: ProtocolVersion::V2_5,
2936            vendor_id: VendorId::ZERODDS,
2937            default_unicast_locator: Some(user_locator),
2938            default_multicast_locator: None,
2939            metatraffic_unicast_locator: Some(spdp_uc_locator),
2940            metatraffic_multicast_locator: Some(Locator {
2941                kind: LocatorKind::UdpV4,
2942                port: u32::from(spdp_port),
2943                address: {
2944                    let mut a = [0u8; 16];
2945                    a[12..].copy_from_slice(&config.spdp_multicast_group.octets());
2946                    a
2947                },
2948            }),
2949            domain_id: Some(domain_id as u32),
2950            // We announce the endpoints we actually
2951            // implement: SPDP (participant ann/det) + SEDP
2952            // (publications/subscriptions ann+det) + WLP (10/11) +
2953            // TypeLookup service (12/13). Cyclone/Fast-DDS filter
2954            // their proxy setup by these flags — without them
2955            // we get no SEDP/WLP peers. SEDP topic
2956            // endpoints (bits 28/29) are optional per RTPS 2.5 §8.5.4.4
2957            // and covered in ZeroDDS via synthetic DCPSTopic
2958            // derivation from pub/sub — we do not announce them,
2959            // otherwise we promise peers a non-existent
2960            // endpoint pairing. When the caller sets
2961            // `announce_secure_endpoints = true` (security
2962            // factory path), we additionally mix in the 12 secure
2963            // discovery bits (16..27, DDS-Security 1.2 §7.4.7.1).
2964            builtin_endpoint_set: {
2965                let mut mask = endpoint_flag::ALL_STANDARD;
2966                if config.announce_secure_endpoints {
2967                    mask |= endpoint_flag::ALL_SECURE;
2968                }
2969                mask
2970            },
2971            // Spec default lease = 100 s; configurable via
2972            // `RuntimeConfig::participant_lease_duration`.
2973            lease_duration: qos_duration_from_std(config.participant_lease_duration),
2974            // UserData on the participant — filled from
2975            // `DomainParticipantQos::user_data` via RuntimeConfig.
2976            user_data: config.user_data.clone(),
2977            // PROPERTY_LIST: security fills this with security caps
2978            // once a PolicyEngine is configured. Default-empty
2979            // stays backward-compatible with legacy peers.
2980            properties: Default::default(),
2981            // IdentityToken/PermissionsToken are filled by the security
2982            // layer once authentication + access control are
2983            // initialized. Default `None` = legacy announce.
2984            identity_token: None,
2985            permissions_token: None,
2986            identity_status_token: None,
2987            sig_algo_info: None,
2988            kx_algo_info: None,
2989            sym_cipher_algo_info: None,
2990            // Filled by the security layer (enable_security_builtins*) —
2991            // without PID_PARTICIPANT_SECURITY_INFO foreign vendors classify
2992            // us as non-secure. Default None = legacy/plain.
2993            participant_security_info: None,
2994        };
2995        let beacon = SpdpBeacon::new(participant_data.clone());
2996        let sedp = SedpStack::new(guid_prefix, VendorId::ZERODDS);
2997        // In-process discovery fastpath: remember the multicast group before
2998        // `config` is moved into the struct literal.
2999        let inproc_group = config.spdp_multicast_group;
3000
3001        #[cfg(feature = "security")]
3002        let outbound_pool = if config.interface_bindings.is_empty() {
3003            None
3004        } else {
3005            Some(Arc::new(OutboundSocketPool::bind_all(
3006                &config.interface_bindings,
3007            )?))
3008        };
3009
3010        // WLP endpoint (RTPS 2.5 §8.4.13). The tick period is explicit
3011        // `wlp_period`, or `lease/3` when `wlp_period == ZERO`
3012        // (spec recommendation: three misses before the reader marks the
3013        // writer as not-alive).
3014        let wlp_tick_period = if config.wlp_period.is_zero() {
3015            config.participant_lease_duration / 3
3016        } else {
3017            config.wlp_period
3018        };
3019        let wlp = crate::wlp::WlpEndpoint::new(guid_prefix, VendorId::ZERODDS, wlp_tick_period);
3020
3021        let rt = Arc::new(Self {
3022            guid_prefix,
3023            domain_id,
3024            spdp_multicast_rx: Arc::new(spdp_mc),
3025            spdp_unicast: Arc::new(spdp_uc),
3026            user_unicast: user_uc,
3027            user_announce_locator: user_locator,
3028            spdp_mc_tx: Arc::new(spdp_mc_tx),
3029            spdp_beacon: Mutex::new(beacon),
3030            participant_data,
3031            announced_pubs: Mutex::new(Vec::new()),
3032            announced_subs: Mutex::new(Vec::new()),
3033            spdp_reader: SpdpReader::new(),
3034            discovered: Arc::new(Mutex::new(DiscoveredParticipantsCache::new())),
3035            spdp_relay_cache: Mutex::new(alloc::collections::BTreeMap::new()),
3036            sedp: Arc::new(Mutex::new(sedp)),
3037            type_lookup_endpoints: TypeLookupEndpoints::new(guid_prefix),
3038            type_lookup_server: Arc::new(Mutex::new(TypeLookupServer::new())),
3039            type_lookup_client: Arc::new(Mutex::new(TypeLookupClient::new())),
3040            tl_reply_sn: core::sync::atomic::AtomicU64::new(0),
3041            security_builtin: Mutex::new(None),
3042            start_instant: Instant::now(),
3043            user_writers: Arc::new(RwLock::new(BTreeMap::new())),
3044            shm_locators: Arc::new(RwLock::new(BTreeMap::new())),
3045            same_host: Arc::new(crate::same_host::SameHostTracker::new()),
3046            user_readers: Arc::new(RwLock::new(BTreeMap::new())),
3047            #[cfg(feature = "security")]
3048            endpoint_tokens_sent: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
3049            #[cfg(feature = "security")]
3050            sedp_reannounced: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
3051            #[cfg(feature = "security")]
3052            endpoint_crypto: Arc::new(RwLock::new(alloc::collections::BTreeMap::new())),
3053            intra_runtime_routes: Arc::new(RwLock::new(BTreeMap::new())),
3054            entity_counter: AtomicU32::new(1),
3055            config,
3056            stop: stop.clone(),
3057            tick_seq: AtomicU64::new(0),
3058            spdp_announce_seq: AtomicU64::new(0),
3059            inconsistent_topic_seq: AtomicU64::new(0),
3060            tick_wake: Mutex::new(None),
3061            tick_wake_pending: AtomicBool::new(false),
3062            handles: Mutex::new(Vec::new()),
3063            match_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
3064            ack_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
3065            #[cfg(feature = "security")]
3066            outbound_pool,
3067            wlp: Arc::new(Mutex::new(wlp)),
3068            builtin_sinks: Mutex::new(None),
3069            ignore_filter: Mutex::new(None),
3070        });
3071
3072        // In-process discovery fastpath: register the runtime in the process
3073        // registry so same-process+domain peers find each other
3074        // deterministically (see `crate::inproc`). Right
3075        // after, `pull-on-creation`: pull all already-announced endpoints
3076        // of existing peers into our SEDP cache — otherwise
3077        // we see peers that announced endpoints BEFORE us
3078        // only via the (lossy) UDP SEDP path.
3079        crate::inproc::register(&rt, domain_id, inproc_group);
3080        rt.inproc_pull_from_peers();
3081
3082        // Per-socket recv threads + one tick thread (Sprint D.5b).
3083        //
3084        // Previously the entire stack ran in a single event loop
3085        // that went through three blocking `recv()`s with a `tick_period`
3086        // timeout (50 ms) sequentially per iteration. On a
3087        // roundtrip each stage waited up to 50 ms for timeouts of the
3088        // front sockets before its own datagram got its turn —
3089        // yielded 5-14 ms p50.
3090        //
3091        // Refit: every relevant recv path has its own thread
3092        // that sits directly blocking on its socket and dispatches
3093        // immediately when data arrives. The tick thread does the
3094        // periodic outbound work (HEARTBEAT/resend/ACKNACK/
3095        // SPDP announce/deadline/lifespan/liveliness) and sleeps
3096        // `tick_period` between iterations.
3097        //
3098        // Lock order (deadlock avoidance): the tick thread and
3099        // recv threads contend for `rt.sedp.lock()` / `rt.wlp.lock()`.
3100        // Convention: keep lock-hold times short (handle_datagram /
3101        // tick are both fast), do not take a sub-lock under the `sedp`
3102        // or `wlp` lock.
3103        let mut handles_init: Vec<JoinHandle<()>> = Vec::with_capacity(4);
3104
3105        let rt_recv_spdp_mc = Arc::clone(&rt);
3106        let stop_recv_spdp_mc = stop.clone();
3107        handles_init.push(
3108            thread::Builder::new()
3109                .name(String::from("zdds-recv-spdp-mc"))
3110                .spawn(move || recv_spdp_multicast_loop(rt_recv_spdp_mc, stop_recv_spdp_mc))
3111                .map_err(|_| DdsError::PreconditionNotMet {
3112                    reason: "spawn zdds-recv-spdp-mc thread",
3113                })?,
3114        );
3115
3116        let rt_recv_meta = Arc::clone(&rt);
3117        let stop_recv_meta = stop.clone();
3118        handles_init.push(
3119            thread::Builder::new()
3120                .name(String::from("zdds-recv-meta"))
3121                .spawn(move || recv_metatraffic_loop(rt_recv_meta, stop_recv_meta))
3122                .map_err(|_| DdsError::PreconditionNotMet {
3123                    reason: "spawn zdds-recv-meta thread",
3124                })?,
3125        );
3126
3127        let rt_recv_user = Arc::clone(&rt);
3128        let stop_recv_user = stop.clone();
3129        let primary_socket = Arc::clone(&rt.user_unicast);
3130        handles_init.push(
3131            thread::Builder::new()
3132                .name(String::from("zdds-recv-user"))
3133                .spawn(move || recv_user_data_loop(rt_recv_user, primary_socket, stop_recv_user))
3134                .map_err(|_| DdsError::PreconditionNotMet {
3135                    reason: "spawn zdds-recv-user thread",
3136                })?,
3137        );
3138
3139        // TCPv4 variant: a separate accept worker (TcpTransport has
3140        // no implicit accept thread in the constructor — accept_one()
3141        // must be called explicitly).
3142        if let Some(tcp_arc) = tcp_accept_handle {
3143            let stop_accept = stop.clone();
3144            handles_init.push(
3145                thread::Builder::new()
3146                    .name(String::from("zdds-tcp-accept"))
3147                    .spawn(move || {
3148                        while !stop_accept.load(Ordering::Relaxed) {
3149                            // accept_one() blocks until connection +
3150                            // handshake; on EOF it returns Ok(()) and
3151                            // we accept the next peer.
3152                            let _ = tcp_arc.accept_one();
3153                        }
3154                    })
3155                    .map_err(|_| DdsError::PreconditionNotMet {
3156                        reason: "spawn zdds-tcp-accept thread",
3157                    })?,
3158            );
3159        }
3160
3161        // Opt-3 (Spec `zerodds-zero-copy-1.0` §9): additional
3162        // SO_REUSEPORT recv workers. Each binds to the same
3163        // user_unicast port; the kernel distributes incoming datagrams via
3164        // flow hash. On a bind error (e.g. a platform without
3165        // SO_REUSEPORT support) the worker is skipped and the
3166        // runtime continues with the available workers.
3167        if rt.config.extra_recv_threads > 0 {
3168            let user_port = u16::try_from(rt.user_unicast.local_locator().port).unwrap_or(0);
3169            // With an active security config, share the first interface bind address;
3170            // otherwise INADDR_ANY (the kernel chooses).
3171            #[cfg(feature = "security")]
3172            let bind_addr = rt
3173                .config
3174                .interface_bindings
3175                .first()
3176                .map(|spec| spec.bind_addr)
3177                .unwrap_or(Ipv4Addr::UNSPECIFIED);
3178            #[cfg(not(feature = "security"))]
3179            let bind_addr = Ipv4Addr::UNSPECIFIED;
3180            for i in 0..rt.config.extra_recv_threads {
3181                let extra_socket =
3182                    match UdpTransport::bind_v4_reuse(bind_addr, user_port) {
3183                        Ok(t) => Arc::new(t.with_timeout(Some(Duration::from_secs(1))).map_err(
3184                            |_| DdsError::TransportError {
3185                                label: "extra-recv set_timeout failed",
3186                            },
3187                        )?),
3188                        Err(_) => break, // SO_REUSEPORT not available — skip.
3189                    };
3190                let rt_extra = Arc::clone(&rt);
3191                let stop_extra = stop.clone();
3192                let name = format!("zdds-recv-user-{}", i + 1);
3193                handles_init.push(
3194                    thread::Builder::new()
3195                        .name(name)
3196                        .spawn(move || recv_user_data_loop(rt_extra, extra_socket, stop_extra))
3197                        .map_err(|_| DdsError::PreconditionNotMet {
3198                            reason: "spawn zdds-recv-user-N thread",
3199                        })?,
3200                );
3201            }
3202        }
3203
3204        // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6): per-owner
3205        // SHM recv loop. Polls all bound consumer entries of the
3206        // SameHostTracker round-robin and dispatches incoming
3207        // frames analogous to the UDP path. Only compiled when
3208        // the `same-host-shm` feature is on.
3209        #[cfg(feature = "same-host-shm")]
3210        {
3211            let rt_recv_shm = Arc::clone(&rt);
3212            let stop_recv_shm = stop.clone();
3213            handles_init.push(
3214                thread::Builder::new()
3215                    .name(String::from("zdds-recv-shm"))
3216                    .spawn(move || recv_user_shm_loop(rt_recv_shm, stop_recv_shm))
3217                    .map_err(|_| DdsError::PreconditionNotMet {
3218                        reason: "spawn zdds-recv-shm thread",
3219                    })?,
3220            );
3221        }
3222
3223        // zerodds-async-1.0 §4: with `external_tick`, the tick loop is driven
3224        // by an external executor (tokio via `spawn_in_tokio`) rather than a
3225        // dedicated thread — so we skip the spawn here. `stop` is dropped; the
3226        // driver observes shutdown via `rt.stop` (set in `Drop`/`stop()`).
3227        if rt.config.external_tick {
3228            drop(stop);
3229        } else if rt.config.scheduler_tick {
3230            // D.5e Phase 3 — event-driven scheduler tick. Create the scheduler
3231            // up front, publish its wake handle so recv loops + the write path
3232            // can `raise_tick_wake`, then drive the (unchanged) tick from the
3233            // deadline-heap worker.
3234            let (scheduler, handle) =
3235                crate::scheduler::Scheduler::<TickEvent>::new(SCHEDULER_IDLE_FLOOR);
3236            if let Ok(mut g) = rt.tick_wake.lock() {
3237                *g = Some(handle.clone());
3238            }
3239            let rt_tick = Arc::clone(&rt);
3240            let stop_tick = stop;
3241            handles_init.push(
3242                thread::Builder::new()
3243                    .name(String::from("zdds-tick-sched"))
3244                    .spawn(move || scheduler_tick_loop(rt_tick, stop_tick, scheduler, handle))
3245                    .map_err(|_| DdsError::PreconditionNotMet {
3246                        reason: "spawn zdds-tick-sched thread",
3247                    })?,
3248            );
3249        } else {
3250            let rt_tick = Arc::clone(&rt);
3251            let stop_tick = stop;
3252            handles_init.push(
3253                thread::Builder::new()
3254                    .name(String::from("zdds-tick"))
3255                    .spawn(move || tick_loop(rt_tick, stop_tick))
3256                    .map_err(|_| DdsError::PreconditionNotMet {
3257                        reason: "spawn zdds-tick thread",
3258                    })?,
3259            );
3260        }
3261
3262        let mut guard = rt
3263            .handles
3264            .lock()
3265            .map_err(|_| DdsError::PreconditionNotMet {
3266                reason: "runtime handles mutex poisoned",
3267            })?;
3268        *guard = handles_init;
3269        drop(guard);
3270
3271        Ok(rt)
3272    }
3273
3274    /// Local unicast locator for user data (announced in SPDP).
3275    #[must_use]
3276    pub fn user_locator(&self) -> zerodds_rtps::wire_types::Locator {
3277        self.user_unicast.local_locator()
3278    }
3279
3280    /// Local unicast locator for SPDP metatraffic.
3281    #[must_use]
3282    pub fn spdp_unicast_locator(&self) -> zerodds_rtps::wire_types::Locator {
3283        self.spdp_unicast.local_locator()
3284    }
3285
3286    /// Returns the `BuiltinEndpointSet` bitmask that the runtime
3287    /// currently announces in the SPDP beacon. Used for tests + diagnostics;
3288    /// production consumers should decode the SPDP beacon
3289    /// themselves.
3290    #[must_use]
3291    pub fn announced_builtin_endpoint_set(&self) -> u32 {
3292        self.spdp_beacon
3293            .lock()
3294            .map(|b| b.data.builtin_endpoint_set)
3295            .unwrap_or(0)
3296    }
3297
3298    /// Registers a `TypeObject` in the local TypeLookup server
3299    /// registry. Other participants can then query this type via
3300    /// a `getTypes` request (XTypes 1.3 §7.6.3.3.4).
3301    ///
3302    /// Returns the `EquivalenceHash` of the registered type
3303    /// (the caller can embed it e.g. in `PublicationBuiltinTopicData` as a
3304    /// PID_TYPE_INFORMATION hint).
3305    ///
3306    /// # Errors
3307    /// `DdsError::PreconditionNotMet` on lock poisoning or a hash
3308    /// computation error.
3309    pub fn register_type_object(
3310        &self,
3311        obj: zerodds_types::type_object::TypeObject,
3312    ) -> Result<zerodds_types::EquivalenceHash> {
3313        let hash = zerodds_types::compute_hash(&obj).map_err(|_| DdsError::PreconditionNotMet {
3314            reason: "type hash computation failed",
3315        })?;
3316        let mut server =
3317            self.type_lookup_server
3318                .lock()
3319                .map_err(|_| DdsError::PreconditionNotMet {
3320                    reason: "type_lookup_server mutex poisoned",
3321                })?;
3322        match obj {
3323            zerodds_types::type_object::TypeObject::Minimal(m) => {
3324                server.registry.insert_minimal(hash, m);
3325            }
3326            zerodds_types::type_object::TypeObject::Complete(c) => {
3327                server.registry.insert_complete(hash, c);
3328            }
3329            _ => {
3330                return Err(DdsError::PreconditionNotMet {
3331                    reason: "unknown TypeObject variant",
3332                });
3333            }
3334        }
3335        Ok(hash)
3336    }
3337
3338    /// Sends a `getTypes` request to a discovered peer and
3339    /// returns a `RequestId` with which the caller can correlate the
3340    /// asynchronous reply later (XTypes 1.3
3341    /// §7.6.3.3.4 + `TypeLookupClient::handle_reply`).
3342    ///
3343    /// `peer` must be in `discovered_participants()` — otherwise
3344    /// `None` is returned (no known peer locator). On a
3345    /// successful send the request sample-identity sequence
3346    /// is returned as the `RequestId`; an incoming reply is correlated on
3347    /// this sequence ID.
3348    ///
3349    /// # Errors
3350    /// `DdsError::PreconditionNotMet` on encode errors or lock
3351    /// poisoning.
3352    pub fn send_type_lookup_request(
3353        &self,
3354        peer: zerodds_rtps::wire_types::GuidPrefix,
3355        type_hashes: &[zerodds_types::EquivalenceHash],
3356    ) -> Result<Option<zerodds_discovery::type_lookup::RequestId>> {
3357        use alloc::sync::Arc as AllocArc;
3358        use zerodds_discovery::type_lookup::request_types_payload;
3359        use zerodds_rtps::datagram::encode_data_datagram;
3360        use zerodds_rtps::header::RtpsHeader;
3361        use zerodds_rtps::submessages::DataSubmessage;
3362        use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber};
3363
3364        // Find peer's user-unicast locator (default-unicast first;
3365        // fallback metatraffic-unicast). TypeLookup datagrams go via
3366        // the user-unicast path — the peer DCPS runtime has a
3367        // shared receive loop there for SEDP/user data/TypeLookup.
3368        let target = {
3369            let discovered = self
3370                .discovered
3371                .lock()
3372                .map_err(|_| DdsError::PreconditionNotMet {
3373                    reason: "discovered mutex poisoned",
3374                })?;
3375            let Some(dp) = discovered.get(&peer) else {
3376                return Ok(None);
3377            };
3378            dp.data
3379                .default_unicast_locator
3380                .or(dp.data.metatraffic_unicast_locator)
3381        };
3382        let Some(target) = target else {
3383            return Ok(None);
3384        };
3385
3386        // Allocate RequestId (client-side incrementing sequence). Reply
3387        // correlation runs via the `handle_reply` callback. We
3388        // register a callback that feeds the returned
3389        // TypeObjects into the local `TypeLookupServer.registry`
3390        // (XTypes 1.3 §7.6.3.3.4): hash-by-hash, separately
3391        // for Minimal and Complete variants. This way a hash that
3392        // was resolved once is recognized for future `has_type_for_hash`
3393        // checks (= no re-requests).
3394        let mut client =
3395            self.type_lookup_client
3396                .lock()
3397                .map_err(|_| DdsError::PreconditionNotMet {
3398                    reason: "type_lookup_client mutex poisoned",
3399                })?;
3400        let type_ids: alloc::vec::Vec<zerodds_types::TypeIdentifier> = type_hashes
3401            .iter()
3402            .map(|h| zerodds_types::TypeIdentifier::EquivalenceHashMinimal(*h))
3403            .collect();
3404        let server_for_cb = Arc::clone(&self.type_lookup_server);
3405        let cb = Box::new(
3406            move |reply: zerodds_discovery::type_lookup::TypeLookupReply| {
3407                let zerodds_discovery::type_lookup::TypeLookupReply::Types(types_reply) = reply
3408                else {
3409                    return;
3410                };
3411                let Ok(mut server) = server_for_cb.lock() else {
3412                    return;
3413                };
3414                for t in &types_reply.types {
3415                    match t {
3416                        zerodds_types::type_lookup::ReplyTypeObject::Minimal(m) => {
3417                            let to = zerodds_types::type_object::TypeObject::Minimal(m.clone());
3418                            if let Ok(h) = zerodds_types::compute_hash(&to) {
3419                                server.registry.insert_minimal(h, m.clone());
3420                            }
3421                        }
3422                        zerodds_types::type_lookup::ReplyTypeObject::Complete(c) => {
3423                            let to = zerodds_types::type_object::TypeObject::Complete(c.clone());
3424                            if let Ok(h) = zerodds_types::compute_hash(&to) {
3425                                server.registry.insert_complete(h, c.clone());
3426                            }
3427                        }
3428                    }
3429                }
3430            },
3431        );
3432        let request_id = client.request_types(type_ids.clone(), cb);
3433        drop(client);
3434
3435        // Encode the wire request payload (PL_CDR_LE-Encapsulation).
3436        let body = request_types_payload(&type_ids).map_err(|_| DdsError::PreconditionNotMet {
3437            reason: "type_lookup request payload encode failed",
3438        })?;
3439        let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
3440        payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
3441        payload.extend_from_slice(&body);
3442
3443        // Use the RequestId as the writer_sn so the peer-side reply can
3444        // echo it for correlation (XTypes §7.6.3.3.3 Sample-Identity).
3445        let id_u64 = request_id.0;
3446        let sn =
3447            SequenceNumber::from_high_low((id_u64 >> 32) as i32, (id_u64 & 0xFFFF_FFFF) as u32);
3448        let header = RtpsHeader {
3449            protocol_version: ProtocolVersion::CURRENT,
3450            vendor_id: VendorId::ZERODDS,
3451            guid_prefix: self.guid_prefix,
3452        };
3453        let data = DataSubmessage {
3454            extra_flags: 0,
3455            reader_id: EntityId::TL_SVC_REQ_READER,
3456            writer_id: EntityId::TL_SVC_REQ_WRITER,
3457            writer_sn: sn,
3458            inline_qos: None,
3459            key_flag: false,
3460            non_standard_flag: false,
3461            serialized_payload: AllocArc::from(payload.into_boxed_slice()),
3462        };
3463        let datagram =
3464            encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
3465                reason: "type_lookup request datagram encode failed",
3466            })?;
3467
3468        if is_routable_user_locator(&target) {
3469            let _ = self.user_unicast.send(&target, &datagram);
3470        }
3471        Ok(Some(request_id))
3472    }
3473
3474    /// Activates the security builtin endpoint stack
3475    /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
3476    /// MessageSecure`). Typically called by the factory
3477    /// once a security plugin is registered on the participant.
3478    /// Idempotent: a second call has no effect. Returns the (possibly
3479    /// freshly created) stack handle.
3480    pub fn enable_security_builtins(
3481        &self,
3482        vendor_id: VendorId,
3483    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3484        self.install_security_stack(SecurityBuiltinStack::new(self.guid_prefix, vendor_id))
3485    }
3486
3487    /// Like [`enable_security_builtins`](Self::enable_security_builtins),
3488    /// but with an active auth-handshake driver (FU2 Gap 4). The stack
3489    /// is built via [`SecurityBuiltinStack::with_auth`]: the shared
3490    /// `auth` plugin (= the same instance that hangs on the crypto gate as
3491    /// the `SharedSecretProvider`) drives the PKI handshake as soon as
3492    /// a peer with stateless bits + identity token is discovered.
3493    ///
3494    /// `local_identity` comes from `validate_local_identity`; the local
3495    /// 16-byte participant GUID is derived from the `guid_prefix`.
3496    ///
3497    /// Idempotent (first-wins): if a stack is already active — even one
3498    /// built without auth — that one is returned and the freshly
3499    /// built one discarded.
3500    #[cfg(feature = "security")]
3501    pub fn enable_security_builtins_with_auth(
3502        self: &Arc<Self>,
3503        vendor_id: VendorId,
3504        auth: Arc<Mutex<dyn zerodds_security::authentication::AuthenticationPlugin>>,
3505        local_identity: zerodds_security::authentication::IdentityHandle,
3506    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3507        let local_guid = Guid::new(self.guid_prefix, EntityId::PARTICIPANT).to_bytes();
3508        // Announce the local IdentityToken in the SPDP beacon (PID_IDENTITY_TOKEN,
3509        // FU2 Gap 7c) + set the stateless/volatile-secure bits, so peers
3510        // initiate the auth handshake. Before moving `auth` into the stack.
3511        if let Ok(mut plugin) = auth.lock() {
3512            if let Ok(token) = plugin.get_identity_token(local_identity) {
3513                // PID_PERMISSIONS_TOKEN (§7.4.1.5, S4 point 1): secure
3514                // vendors (cyclone/FastDDS) start validate_remote_identity
3515                // only when SPDP carries identity_token AND permissions_token;
3516                // otherwise we stay non-secure and all endpoints are "not
3517                // allowed". Empty if no permissions are configured.
3518                let perm_token = plugin.get_permissions_token();
3519                let pdata = if let Ok(mut beacon) = self.spdp_beacon.lock() {
3520                    if !token.is_empty() {
3521                        beacon.data.identity_token = Some(token);
3522                    }
3523                    if !perm_token.is_empty() {
3524                        beacon.data.permissions_token = Some(perm_token);
3525                    }
3526                    // Full secure builtin endpoint set (§7.4.7.1): stateless +
3527                    // VolatileSecure (22-25) PLUS secure SEDP (16-19),
3528                    // secure ParticipantMessage (20-21) and DCPSParticipantsSecure
3529                    // (26-27). cyclone starts validate_remote_identity + creates the
3530                    // secure builtin proxies ONLY when the remote announces the full
3531                    // secure set (cyclone-trace-verified) — only
3532                    // 22-25 → "Non secure remote ... not allowed", no handshake.
3533                    beacon.data.builtin_endpoint_set |= endpoint_flag::PUBLICATIONS_SECURE_WRITER
3534                        | endpoint_flag::PUBLICATIONS_SECURE_READER
3535                        | endpoint_flag::SUBSCRIPTIONS_SECURE_WRITER
3536                        | endpoint_flag::SUBSCRIPTIONS_SECURE_READER
3537                        | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_WRITER
3538                        | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_READER
3539                        | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
3540                        | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
3541                        | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
3542                        | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
3543                        | endpoint_flag::PARTICIPANT_SECURE_WRITER
3544                        | endpoint_flag::PARTICIPANT_SECURE_READER;
3545                    // PID_PARTICIPANT_SECURITY_INFO (§7.4.1.6): marks us as a
3546                    // secure participant — mandatory, otherwise cyclone/
3547                    // FastDDS treat us as non-secure and reject all endpoints.
3548                    // IS_VALID on both masks; derive the participant-level
3549                    // ParticipantSecurityAttributes (§9.4.2.4) from the governance:
3550                    // is_{rtps,discovery,liveliness}_protected in the
3551                    // attr mask, is_*_encrypted in the plugin mask. cyclone
3552                    // matches the announced bits against its own governance —
3553                    // a null mask with e.g. discovery=ENCRYPT is a policy
3554                    // mismatch and cyclone then establishes NO secured
3555                    // participant crypto handshake (bug source protected discovery).
3556                    use zerodds_rtps::participant_security_info::{
3557                        ParticipantSecurityInfo, attrs, plugin_attrs,
3558                    };
3559                    let (mut a, mut p) = (attrs::IS_VALID, plugin_attrs::IS_VALID);
3560                    if let Some(gate) = self.config.security.as_ref() {
3561                        let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
3562                        let disc = gate.discovery_protection().unwrap_or(ProtectionLevel::None);
3563                        let live = gate
3564                            .liveliness_protection()
3565                            .unwrap_or(ProtectionLevel::None);
3566                        if rtps != ProtectionLevel::None {
3567                            a |= attrs::IS_RTPS_PROTECTED;
3568                        }
3569                        if disc != ProtectionLevel::None {
3570                            a |= attrs::IS_DISCOVERY_PROTECTED;
3571                        }
3572                        if live != ProtectionLevel::None {
3573                            a |= attrs::IS_LIVELINESS_PROTECTED;
3574                        }
3575                        if rtps == ProtectionLevel::Encrypt {
3576                            p |= plugin_attrs::IS_RTPS_ENCRYPTED;
3577                        }
3578                        if disc == ProtectionLevel::Encrypt {
3579                            p |= plugin_attrs::IS_DISCOVERY_ENCRYPTED;
3580                        }
3581                        if live == ProtectionLevel::Encrypt {
3582                            p |= plugin_attrs::IS_LIVELINESS_ENCRYPTED;
3583                        }
3584                    }
3585                    beacon.data.participant_security_info = Some(ParticipantSecurityInfo {
3586                        participant_security_attributes: a,
3587                        plugin_participant_security_attributes: p,
3588                    });
3589                    // c.pdata (§9.3.2.5.2, S4 root 6+7): our own
3590                    // ParticipantBuiltinTopicData as PL_CDR_**BE** — the replier
3591                    // (cyclone) deserializes c.pdata strictly as a big-endian
3592                    // ParameterList and binds the participant_guid to the
3593                    // authenticated identity. LE → "payload too long".
3594                    Some(beacon.data.to_pl_cdr_be())
3595                } else {
3596                    None
3597                };
3598                if let Some(pd) = pdata {
3599                    plugin.set_local_participant_data(pd);
3600                }
3601            }
3602        }
3603        let stack = self.install_security_stack(SecurityBuiltinStack::with_auth(
3604            self.guid_prefix,
3605            vendor_id,
3606            auth,
3607            local_identity,
3608            local_guid,
3609        ));
3610        // FU2 S3: kick off in-process participant discovery + handshake trigger
3611        // deterministically — decouples the secured discovery from the
3612        // flaky multicast path (codepit LXC). Bidirectional, idempotent.
3613        self.inproc_announce_participant();
3614        // FU2 S3: immediate token-carrying SPDP re-announce (event-driven).
3615        self.announce_spdp_now();
3616        stack
3617    }
3618
3619    /// Installs a freshly built `SecurityBuiltinStack` into the
3620    /// runtime slot (idempotent) and catches up on peers already
3621    /// discovered via SPDP. Shared core of
3622    /// [`enable_security_builtins`](Self::enable_security_builtins) and
3623    /// [`enable_security_builtins_with_auth`](Self::enable_security_builtins_with_auth).
3624    fn install_security_stack(
3625        &self,
3626        fresh: SecurityBuiltinStack,
3627    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3628        // Lock poisoning is a bug indicator here (an earlier panic in the
3629        // hot path). In that case we return a fresh, isolated
3630        // stack — the caller gets at least a
3631        // functional slot, but the hot path writes its mutations
3632        // into the unlocked original. In production code this does not happen;
3633        // in tests (where poisoning can occur) this is a
3634        // best-effort recovery.
3635        let mut slot = match self.security_builtin.lock() {
3636            Ok(g) => g,
3637            Err(_) => {
3638                return Arc::new(Mutex::new(fresh));
3639            }
3640        };
3641        if let Some(existing) = slot.as_ref() {
3642            return Arc::clone(existing);
3643        }
3644        let stack = Arc::new(Mutex::new(fresh));
3645        // Catch up on already-discovered peers (discovery may have already
3646        // seen SPDP beacons before the plugin was activated).
3647        if let Ok(cache) = self.discovered.lock() {
3648            if let Ok(mut s) = stack.lock() {
3649                for peer in cache.iter() {
3650                    s.handle_remote_endpoints(peer);
3651                }
3652            }
3653        }
3654        *slot = Some(Arc::clone(&stack));
3655        // Protected discovery (DDS-Security §8.4.2.4): if the governance demands
3656        // `discovery_protection_kind != NONE`, the SedpStack routes secured
3657        // endpoints via the secure SEDP (DCPSPublicationsSecure/Subscriptions
3658        // Secure) instead of plaintext — the runtime send path protects their DATA/
3659        // HEARTBEAT/GAP with the participant data key. Set before the first
3660        // announce_* (endpoint creation follows the security activation).
3661        #[cfg(feature = "security")]
3662        if let Some(gate) = self.config.security.as_ref() {
3663            let protected = gate
3664                .discovery_protection()
3665                .map(|l| l != ProtectionLevel::None)
3666                .unwrap_or(false);
3667            if protected {
3668                if let Ok(mut sedp) = self.sedp.lock() {
3669                    sedp.set_discovery_protected(true);
3670                }
3671            }
3672        }
3673        stack
3674    }
3675
3676    /// Snapshot handle on the security builtin stack. `None` if
3677    /// [`enable_security_builtins`](Self::enable_security_builtins)
3678    /// has not been called yet.
3679    #[must_use]
3680    pub fn security_builtin_snapshot(&self) -> Option<Arc<Mutex<SecurityBuiltinStack>>> {
3681        self.security_builtin.lock().ok()?.as_ref().map(Arc::clone)
3682    }
3683
3684    /// `assert_liveliness()` on the `DomainParticipant` (DCPS 1.4
3685    /// §2.2.3.11 MANUAL_BY_PARTICIPANT). Sends exactly one WLP heartbeat
3686    /// with `kind = MANUAL_BY_PARTICIPANT` on the next tick;
3687    /// all readers matching this participant refresh their
3688    /// last-seen timestamp. Idempotent — multiple calls within
3689    /// one tick period result in multiple wire sends up to the
3690    /// cap (`MAX_QUEUED_PULSES = 32`).
3691    pub fn assert_liveliness(&self) {
3692        if let Ok(mut wlp) = self.wlp.lock() {
3693            wlp.assert_participant();
3694        }
3695    }
3696
3697    /// `assert_liveliness()` on a `DataWriter` (DCPS 1.4 §2.2.3.11
3698    /// MANUAL_BY_TOPIC). `topic_token` is an opaque token that
3699    /// matching readers can use to associate the pulse with a concrete
3700    /// topic. We use the ZeroDDS vendor kind (Cyclone /
3701    /// Fast-DDS ignore the vendor kind, which is spec-conformant —
3702    /// MSB-set in `kind` requests "ignore unknown" behavior).
3703    pub fn assert_writer_liveliness(&self, topic_token: Vec<u8>) {
3704        if let Ok(mut wlp) = self.wlp.lock() {
3705            wlp.assert_topic(topic_token);
3706        }
3707    }
3708
3709    /// Current WLP last-seen timestamp of a remote peer (relative
3710    /// to runtime start). `None` if the peer has not sent a WLP
3711    /// heartbeat yet.
3712    #[must_use]
3713    pub fn peer_liveliness_last_seen(&self, prefix: &GuidPrefix) -> Option<Duration> {
3714        self.wlp
3715            .lock()
3716            .ok()
3717            .and_then(|w| w.peer_state(prefix).map(|s| s.last_seen))
3718    }
3719
3720    /// Returns the [`zerodds_discovery::PeerCapabilities`] of a remote
3721    /// peer, based on its most recently received SPDP beacon.
3722    /// `None` if the peer has not been discovered via SPDP yet.
3723    #[must_use]
3724    pub fn peer_capabilities(
3725        &self,
3726        prefix: &GuidPrefix,
3727    ) -> Option<zerodds_discovery::PeerCapabilities> {
3728        self.discovered
3729            .lock()
3730            .ok()
3731            .and_then(|d| d.get(prefix).map(|p| p.data.builtin_endpoint_set))
3732            .map(zerodds_discovery::PeerCapabilities::from_bits)
3733    }
3734
3735    /// Snapshot of the currently discovered remote participants.
3736    /// Key = GUID prefix, value = last seen beacon content.
3737    #[must_use]
3738    pub fn discovered_participants(&self) -> Vec<DiscoveredParticipant> {
3739        self.discovered
3740            .lock()
3741            .map(|cache| cache.iter().cloned().collect())
3742            .unwrap_or_default()
3743    }
3744
3745    /// Wires the `BuiltinSinks` of the `DomainParticipant` into the
3746    /// discovery hot path. From this
3747    /// call on, all SPDP/SEDP receive events land as samples in
3748    /// the 4 builtin-topic readers.
3749    ///
3750    /// Called by the `DomainParticipant` constructor exactly once during
3751    /// setup.
3752    pub fn attach_builtin_sinks(&self, sinks: crate::builtin_subscriber::BuiltinSinks) {
3753        if let Ok(mut guard) = self.builtin_sinks.lock() {
3754            *guard = Some(sinks);
3755        }
3756    }
3757
3758    /// Snapshot of the currently wired BuiltinSinks (internal, for the
3759    /// hot path).
3760    pub(crate) fn builtin_sinks_snapshot(&self) -> Option<crate::builtin_subscriber::BuiltinSinks> {
3761        self.builtin_sinks.lock().ok().and_then(|g| g.clone())
3762    }
3763
3764    /// Wires the `IgnoreFilter` of the `DomainParticipant` into the
3765    /// discovery hot path. From
3766    /// this call on, SPDP/SEDP receive events are checked against the
3767    /// filter before being pushed as a builtin sample or used as an
3768    /// SEDP match source.
3769    ///
3770    /// Called by the `DomainParticipant` constructor exactly once during
3771    /// setup.
3772    pub fn attach_ignore_filter(&self, filter: crate::participant::IgnoreFilter) {
3773        if let Ok(mut guard) = self.ignore_filter.lock() {
3774            *guard = Some(filter);
3775        }
3776    }
3777
3778    /// Snapshot of the currently wired IgnoreFilter (internal, for
3779    /// the hot path).
3780    pub(crate) fn ignore_filter_snapshot(&self) -> Option<crate::participant::IgnoreFilter> {
3781        self.ignore_filter.lock().ok().and_then(|g| g.clone())
3782    }
3783
3784    /// Synchronizes the protected-discovery flag of the `SedpStack` with the
3785    /// governance (`discovery_protection_kind`). Idempotent, called before every
3786    /// `announce_*` — so the flag is set correctly regardless of the
3787    /// order in which security activation and endpoint creation ran.
3788    #[cfg(feature = "security")]
3789    fn sync_sedp_discovery_protected(&self, sedp: &mut SedpStack) {
3790        if let Some(gate) = self.config.security.as_ref() {
3791            let protected = gate
3792                .discovery_protection()
3793                .map(|l| l != ProtectionLevel::None)
3794                .unwrap_or(false);
3795            sedp.set_discovery_protected(protected);
3796        }
3797    }
3798
3799    /// Announces a local publication via SEDP. The runtime
3800    /// sends the generated datagrams immediately to all already-
3801    /// discovered remote participants.
3802    ///
3803    /// # Errors
3804    /// `WireError` if encoding fails.
3805    pub fn announce_publication(
3806        &self,
3807        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3808    ) -> Result<()> {
3809        // In-process discovery fastpath: put it in the stash so a
3810        // peer runtime starting later in the same process can pull us
3811        // via `inproc_snapshot`.
3812        if let Ok(mut v) = self.announced_pubs.lock() {
3813            v.push(data.clone());
3814        }
3815        // ADR-0006: side-map lookup. If the local user writer has a
3816        // same-host backend attached (set_shm_locator was
3817        // called), we inject PID_SHM_LOCATOR into the SEDP
3818        // sample. Otherwise pure 1:1 spec wire.
3819        let shm = self.shm_locator(data.key.entity_id);
3820        let datagrams = {
3821            let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3822                reason: "sedp poisoned",
3823            })?;
3824            // Protected discovery (§8.4.2.4): set robustly before the announce —
3825            // independent of the order of enable_security_builtins vs.
3826            // endpoint creation. `discovery_protection_kind != NONE` routes
3827            // the announce into the secure SEDP writer.
3828            #[cfg(feature = "security")]
3829            self.sync_sedp_discovery_protected(&mut sedp);
3830            let res = if let Some(ref bytes) = shm {
3831                sedp.announce_publication_with_shm_locator(data, bytes)
3832            } else {
3833                sedp.announce_publication(data)
3834            };
3835            res.map_err(|_| DdsError::WireError {
3836                message: alloc::string::String::from("sedp announce_publication"),
3837            })?
3838        };
3839        // Send outside the lock (Rc<Vec<Locator>> is !Send,
3840        // but we are on the same thread as `self` — no
3841        // problem).
3842        for dg in datagrams {
3843            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3844                for t in dg.targets.iter() {
3845                    if is_routable_user_locator(t) {
3846                        // §8.3.7: unicast metatraffic (SEDP DATA to the remote
3847                        // metatraffic_unicast_locator) MUST go out from the metatraffic
3848                        // recv socket `spdp_unicast`, NOT from the ephemeral
3849                        // `spdp_mc_tx` — otherwise the peer sees a foreign
3850                        // source port and sends its reliable ACKNACK/resends
3851                        // to a dead port (cross-vendor SEDP stall). Identical
3852                        // to `send_discovery_datagram`.
3853                        let _ = self.spdp_unicast.send(t, &secured);
3854                    }
3855                }
3856            }
3857        }
3858        // In-process discovery fastpath: serve same-process+domain peers
3859        // synchronously + losslessly with this publication.
3860        self.inproc_announce_publication(data);
3861        Ok(())
3862    }
3863
3864    /// Announces a local subscription via SEDP. Analogous to
3865    /// `announce_publication`.
3866    ///
3867    /// # Errors
3868    /// `WireError` if encoding fails.
3869    pub fn announce_subscription(
3870        &self,
3871        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3872    ) -> Result<()> {
3873        if let Ok(mut v) = self.announced_subs.lock() {
3874            v.push(data.clone());
3875        }
3876        let datagrams = {
3877            let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3878                reason: "sedp poisoned",
3879            })?;
3880            #[cfg(feature = "security")]
3881            self.sync_sedp_discovery_protected(&mut sedp);
3882            sedp.announce_subscription(data)
3883                .map_err(|_| DdsError::WireError {
3884                    message: alloc::string::String::from("sedp announce_subscription"),
3885                })?
3886        };
3887        for dg in datagrams {
3888            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3889                for t in dg.targets.iter() {
3890                    if is_routable_user_locator(t) {
3891                        // Source port: metatraffic recv socket, not spdp_mc_tx
3892                        // (see announce_publication / send_discovery_datagram).
3893                        let _ = self.spdp_unicast.send(t, &secured);
3894                    }
3895                }
3896            }
3897        }
3898        // In-process discovery fastpath: see `announce_publication`.
3899        self.inproc_announce_subscription(data);
3900        Ok(())
3901    }
3902
3903    /// Re-announces the local SEDP endpoint records (publications +
3904    /// subscriptions) to a peer whose crypto-token exchange has just
3905    /// completed. Background: under `rtps_protection`/`discovery_
3906    /// protection` ZeroDDS wraps the SEDP message-/submessage-protected; the
3907    /// peer discards the initial SEDP burst UNTIL it has our participant crypto
3908    /// token (via Volatile). From that moment it can decode — a
3909    /// one-time re-announce brings the previously dropped SEDP up (mints fresh
3910    /// SNs; the reliable SEDP writer delivers them, HEARTBEAT/NACK retry covers a
3911    /// not-quite-ready peer timing). Once per peer (dedup).
3912    ///
3913    /// No-op without active rtps_/discovery_protection (then the announce
3914    /// went through plaintext anyway) and for already re-announced peers. Emits
3915    /// the RETAINED records directly (NO additional `announced_pubs` push).
3916    #[cfg(feature = "security")]
3917    fn re_announce_sedp_to_peer(&self, peer_prefix: GuidPrefix) {
3918        let Some(gate) = &self.config.security else {
3919            return;
3920        };
3921        let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3922        let disc =
3923            gate.discovery_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3924        if !rtps && !disc {
3925            return;
3926        }
3927        // First check whether we have any local endpoints at all — the token
3928        // exchange can complete BEFORE the user endpoint creation.
3929        // Without records do NOT mark as "re-announced" (the periodic tick
3930        // retriggers as soon as the user writer/reader is announced).
3931        let pubs = self
3932            .announced_pubs
3933            .lock()
3934            .map(|v| v.clone())
3935            .unwrap_or_default();
3936        let subs = self
3937            .announced_subs
3938            .lock()
3939            .map(|v| v.clone())
3940            .unwrap_or_default();
3941        if pubs.is_empty() && subs.is_empty() {
3942            return;
3943        }
3944        {
3945            let mut set = match self.sedp_reannounced.write() {
3946                Ok(s) => s,
3947                Err(_) => return,
3948            };
3949            if !set.insert(peer_prefix.0) {
3950                return; // already re-announced
3951            }
3952        }
3953        let send_dgs = |dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>| {
3954            for dg in dgs {
3955                if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3956                    for t in dg.targets.iter() {
3957                        if is_routable_user_locator(t) {
3958                            let _ = self.spdp_unicast.send(t, &secured);
3959                        }
3960                    }
3961                }
3962            }
3963        };
3964        for data in &pubs {
3965            let shm = self.shm_locator(data.key.entity_id);
3966            let dgs = {
3967                let Ok(mut sedp) = self.sedp.lock() else {
3968                    continue;
3969                };
3970                self.sync_sedp_discovery_protected(&mut sedp);
3971                let res = if let Some(ref bytes) = shm {
3972                    sedp.announce_publication_with_shm_locator(data, bytes)
3973                } else {
3974                    sedp.announce_publication(data)
3975                };
3976                match res {
3977                    Ok(d) => d,
3978                    Err(_) => continue,
3979                }
3980            };
3981            send_dgs(dgs);
3982        }
3983        for data in &subs {
3984            let dgs = {
3985                let Ok(mut sedp) = self.sedp.lock() else {
3986                    continue;
3987                };
3988                self.sync_sedp_discovery_protected(&mut sedp);
3989                match sedp.announce_subscription(data) {
3990                    Ok(d) => d,
3991                    Err(_) => continue,
3992                }
3993            };
3994            send_dgs(dgs);
3995        }
3996    }
3997
3998    /// Own participant data as a `DiscoveredParticipant` — the
3999    /// self-view that the in-process fastpath hands to peers.
4000    fn self_as_discovered_participant(&self) -> zerodds_discovery::spdp::DiscoveredParticipant {
4001        // From the LIVE SPDP beacon: after `enable_security_builtins_with_auth`
4002        // it carries the `identity_token` + the secure endpoint bits that the
4003        // `participant_data` construction snapshot does NOT have. Without these the
4004        // in-process injected DP is worthless for the security handshake trigger
4005        // (`handle_remote_endpoints`/`begin_handshake_with` need
4006        // the token). Fallback to `participant_data` on lock poisoning.
4007        let data = self
4008            .spdp_beacon
4009            .lock()
4010            .map(|b| b.data.clone())
4011            .unwrap_or_else(|_| self.participant_data.clone());
4012        zerodds_discovery::spdp::DiscoveredParticipant {
4013            sender_prefix: self.guid_prefix,
4014            sender_vendor: VendorId::ZERODDS,
4015            data,
4016        }
4017    }
4018
4019    /// In-process discovery: injects the just-announced publication
4020    /// synchronously into all same-process+domain peer runtimes.
4021    fn inproc_announce_publication(
4022        &self,
4023        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
4024    ) {
4025        let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4026        let mut dp = None;
4027        for peer in peers {
4028            if peer.guid_prefix == self.guid_prefix {
4029                continue;
4030            }
4031            let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
4032            peer.inproc_inject_publication(dp, data);
4033        }
4034    }
4035
4036    /// In-process discovery: injects the just-announced subscription
4037    /// synchronously into all same-process+domain peer runtimes.
4038    fn inproc_announce_subscription(
4039        &self,
4040        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4041    ) {
4042        let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4043        let mut dp = None;
4044        for peer in peers {
4045            if peer.guid_prefix == self.guid_prefix {
4046                continue;
4047            }
4048            let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
4049            peer.inproc_inject_subscription(dp, data);
4050        }
4051    }
4052
4053    /// In-process discovery (receive side): wires the remote
4054    /// participant + injects the publication into the SEDP cache and
4055    /// matches the local readers. Idempotent — an announcement arriving
4056    /// later via UDP is thereby a no-op.
4057    fn inproc_inject_publication(
4058        self: &Arc<Self>,
4059        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4060        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
4061    ) {
4062        // §2.2.2.2.1.17: an ignored publication/participant must not be matched.
4063        // The in-process fastpath bypasses the wire match path, so the ignore
4064        // filter must be honored here too — otherwise the Durability-Service's
4065        // own two participants (ingest + replay, same process) would match and
4066        // echo-loop despite mutually ignoring each other.
4067        if let Some(filter) = self.ignore_filter_snapshot() {
4068            let pub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
4069            let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
4070            if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
4071                return;
4072            }
4073        }
4074        let now = self.start_instant.elapsed();
4075        let is_new = self
4076            .discovered
4077            .lock()
4078            .map(|mut c| c.insert(dp.clone()))
4079            .unwrap_or(false);
4080        if let Ok(mut sedp) = self.sedp.lock() {
4081            if is_new {
4082                sedp.on_participant_discovered(dp);
4083            }
4084            sedp.cache_mut().insert_publication(data.clone(), now);
4085        }
4086        run_matching_pass(self);
4087    }
4088
4089    /// In-process discovery (receive side): like `inproc_inject_publication`
4090    /// for a subscription.
4091    fn inproc_inject_subscription(
4092        self: &Arc<Self>,
4093        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4094        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4095    ) {
4096        // See `inproc_inject_publication`: honor the ignore filter on the
4097        // in-process fastpath (symmetric, subscription side).
4098        if let Some(filter) = self.ignore_filter_snapshot() {
4099            let sub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
4100            let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
4101            if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
4102                return;
4103            }
4104        }
4105        let now = self.start_instant.elapsed();
4106        let is_new = self
4107            .discovered
4108            .lock()
4109            .map(|mut c| c.insert(dp.clone()))
4110            .unwrap_or(false);
4111        if let Ok(mut sedp) = self.sedp.lock() {
4112            if is_new {
4113                sedp.on_participant_discovered(dp);
4114            }
4115            sedp.cache_mut().insert_subscription(data.clone(), now);
4116        }
4117        run_matching_pass(self);
4118    }
4119
4120    /// Snapshot of our own endpoints for the `pull-on-creation` path
4121    /// of a peer runtime starting later in the same process.
4122    fn inproc_snapshot(
4123        &self,
4124    ) -> (
4125        zerodds_discovery::spdp::DiscoveredParticipant,
4126        Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>,
4127        Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>,
4128    ) {
4129        let dp = self.self_as_discovered_participant();
4130        let pubs = self
4131            .announced_pubs
4132            .lock()
4133            .map(|v| v.clone())
4134            .unwrap_or_default();
4135        let subs = self
4136            .announced_subs
4137            .lock()
4138            .map(|v| v.clone())
4139            .unwrap_or_default();
4140        (dp, pubs, subs)
4141    }
4142
4143    /// At runtime creation: ask existing same-process+domain peers
4144    /// for their already-announced endpoints and inject these into
4145    /// our SEDP cache. Symmetric counterpart to the
4146    /// announce hook (which distributes live endpoints to peers).
4147    fn inproc_pull_from_peers(self: &Arc<Self>) {
4148        let peers: Vec<Arc<DcpsRuntime>> =
4149            crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
4150                .into_iter()
4151                .filter(|rt| rt.guid_prefix != self.guid_prefix)
4152                .collect();
4153        for peer in peers {
4154            let (dp, pubs, subs) = peer.inproc_snapshot();
4155            for p in &pubs {
4156                self.inproc_inject_publication(&dp, p);
4157            }
4158            for s in &subs {
4159                self.inproc_inject_subscription(&dp, s);
4160            }
4161        }
4162    }
4163
4164    /// FU2 S3: in-process counterpart to the security part of
4165    /// [`handle_spdp_datagram`]. Wires the secure builtin endpoints of the
4166    /// discovered peer and kicks off — if it announces an `identity_token`
4167    /// — the auth handshake; the resulting AUTH datagrams
4168    /// go to the peer via UDP **unicast** (reliable loopback).
4169    /// No-op without a local security stack or without a peer `identity_token`.
4170    #[cfg(feature = "security")]
4171    fn inproc_drive_security_handshake(
4172        self: &Arc<Self>,
4173        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4174    ) {
4175        if dp.sender_prefix == self.guid_prefix {
4176            return;
4177        }
4178        let Some(sec) = self.security_builtin_snapshot() else {
4179            return;
4180        };
4181        let dgs = if let Ok(mut s) = sec.lock() {
4182            s.note_remote_vendor(dp.sender_prefix, dp.sender_vendor);
4183            s.handle_remote_endpoints(dp);
4184            match dp.data.identity_token.as_ref() {
4185                Some(token) => s
4186                    .begin_handshake_with(dp.sender_prefix, dp.data.guid.to_bytes(), token)
4187                    .unwrap_or_default(),
4188                None => Vec::new(),
4189            }
4190        } else {
4191            Vec::new()
4192        };
4193        for dg in dgs {
4194            send_discovery_datagram(self, &dg.targets, &dg.bytes);
4195        }
4196    }
4197
4198    /// FU2 S3: in-process SPDP **participant** discovery. This was the real
4199    /// gap — `inproc_inject_publication`/`_subscription` only inject
4200    /// SEDP endpoints, the SPDP participant level (identity_token +
4201    /// `begin_handshake_with`) ran EXCLUSIVELY over the multicast path
4202    /// that is flaky on the codepit LXC. This hook, on activation of the
4203    /// security builtins, exchanges the participant DPs (with token) **bidirectionally** with
4204    /// all same-process+domain peers and kicks off the auth handshakes
4205    /// — deterministically, without a single multicast beacon.
4206    #[cfg(feature = "security")]
4207    fn inproc_announce_participant(self: &Arc<Self>) {
4208        let self_dp = self.self_as_discovered_participant();
4209        let peers: Vec<Arc<DcpsRuntime>> =
4210            crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
4211                .into_iter()
4212                .filter(|rt| rt.guid_prefix != self.guid_prefix)
4213                .collect();
4214        for peer in peers {
4215            // self → peer: the peer discovers US + triggers its handshake.
4216            let _ = peer
4217                .discovered
4218                .lock()
4219                .map(|mut c| c.insert(self_dp.clone()));
4220            peer.inproc_drive_security_handshake(&self_dp);
4221            // peer → self: WE discover the peer + trigger our handshake.
4222            let peer_dp = peer.self_as_discovered_participant();
4223            let _ = self
4224                .discovered
4225                .lock()
4226                .map(|mut c| c.insert(peer_dp.clone()));
4227            self.inproc_drive_security_handshake(&peer_dp);
4228        }
4229    }
4230
4231    /// C1 multicast-free discovery: sends a (possibly already security-
4232    /// transformed) SPDP beacon additionally to all configured
4233    /// unicast initial peers. No-op without peers → pure multicast behavior,
4234    /// no additional syscalls by default.
4235    fn send_spdp_to_initial_peers(&self, bytes: &[u8]) {
4236        for peer in &self.config.initial_peers {
4237            let _ = self.spdp_mc_tx.send(peer, bytes);
4238        }
4239    }
4240
4241    /// FU2 S3: sends an SPDP beacon IMMEDIATELY via multicast, instead of waiting
4242    /// for the next periodic `spdp_period` tick. Critical for the
4243    /// cross-process secured handshake: `DcpsRuntime::start` starts the
4244    /// beacon sender, whose first beacon (token-LESS) goes out BEFORE
4245    /// `enable_security_builtins_with_auth` sets the `identity_token` on the beacon.
4246    /// If a peer latches this token-less first beacon, it calls
4247    /// `begin_handshake_with` with `token=None` → no-op → the handshake NEVER
4248    /// starts. An immediate re-announce after setting the token ensures
4249    /// that the first token-carrying beacon goes out promptly.
4250    #[cfg(feature = "security")]
4251    fn announce_spdp_now(&self) {
4252        let mc_target = Locator {
4253            kind: LocatorKind::UdpV4,
4254            port: u32::from(
4255                u16::try_from(spdp_multicast_port(self.domain_id as u32)).unwrap_or(7400),
4256            ),
4257            address: {
4258                let mut a = [0u8; 16];
4259                a[12..].copy_from_slice(&self.config.spdp_multicast_group.octets());
4260                a
4261            },
4262        };
4263        if let Ok(mut beacon) = self.spdp_beacon.lock() {
4264            if let Ok(datagram) = beacon.serialize() {
4265                if let Some(secured) = secure_outbound_bytes(self, &datagram) {
4266                    let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4267                    // C1 multicast-free discovery: on the immediate announce too, to
4268                    // the configured initial peers (ZERODDS_PEERS).
4269                    self.send_spdp_to_initial_peers(&secured);
4270                    // Directed unicast fan-out to already-discovered peers:
4271                    // covers the order in which we discover a peer
4272                    // BEFORE our security builtins (token) are active — then the
4273                    // directed response in handle_spdp_datagram skipped tokenless;
4274                    // announce_spdp_now() (called by enable() after the token set)
4275                    // catches up with the tokened beacon promptly + LXC-multicast-
4276                    // independently. Otherwise the peer waits until spdp_period.
4277                    for loc in wlp_unicast_targets(&self.discovered_participants()) {
4278                        let _ = self.spdp_unicast.send(&loc, &secured);
4279                    }
4280                }
4281            }
4282            // FastDDS interop: additionally announce on the reliable secure SPDP
4283            // writer (0xff0101c2), so FastDDS sees our full secured
4284            // participant data over its expected channel.
4285            if self.config.enable_secure_spdp {
4286                if let Ok(datagram) = beacon.serialize_secure() {
4287                    let protected = protect_secure_spdp(self, &datagram).unwrap_or(datagram);
4288                    if let Some(secured) = secure_outbound_bytes(self, &protected) {
4289                        let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4290                    }
4291                }
4292            }
4293        }
4294    }
4295
4296    /// FU2 cross-vendor: `EndpointSecurityInfo` (PID_ENDPOINT_SECURITY_INFO,
4297    /// 0x1004) for user endpoints, derived from the governance
4298    /// `data_protection`. Foreign vendors (cyclone/FastDDS) reject, with
4299    /// `data_protection=ENCRYPT`, a user endpoint WITHOUT this PID as
4300    /// non-secure ("Non secure remote ... not allowed by security").
4301    /// `None` without an active security gate (plain).
4302    #[cfg(feature = "security")]
4303    fn user_endpoint_security_info(
4304        &self,
4305    ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4306        let gate = self.config.security.as_ref()?;
4307        let meta = gate.metadata_protection().ok()?;
4308        let data = gate.data_protection().ok()?;
4309        let disc = gate.topic_discovery_protected().unwrap_or(false);
4310        let liv = gate
4311            .liveliness_protection()
4312            .map(|l| l != ProtectionLevel::None)
4313            .unwrap_or(false);
4314        let rdp = gate.topic_read_protected().unwrap_or(false);
4315        let wrp = gate.topic_write_protected().unwrap_or(false);
4316        Some(compute_user_endpoint_attrs(meta, data, disc, liv, rdp, wrp))
4317    }
4318
4319    #[cfg(not(feature = "security"))]
4320    fn user_endpoint_security_info(
4321        &self,
4322    ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4323        None
4324    }
4325
4326    /// Registers a local user writer. The caller gets the
4327    /// writer `EntityId`; for sends via `write_user_sample(eid, ...)`.
4328    ///
4329    /// In the runtime there is **still no** automatic SEDP announce +
4330    /// matching — that comes in B4b. Currently `register_user_writer`
4331    /// is just the wiring.
4332    ///
4333    /// # Errors
4334    /// `PreconditionNotMet` if the registry mutex is poisoned.
4335    pub fn register_user_writer(&self, cfg: UserWriterConfig) -> Result<EntityId> {
4336        // Default: WithKey. Backward-compat for all test callers.
4337        self.register_user_writer_kind(cfg, true)
4338    }
4339
4340    /// Like [`register_user_writer`] but with an explicit NoKey/WithKey
4341    /// flag. Cross-vendor interop needs it: if the IDL type has no
4342    /// `@key`, the writer MUST set `is_keyed=false`, otherwise
4343    /// a remote reader rejects the DATA submessage due to an
4344    /// entityKind mismatch (Spec §9.3.1.2 table 9.1: 0x02=WithKey
4345    /// vs 0x03=NoKey).
4346    pub fn register_user_writer_kind(
4347        &self,
4348        cfg: UserWriterConfig,
4349        is_keyed: bool,
4350    ) -> Result<EntityId> {
4351        let now = self.start_instant.elapsed();
4352        let key = self.next_entity_key();
4353        let eid = if is_keyed {
4354            EntityId::user_writer_with_key(key)
4355        } else {
4356            EntityId::user_writer_no_key(key)
4357        };
4358        let writer = ReliableWriter::new(ReliableWriterConfig {
4359            guid: Guid::new(self.guid_prefix, eid),
4360            vendor_id: VendorId::ZERODDS,
4361            reader_proxies: Vec::new(),
4362            max_samples: 1024,
4363            history_kind: HistoryKind::KeepLast { depth: 32 },
4364            heartbeat_period: DEFAULT_HEARTBEAT_PERIOD,
4365            // Ethernet-safe default; the value is raised at the reader match
4366            // if all readers are same-host (see the
4367            // set_fragmentation call after add_reader_proxy).
4368            fragment_size: DEFAULT_FRAGMENT_SIZE,
4369            mtu: DEFAULT_MTU,
4370        });
4371        let mut pub_data = build_publication_data(
4372            self.guid_prefix,
4373            eid,
4374            &cfg,
4375            &self.config.data_representation_offer,
4376            self.user_announce_locator,
4377        );
4378        // FU2 cross-vendor: EndpointSecurityInfo from the governance
4379        // data_protection — otherwise cyclone/FastDDS reject the user endpoint
4380        // with data_protection=ENCRYPT as non-secure.
4381        pub_data.security_info = self.user_endpoint_security_info();
4382        self.user_writers
4383            .write()
4384            .map_err(|_| DdsError::PreconditionNotMet {
4385                reason: "user_writers poisoned",
4386            })?
4387            .insert(
4388                eid,
4389                Arc::new(Mutex::new(UserWriterSlot {
4390                    writer,
4391                    topic_name: cfg.topic_name.clone(),
4392                    type_name: cfg.type_name.clone(),
4393                    reliable: cfg.reliable,
4394                    durability: cfg.durability,
4395                    deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4396                    // Initial `None`: the deadline window starts only on the
4397                    // first real write. Prevents false misses due to
4398                    // slow entity setup (e.g. Linux CI container)
4399                    // before the app does its first write(). On the
4400                    // first write() `last_write = Some(now)` is set,
4401                    // and from then the deadline counter ticks.
4402                    last_write: None,
4403                    offered_deadline_missed_count: 0,
4404                    liveliness_lost_count: 0,
4405                    last_liveliness_assert: Some(now),
4406                    offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus::default(
4407                    ),
4408                    lifespan_nanos: qos_duration_to_nanos(cfg.lifespan.duration),
4409                    sample_insert_times: alloc::collections::VecDeque::new(),
4410                    liveliness_kind: cfg.liveliness.kind,
4411                    liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4412                    ownership: cfg.ownership,
4413                    ownership_strength: cfg.ownership_strength,
4414                    partition: cfg.partition.clone(),
4415                    #[cfg(feature = "security")]
4416                    reader_protection: BTreeMap::new(),
4417                    #[cfg(feature = "security")]
4418                    locator_to_peer: BTreeMap::new(),
4419                    type_identifier: cfg.type_identifier.clone(),
4420                    data_rep_offer_override: cfg.data_representation_offer.clone(),
4421                    // Default FINAL: irrelevant for XCDR1 (default offer)
4422                    // (final==appendable==CDR_LE), correct for XCDR2 for
4423                    // @final types. Appendable/mutable types set this later via
4424                    // set_user_writer_wire_extensibility.
4425                    wire_extensibility: zerodds_types::qos::ExtensibilityForRepr::Final,
4426                    big_endian_override: false,
4427                    durability_backend: None,
4428                    backend_primed: false,
4429                    history_depth: DEFAULT_INTRA_HISTORY_DEPTH,
4430                    retained: alloc::collections::VecDeque::new(),
4431                    intra_replayed_readers: alloc::collections::BTreeSet::new(),
4432                })),
4433            );
4434        // FIRST match locally, THEN announce — symmetric to
4435        // register_user_reader_kind. Avoids a peer-side match
4436        // triggered by our announce_publication
4437        // starting a data flow to us before we have wired the
4438        // ReaderProxies.
4439        self.match_local_writer_against_cache(eid);
4440        let _ = self.announce_publication(&pub_data);
4441        // Intra-runtime routing: scan local readers for a match on
4442        // (topic, type). Applies to bridge daemons with writer+reader in
4443        // the same runtime (WS/MQTT/CoAP/AMQP bridges). Without this
4444        // route the local reader gets no samples from the local
4445        // writer — the `inproc` fastpath explicitly skips self, UDP loopback
4446        // is not guaranteed, and SEDP match paths go via
4447        // the discovered cache, which does not contain self.
4448        self.recompute_intra_runtime_routes();
4449        // FU2 F-ECHO-WRITE: a user writer created AFTER handshake completion
4450        // (e.g. the event-driven echo writer in the responder/pong) must send its
4451        // per-endpoint datawriter_crypto_tokens IMMEDIATELY to the already-
4452        // authenticated peers — not only on the next tick. Otherwise
4453        // cyclone's reader stays in "waiting for approval by security" beyond
4454        // its match deadline (the event-driven pong may not tick
4455        // in time) → flaky sub=0. Idempotent via endpoint_tokens_sent dedup.
4456        #[cfg(feature = "security")]
4457        self.flush_late_endpoint_tokens();
4458        // Observability event.
4459        self.config.observability.record(
4460            &zerodds_foundation::observability::Event::new(
4461                zerodds_foundation::observability::Level::Info,
4462                zerodds_foundation::observability::Component::Dcps,
4463                "user_writer.created",
4464            )
4465            .with_attr("topic", cfg.topic_name.as_str())
4466            .with_attr("type", cfg.type_name.as_str())
4467            .with_attr("reliable", if cfg.reliable { "true" } else { "false" }),
4468        );
4469        Ok(eid)
4470    }
4471
4472    /// FU2 F-ECHO-WRITE: sends pending per-endpoint crypto tokens IMMEDIATELY to all
4473    /// already-authenticated peers. For user endpoints created AFTER handshake
4474    /// completion (event-driven echo writer in the responder): their token
4475    /// must go out before cyclone's reader match deadline expires — the periodic
4476    /// tick (or a VolatileSecure recv) is otherwise possibly too late. Idempotent
4477    /// via `endpoint_tokens_sent` dedup (double-send with the tick excluded).
4478    #[cfg(feature = "security")]
4479    fn flush_late_endpoint_tokens(&self) {
4480        let Some(stack) = self.security_builtin_snapshot() else {
4481            return;
4482        };
4483        let Ok(mut s) = stack.lock() else {
4484            return;
4485        };
4486        let now = self.start_instant.elapsed();
4487        let peers: alloc::vec::Vec<GuidPrefix> = self
4488            .config
4489            .security
4490            .as_ref()
4491            .map(|g| {
4492                g.authenticated_peer_prefixes()
4493                    .into_iter()
4494                    .map(GuidPrefix::from_bytes)
4495                    .collect()
4496            })
4497            .unwrap_or_default();
4498        for prefix in peers {
4499            let already = self
4500                .endpoint_tokens_sent
4501                .read()
4502                .map(|set| set.clone())
4503                .unwrap_or_default();
4504            let pending =
4505                pending_endpoint_tokens(prepare_endpoint_crypto_tokens(self, prefix), &already);
4506            for ep_msg in pending {
4507                let key = endpoint_token_key(&ep_msg);
4508                let dgs = protect_volatile_outbound(
4509                    self,
4510                    prefix,
4511                    s.volatile_writer
4512                        .write_with_heartbeat(&ep_msg, now)
4513                        .unwrap_or_default(),
4514                );
4515                for dg in dgs {
4516                    for t in dg.targets.iter() {
4517                        let _ = self.spdp_unicast.send(t, &dg.bytes);
4518                    }
4519                }
4520                if let Ok(mut set) = self.endpoint_tokens_sent.write() {
4521                    set.insert(key);
4522                }
4523            }
4524            // Periodic re-announce retrigger: as soon as the user writer/reader
4525            // is announced (announced_pubs/subs not empty), this catches up the
4526            // SEDP initially dropped under rtps_/discovery_protection to this
4527            // (now tokened) peer. Once per peer (dedup in the method).
4528            self.re_announce_sedp_to_peer(prefix);
4529        }
4530    }
4531
4532    /// Spec §2.2.3.5 — registers a durability-service backend on
4533    /// a writer already registered via [`register_user_writer`].
4534    /// With Durability=Transient/Persistent the backend is replayed into the
4535    /// HistoryCache on the first late-joiner match in
4536    /// `wire_writer_to_remote_reader`, so the reader gets all samples —
4537    /// including those no longer in the writer cache due to history eviction
4538    /// or those that have survived a writer restart.
4539    pub fn attach_durability_backend(
4540        &self,
4541        eid: EntityId,
4542        backend: alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>,
4543    ) -> Result<()> {
4544        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4545            what: "attach_durability_backend: unknown writer entity id",
4546        })?;
4547        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4548            reason: "user_writer slot poisoned",
4549        })?;
4550        slot.durability_backend = Some(backend);
4551        slot.backend_primed = false;
4552        Ok(())
4553    }
4554
4555    /// Sets the type extensibility of a writer (FINAL/APPENDABLE/
4556    /// MUTABLE). Affects exclusively the encapsulation header
4557    /// of the user payload (see [`user_payload_encap`]) — relevant for
4558    /// XCDR2 wire, where @appendable requires a `D_CDR2_LE` and @mutable a
4559    /// `PL_CDR2_LE` header. The codegen/FFI calls this after
4560    /// `register_user_writer*` when the type is not @final.
4561    /// Does NOT change the SEDP announce offer list.
4562    ///
4563    /// # Errors
4564    /// `BadParameter` on an unknown EntityId, `PreconditionNotMet` on a
4565    /// poisoned slot mutex.
4566    pub fn set_user_writer_wire_extensibility(
4567        &self,
4568        eid: EntityId,
4569        ext: zerodds_types::qos::ExtensibilityForRepr,
4570    ) -> Result<()> {
4571        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4572            what: "set_user_writer_wire_extensibility: unknown writer entity id",
4573        })?;
4574        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4575            reason: "user_writer slot poisoned",
4576        })?;
4577        slot.wire_extensibility = ext;
4578        Ok(())
4579    }
4580
4581    /// Registers a local user reader. Returns the reader EntityId
4582    /// and an `mpsc::Receiver` through which DataReader handles
4583    /// consume incoming samples.
4584    ///
4585    /// # Errors
4586    /// `PreconditionNotMet` if the registry mutex is poisoned.
4587    /// Registers a user reader. Returns the EntityId and an
4588    /// `mpsc::Receiver<UserSample>` — alive samples deliver payload,
4589    /// lifecycle markers carry key hash + ChangeKind.
4590    pub fn register_user_reader(
4591        &self,
4592        cfg: UserReaderConfig,
4593    ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4594        // Default: WithKey. Backward-compat for all test callers.
4595        self.register_user_reader_kind(cfg, true)
4596    }
4597
4598    /// Like [`register_user_reader`] but with an explicit NoKey/WithKey
4599    /// flag. Symmetric to [`register_user_writer_kind`] — the reader kind
4600    /// must match the writer kind.
4601    pub fn register_user_reader_kind(
4602        &self,
4603        cfg: UserReaderConfig,
4604        is_keyed: bool,
4605    ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4606        let now = self.start_instant.elapsed();
4607        let key = self.next_entity_key();
4608        let eid = if is_keyed {
4609            EntityId::user_reader_with_key(key)
4610        } else {
4611            EntityId::user_reader_no_key(key)
4612        };
4613        let reader = ReliableReader::new(ReliableReaderConfig {
4614            guid: Guid::new(self.guid_prefix, eid),
4615            vendor_id: VendorId::ZERODDS,
4616            writer_proxies: Vec::new(),
4617            max_samples_per_proxy: 256,
4618            // D.5e: 0ms = synchronous ACK response (Cyclone parity).
4619            // Previously 200ms = pre-1.0 default without spec justification.
4620            heartbeat_response_delay:
4621                zerodds_rtps::reliable_reader::DEFAULT_HEARTBEAT_RESPONSE_DELAY,
4622            // C3: ROS-realistic reassembly cap (PointCloud2/Image),
4623            // instead of the conservative rtps 1-MiB default.
4624            assembler_caps: AssemblerCaps {
4625                max_sample_bytes: self.config.max_reassembly_sample_bytes,
4626                ..AssemblerCaps::default()
4627            },
4628        });
4629        // BEST_EFFORT readers must not block delivery on a leading sequence-number
4630        // gap (RTPS §8.4.12.1) — they don't NACK to repair it, so waiting would
4631        // deadlock against e.g. an OpenDDS/RTI writer whose first sample to us is
4632        // mid-stream. Reliable readers keep strict in-order delivery.
4633        let mut reader = reader;
4634        reader.set_best_effort(!cfg.reliable);
4635        let (tx, rx) = mpsc::channel();
4636        // A DataReader announces every representation it can decode (XCDR2 +
4637        // XCDR1), not just the writer-preferred one — see `reader_accept_repr`.
4638        let reader_repr = reader_accept_repr(&self.config.data_representation_offer);
4639        let mut sub_data = build_subscription_data(
4640            self.guid_prefix,
4641            eid,
4642            &cfg,
4643            &reader_repr,
4644            self.user_announce_locator,
4645        );
4646        // FU2 cross-vendor: EndpointSecurityInfo from the governance (see writer).
4647        sub_data.security_info = self.user_endpoint_security_info();
4648        self.user_readers
4649            .write()
4650            .map_err(|_| DdsError::PreconditionNotMet {
4651                reason: "user_readers poisoned",
4652            })?
4653            .insert(
4654                eid,
4655                Arc::new(Mutex::new(UserReaderSlot {
4656                    reader,
4657                    topic_name: cfg.topic_name.clone(),
4658                    type_name: cfg.type_name.clone(),
4659                    sample_tx: tx,
4660                    async_waker: Arc::new(std::sync::Mutex::new(None)),
4661                    listener: None,
4662                    durability: cfg.durability,
4663                    deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4664                    // Start time as reference (see register_user_writer).
4665                    last_sample_received: Some(now),
4666                    requested_deadline_missed_count: 0,
4667                    requested_incompatible_qos:
4668                        crate::status::RequestedIncompatibleQosStatus::default(),
4669                    sample_lost_count: 0,
4670                    sample_rejected: crate::status::SampleRejectedStatus::default(),
4671                    samples_delivered_count: 0,
4672                    liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4673                    liveliness_kind: cfg.liveliness.kind,
4674                    liveliness_alive_count: 0,
4675                    liveliness_not_alive_count: 0,
4676                    // Optimistic init: we see the writer via SEDP,
4677                    // until the lease expires it counts as alive.
4678                    liveliness_alive: true,
4679                    liveliness_alive_writers: alloc::collections::BTreeSet::new(),
4680                    ownership: cfg.ownership,
4681                    partition: cfg.partition.clone(),
4682                    writer_strengths: alloc::collections::BTreeMap::new(),
4683                    type_identifier: cfg.type_identifier.clone(),
4684                    type_consistency: cfg.type_consistency,
4685                    // A2 — TIME_BASED_FILTER off by default; the C-FFI/rmw path
4686                    // arms it via `set_user_reader_time_based_filter`.
4687                    tbf_min_separation_nanos: 0,
4688                    tbf_last_delivered: alloc::collections::BTreeMap::new(),
4689                })),
4690            );
4691        // FIRST match locally (create the writer proxy on the reader),
4692        // THEN announce. Otherwise our announce_subscription triggers a
4693        // backend replay at the peer via the in-process fastpath
4694        // (Spec §2.2.3.5), which injects DATA into *our* reader
4695        // before we have wired the matching WriterProxies — the
4696        // samples are then discarded as unknown-source
4697        // (tests `{transient,persistent}_late_joiner_receives_backend_replay`).
4698        self.match_local_reader_against_cache(eid);
4699        let _ = self.announce_subscription(&sub_data);
4700        // Intra-runtime routing: see `register_user_writer_kind`.
4701        self.recompute_intra_runtime_routes();
4702        // Observability event.
4703        self.config.observability.record(
4704            &zerodds_foundation::observability::Event::new(
4705                zerodds_foundation::observability::Level::Info,
4706                zerodds_foundation::observability::Component::Dcps,
4707                "user_reader.created",
4708            )
4709            .with_attr("topic", cfg.topic_name.as_str())
4710            .with_attr("type", cfg.type_name.as_str()),
4711        );
4712        Ok((eid, rx))
4713    }
4714
4715    /// Tears down a deleted local user **writer** (DataWriter delete / drop):
4716    /// removes its slot — which drops the contained RTPS `ReliableWriter` and
4717    /// with it the heartbeat schedule + reader proxies — rebuilds the
4718    /// intra-runtime routes, stops announcing it to late in-process joiners, and
4719    /// sends an SEDP **dispose** so remote peers drop the matched writer at once
4720    /// instead of waiting for a liveliness timeout.
4721    pub(crate) fn remove_user_writer(&self, eid: EntityId) {
4722        if let Ok(mut w) = self.user_writers.write() {
4723            w.remove(&eid);
4724        }
4725        self.recompute_intra_runtime_routes();
4726        if let Ok(mut v) = self.announced_pubs.lock() {
4727            v.retain(|p| p.key.entity_id != eid);
4728        }
4729        self.send_endpoint_dispose(eid, true);
4730    }
4731
4732    /// Tears down a deleted local user **reader** — symmetric to
4733    /// [`Self::remove_user_writer`] (drops the slot, rebuilds routes, stops
4734    /// announcing, and disposes the SEDP subscription).
4735    pub(crate) fn remove_user_reader(&self, eid: EntityId) {
4736        if let Ok(mut r) = self.user_readers.write() {
4737            r.remove(&eid);
4738        }
4739        self.recompute_intra_runtime_routes();
4740        if let Ok(mut v) = self.announced_subs.lock() {
4741            v.retain(|s| s.key.entity_id != eid);
4742        }
4743        self.send_endpoint_dispose(eid, false);
4744    }
4745
4746    /// Sends the SEDP dispose datagrams for a deleted endpoint, mirroring the
4747    /// send path of `announce_publication` (secure-wrap + routable-locator
4748    /// filter + metatraffic unicast socket).
4749    fn send_endpoint_dispose(&self, eid: EntityId, is_publication: bool) {
4750        let guid = Guid::new(self.guid_prefix, eid);
4751        let datagrams = {
4752            let mut sedp = match self.sedp.lock() {
4753                Ok(s) => s,
4754                Err(_) => return,
4755            };
4756            #[cfg(feature = "security")]
4757            self.sync_sedp_discovery_protected(&mut sedp);
4758            let res = if is_publication {
4759                sedp.dispose_publication(guid)
4760            } else {
4761                sedp.dispose_subscription(guid)
4762            };
4763            match res {
4764                Ok(d) => d,
4765                Err(_) => return,
4766            }
4767        };
4768        for dg in datagrams {
4769            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
4770                for t in dg.targets.iter() {
4771                    if is_routable_user_locator(t) {
4772                        let _ = self.spdp_unicast.send(t, &secured);
4773                    }
4774                }
4775            }
4776        }
4777    }
4778
4779    /// Unmatches a remote **writer** from every local user reader — the inverse
4780    /// of `wire_reader_to_remote_writer`/`add_writer_proxy` (`runtime.rs:5661`).
4781    ///
4782    /// Called when a remote writer is lost: SEDP dispose of its publication
4783    /// (the immediate driver), and — once wired — also participant-lost and a
4784    /// liveliness-lease expiry. ZeroDDS previously had **no** unmatch path at
4785    /// all (the matching subsystem was add-only), so a deleted remote writer
4786    /// lingered as a stale `WriterProxy` until the local reader was itself
4787    /// dropped. Removing the proxy here makes the reader's
4788    /// `subscription_matched_status` / `discovered_publications` drop
4789    /// immediately instead of after the liveliness timeout.
4790    ///
4791    /// Idempotent: a GUID that matches no local reader is a silent no-op.
4792    pub(crate) fn remove_remote_writer(&self, guid: Guid) {
4793        for (reader_eid, r_arc) in self.reader_slots_snapshot() {
4794            let Ok(mut slot) = r_arc.lock() else { continue };
4795            if slot.reader.remove_writer_proxy(guid).is_some() {
4796                // Drop the per-writer caches keyed by the remote GUID so a
4797                // later writer reusing the same GUID starts clean (ownership
4798                // strength + intra-runtime liveliness tracking).
4799                let key = guid.to_bytes();
4800                slot.writer_strengths.remove(&key);
4801                slot.liveliness_alive_writers.remove(&key);
4802                // Release the same-host SHM pairing, if one was registered.
4803                let local_reader_guid = Guid::new(self.guid_prefix, reader_eid);
4804                self.same_host.remove(guid, local_reader_guid);
4805            }
4806        }
4807    }
4808
4809    /// Unmatches a remote **reader** from every local user writer — the inverse
4810    /// of `wire_writer_to_remote_reader`/`add_reader_proxy`. Symmetric to
4811    /// [`Self::remove_remote_writer`]; driven by an SEDP dispose of the remote
4812    /// subscription. Idempotent.
4813    pub(crate) fn remove_remote_reader(&self, guid: Guid) {
4814        for (_writer_eid, w_arc) in self.writer_slots_snapshot() {
4815            let Ok(mut slot) = w_arc.lock() else { continue };
4816            slot.writer.remove_reader_proxy(guid);
4817        }
4818    }
4819
4820    /// Rebuilds the same-runtime writer→reader routing table.
4821    /// Called in `register_user_writer_kind` and `register_user_reader_kind`
4822    /// after every endpoint create, and on endpoint removal via
4823    /// [`Self::remove_user_writer`] / [`Self::remove_user_reader`]. Per local
4824    /// writer it collects all local readers that have exactly the same
4825    /// `topic_name` and `type_name`. The lookup in the write hot path
4826    /// (`write_user_sample_borrowed`) is read-locked and cheap
4827    /// (BTreeMap lookup → Vec clone).
4828    fn recompute_intra_runtime_routes(&self) {
4829        let writer_snap = self.writer_slots_snapshot();
4830        let reader_snap = self.reader_slots_snapshot();
4831        let mut new_map: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
4832        // QR-cluster (b): writers whose route gained a new reader and which are
4833        // TransientLocal must replay their retained history to those readers.
4834        // (writer_eid, reader_eid) pairs collected here, replayed after the
4835        // routing lock is released.
4836        let mut replay_targets: Vec<(EntityId, EntityId)> = Vec::new();
4837        for (writer_eid, w_arc) in writer_snap {
4838            let (w_topic, w_type, w_partition, w_transient_local) = match w_arc.lock() {
4839                Ok(s) => (
4840                    s.topic_name.clone(),
4841                    s.type_name.clone(),
4842                    s.partition.clone(),
4843                    !matches!(s.durability, zerodds_qos::DurabilityKind::Volatile),
4844                ),
4845                Err(_) => continue,
4846            };
4847            let mut readers: Vec<EntityId> = Vec::new();
4848            for (reader_eid, r_arc) in &reader_snap {
4849                let matches = match r_arc.lock() {
4850                    Ok(s) => {
4851                        s.topic_name == w_topic
4852                            && s.type_name == w_type
4853                            // QR-cluster (c): PARTITION gates the same-runtime
4854                            // match exactly as on the wire (DDS 1.4 §2.2.3.13).
4855                            && partitions_overlap(&w_partition, &s.partition)
4856                    }
4857                    Err(_) => false,
4858                };
4859                if matches {
4860                    readers.push(*reader_eid);
4861                    // Schedule a TransientLocal replay if this writer has not yet
4862                    // replayed to this reader.
4863                    if w_transient_local {
4864                        let already = w_arc
4865                            .lock()
4866                            .map(|s| s.intra_replayed_readers.contains(reader_eid))
4867                            .unwrap_or(true);
4868                        if !already {
4869                            replay_targets.push((writer_eid, *reader_eid));
4870                        }
4871                    }
4872                }
4873            }
4874            if !readers.is_empty() {
4875                new_map.insert(writer_eid, readers);
4876            }
4877        }
4878        // Perform the TransientLocal retained-sample replay to each new reader
4879        // (DDS 1.4 §2.2.3.4 late-joiner delivery). Done outside the routing
4880        // lock; the per-writer slot lock guards `retained` + the
4881        // already-replayed dedup set.
4882        for (writer_eid, reader_eid) in replay_targets {
4883            self.intra_runtime_replay_retained(writer_eid, reader_eid);
4884        }
4885        let changed = match self.intra_runtime_routes.write() {
4886            Ok(mut g) => {
4887                let changed = *g != new_map;
4888                *g = new_map;
4889                changed
4890            }
4891            Err(_) => false,
4892        };
4893        // A new/changed intra-runtime route is a same-participant
4894        // match → wake the `wait_for_matched_{subscription,publication}` waiter
4895        // (the matched count now includes these routes).
4896        if changed {
4897            self.match_event.1.notify_all();
4898        }
4899    }
4900
4901    /// QR-cluster (b): replays a TransientLocal writer's retained samples
4902    /// (DDS 1.4 §2.2.3.4) to a single late-joining intra-runtime reader. Each
4903    /// retained Alive entry is delivered as `UserSample::Alive`; each terminal
4904    /// lifecycle entry as `UserSample::Lifecycle`, so the reader's instance
4905    /// state reflects the most recent NOT_ALIVE_DISPOSED / NOT_ALIVE_NO_WRITERS.
4906    /// Idempotent via the per-writer `intra_replayed_readers` set.
4907    fn intra_runtime_replay_retained(&self, writer_eid: EntityId, reader_eid: EntityId) {
4908        // Snapshot retained under the writer lock, mark replayed, then release.
4909        let samples: Vec<RetainedSample> = {
4910            let Some(w_arc) = self.writer_slot(writer_eid) else {
4911                return;
4912            };
4913            let Ok(mut w) = w_arc.lock() else {
4914                return;
4915            };
4916            if !w.intra_replayed_readers.insert(reader_eid) {
4917                return; // already replayed to this reader
4918            }
4919            w.retained.iter().cloned().collect()
4920        };
4921        if samples.is_empty() {
4922            return;
4923        }
4924        let Some(r_arc) = self.reader_slot(reader_eid) else {
4925            return;
4926        };
4927        let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
4928        let (listener, waker, sender) = {
4929            let Ok(r) = r_arc.lock() else {
4930                return;
4931            };
4932            (
4933                r.listener.clone(),
4934                Arc::clone(&r.async_waker),
4935                r.sample_tx.clone(),
4936            )
4937        };
4938        for s in samples {
4939            match s.lifecycle {
4940                None => {
4941                    if let Some(l) = &listener {
4942                        // Durability replay is little-endian (the store does not
4943                        // retain the original byte order) → big_endian = 0.
4944                        l(&s.payload, s.representation, 0);
4945                    } else {
4946                        let sample = UserSample::Alive {
4947                            payload: crate::sample_bytes::SampleBytes::from_vec(s.payload.clone()),
4948                            writer_guid,
4949                            writer_strength: s.strength,
4950                            representation: s.representation,
4951                            // Durability replay: the store does not yet retain
4952                            // the original byte order (ZeroDDS-internal samples
4953                            // are little-endian); a big-endian peer's durable
4954                            // sample would replay LE. Tracked as a durability
4955                            // followup, not part of the live RTPS BE path.
4956                            big_endian: false,
4957                            // Durability replay: original source timestamp not
4958                            // retained in the store today → reception order.
4959                            source_timestamp: None,
4960                        };
4961                        let _ = sender.send(sample);
4962                        wake_async_waker(&waker);
4963                    }
4964                }
4965                Some(kind) => {
4966                    // Lifecycle markers always go to the MPSC channel (the
4967                    // alive-only listener does not carry instance state).
4968                    let _ = sender.send(UserSample::Lifecycle {
4969                        key_hash: s.key_hash,
4970                        kind,
4971                    });
4972                    wake_async_waker(&waker);
4973                }
4974            }
4975        }
4976    }
4977
4978    /// QR-cluster (d): delivers a lifecycle marker (dispose / unregister) to all
4979    /// matched intra-runtime readers (DDS 1.4 §2.2.2.4.2.10 / §2.2.2.4.2.7) so
4980    /// their instance state becomes NOT_ALIVE_DISPOSED / NOT_ALIVE_NO_WRITERS,
4981    /// and records it in the writer's retained buffer so a later late joiner
4982    /// also observes the terminal state. The wire path is handled separately by
4983    /// [`Self::write_user_lifecycle`].
4984    fn intra_runtime_dispatch_lifecycle(
4985        &self,
4986        writer_eid: EntityId,
4987        key_hash: [u8; 16],
4988        kind: zerodds_rtps::history_cache::ChangeKind,
4989    ) {
4990        // Record in retained (terminal marker for the key) so future late
4991        // joiners observe the NOT_ALIVE state.
4992        if let Some(w_arc) = self.writer_slot(writer_eid) {
4993            if let Ok(mut w) = w_arc.lock() {
4994                if !matches!(w.durability, zerodds_qos::DurabilityKind::Volatile) {
4995                    // Replace any prior terminal marker for this key; keep the
4996                    // retained alive samples (the reader saw them already, but a
4997                    // brand-new late joiner needs both the data and the state).
4998                    w.retained
4999                        .retain(|s| !(s.lifecycle.is_some() && s.key_hash == key_hash));
5000                    w.retained.push_back(RetainedSample {
5001                        key_hash,
5002                        payload: Vec::new(),
5003                        representation: 0,
5004                        strength: 0,
5005                        lifecycle: Some(kind),
5006                    });
5007                }
5008            }
5009        }
5010        let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
5011            Ok(g) => match g.get(&writer_eid) {
5012                Some(v) => v.clone(),
5013                None => return,
5014            },
5015            Err(_) => return,
5016        };
5017        for reader_eid in routes {
5018            let Some(slot_arc) = self.reader_slot(reader_eid) else {
5019                continue;
5020            };
5021            let (waker, sender) = {
5022                let Ok(slot) = slot_arc.lock() else {
5023                    continue;
5024                };
5025                (Arc::clone(&slot.async_waker), slot.sample_tx.clone())
5026            };
5027            let _ = sender.send(UserSample::Lifecycle { key_hash, kind });
5028            wake_async_waker(&waker);
5029        }
5030    }
5031
5032    /// Same-runtime direct dispatch: pushes the just-written
5033    /// sample directly into the `sample_tx` channel of all local readers
5034    /// on the same topic+type. Avoids an RTPS wire roundtrip + UDP
5035    /// loopback for the bridge-daemon case (writer+reader in the same
5036    /// `DcpsRuntime`). Called by the write hot path after the normal
5037    /// wire dispatch.
5038    fn intra_runtime_dispatch_alive(
5039        &self,
5040        writer_eid: EntityId,
5041        payload: &[u8],
5042        writer_strength: i32,
5043        // XCDR version tag of the writer's effective offer (`0` = XCDR1,
5044        // `1` = XCDR2), matching `encap_representation`'s convention on the
5045        // wire-receive path. On the wire path the reader recovers this from
5046        // byte[1] of the encap header; here the intra-runtime payload carries
5047        // no encap header, so the writer's actual representation must be
5048        // threaded through explicitly (Bug R4 — previously hardcoded `0`,
5049        // losing the XCDR version on the same-runtime loopback path).
5050        representation: u8,
5051    ) {
5052        let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
5053            Ok(g) => match g.get(&writer_eid) {
5054                Some(v) => v.clone(),
5055                None => return,
5056            },
5057            Err(_) => return,
5058        };
5059        if routes.is_empty() {
5060            return;
5061        }
5062        let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
5063        // QR-cluster (e): LIVELINESS AUTOMATIC auto-renew. A delivered sample
5064        // proves the matched writer is alive (DDS 1.4 §2.2.3.11). For AUTOMATIC
5065        // kind the infrastructure renews liveliness implicitly, so each
5066        // intra-runtime delivery marks the writer alive at the reader.
5067        let writer_liveliness_automatic = self
5068            .writer_slot(writer_eid)
5069            .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_kind))
5070            .map(|k| matches!(k, zerodds_qos::LivelinessKind::Automatic))
5071            .unwrap_or(false);
5072        for reader_eid in routes {
5073            let Some(slot_arc) = self.reader_slot(reader_eid) else {
5074                continue;
5075            };
5076            // Hold the slot lock only for the listener/sender clone, dispatch
5077            // outside (symmetric to the data-receive path above, which
5078            // preserves exactly the same order in the DATA arm).
5079            let listener;
5080            let waker;
5081            let sender;
5082            {
5083                let Ok(mut slot) = slot_arc.lock() else {
5084                    continue;
5085                };
5086                // Liveliness renew: bump alive_count once per writer-alive
5087                // transition (the reader sees this writer become alive).
5088                if writer_liveliness_automatic {
5089                    let newly_alive = slot.liveliness_alive_writers.insert(writer_guid);
5090                    if newly_alive {
5091                        slot.liveliness_alive = true;
5092                        slot.liveliness_alive_count = slot.liveliness_alive_count.saturating_add(1);
5093                    }
5094                }
5095                listener = slot.listener.clone();
5096                waker = Arc::clone(&slot.async_waker);
5097                sender = slot.sample_tx.clone();
5098            }
5099            // Listener and MPSC are exclusive (see the data-arm comment):
5100            // if a listener is set, the sample only goes to it;
5101            // otherwise to the MPSC receiver.
5102            if let Some(l) = listener {
5103                // The listener signature is `(payload, representation, big_endian)`.
5104                // Intra-runtime: no encap header, so carry the writer's
5105                // actual representation tag (Bug R4); same-process delivery is
5106                // always native little-endian → big_endian = 0.
5107                l(payload, representation, 0);
5108            } else {
5109                let sample = UserSample::Alive {
5110                    payload: crate::sample_bytes::SampleBytes::from_vec(payload.to_vec()),
5111                    writer_guid,
5112                    writer_strength,
5113                    representation,
5114                    // Intra-runtime same-process delivery always produces the
5115                    // native little-endian wire.
5116                    big_endian: false,
5117                    // Intra-runtime same-process delivery bypasses the INFO_TS
5118                    // wire path → reception order.
5119                    source_timestamp: None,
5120                };
5121                let _ = sender.send(sample);
5122                wake_async_waker(&waker);
5123            }
5124        }
5125    }
5126
5127    /// On registration / SEDP event: for a local writer `eid`
5128    /// go through all subscriptions known in the cache; on a topic+type
5129    /// match add a `ReaderProxy` to the local ReliableWriter.
5130    fn match_local_writer_against_cache(&self, eid: EntityId) {
5131        let (topic, type_name) = {
5132            let Some(arc) = self.writer_slot(eid) else {
5133                return;
5134            };
5135            let Ok(s) = arc.lock() else {
5136                return;
5137            };
5138            (s.topic_name.clone(), s.type_name.clone())
5139        };
5140        let (matches, conflict): (Vec<_>, bool) = {
5141            let sedp = match self.sedp.lock() {
5142                Ok(s) => s,
5143                Err(_) => return,
5144            };
5145            let matches = sedp
5146                .cache()
5147                .match_subscriptions(&topic, &type_name)
5148                .map(|s| s.data.clone())
5149                .collect();
5150            let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
5151            (matches, conflict)
5152        };
5153        if conflict {
5154            self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
5155        }
5156        for sub in matches {
5157            self.wire_writer_to_remote_reader(eid, &sub);
5158        }
5159    }
5160
5161    fn match_local_reader_against_cache(&self, eid: EntityId) {
5162        let (topic, type_name) = {
5163            let Some(arc) = self.reader_slot(eid) else {
5164                return;
5165            };
5166            let Ok(s) = arc.lock() else {
5167                return;
5168            };
5169            (s.topic_name.clone(), s.type_name.clone())
5170        };
5171        let (matches, conflict): (Vec<_>, bool) = {
5172            let sedp = match self.sedp.lock() {
5173                Ok(s) => s,
5174                Err(_) => return,
5175            };
5176            let matches = sedp
5177                .cache()
5178                .match_publications(&topic, &type_name)
5179                .map(|p| p.data.clone())
5180                .collect();
5181            let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
5182            (matches, conflict)
5183        };
5184        if conflict {
5185            self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
5186        }
5187        for pubd in matches {
5188            self.wire_reader_to_remote_writer(eid, &pubd);
5189        }
5190    }
5191
5192    fn wire_writer_to_remote_reader(
5193        &self,
5194        writer_eid: EntityId,
5195        sub: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
5196    ) {
5197        // §2.2.2.2.1.16: an ignored subscription must not be MATCHED (symmetric
5198        // to the publication gate in `wire_reader_to_remote_writer`). The
5199        // Durability-Service ignores its own ingest reader here so the replay
5200        // writer never delivers back to it (echo loop).
5201        if let Some(filter) = self.ignore_filter_snapshot() {
5202            let sub_h = crate::instance_handle::InstanceHandle::from_guid(sub.key);
5203            let part_h = crate::instance_handle::InstanceHandle::from_guid(sub.participant_key);
5204            if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
5205                return;
5206            }
5207        }
5208        let locators =
5209            endpoint_or_default_locators(&sub.unicast_locators, sub.key.prefix, &self.discovered);
5210        if locators.is_empty() {
5211            return;
5212        }
5213        // Backend replay datagrams (Spec §2.2.3.5). Sent after
5214        // the slot-lock release, so the send path does not run under
5215        // the slot mutex.
5216        let mut replay_dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram> = Vec::new();
5217        if let Some(slot_arc) = self.writer_slot(writer_eid) {
5218            if let Ok(mut slot) = slot_arc.lock() {
5219                let slot = &mut *slot;
5220                // Idempotency gate: if a ReaderProxy already exists for this
5221                // remote reader, the match has already run
5222                // once. A re-wire (e.g. when the SEDP announcement
5223                // arrives at the writer both via the in-process fastpath and via UDP)
5224                // would REPLACE the proxy via
5225                // `add_reader_proxy` — and thereby reset
5226                // `highest_acked_sn`/`highest_sent_sn`.
5227                // The next tick then emits an invalid HEARTBEAT
5228                // with `first_sn > last_sn` (cache_min=N, highest_acked+1=N+1),
5229                // the reader interprets this as "everything before first_sn is
5230                // lost" and advances `delivered_up_to` past not-yet-
5231                // delivered backend replay samples (tests
5232                // `{transient,persistent}_late_joiner_receives_backend_replay`
5233                // — 3% flake without the gate).
5234                if slot
5235                    .writer
5236                    .reader_proxies()
5237                    .iter()
5238                    .any(|p| p.remote_reader_guid == sub.key)
5239                {
5240                    return;
5241                }
5242                // --- QoS-Compatibility ---
5243                // Spec OMG DDS 1.4 §2.2.3.6: Writer offered >= Reader requested.
5244                //
5245                // Per reject, bump the responsible policy ID in
5246                // `offered_incompatible_qos.policies`, so the
5247                // DataWriter listener is triggered via `dispatch_offered_incompatible_qos`.
5248                // We track the *first* faulty
5249                // policy as `last_policy_id` (Spec §2.2.4.1: most-recent).
5250                use crate::psm_constants::qos_policy_id as qid;
5251                use crate::status::bump_policy_count;
5252                // C2 "loud instead of silent": an incompatible QoS match is
5253                // not only kept as a pollable status (Spec §2.2.4.1),
5254                // but logged loudly IMMEDIATELY. The central ROS-DDS
5255                // pain point is that QoS mismatches are silently discarded
5256                // (e.g. Cyclone's `DDS_INVALID_QOS_POLICY_ID` without a
5257                // log) — exactly that made the ROS-2 entityKind diagnosis so
5258                // expensive. The reject names the topic, remote reader and
5259                // the exact policy.
5260                let obs = self.config.observability.clone();
5261                let topic_for_log = slot.topic_name.clone();
5262                let remote_for_log = alloc::format!("{:?}", sub.key);
5263                let bump = |slot: &mut UserWriterSlot, pid: u32| {
5264                    slot.offered_incompatible_qos.total_count =
5265                        slot.offered_incompatible_qos.total_count.saturating_add(1);
5266                    slot.offered_incompatible_qos.last_policy_id = pid;
5267                    bump_policy_count(&mut slot.offered_incompatible_qos.policies, pid);
5268                    obs.record(
5269                        &zerodds_foundation::observability::Event::new(
5270                            zerodds_foundation::observability::Level::Warn,
5271                            zerodds_foundation::observability::Component::Dcps,
5272                            "qos.incompatible.offered",
5273                        )
5274                        .with_attr("topic", topic_for_log.as_str())
5275                        .with_attr("remote_reader", remote_for_log.as_str())
5276                        .with_attr("policy", qos_policy_id_name(pid)),
5277                    );
5278                };
5279
5280                // Durability rank: Volatile < TransientLocal < Transient <
5281                // Persistent. The writer may offer more than the reader requests.
5282                if (slot.durability as u8) < (sub.durability as u8) {
5283                    bump(slot, qid::DURABILITY);
5284                    return;
5285                }
5286                // Deadline: writer period <= reader period (the writer promises
5287                // to write faster than the reader expects).
5288                if !deadline_compat(
5289                    slot.deadline_nanos,
5290                    qos_duration_to_nanos(sub.deadline.period),
5291                ) {
5292                    bump(slot, qid::DEADLINE);
5293                    return;
5294                }
5295                // Liveliness-Kind: Automatic < ManualByParticipant < ManualByTopic.
5296                // Writer-Kind >= Reader-Kind. Lease: writer.lease <= reader.lease.
5297                if (slot.liveliness_kind as u8) < (sub.liveliness.kind as u8) {
5298                    bump(slot, qid::LIVELINESS);
5299                    return;
5300                }
5301                if !deadline_compat(
5302                    slot.liveliness_lease_nanos,
5303                    qos_duration_to_nanos(sub.liveliness.lease_duration),
5304                ) {
5305                    bump(slot, qid::LIVELINESS);
5306                    return;
5307                }
5308                // Ownership: both must be equal (Spec §2.2.3.6 Table:
5309                // no "compatible" case except exactly equal).
5310                if slot.ownership != sub.ownership {
5311                    bump(slot, qid::OWNERSHIP);
5312                    return;
5313                }
5314                // Partition: at least one common partition — or
5315                // both empty (default partition "").
5316                if !partitions_overlap(&slot.partition, &sub.partition) {
5317                    bump(slot, qid::PARTITION);
5318                    return;
5319                }
5320                // F-TYPES-3 XTypes-1.3 §7.6.3.7 symmetric writer-side check.
5321                // If both sides carry a TypeIdentifier (≠ None),
5322                // we check compatibility. The reader's TCE policy is not
5323                // directly available here; we take the default TCE
5324                // (AllowTypeCoercion without PreventWidening) — the reader-
5325                // side check in `wire_reader_to_remote_writer` validates
5326                // with the real reader TCE.
5327                if slot.type_identifier != zerodds_types::TypeIdentifier::None
5328                    && sub.type_identifier != zerodds_types::TypeIdentifier::None
5329                    // Equal TypeIdentifiers are by definition the same type
5330                    // (XTypes 1.3 §7.2.4.1 identity). This is the typed-endpoint
5331                    // case: writer + reader of the same generated type carry the
5332                    // same (possibly complete) TypeIdentifier, whose TypeObject
5333                    // is NOT in this fresh registry. Without this short-circuit a
5334                    // complete-hash type-id would fail the assignability lookup
5335                    // (Bug QT). Skip the registry-backed structural check when the
5336                    // ids are identical.
5337                    && slot.type_identifier != sub.type_identifier
5338                {
5339                    let registry = zerodds_types::resolve::TypeRegistry::new();
5340                    let tce = zerodds_types::qos::TypeConsistencyEnforcement::default();
5341                    let matcher = zerodds_types::type_matcher::TypeMatcher::new(&tce);
5342                    if !matcher
5343                        .match_types(&slot.type_identifier, &sub.type_identifier, &registry)
5344                        .is_match()
5345                    {
5346                        bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5347                        return;
5348                    }
5349                }
5350
5351                let mut proxy = zerodds_rtps::reader_proxy::ReaderProxy::new(
5352                    sub.key,
5353                    locators.clone(),
5354                    Vec::new(),
5355                    slot.reliable,
5356                );
5357                // D.5g — Per-Peer DataRepresentation negotiation
5358                // (XTypes 1.3 §7.6.3.1.2). Writer-offered = Per-Writer-
5359                // Override (slot.data_rep_offer_override) ODER Runtime-
5360                // Default. Reader-accepted = sub.data_representation
5361                // (spec default `[XCDR1]` if empty). Match mode from
5362                // RuntimeConfig.
5363                {
5364                    use zerodds_rtps::publication_data::data_representation as dr;
5365                    let writer_offered: Vec<i16> = slot
5366                        .data_rep_offer_override
5367                        .clone()
5368                        .unwrap_or_else(|| self.config.data_representation_offer.clone());
5369                    let mode = self.config.data_rep_match_mode;
5370                    if let Some(negotiated) =
5371                        dr::negotiate(&writer_offered, &sub.data_representation, mode)
5372                    {
5373                        proxy.set_negotiated_data_representation(negotiated);
5374                    } else {
5375                        // No overlap → SEDP match spec violation.
5376                        // We add the proxy anyway for best-effort
5377                        // compat; the wire-format default stays XCDR2.
5378                        // A spec-strict caller should reject the match.
5379                    }
5380                }
5381                // Spec §2.2.3.4 Tab. 16: cache replay suppression. For
5382                // Volatile the reader must not see any late-joiner history
5383                // → skip up to `cache.max_sn`. For Transient/Persistent
5384                // the backend is authoritative — we deliver the history
5385                // via the backend replay path with NEW SNs; the
5386                // writer's own cache (especially gappy under KeepLast
5387                // eviction) must not serve the reader twice.
5388                // TransientLocal is the only tier where the
5389                // writer cache is the real history anchor.
5390                if !matches!(slot.durability, zerodds_qos::DurabilityKind::TransientLocal) {
5391                    if let Some(max) = slot.writer.cache().max_sn() {
5392                        proxy.skip_samples_up_to(max);
5393                    }
5394                }
5395                // Spec §2.2.3.5 — Durability=Transient/Persistent:
5396                // on the first late-joiner match, re-inject the backend samples
5397                // into the HistoryCache. The existing
5398                // reliable-reader path then delivers them via DATA +
5399                // heartbeat/AckNack. Idempotent via the
5400                // `backend_primed` flag.
5401                let backend_writes: Vec<Vec<u8>> = if !slot.backend_primed
5402                    && (slot.durability == zerodds_qos::DurabilityKind::Transient
5403                        || slot.durability == zerodds_qos::DurabilityKind::Persistent)
5404                {
5405                    slot.durability_backend
5406                        .as_ref()
5407                        .and_then(|b| b.replay_for_topic(&slot.topic_name).ok())
5408                        .unwrap_or_default()
5409                        .into_iter()
5410                        .map(|s| s.payload)
5411                        .collect()
5412                } else {
5413                    Vec::new()
5414                };
5415                slot.writer.add_reader_proxy(proxy);
5416                // Path-MTU-aware fragmentation: if ALL matched
5417                // readers run on the same host, traffic goes via
5418                // loopback (MTU 65536) — then one datagram per sample
5419                // instead of N 1344-B fragments (halves the 8-kB roundtrip
5420                // latency). As soon as a reader is remote, it stays
5421                // Ethernet-safe at DEFAULT_FRAGMENT_SIZE, so no
5422                // oversized datagram gets IP-fragmented on the 1500-byte
5423                // path.
5424                let all_same_host = slot
5425                    .writer
5426                    .reader_proxies()
5427                    .iter()
5428                    .all(|p| self.guid_prefix.is_same_host(p.remote_reader_guid.prefix));
5429                if all_same_host {
5430                    slot.writer
5431                        .set_fragmentation(LOOPBACK_FRAGMENT_SIZE, LOOPBACK_MTU);
5432                } else {
5433                    slot.writer
5434                        .set_fragmentation(DEFAULT_FRAGMENT_SIZE, DEFAULT_MTU);
5435                }
5436                // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): if the
5437                // remote reader runs on the same host (matching
5438                // GuidPrefix host-id, wave 4a), register the pair in the
5439                // SameHostTracker. Wave 4b.3 (feature `same-host-shm`):
5440                // additionally try to set up a PosixShmTransport owner
5441                // segment — on success `mark_bound(Owner)`,
5442                // otherwise `mark_failed` and UDP fallback.
5443                if self.guid_prefix.is_same_host(sub.key.prefix) {
5444                    let local_writer_guid =
5445                        zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5446                    self.same_host.register_pending(local_writer_guid, sub.key);
5447                    #[cfg(feature = "same-host-shm")]
5448                    {
5449                        match crate::same_host_shm::open_owner_segment(
5450                            self.guid_prefix,
5451                            local_writer_guid,
5452                            sub.key,
5453                        ) {
5454                            Ok(t) => self.same_host.mark_bound(
5455                                local_writer_guid,
5456                                sub.key,
5457                                t,
5458                                crate::same_host::Role::Owner,
5459                            ),
5460                            Err(reason) => {
5461                                self.same_host
5462                                    .mark_failed(local_writer_guid, sub.key, reason)
5463                            }
5464                        }
5465                    }
5466                }
5467                // Inject the backend replay into the HistoryCache (within
5468                // the slot lock). Important: with `KeepLast(N)` and a small N
5469                // the cache would immediately evict every replay sample
5470                // again — the subsequent writer tick then sees
5471                // SN=4,5 as "not in cache" and sends GAPs to the
5472                // reader, which marks our replay samples as irrelevant.
5473                // Solution: temporarily expand the cache to `KeepAll` with
5474                // a sufficient cap, for the duration of the
5475                // burst, then restore the user QoS.
5476                // Backend samples are in **raw** format (that is how
5477                // `DataWriter::write` in publisher.rs stores them) — before the
5478                // writer.write we must prepend the USER_PAYLOAD_ENCAP framing,
5479                // so the reader recognizes the stream value spec-conformantly
5480                // (see `validate_user_encap_offset`).
5481                let now_replay = self.start_instant.elapsed();
5482                if !backend_writes.is_empty() {
5483                    // Same encap header as in the live-write path
5484                    // (offer `first` + extensibility), so replay samples
5485                    // declare the same wire encoding.
5486                    let replay_encap = {
5487                        let offer_first = slot
5488                            .data_rep_offer_override
5489                            .as_ref()
5490                            .and_then(|v| v.first().copied())
5491                            .or_else(|| self.config.data_representation_offer.first().copied())
5492                            .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
5493                        user_payload_encap(
5494                            offer_first,
5495                            slot.wire_extensibility,
5496                            slot.big_endian_override,
5497                        )
5498                    };
5499                    let original_kind = slot.writer.cache().kind();
5500                    let original_max = slot.writer.cache().max_samples();
5501                    let burst_max = original_max
5502                        .saturating_add(backend_writes.len())
5503                        .max(backend_writes.len() + 16);
5504                    slot.writer.set_cache_kind_and_max(
5505                        zerodds_rtps::history_cache::HistoryKind::KeepAll,
5506                        burst_max,
5507                    );
5508                    for raw_payload in &backend_writes {
5509                        let mut framed = Vec::with_capacity(replay_encap.len() + raw_payload.len());
5510                        framed.extend_from_slice(&replay_encap);
5511                        framed.extend_from_slice(raw_payload);
5512                        if let Ok(out) = slot.writer.write_with_heartbeat(&framed, now_replay) {
5513                            replay_dgs.extend(out);
5514                        }
5515                    }
5516                    slot.writer
5517                        .set_cache_kind_and_max(original_kind, original_max);
5518                    slot.backend_primed = true;
5519                }
5520                // D.5e Phase-1: wake `wait_for_matched_subscription`-waiters.
5521                self.match_event.1.notify_all();
5522
5523                // Security: derive the per-reader protection level from
5524                // security_info and build the locator lookup map,
5525                // so the writer tick can serialize per target
5526                // individually.
5527                #[cfg(feature = "security")]
5528                {
5529                    let peer_key = sub.key.prefix.0;
5530                    // Set the per-reader level ONLY for an EXPLICITLY announced
5531                    // `PID_ENDPOINT_SECURITY_INFO`. If it is missing (OpenDDS does not
5532                    // send it — it relies on the domain governance), NO
5533                    // None override: then the governance `data_protection` FLOOR
5534                    // applies in `secure_outbound_for_target`. An authenticated peer
5535                    // in a data_protection=ENCRYPT domain expects the encrypted
5536                    // payload; a None override would leak plaintext (cyclone/
5537                    // FastDDS announce security_info → unchanged).
5538                    if let Some(info) = sub.security_info.as_ref() {
5539                        let level = EndpointProtection::from_info(Some(info)).level;
5540                        slot.reader_protection.insert(peer_key, level);
5541                    }
5542                    for loc in &locators {
5543                        slot.locator_to_peer.insert(*loc, peer_key);
5544                    }
5545                }
5546            }
5547        }
5548        // Send the backend replay datagrams (Spec §2.2.3.5). The slot mutex
5549        // is released here; the send path mirrors the pattern from
5550        // `write_user_sample` — including the in-process fastpath for
5551        // same-process peers (otherwise UDP loopback loss under load can
5552        // swallow the Transient/Persistent replay samples).
5553        let inproc_peers: Vec<Arc<DcpsRuntime>> = {
5554            let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
5555            all.into_iter()
5556                .filter(|rt| rt.guid_prefix != self.guid_prefix)
5557                .collect()
5558        };
5559        let now_send = self.start_instant.elapsed();
5560        for dg in &replay_dgs {
5561            for t in dg.targets.iter() {
5562                if is_routable_user_locator(t) {
5563                    let _ = self.user_unicast.send(t, &dg.bytes);
5564                }
5565            }
5566            for peer in &inproc_peers {
5567                handle_user_datagram(peer, &dg.bytes, now_send);
5568            }
5569        }
5570        // Emit the match event outside the slot mutex.
5571        self.config.observability.record(
5572            &zerodds_foundation::observability::Event::new(
5573                zerodds_foundation::observability::Level::Info,
5574                zerodds_foundation::observability::Component::Discovery,
5575                "writer.matched_remote_reader",
5576            )
5577            .with_attr("writer_eid", alloc::format!("{writer_eid:?}")),
5578        );
5579    }
5580
5581    fn wire_reader_to_remote_writer(
5582        &self,
5583        reader_eid: EntityId,
5584        pubd: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
5585    ) {
5586        // §2.2.2.2.1.17: an ignored publication must not be MATCHED, not merely
5587        // hidden from the DCPSPublication builtin reader. The Durability-Service
5588        // relies on this to avoid ingesting its own replay writer (echo loop).
5589        if let Some(filter) = self.ignore_filter_snapshot() {
5590            let pub_h = crate::instance_handle::InstanceHandle::from_guid(pubd.key);
5591            let part_h = crate::instance_handle::InstanceHandle::from_guid(pubd.participant_key);
5592            if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
5593                return;
5594            }
5595        }
5596        let locators =
5597            endpoint_or_default_locators(&pubd.unicast_locators, pubd.key.prefix, &self.discovered);
5598        if locators.is_empty() {
5599            return;
5600        }
5601        if let Some(slot_arc) = self.reader_slot(reader_eid) {
5602            if let Ok(mut slot) = slot_arc.lock() {
5603                let slot = &mut *slot;
5604                // Idempotency gate (symmetric to
5605                // `wire_writer_to_remote_reader`): if a WriterProxy already
5606                // exists for this remote writer, the
5607                // match has already run. A re-wire via UDP SEDP after
5608                // an in-process pull would REPLACE via `add_writer_proxy` —
5609                // resetting `delivered_up_to`/`received` and
5610                // losing already-buffered/delivered samples.
5611                if slot
5612                    .reader
5613                    .writer_proxies()
5614                    .iter()
5615                    .any(|s| s.proxy.remote_writer_guid == pubd.key)
5616                {
5617                    return;
5618                }
5619                // Per-policy bump for requested_incompatible_qos.
5620                use crate::psm_constants::qos_policy_id as qid;
5621                use crate::status::bump_policy_count;
5622                // C2 "loud instead of silent" (symmetric to the writer side):
5623                // an incompatible QoS match is logged loudly immediately.
5624                let obs = self.config.observability.clone();
5625                let topic_for_log = slot.topic_name.clone();
5626                let remote_for_log = alloc::format!("{:?}", pubd.key);
5627                let bump = |slot: &mut UserReaderSlot, pid: u32| {
5628                    slot.requested_incompatible_qos.total_count = slot
5629                        .requested_incompatible_qos
5630                        .total_count
5631                        .saturating_add(1);
5632                    slot.requested_incompatible_qos.last_policy_id = pid;
5633                    bump_policy_count(&mut slot.requested_incompatible_qos.policies, pid);
5634                    obs.record(
5635                        &zerodds_foundation::observability::Event::new(
5636                            zerodds_foundation::observability::Level::Warn,
5637                            zerodds_foundation::observability::Component::Dcps,
5638                            "qos.incompatible.requested",
5639                        )
5640                        .with_attr("topic", topic_for_log.as_str())
5641                        .with_attr("remote_writer", remote_for_log.as_str())
5642                        .with_attr("policy", qos_policy_id_name(pid)),
5643                    );
5644                };
5645
5646                // See wire_writer... — symmetric, the writer is now remote.
5647                if (pubd.durability as u8) < (slot.durability as u8) {
5648                    bump(slot, qid::DURABILITY);
5649                    return;
5650                }
5651                if !deadline_compat(
5652                    qos_duration_to_nanos(pubd.deadline.period),
5653                    slot.deadline_nanos,
5654                ) {
5655                    bump(slot, qid::DEADLINE);
5656                    return;
5657                }
5658                if (pubd.liveliness.kind as u8) < (slot.liveliness_kind as u8) {
5659                    bump(slot, qid::LIVELINESS);
5660                    return;
5661                }
5662                if !deadline_compat(
5663                    qos_duration_to_nanos(pubd.liveliness.lease_duration),
5664                    slot.liveliness_lease_nanos,
5665                ) {
5666                    bump(slot, qid::LIVELINESS);
5667                    return;
5668                }
5669                if pubd.ownership != slot.ownership {
5670                    bump(slot, qid::OWNERSHIP);
5671                    return;
5672                }
5673                if !partitions_overlap(&pubd.partition, &slot.partition) {
5674                    bump(slot, qid::PARTITION);
5675                    return;
5676                }
5677
5678                // F-TYPES-3 XTypes-1.3 §7.6.3.7 TypeConsistencyEnforcement.
5679                // If both sides carry a TypeIdentifier (≠ None),
5680                // we check compatibility via the TypeMatcher. Otherwise
5681                // the match falls back to a pure type_name comparison (default path).
5682                if slot.type_identifier != zerodds_types::TypeIdentifier::None
5683                    && pubd.type_identifier != zerodds_types::TypeIdentifier::None
5684                    // Equal TypeIdentifiers ⇒ same type (XTypes 1.3 §7.2.4.1).
5685                    // The typed-endpoint case carries a complete TypeIdentifier
5686                    // whose TypeObject is not in this fresh registry; identity
5687                    // is decisive without a structural lookup (Bug QT).
5688                    && pubd.type_identifier != slot.type_identifier
5689                {
5690                    let registry = zerodds_types::resolve::TypeRegistry::new();
5691                    let matcher =
5692                        zerodds_types::type_matcher::TypeMatcher::new(&slot.type_consistency);
5693                    if !matcher
5694                        .match_types(&pubd.type_identifier, &slot.type_identifier, &registry)
5695                        .is_match()
5696                    {
5697                        bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5698                        return;
5699                    }
5700                }
5701
5702                slot.reader
5703                    .add_writer_proxy(zerodds_rtps::writer_proxy::WriterProxy::new(
5704                        pubd.key,
5705                        locators,
5706                        Vec::new(),
5707                        true,
5708                    ));
5709                // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): reader
5710                // side of the same-host match. If the remote writer runs on
5711                // the same host, register the pair AND
5712                // attach synchronously to the SHM segment.
5713                //
5714                // Idempotent: thanks to the `PosixShmTransport::open` refactor
5715                // (transport-shm bug fix 2026-05-19) it does not matter whether the
5716                // writer hook (open_owner) or the reader hook
5717                // (open_consumer) runs first — whoever comes first
5718                // creates the segment, whoever later attaches. Real-life
5719                // DDS has no guaranteed SEDP match order.
5720                if self.guid_prefix.is_same_host(pubd.key.prefix) {
5721                    let local_reader_guid =
5722                        zerodds_rtps::wire_types::Guid::new(self.guid_prefix, reader_eid);
5723                    self.same_host.register_pending(pubd.key, local_reader_guid);
5724                    #[cfg(feature = "same-host-shm")]
5725                    {
5726                        match crate::same_host_shm::open_consumer_segment(
5727                            self.guid_prefix,
5728                            pubd.key,
5729                            local_reader_guid,
5730                        ) {
5731                            Ok(t) => self.same_host.mark_bound(
5732                                pubd.key,
5733                                local_reader_guid,
5734                                t,
5735                                crate::same_host::Role::Consumer,
5736                            ),
5737                            Err(reason) => {
5738                                self.same_host
5739                                    .mark_failed(pubd.key, local_reader_guid, reason)
5740                            }
5741                        }
5742                    }
5743                }
5744                // D.5e Phase-1: wake `wait_for_matched_publication`-waiters.
5745                self.match_event.1.notify_all();
5746
5747                // §2.2.3.23 exclusive-ownership resolver cache:
5748                // remember the writer `ownership_strength` from discovery, so
5749                // `delivered_to_user_sample` can pack the value into every
5750                // sample.
5751                slot.writer_strengths
5752                    .insert(pubd.key.to_bytes(), pubd.ownership_strength);
5753            }
5754        }
5755    }
5756
5757    /// Writes a sample to a registered user writer and
5758    /// sends the generated datagrams.
5759    ///
5760    /// The payload is prefixed with the RTPS serialized-payload header
5761    /// (encapsulation scheme) before it goes into the DATA
5762    /// submessage. OMG RTPS 2.5 §9.4.2.13 requires exactly these
5763    /// 4 bytes at the start of every serialized user payload —
5764    /// see [`USER_PAYLOAD_ENCAP`] (`CDR_LE` / XCDR1).
5765    /// Without this header Cyclone/Fast-DDS readers refuse to
5766    /// deliver the sample (they parse the first 4 bytes as
5767    /// encapsulation kind + options and drop unknown-scheme).
5768    ///
5769    /// # Errors
5770    /// - `BadParameter` if the EntityId has no registered writer.
5771    /// - `WireError` on an encoding error.
5772    pub fn write_user_sample(&self, eid: EntityId, payload: Vec<u8>) -> Result<()> {
5773        // Vec-ownership API. The spec contract is unchanged. We delegate to
5774        // the borrowed variant; this saves a heap-allocation hop when
5775        // the caller already has a `&[u8]` (e.g. the C-FFI loan API).
5776        self.write_user_sample_borrowed(eid, &payload)
5777    }
5778
5779    /// Sets the per-writer data-representation override for a user writer. The
5780    /// next `write_user_sample*` derives its encapsulation header from this
5781    /// override's first element instead of the runtime default — so a
5782    /// representation-faithful re-publisher (e.g. the durability service
5783    /// replaying foreign-vendor XCDR1 bytes) can declare the encap that matches
5784    /// the body it holds. `None` clears the override (back to the runtime
5785    /// default). Idempotent + cheap; safe to call before every write.
5786    ///
5787    /// # Errors
5788    /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5789    /// poisoned slot lock.
5790    pub fn set_user_writer_data_rep_override(
5791        &self,
5792        eid: EntityId,
5793        offer: Option<Vec<i16>>,
5794    ) -> Result<()> {
5795        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5796            what: "unknown writer entity id",
5797        })?;
5798        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5799            reason: "user_writer slot poisoned",
5800        })?;
5801        slot.data_rep_offer_override = offer;
5802        Ok(())
5803    }
5804
5805    /// Forces the writer to emit the big-endian (`_BE`) encapsulation variant
5806    /// (RTPS 2.5 §10.5) instead of the little-endian default. Used by the
5807    /// durability service replay path: a big-endian peer's stored sample holds
5808    /// big-endian body bytes, so its replay must carry a matching BE encap
5809    /// header. `false` restores the canonical little-endian wire.
5810    ///
5811    /// # Errors
5812    /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5813    /// poisoned slot lock.
5814    pub fn set_user_writer_byte_order_override(
5815        &self,
5816        eid: EntityId,
5817        big_endian: bool,
5818    ) -> Result<()> {
5819        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5820            what: "unknown writer entity id",
5821        })?;
5822        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5823            reason: "user_writer slot poisoned",
5824        })?;
5825        slot.big_endian_override = big_endian;
5826        Ok(())
5827    }
5828
5829    /// Sets the HISTORY KeepLast depth (DDS 1.4 §2.2.3.18) for a user writer.
5830    /// This governs how many of the most-recent samples **per instance key**
5831    /// are retained for the same-runtime TransientLocal late-joiner replay path
5832    /// (`intra_runtime_dispatch_alive` retains, a new route replays). Pass
5833    /// `usize::MAX` for KeepAll. A binding maps its HistoryQosPolicy here.
5834    ///
5835    /// # Errors
5836    /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5837    /// poisoned slot lock.
5838    pub fn set_user_writer_history_depth(&self, eid: EntityId, depth: usize) -> Result<()> {
5839        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5840            what: "unknown writer entity id",
5841        })?;
5842        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5843            reason: "user_writer slot poisoned",
5844        })?;
5845        slot.history_depth = depth.max(1);
5846        // Re-enforce the new depth over the already-retained samples per key.
5847        let d = slot.history_depth;
5848        enforce_retained_depth(&mut slot.retained, d);
5849        Ok(())
5850    }
5851
5852    /// Reads the current TransientLocal retained-sample count for a user writer
5853    /// (test/introspection helper). `0` for an unknown writer.
5854    #[must_use]
5855    pub fn user_writer_retained_len(&self, eid: EntityId) -> usize {
5856        self.writer_slot(eid)
5857            .and_then(|arc| arc.lock().ok().map(|s| s.retained.len()))
5858            .unwrap_or(0)
5859    }
5860
5861    /// Writes a user sample from a borrowed byte slice.
5862    /// **Zero-copy path** for the loan API and SHM backend: avoids
5863    /// the Vec materialization when the caller holds a slot/stack buffer.
5864    ///
5865    /// Identical semantics to `write_user_sample`; it just takes no
5866    /// ownership of the buffer.
5867    ///
5868    /// # Errors
5869    /// As `write_user_sample`.
5870    pub fn write_user_sample_borrowed(&self, eid: EntityId, payload: &[u8]) -> Result<()> {
5871        self.write_user_sample_keyed(eid, payload, [0u8; 16])
5872    }
5873
5874    /// Like [`write_user_sample_borrowed`] but with an explicit 16-byte instance
5875    /// `key_hash` (DDS 1.4 §2.2.2.4.2 keyed topics). The key is used by the
5876    /// same-runtime TransientLocal retention path so KeepLast depth is enforced
5877    /// **per instance** and a late joiner replays the most-recent samples of
5878    /// every live instance (and any disposed/unregistered terminal marker).
5879    /// A binding that does not key its topic passes the all-zero key (one
5880    /// default instance), which is what `write_user_sample_borrowed` does.
5881    ///
5882    /// # Errors
5883    /// As [`write_user_sample_borrowed`].
5884    pub fn write_user_sample_keyed(
5885        &self,
5886        eid: EntityId,
5887        payload: &[u8],
5888        key_hash: [u8; 16],
5889    ) -> Result<()> {
5890        let _phase_guard = if phase_timing_enabled() {
5891            Some(PhaseTimer {
5892                start: std::time::Instant::now(),
5893                ns_acc: &PHASE_WRITE_USER_NS,
5894                calls_acc: &PHASE_WRITE_USER_CALLS,
5895            })
5896        } else {
5897            None
5898        };
5899        let pt_on = phase_timing_enabled();
5900        let pt_t0 = if pt_on {
5901            Some(std::time::Instant::now())
5902        } else {
5903            None
5904        };
5905        // Hot path: for small samples (<= 1.5 kB payload)
5906        // the encap framing is copied into a stack PoolBuffer —
5907        // zero heap touch in the framing step. Large samples fall
5908        // back to Vec.
5909        let now = self.start_instant.elapsed();
5910        let total = USER_PAYLOAD_ENCAP.len() + payload.len();
5911        let pt_t2_out: Option<std::time::Instant>;
5912        // XCDR version tag of the writer's effective offer (`0` = XCDR1,
5913        // `1` = XCDR2), set below from the same `offer_first` that drives the
5914        // wire encap header. Carried into the same-runtime loopback dispatch
5915        // so the intra-runtime reader sees the writer's real representation
5916        // (Bug R4) instead of an unconditional `0`.
5917        let intra_representation: u8;
5918        let out_datagrams = {
5919            let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5920                what: "unknown writer entity id",
5921            })?;
5922            let pt_t1 = if pt_on {
5923                Some(std::time::Instant::now())
5924            } else {
5925                None
5926            };
5927            if let (Some(t0), Some(t1)) = (pt_t0, pt_t1) {
5928                PHASE_WRITE_SUB_NS[0].fetch_add(
5929                    (t1 - t0).as_nanos() as u64,
5930                    core::sync::atomic::Ordering::Relaxed,
5931                );
5932            }
5933            let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5934                reason: "user_writer slot poisoned",
5935            })?;
5936            let pt_t2 = if pt_on {
5937                Some(std::time::Instant::now())
5938            } else {
5939                None
5940            };
5941            pt_t2_out = pt_t2;
5942            if let (Some(t1), Some(t2)) = (pt_t1, pt_t2) {
5943                PHASE_WRITE_SUB_NS[1].fetch_add(
5944                    (t2 - t1).as_nanos() as u64,
5945                    core::sync::atomic::Ordering::Relaxed,
5946                );
5947            }
5948            // Deadline timer: remember the last write for offered_deadline_missed.
5949            slot.last_write = Some(now);
5950            // Encap header from the effective offer `first` (per-writer
5951            // override else runtime default) + type extensibility. The app
5952            // encoder serializes exactly this wire format; the header must
5953            // declare it honestly (otherwise an XCDR2-only vendor
5954            // reader misparses). See `user_payload_encap`.
5955            let encap = {
5956                let offer_first = slot
5957                    .data_rep_offer_override
5958                    .as_ref()
5959                    .and_then(|v| v.first().copied())
5960                    .or_else(|| self.config.data_representation_offer.first().copied())
5961                    .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
5962                // Map the negotiated i16 DataRepresentationId to the u8 XCDR
5963                // version tag used by `UserSample::Alive.representation` /
5964                // `encap_representation` (`1` = XCDR2, `0` = XCDR1). Mirrors
5965                // the wire path where the reader derives this from the encap
5966                // header byte[1].
5967                intra_representation =
5968                    if offer_first == zerodds_rtps::publication_data::data_representation::XCDR2 {
5969                        1
5970                    } else {
5971                        0
5972                    };
5973                user_payload_encap(
5974                    offer_first,
5975                    slot.wire_extensibility,
5976                    slot.big_endian_override,
5977                )
5978            };
5979            // Spec §2.2.3.5 backend filling happens in
5980            // `DataWriter::write` (publisher.rs) with the **raw** payload —
5981            // here only the HistoryCache filling + wire send.
5982            let dgs = if total <= SMALL_FRAME_CAP {
5983                write_user_sample_pooled(&mut slot.writer, payload, now, &encap)?
5984            } else {
5985                let mut framed = Vec::with_capacity(total);
5986                framed.extend_from_slice(&encap);
5987                framed.extend_from_slice(payload);
5988                // See write_user_sample_pooled: HB rate-limited via the
5989                // tick loop instead of per-write.
5990                let _ = now;
5991                slot.writer
5992                    .write(&framed)
5993                    .map_err(|_| DdsError::WireError {
5994                        message: String::from("user writer encode"),
5995                    })?
5996            };
5997            // Lifespan: remember the insert time of the just-written SN.
5998            if slot.lifespan_nanos != 0 {
5999                if let Some(sn) = slot.writer.cache().max_sn() {
6000                    slot.sample_insert_times.push_back((sn, now));
6001                }
6002            }
6003            // QR-cluster (a)+(b): TRANSIENT_LOCAL same-runtime retention with
6004            // per-instance HISTORY KeepLast depth (DDS 1.4 §2.2.3.4 + §2.2.3.18).
6005            // A new sample for a key clears any prior terminal lifecycle marker
6006            // for that key (the instance is alive again) and is appended; the
6007            // depth is then re-enforced per key.
6008            if !matches!(slot.durability, zerodds_qos::DurabilityKind::Volatile) {
6009                slot.retained
6010                    .retain(|s| !(s.lifecycle.is_some() && s.key_hash == key_hash));
6011                let strength = slot.ownership_strength;
6012                slot.retained.push_back(RetainedSample {
6013                    key_hash,
6014                    payload: payload.to_vec(),
6015                    representation: intra_representation,
6016                    strength,
6017                    lifecycle: None,
6018                });
6019                let depth = slot.history_depth;
6020                enforce_retained_depth(&mut slot.retained, depth);
6021            }
6022            dgs
6023        };
6024        let pt_t3 = if pt_on {
6025            Some(std::time::Instant::now())
6026        } else {
6027            None
6028        };
6029        if let (Some(t2), Some(t3)) = (pt_t2_out, pt_t3) {
6030            PHASE_WRITE_SUB_NS[2].fetch_add(
6031                (t3 - t2).as_nanos() as u64,
6032                core::sync::atomic::Ordering::Relaxed,
6033            );
6034        }
6035        // Opt-4 (Spec `zerodds-zero-copy-1.0` §9): precompute the skip set
6036        // of UDP locators occupied by a bound same-host reader.
6037        // Readers on these locators get the sample via
6038        // SHM (`same_host_send_pass` below); a UDP send would be a duplicate.
6039        #[cfg(feature = "same-host-shm")]
6040        let same_host_skip_locators: Vec<Locator> = self.same_host_udp_skip_set(eid);
6041        // In-process fastpath (same-process+domain peers): snapshot the
6042        // peer runtimes ONCE per write, then feed each datagram directly into
6043        // their recv path — no UDP loopback, no reliable
6044        // recovery race. The receiver deduplicates by SequenceNumber,
6045        // a copy arriving additionally via UDP later is a
6046        // no-op. The wire path stays untouched for cross-process.
6047        //
6048        // Hot-path fast path: lock-free registry hint. In the typical
6049        // cross-process bench (ping in process A, pong in process B)
6050        // A's registry has only A — the `peers()` lock+Vec alloc would be
6051        // pure overhead per write. Skip when count ≤ 1.
6052        let inproc_peers: Vec<Arc<DcpsRuntime>> = if crate::inproc::registry_count_hint() <= 1 {
6053            Vec::new()
6054        } else {
6055            let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
6056            all.into_iter()
6057                .filter(|rt| rt.guid_prefix != self.guid_prefix)
6058                .collect()
6059        };
6060        for dg in out_datagrams {
6061            // FU2 S3: UDP per target with per-reader data_protection
6062            // (`secure_outbound_for_target` — heterogeneously correct: legacy readers
6063            // get plaintext, secure readers SRTPS; the governance
6064            // data_protection fallback applies for readers without explicit
6065            // SEDP security_info).
6066            for t in dg.targets.iter() {
6067                if is_routable_user_locator(t) {
6068                    #[cfg(feature = "same-host-shm")]
6069                    if same_host_skip_locators.iter().any(|s| s == t) {
6070                        continue;
6071                    }
6072                    if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
6073                        #[allow(clippy::print_stderr)]
6074                        if let Err(e) = self.user_unicast.send(t, &secured) {
6075                            if std::env::var("ZERODDS_TRACE_SEND_ERR")
6076                                .map(|s| s == "1")
6077                                .unwrap_or(false)
6078                            {
6079                                eprintln!("[TRACE] user_unicast.send({t:?}) failed: {e:?}");
6080                            }
6081                        }
6082                    }
6083                }
6084            }
6085            // SHM + in-process fastpath: `secure_user_outbound` (uniform
6086            // governance data_protection level). The inproc peer runs through
6087            // its secured inbound path (decrypt or drop),
6088            // symmetric to the UDP recv — otherwise a non-
6089            // authenticated same-process peer could see encrypted data
6090            // unencrypted.
6091            if let Some(secured) = secure_user_outbound(self, &dg.bytes) {
6092                // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6):
6093                // parallel send via SHM to all bound-owner entries
6094                // for this writer. Opt-4 above filters their UDP
6095                // locators out beforehand, so nothing is sent twice.
6096                #[cfg(feature = "same-host-shm")]
6097                self.same_host_send_pass(eid, &secured);
6098                for peer in &inproc_peers {
6099                    #[cfg(feature = "security")]
6100                    {
6101                        if let Some(clear) =
6102                            secure_inbound_bytes(peer, &secured, &DEFAULT_INBOUND_IFACE)
6103                        {
6104                            handle_user_datagram(peer, &clear, now);
6105                        }
6106                    }
6107                    #[cfg(not(feature = "security"))]
6108                    handle_user_datagram(peer, &secured, now);
6109                }
6110            }
6111        }
6112        let pt_t4 = if pt_on {
6113            Some(std::time::Instant::now())
6114        } else {
6115            None
6116        };
6117        if let (Some(t3), Some(t4)) = (pt_t3, pt_t4) {
6118            PHASE_WRITE_SUB_NS[3].fetch_add(
6119                (t4 - t3).as_nanos() as u64,
6120                core::sync::atomic::Ordering::Relaxed,
6121            );
6122        }
6123        // Same-runtime writer→reader loopback: in parallel to the wire path
6124        // push directly into the `sample_tx` of all local readers on the same
6125        // topic+type. Bridge-daemon use case (writer+reader
6126        // in the same DcpsRuntime); without this hook intra-process
6127        // loopback would be completely dead, because `inproc_announce_*` skips self
6128        // and UDP multicast loopback is not guaranteed. Strength from
6129        // the writer slot.
6130        let writer_strength = self
6131            .writer_slot(eid)
6132            .and_then(|arc| arc.lock().ok().map(|s| s.ownership_strength))
6133            .unwrap_or(0);
6134        self.intra_runtime_dispatch_alive(eid, payload, writer_strength, intra_representation);
6135        // Embargo inspect tap at the DCPS layer (path-separated from the
6136        // production path). Only compiled when the `inspect` feature is
6137        // on. The topic name is fetched via a separate lookup, outside
6138        // the lock region so hooks do not run under the lock.
6139        #[cfg(feature = "inspect")]
6140        {
6141            self.dispatch_inspect_dcps_tap(eid, payload);
6142        }
6143        // D.5e Phase 3 — a freshly written sample makes a HEARTBEAT due: wake the
6144        // scheduler tick so it goes out immediately (no 5 ms tail), speeding the
6145        // reliable HB→ACKNACK handshake.
6146        self.raise_tick_wake();
6147        Ok(())
6148    }
6149
6150    /// Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) helper:
6151    /// sends `bytes` to all bound-owner entries of the [`SameHostTracker`]
6152    /// for this local writer (owner role).
6153    ///
6154    /// Called by the [`Self::write_user_sample`] hot path after the UDP send.
6155    /// Same-host readers thereby receive the sample frame
6156    /// via SHM **in addition** to the UDP path — the reader HistoryCache
6157    /// deduplicates by SequenceNumber.
6158    #[cfg(feature = "same-host-shm")]
6159    /// Opt-4 (Spec `zerodds-zero-copy-1.0` §9): locator skip set for
6160    /// the UDP send path. Returns all UDP default-unicast locators
6161    /// of the readers that have a bound same-host SHM pair with this
6162    /// writer — the hot-path caller filters these targets out of
6163    /// `dg.targets`, so the same readers are not served twice
6164    /// (UDP + SHM).
6165    #[cfg(feature = "same-host-shm")]
6166    fn same_host_udp_skip_set(&self, writer_eid: EntityId) -> Vec<Locator> {
6167        use crate::same_host::{Role, SameHostState};
6168        let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
6169        let mut skip: Vec<Locator> = Vec::new();
6170        let snapshot = self.same_host.snapshot();
6171        let discovered = self.discovered.clone();
6172        for (w, reader, state) in snapshot {
6173            if w != writer_guid {
6174                continue;
6175            }
6176            if !matches!(
6177                state,
6178                SameHostState::Bound {
6179                    role: Role::Owner,
6180                    ..
6181                }
6182            ) {
6183                continue;
6184            }
6185            // Reader prefix → default_unicast_locator from discovery.
6186            if let Ok(cache) = discovered.lock() {
6187                if let Some(p) = cache.get(&reader.prefix) {
6188                    if let Some(loc) = p.data.default_unicast_locator {
6189                        skip.push(loc);
6190                    }
6191                }
6192            }
6193        }
6194        skip
6195    }
6196
6197    #[cfg(feature = "same-host-shm")]
6198    fn same_host_send_pass(&self, writer_eid: EntityId, bytes: &[u8]) {
6199        use crate::same_host::{Role, SameHostState};
6200        use zerodds_transport::Transport;
6201        use zerodds_transport_shm::PosixShmTransport;
6202
6203        let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
6204        let snapshot = self.same_host.snapshot();
6205        let total = snapshot.len();
6206        let mut matched = 0u32;
6207        let mut owners = 0u32;
6208        let mut sent = 0u32;
6209        for (w, _reader, state) in snapshot {
6210            if w != writer_guid {
6211                continue;
6212            }
6213            matched += 1;
6214            let SameHostState::Bound { transport, role } = state else {
6215                continue;
6216            };
6217            if !matches!(role, Role::Owner) {
6218                continue;
6219            }
6220            owners += 1;
6221            let Ok(t) = transport.downcast::<PosixShmTransport>() else {
6222                continue;
6223            };
6224            // ShmTransport is 1:1: send() validates `dest ==
6225            // peer_locator`. Owner.peer_locator points to the
6226            // consumer endpoint → that is our target.
6227            let target = t.peer_locator();
6228            if t.send(&target, bytes).is_ok() {
6229                sent += 1;
6230            }
6231        }
6232        let _ = (total, matched, owners, sent); // diag counter removed after the Bug-3 fix
6233    }
6234
6235    /// Inspect-endpoint tap dispatch for DCPS publish.
6236    /// Reads the topic name separately from the WriterSlot and passes
6237    /// a frame to the zerodds-inspect-endpoint tap registry.
6238    /// **Not** the production hot path: only when the `inspect` feature is on.
6239    #[cfg(feature = "inspect")]
6240    fn dispatch_inspect_dcps_tap(&self, eid: EntityId, payload: &[u8]) {
6241        let Some(slot_arc) = self.writer_slot(eid) else {
6242            return;
6243        };
6244        let topic = match slot_arc.lock() {
6245            Ok(slot) => slot.topic_name.clone(),
6246            Err(_) => return,
6247        };
6248        let ts_ns = std::time::SystemTime::now()
6249            .duration_since(std::time::UNIX_EPOCH)
6250            .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
6251            .unwrap_or(0);
6252        let mut corr: u64 = 0;
6253        for (i, byte) in eid.entity_key.iter().enumerate() {
6254            corr |= u64::from(*byte) << (i * 8);
6255        }
6256        corr |= u64::from(eid.entity_kind as u8) << 24;
6257        let frame = zerodds_inspect_endpoint::Frame::dcps(topic, ts_ns, corr, payload.to_vec());
6258        zerodds_inspect_endpoint::tap::dispatch(&frame);
6259    }
6260
6261    /// Sends a lifecycle marker (`dispose`/`unregister_instance`) to
6262    /// all matched readers. Spec §2.2.2.4.2.10/.7 + §9.6.3.9 PID_STATUS_INFO.
6263    /// `status_bits` is the OR combination of
6264    /// `zerodds_rtps::inline_qos::status_info::DISPOSED` and/or `UNREGISTERED`.
6265    ///
6266    /// # Errors
6267    /// - `BadParameter` if the EntityId has no registered writer.
6268    /// - `WireError` on an encode error.
6269    pub fn write_user_lifecycle(
6270        &self,
6271        eid: EntityId,
6272        key_hash: [u8; 16],
6273        status_bits: u32,
6274    ) -> Result<()> {
6275        let out_datagrams = {
6276            let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
6277                what: "unknown writer entity id",
6278            })?;
6279            let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
6280                reason: "user_writer slot poisoned",
6281            })?;
6282            slot.writer
6283                .write_lifecycle(key_hash, status_bits)
6284                .map_err(|_| DdsError::WireError {
6285                    message: String::from("user writer lifecycle encode"),
6286                })?
6287        };
6288        for dg in out_datagrams {
6289            // FU2 S3: lifecycle DATA (dispose/unregister) per-target
6290            // data_protection-aware (heterogeneously correct like the immediate send).
6291            for t in dg.targets.iter() {
6292                if is_routable_user_locator(t) {
6293                    if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
6294                        let _ = self.user_unicast.send(t, &secured);
6295                    }
6296                }
6297            }
6298        }
6299        // QR-cluster (d): also deliver the lifecycle marker to matched
6300        // same-runtime readers — the wire targets above never include
6301        // intra-runtime local readers (those go via the direct dispatch path).
6302        // Map the PID_STATUS_INFO bits to the HistoryCache ChangeKind.
6303        use zerodds_rtps::inline_qos::status_info;
6304        let disposed = status_bits & status_info::DISPOSED != 0;
6305        let unregistered = status_bits & status_info::UNREGISTERED != 0;
6306        let kind = match (disposed, unregistered) {
6307            (true, true) => zerodds_rtps::history_cache::ChangeKind::NotAliveDisposedUnregistered,
6308            (true, false) => zerodds_rtps::history_cache::ChangeKind::NotAliveDisposed,
6309            (false, true) => zerodds_rtps::history_cache::ChangeKind::NotAliveUnregistered,
6310            // No status bits set: nothing to deliver as a lifecycle marker.
6311            (false, false) => return Ok(()),
6312        };
6313        self.intra_runtime_dispatch_lifecycle(eid, key_hash, kind);
6314        Ok(())
6315    }
6316
6317    /// Generates a 3-byte entity key for new user endpoints.
6318    fn next_entity_key(&self) -> [u8; 3] {
6319        let n = self.entity_counter.fetch_add(1, Ordering::Relaxed);
6320        [(n >> 16) as u8, (n >> 8) as u8, n as u8]
6321    }
6322
6323    /// Snapshot of all currently known remote publications (topic
6324    /// name + type name + writer GUID).
6325    #[must_use]
6326    pub fn discovered_publications_count(&self) -> usize {
6327        self.sedp
6328            .lock()
6329            .map(|s| s.cache().publications_len())
6330            .unwrap_or(0)
6331    }
6332
6333    /// Snapshot of every publication on this domain as `(topic_name,
6334    /// type_name)` — raw DDS topic/type strings — for graph introspection
6335    /// (`rmw_get_topic_names_and_types`, `rmw_count_publishers`). Includes BOTH
6336    /// this participant's LOCAL user writers AND the remote publications from
6337    /// SEDP, so a node sees its own topics as well as its peers'.
6338    #[must_use]
6339    pub fn discovered_publication_topics(&self) -> Vec<(String, String)> {
6340        let mut out: Vec<(String, String)> = Vec::new();
6341        if let Ok(map) = self.user_writers.read() {
6342            for slot in map.values() {
6343                if let Ok(s) = slot.lock() {
6344                    out.push((s.topic_name.clone(), s.type_name.clone()));
6345                }
6346            }
6347        }
6348        if let Ok(s) = self.sedp.lock() {
6349            out.extend(
6350                s.cache()
6351                    .publications()
6352                    .map(|p| (p.data.topic_name.clone(), p.data.type_name.clone())),
6353            );
6354        }
6355        out
6356    }
6357
6358    /// Snapshot of every subscription on this domain as `(topic_name,
6359    /// type_name)` (local user readers + remote SEDP). Counterpart to
6360    /// [`Self::discovered_publication_topics`].
6361    #[must_use]
6362    pub fn discovered_subscription_topics(&self) -> Vec<(String, String)> {
6363        let mut out: Vec<(String, String)> = Vec::new();
6364        if let Ok(map) = self.user_readers.read() {
6365            for slot in map.values() {
6366                if let Ok(s) = slot.lock() {
6367                    out.push((s.topic_name.clone(), s.type_name.clone()));
6368                }
6369            }
6370        }
6371        if let Ok(s) = self.sedp.lock() {
6372            out.extend(
6373                s.cache()
6374                    .subscriptions()
6375                    .map(|s| (s.data.topic_name.clone(), s.data.type_name.clone())),
6376            );
6377        }
6378        out
6379    }
6380
6381    /// Snapshot of all currently known remote subscriptions.
6382    #[must_use]
6383    pub fn discovered_subscriptions_count(&self) -> usize {
6384        self.sedp
6385            .lock()
6386            .map(|s| s.cache().subscriptions_len())
6387            .unwrap_or(0)
6388    }
6389
6390    /// Per-endpoint snapshot of every publication on this domain (local user
6391    /// writers + remote SEDP), for ROS 2 `rmw_get_publishers_info_by_topic`.
6392    #[must_use]
6393    pub fn discovered_publication_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
6394        let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
6395        let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
6396        if let Ok(map) = self.user_writers.read() {
6397            for slot in map.values() {
6398                if let Ok(s) = slot.lock() {
6399                    out.push(DiscoveredEndpointInfo {
6400                        topic_name: s.topic_name.clone(),
6401                        type_name: s.type_name.clone(),
6402                        endpoint_guid: guid_to_16(s.writer.guid()),
6403                        reliable: s.reliable,
6404                        transient_local: !matches!(
6405                            s.durability,
6406                            zerodds_qos::DurabilityKind::Volatile
6407                        ),
6408                        deadline_seconds: secs(s.deadline_nanos),
6409                        lifespan_seconds: secs(s.lifespan_nanos),
6410                        liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
6411                    });
6412                }
6413            }
6414        }
6415        if let Ok(s) = self.sedp.lock() {
6416            for p in s.cache().publications() {
6417                out.push(DiscoveredEndpointInfo {
6418                    topic_name: p.data.topic_name.clone(),
6419                    type_name: p.data.type_name.clone(),
6420                    endpoint_guid: guid_to_16(p.data.key),
6421                    reliable: matches!(
6422                        p.data.reliability.kind,
6423                        zerodds_qos::ReliabilityKind::Reliable
6424                    ),
6425                    transient_local: !matches!(
6426                        p.data.durability,
6427                        zerodds_qos::DurabilityKind::Volatile
6428                    ),
6429                    deadline_seconds: p.data.deadline.period.seconds,
6430                    lifespan_seconds: p.data.lifespan.duration.seconds,
6431                    liveliness_lease_seconds: p.data.liveliness.lease_duration.seconds,
6432                });
6433            }
6434        }
6435        out
6436    }
6437
6438    /// Counterpart to [`Self::discovered_publication_endpoints`] for
6439    /// subscriptions (`rmw_get_subscriptions_info_by_topic`).
6440    #[must_use]
6441    pub fn discovered_subscription_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
6442        let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
6443        let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
6444        if let Ok(map) = self.user_readers.read() {
6445            for slot in map.values() {
6446                if let Ok(s) = slot.lock() {
6447                    out.push(DiscoveredEndpointInfo {
6448                        topic_name: s.topic_name.clone(),
6449                        type_name: s.type_name.clone(),
6450                        endpoint_guid: guid_to_16(s.reader.guid()),
6451                        // Reader requested-reliability is not retained in the
6452                        // slot; RELIABLE is the rmw default (best-effort field).
6453                        reliable: true,
6454                        transient_local: !matches!(
6455                            s.durability,
6456                            zerodds_qos::DurabilityKind::Volatile
6457                        ),
6458                        deadline_seconds: secs(s.deadline_nanos),
6459                        lifespan_seconds: 0,
6460                        liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
6461                    });
6462                }
6463            }
6464        }
6465        if let Ok(s) = self.sedp.lock() {
6466            for sub in s.cache().subscriptions() {
6467                out.push(DiscoveredEndpointInfo {
6468                    topic_name: sub.data.topic_name.clone(),
6469                    type_name: sub.data.type_name.clone(),
6470                    endpoint_guid: guid_to_16(sub.data.key),
6471                    reliable: matches!(
6472                        sub.data.reliability.kind,
6473                        zerodds_qos::ReliabilityKind::Reliable
6474                    ),
6475                    transient_local: !matches!(
6476                        sub.data.durability,
6477                        zerodds_qos::DurabilityKind::Volatile
6478                    ),
6479                    deadline_seconds: sub.data.deadline.period.seconds,
6480                    lifespan_seconds: 0,
6481                    liveliness_lease_seconds: sub.data.liveliness.lease_duration.seconds,
6482                });
6483            }
6484        }
6485        out
6486    }
6487
6488    /// Number of matched remote readers for a local user writer.
6489    /// Polled by `DataWriter::wait_for_matched_subscription`.
6490    #[must_use]
6491    pub fn user_writer_matched_count(&self, eid: EntityId) -> usize {
6492        // Distinct matched subscriptions = remote/cross-participant reader
6493        // proxies UNION same-participant (intra-runtime) local readers. The
6494        // intra-runtime self-match path delivers samples without adding a wire
6495        // reader-proxy (avoids UDP-to-self double-delivery), so its matches
6496        // would otherwise be invisible to `wait_for_matched_subscription`.
6497        self.user_writer_matched_subscription_handles(eid).len()
6498    }
6499
6500    /// List of `InstanceHandle`s of all matched readers for a local
6501    /// user writer (Spec §2.2.2.4.2.x `get_matched_subscriptions`): remote/
6502    /// cross-participant readers (reader proxies) plus the same-participant
6503    /// readers from the intra-runtime routes, deduplicated by GUID.
6504    #[must_use]
6505    pub fn user_writer_matched_subscription_handles(
6506        &self,
6507        eid: EntityId,
6508    ) -> Vec<crate::instance_handle::InstanceHandle> {
6509        let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
6510            .writer_slot(eid)
6511            .and_then(|arc| {
6512                arc.lock().ok().map(|s| {
6513                    s.writer
6514                        .reader_proxies()
6515                        .iter()
6516                        .map(|p| {
6517                            crate::instance_handle::InstanceHandle::from_guid(p.remote_reader_guid)
6518                        })
6519                        .collect::<Vec<_>>()
6520                })
6521            })
6522            .unwrap_or_default();
6523        for h in self.intra_runtime_writer_matched_readers(eid) {
6524            if !handles.contains(&h) {
6525                handles.push(h);
6526            }
6527        }
6528        handles
6529    }
6530
6531    /// Same-participant readers that the local writer `eid` delivers to via
6532    /// an intra-runtime route (as matched-subscription handles).
6533    fn intra_runtime_writer_matched_readers(
6534        &self,
6535        writer_eid: EntityId,
6536    ) -> Vec<crate::instance_handle::InstanceHandle> {
6537        match self.intra_runtime_routes.read() {
6538            Ok(g) => g
6539                .get(&writer_eid)
6540                .map(|readers| {
6541                    readers
6542                        .iter()
6543                        .map(|reid| {
6544                            crate::instance_handle::InstanceHandle::from_guid(Guid::new(
6545                                self.guid_prefix,
6546                                *reid,
6547                            ))
6548                        })
6549                        .collect()
6550                })
6551                .unwrap_or_default(),
6552            Err(_) => Vec::new(),
6553        }
6554    }
6555
6556    /// Same-participant writers that deliver to the local
6557    /// reader `reader_eid` via an intra-runtime route (as matched-publication handles).
6558    fn intra_runtime_reader_matched_writers(
6559        &self,
6560        reader_eid: EntityId,
6561    ) -> Vec<crate::instance_handle::InstanceHandle> {
6562        match self.intra_runtime_routes.read() {
6563            Ok(g) => g
6564                .iter()
6565                .filter(|(_, readers)| readers.contains(&reader_eid))
6566                .map(|(weid, _)| {
6567                    crate::instance_handle::InstanceHandle::from_guid(Guid::new(
6568                        self.guid_prefix,
6569                        *weid,
6570                    ))
6571                })
6572                .collect(),
6573            Err(_) => Vec::new(),
6574        }
6575    }
6576
6577    /// List of `InstanceHandle`s of all matched remote writers for a
6578    /// local user reader (Spec §2.2.2.5.x `get_matched_publications`).
6579    #[must_use]
6580    pub fn user_reader_matched_publication_handles(
6581        &self,
6582        eid: EntityId,
6583    ) -> Vec<crate::instance_handle::InstanceHandle> {
6584        let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
6585            .reader_slot(eid)
6586            .and_then(|arc| {
6587                arc.lock().ok().map(|s| {
6588                    s.reader
6589                        .writer_proxies()
6590                        .iter()
6591                        .map(|p| {
6592                            crate::instance_handle::InstanceHandle::from_guid(
6593                                p.proxy.remote_writer_guid,
6594                            )
6595                        })
6596                        .collect::<Vec<_>>()
6597                })
6598            })
6599            .unwrap_or_default();
6600        for h in self.intra_runtime_reader_matched_writers(eid) {
6601            if !handles.contains(&h) {
6602                handles.push(h);
6603            }
6604        }
6605        handles
6606    }
6607
6608    /// Counter for missed offered deadlines on the user writer.
6609    /// Spec OMG DDS 1.4 §2.2.4.2.9 `OFFERED_DEADLINE_MISSED_STATUS`.
6610    #[must_use]
6611    pub fn user_writer_offered_deadline_missed(&self, eid: EntityId) -> u64 {
6612        self.writer_slot(eid)
6613            .and_then(|arc| arc.lock().ok().map(|s| s.offered_deadline_missed_count))
6614            .unwrap_or(0)
6615    }
6616
6617    /// Counter for missed requested deadlines on the user reader.
6618    /// Spec §2.2.4.2.11 `REQUESTED_DEADLINE_MISSED_STATUS`.
6619    #[must_use]
6620    pub fn user_reader_requested_deadline_missed(&self, eid: EntityId) -> u64 {
6621        self.reader_slot(eid)
6622            .and_then(|arc| arc.lock().ok().map(|s| s.requested_deadline_missed_count))
6623            .unwrap_or(0)
6624    }
6625
6626    /// Current liveliness status of a local user reader.
6627    /// Spec §2.2.4.2.14 `LIVELINESS_CHANGED_STATUS`:
6628    /// `(alive, alive_count, not_alive_count)`.
6629    #[must_use]
6630    pub fn user_reader_liveliness_status(&self, eid: EntityId) -> (bool, u64, u64) {
6631        self.reader_slot(eid)
6632            .and_then(|arc| {
6633                arc.lock().ok().map(|s| {
6634                    (
6635                        s.liveliness_alive,
6636                        s.liveliness_alive_count,
6637                        s.liveliness_not_alive_count,
6638                    )
6639                })
6640            })
6641            .unwrap_or((false, 0, 0))
6642    }
6643
6644    /// LivelinessLost counter on the user writer (Spec §2.2.4.2.10).
6645    /// Incremented by `check_writer_liveliness`.
6646    #[must_use]
6647    pub fn user_writer_liveliness_lost(&self, eid: EntityId) -> u64 {
6648        self.writer_slot(eid)
6649            .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_lost_count))
6650            .unwrap_or(0)
6651    }
6652
6653    /// Snapshot of OfferedIncompatibleQosStatus on the writer.
6654    #[must_use]
6655    pub fn user_writer_offered_incompatible_qos(
6656        &self,
6657        eid: EntityId,
6658    ) -> crate::status::OfferedIncompatibleQosStatus {
6659        self.writer_slot(eid)
6660            .and_then(|arc| arc.lock().ok().map(|s| s.offered_incompatible_qos.clone()))
6661            .unwrap_or_default()
6662    }
6663
6664    /// Snapshot of RequestedIncompatibleQosStatus on the reader.
6665    #[must_use]
6666    pub fn user_reader_requested_incompatible_qos(
6667        &self,
6668        eid: EntityId,
6669    ) -> crate::status::RequestedIncompatibleQosStatus {
6670        self.reader_slot(eid)
6671            .and_then(|arc| {
6672                arc.lock()
6673                    .ok()
6674                    .map(|s| s.requested_incompatible_qos.clone())
6675            })
6676            .unwrap_or_default()
6677    }
6678
6679    /// Sample-lost counter (reader side). Spec §2.2.4.2.6.2.
6680    #[must_use]
6681    pub fn user_reader_sample_lost(&self, eid: EntityId) -> u64 {
6682        self.reader_slot(eid)
6683            .and_then(|arc| arc.lock().ok().map(|s| s.sample_lost_count))
6684            .unwrap_or(0)
6685    }
6686
6687    /// Monotonically increasing count of alive samples delivered to the
6688    /// user (Spec §2.2.4.2.6.1 `on_data_available` detector). A delta
6689    /// against the last poll snapshot means "new data available".
6690    #[must_use]
6691    pub fn user_reader_samples_delivered(&self, eid: EntityId) -> u64 {
6692        self.reader_slot(eid)
6693            .and_then(|arc| arc.lock().ok().map(|s| s.samples_delivered_count))
6694            .unwrap_or(0)
6695    }
6696
6697    /// A2 — arm TIME_BASED_FILTER (DDS 1.4 §2.2.3.12) on a runtime/C-FFI user
6698    /// reader: it then receives at most one sample per instance per
6699    /// `min_separation_nanos`; closer-spaced samples are dropped before they
6700    /// reach the reader's channel. `0` disables the filter. Returns `true` if
6701    /// the reader exists. This is the seam `rmw_zerodds` uses to rate-limit ROS-2
6702    /// subscriptions (`rmw_qos_profile_t` carries no TIME_BASED_FILTER field).
6703    pub fn set_user_reader_time_based_filter(
6704        &self,
6705        eid: EntityId,
6706        min_separation_nanos: u128,
6707    ) -> bool {
6708        let Some(arc) = self.reader_slot(eid) else {
6709            return false;
6710        };
6711        let Ok(mut slot) = arc.lock() else {
6712            return false;
6713        };
6714        slot.tbf_min_separation_nanos = min_separation_nanos;
6715        if min_separation_nanos == 0 {
6716            slot.tbf_last_delivered.clear();
6717        }
6718        true
6719    }
6720
6721    /// Bug-2 diagnosis (2026-05-19): number of submessages dropped
6722    /// because of an unknown writer_id. If this value is incremented
6723    /// after a write, it indicates an SEDP match
6724    /// race (writer_proxy not yet added when DATA is received).
6725    #[must_use]
6726    pub fn user_reader_unknown_src_count(&self, eid: EntityId) -> u64 {
6727        self.reader_slot(eid)
6728            .and_then(|arc| arc.lock().ok().map(|s| s.reader.unknown_src_count()))
6729            .unwrap_or(0)
6730    }
6731
6732    /// Sample-rejected status (reader side). Spec §2.2.4.2.6.3.
6733    #[must_use]
6734    pub fn user_reader_sample_rejected(
6735        &self,
6736        eid: EntityId,
6737    ) -> crate::status::SampleRejectedStatus {
6738        self.reader_slot(eid)
6739            .and_then(|arc| arc.lock().ok().map(|s| s.sample_rejected))
6740            .unwrap_or_default()
6741    }
6742
6743    /// Records a lost sample on the user reader. Called
6744    /// by resource-limit or decode-failure paths — the
6745    /// detector is application-external, because sample-lost depending on the
6746    /// implementation comes from several sources (cache drop, decode
6747    /// fail, sequence-number gap drop).
6748    pub fn record_sample_lost(&self, eid: EntityId, count: u32) {
6749        if count == 0 {
6750            return;
6751        }
6752        if let Some(arc) = self.reader_slot(eid) {
6753            if let Ok(mut slot) = arc.lock() {
6754                slot.sample_lost_count = slot.sample_lost_count.saturating_add(u64::from(count));
6755            }
6756        }
6757    }
6758
6759    /// Records a rejected sample on the user reader.
6760    pub fn record_sample_rejected(
6761        &self,
6762        eid: EntityId,
6763        kind: crate::status::SampleRejectedStatusKind,
6764        instance: crate::instance_handle::InstanceHandle,
6765    ) {
6766        if let Some(arc) = self.reader_slot(eid) {
6767            if let Ok(mut slot) = arc.lock() {
6768                slot.sample_rejected.total_count =
6769                    slot.sample_rejected.total_count.saturating_add(1);
6770                slot.sample_rejected.last_reason = kind;
6771                slot.sample_rejected.last_instance_handle = instance;
6772            }
6773        }
6774    }
6775
6776    /// Manual liveliness assert on the user writer. Sets the
6777    /// `last_liveliness_assert` timestamp. For `LivelinessKind::Automatic`
6778    /// `last_write` is also set — the liveliness path
6779    /// otherwise never falls through the `assert` trigger, because every successful
6780    /// `write` already takes over the liveliness tick.
6781    pub fn assert_writer_liveliness_eid(&self, eid: EntityId) {
6782        let now = self.start_instant.elapsed();
6783        if let Some(arc) = self.writer_slot(eid) {
6784            if let Ok(mut slot) = arc.lock() {
6785                slot.last_liveliness_assert = Some(now);
6786                if slot.liveliness_kind == zerodds_qos::LivelinessKind::Automatic {
6787                    slot.last_write = Some(now);
6788                }
6789            }
6790        }
6791    }
6792
6793    /// True if all matched readers have acknowledged all samples written
6794    /// so far. Empty cache or no proxies → true.
6795    #[must_use]
6796    pub fn user_writer_all_acknowledged(&self, eid: EntityId) -> bool {
6797        self.writer_slot(eid)
6798            .and_then(|arc| arc.lock().ok().map(|s| s.writer.all_samples_acknowledged()))
6799            .unwrap_or(true)
6800    }
6801
6802    /// Test helper — pushes a synthetic `UserSample::Alive`
6803    /// directly into the `mpsc::Sender` of the given reader, without
6804    /// going through the wire/discovery path. Enables end-to-end tests of
6805    /// downstream consumers (e.g. bridge-daemon pumps) that otherwise
6806    /// become flaky in CI containers due to multicast-loopback limits.
6807    /// **Not** for production code.
6808    ///
6809    /// `writer_guid` and `writer_strength` are set to default values
6810    /// (shared-ownership assumption).
6811    ///
6812    /// Returns `true` if the reader slot exists and the push
6813    /// succeeded, `false` if the EID is unknown or the channel is
6814    /// closed.
6815    #[doc(hidden)]
6816    pub fn test_inject_user_alive(&self, eid: EntityId, payload: Vec<u8>) -> bool {
6817        let Some(arc) = self.reader_slot(eid) else {
6818            return false;
6819        };
6820        let Ok(mut slot) = arc.lock() else {
6821            return false;
6822        };
6823        let sent = slot
6824            .sample_tx
6825            .send(UserSample::Alive {
6826                payload: crate::sample_bytes::SampleBytes::from_vec(payload),
6827                writer_guid: [0u8; 16],
6828                writer_strength: 0,
6829                representation: 0,
6830                big_endian: false,
6831                source_timestamp: None,
6832            })
6833            .is_ok();
6834        if sent {
6835            slot.samples_delivered_count = slot.samples_delivered_count.saturating_add(1);
6836        }
6837        sent
6838    }
6839
6840    /// Test helper — bumps the inconsistent-topic counter as if matching had
6841    /// discovered a remote endpoint with the same `topic_name` but a
6842    /// different `type_name`. Lets listener-FFI tests exercise the
6843    /// `on_inconsistent_topic` poll path without standing up two
6844    /// participants with a real SEDP type mismatch. **Not** for production.
6845    #[doc(hidden)]
6846    pub fn test_bump_inconsistent_topic(&self) {
6847        self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
6848    }
6849
6850    /// Spec §3.1 zerodds-async-1.0: registers the waker of an
6851    /// async reader in the UserReaderSlot. On `sample_tx.send`
6852    /// the waker is woken. `None` as the argument clears the waker
6853    /// (e.g. after the async reader is dropped).
6854    pub fn register_user_reader_waker(&self, eid: EntityId, waker: Option<core::task::Waker>) {
6855        if let Some(arc) = self.reader_slot(eid) {
6856            if let Ok(slot) = arc.lock() {
6857                if let Ok(mut g) = slot.async_waker.lock() {
6858                    *g = waker;
6859                }
6860            }
6861        }
6862    }
6863
6864    /// Register a listener callback for alive-sample
6865    /// arrival on the user reader. `None` clears an
6866    /// existing listener.
6867    ///
6868    /// The listener fires synchronously on the recv thread of
6869    /// `recv_user_data_loop` — see the contract doc on the
6870    /// [`UserReaderListener`] type. Eliminates the user-polling
6871    /// latency (~50-100 µs) compared to `sample_tx.recv()`.
6872    ///
6873    /// Returns `true` if the reader slot exists and the listener
6874    /// was set, `false` if the EID is not a known user reader.
6875    pub fn set_user_reader_listener(
6876        &self,
6877        eid: EntityId,
6878        listener: Option<UserReaderListener>,
6879    ) -> bool {
6880        let Some(arc) = self.reader_slot(eid) else {
6881            return false;
6882        };
6883        let Ok(mut slot) = arc.lock() else {
6884            return false;
6885        };
6886        slot.listener = listener.map(alloc::sync::Arc::new);
6887        true
6888    }
6889
6890    /// Number of matched writers for a local user reader: remote/cross-
6891    /// participant writers (writer proxies) plus same-participant writers from the
6892    /// intra-runtime routes, deduplicated by GUID (symmetric to the writer).
6893    #[must_use]
6894    pub fn user_reader_matched_count(&self, eid: EntityId) -> usize {
6895        self.user_reader_matched_publication_handles(eid).len()
6896    }
6897
6898    /// D.5e Phase-1 — waits until a match event occurs or the timeout
6899    /// is reached. Replaces 20-ms polling in `DataReader::wait_for_matched_*`
6900    /// and `DataWriter::wait_for_matched_*`.
6901    ///
6902    /// The caller checks the match count itself (via `user_*_matched_count`)
6903    /// before and after the wait — this function is only the block mechanics.
6904    /// Returns `false` if the timeout is reached, `true` if a notify came.
6905    #[cfg(feature = "std")]
6906    pub fn wait_match_event(&self, timeout: core::time::Duration) -> bool {
6907        let (lock, cvar) = &*self.match_event;
6908        let Ok(guard) = lock.lock() else { return false };
6909        match cvar.wait_timeout(guard, timeout) {
6910            Ok((_, t)) => !t.timed_out(),
6911            Err(_) => false,
6912        }
6913    }
6914
6915    /// D.5e Phase-1 — waits until an ACK event occurs or a timeout.
6916    /// Replaces 50-ms polling in `DataWriter::wait_for_acknowledgments`.
6917    #[cfg(feature = "std")]
6918    pub fn wait_ack_event(&self, timeout: core::time::Duration) -> bool {
6919        let (lock, cvar) = &*self.ack_event;
6920        let Ok(guard) = lock.lock() else { return false };
6921        match cvar.wait_timeout(guard, timeout) {
6922            Ok((_, t)) => !t.timed_out(),
6923            Err(_) => false,
6924        }
6925    }
6926
6927    /// D.5e Phase-1 — notify helper for the ACK event. Called by the reliable
6928    /// writer path when an ACKNACK advances the acked-base.
6929    #[cfg(feature = "std")]
6930    pub(crate) fn notify_ack_event(&self) {
6931        self.ack_event.1.notify_all();
6932    }
6933
6934    /// ADR-0006 — sets the PID_SHM_LOCATOR bytes for a local
6935    /// user writer in the side map. Called by the DataWriter
6936    /// once `set_flat_backend` has attached a same-host backend (POSIX shm /
6937    /// Iceoryx2). On the next SEDP push the wire encoder
6938    /// injects PID 0x8001 into the `PublicationData`.
6939    pub fn set_shm_locator(&self, eid: EntityId, bytes: Vec<u8>) {
6940        if let Ok(mut g) = self.shm_locators.write() {
6941            g.insert(eid, bytes);
6942        }
6943    }
6944
6945    /// ADR-0006 — reads the PID_SHM_LOCATOR bytes for a local
6946    /// user writer from the side map. Returns `None` if no
6947    /// same-host backend is set.
6948    #[must_use]
6949    pub fn shm_locator(&self, eid: EntityId) -> Option<Vec<u8>> {
6950        self.shm_locators.read().ok()?.get(&eid).cloned()
6951    }
6952
6953    /// ADR-0006 — removes the PID_SHM_LOCATOR entry (e.g. when the
6954    /// user writer is reconfigured without a backend).
6955    pub fn clear_shm_locator(&self, eid: EntityId) {
6956        if let Ok(mut g) = self.shm_locators.write() {
6957            g.remove(&eid);
6958        }
6959    }
6960
6961    /// Stops all worker threads (recv loops + tick loop) and joins
6962    /// them. Idempotent — repeated calls are no-ops.
6963    ///
6964    /// Shutdown delay: up to ~1 s, because the recv threads sit in
6965    /// `recv()` with a 1 s read timeout. After the
6966    /// current recv() call finishes they check the stop flag and
6967    /// terminate.
6968    pub fn shutdown(&self) {
6969        self.stop.store(true, Ordering::Relaxed);
6970        // D.5e Phase 3 — wake the scheduler tick worker so it observes `stop`
6971        // immediately instead of parking up to the idle floor.
6972        if let Ok(guard) = self.tick_wake.lock() {
6973            if let Some(h) = guard.as_ref() {
6974                h.stop();
6975            }
6976        }
6977        if let Ok(mut guard) = self.handles.lock() {
6978            for h in guard.drain(..) {
6979                let _ = h.join();
6980            }
6981        }
6982    }
6983}
6984
6985impl Drop for DcpsRuntime {
6986    // ZERODDS_PHASE_DUMP=1 is on-demand debug telemetry for
6987    // phase-latency profiling. eprintln is semantically correct here
6988    // (stderr diagnostics), no log-crate dependency wanted.
6989    #[allow(clippy::print_stderr)]
6990    fn drop(&mut self) {
6991        if std::env::var("ZERODDS_PHASE_DUMP")
6992            .map(|s| s == "1")
6993            .unwrap_or(false)
6994        {
6995            let hu_ns = PHASE_HANDLE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6996            let hu_n = PHASE_HANDLE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6997            let wu_ns = PHASE_WRITE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6998            let wu_n = PHASE_WRITE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6999            let hu_us = if hu_n > 0 {
7000                hu_ns as f64 / hu_n as f64 / 1000.0
7001            } else {
7002                0.0
7003            };
7004            let wu_us = if wu_n > 0 {
7005                wu_ns as f64 / wu_n as f64 / 1000.0
7006            } else {
7007                0.0
7008            };
7009            eprintln!(
7010                "[ZERODDS_PHASE] handle_user_datagram:  N={}  avg={:.3}us  total={:.1}ms",
7011                hu_n,
7012                hu_us,
7013                hu_ns as f64 / 1_000_000.0
7014            );
7015            eprintln!(
7016                "[ZERODDS_PHASE] write_user_sample:      N={}  avg={:.3}us  total={:.1}ms",
7017                wu_n,
7018                wu_us,
7019                wu_ns as f64 / 1_000_000.0
7020            );
7021            // Sub-phases of write_user_sample_borrowed.
7022            // [0] slot_lookup, [1] slot_lock_acquire,
7023            // [2] writer.write + framing, [3] dispatch (UDP + inproc).
7024            const SUB_LABELS: [&str; 4] = [
7025                "  ├─ slot_lookup       ",
7026                "  ├─ slot_lock_acquire ",
7027                "  ├─ writer.write+frame",
7028                "  └─ dispatch (UDP+...)",
7029            ];
7030            for (i, label) in SUB_LABELS.iter().enumerate() {
7031                let s_ns = PHASE_WRITE_SUB_NS[i].load(core::sync::atomic::Ordering::Relaxed);
7032                if s_ns > 0 && wu_n > 0 {
7033                    let s_us = s_ns as f64 / wu_n as f64 / 1000.0;
7034                    eprintln!(
7035                        "[ZERODDS_PHASE] {} avg={:.3}us  total={:.1}ms",
7036                        label,
7037                        s_us,
7038                        s_ns as f64 / 1_000_000.0
7039                    );
7040                }
7041            }
7042        }
7043        self.shutdown();
7044    }
7045}
7046
7047// ---------------------------------------------------------------------
7048// Worker threads (Sprint D.5b — per-socket recv + central tick).
7049//
7050// Before: a single `event_loop` that went through three sequential
7051// blocking `recv()`s with a `tick_period` timeout (50 ms) per iteration.
7052// Roundtrip latency: 5-14 ms p50 (CFS drift + sequential wait stages).
7053//
7054// Now: four dedicated threads.
7055//   * recv_spdp_multicast_loop  — blocks on the SPDP multicast socket
7056//   * recv_metatraffic_loop     — blocks on SPDP unicast (= metatraffic)
7057//   * recv_user_data_loop       — blocks on user-data unicast
7058//   * tick_loop                 — periodic outbound tasks +
7059//                                 per-interface inbound (non-blocking) +
7060//                                 deadline/lifespan/liveliness
7061//
7062// Lock discipline: the recv threads and the tick thread contend for
7063// `rt.sedp.lock()` / `rt.wlp.lock()` / per-slot `slot.lock()`.
7064// Convention: keep lock-hold times short (handle_datagram + tick each
7065// have only single-pass logic), no sub-lock under sedp/wlp.
7066// ---------------------------------------------------------------------
7067
7068/// Sprint D.5d lever C — applies SCHED_FIFO + CPU affinity to the
7069/// calling thread. Linux-only; no-op on macOS/Windows.
7070///
7071/// Called by every worker loop right at the start, so
7072/// the syscalls run on the actual worker thread
7073/// (`pthread_self()` must come from the thread itself).
7074///
7075/// Failures are logged to stderr but are not fatal — if
7076/// the process has no `CAP_SYS_NICE`, the runtime continues with
7077/// the CFS default scheduler.
7078#[allow(unused_variables)]
7079fn apply_thread_tuning(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
7080    #[cfg(target_os = "linux")]
7081    rt_pinning::apply(label, priority, cpus);
7082}
7083
7084/// Linux-only `pthread_setschedparam` + `sched_setaffinity` wrapper.
7085/// A dedicated module encapsulates the `unsafe` locally with safety notes; the
7086/// crate-level `#![deny(unsafe_code)]` stays active for the rest of the dcps
7087/// codebase.
7088#[cfg(target_os = "linux")]
7089#[allow(unsafe_code, clippy::print_stderr)]
7090mod rt_pinning {
7091    pub(super) fn apply(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
7092        if let Some(prio) = priority {
7093            // SAFETY: libc FFI with an owned `param` struct. The self-thread via
7094            // `pthread_self()` is always valid.
7095            // musl libc has additional `sched_ss_*` fields (POSIX
7096            // sporadic-server) that we do not set — `mem::zeroed`
7097            // initializes them cleanly to 0.
7098            unsafe {
7099                let mut param: libc::sched_param = core::mem::zeroed();
7100                param.sched_priority = prio;
7101                let rc = libc::pthread_setschedparam(
7102                    libc::pthread_self(),
7103                    libc::SCHED_FIFO,
7104                    &raw const param,
7105                );
7106                if rc != 0 {
7107                    eprintln!(
7108                        "zdds[{label}]: pthread_setschedparam SCHED_FIFO {prio} \
7109                         failed (rc={rc}). Need CAP_SYS_NICE or RLIMIT_RTPRIO."
7110                    );
7111                }
7112            }
7113        }
7114        if let Some(cpu_list) = cpus {
7115            // SAFETY: cpu_set_t is POD; CPU_ZERO/SET are libc inline
7116            // functions without lifetime requirements.
7117            unsafe {
7118                let mut set: libc::cpu_set_t = core::mem::zeroed();
7119                libc::CPU_ZERO(&mut set);
7120                for &cpu in cpu_list {
7121                    if cpu < libc::CPU_SETSIZE as usize {
7122                        libc::CPU_SET(cpu, &mut set);
7123                    }
7124                }
7125                let rc = libc::sched_setaffinity(
7126                    0,
7127                    core::mem::size_of::<libc::cpu_set_t>(),
7128                    &raw const set,
7129                );
7130                if rc != 0 {
7131                    eprintln!("zdds[{label}]: sched_setaffinity({cpu_list:?}) failed.");
7132                }
7133            }
7134        }
7135    }
7136}
7137
7138/// FastDDS interop (phase 2): acknowledges FastDDS' reliable secure SPDP writer
7139/// (0xff0101c2). FastDDS heartbeats its secure SPDP reliably and sends the
7140/// `participant_crypto_tokens` only once our 0xff0101c7 reader has acked its writer
7141/// (fast<->fast reference pcap: ACKNACK on 0xff0101c7). We respond to
7142/// every incoming secure-SPDP HEARTBEAT with an ACKNACK (base = last+1,
7143/// final), addressed via INFO_DST to the sender prefix. Gated on
7144/// `enable_secure_spdp`.
7145#[cfg(feature = "security")]
7146fn secure_spdp_reader_acks(rt: &DcpsRuntime, clear: &[u8]) -> Vec<Vec<u8>> {
7147    use zerodds_rtps::header::RtpsHeader;
7148    use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
7149    use zerodds_rtps::submessages::{AckNackSubmessage, HeartbeatSubmessage, SequenceNumberSet};
7150    use zerodds_rtps::wire_types::SequenceNumber;
7151    if !rt.config.enable_secure_spdp {
7152        return Vec::new();
7153    }
7154    let Ok(parsed) = decode_datagram(clear) else {
7155        return Vec::new();
7156    };
7157    let peer_prefix = parsed.header.guid_prefix;
7158    let mut out = Vec::new();
7159    let mut count = 0i32;
7160    let secure_writer = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER;
7161    let secure_reader = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER;
7162    // Header + INFO_DST(peer) + submessage. INFO_DST is mandatory, otherwise the
7163    // dest prefix is UNKNOWN -> FastDDS discards it as "not a connection".
7164    let wrap = |id: SubmessageId, body: &[u8], flags: u8| -> Option<Vec<u8>> {
7165        let blen = u16::try_from(body.len()).ok()?;
7166        let header = RtpsHeader::new(VendorId::ZERODDS, rt.guid_prefix);
7167        let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
7168        dg.extend_from_slice(&header.to_bytes());
7169        let info = SubmessageHeader {
7170            submessage_id: SubmessageId::InfoDst,
7171            flags: FLAG_E_LITTLE_ENDIAN,
7172            octets_to_next_header: 12,
7173        };
7174        dg.extend_from_slice(&info.to_bytes());
7175        dg.extend_from_slice(&peer_prefix.to_bytes());
7176        let sh = SubmessageHeader {
7177            submessage_id: id,
7178            flags: flags | FLAG_E_LITTLE_ENDIAN,
7179            octets_to_next_header: blen,
7180        };
7181        dg.extend_from_slice(&sh.to_bytes());
7182        dg.extend_from_slice(body);
7183        Some(dg)
7184    };
7185    for sub in &parsed.submessages {
7186        match sub {
7187            // FastDDS' secure-SPDP writer HEARTBEAT -> we ack (reader 0xff0101c7).
7188            ParsedSubmessage::Heartbeat(hb) if hb.writer_id == secure_writer => {
7189                count = count.wrapping_add(1);
7190                let ack = AckNackSubmessage {
7191                    reader_id: secure_reader,
7192                    writer_id: secure_writer,
7193                    reader_sn_state: SequenceNumberSet {
7194                        bitmap_base: SequenceNumber(hb.last_sn.0 + 1),
7195                        num_bits: 0,
7196                        bitmap: Vec::new(),
7197                    },
7198                    count,
7199                    final_flag: true,
7200                };
7201                let (body, flags) = ack.write_body(true);
7202                if let Some(dg) = wrap(SubmessageId::AckNack, &body, flags) {
7203                    out.push(dg);
7204                }
7205            }
7206            // FastDDS' reader requests (preemptive ACKNACK to our 0xff0101c2
7207            // writer) our secure-SPDP data reliably -> deliver DATA(SN=1) +
7208            // HEARTBEAT(1,1), otherwise FastDDS' reader never matches and
7209            // sends no crypto_tokens.
7210            ParsedSubmessage::AckNack(a) if a.writer_id == secure_writer => {
7211                if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7212                    if let Ok(data_dg) = beacon.serialize_secure() {
7213                        out.push(protect_secure_spdp(rt, &data_dg).unwrap_or(data_dg));
7214                    }
7215                }
7216                count = count.wrapping_add(1);
7217                let hbsm = HeartbeatSubmessage {
7218                    reader_id: secure_reader,
7219                    writer_id: secure_writer,
7220                    first_sn: SequenceNumber(1),
7221                    last_sn: SequenceNumber(1),
7222                    count,
7223                    final_flag: false,
7224                    liveliness_flag: false,
7225                    group_info: None,
7226                };
7227                let (body, flags) = hbsm.write_body(true);
7228                if let Some(dg) = wrap(SubmessageId::Heartbeat, &body, flags) {
7229                    out.push(dg);
7230                }
7231            }
7232            _ => {}
7233        }
7234    }
7235    out
7236}
7237
7238/// FastDDS interop (phase 2b): builds a secure-SPDP HEARTBEAT (writer
7239/// 0xff0101c2, first=1/last=1) with INFO_DST to `peer_prefix`. Sent periodically per
7240/// discovered peer, so FastDDS' reliable secure-SPDP reader is solicited to a
7241/// (preemptive) ACKNACK and matches our writer.
7242#[cfg(feature = "security")]
7243fn build_secure_spdp_heartbeat(
7244    local_prefix: GuidPrefix,
7245    peer_prefix: GuidPrefix,
7246    count: i32,
7247) -> Option<Vec<u8>> {
7248    use zerodds_rtps::header::RtpsHeader;
7249    use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
7250    use zerodds_rtps::submessages::HeartbeatSubmessage;
7251    use zerodds_rtps::wire_types::SequenceNumber;
7252    let hb = HeartbeatSubmessage {
7253        reader_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
7254        writer_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
7255        first_sn: SequenceNumber(1),
7256        last_sn: SequenceNumber(1),
7257        count,
7258        final_flag: false,
7259        liveliness_flag: false,
7260        group_info: None,
7261    };
7262    let (body, flags) = hb.write_body(true);
7263    let blen = u16::try_from(body.len()).ok()?;
7264    let header = RtpsHeader::new(VendorId::ZERODDS, local_prefix);
7265    let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
7266    dg.extend_from_slice(&header.to_bytes());
7267    let info = SubmessageHeader {
7268        submessage_id: SubmessageId::InfoDst,
7269        flags: FLAG_E_LITTLE_ENDIAN,
7270        octets_to_next_header: 12,
7271    };
7272    dg.extend_from_slice(&info.to_bytes());
7273    dg.extend_from_slice(&peer_prefix.to_bytes());
7274    let sh = SubmessageHeader {
7275        submessage_id: SubmessageId::Heartbeat,
7276        flags: flags | FLAG_E_LITTLE_ENDIAN,
7277        octets_to_next_header: blen,
7278    };
7279    dg.extend_from_slice(&sh.to_bytes());
7280    dg.extend_from_slice(&body);
7281    Some(dg)
7282}
7283
7284/// FastDDS interop: SEC-protects the secure-SPDP DATA (0xff0101c2) under
7285/// `discovery_protection != NONE` — FastDDS then encrypts the secure-SPDP DATA
7286/// (like the secure SEDP), and a PLAIN secure SPDP is discarded. Wraps
7287/// the DATA submessage with the per-endpoint writer key (0xff0101c2) as
7288/// SEC_PREFIX/BODY/POSTFIX; framing submessages (INFO_*) stay. Without
7289/// discovery_protection (common subset) passthrough. `None` on a crypto error.
7290#[cfg(feature = "security")]
7291fn protect_secure_spdp(rt: &DcpsRuntime, datagram: &[u8]) -> Option<Vec<u8>> {
7292    let gate = rt.config.security.as_ref()?;
7293    if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None
7294        || datagram.len() < 20
7295    {
7296        return Some(datagram.to_vec());
7297    }
7298    let h = local_endpoint_crypto_handle(
7299        rt,
7300        EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
7301        true,
7302    )?;
7303    let mut out = datagram[..20].to_vec();
7304    for (id, start, total) in walk_submessages(datagram) {
7305        let submsg = &datagram[start..start + total];
7306        if id == SMID_DATA {
7307            match gate.encode_data_datawriter_by_handle(h, submsg) {
7308                Ok(s) => out.extend_from_slice(&s),
7309                Err(_) => return None,
7310            }
7311        } else {
7312            out.extend_from_slice(submsg);
7313        }
7314    }
7315    Some(out)
7316}
7317
7318/// Worker: blocks on the SPDP multicast socket, dispatches SPDP beacons +
7319/// WLP heartbeats that come in over multicast.
7320fn recv_spdp_multicast_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7321    apply_thread_tuning(
7322        "recv-spdp-mc",
7323        rt.config.recv_thread_priority,
7324        rt.config.recv_thread_cpus.as_deref(),
7325    );
7326    while !stop.load(Ordering::Relaxed) {
7327        let elapsed = rt.start_instant.elapsed();
7328        let sedp_now = Duration::from_secs(elapsed.as_secs())
7329            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7330        let Ok(dg) = rt.spdp_multicast_rx.recv() else {
7331            continue;
7332        };
7333        #[cfg(feature = "security")]
7334        let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7335        #[cfg(not(feature = "security"))]
7336        let clear = secure_inbound_bytes(&rt, &dg.data);
7337        if let Some(clear) = clear {
7338            handle_spdp_datagram(&rt, &clear);
7339            // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
7340            // reliably, otherwise FastDDS sends no crypto_tokens.
7341            #[cfg(feature = "security")]
7342            for ack in secure_spdp_reader_acks(&rt, &clear) {
7343                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7344                    let _ = rt.spdp_unicast.send(&loc, &ack);
7345                }
7346            }
7347            // WLP heartbeats arrive on the SPDP multicast socket
7348            // (the sender sends them to the SPDP multicast group).
7349            // handle_spdp_datagram ignores them, so we also feed
7350            // the same buffer into the WLP endpoint. A
7351            // secure-WLP DATA is participant-key SEC-protected → decode
7352            // it first (like secure SEDP in the metatraffic loop), otherwise
7353            // wlp.handle_datagram would only see the SEC block.
7354            #[cfg(feature = "security")]
7355            let wlp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
7356                let mut pk = [0u8; 12];
7357                pk.copy_from_slice(&clear[8..20]);
7358                unprotect_user_datagram(&rt, &clear, &pk)
7359            } else {
7360                None
7361            };
7362            #[cfg(feature = "security")]
7363            let wlp_input: &[u8] = wlp_decoded.as_deref().unwrap_or(&clear);
7364            #[cfg(not(feature = "security"))]
7365            let wlp_input: &[u8] = &clear;
7366            if let Ok(mut wlp) = rt.wlp.lock() {
7367                let _ = wlp.handle_datagram(wlp_input, sedp_now);
7368            }
7369        }
7370    }
7371}
7372
7373/// Worker: blocks on SPDP unicast (= metatraffic socket), dispatches
7374/// SPDP reverse beacons + SEDP + WLP + security builtin.
7375fn recv_metatraffic_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7376    apply_thread_tuning(
7377        "recv-meta",
7378        rt.config.recv_thread_priority,
7379        rt.config.recv_thread_cpus.as_deref(),
7380    );
7381    while !stop.load(Ordering::Relaxed) {
7382        let elapsed = rt.start_instant.elapsed();
7383        let sedp_now = Duration::from_secs(elapsed.as_secs())
7384            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7385        let Ok(dg) = rt.spdp_unicast.recv() else {
7386            continue;
7387        };
7388        #[cfg(feature = "security")]
7389        let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7390        #[cfg(not(feature = "security"))]
7391        let clear = secure_inbound_bytes(&rt, &dg.data);
7392        if let Some(clear) = clear {
7393            // A single recv call, both handlers on the same
7394            // datagram. SPDP first (Cyclone reverse beacons), then
7395            // SEDP, then WLP, then security builtin.
7396            handle_spdp_datagram(&rt, &clear);
7397            // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
7398            // reliably (they arrive unicast over the metatraffic socket).
7399            #[cfg(feature = "security")]
7400            for ack in secure_spdp_reader_acks(&rt, &clear) {
7401                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7402                    let _ = rt.spdp_unicast.send(&loc, &ack);
7403                }
7404            }
7405            // Protected discovery: secure-SEDP DATA is SEC_* submessage-
7406            // protected (the sender's participant data key). Before the SEDP parse
7407            // decode it with the sender prefix (RTPS header bytes[8..20]); for
7408            // plaintext SEDP (no SEC_*) unprotect_user_datagram returns None
7409            // and we use `clear` unchanged.
7410            #[cfg(feature = "security")]
7411            let sedp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
7412                let mut pk = [0u8; 12];
7413                pk.copy_from_slice(&clear[8..20]);
7414                unprotect_user_datagram(&rt, &clear, &pk)
7415            } else {
7416                None
7417            };
7418            // OPEN (phase 3, internal/security/per-endpoint-crypto-followup.md):
7419            // if `unprotect_user_datagram` fails for a secure-SEDP DATA
7420            // (cyclone's per-endpoint token not yet installed — race),
7421            // `sedp_input` falls back to the SEC_* bytes and the DATA is discarded.
7422            // Cross-vendor (discovery=ENCRYPT) must make this deterministic:
7423            // treat the reliable secure-SEDP DATA as not-received (NACK,
7424            // no SN advance), so the re-send after token install decodes.
7425            #[cfg(feature = "security")]
7426            let sedp_input: &[u8] = sedp_decoded.as_deref().unwrap_or(&clear);
7427            #[cfg(not(feature = "security"))]
7428            let sedp_input: &[u8] = &clear;
7429            let events = {
7430                if let Ok(mut sedp) = rt.sedp.lock() {
7431                    sedp.handle_datagram(sedp_input, sedp_now).ok()
7432                } else {
7433                    None
7434                }
7435            };
7436            if let Some(ev) = events {
7437                if !ev.is_empty() {
7438                    run_matching_pass(&rt);
7439                    apply_sedp_removals(&rt, &ev);
7440                    push_sedp_events_to_builtin_readers(&rt, &ev);
7441                }
7442            }
7443
7444            // Secure WLP (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER) is, like
7445            // secure SEDP, participant-key SEC-protected → feed the decoded variant
7446            // (sedp_input), not the still SEC-wrapped `clear`. For
7447            // plaintext WLP, sedp_input == clear.
7448            let wlp_resends = if let Ok(mut wlp) = rt.wlp.lock() {
7449                let _ = wlp.handle_datagram(sedp_input, sedp_now);
7450                // Reliable resend: if the peer NACKs our (secure-)WLP writer,
7451                // we re-emit the missing beats (cyclone treats WLP as
7452                // reliable; without a resend it would never get the liveliness assertion).
7453                wlp.wlp_acknack_resends(sedp_input)
7454            } else {
7455                Vec::new()
7456            };
7457            for beat in wlp_resends {
7458                if let Some(secured) = protect_wlp_outbound(&rt, &beat) {
7459                    for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7460                        let _ = rt.spdp_unicast.send(&loc, &secured);
7461                    }
7462                }
7463            }
7464            for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
7465                send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7466            }
7467        }
7468    }
7469}
7470
7471/// Supervisor: wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) — same-host SHM
7472/// receive. Spawns **one dedicated receive thread per bound SHM consumer**, so
7473/// each blocks on its own segment futex ([`PosixShmTransport::recv`] →
7474/// `wait_for_frame`) and dispatches a sample the instant it lands.
7475///
7476/// History: the original single loop iterated all consumers round-robin and
7477/// called the blocking `recv()` on each in turn. With N consumers the
7478/// worst-case per-sample latency was `(N-1) × recv_timeout` (1 ms each, see
7479/// [`crate::same_host_shm::shm_config_for_pair`]) — a single thread cannot wait
7480/// on N futexes at once, so the active consumer's sample waited while the loop
7481/// sat in idle consumers' `recv()` timeouts. Fine for 1-2 same-host peers, but
7482/// it dominated at the many-endpoint scale ROS hits (user topics +
7483/// `ros_discovery_info` + parameter services + `rosout` = a dozen same-host SHM
7484/// consumers), turning a ~30 µs delivery into ~570 µs. The documented fix
7485/// ("multiple threads or epoll-style multiplexing") is realized here as one
7486/// thread per consumer. The supervisor only polls *membership* (discovery-rate,
7487/// not the data path) to spawn/reap workers.
7488#[cfg(feature = "same-host-shm")]
7489fn recv_user_shm_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7490    use crate::same_host::{Role, SameHostState};
7491    use zerodds_transport_shm::PosixShmTransport;
7492
7493    apply_thread_tuning(
7494        "recv-shm-sup",
7495        rt.config.recv_thread_priority,
7496        rt.config.recv_thread_cpus.as_deref(),
7497    );
7498    // segment-id → (per-worker stop flag, join handle).
7499    let mut workers: std::collections::HashMap<
7500        [u8; 16],
7501        (Arc<AtomicBool>, thread::JoinHandle<()>),
7502    > = std::collections::HashMap::new();
7503    // Membership poll is discovery-rate, NOT the data path: it only detects
7504    // newly-bound / vanished consumers to spawn / reap their worker thread.
7505    let membership_poll = Duration::from_millis(100);
7506    while !stop.load(Ordering::Relaxed) {
7507        let mut live: std::collections::HashSet<[u8; 16]> = std::collections::HashSet::new();
7508        for (w, r, state) in rt.same_host.snapshot() {
7509            let SameHostState::Bound { transport, role } = state else {
7510                continue;
7511            };
7512            if !matches!(role, Role::Consumer) {
7513                continue;
7514            }
7515            let Ok(consumer) = transport.downcast::<PosixShmTransport>() else {
7516                continue;
7517            };
7518            let key = crate::same_host::shm_segment_id_for_pair(w, r);
7519            live.insert(key);
7520            if workers.contains_key(&key) {
7521                continue;
7522            }
7523            let wstop = Arc::new(AtomicBool::new(false));
7524            let (rt_w, stop_w, wstop_w) = (Arc::clone(&rt), Arc::clone(&stop), Arc::clone(&wstop));
7525            if let Ok(h) = thread::Builder::new()
7526                .name(String::from("zdds-recv-shm-c"))
7527                .spawn(move || shm_consumer_recv_loop(rt_w, consumer, stop_w, wstop_w))
7528            {
7529                workers.insert(key, (wstop, h));
7530            }
7531        }
7532        // Reap workers whose consumer disappeared.
7533        let gone: Vec<[u8; 16]> = workers
7534            .keys()
7535            .filter(|k| !live.contains(*k))
7536            .copied()
7537            .collect();
7538        for k in gone {
7539            if let Some((wstop, h)) = workers.remove(&k) {
7540                wstop.store(true, Ordering::Relaxed);
7541                let _ = h.join();
7542            }
7543        }
7544        thread::sleep(membership_poll);
7545    }
7546    // Shutdown: stop + join every worker (each wakes within its 1 ms recv_timeout).
7547    for (_, (wstop, h)) in workers {
7548        wstop.store(true, Ordering::Relaxed);
7549        let _ = h.join();
7550    }
7551}
7552
7553/// One per-consumer SHM receive thread: blocks on this segment's futex and
7554/// dispatches each frame the instant it arrives — no cross-consumer
7555/// serialization. Exits when the runtime stops, the worker is reaped, or the
7556/// segment dies. See [`recv_user_shm_loop`].
7557#[cfg(feature = "same-host-shm")]
7558fn shm_consumer_recv_loop(
7559    rt: Arc<DcpsRuntime>,
7560    consumer: Arc<zerodds_transport_shm::PosixShmTransport>,
7561    stop: Arc<AtomicBool>,
7562    wstop: Arc<AtomicBool>,
7563) {
7564    use zerodds_transport::Transport;
7565    apply_thread_tuning(
7566        "recv-shm-c",
7567        rt.config.recv_thread_priority,
7568        rt.config.recv_thread_cpus.as_deref(),
7569    );
7570    while !stop.load(Ordering::Relaxed) && !wstop.load(Ordering::Relaxed) {
7571        match consumer.recv() {
7572            Ok(dg) => {
7573                let elapsed = rt.start_instant.elapsed();
7574                let sedp_now = Duration::from_secs(elapsed.as_secs())
7575                    + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7576                // Security gate (analogous to the UDP path). SHM is
7577                // same-host-only — if the policy allows plaintext, the
7578                // datagram comes through unchanged.
7579                #[cfg(feature = "security")]
7580                let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7581                #[cfg(not(feature = "security"))]
7582                let clear = secure_inbound_bytes(&rt, &dg.data);
7583                if let Some(clear) = clear {
7584                    handle_user_datagram(&rt, &clear, sedp_now);
7585                }
7586            }
7587            // A timeout is normal — the 1 ms recv_timeout just lets the loop
7588            // re-check the stop flags; an empty segment is not an error.
7589            Err(zerodds_transport::RecvError::Timeout) => {}
7590            // Hard error (broken segment / peer crashed): drop this worker; the
7591            // supervisor respawns if the segment is re-bound, UDP stays fallback.
7592            Err(_) => break,
7593        }
7594    }
7595}
7596
7597/// Worker: blocks on the user-data unicast socket, dispatches
7598/// TypeLookup service replies + user-sample datagrams.
7599///
7600/// Int-1 (Spec `zerodds-zero-copy-1.0` §9): with the feature
7601/// `recvmmsg-batch` on Linux the loop uses `recv_batch_linux` and
7602/// fetches up to 32 datagrams per syscall — a 7-8x throughput boost.
7603/// On an empty batch the path falls back to single-recv() so
7604/// the recv thread does not spin in a busy loop at low traffic.
7605fn recv_user_data_loop(
7606    rt: Arc<DcpsRuntime>,
7607    socket: Arc<dyn Transport + Send + Sync>,
7608    stop: Arc<AtomicBool>,
7609) {
7610    apply_thread_tuning(
7611        "recv-user",
7612        rt.config.recv_thread_priority,
7613        rt.config.recv_thread_cpus.as_deref(),
7614    );
7615    // recvmmsg-batch (Linux + feature) needs the concrete UdpSocket
7616    // under the trait. With a trait-object transport this is not directly
7617    // accessible — we fall back to single-recv(). recvmmsg is
7618    // a UDP optimization; once TCP/SHM transports are to be mixed,
7619    // it is no longer worth it. For a pure UDPv4 user transport
7620    // this costs ~5-10% throughput in Linux batch mode (measured 2026-05).
7621    while !stop.load(Ordering::Relaxed) {
7622        let elapsed = rt.start_instant.elapsed();
7623        let sedp_now = Duration::from_secs(elapsed.as_secs())
7624            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7625        let Ok(dg) = socket.recv() else {
7626            continue;
7627        };
7628        dispatch_user_datagram(&rt, &dg, sedp_now);
7629        // D.5e Phase 3 — incoming user data may solicit an ACKNACK or advance a
7630        // reliable reader: wake the scheduler tick immediately (no 5 ms tail).
7631        rt.raise_tick_wake();
7632    }
7633}
7634
7635/// Helper: dispatches a single user datagram through the security gate +
7636/// TypeLookup + handle_user_datagram. Shared by the single-recv and the
7637/// recvmmsg batch path.
7638fn dispatch_user_datagram(
7639    rt: &Arc<DcpsRuntime>,
7640    dg: &zerodds_transport::ReceivedDatagram,
7641    sedp_now: Duration,
7642) {
7643    #[cfg(feature = "security")]
7644    let clear = secure_inbound_bytes(rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7645    #[cfg(not(feature = "security"))]
7646    let clear = secure_inbound_bytes(rt, &dg.data);
7647    if let Some(clear) = clear {
7648        // TypeLookup service first — if the frame is addressed to
7649        // TL_SVC_*_READER, it does not go to a
7650        // user reader. Other frames fall through.
7651        if !dispatch_type_lookup_datagram(rt, &clear, &dg.source) {
7652            handle_user_datagram(rt, &clear, sedp_now);
7653        }
7654    }
7655}
7656
7657/// Worker: periodic outbound tasks + per-interface inbound
7658/// (non-blocking) + housekeeping. Sleeps `tick_period` between
7659/// iterations.
7660fn tick_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7661    apply_thread_tuning(
7662        "tick",
7663        rt.config.tick_thread_priority,
7664        rt.config.tick_thread_cpus.as_deref(),
7665    );
7666    let mut st = TickState::new(&rt);
7667    while !stop.load(Ordering::Relaxed) {
7668        run_tick_iteration(Arc::clone(&rt), &mut st);
7669        // Housekeeping runs inline here in the classic fixed-period path,
7670        // exactly as before (every `tick_period`, same cadence).
7671        tick_housekeep(&rt, rt.start_instant.elapsed());
7672        std::thread::sleep(rt.config.tick_period);
7673    }
7674}
7675
7676/// D.5e Phase 3 — idle park cap for a discovery-only participant (no user
7677/// endpoints): how long the scheduler tick worker may sleep when nothing but
7678/// SPDP/WLP is pending. SPDP/WLP fire on their own (longer) periods, so this is
7679/// just a safety heartbeat — well above the 5 ms poll it replaces.
7680const SCHEDULER_IDLE_FLOOR: Duration = Duration::from_millis(250);
7681
7682/// Earliest instant the scheduler tick worker must next run `run_tick_iteration`
7683/// so no periodic work is delayed: never past the next SPDP announce, and —
7684/// while user endpoints exist — capped at `tick_period` so HEARTBEAT/ACKNACK/
7685/// deadline/lifespan/liveliness keep their current cadence (identical wire
7686/// behaviour). With no user endpoints, parks up to [`SCHEDULER_IDLE_FLOOR`].
7687/// Active traffic is handled out-of-band by `raise_tick_wake` (immediate).
7688fn next_tick_deadline(rt: &Arc<DcpsRuntime>, st: &TickState) -> Instant {
7689    let now = Instant::now();
7690    let fine_cap = if rt.has_user_endpoints() {
7691        rt.config.tick_period
7692    } else {
7693        SCHEDULER_IDLE_FLOOR
7694    };
7695    st.next_announce.min(now + fine_cap).max(now)
7696}
7697
7698/// D.5e Phase 3 B-2 — the kinds of work the deadline-heap scheduler fires as
7699/// distinct heap events, each re-armed at its own next deadline.
7700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7701enum TickEvent {
7702    /// Periodic SPDP announce + reliable outbound (SEDP / WLP / user HEARTBEAT /
7703    /// ACKNACK) + secondary inbound poll — the wire-producing tick
7704    /// ([`run_tick_iteration`]), re-armed at [`next_tick_deadline`].
7705    Tick,
7706    /// Deadline / lifespan / liveliness housekeeping ([`tick_housekeep`]),
7707    /// re-armed at the **exact** next QoS due-instant (no fixed quantum).
7708    Housekeep,
7709}
7710
7711/// D.5e Phase 3 — event-driven scheduler tick worker. Replaces the fixed-period
7712/// `tick_loop` sleep with a deadline-heap park. Two independent event streams:
7713/// [`TickEvent::Tick`] drives the **unchanged** `run_tick_iteration` (wire
7714/// output byte-identical to `tick_loop`), re-armed at [`next_tick_deadline`];
7715/// [`TickEvent::Housekeep`] runs the QoS checks, re-armed at their exact next
7716/// due-instant so a deadline/lifespan/liveliness fires on time instead of up to
7717/// one `tick_period` late, and an idle participant parks long. A write/recv
7718/// `raise_tick_wake` wakes **both** immediately, so freshly-armed QoS windows
7719/// are picked up without delay.
7720fn scheduler_tick_loop(
7721    rt: Arc<DcpsRuntime>,
7722    stop: Arc<AtomicBool>,
7723    mut scheduler: crate::scheduler::Scheduler<TickEvent>,
7724    handle: crate::scheduler::SchedulerHandle<TickEvent>,
7725) {
7726    apply_thread_tuning(
7727        "tick",
7728        rt.config.tick_thread_priority,
7729        rt.config.tick_thread_cpus.as_deref(),
7730    );
7731    let mut st = TickState::new(&rt);
7732    // Prime both event streams immediately.
7733    handle.raise_now(TickEvent::Tick);
7734    handle.raise_now(TickEvent::Housekeep);
7735    loop {
7736        let (due, stopped) = scheduler.park_due_batch();
7737        if stopped || stop.load(Ordering::Relaxed) {
7738            break;
7739        }
7740        if due.is_empty() {
7741            continue; // woken with nothing due yet — re-evaluate.
7742        }
7743        // Coalesce: a batch of wakes maps to at most ONE run of each kind.
7744        let mut do_tick = false;
7745        let mut do_housekeep = false;
7746        for ev in due {
7747            match ev {
7748                TickEvent::Tick => do_tick = true,
7749                TickEvent::Housekeep => do_housekeep = true,
7750            }
7751        }
7752        if do_tick {
7753            rt.tick_wake_pending.store(false, Ordering::Release);
7754            run_tick_iteration(Arc::clone(&rt), &mut st);
7755            if stop.load(Ordering::Relaxed) {
7756                break;
7757            }
7758            handle.raise_at(next_tick_deadline(&rt, &st), TickEvent::Tick);
7759        }
7760        if do_housekeep {
7761            let next = tick_housekeep(&rt, rt.start_instant.elapsed());
7762            if stop.load(Ordering::Relaxed) {
7763                break;
7764            }
7765            // Park exactly until the next QoS due-instant; nothing pending →
7766            // idle floor (a later write re-arms via `raise_tick_wake`).
7767            let deadline = match next {
7768                Some(due_nanos) => rt.start_instant + Duration::from_nanos(due_nanos),
7769                None => Instant::now() + SCHEDULER_IDLE_FLOOR,
7770            };
7771            handle.raise_at(deadline, TickEvent::Housekeep);
7772        }
7773    }
7774}
7775
7776/// Per-iteration mutable state of the runtime tick. Held across iterations so
7777/// the same body ([`run_tick_iteration`]) can be driven from either the
7778/// dedicated `zdds-tick` thread (default) or an external executor — tokio via
7779/// [`DcpsRuntime::tick_driver`] / async `spawn_in_tokio`
7780/// (zerodds-async-1.0 §4).
7781struct TickState {
7782    /// Multicast target locator to which we send SPDP beacons.
7783    mc_target: Locator,
7784    /// Next instant at which a periodic SPDP announce is due.
7785    next_announce: Instant,
7786    /// Number of SPDP announces already sent. Drives the C3 initial
7787    /// announcement burst: as long as `< initial_announce_count` **and** no
7788    /// peer discovered yet, announces happen at `initial_announce_period` cadence
7789    /// instead of the full `spdp_period` — so discovery over lossy/power-save WiFi
7790    /// does not fail on lost first beacons.
7791    announces_done: u32,
7792    /// FastDDS interop: count for the periodic secure-SPDP HEARTBEATs
7793    /// (0xff0101c2). Must increase, otherwise FastDDS' reader ignores follow-up HBs.
7794    #[cfg(feature = "security")]
7795    secure_hb_count: i32,
7796}
7797
7798impl TickState {
7799    fn new(rt: &Arc<DcpsRuntime>) -> Self {
7800        let mc_target = Locator {
7801            kind: LocatorKind::UdpV4,
7802            port: u32::from(
7803                u16::try_from(spdp_multicast_port(rt.domain_id as u32)).unwrap_or(7400),
7804            ),
7805            address: {
7806                let mut a = [0u8; 16];
7807                a[12..].copy_from_slice(&rt.config.spdp_multicast_group.octets());
7808                a
7809            },
7810        };
7811        Self {
7812            mc_target,
7813            next_announce: Instant::now(), // immediately at start
7814            announces_done: 0,
7815            #[cfg(feature = "security")]
7816            secure_hb_count: 0,
7817        }
7818    }
7819}
7820
7821/// One iteration of the runtime's **wire** tick: periodic SPDP announce,
7822/// SEDP/WLP ticks, per-user-writer + per-user-reader ticks, secondary inbound
7823/// poll. QoS housekeeping (deadline/lifespan/liveliness) is **not** part of this
7824/// — each driver calls [`tick_housekeep`] separately (D.5e Phase 3 B-2), so the
7825/// event-driven scheduler can fire it on its own exact-deadline schedule.
7826/// Mutable per-iteration state lives in `st`; the caller waits `tick_period`
7827/// between calls. Factored out of [`tick_loop`] so an external executor can
7828/// drive the tick without the dedicated thread (zerodds-async-1.0 §4).
7829fn run_tick_iteration(rt: Arc<DcpsRuntime>, st: &mut TickState) {
7830    // Monotonic clock relative to runtime start. Used by the SEDP,
7831    // WLP and user tick alike.
7832    let elapsed_since_start = rt.start_instant.elapsed();
7833    let sedp_now = Duration::from_secs(elapsed_since_start.as_secs())
7834        + Duration::from_nanos(u64::from(elapsed_since_start.subsec_nanos()));
7835
7836    // --- Periodic SPDP announce ---
7837    // FU2 cross-vendor (cyclone-trace-documented): a secured participant MUST
7838    // NOT announce before its security builtins are enabled — otherwise
7839    // a token-less/non-secure first beacon goes out, which foreign vendors
7840    // (cyclone: "Non secure remote ... not allowed by security") latch as
7841    // non-secure and, on the later token beacon, treat ONLY as a QoS update
7842    // (no security re-evaluation) → the handshake never starts.
7843    // `config.security.is_some()` = secured runtime; until
7844    // `enable_security_builtins*` installs the stack (snapshot Some) +
7845    // sets the token/security-info on the beacon, we hold the beacon
7846    // back. enable() triggers the first token-carrying beacon via
7847    // `announce_spdp_now()`. Plain runtimes (security None) announce
7848    // immediately as before.
7849    #[cfg(feature = "security")]
7850    let security_pending = rt.config.security.is_some() && rt.security_builtin_snapshot().is_none();
7851    #[cfg(not(feature = "security"))]
7852    let security_pending = false;
7853    if Instant::now() >= st.next_announce && !security_pending {
7854        let secured_beacon: Option<Vec<u8>> = {
7855            if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7856                beacon
7857                    .serialize()
7858                    .ok()
7859                    .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7860            } else {
7861                None
7862            }
7863        };
7864        if let Some(secured) = secured_beacon {
7865            let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7866            // C1 multicast-free discovery: additionally to all configured
7867            // initial peers (ZERODDS_PEERS) — bootstrap without multicast.
7868            rt.send_spdp_to_initial_peers(&secured);
7869            // SPDP unicast fan-out to discovered peers (analogous to WLP-M-2/H-3-H-4):
7870            // codepit-LXC multicast is flaky; if it loses the tokened
7871            // secure beacon, the peer never discovers ZeroDDS as secure and
7872            // NEVER starts the auth handshake (cyclone→ZeroDDS responder hung
7873            // exactly here: HS_DISPATCH=0). From the metatraffic recv socket
7874            // (spdp_unicast), so the source port is correct.
7875            // Periodic directed unicast fan-out to discovered peers:
7876            // codepit-LXC multicast is flaky; if it loses the tokened
7877            // beacon, the peer never discovers ZeroDDS as secure and never starts
7878            // the auth handshake. The unicast refresh (every spdp_period) robustly
7879            // covers lost multicasts + late joiners. (Previously disabled for a
7880            // flaky-diag experiment — reactivated as a regular path,
7881            // complements the event-driven directed response in handle_spdp_datagram.)
7882            for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7883                let _ = rt.spdp_unicast.send(&loc, &secured);
7884            }
7885        }
7886        // FastDDS interop: announce in parallel on the reliable secure-SPDP writer
7887        // (0xff0101c2). FastDDS announces its full secured
7888        // participant data over this channel and gates the crypto-token
7889        // reciprocation on it; without our secure SPDP it never sees ZeroDDS there
7890        // and reciprocates no datawriter/datareader tokens.
7891        #[cfg(feature = "security")]
7892        if rt.config.enable_secure_spdp {
7893            let secure_beacon: Option<Vec<u8>> = {
7894                if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7895                    beacon
7896                        .serialize_secure()
7897                        .ok()
7898                        .and_then(|d| protect_secure_spdp(&rt, &d))
7899                        .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7900                } else {
7901                    None
7902                }
7903            };
7904            if let Some(secured) = secure_beacon {
7905                let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7906                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7907                    let _ = rt.spdp_unicast.send(&loc, &secured);
7908                }
7909            }
7910            // Secure-SPDP HEARTBEAT per peer (INFO_DST), so FastDDS' reader
7911            // — even as a late joiner — is solicited to a (preemptive) ACKNACK
7912            // and matches our 0xff0101c2 writer. Without a HEARTBEAT
7913            // FastDDS does not engage our writer (fastdds->zerodds: 0 ACKNACK).
7914            st.secure_hb_count = st.secure_hb_count.wrapping_add(1);
7915            for p in rt.discovered_participants() {
7916                let peer_prefix = p.data.guid.prefix;
7917                if let Some(hb) =
7918                    build_secure_spdp_heartbeat(rt.guid_prefix, peer_prefix, st.secure_hb_count)
7919                {
7920                    for loc in wlp_unicast_targets(core::slice::from_ref(&p)) {
7921                        let _ = rt.spdp_unicast.send(&loc, &hb);
7922                    }
7923                }
7924            }
7925        }
7926        // C3 WiFi robustness — initial announcement burst: as long as we have
7927        // not discovered a peer yet and the burst count is not exhausted,
7928        // announce at the fast `initial_announce_period` cadence. Over
7929        // lossy/power-save WiFi the first beacons often get lost in the cold-start
7930        // or sleep window; a single announce + 5s period
7931        // then leads to `participants=0`. The burst keeps the NIC awake through
7932        // frequent TX, keeps the stateful-firewall pinhole open and
7933        // elicits directed SPDP responses that arrive in the wake windows
7934        // — analogous to FastDDS `initial_announcements`. As soon as a peer
7935        // is discovered, the cadence falls back to the full `spdp_period`.
7936        st.announces_done = st.announces_done.saturating_add(1);
7937        rt.spdp_announce_seq.fetch_add(1, Ordering::Relaxed);
7938        let still_searching = st.announces_done < rt.config.initial_announce_count
7939            && rt.discovered_participants().is_empty();
7940        let period = if still_searching {
7941            rt.config.initial_announce_period
7942        } else {
7943            rt.config.spdp_period
7944        };
7945        st.next_announce = Instant::now() + period;
7946    }
7947
7948    // (SPDP multicast recv: now in `recv_spdp_multicast_loop`.)
7949
7950    // --- SEDP-Tick (outbound HEARTBEAT/Resend/ACKNACK) ---
7951    let sedp_outbound = {
7952        if let Ok(mut sedp) = rt.sedp.lock() {
7953            sedp.tick(sedp_now).unwrap_or_default()
7954        } else {
7955            Vec::new()
7956        }
7957    };
7958    for dg in sedp_outbound {
7959        // Protected discovery: SEC_*-protect secure-SEDP DATA/HEARTBEAT/GAP
7960        // (participant data key). Non-secure SEDP goes unchanged; on a
7961        // crypto error on secure SEDP it is dropped (no plaintext leak).
7962        #[cfg(feature = "security")]
7963        {
7964            if let Some(inner) = protect_sedp_outbound(&rt, &dg.bytes) {
7965                // discovery_protection has SEC-wrapped the secure SEDP per-submessage
7966                // (SEC_PREFIX/BODY/POSTFIX, per-endpoint key). Under
7967                // rtps_protection SRTPS MUST additionally go on top — BOTH layers,
7968                // like cyclone<->cyclone (reference pcap: 0x "clear submsg from
7969                // protected src"). send_discovery_datagram -> secure_outbound_bytes
7970                // would classify the SEC_PREFIX datagram as volatile-Kx (which is
7971                // RIGHTLY SRTPS-exempt, because its key only comes over the volatile
7972                // itself) and skip SRTPS -> cyclone would see the
7973                // secure SEDP clear, discard ACKNACK/HEARTBEAT as "clear submsg
7974                // from protected src" and never re-send the SubscriptionData ->
7975                // ZeroDDS' writer never matches cyclone's reader (wait_for_matched
7976                // timeout). Hence wrap SRTPS EXPLICITLY here instead of via the
7977                // generic exempt heuristic.
7978                let final_bytes: Option<Vec<u8>> = match &rt.config.security {
7979                    Some(gate)
7980                        if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
7981                            != ProtectionLevel::None =>
7982                    {
7983                        gate.transform_outbound(&inner).ok()
7984                    }
7985                    _ => Some(inner),
7986                };
7987                if let Some(fb) = final_bytes {
7988                    for t in dg.targets.iter() {
7989                        if is_routable_user_locator(t) {
7990                            let _ = rt.spdp_unicast.send(t, &fb);
7991                        }
7992                    }
7993                }
7994            }
7995        }
7996        #[cfg(not(feature = "security"))]
7997        send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7998    }
7999
8000    // --- Security-Builtin-Tick ---
8001    // Volatile-Secure-Writer heartbeats + Volatile-Secure-Reader
8002    // ACKNACK/NACK_FRAG. Stateless hat keinen Tick (BestEffort).
8003    if let Some(stack) = rt.security_builtin_snapshot() {
8004        let outbound = {
8005            if let Ok(mut s) = stack.lock() {
8006                // `out` is only mutated under feature="security" (reassign +
8007                // extend in the cfg block below); otherwise unused_mut in the no-security build.
8008                #[allow(unused_mut)]
8009                let mut out = s.poll(sedp_now).unwrap_or_default();
8010                #[cfg(feature = "security")]
8011                if rt.config.security.is_some() {
8012                    // STABLE peer list: `completed_peer_prefixes()` reads
8013                    // `self.handshakes`, which is GC'd after handshake completion
8014                    // → the LATE volatile RESENDS/HEARTBEATs (tick, long after
8015                    // completion) would then find NO peer anymore (`peers.len()!=1`)
8016                    // and go out CLEAR → cyclone discards them as "clear
8017                    // submsg from protected src". The stabler `authenticated_peer_
8018                    // prefixes()` (the installed Kx key stays) — identical to the
8019                    // token-send tick further below.
8020                    let peers: Vec<GuidPrefix> = rt
8021                        .config
8022                        .security
8023                        .as_ref()
8024                        .map(|g| {
8025                            g.authenticated_peer_prefixes()
8026                                .into_iter()
8027                                .map(GuidPrefix::from_bytes)
8028                                .collect()
8029                        })
8030                        .unwrap_or_default();
8031                    // The reliable volatile submessages from poll() (DATA RESENDS
8032                    // + HEARTBEAT + GAP) must — like the first send — be SEC_*-
8033                    // protected (§8.4.2.4, all writer submessages incl.
8034                    // HEARTBEAT). protect_volatile_datagram now protects all
8035                    // is_protected_writer_submessage. With exactly one peer
8036                    // (bench) with its Kx key.
8037                    if peers.len() == 1 {
8038                        let pk = peers[0].to_bytes();
8039                        out = out
8040                            .into_iter()
8041                            .filter_map(|dg| {
8042                                protect_volatile_datagram(&rt, &dg.bytes, &pk).map(|bytes| {
8043                                    zerodds_rtps::message_builder::OutboundDatagram {
8044                                        bytes,
8045                                        targets: dg.targets,
8046                                    }
8047                                })
8048                            })
8049                            .collect();
8050                    }
8051                    // FU2 step 6b: send per-endpoint datawriter/datareader crypto
8052                    // tokens to every authenticated peer as soon as the
8053                    // local user endpoints exist.
8054                    //
8055                    // STABLE peer list instead of `completed_peer_prefixes()`: the
8056                    // handshake entry is GC'd after completion, so a
8057                    // late-matching user writer/reader (user endpoints match
8058                    // AFTER the secure SEDP) would find no tick window in which
8059                    // its per-endpoint token would go out — the peer could then never
8060                    // decode ZeroDDS' user DATA (#29). `authenticated_peer_
8061                    // prefixes()` (the installed data key) stays.
8062                    let token_peers: Vec<GuidPrefix> = rt
8063                        .config
8064                        .security
8065                        .as_ref()
8066                        .map(|g| {
8067                            g.authenticated_peer_prefixes()
8068                                .into_iter()
8069                                .map(GuidPrefix::from_bytes)
8070                                .collect()
8071                        })
8072                        .unwrap_or_default();
8073                    for prefix in token_peers {
8074                        // Per-token dedup (#29): each per-endpoint token
8075                        // exactly once — builtins early, user endpoints
8076                        // as soon as they match. A per-peer guard would
8077                        // block late-matched user endpoints forever.
8078                        let already = rt
8079                            .endpoint_tokens_sent
8080                            .read()
8081                            .map(|set| set.clone())
8082                            .unwrap_or_default();
8083                        let pending = pending_endpoint_tokens(
8084                            prepare_endpoint_crypto_tokens(&rt, prefix),
8085                            &already,
8086                        );
8087                        for ep_msg in pending {
8088                            let key = endpoint_token_key(&ep_msg);
8089                            out.extend(protect_volatile_outbound(
8090                                &rt,
8091                                prefix,
8092                                s.volatile_writer
8093                                    .write_with_heartbeat(&ep_msg, sedp_now)
8094                                    .unwrap_or_default(),
8095                            ));
8096                            if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
8097                                set.insert(key);
8098                            }
8099                        }
8100                    }
8101                }
8102                out
8103            } else {
8104                Vec::new()
8105            }
8106        };
8107        for dg in outbound {
8108            send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8109        }
8110    }
8111
8112    // --- WLP-Tick (Writer-Liveliness-Protocol Heartbeats) ---
8113    //
8114    // RTPS 2.5 §8.4.13: WLP heartbeats are metatraffic.
8115    // Spec recommendation: multicast to all known peers, one
8116    // heartbeat per `lease_duration / 3`. We send via the
8117    // SPDP multicast sender — that is the same socket that
8118    // sends out the SPDP beacons, and it ensures that all
8119    // peers see the WLP pulses without the runtime having to
8120    // look up a unicast locator per peer.
8121    let wlp_outbound = {
8122        if let Ok(mut wlp) = rt.wlp.lock() {
8123            // Use the secure-WLP entity when liveliness_protection != NONE
8124            // (set idempotently per tick — follows the current governance).
8125            wlp.set_secure(wlp_liveliness_protected(&rt));
8126            wlp.tick(sedp_now).unwrap_or(None)
8127        } else {
8128            None
8129        }
8130    };
8131    if let Some(bytes) = wlp_outbound {
8132        // Under liveliness_protection != NONE the secure-WLP DATA is protected
8133        // with the participant key (§8.4.2.4); otherwise rtps-level/plaintext.
8134        if let Some(secured) = protect_wlp_outbound(&rt, &bytes) {
8135            // Multicast to all peers (spec recommendation §8.4.13)...
8136            let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
8137            // ...plus unicast to every discovered peer (M-2), so WLP also
8138            // arrives without multicast (container/cloud). From the metatraffic recv
8139            // socket (spdp_unicast), so the source port is correct (cf. H-3/H-4).
8140            for loc in wlp_unicast_targets(&rt.discovered_participants()) {
8141                let _ = rt.spdp_unicast.send(&loc, &secured);
8142            }
8143        }
8144    }
8145
8146    // (Metatraffic unicast recv: now in `recv_metatraffic_loop`.)
8147
8148    // --- User-Writer-Tick (HEARTBEAT + Resends) ---
8149    //
8150    // Security: per-target serializer. A datagram can go to
8151    // multiple reader locators. Per target we pull it
8152    // individually through `secure_outbound_for_target`, so the
8153    // wire payload matches the protection class of the respective reader.
8154    let user_writer_outbound: Vec<(EntityId, _)> = {
8155        let mut all = Vec::new();
8156        for (eid, arc) in rt.writer_slots_snapshot() {
8157            if let Ok(mut slot) = arc.lock() {
8158                if let Ok(dgs) = slot.writer.tick(sedp_now) {
8159                    for dg in dgs {
8160                        all.push((eid, dg));
8161                    }
8162                }
8163            }
8164        }
8165        all
8166    };
8167    for (writer_eid, dg) in user_writer_outbound {
8168        for t in dg.targets.iter() {
8169            if !is_routable_user_locator(t) {
8170                continue;
8171            }
8172            if let Some(secured) = secure_outbound_for_target(&rt, writer_eid, &dg.bytes, t) {
8173                send_on_best_interface(&rt, t, &secured);
8174            }
8175        }
8176    }
8177
8178    // --- User-Reader-Tick-Outbound (ACKNACK / NACK_FRAG) ---
8179    let user_reader_outbound: Vec<_> = {
8180        let mut all = Vec::new();
8181        for (_eid, arc) in rt.reader_slots_snapshot() {
8182            if let Ok(mut slot) = arc.lock() {
8183                if let Ok(dgs) = slot.reader.tick_outbound(sedp_now) {
8184                    all.extend(dgs);
8185                }
8186            }
8187        }
8188        all
8189    };
8190    for dg in user_reader_outbound {
8191        if let Some(secured) = protect_user_reader_datagram(&rt, &dg.bytes) {
8192            for t in dg.targets.iter() {
8193                if is_routable_user_locator(t) {
8194                    let _ = rt.user_unicast.send(t, &secured);
8195                }
8196            }
8197        }
8198    }
8199
8200    // (User-data unicast recv: now in `recv_user_data_loop`.)
8201
8202    // --- Per-interface inbound ---
8203    //
8204    // Each pool binding is polled non-blocking; the
8205    // received datagram goes through `secure_inbound_bytes` with
8206    // the matching NetInterface class. This lets the
8207    // PolicyEngine make interface-specific decisions
8208    // (e.g. accept loopback-plain on a protected domain).
8209    //
8210    // The non-blocking semantics are achieved by each socket
8211    // in `bind_all` holding a short read timeout — see
8212    // `OutboundSocketPool::bind_all`. Without a timeout the
8213    // event loop would hang on an empty binding per tick.
8214    #[cfg(feature = "security")]
8215    if let Some(pool) = &rt.outbound_pool {
8216        for binding in &pool.bindings {
8217            while let Ok(dg) = binding.socket.recv() {
8218                let iface = binding.spec.kind.clone();
8219                if let Some(clear) = secure_inbound_bytes(&rt, &dg.data, &iface) {
8220                    // Try SPDP first (reverse beacons), then
8221                    // SEDP, then user data — same dispatch as
8222                    // for the legacy sockets.
8223                    handle_spdp_datagram(&rt, &clear);
8224                    let events = rt
8225                        .sedp
8226                        .lock()
8227                        .ok()
8228                        .and_then(|mut s| s.handle_datagram(&clear, sedp_now).ok());
8229                    if let Some(ev) = events {
8230                        if !ev.is_empty() {
8231                            run_matching_pass(&rt);
8232                            apply_sedp_removals(&rt, &ev);
8233                            push_sedp_events_to_builtin_readers(&rt, &ev);
8234                        }
8235                    }
8236                    if !dispatch_type_lookup_datagram(&rt, &clear, &dg.source) {
8237                        handle_user_datagram(&rt, &clear, sedp_now);
8238                    }
8239                    // DDS-Security 1.2 §7.4.2 Builtin-Endpoints
8240                    for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
8241                        send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8242                    }
8243                }
8244            }
8245        }
8246    }
8247
8248    // Housekeeping (deadline/lifespan/liveliness) runs as a separate
8249    // `tick_housekeep` call of the respective driver (tick_loop /
8250    // tick_driver / scheduler_tick_loop) — see `tick_housekeep`.
8251
8252    // Diagnostic: mark this iteration complete so `tick_count()` advances
8253    // whether driven by the internal thread or an external executor.
8254    rt.tick_seq.fetch_add(1, Ordering::Relaxed);
8255}
8256
8257/// Min tracker for the earliest "next-due" instant (nanos in the runtime
8258/// `elapsed` time base) across multiple housekeeping sources.
8259struct NextDue(Option<u64>);
8260
8261impl NextDue {
8262    fn new() -> Self {
8263        Self(None)
8264    }
8265    fn note(&mut self, due_nanos: u64) {
8266        self.0 = Some(self.0.map_or(due_nanos, |e| e.min(due_nanos)));
8267    }
8268    fn into_inner(self) -> Option<u64> {
8269        self.0
8270    }
8271}
8272
8273/// D.5e Phase 3 B-2 — the time-driven housekeeping checks, factored out of
8274/// [`run_tick_iteration`], so the event-driven scheduler can fire them
8275/// as its own [`TickEvent::Housekeep`] heap event exactly at the next
8276/// due-instant (and `tick_loop`/`tick_driver` call them inline).
8277/// Pure reader/writer-side bookkeeping — **no** cross-vendor wire
8278/// output, the cadence is purely internal.
8279///
8280/// Return value: the earliest instant (nanos in the `elapsed` time base) at which
8281/// a check is due again, or `None` if nothing is currently pending
8282/// (no active deadline/lifespan/liveliness slot) — then the
8283/// scheduler parks until the idle floor resp. until a `raise_tick_wake` signals new
8284/// work.
8285fn tick_housekeep(rt: &Arc<DcpsRuntime>, elapsed: Duration) -> Option<u64> {
8286    let mut next_due = NextDue::new();
8287    // --- Deadline-Monitoring ---
8288    if let Some(d) = check_deadlines(rt, elapsed) {
8289        next_due.note(d);
8290    }
8291    // --- Lifespan-Expire ---
8292    if let Some(d) = expire_by_lifespan(rt, elapsed) {
8293        next_due.note(d);
8294    }
8295    // --- Liveliness lease check (reader side) ---
8296    if let Some(d) = check_liveliness(rt, elapsed) {
8297        next_due.note(d);
8298    }
8299    // --- Writer-side liveliness-lost check ---
8300    if let Some(d) = check_writer_liveliness(rt, elapsed) {
8301        next_due.note(d);
8302    }
8303    next_due.into_inner()
8304}
8305
8306impl DcpsRuntime {
8307    /// Number of completed tick iterations since `start()`. Advances once per
8308    /// tick regardless of whether the internal `zdds-tick` thread or an
8309    /// external executor ([`DcpsRuntime::tick_driver`]) drives it — a stalled
8310    /// value means the periodic tick stopped. Diagnostic only.
8311    #[must_use]
8312    pub fn tick_count(&self) -> u64 {
8313        self.tick_seq.load(Ordering::Relaxed)
8314    }
8315
8316    /// Number of SPDP announces emitted since `start()`. Diagnostic for the C3
8317    /// initial-announcement burst: a fresh participant with no discovered peer
8318    /// advances this at [`RuntimeConfig::initial_announce_period`] for the first
8319    /// [`RuntimeConfig::initial_announce_count`] announces, then slows to
8320    /// `spdp_period`.
8321    #[must_use]
8322    pub fn spdp_announce_count(&self) -> u64 {
8323        self.spdp_announce_seq.load(Ordering::Relaxed)
8324    }
8325
8326    /// Number of discovered topic inconsistencies (DDS 1.4 §2.2.4.2.4).
8327    /// Bumped during matching against the SEDP cache whenever a remote
8328    /// endpoint carries the same `topic_name` but a differing `type_name`
8329    /// than a local endpoint. A delta against the last poll snapshot
8330    /// triggers `on_inconsistent_topic`.
8331    #[must_use]
8332    pub fn inconsistent_topic_count(&self) -> u64 {
8333        self.inconsistent_topic_seq.load(Ordering::Relaxed)
8334    }
8335
8336    /// External tick driver (zerodds-async-1.0 §4). Only meaningful when the
8337    /// runtime was started with [`RuntimeConfig::external_tick`] = `true`,
8338    /// which suppresses the dedicated `zdds-tick` thread. Each
8339    /// [`DcpsTickDriver::tick`] call runs exactly one tick iteration; the
8340    /// caller schedules the next after [`DcpsTickDriver::tick_period`]. The
8341    /// async API's `spawn_in_tokio` uses this to multiplex many participants'
8342    /// tick loops onto a tokio runtime instead of one std::thread each.
8343    #[must_use]
8344    pub fn tick_driver(self: &Arc<Self>) -> DcpsTickDriver {
8345        DcpsTickDriver {
8346            st: TickState::new(self),
8347            rt: Arc::clone(self),
8348        }
8349    }
8350
8351    /// D.5e Phase 3 — wake the scheduler tick worker immediately (new work:
8352    /// a sample written, a HEARTBEAT/DATA/ACKNACK received). Coalesced: many
8353    /// raises between two worker passes collapse into a single wake, so a
8354    /// datagram storm does not flood the channel. No-op unless started with
8355    /// `scheduler_tick`.
8356    pub fn raise_tick_wake(&self) {
8357        // Only the first raiser since the last pass actually sends.
8358        if self.tick_wake_pending.swap(true, Ordering::AcqRel) {
8359            return;
8360        }
8361        if let Ok(guard) = self.tick_wake.lock() {
8362            if let Some(h) = guard.as_ref() {
8363                // Active traffic wakes the reliable tick AND re-evaluates
8364                // housekeeping, so a freshly-armed deadline/lifespan/liveliness
8365                // window is scheduled at once instead of waiting out the park.
8366                h.raise_now(TickEvent::Tick);
8367                h.raise_now(TickEvent::Housekeep);
8368            }
8369        }
8370    }
8371
8372    /// `true` if this participant has any user DataWriter or DataReader — i.e.
8373    /// the fine-grained periodic work (HEARTBEAT / ACKNACK / deadline / lifespan
8374    /// / liveliness) may be due and the scheduler keeps a fine cadence. A pure
8375    /// discovery-only participant parks long.
8376    fn has_user_endpoints(&self) -> bool {
8377        self.user_writers
8378            .read()
8379            .map(|m| !m.is_empty())
8380            .unwrap_or(true)
8381            || self
8382                .user_readers
8383                .read()
8384                .map(|m| !m.is_empty())
8385                .unwrap_or(true)
8386    }
8387}
8388
8389/// Drives a runtime's periodic tick from an external executor (tokio, an
8390/// embedded scheduler, a manual test loop). Obtained via
8391/// [`DcpsRuntime::tick_driver`]; only does useful work when the runtime was
8392/// started with [`RuntimeConfig::external_tick`] = `true`.
8393///
8394/// Typical loop (the async crate's `spawn_in_tokio` shape):
8395///
8396/// ```ignore
8397/// let mut driver = runtime.tick_driver();
8398/// let period = driver.tick_period();
8399/// while !driver.is_stopped() {
8400///     driver.tick();
8401///     tokio::time::sleep(period).await;
8402/// }
8403/// ```
8404pub struct DcpsTickDriver {
8405    rt: Arc<DcpsRuntime>,
8406    st: TickState,
8407}
8408
8409impl DcpsTickDriver {
8410    /// Period the caller should wait between consecutive [`Self::tick`] calls
8411    /// (mirrors the internal `zdds-tick` thread's `tick_period`).
8412    #[must_use]
8413    pub fn tick_period(&self) -> Duration {
8414        self.rt.config.tick_period
8415    }
8416
8417    /// `true` once the runtime is shutting down (set by `Drop`/`stop()`). The
8418    /// driving task must then stop calling [`Self::tick`] and return so the
8419    /// runtime can be dropped cleanly.
8420    #[must_use]
8421    pub fn is_stopped(&self) -> bool {
8422        self.rt.stop.load(Ordering::Relaxed)
8423    }
8424
8425    /// Run one tick iteration: periodic SPDP announce, SEDP/WLP ticks,
8426    /// per-user-writer ticks, deadline/lifespan/liveliness checks. Equivalent
8427    /// to one pass of the internal `zdds-tick` loop body.
8428    pub fn tick(&mut self) {
8429        run_tick_iteration(Arc::clone(&self.rt), &mut self.st);
8430        tick_housekeep(&self.rt, self.rt.start_instant.elapsed());
8431    }
8432}
8433
8434/// Writer-side liveliness-lost detection. Spec §2.2.4.2.10.
8435///
8436/// For all user writers: if a lease duration is set and more time
8437/// has elapsed since the last assert (Automatic = `last_write`, Manual =
8438/// `last_liveliness_assert`) than the
8439/// lease duration allows, the writer counts as
8440/// "not-alive" from the DDS view — `liveliness_lost_count++` and reset the window.
8441///
8442/// Note: with pure best-effort tests + `Automatic` the
8443/// counter typically does not advance — Automatic asserts with every
8444/// `write_user_sample`. Manual mode requires an explicit
8445/// `assert_liveliness` (comes with .4b — until then we already provide
8446/// the detection here, the hot-path trigger triggers it).
8447fn check_writer_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8448    let now_nanos = now.as_nanos() as u64;
8449    let mut next_due = NextDue::new();
8450    for (_eid, arc) in rt.writer_slots_snapshot() {
8451        let Ok(mut slot) = arc.lock() else { continue };
8452        if slot.liveliness_lease_nanos == 0 {
8453            continue;
8454        }
8455        let last = match slot.liveliness_kind {
8456            zerodds_qos::LivelinessKind::Automatic => slot.last_write,
8457            _ => slot.last_liveliness_assert,
8458        };
8459        let last_nanos = match last {
8460            Some(t) => t.as_nanos() as u64,
8461            None => continue,
8462        };
8463        if now_nanos.saturating_sub(last_nanos) >= slot.liveliness_lease_nanos {
8464            slot.liveliness_lost_count = slot.liveliness_lost_count.saturating_add(1);
8465            // Reset the window, so the same lease-window
8466            // overrun does not count in an infinite loop.
8467            // Spec §2.2.3.11: "lease has elapsed" — `>=` is boundary-
8468            // stable and avoids flakiness when tick_period == lease.
8469            slot.last_liveliness_assert = Some(now);
8470            slot.last_write = Some(now);
8471            next_due.note(now_nanos.saturating_add(slot.liveliness_lease_nanos));
8472        } else {
8473            next_due.note(last_nanos.saturating_add(slot.liveliness_lease_nanos));
8474        }
8475    }
8476    next_due.into_inner()
8477}
8478
8479/// Checks for all user readers whether the writer has delivered no sample
8480/// for longer than `lease_duration`. If so: transition
8481/// alive → not_alive, `not_alive_count++`.
8482///
8483/// Automatic liveliness (§2.2.3.11): every write is an implicit assert.
8484/// So we check the reader-side `last_sample_received`.
8485/// Manual kinds come with .4b (explicit assert messages).
8486fn check_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8487    let now_nanos = now.as_nanos() as u64;
8488    let mut next_due = NextDue::new();
8489    for (_eid, arc) in rt.reader_slots_snapshot() {
8490        let Ok(mut slot) = arc.lock() else { continue };
8491        if slot.liveliness_lease_nanos == 0 {
8492            continue;
8493        }
8494        // Until the first sample: consider it alive (optimistic).
8495        let last = match slot.last_sample_received {
8496            Some(t) => t.as_nanos() as u64,
8497            None => continue,
8498        };
8499        // Only a still-alive reader can transition; one already
8500        // not_alive stays so until a new sample arrives (event-driven
8501        // via the recv path) — so no re-schedule needed.
8502        if !slot.liveliness_alive {
8503            continue;
8504        }
8505        if now_nanos.saturating_sub(last) >= slot.liveliness_lease_nanos {
8506            slot.liveliness_alive = false;
8507            slot.liveliness_not_alive_count = slot.liveliness_not_alive_count.saturating_add(1);
8508        } else {
8509            next_due.note(last.saturating_add(slot.liveliness_lease_nanos));
8510        }
8511    }
8512    next_due.into_inner()
8513}
8514
8515/// For all user writers: remove samples from the HistoryCache whose
8516/// insert time + lifespan has elapsed. OMG DDS 1.4 §2.2.3.16:
8517/// "If the duration...elapses and the sample is still in the cache...
8518/// the sample is no longer available to any future DataReaders".
8519///
8520/// Implementation: `sample_insert_times` is a VecDeque, sorted
8521/// by insert time (= SN, because monotonic). Front-pop while expired;
8522/// the highest expired SN runs through via `cache.remove_up_to(sn + 1)`.
8523fn expire_by_lifespan(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8524    let now_nanos = now.as_nanos() as u64;
8525    let mut next_due = NextDue::new();
8526    for (_eid, arc) in rt.writer_slots_snapshot() {
8527        let Ok(mut slot) = arc.lock() else { continue };
8528        if slot.lifespan_nanos == 0 {
8529            continue;
8530        }
8531        let mut highest_expired = None;
8532        while let Some(&(sn, inserted)) = slot.sample_insert_times.front() {
8533            let inserted_nanos = inserted.as_nanos() as u64;
8534            if now_nanos.saturating_sub(inserted_nanos) >= slot.lifespan_nanos {
8535                highest_expired = Some(sn);
8536                slot.sample_insert_times.pop_front();
8537            } else {
8538                break;
8539            }
8540        }
8541        if let Some(sn) = highest_expired {
8542            let _removed = slot
8543                .writer
8544                .remove_samples_up_to(zerodds_rtps::wire_types::SequenceNumber(sn.0 + 1));
8545        }
8546        // Next lifespan due = expiry of the now-oldest sample still
8547        // remaining in the cache. Empty deque → nothing due,
8548        // until a new sample is written (raise_tick_wake covers that).
8549        if let Some(&(_sn, inserted)) = slot.sample_insert_times.front() {
8550            next_due.note((inserted.as_nanos() as u64).saturating_add(slot.lifespan_nanos));
8551        }
8552    }
8553    next_due.into_inner()
8554}
8555
8556/// Checks for all user writers + user readers whether the deadline period
8557/// has been exceeded since the last sample. Every exceedance
8558/// increments the corresponding missed counter by exactly 1
8559/// — regardless of how often `check_deadlines` is called within an
8560/// elapsed window, because we keep setting `last_*`
8561/// to "now" after we have counted.
8562///
8563/// **Init-state semantics:** as long as `last_write`/`last_sample_received`
8564/// is `None` (no real write/sample yet), the deadline
8565/// check does not count. Only after the first real data point does the
8566/// deadline window start. This prevents false misses due to slow
8567/// entity setup (Linux CI/container) before the app even issues a
8568/// write.
8569fn check_deadlines(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8570    let now_nanos = now.as_nanos() as u64;
8571    let mut next_due = NextDue::new();
8572    for (_eid, arc) in rt.writer_slots_snapshot() {
8573        let Ok(mut slot) = arc.lock() else { continue };
8574        if slot.deadline_nanos == 0 {
8575            continue;
8576        }
8577        let Some(last) = slot.last_write.map(|d| d.as_nanos() as u64) else {
8578            // Never written yet → deadline window not active.
8579            continue;
8580        };
8581        if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
8582            slot.offered_deadline_missed_count =
8583                slot.offered_deadline_missed_count.saturating_add(1);
8584            // Reset the window: the next deadline is counted relative
8585            // to the current tick. `>=` is boundary-stable
8586            // (Spec §2.2.3.7: "deadline has elapsed").
8587            slot.last_write = Some(now);
8588            next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
8589        } else {
8590            next_due.note(last.saturating_add(slot.deadline_nanos));
8591        }
8592    }
8593    for (_eid, arc) in rt.reader_slots_snapshot() {
8594        let Ok(mut slot) = arc.lock() else { continue };
8595        if slot.deadline_nanos == 0 {
8596            continue;
8597        }
8598        let Some(last) = slot.last_sample_received.map(|d| d.as_nanos() as u64) else {
8599            continue;
8600        };
8601        if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
8602            slot.requested_deadline_missed_count =
8603                slot.requested_deadline_missed_count.saturating_add(1);
8604            slot.last_sample_received = Some(now);
8605            next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
8606        } else {
8607            next_due.note(last.saturating_add(slot.deadline_nanos));
8608        }
8609    }
8610    next_due.into_inner()
8611}
8612
8613/// For all local writers + readers: matching against the current
8614/// SEDP cache. A cheap re-run when SEDP events came in — idempotent,
8615/// because ReliableWriter/Reader add_*_proxy are idempotent (same
8616/// GUID → replaced).
8617fn run_matching_pass(rt: &Arc<DcpsRuntime>) {
8618    let writer_ids: Vec<EntityId> = rt.writer_eids();
8619    for eid in writer_ids {
8620        rt.match_local_writer_against_cache(eid);
8621    }
8622    let reader_ids: Vec<EntityId> = rt.reader_eids();
8623    for eid in reader_ids {
8624        rt.match_local_reader_against_cache(eid);
8625    }
8626}
8627
8628/// Returns the default-unicast locator of a discovered remote
8629/// participant.
8630fn remote_user_locators(
8631    prefix: GuidPrefix,
8632    discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
8633) -> Vec<Locator> {
8634    match discovered.lock() {
8635        Ok(cache) => cache
8636            .get(&prefix)
8637            .and_then(|p| p.data.default_unicast_locator)
8638            .into_iter()
8639            .collect(),
8640        Err(_) => Vec::new(),
8641    }
8642}
8643
8644/// Determine the destination for user traffic to a remote endpoint.
8645///
8646/// DDSI-RTPS 2.5 §8.5.3.2/§8.5.3.3: the per-endpoint `unicastLocatorList`
8647/// from the SEDP announce is authoritative. §8.5.5: only when it is empty
8648/// does the sender fall back to the participant `DEFAULT_UNICAST_LOCATOR` from
8649/// SPDP.
8650///
8651/// Before this fix ZeroDDS *always* used the participant default — which
8652/// broke OpenDDS interop: OpenDDS stores only the
8653/// placeholder 127.0.0.1:12345 as the participant default and announces the real user locator
8654/// exclusively per-endpoint.
8655fn endpoint_or_default_locators(
8656    endpoint: &[Locator],
8657    prefix: GuidPrefix,
8658    discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
8659) -> Vec<Locator> {
8660    if !endpoint.is_empty() {
8661        return endpoint.to_vec();
8662    }
8663    remote_user_locators(prefix, discovered)
8664}
8665
8666/// Dispatches a received RTPS datagram to matching user readers.
8667/// Decides, based on the `reader_id` in DATA/DATA_FRAG/HEARTBEAT/GAP,
8668/// which local reader is responsible.
8669/// Strip the 4-byte encapsulation header off the received sample payload.
8670/// Returns `None` if the payload is < 4 bytes or carries an unknown
8671/// scheme (PL_CDR variants would not get here; they go via
8672/// SEDP — if we see such a thing on user endpoints, it is garbage).
8673/// Spec §3.2 zerodds-async-1.0: wakes a registered waker
8674/// after every `sample_tx.send`. `take` consumes the waker, to
8675/// avoid double wakeups — the caller registers a new one after
8676/// every `Pending` result.
8677fn wake_async_waker(slot: &alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>) {
8678    if let Ok(mut g) = slot.lock() {
8679        if let Some(w) = g.take() {
8680            w.wake();
8681        }
8682    }
8683}
8684
8685/// Converts a sample delivered by the ReliableReader into a
8686/// `UserSample` channel entry. For `ChangeKind::Alive` the
8687/// CDR encapsulation header is stripped; for lifecycle markers
8688/// the key hash is reconstructed from the bytes.
8689/// Inspect-endpoint tap dispatch for the DCPS receive path.
8690///
8691/// Called in `handle_user_datagram` when a sample is delivered to
8692/// a user reader. Only when the `inspect` feature is
8693/// on; without the feature no code, no branch.
8694#[cfg(feature = "inspect")]
8695fn dispatch_inspect_dcps_receive_tap(topic: &str, reader_id: EntityId, item: &UserSample) {
8696    let payload: Vec<u8> = match item {
8697        UserSample::Alive { payload, .. } => payload.to_vec(),
8698        UserSample::Lifecycle { key_hash, .. } => key_hash.to_vec(),
8699    };
8700    let ts_ns = std::time::SystemTime::now()
8701        .duration_since(std::time::UNIX_EPOCH)
8702        .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
8703        .unwrap_or(0);
8704    let mut corr: u64 = 0;
8705    for (i, byte) in reader_id.entity_key.iter().enumerate() {
8706        corr |= u64::from(*byte) << (i * 8);
8707    }
8708    corr |= u64::from(reader_id.entity_kind as u8) << 24;
8709    let frame = zerodds_inspect_endpoint::Frame::dcps(topic.to_owned(), ts_ns, corr, payload);
8710    zerodds_inspect_endpoint::tap::dispatch(&frame);
8711}
8712
8713fn delivered_to_user_sample(
8714    sample: &zerodds_rtps::reliable_reader::DeliveredSample,
8715    writer_strengths: &alloc::collections::BTreeMap<[u8; 16], i32>,
8716) -> Option<UserSample> {
8717    use zerodds_rtps::history_cache::ChangeKind;
8718    match sample.kind {
8719        ChangeKind::Alive | ChangeKind::AliveFiltered => {
8720            let writer_guid = sample.writer_guid.to_bytes();
8721            let writer_strength = writer_strengths.get(&writer_guid).copied().unwrap_or(0);
8722            // Encapsulation representation from byte[1] of the header
8723            // (RTPS 2.5 §10.5) — BEFORE stripping. 0x00–0x03 = XCDR1
8724            // (CDR/PL_CDR), 0x06–0x0b = XCDR2 (CDR2/D_CDR2/PL_CDR2).
8725            let representation = encap_representation(&sample.payload);
8726            let big_endian = encap_big_endian(&sample.payload);
8727            strip_user_encap_arc(&sample.payload).map(|payload| UserSample::Alive {
8728                payload,
8729                writer_guid,
8730                writer_strength,
8731                representation,
8732                big_endian,
8733                source_timestamp: sample.source_timestamp,
8734            })
8735        }
8736        ChangeKind::NotAliveDisposed
8737        | ChangeKind::NotAliveUnregistered
8738        | ChangeKind::NotAliveDisposedUnregistered => {
8739            // Lifecycle marker: Spec §9.6.4.8 + §9.6.3.9 requires
8740            // `PID_KEY_HASH` in the inline QoS — the reader reads it
8741            // and propagates it via `DeliveredSample.key_hash`.
8742            // Fallback: with non-spec-conformant writers the
8743            // hash falls back to the first 16 bytes of the key-only payload
8744            // (PLAIN_CDR2-BE key holder).
8745            let kh = sample.key_hash.unwrap_or_else(|| {
8746                let mut h = [0u8; 16];
8747                let n = sample.payload.len().min(16);
8748                h[..n].copy_from_slice(&sample.payload[..n]);
8749                h
8750            });
8751            Some(UserSample::Lifecycle {
8752                key_hash: kh,
8753                kind: sample.kind,
8754            })
8755        }
8756    }
8757}
8758
8759/// Returns the XCDR version from the 4-byte encapsulation header
8760/// (RTPS 2.5 §10.5): `0` = XCDR1 (CDR/PL_CDR, encap byte 0x00–0x05),
8761/// `1` = XCDR2 (CDR2/DELIMITED_CDR2/PL_CDR2, encap byte 0x06–0x0b).
8762/// Default `0` for a too-short payload — XCDR1 is the spec baseline.
8763fn encap_representation(payload: &[u8]) -> u8 {
8764    if payload.len() >= 2 && payload[1] >= 0x06 {
8765        1
8766    } else {
8767        0
8768    }
8769}
8770
8771/// Returns the byte order from the 4-byte encapsulation representation
8772/// identifier (RTPS 2.5 §10.5). The repr-id is a big-endian `uint16`; its low
8773/// bit selects the byte order — the `_BE` variants (CDR_BE 0x0000, PL_CDR_BE
8774/// 0x0002, CDR2_BE 0x0006, D_CDR2_BE 0x0008, PL_CDR2_BE 0x000a) are even, the
8775/// `_LE` variants odd. `true` ⇒ big-endian. A too-short / header-less payload
8776/// (e.g. the intra-runtime bare body) defaults to little-endian (`false`).
8777fn encap_big_endian(payload: &[u8]) -> bool {
8778    payload.len() >= 2 && (payload[1] & 0x01) == 0
8779}
8780
8781/// Checks whether `payload` has a known 4-byte encapsulation header.
8782/// Returns `Some(4)` if so (= offset behind the header), `None` if
8783/// no known scheme. Separated in use from [`strip_user_encap`]:
8784/// here only validation without allocation, for the listener zero-copy
8785/// path (lever E / Sprint D.5d).
8786fn validate_user_encap_offset(payload: &[u8]) -> Option<usize> {
8787    if payload.len() < 4 {
8788        return None;
8789    }
8790    // Accept all data-representation schemes (RTPS 2.5 §10.5,
8791    // table 10.3): byte0 = 0x00, byte1 in:
8792    //   0x00/0x01 CDR_BE/LE        (XCDR1 PLAIN_CDR)
8793    //   0x02/0x03 PL_CDR_BE/LE     (XCDR1 parameter list, key serial.)
8794    //   0x06/0x07 CDR2_BE/LE       (XCDR2 PLAIN_CDR2)
8795    //   0x08/0x09 D_CDR2_BE/LE     (XCDR2 DELIMITED_CDR2, @appendable)
8796    //   0x0a/0x0b PL_CDR2_BE/LE    (XCDR2 PL_CDR2, @mutable)
8797    // Cyclone often sends XCDR1, OpenDDS/FastDDS XCDR2. We pass
8798    // all through; the typed decoder picks the correct alignment rule
8799    // based on the `representation` (see `encap_representation`).
8800    if payload[0] != 0x00 {
8801        return None;
8802    }
8803    match payload[1] {
8804        0x00..=0x03 | 0x06..=0x0b => Some(4),
8805        _ => None,
8806    }
8807}
8808
8809/// Zero-copy variant: strips the encap header via range slicing
8810/// on the refcounted `Arc<[u8]>` backing store. No heap alloc.
8811/// Spec: `docs/specs/zerodds-zero-copy-1.0.md` §6 wave 2.
8812fn strip_user_encap_arc(
8813    payload: &alloc::sync::Arc<[u8]>,
8814) -> Option<crate::sample_bytes::SampleBytes> {
8815    validate_user_encap_offset(payload).map(|off| {
8816        crate::sample_bytes::SampleBytes::from_arc_slice(
8817            alloc::sync::Arc::clone(payload),
8818            off..payload.len(),
8819        )
8820    })
8821}
8822
8823#[cfg(test)]
8824fn strip_user_encap(payload: &[u8]) -> Option<alloc::vec::Vec<u8>> {
8825    validate_user_encap_offset(payload).map(|off| payload[off..].to_vec())
8826}
8827
8828/// Bench-only phase-timing accumulators. Active with env
8829/// `ZERODDS_PHASE_TIMING=1`. With `ZERODDS_PHASE_DUMP=1` the
8830/// atexit hook prints the totals on drop of the first runtime.
8831#[doc(hidden)]
8832pub static PHASE_HANDLE_USER_NS: core::sync::atomic::AtomicU64 =
8833    core::sync::atomic::AtomicU64::new(0);
8834#[doc(hidden)]
8835pub static PHASE_HANDLE_USER_CALLS: core::sync::atomic::AtomicU64 =
8836    core::sync::atomic::AtomicU64::new(0);
8837#[doc(hidden)]
8838pub static PHASE_WRITE_USER_NS: core::sync::atomic::AtomicU64 =
8839    core::sync::atomic::AtomicU64::new(0);
8840#[doc(hidden)]
8841pub static PHASE_WRITE_USER_CALLS: core::sync::atomic::AtomicU64 =
8842    core::sync::atomic::AtomicU64::new(0);
8843
8844/// Sub-phases in the `handle_user_datagram` receive hot path:
8845/// 0=decode_datagram, 1=slot-lookup+lock, 2=reader.handle_data,
8846/// 3=delivered_to_user_sample, 4=listener+sender-dispatch.
8847/// Active under `ZERODDS_PHASE_TIMING=1`. Each `Instant::now()` bracket
8848/// costs ~50 ns; at a ~3 µs handle that is ~1.6% per sub-phase.
8849#[doc(hidden)]
8850pub static PHASE_HANDLE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8851    core::sync::atomic::AtomicU64::new(0),
8852    core::sync::atomic::AtomicU64::new(0),
8853    core::sync::atomic::AtomicU64::new(0),
8854    core::sync::atomic::AtomicU64::new(0),
8855    core::sync::atomic::AtomicU64::new(0),
8856];
8857
8858/// Sub-phases in `write_user_sample_borrowed` (sender hot path):
8859/// 0=lookup, 1=lock, 2=write_with_heartbeat, 3=send-loop, 4=reserved.
8860/// The detail drilldown into socket.send_to vs. inproc-peer dispatch was
8861/// done once for the connected-UDP lever (showed send_to as
8862/// 97% of the dispatch path); not permanent in the code, because per-phase
8863/// `Instant::now()` itself costs ~50 ns — at a 6 µs send that
8864/// would be 1% overhead and skews the calibrated measurement.
8865#[doc(hidden)]
8866pub static PHASE_WRITE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8867    core::sync::atomic::AtomicU64::new(0),
8868    core::sync::atomic::AtomicU64::new(0),
8869    core::sync::atomic::AtomicU64::new(0),
8870    core::sync::atomic::AtomicU64::new(0),
8871    core::sync::atomic::AtomicU64::new(0),
8872];
8873
8874fn phase_timing_enabled() -> bool {
8875    static CACHE: core::sync::atomic::AtomicI8 = core::sync::atomic::AtomicI8::new(-1);
8876    let v = CACHE.load(core::sync::atomic::Ordering::Relaxed);
8877    if v >= 0 {
8878        return v == 1;
8879    }
8880    let on = std::env::var("ZERODDS_PHASE_TIMING")
8881        .map(|s| s == "1")
8882        .unwrap_or(false);
8883    CACHE.store(
8884        if on { 1 } else { 0 },
8885        core::sync::atomic::Ordering::Relaxed,
8886    );
8887    on
8888}
8889
8890struct PhaseTimer {
8891    start: std::time::Instant,
8892    ns_acc: &'static core::sync::atomic::AtomicU64,
8893    calls_acc: &'static core::sync::atomic::AtomicU64,
8894}
8895
8896impl Drop for PhaseTimer {
8897    fn drop(&mut self) {
8898        let ns = self.start.elapsed().as_nanos() as u64;
8899        self.ns_acc
8900            .fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8901        self.calls_acc
8902            .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
8903    }
8904}
8905
8906fn handle_user_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], now: Duration) {
8907    let _phase_guard = if phase_timing_enabled() {
8908        Some(PhaseTimer {
8909            start: std::time::Instant::now(),
8910            ns_acc: &PHASE_HANDLE_USER_NS,
8911            calls_acc: &PHASE_HANDLE_USER_CALLS,
8912        })
8913    } else {
8914        None
8915    };
8916    let pt_on = phase_timing_enabled();
8917    let pt_t0 = if pt_on {
8918        Some(std::time::Instant::now())
8919    } else {
8920        None
8921    };
8922    let parsed = match decode_datagram(bytes) {
8923        Ok(p) => p,
8924        Err(_) => return,
8925    };
8926    // DDSI-RTPS §8.3.4: the effective source of each writer submessage is the
8927    // sourceGuidPrefix from the RTPS header. The reader demux needs it to
8928    // distinguish writer proxies with the same EntityId but a different participant
8929    // (fan-in / multiple publishers on the same topic).
8930    let src_prefix = parsed.header.guid_prefix;
8931    if let (Some(t0), true) = (pt_t0, pt_on) {
8932        let ns = t0.elapsed().as_nanos() as u64;
8933        PHASE_HANDLE_SUB_NS[0].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8934    }
8935    // Per-submessage: take the matching slot mutex individually per
8936    // submessage — no global user_writers/user_readers lock anymore.
8937    // With per-submessage granularity, reader datagrams can be processed in parallel
8938    // to writer AckNacks.
8939    //
8940    // RTPS-F1 (DDSI-RTPS §8.3.4 ReceiverState.haveTimestamp): an INFO_TS
8941    // submessage sets the source timestamp applied to every following DATA in
8942    // the same message, until another INFO_TS (or an I-flag clears it). We
8943    // carry it forward and hand it to the reader so it lands in
8944    // `SampleInfo.source_timestamp`.
8945    let mut cur_source_ts: Option<zerodds_rtps::header_extension::HeTimestamp> = None;
8946    for sub in parsed.submessages {
8947        match sub {
8948            ParsedSubmessage::InfoTimestamp(its) => {
8949                cur_source_ts = if its.invalidate {
8950                    None
8951                } else {
8952                    Some(its.timestamp)
8953                };
8954            }
8955            ParsedSubmessage::Data(d) => {
8956                // Sprint D.5d lever B — collect-then-dispatch:
8957                // sample conversion + liveliness update inside slot.lock,
8958                // then listener fire + channel send + waker wake
8959                // OUTSIDE the lock.
8960                //
8961                // Cross-vendor fix 2026-05-19: when reader_id ==
8962                // ENTITYID_UNKNOWN (RTPS spec §8.3.7.2: "deliver to all
8963                // matched readers on this topic"), we iterate over
8964                // ALL reader slots and let `handle_data` filter by
8965                // writer_proxies. Cyclone DDS/FastDDS/RTI send
8966                // user DATA with reader_id=UNKNOWN; without this fan-out
8967                // ZeroDDS would drop every such DATA.
8968                let pt_t1 = if pt_on {
8969                    Some(std::time::Instant::now())
8970                } else {
8971                    None
8972                };
8973                let target_slots: Vec<ReaderSlotArc> = if d.reader_id == EntityId::UNKNOWN {
8974                    let snap = rt.reader_slots_snapshot();
8975                    let mut v = Vec::with_capacity(snap.len());
8976                    v.extend(snap.into_iter().map(|(_, arc)| arc));
8977                    v
8978                } else {
8979                    let mut v = Vec::with_capacity(1);
8980                    if let Some(arc) = rt.reader_slot(d.reader_id) {
8981                        v.push(arc);
8982                    }
8983                    v
8984                };
8985                if let (Some(t1), true) = (pt_t1, pt_on) {
8986                    let ns = t1.elapsed().as_nanos() as u64;
8987                    PHASE_HANDLE_SUB_NS[1].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8988                }
8989                for arc in target_slots {
8990                    // Lever E: alongside the UserSample we carry a
8991                    // zero-copy view on the original `Arc<[u8]>` with
8992                    // the encap offset — the listener can thereby read into
8993                    // the payload without allocation.
8994                    let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
8995                    let listener;
8996                    let waker;
8997                    let sender;
8998                    #[cfg(feature = "inspect")]
8999                    let topic_name;
9000                    let pt_t2 = if pt_on {
9001                        Some(std::time::Instant::now())
9002                    } else {
9003                        None
9004                    };
9005                    {
9006                        let Ok(mut slot) = arc.lock() else { continue };
9007                        let hd_samples: Vec<_> = slot
9008                            .reader
9009                            .handle_data(src_prefix, &d, cur_source_ts)
9010                            .into_iter()
9011                            .collect();
9012                        for sample in hd_samples {
9013                            // A2 TIME_BASED_FILTER (§2.2.3.12): drop alive samples
9014                            // that arrive within minimum_separation of the last
9015                            // delivered sample of the same instance. No-op when
9016                            // the filter is disabled (tbf_min_separation_nanos==0).
9017                            if matches!(
9018                                sample.kind,
9019                                zerodds_rtps::history_cache::ChangeKind::Alive
9020                                    | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9021                            ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9022                            {
9023                                continue;
9024                            }
9025                            // Listener zero-copy view only for alive samples
9026                            // with a valid encap header. Arc::clone is
9027                            // an atomic refcount inc, no data copy.
9028                            let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
9029                                zerodds_rtps::history_cache::ChangeKind::Alive
9030                                | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
9031                                    validate_user_encap_offset(&sample.payload)
9032                                        .map(|off| (Arc::clone(&sample.payload), off))
9033                                }
9034                                _ => None,
9035                            };
9036                            if let Some(item) =
9037                                delivered_to_user_sample(&sample, &slot.writer_strengths)
9038                            {
9039                                items.push((item, listener_view));
9040                            }
9041                        }
9042                        if !items.is_empty() {
9043                            slot.last_sample_received = Some(now);
9044                            slot.samples_delivered_count = slot
9045                                .samples_delivered_count
9046                                .saturating_add(items.len() as u64);
9047                            if !slot.liveliness_alive {
9048                                slot.liveliness_alive = true;
9049                                slot.liveliness_alive_count =
9050                                    slot.liveliness_alive_count.saturating_add(1);
9051                            }
9052                        }
9053                        listener = slot.listener.clone();
9054                        waker = Arc::clone(&slot.async_waker);
9055                        sender = slot.sample_tx.clone();
9056                        #[cfg(feature = "inspect")]
9057                        {
9058                            topic_name = slot.topic_name.clone();
9059                        }
9060                    }
9061                    if let (Some(t2), true) = (pt_t2, pt_on) {
9062                        let ns = t2.elapsed().as_nanos() as u64;
9063                        PHASE_HANDLE_SUB_NS[2].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9064                    }
9065                    let pt_t3 = if pt_on {
9066                        Some(std::time::Instant::now())
9067                    } else {
9068                        None
9069                    };
9070                    // --- Outside slot.lock: dispatch ---
9071                    //
9072                    // Listener and MPSC are exclusive: if a listener
9073                    // (callback) is set, the consumer is on the
9074                    // callback path — the additional `sender.send` +
9075                    // `wake_async_waker` would be pure overhead AND
9076                    // would grow the channel buffer unboundedly
9077                    // (memory leak in callback-only apps). We
9078                    // dispatch either the callback OR the MPSC, not
9079                    // both. A caller (Rust API) that wants take()+listener
9080                    // at the same time simply sets NO listener
9081                    // and polls via take().
9082                    for (item, listener_view) in items {
9083                        let (item_repr, item_be) = if let UserSample::Alive {
9084                            representation,
9085                            big_endian,
9086                            ..
9087                        } = &item
9088                        {
9089                            (*representation, u8::from(*big_endian))
9090                        } else {
9091                            (0, 0)
9092                        };
9093                        #[cfg(feature = "inspect")]
9094                        dispatch_inspect_dcps_receive_tap(&topic_name, d.reader_id, &item);
9095                        if let Some(ref l) = listener {
9096                            if let Some((arc_payload, off)) = listener_view {
9097                                // Zero-copy: slice view into the original Arc.
9098                                l(&arc_payload[off..], item_repr, item_be);
9099                            }
9100                        } else {
9101                            let _ = sender.send(item);
9102                            wake_async_waker(&waker);
9103                        }
9104                    }
9105                    if let (Some(t3), true) = (pt_t3, pt_on) {
9106                        let ns = t3.elapsed().as_nanos() as u64;
9107                        PHASE_HANDLE_SUB_NS[4].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9108                    }
9109                } // for arc in target_slots
9110            }
9111            ParsedSubmessage::DataFrag(df) => {
9112                // Lever B+E — see the Data arm above.
9113                // Cross-vendor: same UNKNOWN fan-out as for Data.
9114                let target_slots: Vec<ReaderSlotArc> = if df.reader_id == EntityId::UNKNOWN {
9115                    rt.reader_slots_snapshot()
9116                        .into_iter()
9117                        .map(|(_, arc)| arc)
9118                        .collect()
9119                } else {
9120                    rt.reader_slot(df.reader_id).into_iter().collect()
9121                };
9122                for arc in target_slots {
9123                    let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
9124                    let listener;
9125                    let waker;
9126                    let sender;
9127                    #[cfg(feature = "inspect")]
9128                    let topic_name;
9129                    {
9130                        let Ok(mut slot) = arc.lock() else { continue };
9131                        for sample in
9132                            slot.reader
9133                                .handle_data_frag(src_prefix, &df, now, cur_source_ts)
9134                        {
9135                            // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9136                            if matches!(
9137                                sample.kind,
9138                                zerodds_rtps::history_cache::ChangeKind::Alive
9139                                    | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9140                            ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9141                            {
9142                                continue;
9143                            }
9144                            let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
9145                                zerodds_rtps::history_cache::ChangeKind::Alive
9146                                | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
9147                                    validate_user_encap_offset(&sample.payload)
9148                                        .map(|off| (Arc::clone(&sample.payload), off))
9149                                }
9150                                _ => None,
9151                            };
9152                            if let Some(item) =
9153                                delivered_to_user_sample(&sample, &slot.writer_strengths)
9154                            {
9155                                items.push((item, listener_view));
9156                            }
9157                        }
9158                        if !items.is_empty() {
9159                            slot.last_sample_received = Some(now);
9160                            slot.samples_delivered_count = slot
9161                                .samples_delivered_count
9162                                .saturating_add(items.len() as u64);
9163                            if !slot.liveliness_alive {
9164                                slot.liveliness_alive = true;
9165                                slot.liveliness_alive_count =
9166                                    slot.liveliness_alive_count.saturating_add(1);
9167                            }
9168                        }
9169                        listener = slot.listener.clone();
9170                        waker = Arc::clone(&slot.async_waker);
9171                        sender = slot.sample_tx.clone();
9172                        #[cfg(feature = "inspect")]
9173                        {
9174                            topic_name = slot.topic_name.clone();
9175                        }
9176                    }
9177                    for (item, listener_view) in items {
9178                        let (item_repr, item_be) = if let UserSample::Alive {
9179                            representation,
9180                            big_endian,
9181                            ..
9182                        } = &item
9183                        {
9184                            (*representation, u8::from(*big_endian))
9185                        } else {
9186                            (0, 0)
9187                        };
9188                        #[cfg(feature = "inspect")]
9189                        dispatch_inspect_dcps_receive_tap(&topic_name, df.reader_id, &item);
9190                        // See the Data arm: listener and MPSC are exclusive.
9191                        if let Some(ref l) = listener {
9192                            if let Some((arc_payload, off)) = listener_view {
9193                                l(&arc_payload[off..], item_repr, item_be);
9194                            }
9195                        } else {
9196                            let _ = sender.send(item);
9197                            wake_async_waker(&waker);
9198                        }
9199                    }
9200                } // for arc in target_slots (DataFrag)
9201            }
9202            ParsedSubmessage::Heartbeat(h) => {
9203                // Lever B — collect-then-dispatch like the Data arm. An HB can
9204                // unlock samples that were waiting on a hole fill
9205                // (volatile skip, historic eviction).
9206                //
9207                // D.5e Phase-2: synchronous ACKNACK emit on HB receipt
9208                // instead of deferred-via-tick. With `heartbeat_response_delay=0`
9209                // (D.5e default) `tick_outbound(now)` flushes the
9210                // ACKNACK directly for all pending writer_proxies — the tick loop
9211                // no longer has to wait 5 ms.
9212                // Cross-vendor: a HEARTBEAT with reader_id=UNKNOWN is
9213                // "to all matched readers". Cyclone often packs this into
9214                // DATA+HB submessage bundles.
9215                let target_slots: Vec<ReaderSlotArc> = if h.reader_id == EntityId::UNKNOWN {
9216                    rt.reader_slots_snapshot()
9217                        .into_iter()
9218                        .map(|(_, arc)| arc)
9219                        .collect()
9220                } else {
9221                    rt.reader_slot(h.reader_id).into_iter().collect()
9222                };
9223                for arc in target_slots {
9224                    let mut items: Vec<UserSample> = Vec::new();
9225                    let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
9226                        Vec::new();
9227                    let waker;
9228                    let sender;
9229                    {
9230                        let Ok(mut slot) = arc.lock() else { continue };
9231                        for sample in slot.reader.handle_heartbeat(src_prefix, &h, now) {
9232                            // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9233                            if matches!(
9234                                sample.kind,
9235                                zerodds_rtps::history_cache::ChangeKind::Alive
9236                                    | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9237                            ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9238                            {
9239                                continue;
9240                            }
9241                            if let Some(item) =
9242                                delivered_to_user_sample(&sample, &slot.writer_strengths)
9243                            {
9244                                items.push(item);
9245                            }
9246                        }
9247                        if !items.is_empty() {
9248                            slot.last_sample_received = Some(now);
9249                            slot.samples_delivered_count = slot
9250                                .samples_delivered_count
9251                                .saturating_add(items.len() as u64);
9252                            if !slot.liveliness_alive {
9253                                slot.liveliness_alive = true;
9254                                slot.liveliness_alive_count =
9255                                    slot.liveliness_alive_count.saturating_add(1);
9256                            }
9257                        }
9258                        // D.5e Phase-2: synchronous ACKNACK directly in the recv thread.
9259                        if let Ok(dgs) = slot.reader.tick_outbound(now) {
9260                            sync_outbound = dgs;
9261                        }
9262                        waker = Arc::clone(&slot.async_waker);
9263                        sender = slot.sample_tx.clone();
9264                    }
9265                    for item in items {
9266                        let _ = sender.send(item);
9267                        wake_async_waker(&waker);
9268                    }
9269                    // Send ACKNACK datagrams synchronously — no tick-quantization tax.
9270                    for dg in sync_outbound {
9271                        if let Some(secured) = protect_user_reader_datagram(rt, &dg.bytes) {
9272                            for t in dg.targets.iter() {
9273                                if is_routable_user_locator(t) {
9274                                    let _ = rt.user_unicast.send(t, &secured);
9275                                }
9276                            }
9277                        }
9278                    }
9279                } // for arc in target_slots (Heartbeat)
9280            }
9281            ParsedSubmessage::Gap(g) => {
9282                // Cross-vendor: Gap with UNKNOWN reader → fan-out.
9283                let target_slots: Vec<ReaderSlotArc> = if g.reader_id == EntityId::UNKNOWN {
9284                    rt.reader_slots_snapshot()
9285                        .into_iter()
9286                        .map(|(_, arc)| arc)
9287                        .collect()
9288                } else {
9289                    rt.reader_slot(g.reader_id).into_iter().collect()
9290                };
9291                for arc in target_slots {
9292                    if let Ok(mut slot) = arc.lock() {
9293                        for sample in slot.reader.handle_gap(src_prefix, &g) {
9294                            // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9295                            if matches!(
9296                                sample.kind,
9297                                zerodds_rtps::history_cache::ChangeKind::Alive
9298                                    | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9299                            ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9300                            {
9301                                continue;
9302                            }
9303                            if let Some(item) =
9304                                delivered_to_user_sample(&sample, &slot.writer_strengths)
9305                            {
9306                                let _ = slot.sample_tx.send(item);
9307                                wake_async_waker(&slot.async_waker);
9308                            }
9309                        }
9310                    }
9311                }
9312            }
9313            ParsedSubmessage::AckNack(ack) => {
9314                if let Some(arc) = rt.writer_slot(ack.writer_id) {
9315                    let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
9316                        Vec::new();
9317                    if let Ok(mut slot) = arc.lock() {
9318                        let base = ack.reader_sn_state.bitmap_base;
9319                        let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
9320                        let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
9321                        slot.writer.handle_acknack(src, base, requested);
9322                        // D.5e Phase-2: synchronous resend on NACK receipt.
9323                        // An ACKNACK may have listed requested SNs for resend;
9324                        // tick delivers the resend datagrams directly in the recv thread.
9325                        if let Ok(dgs) = slot.writer.tick(now) {
9326                            sync_outbound = dgs;
9327                        }
9328                    }
9329                    // ACK-Event-Cvar: wake `wait_for_acknowledgments`-waiters.
9330                    rt.notify_ack_event();
9331                    // Send sync resends (no more tick wait). FU2 S3:
9332                    // per-target data_protection (a reliable resend of user DATA
9333                    // must be encrypted just like the immediate send).
9334                    for dg in sync_outbound {
9335                        for t in dg.targets.iter() {
9336                            if is_routable_user_locator(t) {
9337                                if let Some(secured) =
9338                                    secure_outbound_for_target(rt, ack.writer_id, &dg.bytes, t)
9339                                {
9340                                    let _ = rt.user_unicast.send(t, &secured);
9341                                }
9342                            }
9343                        }
9344                    }
9345                }
9346            }
9347            ParsedSubmessage::NackFrag(nf) => {
9348                if let Some(arc) = rt.writer_slot(nf.writer_id) {
9349                    if let Ok(mut slot) = arc.lock() {
9350                        let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
9351                        slot.writer.handle_nackfrag(src, &nf);
9352                    }
9353                }
9354            }
9355            _ => {}
9356        }
9357    }
9358}
9359
9360/// Test hook: allows a direct call of `handle_spdp_datagram` from
9361/// other modules without spinning up the whole event loop.
9362/// For internal tests only.
9363#[cfg(test)]
9364pub(crate) fn handle_spdp_datagram_for_test(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
9365    handle_spdp_datagram(rt, bytes);
9366}
9367
9368fn handle_spdp_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
9369    let parsed = match rt.spdp_reader.parse_datagram(bytes) {
9370        Ok(p) => p,
9371        Err(_) => return, // not SPDP or wire error — swallow
9372    };
9373    // Self-discovery filter: ignore our own beacons.
9374    if parsed.sender_prefix == rt.guid_prefix {
9375        return;
9376    }
9377    let is_new = {
9378        if let Ok(mut cache) = rt.discovered.lock() {
9379            cache.insert(parsed.clone())
9380        } else {
9381            false
9382        }
9383    };
9384    // On first discovery: wire the SEDP stack + send out initial
9385    // announcements.
9386    if is_new {
9387        // A1 discovery-server relay: bridge the newly-joined client to every
9388        // already-known client over a single well-known address. Forwards ONLY
9389        // raw SPDP (participant locators) — SEDP (endpoint discovery, incl. ROS-2
9390        // Action endpoints) then proceeds DIRECTLY peer-to-peer, which is exactly
9391        // why Actions keep working (unlike a SEDP-proxying discovery server).
9392        // Plain discovery only; secured relay is a follow-up.
9393        #[cfg(feature = "security")]
9394        let relay_plain = rt.config.security.is_none();
9395        #[cfg(not(feature = "security"))]
9396        let relay_plain = true;
9397        if rt.config.discovery_server && relay_plain {
9398            let new_client = wlp_unicast_targets(core::slice::from_ref(&parsed));
9399            let others: Vec<_> = rt
9400                .discovered_participants()
9401                .into_iter()
9402                .filter(|dp| dp.sender_prefix != parsed.sender_prefix)
9403                .collect();
9404            if let Ok(mut relay) = rt.spdp_relay_cache.lock() {
9405                // 1) tell the new client about every already-known client.
9406                for dp in &others {
9407                    if let Some(raw) = relay.get(&dp.sender_prefix) {
9408                        for loc in &new_client {
9409                            let _ = rt.spdp_unicast.send(loc, raw);
9410                        }
9411                    }
9412                }
9413                // 2) tell every already-known client about the new client.
9414                for dp in &others {
9415                    for loc in wlp_unicast_targets(core::slice::from_ref(dp)) {
9416                        let _ = rt.spdp_unicast.send(&loc, bytes);
9417                    }
9418                }
9419                // 3) remember the new client's SPDP for future joiners.
9420                relay.insert(parsed.sender_prefix, bytes.to_vec());
9421            }
9422        }
9423        if let Ok(mut sedp) = rt.sedp.lock() {
9424            sedp.on_participant_discovered(&parsed);
9425        }
9426        // Event-driven directed SPDP response (§8.5.3): send OUR own
9427        // SPDP IMMEDIATELY unicast to the newly discovered peer, instead of letting it
9428        // wait for our next periodic multicast beacon (spdp_period=5s, codepit-LXC
9429        // multicast flaky). A spec-conformant peer (OpenDDS)
9430        // processes our auth request ONLY once it has our identity_token from
9431        // our SPDP — without this directed response it waits up to
9432        // spdp_period (seconds latency → cross-vendor ping wait_for_matched
9433        // timeout). NO timeout band-aid: the seconds latency was the missing
9434        // discovery event. Token-less first beacons (security not yet enabled)
9435        // are NOT sent (see security_pending in the announce loop) — the
9436        // periodic/announce_spdp_now path catches up.
9437        #[cfg(feature = "security")]
9438        let beacon_ready =
9439            !(rt.config.security.is_some() && rt.security_builtin_snapshot().is_none());
9440        #[cfg(not(feature = "security"))]
9441        let beacon_ready = true;
9442        if beacon_ready {
9443            let targets = wlp_unicast_targets(core::slice::from_ref(&parsed));
9444            if !targets.is_empty() {
9445                if let Some(secured) = rt
9446                    .spdp_beacon
9447                    .lock()
9448                    .ok()
9449                    .and_then(|mut b| b.serialize().ok())
9450                    .and_then(|d| secure_outbound_bytes(rt, &d).map(|c| c.to_vec()))
9451                {
9452                    for loc in &targets {
9453                        let _ = rt.spdp_unicast.send(loc, &secured);
9454                    }
9455                }
9456            }
9457        }
9458    }
9459    // FU2: wire the security builtin stack + kick off the auth handshake.
9460    // On EVERY beacon (not only is_new): `handle_remote_endpoints` and
9461    // `begin_handshake_with` are idempotent. This also covers the case
9462    // that the peer was discovered before the auth plugin was active via
9463    // `enable_security_builtins_with_auth` — the next
9464    // beacon refresh then kicks off the handshake. No-op without a plugin,
9465    // without security bits or without an announced identity_token.
9466    if let Some(sec) = rt.security_builtin_snapshot() {
9467        let handshake_dgs = if let Ok(mut s) = sec.lock() {
9468            s.note_remote_vendor(parsed.sender_prefix, parsed.sender_vendor);
9469            s.handle_remote_endpoints(&parsed);
9470            match parsed.data.identity_token.as_ref() {
9471                Some(token) => s
9472                    .begin_handshake_with(parsed.sender_prefix, parsed.data.guid.to_bytes(), token)
9473                    .unwrap_or_default(),
9474                None => Vec::new(),
9475            }
9476        } else {
9477            Vec::new()
9478        };
9479        for dg in handshake_dgs {
9480            send_discovery_datagram(rt, &dg.targets, &dg.bytes);
9481        }
9482    }
9483    //  Mirror the SPDP receive into the builtin DCPSParticipant reader.
9484    // We send on every beacon (also refresh) — Spec §2.2.5.1
9485    // allows it, take() returns the respective current
9486    // data to the user. A reader with KEEP_LAST(1) receives only the newest.
9487    if let Some(sinks) = rt.builtin_sinks_snapshot() {
9488        let dcps_sample =
9489            crate::builtin_topics::ParticipantBuiltinTopicData::from_wire(&parsed.data);
9490        // .7 §2.2.2.2.1.14: drop ignored participants before
9491        // they fall into the builtin reader.
9492        if let Some(filter) = rt.ignore_filter_snapshot() {
9493            let h = crate::instance_handle::InstanceHandle::from_guid(dcps_sample.key);
9494            if filter.is_participant_ignored(h) {
9495                return;
9496            }
9497        }
9498        let _ = sinks.push_participant(&dcps_sample);
9499    }
9500}
9501
9502/// Pushes SEDP events (new pubs/subs) into the 4 builtin-topic
9503/// readers. A new pub/sub produces **two** samples:
9504///
9505/// 1. a `DCPSPublication`/`DCPSSubscription` sample,
9506/// 2. a `DCPSTopic` sample (synthetic from topic name + type name).
9507///
9508/// The native SEDP-topics endpoints (RTPS 2.5 §9.3.2.12 bits 28/29)
9509/// are optional per Spec §8.5.4.4 and covered in ZeroDDS via this
9510/// synthetic derivation — see also
9511/// `endpoint_flag::ALL_STANDARD`, which deliberately omits the
9512/// topics bits. Cyclone/Fast-DDS peers that send their own topic
9513/// announces are ignored (no reader endpoint).
9514fn push_sedp_events_to_builtin_readers(
9515    rt: &Arc<DcpsRuntime>,
9516    events: &zerodds_discovery::sedp::SedpEvents,
9517) {
9518    let Some(sinks) = rt.builtin_sinks_snapshot() else {
9519        return;
9520    };
9521    let filter = rt.ignore_filter_snapshot();
9522    for w in &events.new_publications {
9523        let pub_sample = crate::builtin_topics::PublicationBuiltinTopicData::from_wire(w);
9524        let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_publication(w);
9525        // .7 §2.2.2.2.1.14/.16: consult the participant + publication +
9526        // topic ignore filters.
9527        if let Some(f) = &filter {
9528            let part_h = crate::instance_handle::InstanceHandle::from_guid(w.participant_key);
9529            let pub_h = crate::instance_handle::InstanceHandle::from_guid(w.key);
9530            let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
9531            if f.is_participant_ignored(part_h) || f.is_publication_ignored(pub_h) {
9532                continue;
9533            }
9534            let _ = sinks.push_publication(&pub_sample);
9535            if !f.is_topic_ignored(topic_h) {
9536                let _ = sinks.push_topic(&topic_sample);
9537            }
9538        } else {
9539            let _ = sinks.push_publication(&pub_sample);
9540            let _ = sinks.push_topic(&topic_sample);
9541        }
9542    }
9543    for r in &events.new_subscriptions {
9544        let sub_sample = crate::builtin_topics::SubscriptionBuiltinTopicData::from_wire(r);
9545        let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_subscription(r);
9546        if let Some(f) = &filter {
9547            let part_h = crate::instance_handle::InstanceHandle::from_guid(r.participant_key);
9548            let sub_h = crate::instance_handle::InstanceHandle::from_guid(r.key);
9549            let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
9550            if f.is_participant_ignored(part_h) || f.is_subscription_ignored(sub_h) {
9551                continue;
9552            }
9553            let _ = sinks.push_subscription(&sub_sample);
9554            if !f.is_topic_ignored(topic_h) {
9555                let _ = sinks.push_topic(&topic_sample);
9556            }
9557        } else {
9558            let _ = sinks.push_subscription(&sub_sample);
9559            let _ = sinks.push_topic(&topic_sample);
9560        }
9561    }
9562    // Endpoint deletion (SEDP dispose): mark the matching built-in instance
9563    // disposed so a DCPSPublication/DCPSSubscription observer sees the remote
9564    // endpoint vanish (DDSI-RTPS §8.5.4). The unmatch of local user endpoints
9565    // is driven separately via `apply_sedp_removals`.
9566    for g in &events.removed_publications {
9567        sinks.dispose_publication(*g);
9568    }
9569    for g in &events.removed_subscriptions {
9570        sinks.dispose_subscription(*g);
9571    }
9572}
9573
9574/// Drives the unmatch of local user endpoints when SEDP reported a remote
9575/// endpoint deletion: each removed remote publication is removed from every
9576/// local reader's proxy set, each removed remote subscription from every local
9577/// writer's. See [`DcpsRuntime::remove_remote_writer`] /
9578/// [`DcpsRuntime::remove_remote_reader`].
9579fn apply_sedp_removals(rt: &Arc<DcpsRuntime>, events: &zerodds_discovery::sedp::SedpEvents) {
9580    for g in &events.removed_publications {
9581        rt.remove_remote_writer(*g);
9582    }
9583    for g in &events.removed_subscriptions {
9584        rt.remove_remote_reader(*g);
9585    }
9586}
9587
9588/// Binary-property name of the crypto key material in the CryptoToken DataHolder
9589/// (DDS-Security §9.5.2.1.1, cyclone-verified: `dds.cryp.keymat`).
9590#[cfg(feature = "security")]
9591const CRYPTO_TOKEN_PROP: &str = "dds.cryp.keymat";
9592
9593/// CryptoToken `class_id` (§9.5.2.1: `DDS:Crypto:AES_GCM_GMAC` — underscores,
9594/// **not** the plugin-class string with hyphens).
9595#[cfg(feature = "security")]
9596const CRYPTO_TOKEN_CLASS_ID: &str = "DDS:Crypto:AES_GCM_GMAC";
9597
9598/// Builds the `PARTICIPANT_CRYPTO_TOKENS` VolatileSecure message with the
9599/// Kx-encrypted token as a binary property (FU2 S1.4).
9600#[cfg(feature = "security")]
9601fn build_crypto_token_message(
9602    rt: &DcpsRuntime,
9603    remote_prefix: GuidPrefix,
9604    kx_token: Vec<u8>,
9605) -> zerodds_security::generic_message::ParticipantGenericMessage {
9606    use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
9607    use zerodds_security::token::DataHolder;
9608    ParticipantGenericMessage {
9609        message_identity: MessageIdentity {
9610            source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
9611            sequence_number: 1,
9612        },
9613        related_message_identity: MessageIdentity::default(),
9614        destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
9615        destination_endpoint_key: [0; 16],
9616        source_endpoint_key: [0; 16],
9617        message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
9618        message_data: alloc::vec![
9619            DataHolder::new(CRYPTO_TOKEN_CLASS_ID)
9620                .with_binary_property(CRYPTO_TOKEN_PROP, kx_token)
9621        ],
9622    }
9623}
9624
9625/// FU2 S1.4 (send): after handshake completion Kx-encrypt the local data token
9626/// (`gate.local_token`) and send it as
9627/// `PARTICIPANT_CRYPTO_TOKENS` over VolatileSecure.
9628/// Registers the peer's Kx key in the gate beforehand. `None` without a gate
9629/// or on error (drop instead of leak).
9630#[cfg(feature = "security")]
9631fn prepare_crypto_token(
9632    rt: &DcpsRuntime,
9633    remote_prefix: GuidPrefix,
9634    remote_identity: zerodds_security::authentication::IdentityHandle,
9635    secret: zerodds_security::authentication::SharedSecretHandle,
9636) -> Option<zerodds_security::generic_message::ParticipantGenericMessage> {
9637    let gate = rt.config.security.as_ref()?;
9638    let peer_key = remote_prefix.to_bytes();
9639    // ALWAYS register the peer's Kx key — even with rtps=NONE: the per-endpoint
9640    // tokens (discovery_/data_protection) travel Kx-protected over the volatile,
9641    // protect_volatile_datagram needs this key.
9642    gate.register_remote_by_guid_from_secret(peer_key, remote_identity, secret)
9643        .ok()?;
9644    // BUT: send the ParticipantCryptoToken (= SRTPS keymat) ONLY when
9645    // rtps_protection != NONE. With rtps=NONE there is no SRTPS; OpenDDS rejects the
9646    // token (Spdp.cpp:1966 `crypto_handle_==NIL` -> "not configured for RTPS
9647    // Protection", logs `handle_participant_crypto_tokens failed`) and OpenDDS-self
9648    // also does NOT exchange it with rtps=NONE. None here = no participant
9649    // token send; the per-endpoint tokens continue over the separate path.
9650    if gate.rtps_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
9651        return None;
9652    }
9653    // Cross-vendor: the data token travels in PLAINTEXT in the
9654    // ParticipantGenericMessage — it becomes confidential only through the
9655    // SEC_PREFIX/BODY/POSTFIX submessage protection of the whole volatile
9656    // DATA (see protect_volatile_datagram). The `register_*` line above
9657    // created the peer's Kx key in the gate that this protection uses.
9658    let token = gate.local_token().ok()?;
9659    Some(build_crypto_token_message(rt, remote_prefix, token))
9660}
9661
9662/// Per-endpoint crypto handle for a local writer/reader (get-or-register).
9663/// DDS-Security §9.5.3.3: each endpoint has its OWN key material. Registration
9664/// under the write lock (race-free). `None` without an active gate.
9665#[cfg(feature = "security")]
9666fn local_endpoint_crypto_handle(
9667    rt: &DcpsRuntime,
9668    eid: EntityId,
9669    is_writer: bool,
9670) -> Option<zerodds_security::crypto::CryptoHandle> {
9671    let gate = rt.config.security.as_ref()?;
9672    {
9673        let map = rt.endpoint_crypto.read().ok()?;
9674        if let Some(h) = map.get(&eid) {
9675            return Some(*h);
9676        }
9677    }
9678    let mut map = rt.endpoint_crypto.write().ok()?;
9679    if let Some(h) = map.get(&eid) {
9680        return Some(*h);
9681    }
9682    let h = gate.register_local_endpoint(is_writer).ok()?;
9683    map.insert(eid, h);
9684    Some(h)
9685}
9686
9687/// Cross-vendor step 6b (send): per-endpoint `datawriter_crypto_tokens` (for
9688/// every local user writer) + `datareader_crypto_tokens` (for every local
9689/// user reader) to the peer. cyclone needs these to approve the user-endpoint
9690/// match and decode ZeroDDS' user DATA. `source_endpoint_key` = the
9691/// local endpoint GUID; the keymat is the local data key (one key per
9692/// participant in the bench). Empty list without a gate / without user endpoints.
9693#[cfg(feature = "security")]
9694fn prepare_endpoint_crypto_tokens(
9695    rt: &DcpsRuntime,
9696    remote_prefix: GuidPrefix,
9697) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
9698    use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
9699    use zerodds_security::token::DataHolder;
9700    let Some(gate) = rt.config.security.as_ref() else {
9701        return Vec::new();
9702    };
9703    let mut out = Vec::new();
9704    // cyclone associates a datawriter/datareader token via the pair
9705    // (source_endpoint, destination_endpoint). Hence per local endpoint ONE
9706    // token PER matched remote endpoint of **this** peer, with the concrete
9707    // remote GUID as destination_endpoint_key (dst=0 would make cyclone discard it).
9708    //
9709    // §9.5.3.3: the token carries the **per-endpoint** key material of the
9710    // `source_eid` (not the participant key) — the same key with which
9711    // ZeroDDS encodes this endpoint's submessages (protect_user_datagram).
9712    let build = |class: &str,
9713                 source_eid: EntityId,
9714                 dst: [u8; 16]|
9715     -> Option<ParticipantGenericMessage> {
9716        let is_writer = class == class_id::DATAWRITER_CRYPTO_TOKENS;
9717        let handle = local_endpoint_crypto_handle(rt, source_eid, is_writer)?;
9718        let token = gate.create_endpoint_token(handle).ok()?;
9719        // Dual key (metadata != data, meta-sign-data): cyclone expects
9720        // num_key_mat=2 — submessage keymat (metadata kind) + payload keymat
9721        // (data kind) as TWO DataHolders in this order. Single key
9722        // (all other profiles): only the submessage/endpoint keymat.
9723        let mut dhs = alloc::vec![
9724            DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, token)
9725        ];
9726        if let Some(pay) = gate.endpoint_payload_token(handle) {
9727            dhs.push(
9728                DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, pay),
9729            );
9730        }
9731        Some(ParticipantGenericMessage {
9732            message_identity: MessageIdentity {
9733                source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
9734                sequence_number: 1,
9735            },
9736            related_message_identity: MessageIdentity::default(),
9737            destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
9738            destination_endpoint_key: dst,
9739            source_endpoint_key: Guid::new(rt.guid_prefix, source_eid).to_bytes(),
9740            message_class_id: class.into(),
9741            message_data: dhs,
9742        })
9743    };
9744    // datawriter tokens: per local writer for every matched remote reader
9745    // of this peer (dst = reader GUID).
9746    for (weid, warc) in rt.writer_slots_snapshot() {
9747        if let Ok(slot) = warc.lock() {
9748            for proxy in slot.writer.reader_proxies() {
9749                if proxy.remote_reader_guid.prefix == remote_prefix {
9750                    out.extend(build(
9751                        class_id::DATAWRITER_CRYPTO_TOKENS,
9752                        weid,
9753                        proxy.remote_reader_guid.to_bytes(),
9754                    ));
9755                }
9756            }
9757        }
9758    }
9759    // datareader tokens: per local reader for every matched remote writer
9760    // of this peer (dst = writer GUID).
9761    for (reid, rarc) in rt.reader_slots_snapshot() {
9762        if let Ok(slot) = rarc.lock() {
9763            for ws in slot.reader.writer_proxies() {
9764                if ws.proxy.remote_writer_guid.prefix == remote_prefix {
9765                    out.extend(build(
9766                        class_id::DATAREADER_CRYPTO_TOKENS,
9767                        reid,
9768                        ws.proxy.remote_writer_guid.to_bytes(),
9769                    ));
9770                }
9771            }
9772        }
9773    }
9774    // Protected discovery (§8.4.2.4): the secure builtin SEDP endpoints
9775    // (DCPSPublications/SubscriptionsSecure) also need crypto tokens,
9776    // so the peer associates ZeroDDS' data key with them + decodes the secure-SEDP
9777    // submessages. cyclone exchanges these builtin-endpoint tokens
9778    // the same way over the volatile (ff0003c2/c7 + ff0004c2/c7).
9779    if gate
9780        .discovery_protection()
9781        .map(|l| l != ProtectionLevel::None)
9782        .unwrap_or(false)
9783    {
9784        let builtin_pairs = [
9785            (
9786                class_id::DATAWRITER_CRYPTO_TOKENS,
9787                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
9788                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
9789            ),
9790            (
9791                class_id::DATAREADER_CRYPTO_TOKENS,
9792                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
9793                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
9794            ),
9795            (
9796                class_id::DATAWRITER_CRYPTO_TOKENS,
9797                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
9798                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
9799            ),
9800            (
9801                class_id::DATAREADER_CRYPTO_TOKENS,
9802                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
9803                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
9804            ),
9805        ];
9806        for (class, src_eid, dst_eid) in builtin_pairs {
9807            out.extend(build(
9808                class,
9809                src_eid,
9810                Guid::new(remote_prefix, dst_eid).to_bytes(),
9811            ));
9812        }
9813    }
9814    // FastDDS interop: the reliable secure-SPDP builtin (DCPSParticipantsSecure,
9815    // ff0101c2/c7) needs per-endpoint crypto tokens when FastDDS SEC-encrypts the secure-
9816    // SPDP DATA under discovery_protection — otherwise the peer cannot
9817    // decode our secure SPDP -> no secure participant discovery ->
9818    // no token reciprocation. Gated on enable_secure_spdp.
9819    if rt.config.enable_secure_spdp {
9820        let spdp_pairs = [
9821            (
9822                class_id::DATAWRITER_CRYPTO_TOKENS,
9823                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
9824                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
9825            ),
9826            (
9827                class_id::DATAREADER_CRYPTO_TOKENS,
9828                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
9829                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
9830            ),
9831        ];
9832        for (class, src_eid, dst_eid) in spdp_pairs {
9833            out.extend(build(
9834                class,
9835                src_eid,
9836                Guid::new(remote_prefix, dst_eid).to_bytes(),
9837            ));
9838        }
9839    }
9840    // Liveliness protection (§8.4.2.4): the secure-WLP builtin endpoints
9841    // (BuiltinParticipantMessageSecure, ff0200c2/c7) also need per-
9842    // endpoint crypto tokens. cyclone gates the participant security approval
9843    // (and thus the user-endpoint connection) on it — without these tokens
9844    // "connect ... waiting for approval by security" stays hung.
9845    if gate
9846        .liveliness_protection()
9847        .map(|l| l != ProtectionLevel::None)
9848        .unwrap_or(false)
9849    {
9850        let wlp_pairs = [
9851            (
9852                class_id::DATAWRITER_CRYPTO_TOKENS,
9853                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
9854                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
9855            ),
9856            (
9857                class_id::DATAREADER_CRYPTO_TOKENS,
9858                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
9859                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
9860            ),
9861        ];
9862        for (class, src_eid, dst_eid) in wlp_pairs {
9863            out.extend(build(
9864                class,
9865                src_eid,
9866                Guid::new(remote_prefix, dst_eid).to_bytes(),
9867            ));
9868        }
9869    }
9870    out
9871}
9872
9873/// Dedup key of a per-endpoint crypto token: the pair
9874/// (source_endpoint, destination_endpoint). cyclone associates a
9875/// datawriter/datareader token via exactly this pair (§9.5.3.3), so it is
9876/// also the right granularity to remember which tokens have gone out.
9877#[cfg(feature = "security")]
9878fn endpoint_token_key(
9879    m: &zerodds_security::generic_message::ParticipantGenericMessage,
9880) -> [u8; 32] {
9881    let mut k = [0u8; 32];
9882    k[..16].copy_from_slice(&m.source_endpoint_key);
9883    k[16..].copy_from_slice(&m.destination_endpoint_key);
9884    k
9885}
9886
9887/// Filters out the per-endpoint tokens not yet sent. The previously
9888/// used **per-peer** once-guard was too coarse: it snapped shut as soon as the
9889/// participant/secure-SEDP builtin tokens were out — but user endpoints match
9890/// only later (after the secure SEDP). Their tokens then never went out,
9891/// and the peer could never decode ZeroDDS' user DATA. Per-token dedup
9892/// (peer+source+dest) sends each token exactly once — builtins early,
9893/// user endpoints as soon as they match.
9894#[cfg(feature = "security")]
9895fn pending_endpoint_tokens(
9896    msgs: Vec<zerodds_security::generic_message::ParticipantGenericMessage>,
9897    already_sent: &alloc::collections::BTreeSet<[u8; 32]>,
9898) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
9899    msgs.into_iter()
9900        .filter(|m| !already_sent.contains(&endpoint_token_key(m)))
9901        .collect()
9902}
9903
9904/// FU2 S1.4 (recv): Kx-decrypt an incoming `PARTICIPANT_CRYPTO_TOKENS` message
9905/// and install the peer's data key in the gate.
9906/// Afterwards secured user DATA round-trips with this peer.
9907#[cfg(feature = "security")]
9908fn install_crypto_token(
9909    rt: &DcpsRuntime,
9910    remote_prefix: GuidPrefix,
9911    msg: &zerodds_security::generic_message::ParticipantGenericMessage,
9912) {
9913    use zerodds_security::generic_message::class_id;
9914    // Cross-vendor: cyclone sends the data key both as
9915    // participant_crypto_tokens and per-endpoint as datawriter/
9916    // datareader_crypto_tokens. We install the keymat from all three
9917    // under the sender's participant slot (one user endpoint per participant
9918    // in the bench) — so decode_data_datawriter_from decodes the user DATA.
9919    if msg.message_class_id != class_id::PARTICIPANT_CRYPTO_TOKENS
9920        && msg.message_class_id != class_id::DATAWRITER_CRYPTO_TOKENS
9921        && msg.message_class_id != class_id::DATAREADER_CRYPTO_TOKENS
9922    {
9923        return;
9924    }
9925    let Some(gate) = rt.config.security.as_ref() else {
9926        return;
9927    };
9928    let peer_key = remote_prefix.to_bytes();
9929    // `message_data` is a sequence<DataHolder> (DDS-Security §7.4.4.3
9930    // ParticipantGenericMessage): cyclone packs MULTIPLE CryptoTokens (its own
9931    // key material per endpoint, different transformation_key_id) into ONE
9932    // message. Install ALL — taking only `.first()` lost the
9933    // endpoint keys (key_id 2..N) and the secure SEDP stayed undecodable.
9934    // Plaintext token (confidentiality was provided by the submessage protection of
9935    // the transporting volatile DATA, see unprotect_volatile_datagram).
9936    // DDS-Security §9.5.2 vs §9.5.3: the PARTICIPANT crypto token carries the
9937    // message-level key (SRTPS, decode_secured_rtps_message -> slots[peer]); the
9938    // datawriter/datareader tokens carry per-endpoint data keys that belong ONLY in
9939    // the key_id path (remote_by_key_id, decode_data_by_key_id). Putting both
9940    // into slots[peer] let the last-installed (datareader) overwrite the
9941    // participant key -> message-level SRTPS tag mismatch.
9942    let is_participant = msg.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS;
9943    for dh in &msg.message_data {
9944        if let Some(token) = dh.binary_property(CRYPTO_TOKEN_PROP) {
9945            let _ = if is_participant {
9946                gate.set_remote_data_token_by_guid(&peer_key, token)
9947            } else {
9948                gate.install_remote_endpoint_token(token)
9949            };
9950        }
9951    }
9952}
9953
9954// RTPS submessage IDs for the VolatileSecure submessage-protection surgery.
9955#[cfg(feature = "security")]
9956const SMID_DATA: u8 = 0x15;
9957#[cfg(feature = "security")]
9958const SMID_SEC_PREFIX: u8 = 0x31;
9959#[cfg(feature = "security")]
9960const SMID_SEC_POSTFIX: u8 = 0x32;
9961// Further writer submessage IDs (DDSI-RTPS 2.5 §8.3.7). Per DDS-Security
9962// §8.4.2.4 (is_submessage_protected=TRUE, DataWriter) ALL submessages sent by the
9963// writer — not only DATA — MUST be protected via encode_datawriter_submessage.
9964// HEARTBEAT is the critical one: without it the remote
9965// reader cannot NACK a missing sequence number (= no reliable recovery).
9966#[cfg(feature = "security")]
9967const SMID_HEARTBEAT: u8 = 0x07;
9968#[cfg(feature = "security")]
9969const SMID_GAP: u8 = 0x08;
9970#[cfg(feature = "security")]
9971const SMID_DATA_FRAG: u8 = 0x16;
9972#[cfg(feature = "security")]
9973const SMID_HEARTBEAT_FRAG: u8 = 0x13;
9974// Reader submessages (DDSI-RTPS 2.5 §8.3.7): under `metadata_protection_kind
9975// != NONE` to be protected via `encode_datareader_submessage` (§8.4.2.4) with the per-endpoint
9976// reader key — otherwise a spec-conformant remote writer
9977// (cyclone under discovery=ENCRYPT) discards the clear ACKNACK and never re-sends.
9978#[cfg(feature = "security")]
9979const SMID_ACKNACK: u8 = 0x06;
9980#[cfg(feature = "security")]
9981const SMID_NACK_FRAG: u8 = 0x12;
9982
9983/// `true` if the submessage ID is a submessage sent by the DataReader
9984/// (ACKNACK/NACK_FRAG) — datareader protection path.
9985#[cfg(feature = "security")]
9986fn is_protected_reader_submessage(id: u8) -> bool {
9987    matches!(id, SMID_ACKNACK | SMID_NACK_FRAG)
9988}
9989
9990/// Extracts the `reader_id` (sender) from an ACKNACK/NACK_FRAG submessage:
9991/// offset 4 (after header(4)), directly before the writer_id (offset 8).
9992#[cfg(feature = "security")]
9993fn reader_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
9994    if !is_protected_reader_submessage(id) {
9995        return None;
9996    }
9997    let raw: [u8; 4] = submsg.get(4..8)?.try_into().ok()?;
9998    Some(EntityId::from_bytes(raw))
9999}
10000
10001/// `true` if the submessage ID is a submessage sent by the DataWriter that,
10002/// under `metadata_protection_kind != NONE`, must be protected via `encode_datawriter_submessage`
10003/// (DDS-Security §8.4.2.4). ACKNACK/NACK_FRAG are
10004/// reader submessages (datareader path) and are excluded here.
10005#[cfg(feature = "security")]
10006fn is_protected_writer_submessage(id: u8) -> bool {
10007    matches!(
10008        id,
10009        SMID_DATA | SMID_DATA_FRAG | SMID_HEARTBEAT | SMID_HEARTBEAT_FRAG | SMID_GAP
10010    )
10011}
10012
10013/// Walks the submessages of an RTPS datagram from `offset` and returns
10014/// `(submessage_id, start, total_len)`. `octetsToNextHeader == 0` means
10015/// "to the end of the datagram" (RTPS §8.3.3.2.3).
10016#[cfg(feature = "security")]
10017fn walk_submessages(bytes: &[u8]) -> Vec<(u8, usize, usize)> {
10018    let mut out = Vec::new();
10019    let mut o = 20; // RTPS header
10020    while o + 4 <= bytes.len() {
10021        let id = bytes[o];
10022        let le = bytes[o + 1] & 0x01 != 0;
10023        let raw = if le {
10024            u16::from_le_bytes([bytes[o + 2], bytes[o + 3]])
10025        } else {
10026            u16::from_be_bytes([bytes[o + 2], bytes[o + 3]])
10027        } as usize;
10028        let body = if raw == 0 { bytes.len() - (o + 4) } else { raw };
10029        let total = 4 + body;
10030        if o + total > bytes.len() {
10031            break;
10032        }
10033        out.push((id, o, total));
10034        o += total;
10035    }
10036    out
10037}
10038
10039/// Cross-vendor VolatileSecure (send): replaces every DATA submessage in the
10040/// datagram with the cyclone-conformant `SEC_PREFIX`/`SEC_BODY`/`SEC_POSTFIX`
10041/// sequence (encrypted with the peer's Kx key). Other submessages
10042/// (INFO_DST/INFO_TS/HEARTBEAT) stay unchanged. Returns the datagram
10043/// unchanged if no DATA submessage is present (e.g. a pure
10044/// HEARTBEAT tick). `None` only on a crypto error (drop instead of leak).
10045#[cfg(feature = "security")]
10046fn protect_volatile_datagram(
10047    rt: &DcpsRuntime,
10048    bytes: &[u8],
10049    peer_key: &[u8; 12],
10050) -> Option<Vec<u8>> {
10051    let gate = rt.config.security.as_ref()?;
10052    if bytes.len() < 20 {
10053        return Some(bytes.to_vec());
10054    }
10055    let subs = walk_submessages(bytes);
10056    // DDS-Security §8.4.2.4: ParticipantVolatileMessageSecure is submessage-
10057    // protected — ALL submessages sent by the endpoint MUST be protected with the Kx key,
10058    // not only DATA. This holds for BOTH directions:
10059    //  * writer submessages (DATA, DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP)
10060    //  * reader submessages (ACKNACK, NACK_FRAG)
10061    // cyclone/FastDDS otherwise discard the WHOLE volatile sample with "clear
10062    // submsg from protected src" → the crypto-token exchange over the volatile
10063    // stalls. write_with_heartbeat bundles DATA+HEARTBEAT into ONE datagram; if
10064    // the HEARTBEAT stayed clear, the whole token sample was lost (cross-vendor
10065    // cyclone→ZeroDDS responder).
10066    // The reader ACKNACK: OpenDDS' RtpsUdpReceiveStrategy::check_encoded requires
10067    // protection for the volatile reader (ff0202c4, is_submessage_protected=TRUE) and
10068    // otherwise drops the clear ACKNACK ("Submessage requires protection") → its
10069    // volatile WRITER never gets an ACK → considers the token delivery
10070    // unacknowledged → zerodds NEVER sends the SRTPS-protected secure SEDP → no
10071    // user-endpoint match. The volatile channel uses ONE shared Kx session key
10072    // (KDF from the shared secret, §9.5.3.3.4.4), symmetric for both directions
10073    // → protect the ACKNACK with the same Kx key as the DATA.
10074    if !subs.iter().any(|(id, _, _)| {
10075        is_protected_writer_submessage(*id) || is_protected_reader_submessage(*id)
10076    }) {
10077        return Some(bytes.to_vec()); // no protection-worthy submessage -> unchanged
10078    }
10079    let mut out = Vec::with_capacity(bytes.len() + 64);
10080    out.extend_from_slice(&bytes[..20]);
10081    for (id, start, total) in subs {
10082        let submsg = &bytes[start..start + total];
10083        if is_protected_writer_submessage(id) || is_protected_reader_submessage(id) {
10084            match gate.encode_kx_datawriter_for(peer_key, submsg) {
10085                Ok(sec) => out.extend_from_slice(&sec),
10086                Err(_) => return None, // drop instead of plaintext leak
10087            }
10088        } else {
10089            out.extend_from_slice(submsg);
10090        }
10091    }
10092    Some(out)
10093}
10094
10095/// Cross-vendor VolatileSecure (recv): recognizes a `SEC_PREFIX`/`SEC_BODY`/
10096/// `SEC_POSTFIX` sequence, decodes it with the peer's Kx key to the
10097/// original DATA submessage and builds a plain RTPS datagram for the
10098/// `volatile_reader`. `None` if no SEC_* sequence is present (then the normal
10099/// path) or on a crypto error.
10100#[cfg(feature = "security")]
10101fn unprotect_volatile_datagram(
10102    rt: &DcpsRuntime,
10103    bytes: &[u8],
10104    peer_key: &[u8; 12],
10105) -> Option<Vec<u8>> {
10106    let gate = rt.config.security.as_ref()?;
10107    if bytes.len() < 20 {
10108        return None;
10109    }
10110    let subs = walk_submessages(bytes);
10111    // Cyclone/FastDDS bundle, via xpack, MULTIPLE SEC_*-protected volatile
10112    // submessages (all with the Kx key) into ONE datagram. So there can be
10113    // multiple SEC_PREFIX/BODY/POSTFIX triples — transform ALL back
10114    // (like unprotect_user_datagram). Decoding only the first block (an earlier
10115    // bug) left every bundled token sample after the first encrypted;
10116    // the VOLATILE writer does not retransmit them → deterministic
10117    // token loss (no "flaky" transport, all same-host). `None` if there is no
10118    // SEC_PREFIX at all (plaintext) or the Kx decode fails (= not a volatile datagram,
10119    // e.g. secure SEDP with a per-endpoint key).
10120    if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
10121        return None;
10122    }
10123    let mut out = Vec::with_capacity(bytes.len());
10124    out.extend_from_slice(&bytes[..20]);
10125    let mut i = 0;
10126    while i < subs.len() {
10127        let (id, start, total) = subs[i];
10128        if id == SMID_SEC_PREFIX {
10129            let postfix_idx = subs[i..]
10130                .iter()
10131                .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
10132                .map(|off| i + off)?;
10133            let (_, q_start, q_total) = subs[postfix_idx];
10134            let sec_wire = &bytes[start..q_start + q_total];
10135            let submsg = gate.decode_kx_datawriter_from(peer_key, sec_wire).ok()?;
10136            out.extend_from_slice(&submsg);
10137            i = postfix_idx + 1;
10138        } else {
10139            out.extend_from_slice(&bytes[start..start + total]);
10140            i += 1;
10141        }
10142    }
10143    Some(out)
10144}
10145
10146/// Protects a peer's volatile outbound datagrams (DATA -> SEC_*).
10147/// HEARTBEAT/ACKNACK datagrams (without DATA) stay unchanged; datagrams
10148/// with a crypto error are dropped.
10149#[cfg(feature = "security")]
10150fn protect_volatile_outbound(
10151    rt: &DcpsRuntime,
10152    remote_prefix: GuidPrefix,
10153    dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>,
10154) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
10155    let peer_key = remote_prefix.to_bytes();
10156    dgs.into_iter()
10157        .filter_map(|dg| {
10158            protect_volatile_datagram(rt, &dg.bytes, &peer_key).map(|bytes| {
10159                zerodds_rtps::message_builder::OutboundDatagram {
10160                    bytes,
10161                    targets: dg.targets,
10162                }
10163            })
10164        })
10165        .collect()
10166}
10167
10168/// Cross-vendor (send): replaces EVERY submessage sent by the DataWriter (DATA,
10169/// DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP) with the cyclone-conformant
10170/// SEC_PREFIX/BODY/POSTFIX sequence, encrypted with the **local data key**.
10171/// DDS-Security §8.4.2.4 (`is_submessage_protected=TRUE`, DataWriter): ALL
10172/// writer submessages MUST be protected via `encode_datawriter_submessage`
10173/// — in particular the HEARTBEAT, otherwise the remote reader cannot NACK missing
10174/// sequence numbers (no reliable recovery). Framing submessages
10175/// (INFO_TS/INFO_DST/...) stay unchanged; `None` on a crypto error.
10176#[cfg(feature = "security")]
10177fn protect_user_datagram(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10178    let gate = rt.config.security.as_ref()?;
10179    if bytes.len() < 20 {
10180        return Some(bytes.to_vec());
10181    }
10182    let subs = walk_submessages(bytes);
10183    if !subs
10184        .iter()
10185        .any(|(id, _, _)| is_protected_writer_submessage(*id))
10186    {
10187        return Some(bytes.to_vec());
10188    }
10189    // §9.5.3.3 per-endpoint key: all writer submessages of a datagram
10190    // come from the same writer. Take the writer_id from the first protected
10191    // submessage + look up the per-endpoint handle. No handle
10192    // (unregistered endpoint) → participant-key fallback.
10193    let endpoint_handle = subs
10194        .iter()
10195        .find(|(id, _, _)| is_protected_writer_submessage(*id))
10196        .and_then(|&(id, start, total)| writer_eid_in_submessage(&bytes[start..start + total], id))
10197        .and_then(|weid| local_endpoint_crypto_handle(rt, weid, true));
10198    let mut out = Vec::with_capacity(bytes.len() + 64);
10199    out.extend_from_slice(&bytes[..20]);
10200    for (id, start, total) in subs {
10201        let submsg = &bytes[start..start + total];
10202        if is_protected_writer_submessage(id) {
10203            let sec = match endpoint_handle {
10204                Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
10205                None => gate.encode_data_datawriter_local(submsg),
10206            };
10207            match sec {
10208                Ok(s) => out.extend_from_slice(&s),
10209                Err(_) => return None,
10210            }
10211        } else {
10212            out.extend_from_slice(submsg);
10213        }
10214    }
10215    Some(out)
10216}
10217
10218/// Extracts the `writer_id` from an RTPS writer submessage. DATA/DATA_FRAG:
10219/// offset 12 (header(4)+extraFlags(2)+octetsToInlineQos(2)+readerId(4));
10220/// HEARTBEAT/GAP/HEARTBEAT_FRAG: offset 8 (header(4)+readerId(4)).
10221#[cfg(feature = "security")]
10222fn writer_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
10223    let off = match id {
10224        SMID_DATA | SMID_DATA_FRAG => 12,
10225        SMID_HEARTBEAT | SMID_GAP | SMID_HEARTBEAT_FRAG => 8,
10226        _ => return None,
10227    };
10228    let raw: [u8; 4] = submsg.get(off..off + 4)?.try_into().ok()?;
10229    Some(EntityId::from_bytes(raw))
10230}
10231
10232/// Cross-vendor user DATA (recv): decodes the SEC_* sequence with the sender's
10233/// data key (`peer_key` = sender GuidPrefix) back to the DATA submessage.
10234/// `None` if no SEC_* sequence is present (normal path) or on a crypto error.
10235#[cfg(feature = "security")]
10236fn unprotect_user_datagram(rt: &DcpsRuntime, bytes: &[u8], peer_key: &[u8; 12]) -> Option<Vec<u8>> {
10237    let gate = rt.config.security.as_ref()?;
10238    if bytes.len() < 20 {
10239        return None;
10240    }
10241    let subs = walk_submessages(bytes);
10242    // §8.4.2.4: the peer SEC_*-wrapped EVERY writer submessage individually
10243    // (DATA, HEARTBEAT, GAP, ...). So there can be MULTIPLE SEC_PREFIX/BODY/
10244    // POSTFIX triples in the same datagram — transform them all back. `None`
10245    // only if there is no SEC_* sequence at all (normal/plaintext path).
10246    if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
10247        return None;
10248    }
10249    let mut out = Vec::with_capacity(bytes.len());
10250    out.extend_from_slice(&bytes[..20]);
10251    let mut i = 0;
10252    while i < subs.len() {
10253        let (id, start, total) = subs[i];
10254        if id == SMID_SEC_PREFIX {
10255            // Find the matching SEC_POSTFIX from i; the block is [prefix..postfix].
10256            let postfix_idx = subs[i..]
10257                .iter()
10258                .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
10259                .map(|off| i + off)?;
10260            let (_, q_start, q_total) = subs[postfix_idx];
10261            let sec_wire = &bytes[start..q_start + q_total];
10262            // key_id-based decode: the peer has, per endpoint (user +
10263            // secure-builtin discovery), its own key material; the correct
10264            // key is found via the transformation_key_id in the CryptoHeader.
10265            // Fallback for transformation_key_id=0: this is NOT a per-
10266            // endpoint token key, but the participant-level key derived from the
10267            // SharedSecret (DDS-Security Tab.73, AES256-GCM, sender_key_id
10268            // =0) — cyclone protects with it under rtps_protection. That one is decoded by the
10269            // Kx path (peer-prefix-indexed SharedSecret key).
10270            let mut submsg = gate
10271                .decode_data_by_key_id(sec_wire)
10272                .or_else(|_| gate.decode_data_datawriter_from(peer_key, sec_wire))
10273                .or_else(|_| gate.decode_kx_datawriter_from(peer_key, sec_wire))
10274                .ok()?;
10275            // Correct octetsToNextHeader to the real body length: cyclone
10276            // wraps every writer submessage INDIVIDUALLY; within its SEC_BODY
10277            // it is the last one -> octetsToNextHeader=0 ("to the end of the message").
10278            // When concatenating multiple decoded blocks (e.g. DATA + piggybacked
10279            // HEARTBEAT), otn=0 would make the strict decode_datagram swallow the following
10280            // submessage as payload -> the reader would never see the
10281            // HEARTBEAT and would block as a late joiner on the SN gap.
10282            if submsg.len() >= 4 {
10283                let le = submsg[1] & zerodds_rtps::FLAG_E_LITTLE_ENDIAN != 0;
10284                let otn = u16::try_from(submsg.len() - 4).unwrap_or(0);
10285                let b = if le {
10286                    otn.to_le_bytes()
10287                } else {
10288                    otn.to_be_bytes()
10289                };
10290                submsg[2] = b[0];
10291                submsg[3] = b[1];
10292            }
10293            out.extend_from_slice(&submsg);
10294            i = postfix_idx + 1;
10295        } else {
10296            out.extend_from_slice(&bytes[start..start + total]);
10297            i += 1;
10298        }
10299    }
10300    Some(out)
10301}
10302
10303/// §8.5.1.9.1 / §9.5.3.3.1 data_protection (send): encrypts ONLY the
10304/// SerializedPayload INSIDE each DATA submessage (payload layer). The
10305/// submessage header, octetsToInlineQos, InlineQoS and the flags (E/Q/D/K)
10306/// stay byte-identical; only the N-flag (NonStandardPayload, §8.3.8.2) is
10307/// set and octetsToNextHeader adjusted to the new payload length. This is
10308/// the spec-conformant + cyclone-interop form of data_protection (counterpart:
10309/// metadata_protection = whole submessage SEC_*-wrapped). Applied as the INNER
10310/// layer BEFORE the submessage/message protection. `None` on a
10311/// crypto error (drop instead of leak); a datagram without DATA stays unchanged.
10312#[cfg(feature = "security")]
10313fn protect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10314    use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
10315    use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
10316    let gate = rt.config.security.as_ref()?;
10317    if bytes.len() < 20 {
10318        return Some(bytes.to_vec());
10319    }
10320    let subs = walk_submessages(bytes);
10321    if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
10322        return Some(bytes.to_vec());
10323    }
10324    let mut out = Vec::with_capacity(bytes.len() + 64);
10325    out.extend_from_slice(&bytes[..20]);
10326    for (id, start, total) in subs {
10327        let submsg = &bytes[start..start + total];
10328        if id != SMID_DATA {
10329            out.extend_from_slice(submsg);
10330            continue;
10331        }
10332        let flags = submsg[1];
10333        let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
10334        // data_protection payload key: the **per-endpoint DataWriter key**
10335        // (§9.5.3.3.1). cyclone associates the DataWriter strictly with its
10336        // datawriter_crypto_handle and decodes the SerializedPayload ONLY with
10337        // this key — the participant key yields "Invalid Crypto
10338        // Handle" in cyclone. The key is sent to the peer as a datawriter_crypto_token;
10339        // the reader finds it via the transformation_key_id in the CryptoHeader.
10340        let handle = writer_eid_in_submessage(submsg, id)
10341            .and_then(|w| local_endpoint_crypto_handle(rt, w, true))?;
10342        // Payload boundary: read_body_with_flags returns serialized_payload as
10343        // an Arc of body[pos..] -> payload = the last plen bytes of the submessage.
10344        let body = &submsg[4..];
10345        let ds = DataSubmessage::read_body_with_flags(body, le, flags).ok()?;
10346        let plen = ds.serialized_payload.len();
10347        let payload_off = submsg.len() - plen;
10348        let enc = gate
10349            .encode_serialized_payload(handle, &ds.serialized_payload)
10350            .ok()?;
10351        let new_body_len = (payload_off - 4) + enc.len();
10352        if new_body_len > u16::MAX as usize {
10353            return None;
10354        }
10355        out.push(submsg[0]);
10356        out.push(flags | DATA_FLAG_NON_STANDARD);
10357        let otn = new_body_len as u16;
10358        if le {
10359            out.extend_from_slice(&otn.to_le_bytes());
10360        } else {
10361            out.extend_from_slice(&otn.to_be_bytes());
10362        }
10363        // Body prefix (extraFlags..InlineQoS) verbatim, then encrypted payload.
10364        out.extend_from_slice(&submsg[4..payload_off]);
10365        out.extend_from_slice(&enc);
10366    }
10367    Some(out)
10368}
10369
10370/// Result of the inner payload layer on receipt (§8.5.1.9.4).
10371#[cfg(feature = "security")]
10372enum PayloadDecode {
10373    /// No DATA submessage carries the N-flag — plaintext path, pass the datagram
10374    /// on unchanged.
10375    NotEncrypted,
10376    /// Successfully decrypted — use the plaintext datagram.
10377    Decoded(Vec<u8>),
10378    /// N-flag set, but decryption failed. The datagram MUST
10379    /// be discarded — passing an undecodable encrypted payload as
10380    /// ciphertext gives the reader garbage (§8.5: reject). The
10381    /// reliable re-send catches up on the sample once the key is installed
10382    /// resp. another (e.g. inproc/message-level) copy delivers it.
10383    Failed,
10384}
10385
10386/// `true` if the SerializedPayload begins with a CryptoHeader (§9.5.3.3.1):
10387/// the first 4 bytes are a CryptoTransformKind != NONE
10388/// (AES128_GMAC/GCM, AES256_GMAC/GCM = `[0,0,0,1..=4]`). A plaintext CDR
10389/// encapsulation carries either a different first byte pair (CDR_LE `[0,1]`,
10390/// XCDR2 `[0,6/7]`, PL_CDR `[0,2/3]`) or — for CDR_BE `[0,0]` — options
10391/// `[0,0]`, so it does not collide with the transform kinds 1..=4. Serves as
10392/// detection for vendors (cyclone) that encrypt the data_protection payload
10393/// without setting the N-flag of the DATA submessage.
10394#[cfg(feature = "security")]
10395fn payload_has_crypto_header(payload: &[u8]) -> bool {
10396    matches!(payload, [0, 0, 0, 1..=4, ..])
10397}
10398
10399/// §8.5.1.9.4 / §9.5.3.3.1 data_protection (recv): decrypts the
10400/// SerializedPayload of each DATA submessage whose payload begins with a CryptoHeader
10401/// — recognized by the set N-flag (zero↔zero, [`protect_user_payload`])
10402/// OR by the CryptoTransformKind signature (cyclone does not set the N-flag).
10403/// The tag verification of the GCM open IS the detection: if the decode fails
10404/// and the N-flag was not set, the submessage is passed through as plaintext
10405/// (false positive of the signature heuristic). The key is found via the
10406/// `transformation_key_id` (key_id), the sender prefix (peer slot) or — for
10407/// key_id=0 (participant/Kx key, cyclone) — the Kx key material.
10408/// `NotEncrypted` if no DATA submessage was decrypted; `Failed` only
10409/// on an N-flag decode error (§8.5: reject undecryptable).
10410#[cfg(feature = "security")]
10411fn unprotect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> PayloadDecode {
10412    use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
10413    use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
10414    let Some(gate) = rt.config.security.as_ref() else {
10415        return PayloadDecode::NotEncrypted;
10416    };
10417    if bytes.len() < 20 {
10418        return PayloadDecode::NotEncrypted;
10419    }
10420    // Sender prefix (RTPS header bytes[8..20]) as a fallback key index, if the
10421    // transformation_key_id in the CryptoHeader is not uniquely in the remote index
10422    // (zero↔zero indexed via the peer slot, cyclone strictly via key_id).
10423    let mut peer_key = [0u8; 12];
10424    peer_key.copy_from_slice(&bytes[8..20]);
10425    let subs = walk_submessages(bytes);
10426    let mut out = Vec::with_capacity(bytes.len());
10427    out.extend_from_slice(&bytes[..20]);
10428    let mut did_decode = false;
10429    for (id, start, total) in subs {
10430        let submsg = &bytes[start..start + total];
10431        if id != SMID_DATA {
10432            out.extend_from_slice(submsg);
10433            continue;
10434        }
10435        let flags = submsg[1];
10436        let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
10437        let nflag = flags & DATA_FLAG_NON_STANDARD != 0;
10438        let body = &submsg[4..];
10439        let Ok(ds) = DataSubmessage::read_body_with_flags(body, le, flags) else {
10440            // Parse error of a DATA marked as encrypted -> drop;
10441            // a pure plaintext DATA never made read_body_with_flags fail,
10442            // so a set N-flag is the only reason here.
10443            if nflag {
10444                return PayloadDecode::Failed;
10445            }
10446            out.extend_from_slice(submsg);
10447            continue;
10448        };
10449        // Only attempt when the payload is recognizable as encrypted:
10450        // N-flag (zero↔zero) or CryptoHeader signature (cyclone without an N-flag).
10451        if !nflag && !payload_has_crypto_header(&ds.serialized_payload) {
10452            out.extend_from_slice(submsg);
10453            continue;
10454        }
10455        let plen = ds.serialized_payload.len();
10456        let payload_off = submsg.len() - plen;
10457        let pdec = gate
10458            .decode_serialized_payload(&ds.serialized_payload)
10459            .or_else(|_| gate.decode_serialized_payload_from(&peer_key, &ds.serialized_payload))
10460            .or_else(|_| gate.decode_serialized_payload_kx(&peer_key, &ds.serialized_payload));
10461        let Ok(dec) = pdec else {
10462            // §8.5: if the N-flag was set, the payload is surely encrypted
10463            // and the reader would get garbage -> drop (reliable re-send catches it
10464            // up after key install). If only the signature heuristic was the trigger
10465            // (no N-flag), it is a plaintext CDR_BE payload whose options
10466            // happen to look like a TransformKind -> pass through unchanged.
10467            if nflag {
10468                return PayloadDecode::Failed;
10469            }
10470            out.extend_from_slice(submsg);
10471            continue;
10472        };
10473        let new_body_len = (payload_off - 4) + dec.len();
10474        if new_body_len > u16::MAX as usize {
10475            return PayloadDecode::Failed;
10476        }
10477        out.push(submsg[0]);
10478        out.push(flags & !DATA_FLAG_NON_STANDARD);
10479        let otn = new_body_len as u16;
10480        if le {
10481            out.extend_from_slice(&otn.to_le_bytes());
10482        } else {
10483            out.extend_from_slice(&otn.to_be_bytes());
10484        }
10485        out.extend_from_slice(&submsg[4..payload_off]);
10486        out.extend_from_slice(&dec);
10487        did_decode = true;
10488    }
10489    if did_decode {
10490        PayloadDecode::Decoded(out)
10491    } else {
10492        PayloadDecode::NotEncrypted
10493    }
10494}
10495
10496/// `true` if the EntityId is one of the four secure-SEDP discovery endpoints
10497/// (DCPSPublicationsSecure/DCPSSubscriptionsSecure, EntityIds ff0003c2/c7 +
10498/// ff0004c2/c7). Controls whether a SEDP datagram is protected-discovery traffic
10499/// and must be SEC_*-protected (DDS-Security §8.4.2.4).
10500#[cfg(feature = "security")]
10501fn is_secure_sedp_entity(e: EntityId) -> bool {
10502    e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER
10503        || e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER
10504        || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER
10505        || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER
10506}
10507
10508/// `true` if the datagram carries a submessage to/from a secure-SEDP endpoint
10509/// — then it is protected-discovery traffic.
10510#[cfg(feature = "security")]
10511fn is_secure_sedp_datagram(bytes: &[u8]) -> bool {
10512    let Ok(parsed) = decode_datagram(bytes) else {
10513        return false;
10514    };
10515    parsed.submessages.iter().any(|s| {
10516        let ids = match s {
10517            ParsedSubmessage::Data(d) => [Some(d.writer_id), Some(d.reader_id)],
10518            ParsedSubmessage::DataFrag(d) => [Some(d.writer_id), Some(d.reader_id)],
10519            ParsedSubmessage::Heartbeat(h) => [Some(h.writer_id), Some(h.reader_id)],
10520            ParsedSubmessage::Gap(g) => [Some(g.writer_id), Some(g.reader_id)],
10521            ParsedSubmessage::AckNack(a) => [Some(a.writer_id), Some(a.reader_id)],
10522            ParsedSubmessage::NackFrag(n) => [Some(n.writer_id), Some(n.reader_id)],
10523            _ => [None, None],
10524        };
10525        ids.into_iter().flatten().any(is_secure_sedp_entity)
10526    })
10527}
10528
10529/// Protected discovery (DDS-Security §8.4.2.4) send: secure-SEDP datagrams
10530/// (DATA/HEARTBEAT/GAP of the secure writers) are
10531/// `encode_datawriter_submessage`-protected with the participant data key — the same key the peer installs via
10532/// `participant_crypto_tokens`. Non-secure SEDP goes through unchanged.
10533/// `None` ⟹ crypto error on secure SEDP → drop the datagram instead of a
10534/// plaintext leak.
10535#[cfg(feature = "security")]
10536fn protect_sedp_outbound(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10537    let Some(gate) = rt.config.security.as_ref() else {
10538        return Some(bytes.to_vec());
10539    };
10540    if !is_secure_sedp_datagram(bytes) || bytes.len() < 20 {
10541        return Some(bytes.to_vec());
10542    }
10543    // Governance §8.4.2.4: discovery_protection_kind=NONE -> NO discovery
10544    // protection. Secure-SEDP entities (ff0003c7/ff0004c7) must then NOT
10545    // be per-endpoint-protected; otherwise their ACKNACKs leak as message-
10546    // level SEC_PREFIX with a never-exchanged per-endpoint key that a
10547    // peer (cyclone uses plain SEDP under discovery=NONE) discards as "Invalid Crypto
10548    // Handle". Pass through plain -> the outer rtps_protection
10549    // layer (SRTPS via secure_outbound_bytes) wraps the whole message correctly.
10550    if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
10551        return Some(bytes.to_vec());
10552    }
10553    // §8.4.2.4: protect BOTH directions — writer submessages (DATA/HEARTBEAT/
10554    // GAP) with the per-endpoint writer key (encode_datawriter_submessage), reader
10555    // submessages (ACKNACK/NACK_FRAG) with the per-endpoint reader key
10556    // (encode_datareader_submessage). A spec-conformant cyclone under
10557    // discovery=ENCRYPT discards a CLEAR ACKNACK of the secure-SEDP reader →
10558    // never re-sends the SubscriptionData → ZeroDDS never discovers the reader. The
10559    // per-endpoint key (same as in the sent datareader_crypto_token)
10560    // makes the ACKNACK decodable for cyclone.
10561    let subs = walk_submessages(bytes);
10562    let mut out = Vec::with_capacity(bytes.len() + 64);
10563    out.extend_from_slice(&bytes[..20]);
10564    for (id, start, total) in subs {
10565        let submsg = &bytes[start..start + total];
10566        let handle = if is_protected_writer_submessage(id) {
10567            writer_eid_in_submessage(submsg, id)
10568                .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
10569        } else if is_protected_reader_submessage(id) {
10570            reader_eid_in_submessage(submsg, id)
10571                .and_then(|r| local_endpoint_crypto_handle(rt, r, false))
10572        } else {
10573            // Framing submessage (INFO_TS/INFO_DST/...) — unchanged.
10574            out.extend_from_slice(submsg);
10575            continue;
10576        };
10577        let sec = match handle {
10578            Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
10579            // No per-endpoint handle (should not occur for secure SEDP)
10580            // → participant-key fallback, so no plaintext leak arises.
10581            None => gate.encode_data_datawriter_local(submsg),
10582        };
10583        match sec {
10584            Ok(s) => out.extend_from_slice(&s),
10585            Err(_) => return None,
10586        }
10587    }
10588    Some(out)
10589}
10590
10591/// Protects a user-reader outbound datagram (ACKNACK/NACK_FRAG) on the
10592/// send direction (DDS-Security §8.4.2.4). Counterpart to the writer-DATA layer:
10593/// under `metadata_protection != NONE` the reader submessage too MUST be protected with the
10594/// per-endpoint reader key, otherwise a spec-strict
10595/// peer writer (cyclone/FastDDS) discards the CLEAR ACKNACK → the SN gap is never
10596/// re-sent → permanent reliable stall. Only needed when
10597/// **rtps_protection** does NOT already wrap the message as an SRTPS whole; otherwise
10598/// (and with metadata=NONE) the function delegates to `secure_outbound_bytes`.
10599#[cfg(feature = "security")]
10600fn protect_user_reader_datagram<'a>(
10601    rt: &DcpsRuntime,
10602    bytes: &'a [u8],
10603) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10604    let Some(gate) = rt.config.security.as_ref() else {
10605        return Some(alloc::borrow::Cow::Borrowed(bytes));
10606    };
10607    let metadata = gate.metadata_protection().unwrap_or(ProtectionLevel::None);
10608    let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
10609    // rtps != None → SRTPS wraps the whole message incl. ACKNACK; metadata ==
10610    // None → no submessage protection configured. secure_outbound_bytes
10611    // (transform_outbound) covers both cases correctly.
10612    if metadata == ProtectionLevel::None || rtps != ProtectionLevel::None || bytes.len() < 20 {
10613        return secure_outbound_bytes(rt, bytes);
10614    }
10615    let subs = walk_submessages(bytes);
10616    let mut out = Vec::with_capacity(bytes.len() + 64);
10617    out.extend_from_slice(&bytes[..20]);
10618    for (id, start, total) in subs {
10619        let submsg = &bytes[start..start + total];
10620        if is_protected_reader_submessage(id) {
10621            let handle = reader_eid_in_submessage(submsg, id)
10622                .and_then(|r| local_endpoint_crypto_handle(rt, r, false));
10623            match handle {
10624                Some(h) => match gate.encode_data_datawriter_by_handle(h, submsg) {
10625                    Ok(s) => out.extend_from_slice(&s),
10626                    Err(_) => return None,
10627                },
10628                // No per-endpoint reader key yet (the endpoint matches only after
10629                // secure SEDP) → pass through plaintext; the reader tick re-sends
10630                // the ACKNACK once the key is installed.
10631                None => out.extend_from_slice(submsg),
10632            }
10633        } else {
10634            // Framing submessage (INFO_DST/INFO_TS/...) — unchanged.
10635            out.extend_from_slice(submsg);
10636        }
10637    }
10638    Some(alloc::borrow::Cow::Owned(out))
10639}
10640
10641#[cfg(not(feature = "security"))]
10642fn protect_user_reader_datagram<'a>(
10643    rt: &DcpsRuntime,
10644    bytes: &'a [u8],
10645) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10646    secure_outbound_bytes(rt, bytes)
10647}
10648
10649/// `true` if `liveliness_protection != NONE` is configured — then WLP runs
10650/// over the secure entity + participant-key protection (§8.4.2.4).
10651#[cfg(feature = "security")]
10652fn wlp_liveliness_protected(rt: &DcpsRuntime) -> bool {
10653    rt.config.security.as_ref().is_some_and(|gate| {
10654        gate.liveliness_protection()
10655            .unwrap_or(ProtectionLevel::None)
10656            != ProtectionLevel::None
10657    })
10658}
10659
10660#[cfg(not(feature = "security"))]
10661fn wlp_liveliness_protected(_rt: &DcpsRuntime) -> bool {
10662    false
10663}
10664
10665/// Protects a WLP outbound datagram (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER
10666/// DATA) under `liveliness_protection != NONE` with the **participant data key**
10667/// (§8.4.2.4 / §7.4.7.1 Tab.7). WLP is participant-level (no per-endpoint key)
10668/// — analogous to the participant-key fallback in `protect_sedp_outbound`. If
10669/// `rtps_protection` already covers the message as SRTPS (or liveliness=NONE),
10670/// the function delegates to `secure_outbound_bytes`.
10671#[cfg(feature = "security")]
10672fn protect_wlp_outbound<'a>(
10673    rt: &DcpsRuntime,
10674    bytes: &'a [u8],
10675) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10676    let Some(gate) = rt.config.security.as_ref() else {
10677        return Some(alloc::borrow::Cow::Borrowed(bytes));
10678    };
10679    let live = gate
10680        .liveliness_protection()
10681        .unwrap_or(ProtectionLevel::None);
10682    let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
10683    // liveliness=NONE: no inner SEC layer -> secure_outbound_bytes covers
10684    // rtps_protection (SRTPS) resp. passthrough. PREVIOUSLY this branch
10685    // also delegated with rtps!=None and thus left out the liveliness SEC -> cyclone
10686    // saw the WLP DATA "clear submsg from protected src" -> no liveliness.
10687    if live == ProtectionLevel::None || bytes.len() < 20 {
10688        return secure_outbound_bytes(rt, bytes);
10689    }
10690    let subs = walk_submessages(bytes);
10691    let mut out = Vec::with_capacity(bytes.len() + 64);
10692    out.extend_from_slice(&bytes[..20]);
10693    for (id, start, total) in subs {
10694        let submsg = &bytes[start..start + total];
10695        if id == SMID_DATA {
10696            // Protect the secure-WLP DATA with the per-endpoint key of the secure-WLP writer
10697            // (ff0200c2) — the same key ZeroDDS sends the peer via the
10698            // datawriter_crypto_token (prepare_endpoint_crypto_tokens
10699            // liveliness block). encode_data_datawriter_local took the participant
10700            // key, which cyclone does NOT associate with ff0200c2 -> undecodable ->
10701            // no liveliness -> peer approval of the user endpoints hangs.
10702            let sec = writer_eid_in_submessage(submsg, id)
10703                .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
10704                .and_then(|h| gate.encode_data_datawriter_by_handle(h, submsg).ok());
10705            match sec {
10706                Some(s) => out.extend_from_slice(&s),
10707                None => return None,
10708            }
10709        } else {
10710            out.extend_from_slice(submsg);
10711        }
10712    }
10713    // Under additional rtps_protection, message-level SRTPS MUST go around the
10714    // liveliness-SEC-wrapped WLP (both layers, like cyclone<->cyclone) —
10715    // otherwise cyclone would see only the SRTPS shell OR (with the old logic) the
10716    // clear DATA. First inner SEC (above), then SRTPS (here).
10717    if rtps != ProtectionLevel::None {
10718        return gate
10719            .transform_outbound(&out)
10720            .ok()
10721            .map(alloc::borrow::Cow::Owned);
10722    }
10723    Some(alloc::borrow::Cow::Owned(out))
10724}
10725
10726#[cfg(not(feature = "security"))]
10727fn protect_wlp_outbound<'a>(
10728    rt: &DcpsRuntime,
10729    bytes: &'a [u8],
10730) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10731    secure_outbound_bytes(rt, bytes)
10732}
10733
10734/// Wire demux for the security builtin topics. Routes an
10735/// incoming RTPS submessage sequence to the `SecurityBuiltinStack`,
10736/// if the stack is active. No-op if the datagram does not address a security
10737/// builtin reader or the plugin is not enabled.
10738///
10739/// Called by the metatraffic receive path — stateless +
10740/// VolatileSecure run over the SPDP unicast locators (PID 0x0032),
10741/// not over `user_unicast`.
10742fn dispatch_security_builtin_datagram(
10743    rt: &Arc<DcpsRuntime>,
10744    bytes: &[u8],
10745    now: Duration,
10746) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
10747    // `mut` only needed on the security path (the handshake reply is appended
10748    // there); without the feature the list stays empty.
10749    #[cfg(feature = "security")]
10750    let mut outbound = Vec::new();
10751    #[cfg(not(feature = "security"))]
10752    let outbound = Vec::new();
10753    let Some(stack) = rt.security_builtin_snapshot() else {
10754        return outbound;
10755    };
10756    // Cross-vendor VolatileSecure: cyclone protects the volatile DATA as a
10757    // SEC_PREFIX/SEC_BODY/SEC_POSTFIX sequence. Before the submessage parse,
10758    // transform the sequence with the sender's Kx key (GuidPrefix = RTPS header bytes[8..20])
10759    // back to the original DATA submessage. `None` = no SEC_*
10760    // sequence (normal path) resp. crypto error.
10761    #[cfg(feature = "security")]
10762    let unprotected: Option<Vec<u8>> = if bytes.len() >= 20 {
10763        let mut pk = [0u8; 12];
10764        pk.copy_from_slice(&bytes[8..20]);
10765        unprotect_volatile_datagram(rt, bytes, &pk)
10766    } else {
10767        None
10768    };
10769    #[cfg(feature = "security")]
10770    let bytes: &[u8] = unprotected.as_deref().unwrap_or(bytes);
10771    let Ok(parsed) = decode_datagram(bytes) else {
10772        return outbound;
10773    };
10774    // sourceGuidPrefix of the datagram (DDSI-RTPS §8.3.4) — reader demux key for
10775    // the volatile builtin readers. Used in both feature configs.
10776    let remote_prefix = parsed.header.guid_prefix;
10777    let Ok(mut s) = stack.lock() else {
10778        return outbound;
10779    };
10780    for sub in parsed.submessages {
10781        match sub {
10782            ParsedSubmessage::Data(d) => {
10783                if d.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
10784                    || d.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
10785                {
10786                    // FU2 Gap 5: decode the stateless auth and — with
10787                    // an active auth plugin — drive the handshake.
10788                    // `on_stateless_message` returns the next token
10789                    // message (reply/final), which we send back to the peer.
10790                    // Decode errors are swallowed (stateless
10791                    // has no resend path, Spec §10.3.4.1). The
10792                    // completion `(remote_identity, secret)` is stored in the stack
10793                    // (peer_secret) — the gate registration +
10794                    // crypto-token exchange follows in Gap 6.
10795                    if let Ok(msg) = s.stateless_reader.handle_data(&d) {
10796                        #[cfg(feature = "security")]
10797                        s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
10798                        #[cfg(feature = "security")]
10799                        if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, &msg) {
10800                            outbound.extend(out);
10801                            // FU2 S1.4: handshake done → register Kx +
10802                            // send the Kx-encrypted data token to the peer over Volatile-
10803                            // Secure. (the pki lock is free here:
10804                            // on_stateless_message released it.)
10805                            if let Some((remote_identity, secret)) = completed {
10806                                if let Some(token_msg) =
10807                                    prepare_crypto_token(rt, remote_prefix, remote_identity, secret)
10808                                {
10809                                    outbound.extend(protect_volatile_outbound(
10810                                        rt,
10811                                        remote_prefix,
10812                                        s.volatile_writer
10813                                            .write_with_heartbeat(&token_msg, now)
10814                                            .unwrap_or_default(),
10815                                    ));
10816                                }
10817                                // Step 6b: per-endpoint datawriter/datareader
10818                                // tokens (per-token dedup #29: the builtins go out
10819                                // here exactly once + are marked).
10820                                let already = rt
10821                                    .endpoint_tokens_sent
10822                                    .read()
10823                                    .map(|set| set.clone())
10824                                    .unwrap_or_default();
10825                                let pending = pending_endpoint_tokens(
10826                                    prepare_endpoint_crypto_tokens(rt, remote_prefix),
10827                                    &already,
10828                                );
10829                                for ep_msg in pending {
10830                                    let key = endpoint_token_key(&ep_msg);
10831                                    outbound.extend(protect_volatile_outbound(
10832                                        rt,
10833                                        remote_prefix,
10834                                        s.volatile_writer
10835                                            .write_with_heartbeat(&ep_msg, now)
10836                                            .unwrap_or_default(),
10837                                    ));
10838                                    if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10839                                        set.insert(key);
10840                                    }
10841                                }
10842                            }
10843                        }
10844                        #[cfg(not(feature = "security"))]
10845                        let _ = msg;
10846                    }
10847                } else if d.reader_id
10848                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10849                {
10850                    // FU2 S1.4: VolatileSecure carries the crypto-token
10851                    // exchange. Kx-decrypt the received PARTICIPANT_CRYPTO_TOKENS
10852                    // message + install the data key in the gate.
10853                    if let Ok(_msgs) = s.volatile_reader.handle_data(remote_prefix, &d) {
10854                        #[cfg(feature = "security")]
10855                        for m in &_msgs {
10856                            install_crypto_token(rt, remote_prefix, m);
10857                        }
10858                        // Step 6b: now (peer ready) send our per-endpoint
10859                        // tokens back. Per-token dedup (#29): builtins
10860                        // go out early here, the later-matching user-
10861                        // endpoint tokens are caught up by the tick path (no per-peer
10862                        // guard that blocks them forever).
10863                        #[cfg(feature = "security")]
10864                        {
10865                            let already = rt
10866                                .endpoint_tokens_sent
10867                                .read()
10868                                .map(|set| set.clone())
10869                                .unwrap_or_default();
10870                            let pending = pending_endpoint_tokens(
10871                                prepare_endpoint_crypto_tokens(rt, remote_prefix),
10872                                &already,
10873                            );
10874                            for ep_msg in pending {
10875                                let key = endpoint_token_key(&ep_msg);
10876                                outbound.extend(protect_volatile_outbound(
10877                                    rt,
10878                                    remote_prefix,
10879                                    s.volatile_writer
10880                                        .write_with_heartbeat(&ep_msg, now)
10881                                        .unwrap_or_default(),
10882                                ));
10883                                if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10884                                    set.insert(key);
10885                                }
10886                            }
10887                        }
10888                        // The peer now has our participant crypto token (can
10889                        // decode our SRTPS/SEC SEDP): catch up the initially dropped
10890                        // SEDP burst once (OpenDDS convergence).
10891                        #[cfg(feature = "security")]
10892                        rt.re_announce_sedp_to_peer(remote_prefix);
10893                    }
10894                }
10895            }
10896            ParsedSubmessage::DataFrag(df) => {
10897                if df.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
10898                    || df.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
10899                {
10900                    // FU2 cross-vendor: cyclone/FastDDS RTPS-fragment the
10901                    // large HandshakeReply/Final (cert + permissions over
10902                    // MTU). Reassemble the fragments + drive them through the
10903                    // handshake driver like a stateless DATA.
10904                    if let Ok(msgs) = s.stateless_reader.handle_data_frag(&df) {
10905                        #[cfg(feature = "security")]
10906                        s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
10907                        #[cfg(feature = "security")]
10908                        for msg in &msgs {
10909                            if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, msg)
10910                            {
10911                                outbound.extend(out);
10912                                if let Some((remote_identity, secret)) = completed {
10913                                    if let Some(token_msg) = prepare_crypto_token(
10914                                        rt,
10915                                        remote_prefix,
10916                                        remote_identity,
10917                                        secret,
10918                                    ) {
10919                                        outbound.extend(protect_volatile_outbound(
10920                                            rt,
10921                                            remote_prefix,
10922                                            s.volatile_writer
10923                                                .write_with_heartbeat(&token_msg, now)
10924                                                .unwrap_or_default(),
10925                                        ));
10926                                    }
10927                                    let already = rt
10928                                        .endpoint_tokens_sent
10929                                        .read()
10930                                        .map(|set| set.clone())
10931                                        .unwrap_or_default();
10932                                    let pending = pending_endpoint_tokens(
10933                                        prepare_endpoint_crypto_tokens(rt, remote_prefix),
10934                                        &already,
10935                                    );
10936                                    for ep_msg in pending {
10937                                        let key = endpoint_token_key(&ep_msg);
10938                                        outbound.extend(protect_volatile_outbound(
10939                                            rt,
10940                                            remote_prefix,
10941                                            s.volatile_writer
10942                                                .write_with_heartbeat(&ep_msg, now)
10943                                                .unwrap_or_default(),
10944                                        ));
10945                                        if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10946                                            set.insert(key);
10947                                        }
10948                                    }
10949                                }
10950                            }
10951                        }
10952                        #[cfg(not(feature = "security"))]
10953                        let _ = msgs;
10954                    }
10955                } else if df.reader_id
10956                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10957                {
10958                    let _ = s.volatile_reader.handle_data_frag(remote_prefix, &df, now);
10959                }
10960            }
10961            ParsedSubmessage::Heartbeat(h) => {
10962                let to_volatile_reader = h.reader_id
10963                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10964                    || (h.reader_id == EntityId::UNKNOWN
10965                        && h.writer_id
10966                            == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER);
10967                if to_volatile_reader {
10968                    s.volatile_reader.handle_heartbeat(remote_prefix, &h, now);
10969                }
10970            }
10971            ParsedSubmessage::Gap(g) => {
10972                if g.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
10973                    let _ = s.volatile_reader.handle_gap(remote_prefix, &g);
10974                }
10975            }
10976            ParsedSubmessage::AckNack(ack) => {
10977                if ack.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
10978                    let base = ack.reader_sn_state.bitmap_base;
10979                    let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
10980                    let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
10981                    s.volatile_writer.handle_acknack(src, base, requested);
10982                }
10983            }
10984            ParsedSubmessage::NackFrag(nf) => {
10985                if nf.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
10986                    let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
10987                    s.volatile_writer.handle_nackfrag(src, &nf);
10988                }
10989            }
10990            _ => {}
10991        }
10992    }
10993    outbound
10994}
10995
10996/// Dispatches a datagram addressed to the TypeLookup service endpoints
10997/// (XTypes 1.3 §7.6.3.3.4). Handles incoming
10998/// requests (to `TL_SVC_REQ_READER`), generates replies and sends
10999/// them back to the source locator; handles incoming replies
11000/// (to `TL_SVC_REPLY_READER`), correlates with the client.
11001///
11002/// Returns `true` if the datagram was accepted by the TypeLookup path
11003/// — the caller can then skip the user-reader path.
11004fn dispatch_type_lookup_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], source: &Locator) -> bool {
11005    use zerodds_cdr::{BufferReader, Endianness};
11006    use zerodds_rtps::inline_qos::{SampleIdentityBytes, find_related_sample_identity};
11007    use zerodds_types::type_lookup::{
11008        GetTypeDependenciesReply, GetTypeDependenciesRequest, GetTypesReply, GetTypesRequest,
11009    };
11010
11011    let Ok(parsed) = decode_datagram(bytes) else {
11012        return false;
11013    };
11014    // DDS-RPC §7.8.2: the request sample identity = (request writer GUID,
11015    // request SN). The server carries it as PID_RELATED_SAMPLE_IDENTITY in the
11016    // reply inline QoS, so a client (also cross-vendor) can correlate
11017    // without relying on the echoed writer_sn.
11018    let src_prefix = parsed.header.guid_prefix;
11019
11020    let mut accepted = false;
11021
11022    for sub in &parsed.submessages {
11023        let ParsedSubmessage::Data(d) = sub else {
11024            continue;
11025        };
11026        let payload: &[u8] = &d.serialized_payload;
11027        if payload.is_empty() {
11028            continue;
11029        }
11030        // Skip CDR-Encapsulation header (4 bytes) if present.
11031        let body: &[u8] = if payload.len() >= 4 && (payload[0] == 0x00 && payload[1] == 0x01) {
11032            &payload[4..]
11033        } else {
11034            payload
11035        };
11036
11037        // Inbound Request → Server.
11038        if d.reader_id == EntityId::TL_SVC_REQ_READER {
11039            accepted = true;
11040            // Request sample identity = (request writer GUID, request SN) — mirrored
11041            // as related_sample_identity into the reply inline QoS.
11042            let (sn_hi, sn_lo) = d.writer_sn.split();
11043            let req_sn = ((u64::from(sn_hi as u32)) << 32) | u64::from(sn_lo);
11044            let related =
11045                SampleIdentityBytes::new(Guid::new(src_prefix, d.writer_id).to_bytes(), req_sn);
11046            // Try GetTypes-Request first; fall back to
11047            // GetTypeDependenciesRequest if that fails.
11048            let mut r = BufferReader::new(body, Endianness::Little);
11049            if let Ok(req) = GetTypesRequest::decode_from(&mut r) {
11050                let reply = match rt.type_lookup_server.lock() {
11051                    Ok(g) => g.handle_get_types(&req),
11052                    Err(_) => continue,
11053                };
11054                let _ = send_type_lookup_reply(
11055                    rt,
11056                    source,
11057                    TypeLookupReplyPayload::Types(reply),
11058                    related,
11059                );
11060                continue;
11061            }
11062            let mut r = BufferReader::new(body, Endianness::Little);
11063            if let Ok(req) = GetTypeDependenciesRequest::decode_from(&mut r) {
11064                let reply = match rt.type_lookup_server.lock() {
11065                    Ok(g) => g.handle_get_type_dependencies(&req),
11066                    Err(_) => continue,
11067                };
11068                let _ = send_type_lookup_reply(
11069                    rt,
11070                    source,
11071                    TypeLookupReplyPayload::Dependencies(reply),
11072                    related,
11073                );
11074                continue;
11075            }
11076        }
11077
11078        // Inbound Reply → Client.
11079        if d.reader_id == EntityId::TL_SVC_REPLY_READER {
11080            accepted = true;
11081            // Correlation prefers PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2,
11082            // cross-vendor compatible); fallback to the echoed writer_sn for
11083            // peers/legacy replies without inline QoS.
11084            let request_id = d
11085                .inline_qos
11086                .as_ref()
11087                .and_then(|pl| find_related_sample_identity(pl, true).ok().flatten())
11088                .map(|sid| zerodds_discovery::type_lookup::RequestId::from_u64(sid.sequence_number))
11089                .unwrap_or_else(|| {
11090                    let (sn_high, sn_low) = d.writer_sn.split();
11091                    let sn_u64 = ((u64::from(sn_high as u32)) << 32) | u64::from(sn_low);
11092                    zerodds_discovery::type_lookup::RequestId::from_u64(sn_u64)
11093                });
11094            let mut r = BufferReader::new(body, Endianness::Little);
11095            if let Ok(reply) = GetTypesReply::decode_from(&mut r) {
11096                if let Ok(mut client) = rt.type_lookup_client.lock() {
11097                    client.handle_reply(request_id, TypeLookupReply::Types(reply));
11098                }
11099                continue;
11100            }
11101            // M-5: the getTypeDependencies reply carries a different element type
11102            // (TypeIdentifierWithSize list) — its own decode branch, otherwise the
11103            // dependencies callback never fires.
11104            let mut r = BufferReader::new(body, Endianness::Little);
11105            if let Ok(reply) = GetTypeDependenciesReply::decode_from(&mut r) {
11106                if let Ok(mut client) = rt.type_lookup_client.lock() {
11107                    client.handle_reply(request_id, TypeLookupReply::Dependencies(reply));
11108                }
11109                continue;
11110            }
11111        }
11112    }
11113
11114    accepted
11115}
11116
11117/// Reply payload variants that the TypeLookup server can emit.
11118enum TypeLookupReplyPayload {
11119    Types(zerodds_types::type_lookup::GetTypesReply),
11120    Dependencies(zerodds_types::type_lookup::GetTypeDependenciesReply),
11121}
11122
11123/// Sends a TypeLookup reply to a peer locator as a
11124/// DATA datagram on the TL_SVC_REPLY_WRITER → peer's
11125/// TL_SVC_REPLY_READER. The sequence number echoes the request sequence
11126/// for correlation purposes (see XTypes §7.6.3.3.3 sample identity).
11127fn send_type_lookup_reply(
11128    rt: &Arc<DcpsRuntime>,
11129    target: &Locator,
11130    reply: TypeLookupReplyPayload,
11131    related: zerodds_rtps::inline_qos::SampleIdentityBytes,
11132) -> Result<()> {
11133    use alloc::sync::Arc as AllocArc;
11134    use core::sync::atomic::Ordering;
11135    use zerodds_cdr::{BufferWriter, Endianness};
11136    use zerodds_rtps::datagram::encode_data_datagram;
11137    use zerodds_rtps::header::RtpsHeader;
11138    use zerodds_rtps::submessages::DataSubmessage;
11139    use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber, VendorId};
11140
11141    // CDR-encode reply (PL_CDR_LE-Encapsulation).
11142    let mut w = BufferWriter::new(Endianness::Little);
11143    match reply {
11144        TypeLookupReplyPayload::Types(r) => {
11145            r.encode_into(&mut w)
11146                .map_err(|_| DdsError::PreconditionNotMet {
11147                    reason: "type_lookup reply encode failed",
11148                })?;
11149        }
11150        TypeLookupReplyPayload::Dependencies(r) => {
11151            r.encode_into(&mut w)
11152                .map_err(|_| DdsError::PreconditionNotMet {
11153                    reason: "type_lookup deps reply encode failed",
11154                })?;
11155        }
11156    }
11157    let body = w.into_bytes();
11158    let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
11159    payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
11160    payload.extend_from_slice(&body);
11161
11162    let header = RtpsHeader {
11163        protocol_version: ProtocolVersion::CURRENT,
11164        vendor_id: VendorId::ZERODDS,
11165        guid_prefix: rt.guid_prefix,
11166    };
11167    // Own monotonically increasing reply-writer SN (starting at 1) instead of a
11168    // request-SN echo — a reliable cross-vendor reply reader would otherwise see SN jumps.
11169    let reply_sn = rt
11170        .tl_reply_sn
11171        .fetch_add(1, Ordering::Relaxed)
11172        .wrapping_add(1);
11173    let writer_sn =
11174        SequenceNumber::from_high_low((reply_sn >> 32) as i32, (reply_sn & 0xFFFF_FFFF) as u32);
11175    let data = DataSubmessage {
11176        extra_flags: 0,
11177        reader_id: EntityId::TL_SVC_REPLY_READER,
11178        writer_id: EntityId::TL_SVC_REPLY_WRITER,
11179        writer_sn,
11180        // DDS-RPC §7.8.2: related_sample_identity couples the reply to the
11181        // request (cross-vendor correlation without a writer_sn echo).
11182        inline_qos: Some(zerodds_rtps::inline_qos::reply_inline_qos(related, true)),
11183        key_flag: false,
11184        non_standard_flag: false,
11185        serialized_payload: AllocArc::from(payload.into_boxed_slice()),
11186    };
11187    let datagram =
11188        encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
11189            reason: "type_lookup reply datagram encode failed",
11190        })?;
11191
11192    if is_routable_user_locator(target) {
11193        let _ = rt.user_unicast.send(target, &datagram);
11194    }
11195    Ok(())
11196}
11197
11198/// Sends a discovery datagram to all target locators. UDP-only
11199/// (TCPv4/SHM/UDS are not carried in discovery); non-UDP
11200/// locators are silently ignored.
11201fn send_discovery_datagram(rt: &Arc<DcpsRuntime>, targets: &[Locator], bytes: &[u8]) {
11202    let Some(secured) = secure_outbound_bytes(rt, bytes) else {
11203        return;
11204    };
11205    for t in targets {
11206        if !is_routable_user_locator(t) {
11207            continue;
11208        }
11209        // Send unicast metatraffic (SEDP responses, VolatileSecure, stateless auth)
11210        // from the **metatraffic recv socket** (`spdp_unicast`, = announced
11211        // metatraffic_unicast_locator), NOT from the ephemeral `spdp_mc_tx`.
11212        // Otherwise the peer sees a foreign source port and sends its
11213        // responses (e.g. cyclone's VolatileSecure ACKNACK to the source locator)
11214        // to a port ZeroDDS does not listen on → reliable resends stay
11215        // out (cross-vendor). `spdp_mc_tx` stays only for SPDP multicast.
11216        let _ = rt.spdp_unicast.send(t, &secured);
11217    }
11218}
11219
11220/// Default user-multicast locator for a DomainParticipant.
11221/// Not used in live mode 1 yet; SPDP-announced in B2.
11222#[must_use]
11223pub fn user_multicast_endpoint(domain_id: i32) -> SocketAddr {
11224    // Spec §9.6.1.4.1: user-multicast-port = PB + DG * d + d2
11225    //   = 7400 + 250 * d + 1
11226    let port = 7400u16.saturating_add(250u16.saturating_mul(domain_id as u16).saturating_add(1));
11227    SocketAddr::from((Ipv4Addr::from([239, 255, 0, 1]), port))
11228}
11229
11230#[cfg(test)]
11231#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
11232mod tests {
11233    use super::*;
11234
11235    /// A's the last mile of the big-endian decode path: a big-endian
11236    /// encapsulation header on a received DATA sample must route the typed
11237    /// decode to `DdsType::decode_be`, not `decode`. Builds a CDR2_BE wire
11238    /// sample, runs it through `delivered_to_user_sample` (the real recv→sample
11239    /// conversion), and asserts the resulting `big_endian` flag drives a correct
11240    /// decode — while the little-endian `decode` on the same BE body is wrong.
11241    #[test]
11242    fn big_endian_encap_routes_to_decode_be() {
11243        use crate::dds_type::{DdsType, DecodeError, EncodeError};
11244        #[derive(Debug, PartialEq, Clone)]
11245        struct BeProbe {
11246            v: i32,
11247        }
11248        impl DdsType for BeProbe {
11249            const TYPE_NAME: &'static str = "BeProbe";
11250            fn encode(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
11251                let mut w = zerodds_cdr::BufferWriter::new(zerodds_cdr::Endianness::Little).xcdr2();
11252                <i32 as zerodds_cdr::CdrEncode>::encode(&self.v, &mut w)?;
11253                out.extend_from_slice(&w.into_bytes());
11254                Ok(())
11255            }
11256            fn encode_be(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
11257                let mut w = zerodds_cdr::BufferWriter::new(zerodds_cdr::Endianness::Big).xcdr2();
11258                <i32 as zerodds_cdr::CdrEncode>::encode(&self.v, &mut w)?;
11259                out.extend_from_slice(&w.into_bytes());
11260                Ok(())
11261            }
11262            fn decode(b: &[u8]) -> core::result::Result<Self, DecodeError> {
11263                let mut r =
11264                    zerodds_cdr::BufferReader::new(b, zerodds_cdr::Endianness::Little).xcdr2();
11265                Ok(BeProbe {
11266                    v: <i32 as zerodds_cdr::CdrDecode>::decode(&mut r)?,
11267                })
11268            }
11269            fn decode_be(b: &[u8]) -> core::result::Result<Self, DecodeError> {
11270                let mut r = zerodds_cdr::BufferReader::new(b, zerodds_cdr::Endianness::Big).xcdr2();
11271                Ok(BeProbe {
11272                    v: <i32 as zerodds_cdr::CdrDecode>::decode(&mut r)?,
11273                })
11274            }
11275        }
11276
11277        // A value whose 4 LE bytes differ from its 4 BE bytes.
11278        let orig = BeProbe { v: 0x0102_0304 };
11279        let strengths = alloc::collections::BTreeMap::new();
11280
11281        let mk = |repr_lo: u8, body: Vec<u8>| {
11282            let mut wire = alloc::vec![0x00u8, repr_lo, 0x00, 0x00];
11283            wire.extend_from_slice(&body);
11284            zerodds_rtps::reliable_reader::DeliveredSample {
11285                writer_guid: Guid::new(GuidPrefix::from_bytes([0x11; 12]), EntityId::PARTICIPANT),
11286                sequence_number: zerodds_rtps::wire_types::SequenceNumber(1),
11287                payload: alloc::sync::Arc::from(wire.into_boxed_slice()),
11288                kind: zerodds_rtps::history_cache::ChangeKind::Alive,
11289                key_hash: None,
11290                source_timestamp: None,
11291            }
11292        };
11293
11294        // --- big-endian wire: CDR2_BE (repr low byte 0x06) ---
11295        let mut be_body = Vec::new();
11296        orig.encode_be(&mut be_body).unwrap();
11297        let us = delivered_to_user_sample(&mk(0x06, be_body), &strengths).expect("alive");
11298        let UserSample::Alive {
11299            payload,
11300            big_endian,
11301            representation,
11302            ..
11303        } = us
11304        else {
11305            panic!("expected Alive");
11306        };
11307        assert!(big_endian, "CDR2_BE encap must set big_endian");
11308        assert_eq!(representation, 1, "0x06 = XCDR2");
11309        // The subscriber dispatch: decode_be for a big-endian sample.
11310        let decoded = if big_endian {
11311            BeProbe::decode_be(&payload)
11312        } else {
11313            BeProbe::decode(&payload)
11314        }
11315        .unwrap();
11316        assert_eq!(decoded, orig, "BE wire decodes correctly via decode_be");
11317        // The dispatch matters: little-endian decode on the BE body is wrong.
11318        assert_ne!(BeProbe::decode(&payload).unwrap(), orig);
11319
11320        // --- little-endian control: CDR2_LE (repr low byte 0x07) ---
11321        let mut le_body = Vec::new();
11322        orig.encode(&mut le_body).unwrap();
11323        let us_le = delivered_to_user_sample(&mk(0x07, le_body), &strengths).expect("alive");
11324        let UserSample::Alive {
11325            payload: le_payload,
11326            big_endian: be_le,
11327            ..
11328        } = us_le
11329        else {
11330            panic!("expected Alive");
11331        };
11332        assert!(!be_le, "CDR2_LE encap must NOT set big_endian");
11333        assert_eq!(BeProbe::decode(&le_payload).unwrap(), orig);
11334    }
11335
11336    /// FU1 diagnosis: inject a REAL FastDDS-3.6 SPDP datagram (domain 205,
11337    /// codepit capture 2026-05-29) directly into handle_spdp_datagram
11338    /// — does the runtime register FastDDS as a peer? Separates the
11339    /// receive problem (socket) from the handle problem (parse/insert/filter).
11340    #[test]
11341    fn handle_spdp_registers_real_fastdds_participant() {
11342        fn hx(s: &str) -> Vec<u8> {
11343            (0..s.len())
11344                .step_by(2)
11345                .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
11346                .collect()
11347        }
11348        const FASTDDS_SPDP: &str = "525450530203010f010fa72bbbebc90100000000090108006850196a57e882b41505b80100001000000100c7000100c2000000000100000000030000150004000203000016000400010f000000800400030601000f000400cd00000050001000010fa72bbbebc90100000000000001c107800400010000000380280021000000653830653630353335646339343432636231306537323239313038653135666600000000320018000100000024e50000000000000000000000000000c0a8b273310018000100000025e50000000000000000000000000000c0a8b273020008001400000000000000580004003ffc0f006200140010000000525450535061727469636970616e74005900cc0004000000110000005041525449434950414e545f54595045000000000700000053494d504c4500001b000000666173746464732e706879736963616c5f646174612e686f73740000210000006538306536303533356463393434326362313065373232393130386531356666000000001b000000666173746464732e706879736963616c5f646174612e75736572000005000000726f6f74000000001e000000666173746464732e706879736963616c5f646174612e70726f636573730000000600000036303334370000000100000080013800010000001ae50000000000000000000000000000efff00016850196a72ca8cb4020000000000000030040000000000000000000000000000";
11349        let bytes = hx(FASTDDS_SPDP);
11350        let prefix = GuidPrefix::from_bytes([0x99; 12]);
11351        let rt =
11352            Arc::new(DcpsRuntime::start(205, prefix, RuntimeConfig::default()).expect("rt start"));
11353        assert_eq!(rt.discovered_participants().len(), 0, "fresh: no peers");
11354        handle_spdp_datagram_for_test(&rt, &bytes);
11355        let n = rt.discovered_participants().len();
11356        assert_eq!(
11357            n, 1,
11358            "FastDDS must be registered after handle_spdp_datagram (got {n})"
11359        );
11360    }
11361
11362    #[test]
11363    fn select_user_transport_tcpv4_yields_tcpv4_locator() {
11364        let prefix = GuidPrefix::from_bytes([1u8; 12]);
11365        let (t, accept) =
11366            select_user_transport(UserTransportKind::TcpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
11367                .expect("TcpV4 transport");
11368        assert_eq!(t.local_locator().kind, LocatorKind::Tcpv4);
11369        assert!(accept.is_some(), "TCP needs an accept handle");
11370    }
11371
11372    #[test]
11373    fn select_user_transport_udpv4_default_kind() {
11374        let prefix = GuidPrefix::from_bytes([2u8; 12]);
11375        let (t, accept) =
11376            select_user_transport(UserTransportKind::UdpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
11377                .expect("UdpV4 transport");
11378        assert_eq!(t.local_locator().kind, LocatorKind::UdpV4);
11379        assert!(accept.is_none(), "UDP needs no accept handle");
11380    }
11381
11382    #[cfg(feature = "same-host-uds")]
11383    #[test]
11384    fn select_user_transport_uds_yields_uds_locator() {
11385        let prefix = GuidPrefix::from_bytes([3u8; 12]);
11386        let (t, accept) =
11387            select_user_transport(UserTransportKind::Uds, prefix, 0, Ipv4Addr::UNSPECIFIED)
11388                .expect("Uds transport");
11389        assert_eq!(t.local_locator().kind, LocatorKind::Uds);
11390        assert!(accept.is_none(), "UDS needs no accept handle");
11391    }
11392
11393    #[test]
11394    fn strip_user_encap_xcdr2_le() {
11395        let payload = [0x00, 0x07, 0x00, 0x00, 1, 2, 3];
11396        assert_eq!(strip_user_encap(&payload), Some(alloc::vec![1, 2, 3]));
11397    }
11398
11399    #[test]
11400    fn strip_user_encap_xcdr1_le() {
11401        // Cyclone default for simple types.
11402        let payload = [0x00, 0x01, 0x00, 0x00, 0xAA];
11403        assert_eq!(strip_user_encap(&payload), Some(alloc::vec![0xAA]));
11404    }
11405
11406    #[test]
11407    fn strip_user_encap_rejects_unknown_scheme() {
11408        let payload = [0xFF, 0xFF, 0x00, 0x00, 1];
11409        assert_eq!(strip_user_encap(&payload), None);
11410    }
11411
11412    #[test]
11413    fn strip_user_encap_rejects_short() {
11414        assert_eq!(strip_user_encap(&[0x00, 0x07]), None);
11415    }
11416
11417    #[test]
11418    fn user_payload_encap_is_cdr_le() {
11419        // CDR_LE (PLAIN_CDR / XCDR1, Little-Endian) — ehrliche
11420        // Declaration of the body encoding generated by codegen.
11421        assert_eq!(USER_PAYLOAD_ENCAP, [0x00, 0x01, 0x00, 0x00]);
11422    }
11423
11424    #[test]
11425    fn data_repr_offer_str_uses_spec_ids() {
11426        use zerodds_rtps::publication_data::data_representation as dr;
11427        // XCDR1 -> Spec-Id 0 (NICHT 1 = XML); XCDR2 -> 2.
11428        assert_eq!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XCDR]));
11429        assert_eq!(parse_data_repr_offer_str("XCDR2"), Some(vec![dr::XCDR2]));
11430        assert_eq!(parse_data_repr_offer_str("xcdr2"), Some(vec![dr::XCDR2]));
11431        assert_eq!(
11432            parse_data_repr_offer_str("XCDR2,XCDR1"),
11433            Some(vec![dr::XCDR2, dr::XCDR])
11434        );
11435        assert_eq!(parse_data_repr_offer_str("bogus"), None);
11436        assert_eq!(parse_data_repr_offer_str(""), None);
11437        // XCDR1 must NOT map to the XML id (1).
11438        assert_ne!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XML]));
11439    }
11440
11441    /// A DataReader announces every representation it can decode (XCDR2 + XCDR1)
11442    /// — XTypes 1.3 §7.6.2: the default reader policy accepts both. CycloneDDS
11443    /// (and legacy RTI / OpenDDS < 3.16) default their writers to XCDR1 for
11444    /// `@final` types; without XCDR1 in the reader's announced set those writers
11445    /// fail the DataRepresentation RxO check and never deliver. Regression for
11446    /// Bug DR1.
11447    #[test]
11448    fn reader_accept_repr_always_includes_both_representations() {
11449        use zerodds_rtps::publication_data::data_representation as dr;
11450        // Default writer offer [XCDR2] -> reader must also accept XCDR1.
11451        let widened = reader_accept_repr(&[dr::XCDR2]);
11452        assert!(widened.contains(&dr::XCDR2));
11453        assert!(widened.contains(&dr::XCDR));
11454        // XCDR2 stays first (the preferred / generated encoding).
11455        assert_eq!(widened[0], dr::XCDR2);
11456        // Already-both list is preserved (idempotent, order kept).
11457        assert_eq!(
11458            reader_accept_repr(&[dr::XCDR, dr::XCDR2]),
11459            alloc::vec![dr::XCDR, dr::XCDR2]
11460        );
11461        // Empty config still yields both.
11462        let from_empty = reader_accept_repr(&[]);
11463        assert!(from_empty.contains(&dr::XCDR2) && from_empty.contains(&dr::XCDR));
11464    }
11465
11466    #[test]
11467    fn user_payload_encap_maps_repr_and_extensibility() {
11468        use zerodds_rtps::publication_data::data_representation as dr;
11469        use zerodds_types::qos::ExtensibilityForRepr as Ext;
11470        // DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 Encapsulation-IDs
11471        // (2-byte repr-id BE + 2-byte options=0), little-endian variant:
11472        //   XCDR1 final/appendable -> CDR_LE        0x0001
11473        //   XCDR1 mutable          -> PL_CDR_LE      0x0003
11474        //   XCDR2 final            -> PLAIN_CDR2_LE  0x0007
11475        //   XCDR2 appendable       -> D_CDR2_LE      0x0009
11476        //   XCDR2 mutable          -> PL_CDR2_LE     0x000b
11477        assert_eq!(
11478            user_payload_encap(dr::XCDR, Ext::Final, false),
11479            [0x00, 0x01, 0x00, 0x00]
11480        );
11481        assert_eq!(
11482            user_payload_encap(dr::XCDR, Ext::Appendable, false),
11483            [0x00, 0x01, 0x00, 0x00]
11484        );
11485        assert_eq!(
11486            user_payload_encap(dr::XCDR, Ext::Mutable, false),
11487            [0x00, 0x03, 0x00, 0x00]
11488        );
11489        assert_eq!(
11490            user_payload_encap(dr::XCDR2, Ext::Final, false),
11491            [0x00, 0x07, 0x00, 0x00]
11492        );
11493        assert_eq!(
11494            user_payload_encap(dr::XCDR2, Ext::Appendable, false),
11495            [0x00, 0x09, 0x00, 0x00]
11496        );
11497        assert_eq!(
11498            user_payload_encap(dr::XCDR2, Ext::Mutable, false),
11499            [0x00, 0x0b, 0x00, 0x00]
11500        );
11501        // The default const is exactly the (XCDR1, Final) case.
11502        assert_eq!(
11503            user_payload_encap(dr::XCDR, Ext::Final, false),
11504            USER_PAYLOAD_ENCAP
11505        );
11506        // Unknown/XML repr falls back safely to CDR_LE.
11507        assert_eq!(
11508            user_payload_encap(dr::XML, Ext::Final, false),
11509            [0x00, 0x01, 0x00, 0x00]
11510        );
11511        // big_endian=true selects the `_BE` variant (the even predecessor of
11512        // the odd `_LE` id): CDR_BE 0x00, PL_CDR_BE 0x02, PLAIN_CDR2_BE 0x06,
11513        // D_CDR2_BE 0x08, PL_CDR2_BE 0x0a (RTPS 2.5 §10.5). Used by the
11514        // durability service to replay a big-endian peer's stored sample.
11515        assert_eq!(
11516            user_payload_encap(dr::XCDR, Ext::Final, true),
11517            [0x00, 0x00, 0x00, 0x00]
11518        );
11519        assert_eq!(
11520            user_payload_encap(dr::XCDR, Ext::Mutable, true),
11521            [0x00, 0x02, 0x00, 0x00]
11522        );
11523        assert_eq!(
11524            user_payload_encap(dr::XCDR2, Ext::Final, true),
11525            [0x00, 0x06, 0x00, 0x00]
11526        );
11527        assert_eq!(
11528            user_payload_encap(dr::XCDR2, Ext::Appendable, true),
11529            [0x00, 0x08, 0x00, 0x00]
11530        );
11531        assert_eq!(
11532            user_payload_encap(dr::XCDR2, Ext::Mutable, true),
11533            [0x00, 0x0a, 0x00, 0x00]
11534        );
11535    }
11536
11537    #[test]
11538    fn observability_sink_records_writer_and_reader_creation() {
11539        // VecSink injizieren, Writer + Reader erzeugen,
11540        // check that both events arrive.
11541        use std::sync::Arc as StdArc;
11542        use zerodds_foundation::observability::{Component, Level, VecSink};
11543
11544        let sink = StdArc::new(VecSink::new());
11545        let cfg = RuntimeConfig {
11546            observability: sink.clone(),
11547            ..RuntimeConfig::default()
11548        };
11549        let rt =
11550            DcpsRuntime::start(7, GuidPrefix::from_bytes([0xAA; 12]), cfg).expect("start runtime");
11551        let _ = rt.register_user_writer(UserWriterConfig {
11552            topic_name: "ObsTopic".into(),
11553            type_name: "ObsType".into(),
11554            reliable: true,
11555            durability: zerodds_qos::DurabilityKind::Volatile,
11556            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11557            lifespan: zerodds_qos::LifespanQosPolicy::default(),
11558            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11559            ownership: zerodds_qos::OwnershipKind::Shared,
11560            ownership_strength: 0,
11561            partition: alloc::vec![],
11562            user_data: alloc::vec![],
11563            topic_data: alloc::vec![],
11564            group_data: alloc::vec![],
11565            type_identifier: zerodds_types::TypeIdentifier::None,
11566            data_representation_offer: None,
11567        });
11568        let _ = rt.register_user_reader(UserReaderConfig {
11569            topic_name: "ObsTopic".into(),
11570            type_name: "ObsType".into(),
11571            reliable: true,
11572            durability: zerodds_qos::DurabilityKind::Volatile,
11573            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11574            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11575            ownership: zerodds_qos::OwnershipKind::Shared,
11576            partition: alloc::vec![],
11577            user_data: alloc::vec![],
11578            topic_data: alloc::vec![],
11579            group_data: alloc::vec![],
11580            type_identifier: zerodds_types::TypeIdentifier::None,
11581            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11582            data_representation_offer: None,
11583        });
11584        rt.shutdown();
11585
11586        let events = sink.snapshot();
11587        assert!(
11588            events.iter().any(|e| e.name == "user_writer.created"
11589                && e.component == Component::Dcps
11590                && e.level == Level::Info),
11591            "writer-event missing: got {:?}",
11592            events.iter().map(|e| e.name).collect::<Vec<_>>()
11593        );
11594        assert!(
11595            events
11596                .iter()
11597                .any(|e| e.name == "user_reader.created" && e.component == Component::Dcps),
11598            "reader-event missing"
11599        );
11600        // The topic attribute must hang on the writer.created event.
11601        let writer_event = events
11602            .iter()
11603            .find(|e| e.name == "user_writer.created")
11604            .expect("writer event");
11605        assert!(
11606            writer_event
11607                .attrs
11608                .iter()
11609                .any(|a| a.key == "topic" && a.value == "ObsTopic"),
11610            "topic attr missing"
11611        );
11612    }
11613
11614    #[test]
11615    fn user_endpoint_entity_kind_follows_keyedness() {
11616        // Regression (ROS-2 cross-vendor): the entityKind of a user
11617        // endpoint MUST follow the type keyedness (Spec §9.3.1.2). A
11618        // a keyless type yields NoKey (Writer 0x03 / Reader 0x04), a
11619        // keyed type WithKey (0x02 / 0x07). If this does not match the
11620        // peer, CycloneDDS/ROS 2 silently rejects the endpoint match
11621        // (DDS_INVALID_QOS_POLICY_ID, no log). create_datawriter/
11622        // create_datareader derive `is_keyed` from `DdsType::HAS_KEY`.
11623        use zerodds_rtps::wire_types::EntityKind;
11624        let rt = DcpsRuntime::start(
11625            11,
11626            GuidPrefix::from_bytes([0xBC; 12]),
11627            RuntimeConfig::default(),
11628        )
11629        .expect("start runtime");
11630        let mk_w = || UserWriterConfig {
11631            topic_name: "KindTopic".into(),
11632            type_name: "KindType".into(),
11633            reliable: true,
11634            durability: zerodds_qos::DurabilityKind::Volatile,
11635            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11636            lifespan: zerodds_qos::LifespanQosPolicy::default(),
11637            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11638            ownership: zerodds_qos::OwnershipKind::Shared,
11639            ownership_strength: 0,
11640            partition: alloc::vec![],
11641            user_data: alloc::vec![],
11642            topic_data: alloc::vec![],
11643            group_data: alloc::vec![],
11644            type_identifier: zerodds_types::TypeIdentifier::None,
11645            data_representation_offer: None,
11646        };
11647        let mk_r = || UserReaderConfig {
11648            topic_name: "KindTopic".into(),
11649            type_name: "KindType".into(),
11650            reliable: true,
11651            durability: zerodds_qos::DurabilityKind::Volatile,
11652            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11653            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11654            ownership: zerodds_qos::OwnershipKind::Shared,
11655            partition: alloc::vec![],
11656            user_data: alloc::vec![],
11657            topic_data: alloc::vec![],
11658            group_data: alloc::vec![],
11659            type_identifier: zerodds_types::TypeIdentifier::None,
11660            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11661            data_representation_offer: None,
11662        };
11663        // keyless (HAS_KEY=false) -> NoKey
11664        let w_nokey = rt.register_user_writer_kind(mk_w(), false).expect("writer");
11665        assert_eq!(w_nokey.entity_kind, EntityKind::UserWriterNoKey);
11666        let (r_nokey, _) = rt.register_user_reader_kind(mk_r(), false).expect("reader");
11667        assert_eq!(r_nokey.entity_kind, EntityKind::UserReaderNoKey);
11668        // keyed (HAS_KEY=true) -> WithKey
11669        let w_key = rt.register_user_writer_kind(mk_w(), true).expect("writer");
11670        assert_eq!(w_key.entity_kind, EntityKind::UserWriterWithKey);
11671        let (r_key, _) = rt.register_user_reader_kind(mk_r(), true).expect("reader");
11672        assert_eq!(r_key.entity_kind, EntityKind::UserReaderWithKey);
11673        rt.shutdown();
11674    }
11675
11676    #[test]
11677    fn incompatible_qos_match_emits_loud_warning() {
11678        // C2 "loud instead of silent": an incompatible QoS match is logged as a
11679        // warn event with topic + policy, not silently discarded.
11680        // Setup: writer Volatile + reader TransientLocal on the same
11681        // Topic (reader requests more durability than the writer offers)
11682        // → intra-runtime match fails with policy DURABILITY.
11683        use std::sync::Arc as StdArc;
11684        use zerodds_foundation::observability::{Component, Level, VecSink};
11685
11686        let sink = StdArc::new(VecSink::new());
11687        let cfg_a = RuntimeConfig {
11688            observability: sink.clone(),
11689            tick_period: Duration::from_millis(5),
11690            ..RuntimeConfig::default()
11691        };
11692        let cfg_b = RuntimeConfig {
11693            tick_period: Duration::from_millis(5),
11694            ..RuntimeConfig::default()
11695        };
11696        // Two same-process runtimes, same domain → inproc discovery.
11697        let rt = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCE; 12]), cfg_a)
11698            .expect("start runtime a");
11699        let rt_b = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCF; 12]), cfg_b)
11700            .expect("start runtime b");
11701        let _w = rt
11702            .register_user_writer(UserWriterConfig {
11703                topic_name: "QT".into(),
11704                type_name: "QType".into(),
11705                reliable: false,
11706                durability: zerodds_qos::DurabilityKind::Volatile,
11707                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11708                lifespan: zerodds_qos::LifespanQosPolicy::default(),
11709                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11710                ownership: zerodds_qos::OwnershipKind::Shared,
11711                ownership_strength: 0,
11712                partition: alloc::vec![],
11713                user_data: alloc::vec![],
11714                topic_data: alloc::vec![],
11715                group_data: alloc::vec![],
11716                type_identifier: zerodds_types::TypeIdentifier::None,
11717                data_representation_offer: None,
11718            })
11719            .expect("writer");
11720        let _r = rt_b
11721            .register_user_reader(UserReaderConfig {
11722                topic_name: "QT".into(),
11723                type_name: "QType".into(),
11724                reliable: false,
11725                durability: zerodds_qos::DurabilityKind::TransientLocal,
11726                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11727                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11728                ownership: zerodds_qos::OwnershipKind::Shared,
11729                partition: alloc::vec![],
11730                user_data: alloc::vec![],
11731                topic_data: alloc::vec![],
11732                group_data: alloc::vec![],
11733                type_identifier: zerodds_types::TypeIdentifier::None,
11734                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11735                data_representation_offer: None,
11736            })
11737            .expect("reader");
11738        // Await the match pass.
11739        let mut found = false;
11740        for _ in 0..40 {
11741            std::thread::sleep(Duration::from_millis(25));
11742            let events = sink.snapshot();
11743            if events.iter().any(|e| {
11744                (e.name == "qos.incompatible.offered" || e.name == "qos.incompatible.requested")
11745                    && e.component == Component::Dcps
11746                    && e.level == Level::Warn
11747                    && e.attrs.iter().any(|a| a.key == "topic" && a.value == "QT")
11748                    && e.attrs
11749                        .iter()
11750                        .any(|a| a.key == "policy" && a.value == "DURABILITY")
11751            }) {
11752                found = true;
11753                break;
11754            }
11755        }
11756        rt.shutdown();
11757        rt_b.shutdown();
11758        assert!(
11759            found,
11760            "expected a loud qos.incompatible warn event with policy DURABILITY"
11761        );
11762    }
11763
11764    #[test]
11765    fn spdp_unicast_port_follows_rtps_formula() {
11766        // Spec §9.6.1.4.1: PB + DG*domain + d1 + PG*pid = 7400+250*d+10+2*pid.
11767        assert_eq!(super::spdp_unicast_port(0, 0), 7410);
11768        assert_eq!(spdp_unicast_port(0, 1), 7412);
11769        assert_eq!(spdp_unicast_port(1, 0), 7660);
11770        assert_eq!(spdp_unicast_port(7, 0), 9160);
11771    }
11772
11773    #[test]
11774    fn announce_locator_pins_interface_over_route_probe() {
11775        // Interface pinning: a set interface takes precedence over the
11776        // route probe (multi-homed robustness, cf. Cyclone NetworkInterface).
11777        let udp = UdpTransport::bind_v4(Ipv4Addr::UNSPECIFIED, 0).expect("bind");
11778        let pin = Ipv4Addr::new(10, 11, 12, 13);
11779        let loc = super::announce_locator(&udp, pin);
11780        assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
11781        assert_eq!(loc.address[12..], [10, 11, 12, 13]);
11782        // Without a pin (UNSPECIFIED) → probe/fallback does NOT return the pin IP.
11783        let auto = super::announce_locator(&udp, Ipv4Addr::UNSPECIFIED);
11784        assert_ne!(auto.address[12..], [10, 11, 12, 13]);
11785    }
11786
11787    #[test]
11788    fn expand_initial_peer_ip_only_yields_well_known_port_range() {
11789        let m = super::INITIAL_PEER_MAX_PARTICIPANTS;
11790        let mut out = Vec::new();
11791        super::expand_initial_peer("127.0.0.1", 0, m, &mut out);
11792        assert_eq!(out.len(), m as usize);
11793        assert_eq!(out[0].port, 7410);
11794        assert_eq!(out[1].port, 7412);
11795        // Larger limit → more ports (C1 dense multi-robot scenarios).
11796        let mut wide = Vec::new();
11797        super::expand_initial_peer("127.0.0.1", 0, 30, &mut wide);
11798        assert_eq!(wide.len(), 30);
11799        assert_eq!(wide[29].port, 7410 + 2 * 29);
11800        // ip:port -> exactly one exact locator.
11801        let mut one = Vec::new();
11802        super::expand_initial_peer("10.0.0.5:7410", 0, m, &mut one);
11803        assert_eq!(one.len(), 1);
11804        assert_eq!(one[0].port, 7410);
11805        assert_eq!(one[0].address[12..], [10, 0, 0, 5]);
11806        // Garbage is ignored.
11807        let mut none = Vec::new();
11808        super::expand_initial_peer("not-an-ip", 0, m, &mut none);
11809        assert!(none.is_empty());
11810    }
11811
11812    #[test]
11813    #[ignore = "heavy multi-runtime scaling test (12 runtimes); explicit: cargo test -- --ignored"]
11814    #[allow(clippy::print_stdout)]
11815    fn multicast_free_discovery_scales_to_many_participants() {
11816        // C1 scaling: N participants, each with its own multicast group
11817        // (→ separate inproc buckets) AND multicast send off → pure
11818        // Unicast discovery via an explicit well-known-port peer list. Evidence,
11819        // that multicast-free all-to-all discovery works beyond 2 participants
11820        // (the "N²-multicast-storm" pain cluster, but unicast).
11821        // N via env (ZERODDS_SCALE_N, default 12) for >50 perf demos.
11822        let n: u32 = std::env::var("ZERODDS_SCALE_N")
11823            .ok()
11824            .and_then(|s| s.parse().ok())
11825            .unwrap_or(12)
11826            .clamp(2, 120);
11827        let domain = 21;
11828        let peers: Vec<Locator> = (0..n)
11829            .map(|pid| Locator::udp_v4([127, 0, 0, 1], super::spdp_unicast_port(domain, pid)))
11830            .collect();
11831        let mut rts = Vec::new();
11832        for i in 0..n {
11833            let cfg = RuntimeConfig {
11834                tick_period: Duration::from_millis(10),
11835                spdp_period: Duration::from_millis(40),
11836                // Own group per runtime → no inproc, no multicast.
11837                spdp_multicast_group: Ipv4Addr::new(239, 255, 21, (i + 1) as u8),
11838                spdp_multicast_send: false,
11839                initial_peers: peers.clone(),
11840                ..RuntimeConfig::default()
11841            };
11842            // Unique prefix even for n>47 (two-byte index).
11843            let mut pb = [0xD0u8; 12];
11844            pb[0] = (i & 0xff) as u8;
11845            pb[1] = (i >> 8) as u8;
11846            let prefix = GuidPrefix::from_bytes(pb);
11847            rts.push(DcpsRuntime::start(domain as i32, prefix, cfg).expect("start"));
11848        }
11849        // Wait until each participant has discovered all n-1 others.
11850        // Grosszuegiges Fenster: viele Runtimes konkurrieren um CPU; break-early.
11851        let started = std::time::Instant::now();
11852        let mut all_full = false;
11853        for _ in 0..1200 {
11854            std::thread::sleep(Duration::from_millis(25));
11855            if rts
11856                .iter()
11857                .all(|rt| rt.discovered_participants().len() >= (n as usize - 1))
11858            {
11859                all_full = true;
11860                break;
11861            }
11862        }
11863        let elapsed = started.elapsed();
11864        let min_seen = rts
11865            .iter()
11866            .map(|rt| rt.discovered_participants().len())
11867            .min()
11868            .unwrap_or(0);
11869        for rt in &rts {
11870            rt.shutdown();
11871        }
11872        println!(
11873            "C1-Scaling: {n} Participants multicast-frei all-to-all in {:.2}s (min={min_seen}/{})",
11874            elapsed.as_secs_f64(),
11875            n - 1
11876        );
11877        assert!(
11878            all_full,
11879            "multicast-free all-to-all discovery does not scale: min seen = {min_seen}/{}",
11880            n - 1
11881        );
11882    }
11883
11884    #[test]
11885    fn default_reassembly_cap_is_ros_realistic() {
11886        // C3 regression: the DCPS reassembly cap must be ROS-PointCloud2/
11887        // Image-capable (several MB), not the conservative
11888        // rtps 1-MiB default that silently discards large samples.
11889        let cfg = RuntimeConfig::default();
11890        assert!(
11891            cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024,
11892            "reassembly cap too small for ROS PointCloud2/Image: {}",
11893            cfg.max_reassembly_sample_bytes
11894        );
11895    }
11896
11897    #[test]
11898    fn ros_defaults_offers_xcdr1_for_ros_writers() {
11899        // C4: the ROS profile offers [XCDR1, XCDR2] (matches ROS/Cyclone
11900        // XCDR1 writer) + keeps the ROS-realistic reassembly cap.
11901        use zerodds_rtps::publication_data::data_representation as dr;
11902        let cfg = RuntimeConfig::ros_defaults();
11903        assert_eq!(
11904            cfg.data_representation_offer,
11905            alloc::vec![dr::XCDR, dr::XCDR2]
11906        );
11907        assert!(cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024);
11908    }
11909
11910    #[test]
11911    fn multicast_free_discovery_via_initial_peers() {
11912        // C1: two runtimes with DIFFERENT multicast groups lie
11913        // in different inproc buckets AND cannot see each other via
11914        // multicast — so they discover each other EXCLUSIVELY via
11915        // the unicast initial peers (well-known SPDP ports on 127.0.0.1).
11916        let domain = 7;
11917        let mut peers = Vec::new();
11918        super::expand_initial_peer(
11919            "127.0.0.1",
11920            domain as u32,
11921            super::INITIAL_PEER_MAX_PARTICIPANTS,
11922            &mut peers,
11923        );
11924        let mk = |group: [u8; 4]| RuntimeConfig {
11925            tick_period: Duration::from_millis(10),
11926            spdp_period: Duration::from_millis(40),
11927            spdp_multicast_group: Ipv4Addr::from(group),
11928            // Multicast send fully off → rigorous unicast-only proof.
11929            spdp_multicast_send: false,
11930            initial_peers: peers.clone(),
11931            ..RuntimeConfig::default()
11932        };
11933        let a = DcpsRuntime::start(
11934            domain,
11935            GuidPrefix::from_bytes([0xA1; 12]),
11936            mk([239, 255, 7, 1]),
11937        )
11938        .expect("a");
11939        let b = DcpsRuntime::start(
11940            domain,
11941            GuidPrefix::from_bytes([0xB2; 12]),
11942            mk([239, 255, 7, 2]),
11943        )
11944        .expect("b");
11945        let mut discovered = false;
11946        for _ in 0..160 {
11947            std::thread::sleep(Duration::from_millis(25));
11948            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
11949                discovered = true;
11950                break;
11951            }
11952        }
11953        a.shutdown();
11954        b.shutdown();
11955        assert!(
11956            discovered,
11957            "multicast-freie Discovery via Unicast-Initial-Peers fehlgeschlagen"
11958        );
11959    }
11960
11961    #[test]
11962    fn multi_robot_profile_is_multicast_free_and_wan_tolerant() {
11963        // C6: the named profile must be unicast-only with ROS reprs and a
11964        // WAN-tolerant lease, independent of any env.
11965        let cfg = RuntimeConfig::multi_robot();
11966        assert!(
11967            !cfg.spdp_multicast_send,
11968            "multi_robot() must disable multicast send"
11969        );
11970        assert_eq!(
11971            cfg.data_representation_offer,
11972            alloc::vec![
11973                zerodds_rtps::publication_data::data_representation::XCDR,
11974                zerodds_rtps::publication_data::data_representation::XCDR2
11975            ],
11976            "multi_robot() must offer the ROS XCDR1+XCDR2 reprs"
11977        );
11978        assert_eq!(
11979            cfg.participant_lease_duration,
11980            Duration::from_secs(300),
11981            "multi_robot() must use the WAN-tolerant 300s lease"
11982        );
11983    }
11984
11985    #[test]
11986    fn multi_robot_profile_discovers_via_unicast() {
11987        // C6 e2e: two runtimes started from the `multi_robot()` profile (whose
11988        // `spdp_multicast_send = false` is the field under test) sit in
11989        // different multicast buckets and can ONLY find each other through the
11990        // unicast initial peers — proving the profile drives multicast-free
11991        // discovery end-to-end. Only test-timing + the peer list are
11992        // overridden; `spdp_multicast_send` comes from the profile.
11993        let domain = 9;
11994        let mut peers = Vec::new();
11995        super::expand_initial_peer(
11996            "127.0.0.1",
11997            domain as u32,
11998            super::INITIAL_PEER_MAX_PARTICIPANTS,
11999            &mut peers,
12000        );
12001        let mk = |group: [u8; 4]| RuntimeConfig {
12002            tick_period: Duration::from_millis(10),
12003            spdp_period: Duration::from_millis(40),
12004            spdp_multicast_group: Ipv4Addr::from(group),
12005            initial_peers: peers.clone(),
12006            ..RuntimeConfig::multi_robot()
12007        };
12008        let a = DcpsRuntime::start(
12009            domain,
12010            GuidPrefix::from_bytes([0xC6; 12]),
12011            mk([239, 255, 9, 1]),
12012        )
12013        .expect("a");
12014        let b = DcpsRuntime::start(
12015            domain,
12016            GuidPrefix::from_bytes([0xD7; 12]),
12017            mk([239, 255, 9, 2]),
12018        )
12019        .expect("b");
12020        let mut discovered = false;
12021        for _ in 0..160 {
12022            std::thread::sleep(Duration::from_millis(25));
12023            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12024                discovered = true;
12025                break;
12026            }
12027        }
12028        a.shutdown();
12029        b.shutdown();
12030        assert!(
12031            discovered,
12032            "multi_robot() profile failed to discover via unicast initial peers"
12033        );
12034    }
12035
12036    #[test]
12037    fn intra_runtime_writer_to_reader_loopback_delivers_sample() {
12038        // Bridge daemon use case: writer and reader in the SAME
12039        // DcpsRuntime, same topic+type. Before the same-runtime loopback
12040        // hook, a write() produced NO sample at the local reader,
12041        // because `inproc_announce_*` explicitly skips self and UDP multicast
12042        // loopback is not guaranteed.
12043        let rt = DcpsRuntime::start(
12044            17,
12045            GuidPrefix::from_bytes([0x42; 12]),
12046            RuntimeConfig::default(),
12047        )
12048        .expect("start runtime");
12049        let writer_eid = rt
12050            .register_user_writer(UserWriterConfig {
12051                topic_name: "IntraTopic".into(),
12052                type_name: "IntraType".into(),
12053                reliable: true,
12054                durability: zerodds_qos::DurabilityKind::Volatile,
12055                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12056                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12057                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12058                ownership: zerodds_qos::OwnershipKind::Shared,
12059                ownership_strength: 0,
12060                partition: alloc::vec![],
12061                user_data: alloc::vec![],
12062                topic_data: alloc::vec![],
12063                group_data: alloc::vec![],
12064                type_identifier: zerodds_types::TypeIdentifier::None,
12065                data_representation_offer: None,
12066            })
12067            .expect("register writer");
12068        let (_reader_eid, rx) = rt
12069            .register_user_reader(UserReaderConfig {
12070                topic_name: "IntraTopic".into(),
12071                type_name: "IntraType".into(),
12072                reliable: true,
12073                durability: zerodds_qos::DurabilityKind::Volatile,
12074                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12075                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12076                ownership: zerodds_qos::OwnershipKind::Shared,
12077                partition: alloc::vec![],
12078                user_data: alloc::vec![],
12079                topic_data: alloc::vec![],
12080                group_data: alloc::vec![],
12081                type_identifier: zerodds_types::TypeIdentifier::None,
12082                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12083                data_representation_offer: None,
12084            })
12085            .expect("register reader");
12086
12087        rt.write_user_sample(writer_eid, b"hello-intra-runtime".to_vec())
12088            .expect("write");
12089
12090        // Same-runtime loopback is synchronous in the write_user_sample_borrowed
12091        // path — `recv_timeout` needs only microseconds, not the
12092        // wire roundtrip.
12093        let sample = rx
12094            .recv_timeout(core::time::Duration::from_millis(100))
12095            .expect("intra-runtime reader should receive sample");
12096        match sample {
12097            UserSample::Alive { payload, .. } => {
12098                assert_eq!(payload.as_ref(), b"hello-intra-runtime");
12099            }
12100            other => panic!("expected Alive, got {other:?}"),
12101        }
12102        rt.shutdown();
12103    }
12104
12105    /// Bug R4 (#63): the same-runtime writer→reader loopback path
12106    /// (`intra_runtime_dispatch_alive`) used to hardcode the XCDR
12107    /// data-representation tag = `0`, so a DataWriter and DataReader sharing
12108    /// one `DcpsRuntime` lost the writer's real representation. Asserts the
12109    /// tag is carried through: default offer (`[XCDR2]`) → `1`, and an
12110    /// explicit `[XCDR1]` per-writer override → `0`. Also confirms a typed
12111    /// sample (XCDR2-framed body) round-trips intact alongside the tag.
12112    #[test]
12113    fn intra_runtime_loopback_preserves_representation_tag() {
12114        use zerodds_rtps::publication_data::data_representation as dr;
12115
12116        fn run_case(domain: i32, prefix: u8, offer: Option<Vec<i16>>, expected_rep: u8) {
12117            let rt = DcpsRuntime::start(
12118                domain,
12119                GuidPrefix::from_bytes([prefix; 12]),
12120                RuntimeConfig::default(),
12121            )
12122            .expect("start runtime");
12123            let writer_eid = rt
12124                .register_user_writer(UserWriterConfig {
12125                    topic_name: "RepTopic".into(),
12126                    type_name: "RepType".into(),
12127                    reliable: true,
12128                    durability: zerodds_qos::DurabilityKind::Volatile,
12129                    deadline: zerodds_qos::DeadlineQosPolicy::default(),
12130                    lifespan: zerodds_qos::LifespanQosPolicy::default(),
12131                    liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12132                    ownership: zerodds_qos::OwnershipKind::Shared,
12133                    ownership_strength: 0,
12134                    partition: alloc::vec![],
12135                    user_data: alloc::vec![],
12136                    topic_data: alloc::vec![],
12137                    group_data: alloc::vec![],
12138                    type_identifier: zerodds_types::TypeIdentifier::None,
12139                    data_representation_offer: offer,
12140                })
12141                .expect("register writer");
12142            let (_reader_eid, rx) = rt
12143                .register_user_reader(UserReaderConfig {
12144                    topic_name: "RepTopic".into(),
12145                    type_name: "RepType".into(),
12146                    reliable: true,
12147                    durability: zerodds_qos::DurabilityKind::Volatile,
12148                    deadline: zerodds_qos::DeadlineQosPolicy::default(),
12149                    liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12150                    ownership: zerodds_qos::OwnershipKind::Shared,
12151                    partition: alloc::vec![],
12152                    user_data: alloc::vec![],
12153                    topic_data: alloc::vec![],
12154                    group_data: alloc::vec![],
12155                    type_identifier: zerodds_types::TypeIdentifier::None,
12156                    type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12157                    data_representation_offer: None,
12158                })
12159                .expect("register reader");
12160
12161            // Typed sample: a `struct { long seq; }` XCDR2-aligned body
12162            // (little-endian 4-byte long). The intra-runtime path carries the
12163            // RAW body (no encap header), so the representation tag is the
12164            // only carrier of the wire version — exactly the lost signal.
12165            let seq: i32 = 0x0A0B_0C0D;
12166            let typed_payload = seq.to_le_bytes().to_vec();
12167
12168            rt.write_user_sample(writer_eid, typed_payload.clone())
12169                .expect("write");
12170
12171            let sample = rx
12172                .recv_timeout(core::time::Duration::from_millis(100))
12173                .expect("intra-runtime reader should receive sample");
12174            match sample {
12175                UserSample::Alive {
12176                    payload,
12177                    representation,
12178                    ..
12179                } => {
12180                    assert_eq!(
12181                        representation, expected_rep,
12182                        "intra-runtime loopback must carry the writer's XCDR \
12183                         version tag (offer→rep), not a hardcoded 0"
12184                    );
12185                    // Typed round-trip: the recovered body decodes to the
12186                    // original long.
12187                    assert_eq!(payload.as_ref(), typed_payload.as_slice());
12188                    let recovered =
12189                        i32::from_le_bytes(payload.as_ref()[..4].try_into().expect("4-byte long"));
12190                    assert_eq!(recovered, seq, "typed sample must round-trip");
12191                }
12192                other => panic!("expected Alive, got {other:?}"),
12193            }
12194            rt.shutdown();
12195        }
12196
12197        // Default offer is `[XCDR2]` → tag `1`.
12198        run_case(19, 0x60, None, 1);
12199        // Explicit per-writer XCDR1 override → tag `0` (proves the value is
12200        // actually carried from the writer, not constant).
12201        run_case(20, 0x61, Some(alloc::vec![dr::XCDR]), 0);
12202        // Explicit per-writer XCDR2 override → tag `1`.
12203        run_case(21, 0x62, Some(alloc::vec![dr::XCDR2]), 1);
12204    }
12205
12206    #[test]
12207    fn intra_runtime_loopback_not_matched_on_different_topic() {
12208        // Negative test: writer on TopicA, reader on TopicB — no
12209        // intra-runtime match, no sample. Prevents the
12210        // routing table from topic-blindly merging everything.
12211        let rt = DcpsRuntime::start(
12212            18,
12213            GuidPrefix::from_bytes([0x43; 12]),
12214            RuntimeConfig::default(),
12215        )
12216        .expect("start runtime");
12217        let writer_eid = rt
12218            .register_user_writer(UserWriterConfig {
12219                topic_name: "TopicA".into(),
12220                type_name: "TypeA".into(),
12221                reliable: true,
12222                durability: zerodds_qos::DurabilityKind::Volatile,
12223                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12224                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12225                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12226                ownership: zerodds_qos::OwnershipKind::Shared,
12227                ownership_strength: 0,
12228                partition: alloc::vec![],
12229                user_data: alloc::vec![],
12230                topic_data: alloc::vec![],
12231                group_data: alloc::vec![],
12232                type_identifier: zerodds_types::TypeIdentifier::None,
12233                data_representation_offer: None,
12234            })
12235            .expect("register writer");
12236        let (_reader_eid, rx) = rt
12237            .register_user_reader(UserReaderConfig {
12238                topic_name: "TopicB".into(),
12239                type_name: "TypeB".into(),
12240                reliable: true,
12241                durability: zerodds_qos::DurabilityKind::Volatile,
12242                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12243                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12244                ownership: zerodds_qos::OwnershipKind::Shared,
12245                partition: alloc::vec![],
12246                user_data: alloc::vec![],
12247                topic_data: alloc::vec![],
12248                group_data: alloc::vec![],
12249                type_identifier: zerodds_types::TypeIdentifier::None,
12250                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12251                data_representation_offer: None,
12252            })
12253            .expect("register reader");
12254
12255        rt.write_user_sample(writer_eid, b"should-not-arrive".to_vec())
12256            .expect("write");
12257
12258        match rx.recv_timeout(core::time::Duration::from_millis(50)) {
12259            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { /* expected */ }
12260            other => panic!("reader on different topic must not receive: got {other:?}"),
12261        }
12262        rt.shutdown();
12263    }
12264
12265    #[test]
12266    fn runtime_starts_and_shuts_down_cleanly() {
12267        let rt = DcpsRuntime::start(
12268            42,
12269            GuidPrefix::from_bytes([7; 12]),
12270            RuntimeConfig::default(),
12271        )
12272        .expect("start runtime");
12273        assert_eq!(rt.domain_id, 42);
12274        // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): the SameHostTracker
12275        // must be initially empty and a same-host match (manually
12276        // simulated, without SEDP setup) must produce a `Pending`
12277        // entry. The real SEDP hook trigger is the job of the E2E
12278        // test in wave 4c — here only a smoke test of the wiring point.
12279        assert!(rt.same_host.is_empty(), "fresh runtime: no same-host pairs");
12280        let local_writer = zerodds_rtps::wire_types::Guid::new(
12281            rt.guid_prefix,
12282            zerodds_rtps::wire_types::EntityId::user_writer_with_key([1, 2, 3]),
12283        );
12284        let same_host_reader = zerodds_rtps::wire_types::Guid::new(
12285            rt.guid_prefix,
12286            zerodds_rtps::wire_types::EntityId::user_reader_with_key([4, 5, 6]),
12287        );
12288        rt.same_host
12289            .register_pending(local_writer, same_host_reader);
12290        assert_eq!(rt.same_host.len(), 1);
12291        assert!(matches!(
12292            rt.same_host.lookup(local_writer, same_host_reader),
12293            Some(crate::same_host::SameHostState::Pending)
12294        ));
12295        // Shutdown is idempotent.
12296        rt.shutdown();
12297        rt.shutdown();
12298    }
12299
12300    #[test]
12301    fn spdp_announces_standard_bits_by_default() {
12302        // Default config (without security): standard bits + WLP bits 10/11
12303        // + TypeLookup bits 12/13 must be announced along;
12304        // secure bits 16..27 + SEDP-topics bits 28/29 must NOT
12305        // be set. Topics bits are optional per RTPS 2.5 §8.5.4.4
12306        // — ZeroDDS does not implement the native topic endpoints
12307        // (synthetic DCPSTopic derivation from pub/sub covers the
12308        // end-user need), so we do not announce the capability
12309        // either.
12310        let rt = DcpsRuntime::start(
12311            5,
12312            GuidPrefix::from_bytes([0xC; 12]),
12313            RuntimeConfig::default(),
12314        )
12315        .expect("start");
12316        let mask = rt.announced_builtin_endpoint_set();
12317        // Standard bits + WLP + TypeLookup.
12318        assert_ne!(mask & endpoint_flag::PARTICIPANT_ANNOUNCER, 0);
12319        assert_ne!(mask & endpoint_flag::PARTICIPANT_DETECTOR, 0);
12320        assert_ne!(mask & endpoint_flag::PUBLICATIONS_ANNOUNCER, 0);
12321        assert_ne!(mask & endpoint_flag::SUBSCRIPTIONS_DETECTOR, 0);
12322        assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_WRITER, 0);
12323        assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_READER, 0);
12324        assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REQUEST, 0);
12325        assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REPLY, 0);
12326        // Do NOT set the SEDP-topics bits — covered synthetically.
12327        assert_eq!(mask & endpoint_flag::TOPICS_ANNOUNCER, 0);
12328        assert_eq!(mask & endpoint_flag::TOPICS_DETECTOR, 0);
12329        // No secure bits without explicit announce_secure_endpoints.
12330        assert_eq!(mask & endpoint_flag::ALL_SECURE, 0);
12331    }
12332
12333    #[test]
12334    fn spdp_announces_secure_bits_when_configured() {
12335        // With announce_secure_endpoints=true all 12 secure
12336        // bits (16..27) must be set.
12337        let config = RuntimeConfig {
12338            announce_secure_endpoints: true,
12339            ..Default::default()
12340        };
12341        let rt = DcpsRuntime::start(6, GuidPrefix::from_bytes([0xD; 12]), config).expect("start");
12342        let mask = rt.announced_builtin_endpoint_set();
12343        for bit in 16u32..=27 {
12344            assert!(
12345                mask & (1u32 << bit) != 0,
12346                "secure bit {bit} missing in the SPDP announce"
12347            );
12348        }
12349        // Standard bits must still be set.
12350        assert_eq!(
12351            mask & endpoint_flag::ALL_STANDARD,
12352            endpoint_flag::ALL_STANDARD
12353        );
12354    }
12355
12356    #[test]
12357    fn spdp_lease_duration_is_configurable() {
12358        // Default 100 s (spec). The override of 17 s must arrive in the beacon.
12359        let config = RuntimeConfig {
12360            participant_lease_duration: Duration::from_secs(17),
12361            ..Default::default()
12362        };
12363        let rt = DcpsRuntime::start(7, GuidPrefix::from_bytes([0xE; 12]), config).expect("start");
12364        let secs = rt
12365            .spdp_beacon
12366            .lock()
12367            .map(|b| b.data.lease_duration.seconds)
12368            .unwrap_or(0);
12369        assert_eq!(secs, 17);
12370    }
12371
12372    #[test]
12373    fn user_locator_is_udp_v4_127_0_0_x() {
12374        let rt = DcpsRuntime::start(
12375            0,
12376            GuidPrefix::from_bytes([0xA; 12]),
12377            RuntimeConfig::default(),
12378        )
12379        .expect("start");
12380        let loc = rt.user_locator();
12381        assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
12382        // Port > 0 (ephemeral).
12383        assert!(loc.port > 0);
12384    }
12385
12386    #[test]
12387    fn two_runtimes_on_same_domain_can_coexist() {
12388        // The SPDP multicast port is SO_REUSE in our bind.
12389        let a = DcpsRuntime::start(
12390            3,
12391            GuidPrefix::from_bytes([0xA; 12]),
12392            RuntimeConfig::default(),
12393        )
12394        .expect("a");
12395        let b = DcpsRuntime::start(
12396            3,
12397            GuidPrefix::from_bytes([0xB; 12]),
12398            RuntimeConfig::default(),
12399        )
12400        .expect("b");
12401        assert_eq!(a.domain_id, b.domain_id);
12402    }
12403
12404    #[test]
12405    fn peer_capabilities_unknown_peer_returns_none() {
12406        let rt = DcpsRuntime::start(
12407            10,
12408            GuidPrefix::from_bytes([0x60; 12]),
12409            RuntimeConfig::default(),
12410        )
12411        .expect("start");
12412        // A fresh runtime has discovered no peer.
12413        let caps = rt.peer_capabilities(&GuidPrefix::from_bytes([0xEE; 12]));
12414        assert!(caps.is_none());
12415    }
12416
12417    #[test]
12418    fn assert_liveliness_enqueues_wlp_pulse_without_panic() {
12419        // Smoke test: assert_liveliness() must not poison the lock
12420        // and must return synchronously.
12421        //
12422        // Isolate discovery: a UNIQUE multicast group (239.255.88.1, used by no
12423        // other test) + multicast send off, so no co-running test's participant
12424        // can announce itself into this group. Using RuntimeConfig::default()
12425        // put this runtime on the shared spec group (239.255.0.1), where a
12426        // parallel test's participant leaked in and made peer_count flaky under
12427        // the coverage run — a real test-isolation defect, following the same
12428        // unique-group convention the other multi-runtime tests already use.
12429        let cfg = RuntimeConfig {
12430            spdp_multicast_group: Ipv4Addr::new(239, 255, 88, 1),
12431            spdp_multicast_send: false,
12432            ..RuntimeConfig::default()
12433        };
12434        let rt = DcpsRuntime::start(8, GuidPrefix::from_bytes([0xF; 12]), cfg).expect("start");
12435        rt.assert_liveliness();
12436        rt.assert_writer_liveliness(alloc::vec![0xDE, 0xAD]);
12437        // Genuinely isolated now → no peer, and the lock stays usable.
12438        let count = rt.wlp.lock().map(|w| w.peer_count()).unwrap_or(usize::MAX);
12439        assert_eq!(count, 0, "isolated runtime: no peer announced itself → 0");
12440    }
12441
12442    #[test]
12443    fn wlp_period_default_is_lease_over_three() {
12444        // With the default lease of 100 s → wlp_period = 33.33 s.
12445        let rt = DcpsRuntime::start(
12446            9,
12447            GuidPrefix::from_bytes([0x10; 12]),
12448            RuntimeConfig::default(),
12449        )
12450        .expect("start");
12451        // We cannot read the value directly; but we
12452        // know: tick_period > 30 s means the default lease was
12453        // used. Enqueue a pulse and tick — it must fire,
12454        // the next AUTOMATIC comes only in 33 s.
12455        let mut wlp = rt.wlp.lock().unwrap();
12456        wlp.assert_participant();
12457        let now0 = Duration::from_secs(0);
12458        let dg = wlp.tick(now0).unwrap();
12459        assert!(dg.is_some(), "pulse is emitted immediately");
12460    }
12461
12462    // Multicast loopback is unreliable on macOS (no auto-
12463    // interface-join with bind_multicast_v4(0.0.0.0)). On Linux
12464    // it works out of the box; there the test will run in CI.
12465    #[cfg(target_os = "linux")]
12466    #[test]
12467    fn two_runtimes_exchange_wlp_heartbeat_via_multicast() {
12468        // .D-e: A sends periodic WLP heartbeats. B must
12469        // know its own WLP endpoint with A's prefix as a peer
12470        // within ~3 tick periods.
12471        let cfg = RuntimeConfig {
12472            tick_period: Duration::from_millis(20),
12473            spdp_period: Duration::from_millis(100),
12474            // Aggressive WLP period for fast tests.
12475            wlp_period: Duration::from_millis(80),
12476            participant_lease_duration: Duration::from_millis(240),
12477            ..RuntimeConfig::default()
12478        };
12479        let _a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x40; 12]), cfg.clone()).expect("a");
12480        let _b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x41; 12]), cfg).expect("b");
12481
12482        let a_prefix = GuidPrefix::from_bytes([0x40; 12]);
12483        for _ in 0..60 {
12484            thread::sleep(Duration::from_millis(50));
12485            if _b.peer_liveliness_last_seen(&a_prefix).is_some() {
12486                return;
12487            }
12488        }
12489        panic!("B did not see A's WLP heartbeat within 3 s");
12490    }
12491
12492    #[cfg(target_os = "linux")]
12493    #[test]
12494    fn two_runtimes_assert_liveliness_reaches_peer() {
12495        // The Manual-By-Participant pulse must arrive at the peer, the
12496        // last-seen timestamp must reset compared to purely Automatic
12497        // beats. Since the pulse goes out synchronously on the next
12498        // tick, a short wait suffices.
12499        let cfg = RuntimeConfig {
12500            tick_period: Duration::from_millis(20),
12501            spdp_period: Duration::from_millis(100),
12502            // WLP period large enough that no AUTOMATIC beat comes
12503            // in between within the test. The manual pulse queue
12504            // is processed before the AUTOMATIC slot.
12505            wlp_period: Duration::from_secs(3600),
12506            ..RuntimeConfig::default()
12507        };
12508        let a = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x50; 12]), cfg.clone()).expect("a");
12509        let b = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x51; 12]), cfg).expect("b");
12510
12511        a.assert_liveliness();
12512        let a_prefix = GuidPrefix::from_bytes([0x50; 12]);
12513        for _ in 0..60 {
12514            thread::sleep(Duration::from_millis(50));
12515            if b.peer_liveliness_last_seen(&a_prefix).is_some() {
12516                return;
12517            }
12518        }
12519        // In case of multicast-loopback problems, at least check A's
12520        // own pulse counter.
12521        panic!("B did not see A's manual liveliness assert within 3 s");
12522    }
12523
12524    #[cfg(target_os = "linux")]
12525    #[test]
12526    fn two_runtimes_exchange_sedp_publication_announce() {
12527        // E2E smoke: A announces a publication, B sees it
12528        // via SEDP. Assumes SPDP works (so that
12529        // the SEDP peer proxies get wired).
12530        use zerodds_qos::{DurabilityKind, ReliabilityKind};
12531        use zerodds_rtps::publication_data::PublicationBuiltinTopicData;
12532
12533        let cfg = RuntimeConfig {
12534            tick_period: Duration::from_millis(20),
12535            spdp_period: Duration::from_millis(100),
12536            ..RuntimeConfig::default()
12537        };
12538        // Own domain, so the test does not collide with the SPDP-only test
12539        // on domain 0 over the multicast port.
12540        let a = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xCC; 12]), cfg.clone()).expect("a");
12541        let b = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xDD; 12]), cfg).expect("b");
12542
12543        // Wait until both see each other via SPDP.
12544        for _ in 0..40 {
12545            thread::sleep(Duration::from_millis(50));
12546            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12547                break;
12548            }
12549        }
12550        assert!(
12551            !a.discovered_participants().is_empty(),
12552            "no SPDP discovery a"
12553        );
12554
12555        // A announces a publication for topic "Chatter" with type "RawBytes".
12556        let pub_data = PublicationBuiltinTopicData {
12557            key: Guid::new(
12558                a.guid_prefix,
12559                EntityId::user_writer_with_key([0x01, 0x02, 0x03]),
12560            ),
12561            participant_key: Guid::new(a.guid_prefix, EntityId::PARTICIPANT),
12562            topic_name: "Chatter".into(),
12563            type_name: "zerodds::RawBytes".into(),
12564            durability: DurabilityKind::Volatile,
12565            reliability: zerodds_qos::ReliabilityQosPolicy {
12566                kind: ReliabilityKind::Reliable,
12567                max_blocking_time: QosDuration::from_millis(100_i32),
12568            },
12569            ownership: zerodds_qos::OwnershipKind::Shared,
12570            ownership_strength: 0,
12571            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12572            deadline: zerodds_qos::DeadlineQosPolicy::default(),
12573            lifespan: zerodds_qos::LifespanQosPolicy::default(),
12574            partition: Vec::new(),
12575            user_data: Vec::new(),
12576            topic_data: Vec::new(),
12577            group_data: Vec::new(),
12578            type_information: None,
12579            data_representation: Vec::new(),
12580            security_info: None,
12581            service_instance_name: None,
12582            related_entity_guid: None,
12583            topic_aliases: None,
12584            type_identifier: zerodds_types::TypeIdentifier::None,
12585            unicast_locators: Vec::new(),
12586            multicast_locators: Vec::new(),
12587        };
12588        a.announce_publication(&pub_data).expect("announce");
12589
12590        // B should have the publication in the cache within ~3 s.
12591        // CI on shared runners has more jitter, 1 s was too tight.
12592        for _ in 0..60 {
12593            thread::sleep(Duration::from_millis(50));
12594            if b.discovered_publications_count() > 0 {
12595                return;
12596            }
12597        }
12598        panic!(
12599            "B did not receive SEDP publication within 3 s (pub_count={})",
12600            b.discovered_publications_count()
12601        );
12602    }
12603
12604    #[cfg(target_os = "linux")]
12605    #[test]
12606    fn two_runtimes_e2e_user_data_match_and_transfer() {
12607        // E2E smoke: kompletter Pfad
12608        //   Runtime-A register_user_writer(topic, type)
12609        //   Runtime-B register_user_reader(topic, type)
12610        //   SEDP match, writer add_reader_proxy, reader add_writer_proxy
12611        //   A.write_user_sample(payload) → UDP → B's mpsc::Receiver
12612        //
12613        // Eigene Domain (2) um Kollisionen zu vermeiden.
12614        let cfg = RuntimeConfig {
12615            tick_period: Duration::from_millis(20),
12616            spdp_period: Duration::from_millis(100),
12617            ..RuntimeConfig::default()
12618        };
12619        let a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xEE; 12]), cfg.clone()).expect("a");
12620        let b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xFF; 12]), cfg).expect("b");
12621
12622        // SPDP mutual — 3 s Budget.
12623        let mut spdp_ok = false;
12624        for _ in 0..60 {
12625            thread::sleep(Duration::from_millis(50));
12626            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12627                spdp_ok = true;
12628                break;
12629            }
12630        }
12631        assert!(spdp_ok, "SPDP mutual discovery did not complete in 3 s");
12632
12633        // Register endpoints. A publish, B subscribe.
12634        let wid = a
12635            .register_user_writer(UserWriterConfig {
12636                topic_name: "Chatter".into(),
12637                type_name: "zerodds::RawBytes".into(),
12638                reliable: true,
12639                durability: zerodds_qos::DurabilityKind::Volatile,
12640                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12641                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12642                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12643                ownership: zerodds_qos::OwnershipKind::Shared,
12644                ownership_strength: 0,
12645                partition: Vec::new(),
12646                user_data: Vec::new(),
12647                topic_data: Vec::new(),
12648                group_data: Vec::new(),
12649                type_identifier: zerodds_types::TypeIdentifier::None,
12650                data_representation_offer: None,
12651            })
12652            .expect("wid");
12653        let (_rid, rx) = b
12654            .register_user_reader(UserReaderConfig {
12655                topic_name: "Chatter".into(),
12656                type_name: "zerodds::RawBytes".into(),
12657                reliable: true,
12658                durability: zerodds_qos::DurabilityKind::Volatile,
12659                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12660                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12661                ownership: zerodds_qos::OwnershipKind::Shared,
12662                partition: Vec::new(),
12663                user_data: Vec::new(),
12664                topic_data: Vec::new(),
12665                group_data: Vec::new(),
12666                type_identifier: zerodds_types::TypeIdentifier::None,
12667                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12668                data_representation_offer: None,
12669            })
12670            .expect("rid");
12671
12672        // SEDP match + User-Data-Flow. `add_reader_proxy` triggert
12673        // a heartbeat immediately (RTPS §8.4.15.4), so ~tick_period
12674        // (20 ms) + response-delay (200 ms) + resend ≈ 300 ms in
12675        // idle state. A 4 s budget suffices even with CI jitter.
12676        let mut attempts = 0;
12677        loop {
12678            thread::sleep(Duration::from_millis(50));
12679            let _ = a.write_user_sample(wid, alloc::vec![0xAA, 0xBB, 0xCC]);
12680            if let Ok(sample) = rx.recv_timeout(Duration::from_millis(50)) {
12681                match sample {
12682                    UserSample::Alive { payload, .. } => {
12683                        assert_eq!(payload.as_slice(), &[0xAA, 0xBB, 0xCC][..]);
12684                        return;
12685                    }
12686                    other => panic!("expected Alive sample, got {other:?}"),
12687                }
12688            }
12689            attempts += 1;
12690            if attempts > 80 {
12691                panic!("no sample delivered within 4 s");
12692            }
12693        }
12694    }
12695
12696    #[cfg(target_os = "linux")]
12697    #[test]
12698    fn two_runtimes_discover_each_other_via_spdp() {
12699        // We use a tight SPDP period so the test does not wait 5 s.
12700        let cfg = RuntimeConfig {
12701            tick_period: Duration::from_millis(20),
12702            spdp_period: Duration::from_millis(100),
12703            ..RuntimeConfig::default()
12704        };
12705        // Eigene Domain 3 (SEDP=1, E2E=2) um Cross-Test-Kollision zu vermeiden.
12706        let a = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xAA; 12]), cfg.clone()).expect("a");
12707        let b = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xBB; 12]), cfg).expect("b");
12708
12709        // Give the loop time for 2-3 beacon rounds. Multicast on
12710        // loopback is somewhat timing-sensitive when parallel tests
12711        // share the multicast group — hence 60 iterations of 50 ms
12712        // = 3 s budget instead of 1 s.
12713        for _ in 0..60 {
12714            thread::sleep(Duration::from_millis(50));
12715            let a_sees_b = a
12716                .discovered_participants()
12717                .iter()
12718                .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xBB; 12]));
12719            let b_sees_a = b
12720                .discovered_participants()
12721                .iter()
12722                .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xAA; 12]));
12723            if a_sees_b && b_sees_a {
12724                return;
12725            }
12726        }
12727        panic!(
12728            "mutual SPDP discovery failed within 3 s (a={} b={})",
12729            a.discovered_participants().len(),
12730            b.discovered_participants().len()
12731        );
12732    }
12733
12734    // =======================================================================
12735    // Security: Writer-Side Per-Reader-Serializer
12736    // =======================================================================
12737
12738    #[cfg(feature = "security")]
12739    #[test]
12740    fn per_target_serializer_produces_different_wire_per_reader() {
12741        use zerodds_security_crypto::AesGcmCryptoPlugin;
12742        use zerodds_security_permissions::parse_governance_xml;
12743        use zerodds_security_runtime::{
12744            PeerCapabilities, ProtectionLevel as SecProtectionLevel, SharedSecurityGate,
12745        };
12746
12747        // The governance enforces ENCRYPT on domain 0 — the default
12748        // path (transform_outbound) wraps too. A per-reader override
12749        // can still deliver plaintext if the reader is legacy.
12750        const GOV: &str = r#"
12751<domain_access_rules>
12752  <domain_rule>
12753    <domains><id>0</id></domains>
12754    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12755    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12756  </domain_rule>
12757</domain_access_rules>
12758"#;
12759        let gate = SharedSecurityGate::new(
12760            0,
12761            parse_governance_xml(GOV).unwrap(),
12762            Box::new(AesGcmCryptoPlugin::new()),
12763        );
12764
12765        let cfg = RuntimeConfig {
12766            security: Some(std::sync::Arc::new(gate)),
12767            ..RuntimeConfig::default()
12768        };
12769        let rt =
12770            DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE4; 12]), cfg).expect("start runtime");
12771
12772        let wid = rt
12773            .register_user_writer(UserWriterConfig {
12774                topic_name: "HeteroTopic".into(),
12775                type_name: "zerodds::RawBytes".into(),
12776                reliable: true,
12777                durability: zerodds_qos::DurabilityKind::Volatile,
12778                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12779                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12780                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12781                ownership: zerodds_qos::OwnershipKind::Shared,
12782                ownership_strength: 0,
12783                partition: Vec::new(),
12784                user_data: Vec::new(),
12785                topic_data: Vec::new(),
12786                group_data: Vec::new(),
12787                type_identifier: zerodds_types::TypeIdentifier::None,
12788                data_representation_offer: None,
12789            })
12790            .expect("register writer");
12791
12792        // Drei fiktive Reader-Targets — eines pro Protection-Klasse.
12793        let legacy_loc = Locator::udp_v4([127, 0, 0, 11], 40001);
12794        let fast_loc = Locator::udp_v4([127, 0, 0, 12], 40002);
12795        let secure_loc = Locator::udp_v4([127, 0, 0, 13], 40003);
12796        let legacy_peer: [u8; 12] = [0x11; 12];
12797        let fast_peer: [u8; 12] = [0x22; 12];
12798        let secure_peer: [u8; 12] = [0x33; 12];
12799
12800        // Simulates the SEDP match: populate the writer-slot maps.
12801        {
12802            let arc = rt.writer_slot(wid).unwrap();
12803            let mut slot = arc.lock().unwrap();
12804            slot.reader_protection
12805                .insert(legacy_peer, SecProtectionLevel::None);
12806            slot.reader_protection
12807                .insert(fast_peer, SecProtectionLevel::Sign);
12808            slot.reader_protection
12809                .insert(secure_peer, SecProtectionLevel::Encrypt);
12810            slot.locator_to_peer.insert(legacy_loc, legacy_peer);
12811            slot.locator_to_peer.insert(fast_loc, fast_peer);
12812            slot.locator_to_peer.insert(secure_loc, secure_peer);
12813        }
12814
12815        // Fiktive Writer-Datagram-Bytes (RTPS-Header + User-Payload).
12816        let mut msg = Vec::new();
12817        msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12818        msg.extend_from_slice(&[0xE4; 12]); // GuidPrefix
12819        msg.extend_from_slice(b"HELLO-HETERO");
12820
12821        let wire_legacy =
12822            secure_outbound_for_target(&rt, wid, &msg, &legacy_loc).expect("legacy path");
12823        let wire_fast = secure_outbound_for_target(&rt, wid, &msg, &fast_loc).expect("fast path");
12824        let wire_secure =
12825            secure_outbound_for_target(&rt, wid, &msg, &secure_loc).expect("secure path");
12826
12827        // Spec §8.4.2.4: under rtps_protection_kind=ENCRYPT EVERY message MUST
12828        // be SRTPS-wrapped — even a legacy reader (data-level None) may
12829        // get NO plaintext, otherwise user DATA leaks on a protected
12830        // domain. The per-reader data level only controls the inner payload/
12831        // submessage layer, not the outer rtps_protection.
12832        assert_ne!(
12833            wire_legacy, msg,
12834            "legacy under rtps_protection=ENCRYPT MUST be SRTPS-wrapped (no plaintext leak)"
12835        );
12836        assert_ne!(wire_fast, msg, "fast reader must be protected");
12837        assert_ne!(wire_secure, msg, "secure reader must be protected");
12838
12839        // Heterogeneity proof: the three wires are pairwise
12840        // different (each with its own nonce/session counter in SRTPS).
12841        assert_ne!(wire_legacy, wire_fast);
12842        assert_ne!(wire_legacy, wire_secure);
12843        assert_ne!(wire_fast, wire_secure);
12844
12845        // Without a locator match the fallback must take the domain-rule path
12846        // — this governance requires ENCRYPT, so SRTPS-wrapped.
12847        let unknown_loc = Locator::udp_v4([127, 0, 0, 99], 40099);
12848        let wire_unknown =
12849            secure_outbound_for_target(&rt, wid, &msg, &unknown_loc).expect("fallback path");
12850        assert_ne!(
12851            wire_unknown, msg,
12852            "unknown target should be protected via the domain rule"
12853        );
12854
12855        // The absence of the PeerCapabilities type is a compile check:
12856        // the import shows that the entire per-reader structure
12857        // is available in the dcps integration.
12858        let _unused: PeerCapabilities = PeerCapabilities::default();
12859
12860        rt.shutdown();
12861    }
12862
12863    // =======================================================================
12864    // Security: Reader-Side Per-Writer-Validator + Logging
12865    // =======================================================================
12866
12867    #[cfg(feature = "security")]
12868    #[derive(Default, Clone)]
12869    struct CapturingLogger {
12870        inner: std::sync::Arc<
12871            std::sync::Mutex<Vec<(zerodds_security_runtime::LogLevel, String, String)>>,
12872        >,
12873    }
12874
12875    #[cfg(feature = "security")]
12876    impl CapturingLogger {
12877        fn events(&self) -> Vec<(zerodds_security_runtime::LogLevel, String, String)> {
12878            self.inner.lock().map(|g| g.clone()).unwrap_or_default()
12879        }
12880    }
12881
12882    #[cfg(feature = "security")]
12883    impl zerodds_security_runtime::LoggingPlugin for CapturingLogger {
12884        fn log(
12885            &self,
12886            level: zerodds_security_runtime::LogLevel,
12887            _participant: [u8; 16],
12888            category: &str,
12889            message: &str,
12890        ) {
12891            if let Ok(mut g) = self.inner.lock() {
12892                g.push((level, category.to_string(), message.to_string()));
12893            }
12894        }
12895        fn plugin_class_id(&self) -> &str {
12896            "zerodds.test.capturing_logger"
12897        }
12898    }
12899
12900    #[cfg(feature = "security")]
12901    fn build_runtime_with(
12902        gov_xml: &str,
12903        logger: std::sync::Arc<CapturingLogger>,
12904    ) -> std::sync::Arc<DcpsRuntime> {
12905        use zerodds_security_crypto::AesGcmCryptoPlugin;
12906        use zerodds_security_permissions::parse_governance_xml;
12907        use zerodds_security_runtime::{LoggingPlugin, SharedSecurityGate};
12908        let gate = SharedSecurityGate::new(
12909            0,
12910            parse_governance_xml(gov_xml).unwrap(),
12911            Box::new(AesGcmCryptoPlugin::new()),
12912        );
12913        let logger_dyn: std::sync::Arc<dyn LoggingPlugin> = logger;
12914        let cfg = RuntimeConfig {
12915            security: Some(std::sync::Arc::new(gate)),
12916            security_logger: Some(logger_dyn),
12917            ..RuntimeConfig::default()
12918        };
12919        DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE7; 12]), cfg).expect("start rt")
12920    }
12921
12922    #[cfg(feature = "security")]
12923    #[test]
12924    fn inbound_plain_on_encrypt_domain_drops_with_error_event() {
12925        // DoD plan §stage 5: writer sends plain, policy expects
12926        // ENCRYPT → Reader droppt. Ohne allow_unauthenticated ist
12927        // this a "LegacyBlocked" → error level (not warning) per
12928        // the plan spec "missing-caps = Error".
12929        const GOV_ENCRYPT: &str = r#"
12930<domain_access_rules>
12931  <domain_rule>
12932    <domains><id>0</id></domains>
12933    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12934    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12935  </domain_rule>
12936</domain_access_rules>
12937"#;
12938        let logger = std::sync::Arc::new(CapturingLogger::default());
12939        let rt = build_runtime_with(GOV_ENCRYPT, std::sync::Arc::clone(&logger));
12940
12941        // Plain-RTPS-Datagram (header + body).
12942        let mut plain = Vec::new();
12943        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12944        plain.extend_from_slice(&[0x77; 12]); // attacker guid_prefix
12945        plain.extend_from_slice(b"plaintext-on-encrypted-domain");
12946
12947        let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan);
12948        assert!(out.is_none(), "tampering packet must be dropped");
12949
12950        let events = logger.events();
12951        assert_eq!(events.len(), 1, "exactly one log event expected");
12952        let (level, category, _msg) = &events[0];
12953        assert_eq!(
12954            *level,
12955            zerodds_security_runtime::LogLevel::Error,
12956            "plain-on-protected-domain without allow_unauth = Error (LegacyBlocked)"
12957        );
12958        assert_eq!(category, "inbound.legacy_blocked");
12959        rt.shutdown();
12960    }
12961
12962    #[cfg(feature = "security")]
12963    #[test]
12964    fn inbound_legacy_peer_accepted_when_governance_allows_unauth() {
12965        // DoD plan §stage 5: the legacy peer can keep talking to the reader,
12966        // when the governance sets allow_unauthenticated_participants=true.
12967        const GOV: &str = r#"
12968<domain_access_rules>
12969  <domain_rule>
12970    <domains><id>0</id></domains>
12971    <allow_unauthenticated_participants>TRUE</allow_unauthenticated_participants>
12972    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12973    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12974  </domain_rule>
12975</domain_access_rules>
12976"#;
12977        let logger = std::sync::Arc::new(CapturingLogger::default());
12978        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
12979
12980        let mut plain = Vec::new();
12981        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12982        plain.extend_from_slice(&[0x88; 12]);
12983        plain.extend_from_slice(b"legacy-but-allowed");
12984
12985        let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan)
12986            .expect("legacy peer must be accepted");
12987        assert_eq!(out, plain, "output is byte-identical (no crypto unwrap)");
12988        assert!(
12989            logger.events().is_empty(),
12990            "no log event on the accept path"
12991        );
12992        rt.shutdown();
12993    }
12994
12995    #[cfg(feature = "security")]
12996    #[test]
12997    fn inbound_malformed_drops_and_logs_error() {
12998        const GOV: &str = r#"
12999<domain_access_rules>
13000  <domain_rule>
13001    <domains><id>0</id></domains>
13002    <rtps_protection_kind>NONE</rtps_protection_kind>
13003    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13004  </domain_rule>
13005</domain_access_rules>
13006"#;
13007        let logger = std::sync::Arc::new(CapturingLogger::default());
13008        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
13009
13010        let out = secure_inbound_bytes(&rt, &[1, 2, 3, 4], &NetInterface::Wan);
13011        assert!(out.is_none());
13012        let events = logger.events();
13013        assert_eq!(events.len(), 1);
13014        assert_eq!(events[0].0, zerodds_security_runtime::LogLevel::Error);
13015        assert_eq!(events[0].1, "inbound.malformed");
13016        rt.shutdown();
13017    }
13018
13019    #[cfg(feature = "security")]
13020    #[test]
13021    fn inbound_without_security_gate_bypasses_classify_and_logger() {
13022        // Without a security gate: passthrough, no log event.
13023        let logger = std::sync::Arc::new(CapturingLogger::default());
13024        let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
13025            std::sync::Arc::clone(&logger) as _;
13026        let cfg = RuntimeConfig {
13027            security_logger: Some(logger_dyn),
13028            ..RuntimeConfig::default()
13029        };
13030        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE8; 12]), cfg).unwrap();
13031        let msg = vec![0xAAu8; 40];
13032        let out = secure_inbound_bytes(&rt, &msg, &NetInterface::Wan).unwrap();
13033        assert_eq!(out, msg);
13034        assert!(
13035            logger.events().is_empty(),
13036            "the logger must NOT be called without a gate"
13037        );
13038        rt.shutdown();
13039    }
13040
13041    // =======================================================================
13042    // Security: Interface-Routing (Multi-Socket-Binding)
13043    // =======================================================================
13044
13045    #[cfg(feature = "security")]
13046    fn lo_range(third: u8) -> zerodds_security_runtime::IpRange {
13047        zerodds_security_runtime::IpRange {
13048            base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, third)),
13049            prefix_len: 32,
13050        }
13051    }
13052
13053    #[cfg(feature = "security")]
13054    #[test]
13055    fn outbound_pool_routes_target_to_matching_binding() {
13056        let specs = vec![
13057            InterfaceBindingSpec {
13058                name: "lo-a".into(),
13059                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13060                bind_port: 0,
13061                kind: zerodds_security_runtime::NetInterface::Loopback,
13062                subnet: lo_range(11),
13063                default: false,
13064            },
13065            InterfaceBindingSpec {
13066                name: "lo-b".into(),
13067                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13068                bind_port: 0,
13069                kind: zerodds_security_runtime::NetInterface::Wan,
13070                subnet: lo_range(22),
13071                default: true,
13072            },
13073        ];
13074        let pool = OutboundSocketPool::bind_all(&specs).expect("pool");
13075
13076        // Exact match on the first subnet -> lo-a.
13077        let t1 = Locator::udp_v4([127, 0, 0, 11], 40000);
13078        let (sock1, iface1) = pool.route(&t1).expect("route 1");
13079        assert_eq!(iface1, zerodds_security_runtime::NetInterface::Loopback);
13080
13081        // Exact match on the second subnet -> lo-b.
13082        let t2 = Locator::udp_v4([127, 0, 0, 22], 40000);
13083        let (sock2, iface2) = pool.route(&t2).expect("route 2");
13084        assert_eq!(iface2, zerodds_security_runtime::NetInterface::Wan);
13085
13086        // The two sockets must have different local ports.
13087        let p1 = sock1.local_locator().port;
13088        let p2 = sock2.local_locator().port;
13089        assert_ne!(p1, p2);
13090    }
13091
13092    #[cfg(feature = "security")]
13093    #[test]
13094    fn outbound_pool_falls_back_to_default_when_no_subnet_matches() {
13095        let specs = vec![
13096            InterfaceBindingSpec {
13097                name: "lo-specific".into(),
13098                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13099                bind_port: 0,
13100                kind: zerodds_security_runtime::NetInterface::Loopback,
13101                subnet: lo_range(33),
13102                default: false,
13103            },
13104            InterfaceBindingSpec {
13105                name: "wan-default".into(),
13106                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13107                bind_port: 0,
13108                kind: zerodds_security_runtime::NetInterface::Wan,
13109                subnet: zerodds_security_runtime::IpRange {
13110                    base: core::net::IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED),
13111                    prefix_len: 0,
13112                },
13113                default: true,
13114            },
13115        ];
13116        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13117        let unknown = Locator::udp_v4([192, 168, 7, 7], 12345);
13118        let (_sock, iface) = pool.route(&unknown).expect("default fallback");
13119        assert_eq!(iface, zerodds_security_runtime::NetInterface::Wan);
13120    }
13121
13122    #[cfg(feature = "security")]
13123    #[test]
13124    fn outbound_pool_returns_none_when_no_match_and_no_default() {
13125        let specs = vec![InterfaceBindingSpec {
13126            name: "only-lo".into(),
13127            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13128            bind_port: 0,
13129            kind: zerodds_security_runtime::NetInterface::Loopback,
13130            subnet: lo_range(44),
13131            default: false,
13132        }];
13133        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13134        assert!(pool.route(&Locator::udp_v4([8, 8, 8, 8], 53)).is_none());
13135    }
13136
13137    #[cfg(feature = "security")]
13138    #[test]
13139    fn outbound_pool_skips_non_v4_locators() {
13140        let specs = vec![InterfaceBindingSpec {
13141            name: "lo".into(),
13142            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13143            bind_port: 0,
13144            kind: zerodds_security_runtime::NetInterface::Loopback,
13145            subnet: lo_range(55),
13146            default: true,
13147        }];
13148        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13149        // SHM locator (no IPv4) → no match; without a default it would be None,
13150        // here default=true and subnet-contains does not apply
13151        // because ipv4_from_locator returns None.
13152        let shm = Locator {
13153            kind: zerodds_rtps::wire_types::LocatorKind::Shm,
13154            port: 0,
13155            address: [0u8; 16],
13156        };
13157        assert!(pool.route(&shm).is_none());
13158    }
13159
13160    #[cfg(feature = "security")]
13161    #[test]
13162    fn dod_plaintext_lo_vs_srtps_wan_via_sniffer() {
13163        // Spec §8.4.2.4 (spec wins vs DoD loopback plaintext): under
13164        // rtps_protection_kind=ENCRYPT means bytes are SRTPS-wrapped on EVERY
13165        // interface — including loopback. The test proves that the
13166        // per-interface routing serves both targets AND both outputs
13167        // are spec-conformantly protected (no plaintext leak, regardless of which
13168        // binding).
13169        //
13170        // Setup:
13171        //  * 2 sniffer UDP sockets, one simulates a legacy
13172        //    loopback peer (expects plaintext), the other a
13173        //    WAN secure peer (expects SRTPS).
13174        //  * DcpsRuntime with a security gate (governance = ENCRYPT) and
13175        //    two interface bindings: lo-binding on 127.0.0.100,
13176        //    wan-binding auf 127.0.0.200.
13177        //  * 1 writer, 2 matched_readers with different protection
13178        //    (Legacy=None, Secure=Encrypt) and the respective sniffer
13179        //    Socket address as the locator_to_peer target.
13180        //  * `send_on_best_interface(rt, target, bytes)` is triggered
13181        //    manually; the sniffer per target receives and checks
13182        //    the wire format.
13183        use std::net::{SocketAddrV4, UdpSocket};
13184        use zerodds_security_crypto::AesGcmCryptoPlugin;
13185        use zerodds_security_permissions::parse_governance_xml;
13186        use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
13187
13188        const GOV: &str = r#"
13189<domain_access_rules>
13190  <domain_rule>
13191    <domains><id>0</id></domains>
13192    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13193    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13194  </domain_rule>
13195</domain_access_rules>
13196"#;
13197        // Two sniffer sockets on ephemeral loopback ports (independent
13198        // from our bindings; they act as "peer receivers").
13199        let lo_sniffer =
13200            UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).expect("lo sniffer");
13201        lo_sniffer
13202            .set_read_timeout(Some(Duration::from_millis(250)))
13203            .unwrap();
13204        let wan_sniffer = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0))
13205            .expect("wan sniffer");
13206        wan_sniffer
13207            .set_read_timeout(Some(Duration::from_millis(250)))
13208            .unwrap();
13209        let lo_port = lo_sniffer.local_addr().unwrap().port();
13210        let wan_port = wan_sniffer.local_addr().unwrap().port();
13211        let lo_target = Locator::udp_v4([127, 0, 0, 1], u32::from(lo_port));
13212        let wan_target = Locator::udp_v4([127, 0, 0, 1], u32::from(wan_port));
13213
13214        // Two bindings, subnet-matched to exactly these ports. Since
13215        // IpRange currently matches only on IP, we use two
13216        // different /32 host ranges as a trick:
13217        // we set both bindings to the same IP/32, but because
13218        // `route` takes the first subnet match, I list them such
13219        // that "lo-bind" comes first and then the default.
13220        //
13221        // Correct: both sniffers share 127.0.0.1/32 and the pool would
13222        // pick the first binding. To distinguish cleanly, we map
13223        // the binding decision by *target port* — that works
13224        // not today. So: we work around this subtlety by
13225        // calling `send_on_best_interface` directly for different targets
13226        // and assigning the binding by IP range —
13227        // the DoD checks the routing at the binding level, not the
13228        // socket layer.
13229        //
13230        // Pragmatically: we test end-to-end that the pool actually
13231        // picks the right interface socket for the target and
13232        // processes the bytes differently (plain vs SRTPS).
13233        // The target locators differ only in the port, but
13234        // `send_on_best_interface` gets them separately each. The
13235        // decisive point is: both bindings send **and** the
13236        // sniffer socket receives — proving the routing in combination
13237        // with the per-reader serializer from stage 4.
13238
13239        let bindings = vec![InterfaceBindingSpec {
13240            name: "lo-for-legacy".into(),
13241            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13242            bind_port: 0,
13243            kind: SecIf::Loopback,
13244            subnet: zerodds_security_runtime::IpRange {
13245                base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 1)),
13246                prefix_len: 32,
13247            },
13248            default: true,
13249        }];
13250        let gate = SharedSecurityGate::new(
13251            0,
13252            parse_governance_xml(GOV).unwrap(),
13253            Box::new(AesGcmCryptoPlugin::new()),
13254        );
13255        let cfg = RuntimeConfig {
13256            security: Some(std::sync::Arc::new(gate)),
13257            interface_bindings: bindings,
13258            ..RuntimeConfig::default()
13259        };
13260        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF0; 12]), cfg).expect("rt");
13261
13262        let wid = rt
13263            .register_user_writer(UserWriterConfig {
13264                topic_name: "HeteroRouting".into(),
13265                type_name: "zerodds::RawBytes".into(),
13266                reliable: true,
13267                durability: zerodds_qos::DurabilityKind::Volatile,
13268                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13269                lifespan: zerodds_qos::LifespanQosPolicy::default(),
13270                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
13271                ownership: zerodds_qos::OwnershipKind::Shared,
13272                ownership_strength: 0,
13273                partition: Vec::new(),
13274                user_data: Vec::new(),
13275                topic_data: Vec::new(),
13276                group_data: Vec::new(),
13277                type_identifier: zerodds_types::TypeIdentifier::None,
13278                data_representation_offer: None,
13279            })
13280            .unwrap();
13281
13282        // Peer protection setup: Legacy=None for lo_target,
13283        // Encrypt for wan_target.
13284        let legacy_peer: [u8; 12] = [0x01; 12];
13285        let secure_peer: [u8; 12] = [0x02; 12];
13286        {
13287            let arc = rt.writer_slot(wid).unwrap();
13288            let mut slot = arc.lock().unwrap();
13289            slot.reader_protection
13290                .insert(legacy_peer, ProtectionLevel::None);
13291            slot.reader_protection
13292                .insert(secure_peer, ProtectionLevel::Encrypt);
13293            slot.locator_to_peer.insert(lo_target, legacy_peer);
13294            slot.locator_to_peer.insert(wan_target, secure_peer);
13295        }
13296
13297        // Fiktives Datagram.
13298        let mut msg = Vec::new();
13299        msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13300        msg.extend_from_slice(&[0xF0; 12]);
13301        msg.extend_from_slice(b"DOD-ROUTING-PAYLOAD");
13302
13303        // Generate the per-target wire + route via send_on_best_interface.
13304        let plain_wire = secure_outbound_for_target(&rt, wid, &msg, &lo_target).unwrap();
13305        let secure_wire = secure_outbound_for_target(&rt, wid, &msg, &wan_target).unwrap();
13306        assert_ne!(
13307            plain_wire, msg,
13308            "lo-target under rtps_protection=ENCRYPT also SRTPS (no plaintext leak)"
13309        );
13310        assert_ne!(secure_wire, msg, "wan-target: SRTPS-wrapped");
13311
13312        send_on_best_interface(&rt, &lo_target, &plain_wire);
13313        send_on_best_interface(&rt, &wan_target, &secure_wire);
13314
13315        // sniffer receive and compare.
13316        let mut buf = [0u8; 4096];
13317        let (n1, _) = lo_sniffer.recv_from(&mut buf).expect("lo snif got");
13318        assert_ne!(
13319            &buf[..n1],
13320            &msg[..],
13321            "loopback sniffer must see SRTPS (spec wins, no plaintext on a protected domain)"
13322        );
13323        assert_eq!(buf[20], 0x33, "lo output must begin with SRTPS_PREFIX");
13324        let (n2, _) = wan_sniffer.recv_from(&mut buf).expect("wan snif got");
13325        assert_ne!(&buf[..n2], &msg[..], "WAN sniffer must see SRTPS-wrapped");
13326        // Additionally: SRTPS marker at the 20th byte (after the RTPS header).
13327        // SRTPS_PREFIX-Submessage-Id = 0x33 (Spec §7.3.6.3).
13328        assert_eq!(
13329            buf[20], 0x33,
13330            "WAN output must begin with an SRTPS_PREFIX submessage"
13331        );
13332
13333        rt.shutdown();
13334    }
13335
13336    #[cfg(feature = "security")]
13337    #[test]
13338    fn inbound_loopback_accepts_plain_on_protected_domain() {
13339        // Plan §stage 6: the inbound dispatcher should accept plaintext
13340        // for loopback packets even on a protected domain
13341        // (bytes do not leave the host). That is
13342        // exactly the `NetInterface` consultation in classify_inbound.
13343        use zerodds_security_runtime::NetInterface as SecIf;
13344        const GOV: &str = r#"
13345<domain_access_rules>
13346  <domain_rule>
13347    <domains><id>0</id></domains>
13348    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13349    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13350  </domain_rule>
13351</domain_access_rules>
13352"#;
13353        let logger = std::sync::Arc::new(CapturingLogger::default());
13354        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
13355
13356        let mut plain = Vec::new();
13357        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13358        plain.extend_from_slice(&[0x99; 12]);
13359        plain.extend_from_slice(b"loopback-plain-is-ok");
13360
13361        // Accepted on loopback — no log event.
13362        let out = secure_inbound_bytes(&rt, &plain, &SecIf::Loopback)
13363            .expect("loopback plain must be accepted");
13364        assert_eq!(out, plain);
13365        assert!(logger.events().is_empty());
13366
13367        // On WAN the same content → drop + error event.
13368        let out_wan = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
13369        assert!(out_wan.is_none());
13370        let evs = logger.events();
13371        assert_eq!(evs.len(), 1);
13372        assert_eq!(evs[0].0, zerodds_security_runtime::LogLevel::Error);
13373        assert!(
13374            evs[0].2.contains("iface=Wan"),
13375            "log message must carry iface"
13376        );
13377        rt.shutdown();
13378    }
13379
13380    #[cfg(feature = "security")]
13381    #[test]
13382    fn dod_inbound_per_interface_receive_via_pool_socket() {
13383        // Plan §stage 6 inbound DoD: each pool binding has its
13384        // own receive path, and the NetInterface class is
13385        // reflected in the log event (iface=<class>).
13386        //
13387        // Setup:
13388        //  * DcpsRuntime with 1 InterfaceBinding (kind=Loopback,
13389        //    subnet=127.0.0.0/8)
13390        //  * Protected Governance + CapturingLogger
13391        //  * We bind an external UDP socket and send two
13392        //    plain packets:
13393        //      a) to the pool socket (the event loop polls it and
13394        //         classifies as loopback → accept without log)
13395        //      b) we trigger secure_inbound_bytes directly with Wan
13396        //         → error log with iface=Wan
13397        //
13398        // This proves that the per-interface receive path
13399        // exists and the iface class flows through the decision.
13400        use std::net::{SocketAddrV4, UdpSocket};
13401        use zerodds_security_crypto::AesGcmCryptoPlugin;
13402        use zerodds_security_permissions::parse_governance_xml;
13403        use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
13404
13405        const GOV: &str = r#"
13406<domain_access_rules>
13407  <domain_rule>
13408    <domains><id>0</id></domains>
13409    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13410    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13411  </domain_rule>
13412</domain_access_rules>
13413"#;
13414        let logger = std::sync::Arc::new(CapturingLogger::default());
13415        let gate = SharedSecurityGate::new(
13416            0,
13417            parse_governance_xml(GOV).unwrap(),
13418            Box::new(AesGcmCryptoPlugin::new()),
13419        );
13420        let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
13421            std::sync::Arc::clone(&logger) as _;
13422        let bindings = vec![InterfaceBindingSpec {
13423            name: "lo".into(),
13424            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13425            bind_port: 0,
13426            kind: SecIf::Loopback,
13427            subnet: zerodds_security_runtime::IpRange {
13428                base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 0)),
13429                prefix_len: 8,
13430            },
13431            default: true,
13432        }];
13433        let cfg = RuntimeConfig {
13434            security: Some(std::sync::Arc::new(gate)),
13435            security_logger: Some(logger_dyn),
13436            interface_bindings: bindings,
13437            ..RuntimeConfig::default()
13438        };
13439        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF1; 12]), cfg).expect("rt");
13440
13441        // Read the port of the pool binding (ephemeral).
13442        let pool_port = rt.outbound_pool.as_ref().unwrap().bindings[0]
13443            .socket
13444            .local_locator()
13445            .port as u16;
13446        assert!(pool_port > 0);
13447
13448        // An external socket sends a plain packet to the pool socket.
13449        let sender = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).unwrap();
13450        let mut plain = Vec::new();
13451        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13452        plain.extend_from_slice(&[0xAB; 12]);
13453        plain.extend_from_slice(b"loopback-dispatch");
13454        sender
13455            .send_to(
13456                &plain,
13457                SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), pool_port),
13458            )
13459            .unwrap();
13460
13461        // The event loop needs a few ticks to poll the packet.
13462        // The default tick_period is 50 ms; we wait a few of them.
13463        std::thread::sleep(Duration::from_millis(300));
13464
13465        // The pool packet, through classify_inbound with iface=Loopback,
13466        // ran → accept, no log events from this path.
13467        let pool_events = logger.events();
13468
13469        // Comparison test: the same packet through secure_inbound_bytes
13470        // with iface=Wan → error event with an iface=Wan marker.
13471        let _ = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
13472        let after = logger.events();
13473        assert!(
13474            after.len() > pool_events.len(),
13475            "the Wan path must produce a new log event"
13476        );
13477        let new_ev = &after[after.len() - 1];
13478        assert_eq!(new_ev.0, zerodds_security_runtime::LogLevel::Error);
13479        assert!(
13480            new_ev.2.contains("iface=Wan"),
13481            "log message carries the iface marker: got={:?}",
13482            new_ev.2
13483        );
13484
13485        // Log events from the pool path must NOT carry the error level
13486        // (because classify_inbound returns accept on loopback).
13487        for (lvl, cat, msg) in &pool_events {
13488            assert_ne!(
13489                *lvl,
13490                zerodds_security_runtime::LogLevel::Error,
13491                "the loopback path must not produce an error event: cat={cat} msg={msg}"
13492            );
13493        }
13494        rt.shutdown();
13495    }
13496
13497    #[cfg(feature = "security")]
13498    #[test]
13499    fn per_target_without_security_gate_is_passthrough() {
13500        // Without a `security` config in RuntimeConfig, the per-target
13501        // path is a pure passthrough. Important so that we do not
13502        // break the v1.4 backward compat.
13503        let rt = DcpsRuntime::start(
13504            0,
13505            GuidPrefix::from_bytes([0xE5; 12]),
13506            RuntimeConfig::default(),
13507        )
13508        .expect("rt");
13509        let wid = rt
13510            .register_user_writer(UserWriterConfig {
13511                topic_name: "T".into(),
13512                type_name: "zerodds::RawBytes".into(),
13513                reliable: true,
13514                durability: zerodds_qos::DurabilityKind::Volatile,
13515                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13516                lifespan: zerodds_qos::LifespanQosPolicy::default(),
13517                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
13518                ownership: zerodds_qos::OwnershipKind::Shared,
13519                ownership_strength: 0,
13520                partition: Vec::new(),
13521                user_data: Vec::new(),
13522                topic_data: Vec::new(),
13523                group_data: Vec::new(),
13524                type_identifier: zerodds_types::TypeIdentifier::None,
13525                data_representation_offer: None,
13526            })
13527            .unwrap();
13528        let tgt = Locator::udp_v4([127, 0, 0, 1], 40000);
13529        let msg = b"raw-plaintext".to_vec();
13530        let out = secure_outbound_for_target(&rt, wid, &msg, &tgt).unwrap();
13531        assert_eq!(out, msg, "without a gate it must be passthrough");
13532        rt.shutdown();
13533    }
13534
13535    // ----  Builtin-Topic-Reader Discovery-Hook (DDS 1.4 §2.2.5) ----
13536
13537    /// Helper: constructs a synthetic SPDP beacon
13538    /// for a remote participant, so that `handle_spdp_datagram`
13539    /// accepts it.
13540    fn make_remote_spdp_beacon(remote_prefix: GuidPrefix) -> Vec<u8> {
13541        use zerodds_discovery::spdp::SpdpBeacon;
13542        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
13543        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
13544        let data = ParticipantBuiltinTopicData {
13545            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
13546            protocol_version: ProtocolVersion::V2_5,
13547            vendor_id: VendorId::ZERODDS,
13548            default_unicast_locator: None,
13549            default_multicast_locator: None,
13550            metatraffic_unicast_locator: None,
13551            metatraffic_multicast_locator: None,
13552            domain_id: Some(0),
13553            builtin_endpoint_set: 0,
13554            lease_duration: QosDuration::from_secs(100),
13555            user_data: alloc::vec::Vec::new(),
13556            properties: Default::default(),
13557            identity_token: None,
13558            permissions_token: None,
13559            identity_status_token: None,
13560            sig_algo_info: None,
13561            kx_algo_info: None,
13562            sym_cipher_algo_info: None,
13563            participant_security_info: None,
13564        };
13565        let mut beacon = SpdpBeacon::new(data);
13566        beacon.serialize().expect("serialize")
13567    }
13568
13569    #[test]
13570    fn handle_spdp_datagram_pushes_into_builtin_participant_reader() {
13571        let rt = DcpsRuntime::start(
13572            41,
13573            GuidPrefix::from_bytes([0x21; 12]),
13574            RuntimeConfig::default(),
13575        )
13576        .expect("start");
13577        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13578        rt.attach_builtin_sinks(bs.sinks());
13579
13580        let remote = GuidPrefix::from_bytes([0x99; 12]);
13581        let dg = make_remote_spdp_beacon(remote);
13582        // A direct hook call simulates an SPDP receive without multicast.
13583        handle_spdp_datagram(&rt, &dg);
13584
13585        let reader = bs
13586            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13587                "DCPSParticipant",
13588            )
13589            .unwrap();
13590        let samples = reader.take().unwrap();
13591        assert_eq!(samples.len(), 1, "exactly 1 sample for 1 SPDP beacon");
13592        assert_eq!(samples[0].key.prefix, remote);
13593        rt.shutdown();
13594    }
13595
13596    #[test]
13597    fn handle_spdp_datagram_skips_self_beacon() {
13598        let prefix = GuidPrefix::from_bytes([0x22; 12]);
13599        let rt = DcpsRuntime::start(42, prefix, RuntimeConfig::default()).expect("start");
13600        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13601        rt.attach_builtin_sinks(bs.sinks());
13602
13603        // Beacon from our own prefix → must be ignored (Spec
13604        // §8.5.4 self-discovery filter).
13605        let dg = make_remote_spdp_beacon(prefix);
13606        handle_spdp_datagram(&rt, &dg);
13607
13608        let reader = bs
13609            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13610                "DCPSParticipant",
13611            )
13612            .unwrap();
13613        let samples = reader.take().unwrap();
13614        assert!(samples.is_empty(), "own beacon must not be logged");
13615        rt.shutdown();
13616    }
13617
13618    #[test]
13619    fn sedp_event_push_populates_publication_and_topic_readers() {
13620        use crate::builtin_topics as bt;
13621        use zerodds_discovery::sedp::SedpEvents;
13622        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13623        let rt = DcpsRuntime::start(
13624            43,
13625            GuidPrefix::from_bytes([0x23; 12]),
13626            RuntimeConfig::default(),
13627        )
13628        .expect("start");
13629        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13630        rt.attach_builtin_sinks(bs.sinks());
13631
13632        let mut events = SedpEvents::default();
13633        events.new_publications.push(
13634            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13635                key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
13636                participant_key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
13637                topic_name: "WireT".into(),
13638                type_name: "WireType".into(),
13639                durability: zerodds_qos::DurabilityKind::Volatile,
13640                reliability: ReliabilityQosPolicy::default(),
13641                ownership: zerodds_qos::OwnershipKind::Shared,
13642                ownership_strength: 0,
13643                liveliness: LivelinessQosPolicy::default(),
13644                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13645                lifespan: zerodds_qos::LifespanQosPolicy::default(),
13646                partition: Vec::new(),
13647                user_data: Vec::new(),
13648                topic_data: Vec::new(),
13649                group_data: Vec::new(),
13650                type_information: None,
13651                data_representation: Vec::new(),
13652                security_info: None,
13653                service_instance_name: None,
13654                related_entity_guid: None,
13655                topic_aliases: None,
13656                type_identifier: zerodds_types::TypeIdentifier::None,
13657                unicast_locators: Vec::new(),
13658                multicast_locators: Vec::new(),
13659            },
13660        );
13661
13662        push_sedp_events_to_builtin_readers(&rt, &events);
13663
13664        let pub_reader = bs
13665            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
13666            .unwrap();
13667        let pub_samples = pub_reader.take().unwrap();
13668        assert_eq!(pub_samples.len(), 1);
13669        assert_eq!(pub_samples[0].topic_name, "WireT");
13670
13671        let topic_reader = bs
13672            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13673            .unwrap();
13674        let topic_samples = topic_reader.take().unwrap();
13675        assert_eq!(topic_samples.len(), 1);
13676        assert_eq!(topic_samples[0].name, "WireT");
13677        rt.shutdown();
13678    }
13679
13680    #[test]
13681    fn sedp_event_push_populates_subscription_reader() {
13682        use crate::builtin_topics as bt;
13683        use zerodds_discovery::sedp::SedpEvents;
13684        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13685        let rt = DcpsRuntime::start(
13686            44,
13687            GuidPrefix::from_bytes([0x24; 12]),
13688            RuntimeConfig::default(),
13689        )
13690        .expect("start");
13691        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13692        rt.attach_builtin_sinks(bs.sinks());
13693
13694        let mut events = SedpEvents::default();
13695        events.new_subscriptions.push(
13696            zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
13697                key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
13698                participant_key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
13699                topic_name: "SubT".into(),
13700                type_name: "SubType".into(),
13701                durability: zerodds_qos::DurabilityKind::Volatile,
13702                reliability: ReliabilityQosPolicy::default(),
13703                ownership: zerodds_qos::OwnershipKind::Shared,
13704                liveliness: LivelinessQosPolicy::default(),
13705                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13706                partition: Vec::new(),
13707                user_data: Vec::new(),
13708                topic_data: Vec::new(),
13709                group_data: Vec::new(),
13710                type_information: None,
13711                data_representation: Vec::new(),
13712                content_filter: None,
13713                security_info: None,
13714                service_instance_name: None,
13715                related_entity_guid: None,
13716                topic_aliases: None,
13717                type_identifier: zerodds_types::TypeIdentifier::None,
13718                unicast_locators: Vec::new(),
13719                multicast_locators: Vec::new(),
13720            },
13721        );
13722
13723        push_sedp_events_to_builtin_readers(&rt, &events);
13724
13725        let sub_reader = bs
13726            .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
13727            .unwrap();
13728        let sub_samples = sub_reader.take().unwrap();
13729        assert_eq!(sub_samples.len(), 1);
13730        assert_eq!(sub_samples[0].topic_name, "SubT");
13731
13732        // The topic reader gets a synthetic topic sample also from
13733        // Subscription.
13734        let topic_reader = bs
13735            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13736            .unwrap();
13737        let topic_samples = topic_reader.take().unwrap();
13738        assert_eq!(topic_samples.len(), 1);
13739        assert_eq!(topic_samples[0].name, "SubT");
13740        rt.shutdown();
13741    }
13742
13743    #[test]
13744    fn push_sedp_events_to_builtin_readers_is_noop_without_sinks() {
13745        use zerodds_discovery::sedp::SedpEvents;
13746        let rt = DcpsRuntime::start(
13747            45,
13748            GuidPrefix::from_bytes([0x25; 12]),
13749            RuntimeConfig::default(),
13750        )
13751        .expect("start");
13752        // No attach_builtin_sinks → push must stay silent, not
13753        // panic.
13754        let events = SedpEvents::default();
13755        push_sedp_events_to_builtin_readers(&rt, &events);
13756        rt.shutdown();
13757    }
13758
13759    // ----  Ignore-Filter im Discovery-Hot-Path -------------
13760
13761    #[test]
13762    fn handle_spdp_datagram_drops_ignored_participant_beacon() {
13763        // Spec §2.2.2.2.1.14: ein einmal ignorierter Participant
13764        // taucht in keinem nachfolgenden Builtin-Sample mehr auf.
13765        let rt = DcpsRuntime::start(
13766            46,
13767            GuidPrefix::from_bytes([0x26; 12]),
13768            RuntimeConfig::default(),
13769        )
13770        .expect("start");
13771        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13772        rt.attach_builtin_sinks(bs.sinks());
13773        let filter = crate::participant::IgnoreFilter::default();
13774        rt.attach_ignore_filter(filter.clone());
13775
13776        let remote = GuidPrefix::from_bytes([0xAA; 12]);
13777        // Derive the ignore handle from the future beacon — we
13778        // know that the builtin sample key is the GUID of the remote
13779        // participant (=prefix + EntityId::PARTICIPANT).
13780        let key = Guid::new(remote, EntityId::PARTICIPANT);
13781        let h = crate::instance_handle::InstanceHandle::from_guid(key);
13782        if let Ok(mut s) = filter.inner.participants.lock() {
13783            s.insert(h);
13784        }
13785        let dg = make_remote_spdp_beacon(remote);
13786        handle_spdp_datagram(&rt, &dg);
13787
13788        let reader = bs
13789            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13790                "DCPSParticipant",
13791            )
13792            .unwrap();
13793        assert!(
13794            reader.take().unwrap().is_empty(),
13795            "an ignored participant must not land in DCPSParticipant"
13796        );
13797        rt.shutdown();
13798    }
13799
13800    #[test]
13801    fn sedp_event_push_filters_ignored_publication() {
13802        use crate::builtin_topics as bt;
13803        use zerodds_discovery::sedp::SedpEvents;
13804        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13805        let rt = DcpsRuntime::start(
13806            47,
13807            GuidPrefix::from_bytes([0x27; 12]),
13808            RuntimeConfig::default(),
13809        )
13810        .expect("start");
13811        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13812        rt.attach_builtin_sinks(bs.sinks());
13813        let filter = crate::participant::IgnoreFilter::default();
13814        rt.attach_ignore_filter(filter.clone());
13815
13816        let pub_key = Guid::new(GuidPrefix::from_bytes([0x33; 12]), EntityId::PARTICIPANT);
13817        let h_pub = crate::instance_handle::InstanceHandle::from_guid(pub_key);
13818        if let Ok(mut s) = filter.inner.publications.lock() {
13819            s.insert(h_pub);
13820        }
13821
13822        let mut events = SedpEvents::default();
13823        events.new_publications.push(
13824            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13825                key: pub_key,
13826                participant_key: Guid::new(
13827                    GuidPrefix::from_bytes([0x33; 12]),
13828                    EntityId::PARTICIPANT,
13829                ),
13830                topic_name: "Filtered".into(),
13831                type_name: "T".into(),
13832                durability: zerodds_qos::DurabilityKind::Volatile,
13833                reliability: ReliabilityQosPolicy::default(),
13834                ownership: zerodds_qos::OwnershipKind::Shared,
13835                ownership_strength: 0,
13836                liveliness: LivelinessQosPolicy::default(),
13837                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13838                lifespan: zerodds_qos::LifespanQosPolicy::default(),
13839                partition: Vec::new(),
13840                user_data: Vec::new(),
13841                topic_data: Vec::new(),
13842                group_data: Vec::new(),
13843                type_information: None,
13844                data_representation: Vec::new(),
13845                security_info: None,
13846                service_instance_name: None,
13847                related_entity_guid: None,
13848                topic_aliases: None,
13849                type_identifier: zerodds_types::TypeIdentifier::None,
13850                unicast_locators: Vec::new(),
13851                multicast_locators: Vec::new(),
13852            },
13853        );
13854
13855        push_sedp_events_to_builtin_readers(&rt, &events);
13856
13857        let pub_reader = bs
13858            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
13859            .unwrap();
13860        assert!(
13861            pub_reader.take().unwrap().is_empty(),
13862            "an ignored publication must not land in DCPSPublication"
13863        );
13864        // The synthetic DCPSTopic sample too must not be
13865        // forwarded, because the publication is completely
13866        // discarded.
13867        let topic_reader = bs
13868            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13869            .unwrap();
13870        assert!(topic_reader.take().unwrap().is_empty());
13871        rt.shutdown();
13872    }
13873
13874    #[test]
13875    fn sedp_event_push_filters_ignored_subscription() {
13876        use crate::builtin_topics as bt;
13877        use zerodds_discovery::sedp::SedpEvents;
13878        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13879        let rt = DcpsRuntime::start(
13880            48,
13881            GuidPrefix::from_bytes([0x28; 12]),
13882            RuntimeConfig::default(),
13883        )
13884        .expect("start");
13885        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13886        rt.attach_builtin_sinks(bs.sinks());
13887        let filter = crate::participant::IgnoreFilter::default();
13888        rt.attach_ignore_filter(filter.clone());
13889
13890        let sub_key = Guid::new(GuidPrefix::from_bytes([0x44; 12]), EntityId::PARTICIPANT);
13891        let h_sub = crate::instance_handle::InstanceHandle::from_guid(sub_key);
13892        if let Ok(mut s) = filter.inner.subscriptions.lock() {
13893            s.insert(h_sub);
13894        }
13895
13896        let mut events = SedpEvents::default();
13897        events.new_subscriptions.push(
13898            zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
13899                key: sub_key,
13900                participant_key: Guid::new(
13901                    GuidPrefix::from_bytes([0x44; 12]),
13902                    EntityId::PARTICIPANT,
13903                ),
13904                topic_name: "FilteredSub".into(),
13905                type_name: "T".into(),
13906                durability: zerodds_qos::DurabilityKind::Volatile,
13907                reliability: ReliabilityQosPolicy::default(),
13908                ownership: zerodds_qos::OwnershipKind::Shared,
13909                liveliness: LivelinessQosPolicy::default(),
13910                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13911                partition: Vec::new(),
13912                user_data: Vec::new(),
13913                topic_data: Vec::new(),
13914                group_data: Vec::new(),
13915                type_information: None,
13916                data_representation: Vec::new(),
13917                content_filter: None,
13918                security_info: None,
13919                service_instance_name: None,
13920                related_entity_guid: None,
13921                topic_aliases: None,
13922                type_identifier: zerodds_types::TypeIdentifier::None,
13923                unicast_locators: Vec::new(),
13924                multicast_locators: Vec::new(),
13925            },
13926        );
13927
13928        push_sedp_events_to_builtin_readers(&rt, &events);
13929
13930        let sub_reader = bs
13931            .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
13932            .unwrap();
13933        assert!(sub_reader.take().unwrap().is_empty());
13934        rt.shutdown();
13935    }
13936
13937    #[test]
13938    fn sedp_event_push_filters_ignored_topic_only() {
13939        // If only the topic is ignored, DCPSPublication should
13940        // still be pushed — only the DCPSTopic sample falls
13941        // away.
13942        use crate::builtin_topics as bt;
13943        use zerodds_discovery::sedp::SedpEvents;
13944        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13945        let rt = DcpsRuntime::start(
13946            49,
13947            GuidPrefix::from_bytes([0x29; 12]),
13948            RuntimeConfig::default(),
13949        )
13950        .expect("start");
13951        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13952        rt.attach_builtin_sinks(bs.sinks());
13953        let filter = crate::participant::IgnoreFilter::default();
13954        rt.attach_ignore_filter(filter.clone());
13955
13956        let topic_key =
13957            crate::builtin_topics::TopicBuiltinTopicData::synthesize_key("OnlyTopic", "T");
13958        let h_topic = crate::instance_handle::InstanceHandle::from_guid(topic_key);
13959        if let Ok(mut s) = filter.inner.topics.lock() {
13960            s.insert(h_topic);
13961        }
13962
13963        let mut events = SedpEvents::default();
13964        events.new_publications.push(
13965            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13966                key: Guid::new(GuidPrefix::from_bytes([0x55; 12]), EntityId::PARTICIPANT),
13967                participant_key: Guid::new(
13968                    GuidPrefix::from_bytes([0x55; 12]),
13969                    EntityId::PARTICIPANT,
13970                ),
13971                topic_name: "OnlyTopic".into(),
13972                type_name: "T".into(),
13973                durability: zerodds_qos::DurabilityKind::Volatile,
13974                reliability: ReliabilityQosPolicy::default(),
13975                ownership: zerodds_qos::OwnershipKind::Shared,
13976                ownership_strength: 0,
13977                liveliness: LivelinessQosPolicy::default(),
13978                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13979                lifespan: zerodds_qos::LifespanQosPolicy::default(),
13980                partition: Vec::new(),
13981                user_data: Vec::new(),
13982                topic_data: Vec::new(),
13983                group_data: Vec::new(),
13984                type_information: None,
13985                data_representation: Vec::new(),
13986                security_info: None,
13987                service_instance_name: None,
13988                related_entity_guid: None,
13989                topic_aliases: None,
13990                type_identifier: zerodds_types::TypeIdentifier::None,
13991                unicast_locators: Vec::new(),
13992                multicast_locators: Vec::new(),
13993            },
13994        );
13995
13996        push_sedp_events_to_builtin_readers(&rt, &events);
13997
13998        let pub_reader = bs
13999            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
14000            .unwrap();
14001        assert_eq!(pub_reader.take().unwrap().len(), 1);
14002        let topic_reader = bs
14003            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
14004            .unwrap();
14005        assert!(
14006            topic_reader.take().unwrap().is_empty(),
14007            "an ignored topic may block the synthetic DCPSTopic sample"
14008        );
14009        rt.shutdown();
14010    }
14011
14012    // -------- Security-Builtin-Endpoint-Wiring --------
14013
14014    /// Creates an SPDP beacon with configurable BuiltinEndpoint
14015    /// bits. Extension of [`make_remote_spdp_beacon`] with
14016    /// flag-Argument (Security-Bits 22..25).
14017    fn make_remote_spdp_beacon_with_flags(remote_prefix: GuidPrefix, endpoint_set: u32) -> Vec<u8> {
14018        use zerodds_discovery::spdp::SpdpBeacon;
14019        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
14020        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
14021        let data = ParticipantBuiltinTopicData {
14022            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
14023            protocol_version: ProtocolVersion::V2_5,
14024            vendor_id: VendorId::ZERODDS,
14025            default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
14026            default_multicast_locator: None,
14027            metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
14028            metatraffic_multicast_locator: None,
14029            domain_id: Some(0),
14030            builtin_endpoint_set: endpoint_set,
14031            lease_duration: QosDuration::from_secs(100),
14032            user_data: alloc::vec::Vec::new(),
14033            properties: Default::default(),
14034            identity_token: None,
14035            permissions_token: None,
14036            identity_status_token: None,
14037            sig_algo_info: None,
14038            kx_algo_info: None,
14039            sym_cipher_algo_info: None,
14040            participant_security_info: None,
14041        };
14042        let mut beacon = SpdpBeacon::new(data);
14043        beacon.serialize().expect("serialize")
14044    }
14045
14046    fn dp_with_locators(
14047        prefix: GuidPrefix,
14048        metatraffic: Option<Locator>,
14049        default: Option<Locator>,
14050    ) -> zerodds_discovery::spdp::DiscoveredParticipant {
14051        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
14052        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
14053        zerodds_discovery::spdp::DiscoveredParticipant {
14054            sender_prefix: prefix,
14055            sender_vendor: VendorId::ZERODDS,
14056            data: ParticipantBuiltinTopicData {
14057                guid: Guid::new(prefix, EntityId::PARTICIPANT),
14058                protocol_version: ProtocolVersion::V2_5,
14059                vendor_id: VendorId::ZERODDS,
14060                default_unicast_locator: default,
14061                default_multicast_locator: None,
14062                metatraffic_unicast_locator: metatraffic,
14063                metatraffic_multicast_locator: None,
14064                domain_id: Some(0),
14065                builtin_endpoint_set: 0,
14066                lease_duration: QosDuration::from_secs(100),
14067                user_data: alloc::vec::Vec::new(),
14068                properties: Default::default(),
14069                identity_token: None,
14070                permissions_token: None,
14071                identity_status_token: None,
14072                sig_algo_info: None,
14073                kx_algo_info: None,
14074                sym_cipher_algo_info: None,
14075                participant_security_info: None,
14076            },
14077        }
14078    }
14079
14080    #[test]
14081    fn wlp_unicast_targets_prefers_metatraffic_then_default() {
14082        // M-2: WLP-Unicast-Fan-out waehlt pro Peer metatraffic_unicast (bevorzugt),
14083        // otherwise default_unicast; peers without a routable locator fall out.
14084        let meta = Locator::udp_v4([127, 0, 0, 1], 7501);
14085        let deflt = Locator::udp_v4([127, 0, 0, 2], 7500);
14086        let peers = alloc::vec![
14087            // (a) has metatraffic → metatraffic wins
14088            dp_with_locators(GuidPrefix::from_bytes([1; 12]), Some(meta), Some(deflt)),
14089            // (b) only default → default
14090            dp_with_locators(GuidPrefix::from_bytes([2; 12]), None, Some(deflt)),
14091            // (c) none at all → no target
14092            dp_with_locators(GuidPrefix::from_bytes([3; 12]), None, None),
14093        ];
14094        let targets = wlp_unicast_targets(&peers);
14095        assert_eq!(targets, alloc::vec![meta, deflt]);
14096    }
14097
14098    /// Like [`make_remote_spdp_beacon_with_flags`], but with a set
14099    /// `identity_token` (FU2 Gap 7d — triggers the auth handshake).
14100    #[cfg(feature = "security")]
14101    fn make_secure_beacon_with_identity_token(
14102        remote_prefix: GuidPrefix,
14103        endpoint_set: u32,
14104        identity_token: Vec<u8>,
14105    ) -> Vec<u8> {
14106        use zerodds_discovery::spdp::SpdpBeacon;
14107        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
14108        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
14109        let data = ParticipantBuiltinTopicData {
14110            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
14111            protocol_version: ProtocolVersion::V2_5,
14112            vendor_id: VendorId::ZERODDS,
14113            default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
14114            default_multicast_locator: None,
14115            metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
14116            metatraffic_multicast_locator: None,
14117            domain_id: Some(0),
14118            builtin_endpoint_set: endpoint_set,
14119            lease_duration: QosDuration::from_secs(100),
14120            user_data: alloc::vec::Vec::new(),
14121            properties: Default::default(),
14122            identity_token: Some(identity_token),
14123            permissions_token: None,
14124            identity_status_token: None,
14125            sig_algo_info: None,
14126            kx_algo_info: None,
14127            sym_cipher_algo_info: None,
14128            participant_security_info: None,
14129        };
14130        let mut beacon = SpdpBeacon::new(data);
14131        beacon.serialize().expect("serialize")
14132    }
14133
14134    /// Minimal auth plugin for the FU2 wiring tests (Gap 4/7).
14135    /// Crypto correctness is verified in the stack.rs driver test; here
14136    /// it is only about the runtime wiring path.
14137    #[cfg(feature = "security")]
14138    struct FakeAuth;
14139    #[cfg(feature = "security")]
14140    impl zerodds_security::authentication::AuthenticationPlugin for FakeAuth {
14141        fn validate_local_identity(
14142            &mut self,
14143            _: &zerodds_security::properties::PropertyList,
14144            _: [u8; 16],
14145        ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
14146        {
14147            Ok(zerodds_security::authentication::IdentityHandle(1))
14148        }
14149        fn validate_remote_identity(
14150            &mut self,
14151            _: zerodds_security::authentication::IdentityHandle,
14152            _: [u8; 16],
14153            _: &[u8],
14154        ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
14155        {
14156            Ok(zerodds_security::authentication::IdentityHandle(2))
14157        }
14158        fn begin_handshake_request(
14159            &mut self,
14160            _: zerodds_security::authentication::IdentityHandle,
14161            _: zerodds_security::authentication::IdentityHandle,
14162        ) -> zerodds_security::error::SecurityResult<(
14163            zerodds_security::authentication::HandshakeHandle,
14164            zerodds_security::authentication::HandshakeStepOutcome,
14165        )> {
14166            Ok((
14167                zerodds_security::authentication::HandshakeHandle(1),
14168                zerodds_security::authentication::HandshakeStepOutcome::SendMessage {
14169                    token: zerodds_security::token::DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")
14170                        .to_cdr_le(),
14171                },
14172            ))
14173        }
14174        fn begin_handshake_reply(
14175            &mut self,
14176            _: zerodds_security::authentication::IdentityHandle,
14177            _: zerodds_security::authentication::IdentityHandle,
14178            _: &[u8],
14179        ) -> zerodds_security::error::SecurityResult<(
14180            zerodds_security::authentication::HandshakeHandle,
14181            zerodds_security::authentication::HandshakeStepOutcome,
14182        )> {
14183            Ok((
14184                zerodds_security::authentication::HandshakeHandle(2),
14185                zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer,
14186            ))
14187        }
14188        fn process_handshake(
14189            &mut self,
14190            _: zerodds_security::authentication::HandshakeHandle,
14191            _: &[u8],
14192        ) -> zerodds_security::error::SecurityResult<
14193            zerodds_security::authentication::HandshakeStepOutcome,
14194        > {
14195            Ok(zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer)
14196        }
14197        fn shared_secret(
14198            &self,
14199            _: zerodds_security::authentication::HandshakeHandle,
14200        ) -> zerodds_security::error::SecurityResult<
14201            zerodds_security::authentication::SharedSecretHandle,
14202        > {
14203            Err(zerodds_security::error::SecurityError::new(
14204                zerodds_security::error::SecurityErrorKind::BadArgument,
14205                "fake: handshake not complete",
14206            ))
14207        }
14208        fn plugin_class_id(&self) -> &str {
14209            "FAKE:Auth:1.0"
14210        }
14211        fn get_identity_token(
14212            &self,
14213            _: zerodds_security::authentication::IdentityHandle,
14214        ) -> zerodds_security::error::SecurityResult<Vec<u8>> {
14215            // Non-empty Token (Format irrelevant — FakeAuth.validate_remote_
14216            // identity accepts everything); only so the beacon-populate path
14217            // (Gap 7c) has something to announce.
14218            Ok(alloc::vec![0xAB, 0xCD, 0xEF, 0x01])
14219        }
14220        fn get_permissions_token(&self) -> Vec<u8> {
14221            // Non-empty PermissionsToken, so the beacon-populate path
14222            // (S4 point 1) has something to announce (format irrelevant).
14223            zerodds_security::token::DataHolder::new("DDS:Access:Permissions:1.0").to_cdr_le()
14224        }
14225    }
14226
14227    /// Consolidated test for the wiring. A single
14228    /// runtime walks all paths — snapshot API, idempotency of
14229    /// `enable_security_builtins`, SPDP hot path with security bits,
14230    /// without bits, plus the wire-demux hook. We bundle this into one
14231    /// test body, because each `DcpsRuntime::start` binds a multicast socket
14232    /// and parallel tests could brush against the OS resource caps.
14233    #[test]
14234    fn c34c_security_builtin_wiring_end_to_end() {
14235        use zerodds_discovery::security::SecurityBuiltinStack;
14236        use zerodds_security::generic_message::{
14237            MessageIdentity, ParticipantGenericMessage, class_id,
14238        };
14239        use zerodds_security::token::DataHolder;
14240
14241        let local_prefix = GuidPrefix::from_bytes([0x75; 12]);
14242        let rt = DcpsRuntime::start(75, local_prefix, RuntimeConfig::default()).expect("start");
14243
14244        // 1. Snapshot is None before enable
14245        assert!(rt.security_builtin_snapshot().is_none());
14246
14247        // 2. enable ist idempotent
14248        let h1 = rt.enable_security_builtins(VendorId::ZERODDS);
14249        let h2 = rt.enable_security_builtins(VendorId::ZERODDS);
14250        assert!(Arc::ptr_eq(&h1, &h2));
14251        assert!(rt.security_builtin_snapshot().is_some());
14252
14253        // 3. SPDP beacon with all security-builtin bits → the stack has
14254        //    four proxies
14255        let remote_a = GuidPrefix::from_bytes([0x99; 12]);
14256        let flags_all = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14257            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
14258            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
14259            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
14260        handle_spdp_datagram(
14261            &rt,
14262            &make_remote_spdp_beacon_with_flags(remote_a, flags_all),
14263        );
14264        {
14265            let s = h1.lock().unwrap();
14266            assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
14267            assert_eq!(s.stateless_reader.writer_proxy_count(), 1);
14268            assert_eq!(s.volatile_writer.reader_proxy_count(), 1);
14269            assert_eq!(s.volatile_reader.writer_proxy_count(), 1);
14270        }
14271
14272        // 4. SPDP beacon without security bits → the stack stays unchanged
14273        let remote_b = GuidPrefix::from_bytes([0x88; 12]);
14274        handle_spdp_datagram(
14275            &rt,
14276            &make_remote_spdp_beacon_with_flags(remote_b, endpoint_flag::ALL_STANDARD),
14277        );
14278        {
14279            let s = h1.lock().unwrap();
14280            assert_eq!(
14281                s.stateless_writer.reader_proxy_count(),
14282                1,
14283                "a peer without security bits must not touch existing proxies"
14284            );
14285        }
14286
14287        // 5. Wire-demux hook with a valid stateless DATA: remote-stack
14288        //    mirror sends a message → the demux hook routes it through
14289        //    the local reader without panic.
14290        let mut remote_stack = SecurityBuiltinStack::new(remote_a, VendorId::ZERODDS);
14291        let local_peer = make_remote_spdp_beacon_with_flags(local_prefix, flags_all);
14292        let parsed_local = zerodds_discovery::spdp::SpdpReader::new()
14293            .parse_datagram(&local_peer)
14294            .unwrap();
14295        remote_stack.handle_remote_endpoints(&parsed_local);
14296        let msg = ParticipantGenericMessage {
14297            message_identity: MessageIdentity {
14298                source_guid: [0xCD; 16],
14299                sequence_number: 1,
14300            },
14301            related_message_identity: MessageIdentity::default(),
14302            destination_participant_key: [0xEF; 16],
14303            destination_endpoint_key: [0; 16],
14304            source_endpoint_key: [0xFE; 16],
14305            message_class_id: class_id::AUTH_REQUEST.into(),
14306            message_data: alloc::vec![DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")],
14307        };
14308        let dgs = remote_stack.stateless_writer.write(&msg).unwrap();
14309        assert_eq!(dgs.len(), 1);
14310        dispatch_security_builtin_datagram(&rt, &dgs[0].bytes, Duration::from_secs(1));
14311
14312        // 6. The demux hook does not panic on garbage bytes
14313        dispatch_security_builtin_datagram(&rt, &[0u8; 32], Duration::from_secs(1));
14314
14315        rt.shutdown();
14316    }
14317
14318    /// FU2 Gap 4: `enable_security_builtins_with_auth` builds the stack with
14319    /// an active handshake driver — `begin_handshake_with` sends, as
14320    /// the initiator actually sends an AUTH_REQUEST (instead of a no-op like with
14321    /// the auth-less `enable_security_builtins`).
14322    #[cfg(feature = "security")]
14323    #[test]
14324    fn enable_security_builtins_with_auth_activates_handshake_driver() {
14325        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14326
14327        let local_prefix = GuidPrefix::from_bytes([0x40; 12]);
14328        let rt = DcpsRuntime::start(40, local_prefix, RuntimeConfig::default()).expect("start");
14329
14330        let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14331        let stack =
14332            rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
14333
14334        // Discover a peer with stateless bits (WITHOUT identity_token → the
14335        // discovery trigger starts no handshake yet) → proxies
14336        // are wired. The remote prefix is LARGER than local ([0x40]),
14337        // so that local is the initiator under the cyclone convention (smaller GUID
14338        // initiates) and actually sends.
14339        let remote = GuidPrefix::from_bytes([0x99; 12]);
14340        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14341            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14342        handle_spdp_datagram(&rt, &make_remote_spdp_beacon_with_flags(remote, flags));
14343
14344        let dgs = {
14345            let mut s = stack.lock().unwrap();
14346            let remote_guid = Guid::new(remote, EntityId::PARTICIPANT).to_bytes();
14347            s.begin_handshake_with(remote, remote_guid, b"fake-remote-cert-der")
14348                .expect("begin_handshake_with")
14349        };
14350        assert_eq!(
14351            dgs.len(),
14352            1,
14353            "auth driver active → the initiator sends exactly one AUTH_REQUEST"
14354        );
14355
14356        rt.shutdown();
14357    }
14358
14359    /// FU2 Gap 7c/d: `enable_security_builtins_with_auth` announces the
14360    /// local `identity_token` in the SPDP beacon (+ stateless/volatile bits),
14361    /// and an incoming peer beacon WITH an `identity_token` kicks off the
14362    /// Auth-Handshake an (Discovery-Trigger).
14363    #[cfg(feature = "security")]
14364    #[test]
14365    fn spdp_beacon_announces_identity_token_and_discovery_triggers_handshake() {
14366        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14367
14368        let local_prefix = GuidPrefix::from_bytes([0x41; 12]);
14369        let rt = DcpsRuntime::start(41, local_prefix, RuntimeConfig::default()).expect("start");
14370        let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14371        let stack =
14372            rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
14373
14374        // Gap 7c: the beacon now announces identity_token + secure bits.
14375        let beacon_bytes = rt.spdp_beacon.lock().unwrap().serialize().unwrap();
14376        let parsed = zerodds_discovery::spdp::SpdpReader::new()
14377            .parse_datagram(&beacon_bytes)
14378            .unwrap();
14379        assert!(
14380            parsed.data.identity_token.is_some(),
14381            "the beacon must announce PID_IDENTITY_TOKEN"
14382        );
14383        // Cross-vendor: secure vendors validate a remote only when
14384        // SPDP carries **both** tokens. Without PID_PERMISSIONS_TOKEN they treat
14385        // cyclone treats us as non-secure and never starts validate_remote_identity.
14386        assert!(
14387            parsed.data.permissions_token.is_some(),
14388            "the beacon must announce PID_PERMISSIONS_TOKEN (cross-vendor mandatory)"
14389        );
14390        assert_ne!(
14391            parsed.data.builtin_endpoint_set & endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER,
14392            0,
14393            "the beacon must announce the stateless-auth bit"
14394        );
14395
14396        // Gap 7d: peer beacon WITH identity_token + stateless bits → the
14397        // discovery path kicks off begin_handshake_with.
14398        let remote = GuidPrefix::from_bytes([0x99; 12]);
14399        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14400            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14401        let peer_beacon =
14402            make_secure_beacon_with_identity_token(remote, flags, alloc::vec![0x11, 0x22, 0x33]);
14403        handle_spdp_datagram(&rt, &peer_beacon);
14404
14405        // Proof that the discovery trigger fired: the peer is now
14406        // registered in the stack's handshake state. (The earlier length
14407        // probe via a repeated begin_handshake_with no longer applies since the resend path
14408        // resends as the initiator on a repeated call.)
14409        let started = {
14410            let s = stack.lock().unwrap();
14411            s.handshake_peer_count()
14412        };
14413        assert_eq!(
14414            started, 1,
14415            "the discovery trigger must have started the handshake (peer registered)"
14416        );
14417
14418        rt.shutdown();
14419    }
14420
14421    /// FU2 S3: two secure runtimes in the same process MUST find each other via
14422    /// in-process participant discovery and kick off the auth handshake
14423    /// — WITHOUT a single multicast beacon. That was exactly missing:
14424    /// `inproc_inject_publication`/`_subscription` inject only SEDP, the
14425    /// SPDP participant discovery (identity_token + `begin_handshake_with`)
14426    /// ran exclusively over the flaky multicast path.
14427    #[cfg(feature = "security")]
14428    #[test]
14429    fn inproc_participant_discovery_triggers_handshake_without_multicast() {
14430        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14431
14432        let a_prefix = GuidPrefix::from_bytes([0x4A; 12]);
14433        let b_prefix = GuidPrefix::from_bytes([0x4B; 12]);
14434        let rt_a = DcpsRuntime::start(47, a_prefix, RuntimeConfig::default()).expect("start a");
14435        let rt_b = DcpsRuntime::start(47, b_prefix, RuntimeConfig::default()).expect("start b");
14436        let auth_a: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14437        let auth_b: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14438        let stack_a =
14439            rt_a.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_a, IdentityHandle(1));
14440        let stack_b =
14441            rt_b.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_b, IdentityHandle(1));
14442
14443        // KEIN handle_spdp_datagram / Multicast — rein in-process.
14444        let a_peers = stack_a.lock().unwrap().handshake_peer_count();
14445        let b_peers = stack_b.lock().unwrap().handshake_peer_count();
14446        assert!(
14447            a_peers >= 1,
14448            "A must have discovered B in-process + started the handshake (got {a_peers})"
14449        );
14450        assert!(
14451            b_peers >= 1,
14452            "B must have discovered A in-process + started the handshake (got {b_peers})"
14453        );
14454
14455        rt_a.shutdown();
14456        rt_b.shutdown();
14457    }
14458
14459    /// Mints a shared CA + two leaf identities (PEM) for the
14460    /// FU2-Handshake-e2e-Test.
14461    #[cfg(feature = "security")]
14462    #[allow(clippy::type_complexity)]
14463    fn mint_handshake_identities() -> ((Vec<u8>, Vec<u8>), (Vec<u8>, Vec<u8>)) {
14464        use rcgen::{CertificateParams, KeyPair};
14465        let mut ca_params =
14466            CertificateParams::new(alloc::vec![alloc::string::String::from("FU2 CA")]).unwrap();
14467        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
14468        let ca_key = KeyPair::generate().unwrap();
14469        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
14470        let ca_pem = ca_cert.pem().into_bytes();
14471        let mint = |name: &str| -> (Vec<u8>, Vec<u8>) {
14472            let mut p =
14473                CertificateParams::new(alloc::vec![alloc::string::String::from(name)]).unwrap();
14474            p.is_ca = rcgen::IsCa::NoCa;
14475            let k = KeyPair::generate().unwrap();
14476            let c = p.signed_by(&k, &ca_cert, &ca_key).unwrap();
14477            (c.pem().into_bytes(), k.serialize_pem().into_bytes())
14478        };
14479        let alice = {
14480            let (cert, key) = mint("alice");
14481            (cert, key)
14482        };
14483        let bob = {
14484            let (cert, key) = mint("bob");
14485            (cert, key)
14486        };
14487        // attach ca_pem to both, so the caller has the trust anchor.
14488        (
14489            ([alice.0, b"\n".to_vec(), ca_pem.clone()].concat(), alice.1),
14490            ([bob.0, b"\n".to_vec(), ca_pem].concat(), bob.1),
14491        )
14492    }
14493
14494    /// FU2 Gap 5 (e2e): a runtime replier (A) and an in-test initiator
14495    /// stack (B) complete a real PKI 3-round handshake via the dispatch path
14496    /// and BOTH derive the same SharedSecret.
14497    /// Verifies the dispatch wiring (`on_stateless_message` →
14498    /// reply/final → completion) in the real runtime context.
14499    #[cfg(feature = "security")]
14500    #[test]
14501    fn handshake_completes_through_runtime_dispatch_e2e() {
14502        use zerodds_discovery::security::SecurityBuiltinStack;
14503        use zerodds_security::authentication::AuthenticationPlugin;
14504        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
14505
14506        // cert_pem here contains Leaf || CA (mint_handshake_identities),
14507        // identity_ca_pem = the same bundle (CA is included).
14508        let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
14509
14510        // A = Runtime (Replier, HOEHERER Prefix). B = in-test Stack
14511        // (initiator, LOWER prefix) — cyclone convention: smaller
14512        // GUID initiiert.
14513        let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
14514        let b_prefix = GuidPrefix::from_bytes([0x10; 12]);
14515        let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
14516        let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
14517
14518        // --- A: runtime with a real PKI plugin ---
14519        let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14520        let a_local = a_pki
14521            .lock()
14522            .unwrap()
14523            .validate_with_config(
14524                IdentityConfig {
14525                    identity_cert_pem: a_cert.clone(),
14526                    identity_ca_pem: a_cert.clone(),
14527                    identity_key_pem: Some(a_key),
14528                },
14529                a_guid,
14530            )
14531            .unwrap();
14532        let a_token = a_pki.lock().unwrap().get_identity_token(a_local).unwrap();
14533        let rt = DcpsRuntime::start(42, a_prefix, RuntimeConfig::default()).expect("start");
14534        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
14535        let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
14536
14537        // --- B: in-test initiator stack with a real PKI plugin ---
14538        let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14539        let b_local = b_pki
14540            .lock()
14541            .unwrap()
14542            .validate_with_config(
14543                IdentityConfig {
14544                    identity_cert_pem: b_cert.clone(),
14545                    identity_ca_pem: b_cert.clone(),
14546                    identity_key_pem: Some(b_key),
14547                },
14548                b_guid,
14549            )
14550            .unwrap();
14551        let b_token = b_pki.lock().unwrap().get_identity_token(b_local).unwrap();
14552        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
14553        let mut b_stack =
14554            SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
14555
14556        let stateless = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14557            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14558
14559        // B discovers A (wired proxies) — via the parsed A beacon.
14560        let a_beacon = make_secure_beacon_with_identity_token(a_prefix, stateless, a_token.clone());
14561        let a_parsed = zerodds_discovery::spdp::SpdpReader::new()
14562            .parse_datagram(&a_beacon)
14563            .unwrap();
14564        b_stack.handle_remote_endpoints(&a_parsed);
14565
14566        // A discovers B → the discovery trigger creates A's peer state (A is
14567        // the replier, sends nothing).
14568        let b_beacon = make_secure_beacon_with_identity_token(b_prefix, stateless, b_token);
14569        handle_spdp_datagram(&rt, &b_beacon);
14570
14571        // B (initiator) starts → AUTH_REQUEST.
14572        let req = b_stack
14573            .begin_handshake_with(a_prefix, a_guid, &a_token)
14574            .unwrap();
14575        assert_eq!(req.len(), 1, "B sends AUTH_REQUEST");
14576
14577        // Pump: REQUEST → A.dispatch → REPLY.
14578        let reply = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
14579        assert_eq!(reply.len(), 1, "A (replier) answers with AUTH reply");
14580
14581        // REPLY → B verarbeitet → FINAL (+ B erreicht Complete).
14582        let b_msgs = b_stack
14583            .stateless_reader
14584            .handle_datagram(&reply[0].bytes)
14585            .unwrap();
14586        assert_eq!(b_msgs.len(), 1);
14587        let (final_dgs, _b_complete) = b_stack.on_stateless_message(a_prefix, &b_msgs[0]).unwrap();
14588        assert_eq!(final_dgs.len(), 1, "B sends AUTH-Final");
14589
14590        // FINAL → A.dispatch → A erreicht Complete.
14591        let _ =
14592            dispatch_security_builtin_datagram(&rt, &final_dgs[0].bytes, Duration::from_secs(1));
14593
14594        // Both sides must now have derived the same SharedSecret.
14595        let a_secret = {
14596            let s = a_stack.lock().unwrap();
14597            s.peer_secret(b_prefix)
14598                .expect("A must have authenticated B")
14599        };
14600        let b_secret = b_stack
14601            .peer_secret(a_prefix)
14602            .expect("B must have authenticated A");
14603        let a_bytes = a_pki
14604            .lock()
14605            .unwrap()
14606            .secret_bytes(a_secret)
14607            .unwrap()
14608            .to_vec();
14609        let b_bytes = b_pki
14610            .lock()
14611            .unwrap()
14612            .secret_bytes(b_secret)
14613            .unwrap()
14614            .to_vec();
14615        assert_eq!(a_bytes.len(), 32);
14616        assert_eq!(
14617            a_bytes, b_bytes,
14618            "runtime dispatch + in-test stack derive the same secret"
14619        );
14620
14621        rt.shutdown();
14622    }
14623
14624    /// FU2 S1.5 (e2e): after the auth handshake the runtime dispatch
14625    /// (A, replier) and a reference peer (B, stack+gate, initiator) over
14626    /// the Kx-protected VolatileSecure channel automatically exchange their data
14627    /// crypto tokens — afterwards secured user DATA round-trips in BOTH
14628    /// directions. **The secured-DATA proof via the runtime dispatch.**
14629    #[cfg(feature = "security")]
14630    #[test]
14631    #[serial_test::serial(dcps_security_e2e)]
14632    fn secured_data_round_trips_through_runtime_dispatch_e2e() {
14633        use zerodds_discovery::security::SecurityBuiltinStack;
14634        use zerodds_security::authentication::{AuthenticationPlugin, SharedSecretProvider};
14635        use zerodds_security::generic_message::{
14636            MessageIdentity, ParticipantGenericMessage, class_id,
14637        };
14638        use zerodds_security::token::DataHolder;
14639        use zerodds_security_crypto::{AesGcmCryptoPlugin, Suite};
14640        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
14641        use zerodds_security_runtime::{ProtectionLevel, SharedSecurityGate};
14642
14643        // Couples the pki plugin (behind a mutex) as the SharedSecretProvider to
14644        // the crypto plugin — like SecurityProfile in the FFI (Gap 1).
14645        struct PkiProvider(Arc<Mutex<PkiAuthenticationPlugin>>);
14646        impl SharedSecretProvider for PkiProvider {
14647            fn get_shared_secret(
14648                &self,
14649                h: zerodds_security::authentication::SharedSecretHandle,
14650            ) -> Option<Vec<u8>> {
14651                self.0.lock().ok()?.get_shared_secret(h)
14652            }
14653        }
14654        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>"#;
14655        let gov = || zerodds_security_permissions::parse_governance_xml(GOV).unwrap();
14656        let gate_with = |pki: &Arc<Mutex<PkiAuthenticationPlugin>>| {
14657            SharedSecurityGate::new(
14658                0,
14659                gov(),
14660                Box::new(AesGcmCryptoPlugin::with_secret_provider(
14661                    Suite::Aes128Gcm,
14662                    Arc::new(PkiProvider(pki.clone())) as Arc<dyn SharedSecretProvider>,
14663                )),
14664            )
14665        };
14666        let fake_rtps = |prefix: GuidPrefix, body: &[u8]| -> Vec<u8> {
14667            let mut m = Vec::new();
14668            m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
14669            m.extend_from_slice(&prefix.to_bytes());
14670            m.extend_from_slice(body);
14671            m
14672        };
14673
14674        let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
14675        let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
14676        let b_prefix = GuidPrefix::from_bytes([0x10; 12]); // B < A → B initiator (cyclone convention)
14677        let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
14678        let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
14679        let a_key_pk = a_prefix.to_bytes();
14680        let b_key_pk = b_prefix.to_bytes();
14681
14682        // --- A: runtime with auth + gate (sharing pki_a) ---
14683        let pki_a = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14684        let a_local = pki_a
14685            .lock()
14686            .unwrap()
14687            .validate_with_config(
14688                IdentityConfig {
14689                    identity_cert_pem: a_cert.clone(),
14690                    identity_ca_pem: a_cert.clone(),
14691                    identity_key_pem: Some(a_key),
14692                },
14693                a_guid,
14694            )
14695            .unwrap();
14696        let a_token = pki_a.lock().unwrap().get_identity_token(a_local).unwrap();
14697        let gate_a = Arc::new(gate_with(&pki_a));
14698        let rt = DcpsRuntime::start(
14699            43,
14700            a_prefix,
14701            RuntimeConfig {
14702                security: Some(gate_a.clone()),
14703                ..RuntimeConfig::default()
14704            },
14705        )
14706        .expect("start");
14707        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_a.clone();
14708        let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
14709
14710        // --- B: in-test Stack + Gate (sharing pki_b), Initiator ---
14711        let pki_b = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14712        let b_local = pki_b
14713            .lock()
14714            .unwrap()
14715            .validate_with_config(
14716                IdentityConfig {
14717                    identity_cert_pem: b_cert.clone(),
14718                    identity_ca_pem: b_cert.clone(),
14719                    identity_key_pem: Some(b_key),
14720                },
14721                b_guid,
14722            )
14723            .unwrap();
14724        let b_token = pki_b.lock().unwrap().get_identity_token(b_local).unwrap();
14725        let gate_b = gate_with(&pki_b);
14726        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_b.clone();
14727        let mut stack_b =
14728            SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
14729
14730        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14731            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
14732            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
14733            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
14734        let a_beacon = make_secure_beacon_with_identity_token(a_prefix, flags, a_token.clone());
14735        stack_b.handle_remote_endpoints(
14736            &zerodds_discovery::spdp::SpdpReader::new()
14737                .parse_datagram(&a_beacon)
14738                .unwrap(),
14739        );
14740        // Wire A's stack deterministically (no handle_spdp_datagram —
14741        // a running runtime + trigger otherwise produces non-deterministic
14742        // proxy wirings via parallel/loopback beacons). A is the replier:
14743        // begin_handshake_with only sets up the peer state.
14744        let b_beacon = make_secure_beacon_with_identity_token(b_prefix, flags, b_token.clone());
14745        let b_parsed = zerodds_discovery::spdp::SpdpReader::new()
14746            .parse_datagram(&b_beacon)
14747            .unwrap();
14748        {
14749            let mut s = a_stack.lock().unwrap();
14750            s.handle_remote_endpoints(&b_parsed);
14751            s.begin_handshake_with(b_prefix, b_guid, &b_token).unwrap();
14752        }
14753
14754        // --- Stateless-Handshake pumpen (B initiiert) ---
14755        // A is the replier and derives the secret already at begin_handshake_
14756        // reply → A's response to the request contains BOTH: the
14757        // AUTH reply (stateless) AND A's Kx-encrypted crypto token
14758        // (volatile, automatically via the dispatch).
14759        let decode_route = |dgs: &[zerodds_rtps::message_builder::OutboundDatagram]| {
14760            let mut stateless = Vec::new();
14761            let mut volatile = Vec::new();
14762            for dg in dgs {
14763                let parsed = zerodds_rtps::datagram::decode_datagram(&dg.bytes).unwrap();
14764                let is_vol = parsed.submessages.iter().any(|sub| {
14765                    // Klartext-Pfad (unprotected): DATA an den VolatileSecure-Reader.
14766                    matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Data(d)
14767                        if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER)
14768                    // Cross-vendor path (protected): the volatile crypto-token DATA
14769                    // is SEC_*-protected (protect_volatile_outbound) -> the inner
14770                    // DATA is encrypted and recognizable only by the prepended SEC_PREFIX
14771                    // submessage (id 0x31). Stateless AUTH stays plaintext.
14772                    || matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Unknown { id: 0x31, .. })
14773                });
14774                if is_vol {
14775                    volatile.push(dg.bytes.clone());
14776                } else {
14777                    stateless.push(dg.bytes.clone());
14778                }
14779            }
14780            (stateless, volatile)
14781        };
14782
14783        let req = stack_b
14784            .begin_handshake_with(a_prefix, a_guid, &a_token)
14785            .unwrap();
14786        let a_resp = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
14787        let (a_stateless, a_volatile) = decode_route(&a_resp);
14788        assert!(
14789            !a_volatile.is_empty(),
14790            "A dispatch must send A's crypto token"
14791        );
14792
14793        // B verarbeitet A's AUTH-Reply → Final + B completes.
14794        let mut b_remote_id = None;
14795        let mut b_secret = None;
14796        let mut b_final = Vec::new();
14797        for sl in &a_stateless {
14798            for m in stack_b.stateless_reader.handle_datagram(sl).unwrap() {
14799                let (out, comp) = stack_b.on_stateless_message(a_prefix, &m).unwrap();
14800                b_final.extend(out);
14801                if let Some((id, sec)) = comp {
14802                    b_remote_id = Some(id);
14803                    b_secret = Some(sec);
14804                }
14805            }
14806        }
14807        let b_remote_id = b_remote_id.expect("B remote identity");
14808        let b_secret = b_secret.expect("B completes");
14809
14810        // B registers A's Kx, installs A's crypto token (from a_volatile).
14811        gate_b
14812            .register_remote_by_guid_from_secret(a_key_pk, b_remote_id, b_secret)
14813            .unwrap();
14814        // A's volatile crypto token is cross-vendor SEC_*-protected
14815        // (protect_volatile_outbound). B must decrypt the SEC_PREFIX/BODY/POSTFIX sequence
14816        // with A's Kx key to the inner DATA submessage before the
14817        // volatile_reader can process it — mirrors unprotect_volatile_
14818        // datagram im Live-Dispatch.
14819        let unprotect_vol_b = |bytes: &[u8]| -> Option<Vec<u8>> {
14820            let subs = walk_submessages(bytes);
14821            let prefix_pos = subs.iter().position(|(id, _, _)| *id == SMID_SEC_PREFIX)?;
14822            let postfix_idx = subs[prefix_pos..]
14823                .iter()
14824                .position(|(id, _, _)| *id == SMID_SEC_POSTFIX)
14825                .map(|i| prefix_pos + i)?;
14826            let (_, p_start, _) = subs[prefix_pos];
14827            let (_, q_start, q_total) = subs[postfix_idx];
14828            let data_submsg = gate_b
14829                .decode_kx_datawriter_from(&a_key_pk, &bytes[p_start..q_start + q_total])
14830                .ok()?;
14831            let mut out = Vec::with_capacity(bytes.len());
14832            out.extend_from_slice(&bytes[..20]);
14833            for (i, &(_, start, total)) in subs.iter().enumerate() {
14834                if i < prefix_pos || i > postfix_idx {
14835                    out.extend_from_slice(&bytes[start..start + total]);
14836                } else if i == prefix_pos {
14837                    out.extend_from_slice(&data_submsg);
14838                }
14839            }
14840            Some(out)
14841        };
14842        let mut b_installed = 0;
14843        for vol in &a_volatile {
14844            let vol_plain = unprotect_vol_b(vol).unwrap_or_else(|| vol.clone());
14845            let parsed = zerodds_rtps::datagram::decode_datagram(&vol_plain).unwrap();
14846            let vol_src = parsed.header.guid_prefix;
14847            for sub in parsed.submessages {
14848                if let zerodds_rtps::datagram::ParsedSubmessage::Data(d) = sub {
14849                    if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
14850                        for m in stack_b.volatile_reader.handle_data(vol_src, &d).unwrap() {
14851                            if m.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS {
14852                                // plaintext keymat (confidentiality was provided by the SEC_*
14853                                // protection of the volatile DATA, decrypted above) —
14854                                // install directly, no transform_kx_inbound.
14855                                let token = m.message_data[0]
14856                                    .binary_property(CRYPTO_TOKEN_PROP)
14857                                    .unwrap();
14858                                gate_b
14859                                    .set_remote_data_token_by_guid(&a_key_pk, token)
14860                                    .unwrap();
14861                                b_installed += 1;
14862                            }
14863                        }
14864                    }
14865                }
14866            }
14867        }
14868        assert!(b_installed >= 1, "B must install A's crypto token");
14869
14870        // B builds + sends its crypto token — plaintext keymat in the
14871        // ParticipantGenericMessage (cross-vendor: confidentiality via SEC_*
14872        // protection of the transporting volatile DATA, not via token-internal
14873        // Kx encryption).
14874        let b_data_token = gate_b.local_token().unwrap();
14875        let b_crypto_msg = ParticipantGenericMessage {
14876            message_identity: MessageIdentity {
14877                source_guid: b_guid,
14878                sequence_number: 1,
14879            },
14880            related_message_identity: MessageIdentity::default(),
14881            destination_participant_key: a_guid,
14882            destination_endpoint_key: [0; 16],
14883            source_endpoint_key: [0; 16],
14884            message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
14885            message_data: alloc::vec![
14886                DataHolder::new("DDS:Crypto:AES_GCM_GMAC")
14887                    .with_binary_property(CRYPTO_TOKEN_PROP, b_data_token)
14888            ],
14889        };
14890        let b_volatile = stack_b.volatile_writer.write(&b_crypto_msg).unwrap();
14891        // SEC_* submessage protection with A's Kx key (mirrors protect_volatile_
14892        // datagram in the live path): B encrypts the DATA submessage, A's
14893        // dispatch decrypts it via unprotect_volatile_datagram.
14894        let protect_vol_b = |bytes: &[u8]| -> Vec<u8> {
14895            let subs = walk_submessages(bytes);
14896            if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
14897                return bytes.to_vec();
14898            }
14899            let mut out = Vec::with_capacity(bytes.len() + 64);
14900            out.extend_from_slice(&bytes[..20]);
14901            for (id, start, total) in subs {
14902                let submsg = &bytes[start..start + total];
14903                if id == SMID_DATA {
14904                    out.extend_from_slice(
14905                        &gate_b.encode_kx_datawriter_for(&a_key_pk, submsg).unwrap(),
14906                    );
14907                } else {
14908                    out.extend_from_slice(submsg);
14909                }
14910            }
14911            out
14912        };
14913        let b_vol_protected = protect_vol_b(&b_volatile[0].bytes);
14914
14915        // B's Final + B's Crypto-Token an A's Dispatch: A installiert B's
14916        // Data token (automatically via install_crypto_token).
14917        for f in &b_final {
14918            dispatch_security_builtin_datagram(&rt, &f.bytes, Duration::from_secs(1));
14919        }
14920        dispatch_security_builtin_datagram(&rt, &b_vol_protected, Duration::from_secs(1));
14921
14922        // --- Secured DATA in both directions ---
14923        let msg_ab = fake_rtps(a_prefix, b"[A->B secured payload]");
14924        let wire_ab = gate_a
14925            .transform_outbound_for(&b_key_pk, &msg_ab, ProtectionLevel::Encrypt)
14926            .unwrap();
14927        assert_eq!(
14928            gate_b.transform_inbound_from(&a_key_pk, &wire_ab).unwrap(),
14929            msg_ab,
14930            "A->B secured DATA must round-trip"
14931        );
14932        let msg_ba = fake_rtps(b_prefix, b"[B->A secured payload]");
14933        let wire_ba = gate_b
14934            .transform_outbound_for(&a_key_pk, &msg_ba, ProtectionLevel::Encrypt)
14935            .unwrap();
14936        assert_eq!(
14937            gate_a.transform_inbound_from(&b_key_pk, &wire_ba).unwrap(),
14938            msg_ba,
14939            "B->A secured DATA must round-trip (A's dispatch installed B's token)"
14940        );
14941
14942        rt.shutdown();
14943    }
14944
14945    #[test]
14946    fn c34c_enable_security_builtins_replays_known_peers() {
14947        // Order reversed: SPDP discovery first, plugin-
14948        // activation afterward. enable_security_builtins must catch up on already-
14949        // known peers. Plus: demux without a plugin (before enable)
14950        // is a no-op + does not panic.
14951        let rt = DcpsRuntime::start(
14952            76,
14953            GuidPrefix::from_bytes([0x76; 12]),
14954            RuntimeConfig::default(),
14955        )
14956        .expect("start");
14957
14958        // Demux without a plugin: silent no-op
14959        dispatch_security_builtin_datagram(&rt, &[0u8; 16], Duration::from_secs(1));
14960
14961        let remote = GuidPrefix::from_bytes([0x77; 12]);
14962        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14963            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14964        let dg = make_remote_spdp_beacon_with_flags(remote, flags);
14965        handle_spdp_datagram(&rt, &dg);
14966
14967        let stack = rt.enable_security_builtins(VendorId::ZERODDS);
14968        {
14969            let s = stack.lock().unwrap();
14970            assert_eq!(
14971                s.stateless_writer.reader_proxy_count(),
14972                1,
14973                "late plugin activation must catch up on known peers"
14974            );
14975        }
14976
14977        rt.shutdown();
14978    }
14979
14980    /// #29 regression: the earlier per-peer once-guard blocked late-matched
14981    /// user-endpoint tokens. `pending_endpoint_tokens` must, with already-sent
14982    /// builtin tokens, let through EXACTLY the new user token — not treat the whole
14983    /// peer as "done".
14984    #[cfg(feature = "security")]
14985    #[test]
14986    fn pending_endpoint_tokens_keeps_late_user_token_after_builtins_sent() {
14987        use zerodds_security::generic_message::ParticipantGenericMessage;
14988        // An early-sent builtin token (secure-SEDP) ...
14989        let builtin = ParticipantGenericMessage {
14990            source_endpoint_key: [0xff; 16],
14991            destination_endpoint_key: [0xfe; 16],
14992            ..Default::default()
14993        };
14994        // ... and a late-matched user-endpoint token.
14995        let user = ParticipantGenericMessage {
14996            source_endpoint_key: [0x03; 16],
14997            destination_endpoint_key: [0x04; 16],
14998            ..Default::default()
14999        };
15000        let mut sent = alloc::collections::BTreeSet::new();
15001        sent.insert(endpoint_token_key(&builtin));
15002
15003        let pending = pending_endpoint_tokens(vec![builtin.clone(), user.clone()], &sent);
15004
15005        assert_eq!(pending.len(), 1, "only the new user token may be pending");
15006        assert_eq!(
15007            pending[0].source_endpoint_key, user.source_endpoint_key,
15008            "the let-through token must be the user-endpoint token"
15009        );
15010        // Idempotency: after sending, nothing is pending anymore.
15011        let mut sent2 = sent.clone();
15012        sent2.insert(endpoint_token_key(&user));
15013        assert!(
15014            pending_endpoint_tokens(vec![builtin, user], &sent2).is_empty(),
15015            "already-sent tokens must not become pending again"
15016        );
15017    }
15018
15019    /// QT (#76, FOUNDATIONAL): a writer and reader of the SAME type whose
15020    /// TypeIdentifier is a *complete* hash (EquivalenceHashComplete) absent from
15021    /// any registry must still match and exchange data. Before the fix the
15022    /// runtime type-consistency check resolved against a fresh empty registry
15023    /// and rejected the match with TYPE_CONSISTENCY_ENFORCEMENT.
15024    #[test]
15025    fn qt_same_complete_type_identifier_matches_and_exchanges() {
15026        let rt = DcpsRuntime::start(
15027            60,
15028            GuidPrefix::from_bytes([0x60; 12]),
15029            RuntimeConfig::default(),
15030        )
15031        .expect("start");
15032        let complete = zerodds_types::TypeIdentifier::EquivalenceHashComplete(
15033            zerodds_types::type_identifier::EquivalenceHash([0xC0; 14]),
15034        );
15035        let mut w_cfg = qr_writer_cfg(
15036            "QtTopic",
15037            zerodds_qos::DurabilityKind::Volatile,
15038            alloc::vec![],
15039            zerodds_qos::LivelinessKind::Automatic,
15040        );
15041        w_cfg.type_identifier = complete.clone();
15042        let mut r_cfg = qr_reader_cfg(
15043            "QtTopic",
15044            zerodds_qos::DurabilityKind::Volatile,
15045            alloc::vec![],
15046            zerodds_qos::LivelinessKind::Automatic,
15047        );
15048        r_cfg.type_identifier = complete;
15049        let w = rt.register_user_writer(w_cfg).expect("writer");
15050        let (_r, rx) = rt.register_user_reader(r_cfg).expect("reader");
15051        rt.write_user_sample(w, b"complete-typed".to_vec())
15052            .expect("write");
15053        let s = rx
15054            .recv_timeout(core::time::Duration::from_millis(200))
15055            .expect("complete-TypeIdentifier writer+reader must match + exchange");
15056        match s {
15057            UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"complete-typed"),
15058            other => panic!("expected Alive, got {other:?}"),
15059        }
15060        rt.shutdown();
15061    }
15062
15063    // ===================================================================
15064    // QR-cluster (#77) — same-runtime QoS behavioral regression tests.
15065    // ===================================================================
15066
15067    fn qr_writer_cfg(
15068        topic: &str,
15069        durability: zerodds_qos::DurabilityKind,
15070        partition: Vec<String>,
15071        liveliness: zerodds_qos::LivelinessKind,
15072    ) -> UserWriterConfig {
15073        UserWriterConfig {
15074            topic_name: topic.into(),
15075            type_name: "QrType".into(),
15076            reliable: true,
15077            durability,
15078            deadline: zerodds_qos::DeadlineQosPolicy::default(),
15079            lifespan: zerodds_qos::LifespanQosPolicy::default(),
15080            liveliness: zerodds_qos::LivelinessQosPolicy {
15081                kind: liveliness,
15082                lease_duration: QosDuration::INFINITE,
15083            },
15084            ownership: zerodds_qos::OwnershipKind::Shared,
15085            ownership_strength: 0,
15086            partition,
15087            user_data: alloc::vec![],
15088            topic_data: alloc::vec![],
15089            group_data: alloc::vec![],
15090            type_identifier: zerodds_types::TypeIdentifier::None,
15091            data_representation_offer: None,
15092        }
15093    }
15094
15095    fn qr_reader_cfg(
15096        topic: &str,
15097        durability: zerodds_qos::DurabilityKind,
15098        partition: Vec<String>,
15099        liveliness: zerodds_qos::LivelinessKind,
15100    ) -> UserReaderConfig {
15101        UserReaderConfig {
15102            topic_name: topic.into(),
15103            type_name: "QrType".into(),
15104            reliable: true,
15105            durability,
15106            deadline: zerodds_qos::DeadlineQosPolicy::default(),
15107            liveliness: zerodds_qos::LivelinessQosPolicy {
15108                kind: liveliness,
15109                lease_duration: QosDuration::INFINITE,
15110            },
15111            ownership: zerodds_qos::OwnershipKind::Shared,
15112            partition,
15113            user_data: alloc::vec![],
15114            topic_data: alloc::vec![],
15115            group_data: alloc::vec![],
15116            type_identifier: zerodds_types::TypeIdentifier::None,
15117            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
15118            data_representation_offer: None,
15119        }
15120    }
15121
15122    /// QR (a) HISTORY KeepLast: a TransientLocal writer with depth=2 retains
15123    /// only the last 2 samples per instance; a late-joining reader replays
15124    /// exactly those 2 (not all 3 written).
15125    #[test]
15126    fn qr_history_keep_last_depth_enforced_on_replay() {
15127        let rt = DcpsRuntime::start(
15128            61,
15129            GuidPrefix::from_bytes([0x61; 12]),
15130            RuntimeConfig::default(),
15131        )
15132        .expect("start");
15133        let w = rt
15134            .register_user_writer(qr_writer_cfg(
15135                "QrHistory",
15136                zerodds_qos::DurabilityKind::TransientLocal,
15137                alloc::vec![],
15138                zerodds_qos::LivelinessKind::Automatic,
15139            ))
15140            .expect("writer");
15141        rt.set_user_writer_history_depth(w, 2).expect("set depth");
15142
15143        // Three writes BEFORE any reader exists.
15144        rt.write_user_sample(w, b"s1".to_vec()).expect("w1");
15145        rt.write_user_sample(w, b"s2".to_vec()).expect("w2");
15146        rt.write_user_sample(w, b"s3".to_vec()).expect("w3");
15147        assert_eq!(
15148            rt.user_writer_retained_len(w),
15149            2,
15150            "KeepLast(2) must retain only the 2 most recent samples"
15151        );
15152
15153        // Late-joining reader replays exactly the last 2 (s2, s3).
15154        let (_r, rx) = rt
15155            .register_user_reader(qr_reader_cfg(
15156                "QrHistory",
15157                zerodds_qos::DurabilityKind::TransientLocal,
15158                alloc::vec![],
15159                zerodds_qos::LivelinessKind::Automatic,
15160            ))
15161            .expect("reader");
15162
15163        let mut got: Vec<Vec<u8>> = Vec::new();
15164        while let Ok(s) = rx.recv_timeout(core::time::Duration::from_millis(200)) {
15165            if let UserSample::Alive { payload, .. } = s {
15166                got.push(payload.as_ref().to_vec());
15167            }
15168            if got.len() == 2 {
15169                break;
15170            }
15171        }
15172        assert_eq!(got, alloc::vec![b"s2".to_vec(), b"s3".to_vec()]);
15173        rt.shutdown();
15174    }
15175
15176    /// QR (b) DURABILITY TRANSIENT_LOCAL: a late-joining reader receives the
15177    /// retained sample written before it matched. A VOLATILE writer replays
15178    /// nothing.
15179    #[test]
15180    fn qr_transient_local_late_join_replay_vs_volatile() {
15181        // TransientLocal: late joiner sees the prior sample.
15182        let rt = DcpsRuntime::start(
15183            62,
15184            GuidPrefix::from_bytes([0x62; 12]),
15185            RuntimeConfig::default(),
15186        )
15187        .expect("start");
15188        let w = rt
15189            .register_user_writer(qr_writer_cfg(
15190                "QrTL",
15191                zerodds_qos::DurabilityKind::TransientLocal,
15192                alloc::vec![],
15193                zerodds_qos::LivelinessKind::Automatic,
15194            ))
15195            .expect("writer");
15196        rt.write_user_sample(w, b"retained".to_vec())
15197            .expect("write");
15198        let (_r, rx) = rt
15199            .register_user_reader(qr_reader_cfg(
15200                "QrTL",
15201                zerodds_qos::DurabilityKind::TransientLocal,
15202                alloc::vec![],
15203                zerodds_qos::LivelinessKind::Automatic,
15204            ))
15205            .expect("reader");
15206        let s = rx
15207            .recv_timeout(core::time::Duration::from_millis(200))
15208            .expect("TransientLocal late joiner must replay the retained sample");
15209        match s {
15210            UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"retained"),
15211            other => panic!("expected Alive, got {other:?}"),
15212        }
15213        rt.shutdown();
15214
15215        // Volatile: late joiner gets nothing for the pre-match write.
15216        let rt2 = DcpsRuntime::start(
15217            63,
15218            GuidPrefix::from_bytes([0x63; 12]),
15219            RuntimeConfig::default(),
15220        )
15221        .expect("start");
15222        let w2 = rt2
15223            .register_user_writer(qr_writer_cfg(
15224                "QrVol",
15225                zerodds_qos::DurabilityKind::Volatile,
15226                alloc::vec![],
15227                zerodds_qos::LivelinessKind::Automatic,
15228            ))
15229            .expect("writer");
15230        rt2.write_user_sample(w2, b"lost".to_vec()).expect("write");
15231        assert_eq!(
15232            rt2.user_writer_retained_len(w2),
15233            0,
15234            "Volatile retains nothing"
15235        );
15236        let (_r2, rx2) = rt2
15237            .register_user_reader(qr_reader_cfg(
15238                "QrVol",
15239                zerodds_qos::DurabilityKind::Volatile,
15240                alloc::vec![],
15241                zerodds_qos::LivelinessKind::Automatic,
15242            ))
15243            .expect("reader");
15244        assert!(
15245            rx2.recv_timeout(core::time::Duration::from_millis(120))
15246                .is_err(),
15247            "Volatile late joiner must NOT replay a pre-match sample"
15248        );
15249        rt2.shutdown();
15250    }
15251
15252    /// QR (c) PARTITION: a writer in partition ["A"] and a reader in ["B"]
15253    /// must NOT match (no intra-runtime route); a matching partition delivers.
15254    #[test]
15255    fn qr_partition_gates_intra_runtime_match() {
15256        let rt = DcpsRuntime::start(
15257            64,
15258            GuidPrefix::from_bytes([0x64; 12]),
15259            RuntimeConfig::default(),
15260        )
15261        .expect("start");
15262        // Mismatched partitions.
15263        let w = rt
15264            .register_user_writer(qr_writer_cfg(
15265                "QrPart",
15266                zerodds_qos::DurabilityKind::Volatile,
15267                alloc::vec!["A".into()],
15268                zerodds_qos::LivelinessKind::Automatic,
15269            ))
15270            .expect("writer");
15271        let (_r_mismatch, rx_mismatch) = rt
15272            .register_user_reader(qr_reader_cfg(
15273                "QrPart",
15274                zerodds_qos::DurabilityKind::Volatile,
15275                alloc::vec!["B".into()],
15276                zerodds_qos::LivelinessKind::Automatic,
15277            ))
15278            .expect("reader");
15279        rt.write_user_sample(w, b"x".to_vec()).expect("write");
15280        assert!(
15281            rx_mismatch
15282                .recv_timeout(core::time::Duration::from_millis(120))
15283                .is_err(),
15284            "partitions [A] vs [B] must not match"
15285        );
15286
15287        // Matching partition reader added → now delivers.
15288        let (_r_match, rx_match) = rt
15289            .register_user_reader(qr_reader_cfg(
15290                "QrPart",
15291                zerodds_qos::DurabilityKind::Volatile,
15292                alloc::vec!["A".into()],
15293                zerodds_qos::LivelinessKind::Automatic,
15294            ))
15295            .expect("reader");
15296        rt.write_user_sample(w, b"y".to_vec()).expect("write");
15297        let s = rx_match
15298            .recv_timeout(core::time::Duration::from_millis(200))
15299            .expect("partition [A] vs [A] must match");
15300        match s {
15301            UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"y"),
15302            other => panic!("expected Alive, got {other:?}"),
15303        }
15304        rt.shutdown();
15305    }
15306
15307    /// QR (d) KEYED LIFECYCLE: dispose(key) delivers a Lifecycle marker to a
15308    /// matched same-runtime reader; a later late joiner observes the terminal
15309    /// NOT_ALIVE_DISPOSED state.
15310    #[test]
15311    fn qr_dispose_delivers_lifecycle_to_intra_reader() {
15312        use zerodds_rtps::history_cache::ChangeKind;
15313        use zerodds_rtps::inline_qos::status_info;
15314        let rt = DcpsRuntime::start(
15315            65,
15316            GuidPrefix::from_bytes([0x65; 12]),
15317            RuntimeConfig::default(),
15318        )
15319        .expect("start");
15320        let w = rt
15321            .register_user_writer_kind(
15322                qr_writer_cfg(
15323                    "QrLifecycle",
15324                    zerodds_qos::DurabilityKind::TransientLocal,
15325                    alloc::vec![],
15326                    zerodds_qos::LivelinessKind::Automatic,
15327                ),
15328                true,
15329            )
15330            .expect("writer");
15331        let (_r, rx) = rt
15332            .register_user_reader_kind(
15333                qr_reader_cfg(
15334                    "QrLifecycle",
15335                    zerodds_qos::DurabilityKind::TransientLocal,
15336                    alloc::vec![],
15337                    zerodds_qos::LivelinessKind::Automatic,
15338                ),
15339                true,
15340            )
15341            .expect("reader");
15342
15343        let key = [0xAB_u8; 16];
15344        rt.write_user_sample_keyed(w, b"alive", key).expect("write");
15345        // First the alive sample.
15346        let first = rx
15347            .recv_timeout(core::time::Duration::from_millis(200))
15348            .expect("alive sample");
15349        assert!(matches!(first, UserSample::Alive { .. }));
15350
15351        // dispose(key) → Lifecycle marker NOT_ALIVE_DISPOSED.
15352        rt.write_user_lifecycle(w, key, status_info::DISPOSED)
15353            .expect("dispose");
15354        let life = rx
15355            .recv_timeout(core::time::Duration::from_millis(200))
15356            .expect("dispose must deliver a Lifecycle marker to the matched reader");
15357        match life {
15358            UserSample::Lifecycle { key_hash, kind } => {
15359                assert_eq!(key_hash, key);
15360                assert_eq!(kind, ChangeKind::NotAliveDisposed);
15361            }
15362            other => panic!("expected Lifecycle, got {other:?}"),
15363        }
15364
15365        // A brand-new late joiner replays the alive sample AND the terminal
15366        // disposed marker, so it learns the instance is NOT_ALIVE_DISPOSED.
15367        let (_r2, rx2) = rt
15368            .register_user_reader_kind(
15369                qr_reader_cfg(
15370                    "QrLifecycle",
15371                    zerodds_qos::DurabilityKind::TransientLocal,
15372                    alloc::vec![],
15373                    zerodds_qos::LivelinessKind::Automatic,
15374                ),
15375                true,
15376            )
15377            .expect("reader2");
15378        let mut saw_disposed = false;
15379        while let Ok(s) = rx2.recv_timeout(core::time::Duration::from_millis(200)) {
15380            if let UserSample::Lifecycle { kind, .. } = s {
15381                if kind == ChangeKind::NotAliveDisposed {
15382                    saw_disposed = true;
15383                    break;
15384                }
15385            }
15386        }
15387        assert!(
15388            saw_disposed,
15389            "late joiner must observe the terminal NOT_ALIVE_DISPOSED state"
15390        );
15391        rt.shutdown();
15392    }
15393
15394    /// QR (d) KEYED LIFECYCLE — unregister(key) maps to NOT_ALIVE (NO_WRITERS).
15395    #[test]
15396    fn qr_unregister_delivers_no_writers_lifecycle() {
15397        use zerodds_rtps::history_cache::ChangeKind;
15398        use zerodds_rtps::inline_qos::status_info;
15399        let rt = DcpsRuntime::start(
15400            66,
15401            GuidPrefix::from_bytes([0x66; 12]),
15402            RuntimeConfig::default(),
15403        )
15404        .expect("start");
15405        let w = rt
15406            .register_user_writer_kind(
15407                qr_writer_cfg(
15408                    "QrUnreg",
15409                    zerodds_qos::DurabilityKind::Volatile,
15410                    alloc::vec![],
15411                    zerodds_qos::LivelinessKind::Automatic,
15412                ),
15413                true,
15414            )
15415            .expect("writer");
15416        let (_r, rx) = rt
15417            .register_user_reader_kind(
15418                qr_reader_cfg(
15419                    "QrUnreg",
15420                    zerodds_qos::DurabilityKind::Volatile,
15421                    alloc::vec![],
15422                    zerodds_qos::LivelinessKind::Automatic,
15423                ),
15424                true,
15425            )
15426            .expect("reader");
15427        let key = [0x11_u8; 16];
15428        rt.write_user_lifecycle(w, key, status_info::UNREGISTERED)
15429            .expect("unregister");
15430        let life = rx
15431            .recv_timeout(core::time::Duration::from_millis(200))
15432            .expect("unregister must deliver a Lifecycle marker");
15433        match life {
15434            UserSample::Lifecycle { key_hash, kind } => {
15435                assert_eq!(key_hash, key);
15436                assert_eq!(kind, ChangeKind::NotAliveUnregistered);
15437            }
15438            other => panic!("expected Lifecycle, got {other:?}"),
15439        }
15440        rt.shutdown();
15441    }
15442
15443    /// QR (e) LIVELINESS AUTOMATIC: the reader's liveliness_changed alive_count
15444    /// tracks a live matched writer on the same-runtime path.
15445    #[test]
15446    fn qr_liveliness_automatic_bumps_reader_alive_count() {
15447        let rt = DcpsRuntime::start(
15448            67,
15449            GuidPrefix::from_bytes([0x67; 12]),
15450            RuntimeConfig::default(),
15451        )
15452        .expect("start");
15453        let w = rt
15454            .register_user_writer(qr_writer_cfg(
15455                "QrLive",
15456                zerodds_qos::DurabilityKind::Volatile,
15457                alloc::vec![],
15458                zerodds_qos::LivelinessKind::Automatic,
15459            ))
15460            .expect("writer");
15461        let (r, rx) = rt
15462            .register_user_reader(qr_reader_cfg(
15463                "QrLive",
15464                zerodds_qos::DurabilityKind::Volatile,
15465                alloc::vec![],
15466                zerodds_qos::LivelinessKind::Automatic,
15467            ))
15468            .expect("reader");
15469
15470        let (_alive0, count0, _na0) = rt.user_reader_liveliness_status(r);
15471        assert_eq!(count0, 0, "no writer has delivered yet");
15472
15473        rt.write_user_sample(w, b"beat".to_vec()).expect("write");
15474        let _ = rx.recv_timeout(core::time::Duration::from_millis(200));
15475
15476        let (alive, count, _na) = rt.user_reader_liveliness_status(r);
15477        assert!(alive, "AUTOMATIC writer keeps the reader's match alive");
15478        assert_eq!(
15479            count, 1,
15480            "alive_count must bump to 1 for the live matched AUTOMATIC writer"
15481        );
15482
15483        // A second write from the same writer does NOT double-count.
15484        rt.write_user_sample(w, b"beat2".to_vec()).expect("write");
15485        let _ = rx.recv_timeout(core::time::Duration::from_millis(200));
15486        let (_a, count2, _n) = rt.user_reader_liveliness_status(r);
15487        assert_eq!(count2, 1, "same writer must not bump alive_count twice");
15488        rt.shutdown();
15489    }
15490}