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    source_ts_override: Option<zerodds_rtps::header_extension::HeTimestamp>,
378) -> Result<Vec<zerodds_rtps::message_builder::OutboundDatagram>> {
379    let mut frame = zerodds_foundation::PoolBuffer::<SMALL_FRAME_CAP>::new();
380    frame
381        .extend_from_slice(encap)
382        .map_err(|_| DdsError::WireError {
383            message: String::from("user encap framing"),
384        })?;
385    frame
386        .extend_from_slice(payload)
387        .map_err(|_| DdsError::WireError {
388            message: String::from("user payload framing"),
389        })?;
390    // Hot path: only DATA, NO HEARTBEAT. Cyclone DDS rate-limits the
391    // HB piggyback (≥100 µs spacing, or a packet boundary) — so it does
392    // NOT send an HB per write. At 14k writes/sec this would fire 14k
393    // unnecessary submessages and (with an unaligned payload) 14k extra
394    // sendto syscalls. Periodic HBs are handled by the tick loop (every
395    // `heartbeat_period` ms, default 100 ms); we no longer attach `_now`
396    // to `last_heartbeat`, because we emit nothing.
397    let _ = now;
398    // RTPS-F1: attach the source timestamp so the peer can populate
399    // SampleInfo.source_timestamp + honour DESTINATION_ORDER = BY_SOURCE_TIMESTAMP
400    // (DDSI-RTPS §8.7.3). The writer prepends an INFO_TS before the DATA. A
401    // routing service that preserves the input's source timestamp passes it as
402    // the override; otherwise the writer stamps the current wall clock.
403    let source_ts = source_ts_override
404        .unwrap_or_else(|| crate::time::time_to_he_timestamp(crate::time::get_current_time()));
405    writer
406        .write_stamped(frame.as_slice(), Some(source_ts))
407        .map_err(|_| DdsError::WireError {
408            message: String::from("user writer encode"),
409        })
410}
411
412/// Choice of transport for DCPS user traffic. Discovery (SPDP/SEDP)
413/// remains UDPv4 multicast independently of this.
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415#[non_exhaustive]
416pub enum UserTransportKind {
417    /// UDP IPv4 (default).
418    UdpV4,
419    /// UDP IPv6.
420    UdpV6,
421    /// TCP IPv4 (DDS-TCP-PSM `LOCATOR_KIND_TCPV4`).
422    TcpV4,
423    /// TCP IPv6 (DDS-TCP-PSM `LOCATOR_KIND_TCPV6`).
424    TcpV6,
425    /// POSIX shared memory (same-host). Only with the `same-host-shm`
426    /// feature.
427    #[cfg(feature = "same-host-shm")]
428    Shm,
429    /// Unix domain socket (same-host, container-friendly). Only with the
430    /// `same-host-uds` feature.
431    #[cfg(feature = "same-host-uds")]
432    Uds,
433    /// TSN L2 transport (AF_PACKET, RTPS direct on Ethernet, EtherType
434    /// 0x88B5). Only with the `tsn-live` feature on Linux. Interface/VLAN/
435    /// PCP via the env vars `ZERODDS_TSN_IFACE`/`_VLAN`/`_PCP`.
436    #[cfg(all(feature = "tsn-live", target_os = "linux"))]
437    Tsn,
438}
439
440/// Maps the `ZERODDS_USER_TRANSPORT` env var to a [`UserTransportKind`].
441/// `None` if unset or unknown — the caller then falls back to UDPv4.
442fn parse_user_transport_env() -> Option<UserTransportKind> {
443    match std::env::var("ZERODDS_USER_TRANSPORT").ok()?.as_str() {
444        "UDPv4" => Some(UserTransportKind::UdpV4),
445        "UDPv6" => Some(UserTransportKind::UdpV6),
446        "TCPv4" => Some(UserTransportKind::TcpV4),
447        "TCPv6" => Some(UserTransportKind::TcpV6),
448        #[cfg(feature = "same-host-shm")]
449        "SHM" => Some(UserTransportKind::Shm),
450        #[cfg(feature = "same-host-uds")]
451        "UDS" => Some(UserTransportKind::Uds),
452        #[cfg(all(feature = "tsn-live", target_os = "linux"))]
453        "TSN" => Some(UserTransportKind::Tsn),
454        _ => None,
455    }
456}
457
458/// Result of [`select_user_transport`]: the user-traffic transport plus
459/// an optional `TcpTransport` accept handle (only for TCP).
460type UserTransportSelection = (
461    Arc<dyn Transport + Send + Sync>,
462    Option<Arc<zerodds_transport_tcp::TcpTransport>>,
463);
464
465/// Binds the user-traffic transport for the selected
466/// [`UserTransportKind`]. Discovery (SPDP/SEDP) runs separately over
467/// UDPv4 multicast; this transport carries only the DCPS user traffic.
468///
469/// Additionally returns an optional `TcpTransport` accept handle: TCP has
470/// no implicit accept thread in the constructor, so the caller starts an
471/// `accept_one` worker for it.
472#[cfg_attr(
473    not(any(feature = "same-host-shm", feature = "same-host-uds")),
474    allow(unused_variables)
475)]
476fn select_user_transport(
477    kind: UserTransportKind,
478    guid_prefix: GuidPrefix,
479    domain_id: i32,
480    pinned: Ipv4Addr,
481) -> Result<UserTransportSelection> {
482    match kind {
483        UserTransportKind::UdpV4 => {
484            // Interface pinning: bind to the pinned IPv4 (egress + receive
485            // on exactly this interface), otherwise `0.0.0.0` (auto).
486            let udp = UdpTransport::bind_v4(pinned, 0)
487                .map_err(|_| DdsError::TransportError {
488                    label: "user unicast bind (UDPv4)",
489                })?
490                .with_timeout(Some(Duration::from_secs(1)))
491                .map_err(|_| DdsError::TransportError {
492                    label: "user unicast set_timeout (UDPv4)",
493                })?;
494            Ok((Arc::new(udp), None))
495        }
496        UserTransportKind::UdpV6 => {
497            let udp = UdpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
498                .map_err(|_| DdsError::TransportError {
499                    label: "user unicast bind (UDPv6)",
500                })?
501                .with_timeout(Some(Duration::from_secs(1)))
502                .map_err(|_| DdsError::TransportError {
503                    label: "user unicast set_timeout (UDPv6)",
504                })?;
505            Ok((Arc::new(udp), None))
506        }
507        UserTransportKind::TcpV4 => {
508            // Interface pinning analogous to UDPv4.
509            let tcp = zerodds_transport_tcp::TcpTransport::bind_v4(pinned, 0).map_err(|_| {
510                DdsError::TransportError {
511                    label: "user unicast bind (TCPv4)",
512                }
513            })?;
514            let arc = Arc::new(tcp);
515            let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
516            Ok((dynamic, Some(arc)))
517        }
518        UserTransportKind::TcpV6 => {
519            let tcp =
520                zerodds_transport_tcp::TcpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
521                    .map_err(|_| DdsError::TransportError {
522                        label: "user unicast bind (TCPv6)",
523                    })?;
524            let arc = Arc::new(tcp);
525            let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
526            Ok((dynamic, Some(arc)))
527        }
528        #[cfg(feature = "same-host-shm")]
529        UserTransportKind::Shm => {
530            // local_id = guid_prefix (12 bytes) + 4-byte domain id so that
531            // separate domains get separate segments (no cross-domain
532            // collisions).
533            let mut local_id = [0u8; 16];
534            local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
535            local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
536            let shm = crate::shm_user::ShmUserTransport::new(
537                local_id,
538                zerodds_transport_shm::posix::ShmConfig::default(),
539            );
540            Ok((Arc::new(shm), None))
541        }
542        #[cfg(feature = "same-host-uds")]
543        UserTransportKind::Uds => {
544            // local_id = guid_prefix (12 bytes) + 4-byte domain id — the
545            // peer resolves this id from the announced UDS locator into the
546            // same socket path.
547            let mut local_id = [0u8; 16];
548            local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
549            local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
550            // recv_timeout analogous to the UDP path: the recv loop must
551            // periodically check the stop flag (otherwise a thread hang on
552            // shutdown on a blocking recv).
553            let uds_cfg = zerodds_transport_uds::UdsConfig {
554                recv_timeout: Some(Duration::from_secs(1)),
555                ..zerodds_transport_uds::UdsConfig::default()
556            };
557            let uds =
558                zerodds_transport_uds::UdsTransport::bind(local_id, uds_cfg).map_err(|_| {
559                    DdsError::TransportError {
560                        label: "user unicast bind (UDS)",
561                    }
562                })?;
563            Ok((Arc::new(uds), None))
564        }
565        #[cfg(all(feature = "tsn-live", target_os = "linux"))]
566        UserTransportKind::Tsn => {
567            // Interface/VLAN/PCP via env (TSN needs a concrete interface;
568            // not bindable to 0.0.0.0 like UDP/TCP). recv_timeout 1s
569            // analogous to UDP for the stop-flag check.
570            let iface =
571                std::env::var("ZERODDS_TSN_IFACE").map_err(|_| DdsError::TransportError {
572                    label: "ZERODDS_TSN_IFACE not set (TSN transport)",
573                })?;
574            let vlan = std::env::var("ZERODDS_TSN_VLAN")
575                .ok()
576                .and_then(|s| s.parse::<u16>().ok())
577                .unwrap_or(0);
578            let pcp = std::env::var("ZERODDS_TSN_PCP")
579                .ok()
580                .and_then(|s| s.parse::<u8>().ok())
581                .unwrap_or(0);
582            let tsn = zerodds_transport_tsn::socket::TsnTransport::bind(
583                &iface,
584                vlan,
585                pcp,
586                Some(Duration::from_secs(1)),
587            )
588            .map_err(|_| DdsError::TransportError {
589                label: "user unicast bind (TSN)",
590            })?;
591            Ok((Arc::new(tsn), None))
592        }
593    }
594}
595
596/// Configuration for the runtime. Exposed via DomainParticipant factory
597/// methods.
598#[derive(Clone)]
599pub struct RuntimeConfig {
600    /// Tick period of the event loop. Default 50 ms.
601    pub tick_period: Duration,
602    /// SPDP announce period. Default 5 s.
603    pub spdp_period: Duration,
604    /// C3 WiFi-robust discovery — number of initial SPDP announces sent at the
605    /// fast [`Self::initial_announce_period`] cadence (instead of `spdp_period`)
606    /// while no peer is yet discovered. Default
607    /// [`DEFAULT_INITIAL_ANNOUNCE_COUNT`]. `0` disables the burst (legacy
608    /// single-announce-then-`spdp_period` behaviour).
609    pub initial_announce_count: u32,
610    /// Period between initial-announcement-burst SPDP sends. Default
611    /// [`DEFAULT_INITIAL_ANNOUNCE_PERIOD`].
612    pub initial_announce_period: Duration,
613    /// SPDP multicast group (IPv4). Default 239.255.0.1 (Spec §9.6.1.4.1).
614    pub spdp_multicast_group: Ipv4Addr,
615    /// Interface address for the multicast join. Default 0.0.0.0 (the
616    /// kernel picks the default interface).
617    pub multicast_interface: Ipv4Addr,
618
619    /// Opt-in multicast allowlist (IPv4 groups). When non-empty, ZeroDDS joins
620    /// only multicast groups on this list — any other group, including a
621    /// `spdp_multicast_group` not on the list, is refused at participant
622    /// creation. Empty = no restriction (default).
623    ///
624    /// Defense-in-depth: announced multicast locators are already never used as
625    /// send targets (they are dropped when a reader proxy is built, see
626    /// `ReaderProxy::new(.., Vec::new(), ..)` on the SEDP match path), so ZeroDDS
627    /// only ever touches its own `spdp_multicast_group`. This allowlist turns
628    /// that implicit code property into an enforced, auditable config.
629    /// Env: `ZERODDS_MULTICAST_ALLOWLIST` (comma-separated IPv4).
630    pub multicast_allowlist: Vec<Ipv4Addr>,
631
632    /// C1: whether SPDP beacons are sent via multicast. Default `true`
633    /// (spec behavior). `false` (env `ZERODDS_NO_MULTICAST`) → pure
634    /// unicast discovery via [`Self::initial_peers`], not a single
635    /// multicast packet — for networks that drop multicast (WiFi/cloud
636    /// VPC), and for a rigorous multicast-free discovery proof.
637    pub spdp_multicast_send: bool,
638
639    /// A1: **discovery-server** mode. When `true`, this participant relays the
640    /// raw SPDP (participant-locator) announcements between the clients that
641    /// point their [`Self::initial_peers`] at it — so N clients discover each
642    /// other through one well-known address instead of an O(N²) peer list or
643    /// multicast. Crucially it relays **only SPDP** (participant discovery);
644    /// SEDP (endpoint discovery, incl. dynamically-created ROS-2 Action
645    /// endpoints) then happens **directly peer-to-peer** between the real
646    /// participants — which is exactly why ROS-2 Actions work over it, unlike a
647    /// SEDP-proxying discovery server. Default `false`. Plain discovery only
648    /// (DDS-Security secured discovery-server relay is a follow-up).
649    pub discovery_server: bool,
650
651    /// C3: max reassemblable sample size (DoS cap of the fragment
652    /// assembler). Larger samples are silently discarded. The rtps
653    /// default was 1 MiB (the phase-1 assumption "large images = no
654    /// use case") — too small for ROS PointCloud2/Image (often several
655    /// MB). Default here 16 MiB; env `ZERODDS_MAX_SAMPLE_BYTES` (bytes)
656    /// overrides. Still a deliberate DoS guard, just ROS-realistic.
657    pub max_reassembly_sample_bytes: usize,
658
659    /// C1 multicast-free discovery: unicast initial-peer locators to
660    /// which SPDP beacons are sent **in addition** to multicast. Default
661    /// empty (= pure multicast behavior as before). Populated via
662    /// [`RuntimeConfig::default`] from the env `ZERODDS_PEERS` (comma
663    /// list of `ip` or `ip:port`). An `ip` without a port is expanded to
664    /// the well-known SPDP unicast ports of participant indices 0..N
665    /// (see [`expand_initial_peer`]).
666    pub initial_peers: Vec<Locator>,
667
668    /// Transport for DCPS user traffic. `None` (default) → fall back to
669    /// the env var `ZERODDS_USER_TRANSPORT`, otherwise UDPv4. Discovery
670    /// (SPDP/SEDP) remains UDPv4 multicast independently of this. Ignored when
671    /// [`Self::user_transports`] is non-empty.
672    pub user_transport: Option<UserTransportKind>,
673
674    /// Preference-ordered set of transports for DCPS user traffic. When
675    /// non-empty, the runtime builds a [`LayeredUserTransport`](crate::layered_transport::LayeredUserTransport)
676    /// over all of them: each datagram is routed to the first transport whose
677    /// locator kind matches the destination (so list the fast/local transport
678    /// first — e.g. `[Shm, UdpV4]` — and the fallback last), and receives are
679    /// multiplexed from all of them. Empty (default) → single-transport via
680    /// [`Self::user_transport`].
681    pub user_transports: alloc::vec::Vec<UserTransportKind>,
682
683    /// Optional security gate. Active only with the `security` feature.
684    /// When set, UDP outbound messages are pulled through
685    /// [`SharedSecurityGate::transform_outbound`], and inbound messages
686    /// through [`SharedSecurityGate::transform_inbound_from`] (peer key
687    /// from RTPS header bytes 8..20).
688    #[cfg(feature = "security")]
689    pub security: Option<std::sync::Arc<zerodds_security_runtime::SharedSecurityGate>>,
690    /// Optional LoggingPlugin for security events. Called by the inbound
691    /// path when packets are dropped due to a policy violation, tampering
692    /// or a legacy block.
693    #[cfg(feature = "security")]
694    pub security_logger: Option<std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin>>,
695
696    /// Multi-interface bindings. Empty → `user_unicast` is the only
697    /// outbound socket (legacy behavior). Non-empty →
698    /// `DcpsRuntime::start` builds a dedicated UDP socket per spec and the
699    /// writer tick loop routes to the matching socket per destination
700    /// locator.
701    #[cfg(feature = "security")]
702    pub interface_bindings: Vec<InterfaceBindingSpec>,
703
704    /// `true` → the SPDP beacon additionally announces the 12 secure
705    /// discovery bits (16..27, DDS-Security 1.2 §7.4.7.1). Default
706    /// `false` — only standard bits are announced. Set by the DCPS
707    /// factory once a PolicyEngine is configured. This flag is available
708    /// even without the `security` feature, so that tests can check bit
709    /// presence without activating the whole crypto crate.
710    pub announce_secure_endpoints: bool,
711
712    /// FastDDS interop: run the reliable secure SPDP channel (0xff0101c2/c7,
713    /// `ENTITYID_SPDP_RELIABLE_BUILTIN_PARTICIPANT_SECURE_*`). FastDDS announces
714    /// its full secured participant data (identity_token/security_info) over
715    /// this channel and gates the crypto-token reciprocation/endpoint matching
716    /// on it; cyclone does NOT need it (cyclone↔zerodds runs without). Default off
717    /// — enable only for FastDDS cross-vendor.
718    pub enable_secure_spdp: bool,
719
720    /// WLP-Tick-Periode (Writer-Liveliness-Protocol, RTPS 2.5 §8.4.13).
721    /// `Duration::ZERO` → default `participant_lease_duration / 3`
722    /// (spec recommendation: three misses before the reader marks the
723    /// writer as not-alive). A direct override enables aggressive
724    /// tests.
725    pub wlp_period: Duration,
726
727    /// Lease duration announced in the SPDP beacon as
728    /// `PARTICIPANT_LEASE_DURATION` (spec default 100 s). Also used as the
729    /// basis for the AUTOMATIC WLP tick (`wlp_period =
730    /// participant_lease_duration / 3` if `wlp_period == Duration::ZERO`).
731    pub participant_lease_duration: Duration,
732
733    /// USER_DATA bytes of the participant (DDS 1.4 §2.2.3.1
734    /// `UserDataQosPolicy`). Announced in the SPDP beacon as PID_USER_DATA
735    /// (DDSI-RTPS §9.6.3.2) and exposed on the receiver side in
736    /// `ParticipantBuiltinTopicData.user_data`. Default empty.
737    pub user_data: Vec<u8>,
738
739    /// Observability sink. Default is `null_sink()` — each event emit is
740    /// then a direct return without allocation on the consumer side.
741    /// Consumers inject e.g.
742    /// [`zerodds_foundation::observability::StderrJsonSink`] (JSON lines
743    /// for Vector/fluentd/Datadog) or their own OTLP bridge.
744    pub observability: zerodds_foundation::observability::SharedSink,
745
746    /// Sprint D.5d lever C — RT pinning + priority. Linux-only; on
747    /// macOS/Windows the hooks are no-ops.
748    ///
749    /// SCHED_FIFO priority (1-99) for the three recv workers (SPDP MC,
750    /// metatraffic, user data). `None` = default scheduler (CFS).
751    /// `Some(80)` is the spec recommendation for real-time paths. Requires
752    /// `CAP_SYS_NICE` or an `RLIMIT_RTPRIO`-permitted user.
753    pub recv_thread_priority: Option<i32>,
754
755    /// Like [`Self::recv_thread_priority`], but for the tick worker.
756    pub tick_thread_priority: Option<i32>,
757
758    /// CPU affinity mask for the recv workers. `None` = no affinity (the
759    /// kernel schedules freely). A list of CPU indices, e.g.
760    /// `vec![2, 3]` for cores 2+3. Set via `sched_setaffinity`; all three
761    /// recv threads share the same mask.
762    pub recv_thread_cpus: Option<Vec<usize>>,
763
764    /// Like [`Self::recv_thread_cpus`], but for the tick worker.
765    pub tick_thread_cpus: Option<Vec<usize>>,
766
767    /// Opt-3 (Spec `zerodds-zero-copy-1.0` §9): number of additional
768    /// user-data recv workers that listen on the same port as
769    /// `user_unicast` via `SO_REUSEPORT`. `0` (default) = only the primary
770    /// `recv_user_data_loop` worker. Under high recv load the pool scales
771    /// linearly with cores (kernel flow hashing distributes incoming
772    /// datagrams). Recommended values: 1-3 additional workers per CPU
773    /// core.
774    pub extra_recv_threads: usize,
775
776    /// D.5g — default DataRepresentation list announced in SEDP
777    /// PublicationData and SEDP SubscriptionData, when not overridden
778    /// per-writer/reader (UserWriterConfig/UserReaderConfig).
779    ///
780    /// **Important**: per strict spec (XTypes 1.3 §7.6.3.1.2) the first
781    /// element is the writer's "offered" and must be in the reader's
782    /// "accepted" list for a match to happen. Default `[XCDR1, XCDR2]` =
783    /// legacy-first → max interop with the RTI Connext Shapes Demo
784    /// (XCDR1-only). Pure-XCDR2 deployments can switch this to `[XCDR2]`
785    /// or `[XCDR2, XCDR1]` for bandwidth efficiency and
786    /// @appendable/@mutable support.
787    ///
788    /// Empty (`vec![]`) is interpreted per spec as `[XCDR1]`.
789    pub data_representation_offer: Vec<i16>,
790
791    /// D.5g — default match mode for DataRepresentation negotiation.
792    ///
793    /// `Strict` (XTypes 1.3 §7.6.3.1.2 normative): writer.first ∈
794    /// reader.list = match. `Tolerant` (industry norm): any overlap =
795    /// match, picks the first overlap as the wire format.
796    ///
797    /// Default `Tolerant` because Cyclone DDS and FastDDS match this way —
798    /// maximizes interop. The strict setting is only meaningful for
799    /// formal spec-compliance tests.
800    pub data_rep_match_mode: zerodds_rtps::publication_data::data_representation::DataRepMatchMode,
801
802    /// zerodds-async-1.0 §4 — when `true`, `start()` does **not** spawn the
803    /// dedicated `zdds-tick` std::thread. The periodic tick (SPDP announce,
804    /// SEDP/WLP, deadline/lifespan/liveliness) must then be driven externally
805    /// via [`DcpsRuntime::tick_driver`]. Used by the async API's
806    /// `spawn_in_tokio`, which multiplexes many participants' tick loops onto
807    /// a tokio runtime instead of one thread each. Default `false` (internal
808    /// thread, unchanged behaviour). The recv worker threads are unaffected —
809    /// they block on socket recv and stay regardless.
810    pub external_tick: bool,
811
812    /// D.5e Phase 3 — when `true`, `start()` drives the periodic tick via the
813    /// event-driven deadline scheduler ([`crate::scheduler`]) instead of the
814    /// fixed-`tick_period` poll: the worker parks until the next due deadline
815    /// (SPDP announce, or a fine floor while user endpoints/QoS timers are
816    /// active) or until a write/recv `raise` wakes it — no busy-poll, lower idle
817    /// CPU, lower tail latency. The work done per wake is the **unchanged**
818    /// `run_tick_iteration` (identical wire output + cadence — cross-vendor
819    /// safe). **Default `true`** since D.5e Phase C (2026-06-14) — set
820    /// `ZERODDS_SCHEDULER_TICK=0` or this field to `false` for the classic
821    /// fixed-period `tick_loop`. Mutually exclusive with `external_tick`
822    /// (external wins).
823    pub scheduler_tick: bool,
824}
825
826/// Configuration entry for a physical or logical network interface.
827///
828/// A binding describes an outbound socket: which IP/port it binds to,
829/// which `NetInterface` class the interface represents, and which IP
830/// range counts as "associated peers" (routing match).
831#[cfg(feature = "security")]
832#[derive(Clone, Debug)]
833pub struct InterfaceBindingSpec {
834    /// Name for diagnostics + log attribution (e.g. `"eth0"`, `"tun0"`,
835    /// `"lo"`).
836    pub name: String,
837    /// Bind address. `0.0.0.0` leaves the interface to the kernel.
838    pub bind_addr: Ipv4Addr,
839    /// Bind port. `0` = ephemeral.
840    pub bind_port: u16,
841    /// Interface class — feeds into the PolicyEngine context.
842    pub kind: NetInterface,
843    /// Destination IP range this binding is responsible for. Example:
844    /// `127.0.0.0/8` for loopback. A target whose IP lies in this range is
845    /// routed to this binding.
846    pub subnet: IpRange,
847    /// If `true`: this binding is used when **no** other subnet match
848    /// applies. Exactly one entry should have `default = true` (usually
849    /// the WAN binding).
850    pub default: bool,
851}
852
853/// Fully bound interface with its UDP socket.
854#[cfg(feature = "security")]
855struct InterfaceBinding {
856    spec: InterfaceBindingSpec,
857    socket: Arc<UdpTransport>,
858}
859
860/// Pool of per-interface UDP sockets with target-based routing.
861///
862/// Decision:
863/// 1. Iterates over all bindings; the first whose subnet contains the
864///    target wins.
865/// 2. If no match and a default binding exists → default path.
866/// 3. No match + no default → `None`, the caller drops.
867#[cfg(feature = "security")]
868struct OutboundSocketPool {
869    bindings: Vec<InterfaceBinding>,
870    default_idx: Option<usize>,
871}
872
873#[cfg(feature = "security")]
874impl OutboundSocketPool {
875    fn bind_all(specs: &[InterfaceBindingSpec]) -> Result<Self> {
876        let mut bindings = Vec::with_capacity(specs.len());
877        for spec in specs {
878            let socket = UdpTransport::bind_v4(spec.bind_addr, spec.bind_port).map_err(|_| {
879                DdsError::TransportError {
880                    label: "interface-binding bind_v4 failed",
881                }
882            })?;
883            // Short read timeout so that the per-interface inbound poll in
884            // the event loop becomes non-blocking. 5 ms is small enough not
885            // to create latency elsewhere (the tick period defaults to
886            // 50 ms), but large enough to amortize context switches.
887            let socket = socket
888                .with_timeout(Some(Duration::from_millis(5)))
889                .map_err(|_| DdsError::TransportError {
890                    label: "interface-binding set_timeout failed",
891                })?;
892            bindings.push(InterfaceBinding {
893                spec: spec.clone(),
894                socket: Arc::new(socket),
895            });
896        }
897        let default_idx = bindings.iter().position(|b| b.spec.default);
898        Ok(Self {
899            bindings,
900            default_idx,
901        })
902    }
903
904    /// Returns `(socket, NetInterface class)` for a destination locator.
905    /// `None` if neither a subnet match nor a default binding exists.
906    fn route(&self, target: &Locator) -> Option<(&Arc<UdpTransport>, NetInterface)> {
907        let ip = ipv4_from_locator(target)?;
908        let addr = core::net::IpAddr::V4(core::net::Ipv4Addr::from(ip));
909        for b in &self.bindings {
910            if b.spec.subnet.contains(&addr) {
911                return Some((&b.socket, b.spec.kind.clone()));
912            }
913        }
914        let idx = self.default_idx?;
915        let b = self.bindings.get(idx)?;
916        Some((&b.socket, b.spec.kind.clone()))
917    }
918}
919
920/// True if the locator is routable over the user-data transport
921/// (trait object). Accepts UDPv4, UDPv6, TCPv4, Shm. The concrete
922/// transport (UdpTransport/TcpTransport/ShmUserTransport) then returns
923/// `UnsupportedLocator` for kinds it does not itself speak;
924/// the filter here only prevents sending to clearly non-IP/SHM
925/// locators like UDS (for which we have no transport plugin).
926fn is_routable_user_locator(loc: &Locator) -> bool {
927    matches!(
928        loc.kind,
929        LocatorKind::UdpV4
930            | LocatorKind::UdpV6
931            | LocatorKind::Tcpv4
932            | LocatorKind::Tcpv6
933            | LocatorKind::Shm
934            | LocatorKind::Uds
935            | LocatorKind::Tsn
936    )
937}
938
939/// Computes the user-endpoint `EndpointSecurityInfo` mask from the governance
940/// protection kinds (DDS-Security 1.2 §10.4.1.2.6 / §9.4.2.4). The wire mask
941/// MUST match cyclone/FastDDS/OpenDDS byte-exactly, otherwise the peer rejects
942/// the endpoint match with "security_attributes mismatch".
943///
944/// - metadata=SIGN/ENCRYPT → IS_SUBMESSAGE_PROTECTED (+ plugin SUBMESSAGE_ENCRYPTED on ENCRYPT)
945/// - data=SIGN    → IS_PAYLOAD_PROTECTED
946/// - data=ENCRYPT → IS_PAYLOAD_PROTECTED | **IS_KEY_PROTECTED** (+ plugin PAYLOAD_ENCRYPTED)
947/// - liveliness=SIGN/ENCRYPT → **IS_LIVELINESS_PROTECTED** (§9.4.1.3: per-endpoint!)
948/// - topic enable_discovery_protection → IS_DISCOVERY_PROTECTED
949///
950/// is_key_protected follows §10.4.1.2.6 exclusively from the **DATA** protection
951/// and only on ENCRYPT — NOT from the metadata protection. is_liveliness_protected
952/// in contrast MUST be on every user endpoint as soon as liveliness_protection is active;
953/// cyclone compares the mask at endpoint match and otherwise rejects with
954/// "security_attributes mismatch" (0x..30 vs 0x..70).
955#[cfg(feature = "security")]
956fn compute_user_endpoint_attrs(
957    meta: ProtectionLevel,
958    data: ProtectionLevel,
959    discovery_protected: bool,
960    liveliness_protected: bool,
961    read_protected: bool,
962    write_protected: bool,
963) -> zerodds_rtps::endpoint_security_info::EndpointSecurityInfo {
964    use zerodds_rtps::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
965    let mut a = attrs::IS_VALID;
966    let mut p = plugin_attrs::IS_VALID;
967    if read_protected {
968        a |= attrs::IS_READ_PROTECTED;
969    }
970    if write_protected {
971        a |= attrs::IS_WRITE_PROTECTED;
972    }
973    if meta != ProtectionLevel::None {
974        a |= attrs::IS_SUBMESSAGE_PROTECTED;
975    }
976    if meta == ProtectionLevel::Encrypt {
977        p |= plugin_attrs::IS_SUBMESSAGE_ENCRYPTED;
978    }
979    if data != ProtectionLevel::None {
980        a |= attrs::IS_PAYLOAD_PROTECTED;
981    }
982    if data == ProtectionLevel::Encrypt {
983        a |= attrs::IS_KEY_PROTECTED;
984        p |= plugin_attrs::IS_PAYLOAD_ENCRYPTED;
985    }
986    if discovery_protected {
987        a |= attrs::IS_DISCOVERY_PROTECTED;
988    }
989    if liveliness_protected {
990        a |= attrs::IS_LIVELINESS_PROTECTED;
991    }
992    EndpointSecurityInfo {
993        endpoint_security_attributes: a,
994        plugin_endpoint_security_attributes: p,
995    }
996}
997
998#[cfg(all(test, feature = "security"))]
999mod endpoint_attr_tests {
1000    use super::compute_user_endpoint_attrs;
1001    use zerodds_rtps::endpoint_security_info::attrs;
1002    use zerodds_security_runtime::ProtectionLevel;
1003
1004    fn mask(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
1005        compute_user_endpoint_attrs(meta, data, false, false, false, false)
1006            .endpoint_security_attributes
1007    }
1008
1009    fn mask_liv(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
1010        compute_user_endpoint_attrs(meta, data, false, true, false, false)
1011            .endpoint_security_attributes
1012    }
1013
1014    #[test]
1015    fn liveliness_protected_sets_0x40_per_spec_9_4_1_3() {
1016        use ProtectionLevel::{Encrypt, None};
1017        let v = attrs::IS_VALID;
1018        let pay = attrs::IS_PAYLOAD_PROTECTED;
1019        let key = attrs::IS_KEY_PROTECTED;
1020        let liv = attrs::IS_LIVELINESS_PROTECTED;
1021        // liveliness=ENCRYPT + data=ENCRYPT → 0x..70 (cyclone's value at match).
1022        assert_eq!(mask_liv(None, Encrypt), v | pay | key | liv);
1023        // without liveliness → 0x..30, NO 0x40.
1024        assert_eq!(mask(None, Encrypt), v | pay | key);
1025        assert_eq!(mask_liv(None, None), v | liv);
1026    }
1027
1028    #[test]
1029    fn key_protected_follows_data_encrypt_per_spec_10_4_1_2_6() {
1030        use ProtectionLevel::{Encrypt, None, Sign};
1031        let v = attrs::IS_VALID;
1032        let sub = attrs::IS_SUBMESSAGE_PROTECTED;
1033        let pay = attrs::IS_PAYLOAD_PROTECTED;
1034        let key = attrs::IS_KEY_PROTECTED;
1035        // §10.4.1.2.6: is_key_protected follows ONLY data=ENCRYPT.
1036        // data=ENCRYPT → PAYLOAD|KEY (= cyclone's 0x30 in the common subset).
1037        assert_eq!(mask(None, Encrypt), v | pay | key);
1038        // data=SIGN → PAYLOAD, NO KEY.
1039        assert_eq!(mask(None, Sign), v | pay);
1040        // data=NONE → no payload/key bits.
1041        assert_eq!(mask(None, None), v);
1042        // KEY does NOT depend on metadata: meta=ENCRYPT/data=NONE → only SUBMESSAGE.
1043        assert_eq!(mask(Encrypt, None), v | sub);
1044        // meta=ENCRYPT + data=ENCRYPT → SUBMESSAGE|PAYLOAD|KEY (0x38).
1045        assert_eq!(mask(Encrypt, Encrypt), v | sub | pay | key);
1046    }
1047}
1048
1049/// Unicast targets for the WLP heartbeat fan-out (M-2): per discovered peer the
1050/// `metatraffic_unicast_locator` (fallback `default_unicast_locator`), filtered
1051/// to routable kinds. WLP is metatraffic (DDSI-RTPS §8.4.13); in multicast-
1052/// free environments (container/cloud) the pure multicast pulse never reaches the
1053/// peer reader → the lease expires although the peer is alive. The additional
1054/// unicast fan-out follows the SEDP locator model.
1055fn wlp_unicast_targets(peers: &[zerodds_discovery::spdp::DiscoveredParticipant]) -> Vec<Locator> {
1056    peers
1057        .iter()
1058        .filter_map(|dp| {
1059            dp.data
1060                .metatraffic_unicast_locator
1061                .or(dp.data.default_unicast_locator)
1062        })
1063        .filter(is_routable_user_locator)
1064        .collect()
1065}
1066
1067/// Extracts the IPv4 address from a `Locator` (UDP-V4).
1068/// `None` for SHM/UDS/IPv6.
1069#[cfg(feature = "security")]
1070fn ipv4_from_locator(loc: &Locator) -> Option<[u8; 4]> {
1071    if loc.kind != LocatorKind::UdpV4 {
1072        return None;
1073    }
1074    Some([
1075        loc.address[12],
1076        loc.address[13],
1077        loc.address[14],
1078        loc.address[15],
1079    ])
1080}
1081
1082impl core::fmt::Debug for RuntimeConfig {
1083    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1084        let mut dbg = f.debug_struct("RuntimeConfig");
1085        dbg.field("tick_period", &self.tick_period)
1086            .field("spdp_period", &self.spdp_period)
1087            .field("spdp_multicast_group", &self.spdp_multicast_group)
1088            .field("multicast_interface", &self.multicast_interface);
1089        #[cfg(feature = "security")]
1090        {
1091            dbg.field("security", &self.security.as_ref().map(|_| "<gate>"));
1092            dbg.field(
1093                "security_logger",
1094                &self.security_logger.as_ref().map(|_| "<logger>"),
1095            );
1096        }
1097        dbg.finish()
1098    }
1099}
1100
1101impl Default for RuntimeConfig {
1102    fn default() -> Self {
1103        // Env hook for bench tuning: ZERODDS_TICK_PERIOD_MS=N → overrides
1104        // the 5ms default. High (e.g. 1000) relieves the write hot path of
1105        // the periodic HB/tick overhead and makes spread spikes from tick
1106        // preemption visible. Production: do not set; the default 5 ms is
1107        // spec-compliant.
1108        let tick = std::env::var("ZERODDS_TICK_PERIOD_MS")
1109            .ok()
1110            .and_then(|s| s.parse::<u64>().ok())
1111            .map(Duration::from_millis)
1112            .unwrap_or(DEFAULT_TICK_PERIOD);
1113        // C3 WiFi-robust discovery — initial-announcement burst. Env overrides:
1114        // `ZERODDS_INITIAL_ANNOUNCE_COUNT` (0 disables) +
1115        // `ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS`.
1116        let initial_announce_count = std::env::var("ZERODDS_INITIAL_ANNOUNCE_COUNT")
1117            .ok()
1118            .and_then(|s| s.parse::<u32>().ok())
1119            .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_COUNT);
1120        let initial_announce_period = std::env::var("ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS")
1121            .ok()
1122            .and_then(|s| s.parse::<u64>().ok())
1123            .map(Duration::from_millis)
1124            .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_PERIOD);
1125        Self {
1126            tick_period: tick,
1127            spdp_period: DEFAULT_SPDP_PERIOD,
1128            initial_announce_count,
1129            initial_announce_period,
1130            // Env override `ZERODDS_SPDP_MC_GROUP` (IPv4) of the SPDP
1131            // multicast group. Two processes with different groups do NOT
1132            // see each other via multicast → enables a multicast-free C1
1133            // e2e proof (discovery then only via ZERODDS_PEERS). Default is
1134            // the spec group.
1135            spdp_multicast_group: std::env::var("ZERODDS_SPDP_MC_GROUP")
1136                .ok()
1137                .and_then(|s| s.parse::<Ipv4Addr>().ok())
1138                .unwrap_or_else(|| Ipv4Addr::from(SPDP_DEFAULT_MULTICAST_ADDRESS)),
1139            // Interface pinning (Cyclone `NetworkInterface`/FastDDS
1140            // whitelist equivalent): `ZERODDS_INTERFACE=<ipv4>` forces
1141            // announce + bind on this interface. Default UNSPECIFIED = auto
1142            // (route probe). Critical on multi-homed hosts (VPN/Docker/
1143            // macOS bridge100), where the auto choice may announce the
1144            // wrong interface.
1145            multicast_interface: std::env::var("ZERODDS_INTERFACE")
1146                .ok()
1147                .and_then(|s| s.parse::<Ipv4Addr>().ok())
1148                .unwrap_or(Ipv4Addr::UNSPECIFIED),
1149            // Opt-in multicast allowlist. Empty (unset) = no restriction.
1150            multicast_allowlist: std::env::var("ZERODDS_MULTICAST_ALLOWLIST")
1151                .ok()
1152                .map(|s| {
1153                    s.split(',')
1154                        .filter_map(|t| t.trim().parse::<Ipv4Addr>().ok())
1155                        .collect()
1156                })
1157                .unwrap_or_default(),
1158            // Multicast send on by default; `ZERODDS_NO_MULTICAST` (any
1159            // non-empty value) turns it off → pure unicast discovery.
1160            spdp_multicast_send: std::env::var("ZERODDS_NO_MULTICAST")
1161                .map(|v| v.is_empty())
1162                .unwrap_or(true),
1163            // A1: off by default; env `ZERODDS_DISCOVERY_SERVER` (any non-empty
1164            // value) or `RuntimeConfig::discovery_server()` turns the relay on.
1165            discovery_server: std::env::var("ZERODDS_DISCOVERY_SERVER")
1166                .map(|v| !v.is_empty())
1167                .unwrap_or(false),
1168            // C3: 16 MiB default (suitable for ROS PointCloud2/Image),
1169            // env override `ZERODDS_MAX_SAMPLE_BYTES`.
1170            max_reassembly_sample_bytes: std::env::var("ZERODDS_MAX_SAMPLE_BYTES")
1171                .ok()
1172                .and_then(|s| s.parse::<usize>().ok())
1173                .unwrap_or(16 * 1024 * 1024),
1174            // Programmatic default empty. The env `ZERODDS_PEERS` is
1175            // expanded domain-aware only in `DcpsRuntime::start` and merged
1176            // with this field into the effective peer list.
1177            initial_peers: Vec::new(),
1178            user_transport: None,
1179            user_transports: alloc::vec::Vec::new(),
1180            #[cfg(feature = "security")]
1181            security: None,
1182            #[cfg(feature = "security")]
1183            security_logger: None,
1184            #[cfg(feature = "security")]
1185            interface_bindings: Vec::new(),
1186            announce_secure_endpoints: false,
1187            // Env hook for bench/FastDDS interop: ZERODDS_SECURE_SPDP=1 turns
1188            // on the reliable secure SPDP channel (0xff0101). Production sets this
1189            // explicitly via the SecurityProfile/config.
1190            enable_secure_spdp: std::env::var("ZERODDS_SECURE_SPDP").ok().as_deref() == Some("1"),
1191            wlp_period: Duration::ZERO,
1192            participant_lease_duration: Duration::from_secs(100),
1193            user_data: Vec::new(),
1194            observability: zerodds_foundation::observability::null_sink(),
1195            recv_thread_priority: None,
1196            tick_thread_priority: None,
1197            recv_thread_cpus: None,
1198            tick_thread_cpus: None,
1199            extra_recv_threads: 0,
1200            // D.5g — default `[XCDR1, XCDR2]` (legacy-first, max interop).
1201            // Env-var override `ZERODDS_DATA_REPR_OFFER` as a comma list
1202            // ("XCDR1", "XCDR2", "XCDR1,XCDR2", "XCDR2,XCDR1"). Cross-vendor
1203            // benches against strict-matching vendors (RTI) need XCDR2-only
1204            // so that every wire match happens.
1205            data_representation_offer: parse_data_repr_offer_env().unwrap_or_else(|| {
1206                zerodds_rtps::publication_data::data_representation::DEFAULT_OFFER.to_vec()
1207            }),
1208            data_rep_match_mode:
1209                zerodds_rtps::publication_data::data_representation::DataRepMatchMode::default(),
1210            external_tick: false,
1211            // D.5e Phase 3 — the event-driven deadline-heap scheduler is the
1212            // DEFAULT tick (Phase C, 2026-06-14): it parks until the next due
1213            // deadline / a write-recv raise instead of polling every 5 ms (~17×
1214            // fewer idle iterations, lower tail latency, identical wire output).
1215            // Verified cross-vendor secured (data-enc + rtps-enc all pairs) +
1216            // same_host_e2e + latency_assertions on codepit. Escape hatch:
1217            // `ZERODDS_SCHEDULER_TICK=0` restores the classic fixed-period
1218            // `tick_loop`.
1219            scheduler_tick: std::env::var("ZERODDS_SCHEDULER_TICK")
1220                .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
1221                .unwrap_or(true),
1222        }
1223    }
1224}
1225
1226impl RuntimeConfig {
1227    /// Whether `group` may be joined/used for multicast under this config. An
1228    /// empty [`Self::multicast_allowlist`] allows every group (the opt-in is
1229    /// off); a non-empty list allows only its members.
1230    #[must_use]
1231    pub fn multicast_allowed(&self, group: Ipv4Addr) -> bool {
1232        self.multicast_allowlist.is_empty() || self.multicast_allowlist.contains(&group)
1233    }
1234
1235    /// Apply a [`SecurityBundle`](zerodds_security_runtime::SecurityBundle):
1236    /// wires its security-event logger into [`Self::security_logger`] and, if
1237    /// the bundle carries a [`SecurityProfile`](zerodds_security_runtime::SecurityProfile),
1238    /// its gate into [`Self::security`]. Convenience for the common
1239    /// `SecurityBundle::builder()…build()` flow so callers don't have to set
1240    /// the two fields by hand.
1241    #[cfg(feature = "security")]
1242    #[must_use]
1243    pub fn with_security_bundle(
1244        mut self,
1245        bundle: &zerodds_security_runtime::SecurityBundle,
1246    ) -> Self {
1247        if let Some(logger) = bundle.logging_plugin() {
1248            self.security_logger = Some(logger);
1249        }
1250        if let Some(profile) = bundle.security_profile() {
1251            self.security = Some(profile.gate.clone());
1252        }
1253        self
1254    }
1255
1256    /// Materialize a security-event logger from `dds.sec.log.*` properties and
1257    /// wire it into [`Self::security_logger`]. This is the DDS-Security
1258    /// spec-style alternative to handing a logger object in directly (see
1259    /// [`Self::with_security_bundle`]): the participant carries
1260    /// `dds.sec.log.plugin = "stderr,jsonl"` (+ `dds.sec.log.*` parameters) on
1261    /// its [`PropertyQosPolicy`](zerodds_qos::PropertyQosPolicy), and the
1262    /// runtime builds the fan-out logger from them.
1263    ///
1264    /// No-op when `dds.sec.log.plugin` is absent. Errors if a selected sink is
1265    /// misconfigured (e.g. `jsonl` without `dds.sec.log.jsonl.path`).
1266    #[cfg(feature = "security")]
1267    pub fn with_security_log_properties(
1268        mut self,
1269        property: &zerodds_qos::PropertyQosPolicy,
1270    ) -> core::result::Result<Self, zerodds_security_logging::LogConfigError> {
1271        let pairs: alloc::vec::Vec<(&str, &str)> = property.iter().collect();
1272        if let Some(logger) = zerodds_security_logging::logging_plugin_from_properties(&pairs)? {
1273            self.security_logger = Some(alloc::sync::Arc::from(logger));
1274        }
1275        Ok(self)
1276    }
1277
1278    /// C4: robotics-capable defaults for **out-of-the-box ROS-2 interop**.
1279    /// Saves the manual env tuning otherwise needed for real ROS-2 nodes.
1280    /// Specifically, compared to [`RuntimeConfig::default`]:
1281    /// - **`data_representation_offer = [XCDR1, XCDR2]`**: `rmw_cyclonedds`/
1282    ///   `rmw_fastrtps` write XCDR1 for final/simple types (e.g.
1283    ///   `std_msgs/String`). An XCDR2-only reader does not match an XCDR1
1284    ///   writer — so the ROS reader here offers both legacy-first
1285    ///   (tolerant match is already the default). This is the clean,
1286    ///   ROS-specific variant of the `ZERODDS_DATA_REPR_OFFER` env
1287    ///   workaround, WITHOUT changing the global `DEFAULT_OFFER`
1288    ///   (XCDR2-only, deliberately for FastDDS/OpenDDS XCDR2 readers).
1289    ///
1290    /// The ROS-realistic reassembly cap (16 MiB, PointCloud2/Image) is
1291    /// already the global default and is carried over here.
1292    #[must_use]
1293    pub fn ros_defaults() -> Self {
1294        use zerodds_rtps::publication_data::data_representation as dr;
1295        Self {
1296            data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1297            ..Self::default()
1298        }
1299    }
1300
1301    /// C6 multi-robot / WAN / cross-subnet profile.
1302    ///
1303    /// A named profile for fleets that span subnets, the cloud, or WiFi —
1304    /// environments that drop IP multicast, so SPDP discovery cannot rely on
1305    /// the multicast beacon. It is the [`ros_defaults`](Self::ros_defaults)
1306    /// representation offer **plus**:
1307    ///
1308    /// - **Multicast-free discovery** (`spdp_multicast_send = false`):
1309    ///   participants find each other purely through unicast initial peers,
1310    ///   regardless of the `ZERODDS_NO_MULTICAST` env. Set the peers via
1311    ///   `ZERODDS_PEERS` (a comma list of `ip` or `ip:port`); a port-less
1312    ///   `ip` is expanded to the well-known SPDP unicast ports of the first
1313    ///   N participant indices (`ZERODDS_MAX_PEER_PARTICIPANTS`).
1314    /// - **WAN-tolerant liveliness**: a longer participant lease (300 s vs
1315    ///   the 100 s spec default) so transient cross-subnet RTT spikes or
1316    ///   brief link drops do not trigger a false liveliness loss.
1317    ///
1318    /// **Domain isolation** is the caller's lever: pass a fleet-dedicated
1319    /// `domain_id` to [`DcpsRuntime::start`] to keep robots off the default
1320    /// domain 0. The profile deliberately does not pick a domain for you.
1321    ///
1322    /// ```
1323    /// use zerodds_dcps::runtime::RuntimeConfig;
1324    /// let cfg = RuntimeConfig::multi_robot();
1325    /// assert!(!cfg.spdp_multicast_send); // unicast-only discovery
1326    /// ```
1327    pub fn multi_robot() -> Self {
1328        use zerodds_rtps::publication_data::data_representation as dr;
1329        Self {
1330            data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1331            spdp_multicast_send: false,
1332            participant_lease_duration: Duration::from_secs(300),
1333            ..Self::default()
1334        }
1335    }
1336
1337    /// A1 — **discovery-server** profile (the server side). Multicast-free, with
1338    /// the SPDP relay on ([`Self::discovery_server`]): clients point their
1339    /// `initial_peers`/`ZERODDS_PEERS` at this one well-known address and the
1340    /// server bridges their participant discovery, so N clients find each other
1341    /// without an O(N²) peer list or multicast. SEDP (incl. ROS-2 Action
1342    /// endpoints) stays direct peer-to-peer — no SEDP proxy, no Action breakage.
1343    ///
1344    /// Clients run with [`RuntimeConfig::multi_robot`] (or any multicast-free
1345    /// config) and set `ZERODDS_PEERS` to the server's address.
1346    ///
1347    /// ```
1348    /// use zerodds_dcps::runtime::RuntimeConfig;
1349    /// let server = RuntimeConfig::discovery_server();
1350    /// assert!(server.discovery_server);
1351    /// assert!(!server.spdp_multicast_send); // unicast-only
1352    /// ```
1353    pub fn discovery_server() -> Self {
1354        Self {
1355            discovery_server: true,
1356            ..Self::multi_robot()
1357        }
1358    }
1359}
1360
1361/// Parse the `ZERODDS_DATA_REPR_OFFER` env var. Values: "XCDR1", "XCDR2",
1362/// or a comma list. None if the env var is missing or invalid.
1363fn parse_data_repr_offer_env() -> Option<Vec<i16>> {
1364    let s = std::env::var("ZERODDS_DATA_REPR_OFFER").ok()?;
1365    parse_data_repr_offer_str(&s)
1366}
1367
1368/// Computes the **well-known** SPDP unicast discovery port for a
1369/// domain + participant index. Formula (DDSI-RTPS 2.5 §9.6.1.4.1):
1370///   port = PB + DG·domain + d1 + PG·pid = 7400 + 250·domain + 10 + 2·pid
1371///
1372/// This lets a configured unicast initial peer (multicast-free discovery)
1373/// reach a participant deterministically WITHOUT having found it via
1374/// multicast first. Defined locally in `dcps` to avoid touching
1375/// `crates/rtps` (spec constants as literals).
1376#[must_use]
1377fn spdp_unicast_port(domain_id: u32, participant_id: u32) -> u32 {
1378    7400 + 250 * domain_id + 10 + 2 * participant_id
1379}
1380
1381/// Default number of participant indices a port-less initial peer is
1382/// expanded to (Cyclone equivalent: `MaxAutoParticipantIndex`). The
1383/// beacon thereby reaches the first N participants of the peer host via
1384/// their well-known SPDP unicast ports. Overridable via the env
1385/// `ZERODDS_MAX_PEER_PARTICIPANTS` (e.g. for dense multi-robot / >10
1386/// participants-per-host scenarios). Cap 120 (= the well-known-port
1387/// allocation window).
1388const INITIAL_PEER_MAX_PARTICIPANTS: u32 = 10;
1389
1390/// Effective peer-expansion limit: env `ZERODDS_MAX_PEER_PARTICIPANTS`
1391/// or [`INITIAL_PEER_MAX_PARTICIPANTS`], clamped to 1..=120.
1392fn initial_peer_max_participants() -> u32 {
1393    std::env::var("ZERODDS_MAX_PEER_PARTICIPANTS")
1394        .ok()
1395        .and_then(|s| s.parse::<u32>().ok())
1396        .unwrap_or(INITIAL_PEER_MAX_PARTICIPANTS)
1397        .clamp(1, 120)
1398}
1399
1400/// C1 multicast-free discovery: parses the env `ZERODDS_PEERS` (comma
1401/// list of `ip` or `ip:port`) into SPDP unicast initial-peer locators for
1402/// `domain_id`. Empty/invalid → empty list.
1403fn parse_initial_peers_env(domain_id: u32) -> Vec<Locator> {
1404    let mut out = Vec::new();
1405    let max = initial_peer_max_participants();
1406    if let Ok(s) = std::env::var("ZERODDS_PEERS") {
1407        for entry in s.split(',') {
1408            expand_initial_peer(entry.trim(), domain_id, max, &mut out);
1409        }
1410    }
1411    out
1412}
1413
1414/// Expands a single peer spec into locator(s) and appends them to `out`.
1415/// `ip:port` → exact locator. Just `ip` → well-known SPDP unicast ports
1416/// of participant indices `0..max_participants` (Spec §9.6.1.4.1).
1417/// Invalid specs are ignored.
1418fn expand_initial_peer(spec: &str, domain_id: u32, max_participants: u32, out: &mut Vec<Locator>) {
1419    if spec.is_empty() {
1420        return;
1421    }
1422    if let Some((ip_s, port_s)) = spec.rsplit_once(':') {
1423        if let (Ok(ip), Ok(port)) = (ip_s.parse::<Ipv4Addr>(), port_s.parse::<u16>()) {
1424            out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1425            return;
1426        }
1427    }
1428    if let Ok(ip) = spec.parse::<Ipv4Addr>() {
1429        for pid in 0..max_participants {
1430            if let Ok(port) = u16::try_from(spdp_unicast_port(domain_id, pid)) {
1431                out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1432            }
1433        }
1434    }
1435}
1436
1437/// Pure parser for the `ZERODDS_DATA_REPR_OFFER` syntax (testable without
1438/// env). Returns the DataRepresentationId list with the **spec values**
1439/// `XCDR=0`, `XCDR2=2` (XTypes 1.3 §7.6.3.1.2) — NOT version numbers.
1440/// `None` on empty/invalid input.
1441fn parse_data_repr_offer_str(s: &str) -> Option<Vec<i16>> {
1442    use zerodds_rtps::publication_data::data_representation as dr;
1443    let mut out = Vec::new();
1444    for tok in s.split(',').map(str::trim) {
1445        let v = match tok.to_ascii_uppercase().as_str() {
1446            "XCDR1" | "XCDR" | "1" => dr::XCDR,
1447            "XCDR2" | "2" => dr::XCDR2,
1448            _ => return None,
1449        };
1450        out.push(v);
1451    }
1452    if out.is_empty() { None } else { Some(out) }
1453}
1454
1455// ---------------------------------------------------------------------------
1456// Security-gate helpers
1457// ---------------------------------------------------------------------------
1458
1459/// Pull outbound UDP bytes through the security gate (when configured).
1460/// Without the `security` feature or without a gate: pass-through (clone
1461/// as Vec).
1462///
1463/// Errors in the gate are logged silently and the packet is **not** sent —
1464/// better to drop than leak plaintext.
1465/// DDS-Security 8.4.2.4: the RTPS message protection (message-level SRTPS)
1466/// does NOT apply to bootstrap traffic that must flow BEFORE the participant
1467/// crypto-key exchange: SPDP (participant discovery, to everyone) and the
1468/// ParticipantStatelessMessage (auth handshake). Wrapping them in SRTPS would
1469/// mean a not-yet-authenticated peer could not decrypt them
1470/// (no key) -> discovery/auth breaks (match timeout pub=0 sub=0). Detection
1471/// via the writer EntityId of the DATA/DATA_FRAG submessages.
1472#[cfg(feature = "security")]
1473fn rtps_message_protection_exempt(
1474    bytes: &[u8],
1475    discovery_plain: bool,
1476    liveliness_plain: bool,
1477) -> bool {
1478    use zerodds_rtps::wire_types::EntityId;
1479    // Bootstrap endpoints (§8.4.2.4): SPDP/Stateless/Volatile ALWAYS flow
1480    // plain (before/during key exchange resp. their own submessage protection).
1481    // Discovery plane (SEDP pub/sub, TypeLookup) is plain when discovery_
1482    // protection_kind=NONE; WLP (ParticipantMessage) plain when liveliness_
1483    // protection_kind=NONE. cyclone<->cyclone reference capture: under rtps_
1484    // protection=ENCRYPT + discovery=NONE cyclone sends the ENTIRE discovery
1485    // plane (DATA+HEARTBEAT+ACKNACK) PLAINTEXT — only user DATA is SRTPS-
1486    // wrapped. ZeroDDS must mirror this, otherwise it drops cyclone's plain
1487    // SubscriptionData as legacy_blocked -> no user-endpoint match.
1488    let entity_exempt = |e: EntityId| -> bool {
1489        matches!(
1490            e,
1491            EntityId::SPDP_BUILTIN_PARTICIPANT_WRITER
1492                | EntityId::SPDP_BUILTIN_PARTICIPANT_READER
1493                | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
1494                | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
1495                | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
1496                | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
1497        ) || (discovery_plain
1498            && matches!(
1499                e,
1500                EntityId::SEDP_BUILTIN_PUBLICATIONS_WRITER
1501                    | EntityId::SEDP_BUILTIN_PUBLICATIONS_READER
1502                    | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_WRITER
1503                    | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_READER
1504                    | EntityId::TL_SVC_REQ_WRITER
1505                    | EntityId::TL_SVC_REQ_READER
1506                    | EntityId::TL_SVC_REPLY_WRITER
1507                    | EntityId::TL_SVC_REPLY_READER
1508            ))
1509            || (liveliness_plain
1510                && matches!(
1511                    e,
1512                    EntityId::BUILTIN_PARTICIPANT_MESSAGE_WRITER
1513                        | EntityId::BUILTIN_PARTICIPANT_MESSAGE_READER
1514                ))
1515    };
1516    let Ok(parsed) = decode_datagram(bytes) else {
1517        return false;
1518    };
1519    // Datagram exempt if it has at least one relevant submessage AND
1520    // ALL relevant ones are exempt (.all) — otherwise a bundled
1521    // exempt+non-exempt datagram leaks the protection-worthy submessage.
1522    let relevant: alloc::vec::Vec<bool> = parsed
1523        .submessages
1524        .iter()
1525        .filter_map(|sm| match sm {
1526            ParsedSubmessage::Data(d) => {
1527                Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1528            }
1529            ParsedSubmessage::DataFrag(d) => {
1530                Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1531            }
1532            ParsedSubmessage::Heartbeat(h) => {
1533                Some(entity_exempt(h.reader_id) || entity_exempt(h.writer_id))
1534            }
1535            ParsedSubmessage::AckNack(a) => {
1536                Some(entity_exempt(a.reader_id) || entity_exempt(a.writer_id))
1537            }
1538            ParsedSubmessage::Gap(g) => {
1539                Some(entity_exempt(g.reader_id) || entity_exempt(g.writer_id))
1540            }
1541            ParsedSubmessage::NackFrag(n) => {
1542                Some(entity_exempt(n.reader_id) || entity_exempt(n.writer_id))
1543            }
1544            // SEC_PREFIX (Kx-Volatile, inner writer-id encrypted) -> exempt.
1545            ParsedSubmessage::Unknown { id: 0x31, .. } => Some(true),
1546            // Framing (INFO_DST/INFO_TS/...) -> neutral.
1547            _ => None,
1548        })
1549        .collect();
1550    !relevant.is_empty() && relevant.iter().all(|&b| b)
1551}
1552
1553#[cfg(feature = "security")]
1554fn secure_outbound_bytes<'a>(
1555    rt: &DcpsRuntime,
1556    bytes: &'a [u8],
1557) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1558    match &rt.config.security {
1559        // OUTBOUND is spec-strict (DDS-Security 8.4.2.4 Table 27 is_rtps_protected):
1560        // under rtps_protection the ENTIRE RTPS message is SRTPS-wrapped; ONLY the
1561        // "separate messages" (SPDP/Stateless/Volatile) flow plain. SEDP/WLP/
1562        // TypeLookup are NOT among them and must be wrapped — independent
1563        // of discovery_/liveliness_protection (those are orthogonal submessage layers).
1564        // -> discovery_plain=false, liveliness_plain=false forces the wrap.
1565        // OpenDDS' RtpsUdpReceiveStrategy::check_encoded otherwise drops every plain SEDP
1566        // as "Full message requires protection". cyclone does take the shortcut
1567        // (sends SEDP plain), but accepts wrapped SEDP inbound without issue.
1568        // The INBOUND path (secure_inbound_bytes) deliberately stays lenient and still
1569        // accepts cyclone's plain SEDP — the asymmetry is intentional.
1570        Some(gate) if rtps_message_protection_exempt(bytes, false, false) => {
1571            let _ = gate;
1572            Some(alloc::borrow::Cow::Borrowed(bytes))
1573        }
1574        Some(gate) => gate
1575            .transform_outbound(bytes)
1576            .ok()
1577            .map(alloc::borrow::Cow::Owned),
1578        None => Some(alloc::borrow::Cow::Borrowed(bytes)),
1579    }
1580}
1581
1582// Security off: no clone — the caller borrows the datagram bytes
1583// directly (copy 6 of the zero-copy audit eliminated).
1584#[cfg(not(feature = "security"))]
1585fn secure_outbound_bytes<'a>(
1586    _rt: &DcpsRuntime,
1587    bytes: &'a [u8],
1588) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1589    Some(alloc::borrow::Cow::Borrowed(bytes))
1590}
1591
1592/// Pull inbound UDP bytes through the security gate.
1593///
1594/// Expects an RTPS header with the GuidPrefix at bytes 8..20.
1595/// `None` → drop the packet.
1596///
1597/// Security: drop reasons are forwarded, differentiated, to the
1598/// configured `LoggingPlugin`:
1599/// * `Malformed`       → `Error`
1600/// * `LegacyBlocked`   → `Error`
1601/// * `PolicyViolation` → `Warning` (possible tampering)
1602/// * `CryptoError`     → `Warning` (tag mismatch, replay etc.)
1603#[cfg(feature = "security")]
1604fn secure_inbound_bytes<'a>(
1605    rt: &DcpsRuntime,
1606    bytes: &'a [u8],
1607    iface: &NetInterface,
1608) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1609    use zerodds_security_runtime::{InboundVerdict, LogLevel};
1610    let Some(gate) = &rt.config.security else {
1611        return Some(alloc::borrow::Cow::Borrowed(bytes));
1612    };
1613    // DDS-Security 8.4.2.4 (symmetric to outbound): SPDP/Stateless are
1614    // message-protection-exempt and ALWAYS arrive plain (also from cyclone). Without
1615    // this exception classify_inbound discards plain SPDP on the WAN iface under
1616    // rtps_protection as LegacyBlocked -> no discovery (match timeout).
1617    {
1618        let looks_srtps = bytes.len() > 20usize && bytes[20usize] == 0x33;
1619        if !looks_srtps
1620            && rtps_message_protection_exempt(
1621                bytes,
1622                gate.discovery_protection().unwrap_or(ProtectionLevel::None)
1623                    == ProtectionLevel::None,
1624                gate.liveliness_protection()
1625                    .unwrap_or(ProtectionLevel::None)
1626                    == ProtectionLevel::None,
1627            )
1628        {
1629            // SRTPS-exempt. BUT metadata_protection user DATA carries per-submessage
1630            // SEC_PREFIX/BODY/POSTFIX (§9.5.3.3, NO SRTPS) — that must still be
1631            // decrypted per-endpoint here, otherwise the reader gets the
1632            // SEC wrapper instead of the DATA. Volatile-Kx-SEC fails with None
1633            // (key_id not in user-remote_by_key_id) -> unchanged for the
1634            // Volatile handler in the metatraffic loop.
1635            if walk_submessages(bytes)
1636                .iter()
1637                .any(|(id, _, _)| *id == SMID_SEC_PREFIX)
1638            {
1639                let mut pk = [0u8; 12];
1640                pk.copy_from_slice(&bytes[8..20]);
1641                if let Some(mut dg) = unprotect_user_datagram(rt, bytes, &pk) {
1642                    match unprotect_user_payload(rt, &dg) {
1643                        PayloadDecode::Decoded(clear) => dg = clear,
1644                        PayloadDecode::Failed => return None,
1645                        PayloadDecode::NotEncrypted => {}
1646                    }
1647                    return Some(alloc::borrow::Cow::Owned(dg));
1648                }
1649            }
1650            return Some(alloc::borrow::Cow::Borrowed(bytes));
1651        }
1652    }
1653    let verdict = gate.classify_inbound(bytes, iface);
1654    let category = verdict.category();
1655    let (level, message): (LogLevel, String) = match &verdict {
1656        InboundVerdict::Accept(out) => {
1657            // Cross-vendor user DATA: cyclone protects the DATA submessage as a
1658            // SEC_PREFIX/BODY/POSTFIX sequence (metadata_protection=ENCRYPT). Before
1659            // the submessage parse, transform it back with the sender's data key
1660            // (GuidPrefix = bytes[8..20]). `unprotect_user_datagram` returns
1661            // `None` when no SEC_* sequence is present → normal accept path.
1662            // OUTER layer first (metadata_protection, SEC_PREFIX/BODY/
1663            // POSTFIX), then the INNER one (data_protection, encrypted
1664            // SerializedPayload §9.5.3.3.1). Both can be active at once
1665            // (full secure profile); each returns `None` when its layer
1666            // is not present -> then the datagram stays unchanged.
1667            let mut dg: alloc::vec::Vec<u8> = out.clone();
1668            if dg.len() >= 20 {
1669                let mut pk = [0u8; 12];
1670                pk.copy_from_slice(&dg[8..20]);
1671                if let Some(clear) = unprotect_user_datagram(rt, &dg, &pk) {
1672                    dg = clear;
1673                }
1674            }
1675            match unprotect_user_payload(rt, &dg) {
1676                PayloadDecode::Decoded(clear) => dg = clear,
1677                // Undecodable encrypted payload -> discard the datagram
1678                // (no ciphertext garbage to the reader; reliable re-send resp. another
1679                // copy delivers the sample later).
1680                PayloadDecode::Failed => return None,
1681                PayloadDecode::NotEncrypted => {}
1682            }
1683            return Some(alloc::borrow::Cow::Owned(dg));
1684        }
1685        InboundVerdict::Malformed => (
1686            LogLevel::Error,
1687            alloc::format!(
1688                "inbound datagram too short ({} bytes, iface={:?})",
1689                bytes.len(),
1690                iface
1691            ),
1692        ),
1693        InboundVerdict::LegacyBlocked => (
1694            LogLevel::Error,
1695            alloc::format!(
1696                "legacy plaintext peer on protected domain \
1697                 (iface={iface:?}, allow_unauthenticated_participants=false)"
1698            ),
1699        ),
1700        InboundVerdict::PolicyViolation(msg) => {
1701            (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1702        }
1703        InboundVerdict::CryptoError(msg) => {
1704            (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1705        }
1706    };
1707    if let Some(logger) = &rt.config.security_logger {
1708        // Participant ident: GuidPrefix (or 0-padding for Malformed).
1709        let mut participant = [0u8; 16];
1710        if bytes.len() >= 20 {
1711            participant[..12].copy_from_slice(&bytes[8..20]);
1712        }
1713        logger.log(level, participant, category, &message);
1714    }
1715    None
1716}
1717
1718#[cfg(not(feature = "security"))]
1719fn secure_inbound_bytes<'a>(
1720    _rt: &DcpsRuntime,
1721    bytes: &'a [u8],
1722) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1723    Some(alloc::borrow::Cow::Borrowed(bytes))
1724}
1725
1726/// Default interface class for inbound dispatch when the socket does not
1727/// belong to the `outbound_pool`. In the v1.4 setup (without
1728/// `interface_bindings`), all packets run through `user_unicast` and are
1729/// classified as `Wan` — the most conservative assumption (protection
1730/// rules apply as in the single-interface case).
1731#[cfg(feature = "security")]
1732const DEFAULT_INBOUND_IFACE: NetInterface = NetInterface::Wan;
1733
1734/// Per-reader outbound transform.
1735///
1736/// Looks up in the writer slot which `ProtectionLevel` the matched reader
1737/// expects at the given `target` locator, then pulls the datagram through
1738/// the security gate individually. This way each reader gets a wire
1739/// payload matching its security profile (Legacy=plain, Fast=Sign,
1740/// Secure=Encrypt).
1741///
1742/// Fallback paths:
1743/// * No security gate configured → passthrough.
1744/// * No `locator_to_peer` entry (reader not yet matched via SEDP) →
1745///   `transform_outbound` with the domain rule — that is the homogeneous
1746///   v1.4 path.
1747/// * The gate returns an error → `None` (the caller drops — better no
1748///   plaintext leak).
1749#[cfg(feature = "security")]
1750fn secure_outbound_for_target(
1751    rt: &DcpsRuntime,
1752    writer_eid: EntityId,
1753    bytes: &[u8],
1754    target: &Locator,
1755) -> Option<Vec<u8>> {
1756    let Some(gate) = &rt.config.security else {
1757        return Some(bytes.to_vec());
1758    };
1759    // FU2 S3: fallback level from our own governance (data_protection_
1760    // kind), in case the matched reader did not announce an explicit SEDP
1761    // security_info level. This way user data to an authenticated peer is
1762    // encrypted per our own governance, while SPDP/SEDP metatraffic
1763    // bootstraps plaintext over rtps_protection_kind=NONE.
1764    // Governance `data_protection` is a FLOOR, not a mere fallback: a
1765    // per-reader level can only STRENGTHEN (e.g. legacy plaintext is only
1766    // allowed if the domain policy itself permits plaintext), never fall
1767    // below the domain policy. Otherwise a matched-but-not-authenticated
1768    // peer (foreign CA, SEDP match over plaintext discovery,
1769    // reader_protection=None) leaks plaintext user data.
1770    let gov_data_level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1771    // metadata_protection (§8.4.2.4 / §9.5.3.3): EVERY writer submessage (DATA,
1772    // HEARTBEAT, GAP) is SEC_PREFIX/BODY/POSTFIX-wrapped per-submessage —
1773    // TARGET-INDEPENDENT, since the per-endpoint writer key is local (the peer fetches
1774    // it via datawriter_crypto_token). Must take effect BEFORE the locator-based reader
1775    // resolution: otherwise tick HEARTBEATs/GAPs to not-yet-locator-
1776    // matched targets fall into the None branch -> with rtps=NONE PLAIN -> leak + no
1777    // reliable recovery (breaks already zero<->zero). data_protection (inner
1778    // payload layer) first, then the outer submessage layer.
1779    if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1780        let inner = if gov_data_level != ProtectionLevel::None {
1781            protect_user_payload(rt, bytes)?
1782        } else {
1783            bytes.to_vec()
1784        };
1785        let meta_sec = protect_user_datagram(rt, &inner)?;
1786        // Under rtps_protection message-level SRTPS MUST additionally go on top —
1787        // BOTH layers, like cyclone<->cyclone. Without it the peer would see the
1788        // metadata-SEC-DATA as "clear submsg from protected src" and discard it.
1789        if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1790            return gate.transform_outbound(&meta_sec).ok();
1791        }
1792        return Some(meta_sec);
1793    }
1794    let resolved = rt.writer_slot(writer_eid).and_then(|arc| {
1795        arc.lock().ok().and_then(|slot| {
1796            let pk = slot.locator_to_peer.get(target).copied()?;
1797            // An EXPLICITLY negotiated per-reader level is respected: a
1798            // legacy-v1.4 reader has reader_protection=None and MUST get plaintext,
1799            // otherwise it cannot decode (heterogeneous domain). Only
1800            // when NO entry exists (matched via plaintext discovery, but
1801            // no level negotiated -> potentially unauthenticated) does the
1802            // governance data_protection FLOOR apply as leak protection.
1803            // Governance data_protection is a FLOOR (§8.4.2.4, memory-documented):
1804            // a per-reader level can only STRENGTHEN, never fall below the domain
1805            // policy. A reader discovered via secure SEDP whose security_info parses
1806            // to `Some(None)` (no is_payload_protected bit detected, discovery=
1807            // ENCRYPT) would otherwise yield level=None -> Some(None) arm -> PLAINTEXT
1808            // leak, although the domain requires data_protection=ENCRYPT (disc-data-
1809            // enc: zerodds sent user DATA without the N-flag -> OpenDDS decode_serialized_
1810            // payload=0 -> no echo). `.max` enforces at least the governance FLOOR.
1811            // With gov=None legacy plaintext (reader_lv) stays allowed.
1812            let level = match slot.reader_protection.get(&pk).copied() {
1813                Some(reader_lv) => reader_lv.max(gov_data_level),
1814                None => gov_data_level,
1815            };
1816            Some((pk, level))
1817        })
1818    });
1819    match resolved {
1820        // Matched reader with Sign/Encrypt: cyclone-conformant SUBMESSAGE
1821        // protection (SEC_PREFIX/BODY/POSTFIX around the DATA submessage, local
1822        // data key) instead of message-level SRTPS — `metadata_protection_kind=
1823        // ENCRYPT`, §9.5.3.3. cyclone decodes with the key sent via datawriter_crypto_
1824        // tokens. `None` level = byte-identical passthrough.
1825        Some((peer_key, level)) if level != ProtectionLevel::None => {
1826            // Layer choice per governance (DDS-Security §8.4.2.4 vs §7.3.7):
1827            //  * metadata_protection_kind != NONE -> per-submessage protection
1828            //    (`encode_datawriter_submessage`, SEC_PREFIX/BODY/POSTFIX) for
1829            //    EVERY writer submessage (DATA, HEARTBEAT, GAP, ...). This is the
1830            //    cyclone interop path: cyclone expects HEARTBEAT/GAP SEC_*-
1831            //    wrapped too, otherwise its reader never NACKs (no reliable recovery).
1832            //  * otherwise (only rtps_protection_kind != NONE) -> message-level SRTPS
1833            //    via `transform_outbound_for` (whole message, §7.3.7).
1834            // INNER layer (§9.5.3.3.1): data_protection encrypts ONLY the
1835            // SerializedPayload of each DATA submessage. Applied BEFORE the outer
1836            // submessage/message layer — cyclone-conformant
1837            // nesting (§9.5.3.3): data_protection (inner) + metadata_
1838            // protection (outer). With pure data_protection this is the
1839            // only + complete protection.
1840            let inner: Vec<u8> = if gov_data_level != ProtectionLevel::None {
1841                // Crypto error -> drop instead of leak (None propagated via `?`).
1842                protect_user_payload(rt, bytes)?
1843            } else {
1844                bytes.to_vec()
1845            };
1846            // OUTER layer choice (DDS-Security §8.4.2.4 / §7.3.7):
1847            if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None
1848            {
1849                // metadata_protection -> per-submessage protection (DATA, HEARTBEAT,
1850                // GAP, ...) with the per-endpoint writer key (cyclone interop path).
1851                // Under additional rtps_protection message-level SRTPS MUST go on top
1852                // (both layers) — otherwise "clear submsg from protected src".
1853                match protect_user_datagram(rt, &inner) {
1854                    Some(ms)
1855                        if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1856                            != ProtectionLevel::None =>
1857                    {
1858                        gate.transform_outbound(&ms).ok()
1859                    }
1860                    other => other,
1861                }
1862            } else if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1863                != ProtectionLevel::None
1864            {
1865                // rtps_protection -> message-level SRTPS (whole message, §7.3.7),
1866                // per-reader key.
1867                gate.transform_outbound_for(&peer_key, &inner, level).ok()
1868            } else {
1869                // only data_protection -> the payload layer is already the
1870                // complete protection (§9.5.3.3.1). Header/InlineQoS stay
1871                // plaintext, the encrypted payload carries the N-flag.
1872                Some(inner)
1873            }
1874        }
1875        // Matched reader with level None: a legacy-v1.4 reader (explicit
1876        // SEDP legacy or NONE governance) gets byte-identical plaintext —
1877        // message-level SRTPS would make it undecryptable.
1878        Some(_) => {
1879            // Matched reader with data level None: under rtps_protection the
1880            // message MUST still be message-level-SRTPS-wrapped (§8.4.2.4) —
1881            // the data_protection level only controls the payload/submessage layer.
1882            // Without it user DATA/HEARTBEAT leaks plain, although the domain
1883            // requires rtps_protection=ENCRYPT (the peer discards it as legacy).
1884            if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1885                gate.transform_outbound(bytes).ok()
1886            } else {
1887                Some(bytes.to_vec())
1888            }
1889        }
1890        // No locator-resolved reader: multicast/meta bootstrap OR a
1891        // user reader whose locator is (not yet) in `locator_to_peer`
1892        // (e.g. discovered via secure SEDP, discovery_protection=ENCRYPT). The
1893        // data_protection (inner payload layer §9.5.3.3.1) is TARGET-INDEPENDENT
1894        // (local writer key) and MUST still apply for a user writer —
1895        // otherwise under data_protection=ENCRYPT the user DATA leaks PLAINTEXT (N-flag
1896        // missing -> a spec-conformant remote reader never calls `decode_serialized_payload`
1897        // -> no sample, no echo; disc-data-enc stall, source-documented: OpenDDS
1898        // decode_serialized_payload=0). ONLY for user writers — SPDP/SEDP builtin DATA
1899        // must bootstrap plaintext (otherwise undecodable before key exchange).
1900        None => {
1901            use zerodds_rtps::wire_types::EntityKind;
1902            let is_user_writer = matches!(
1903                writer_eid.entity_kind,
1904                EntityKind::UserWriterWithKey | EntityKind::UserWriterNoKey
1905            );
1906            if is_user_writer && gov_data_level != ProtectionLevel::None {
1907                let inner = protect_user_payload(rt, bytes)?;
1908                gate.transform_outbound(&inner).ok()
1909            } else {
1910                gate.transform_outbound(bytes).ok()
1911            }
1912        }
1913    }
1914}
1915
1916#[cfg(not(feature = "security"))]
1917fn secure_outbound_for_target(
1918    _rt: &DcpsRuntime,
1919    _writer_eid: EntityId,
1920    bytes: &[u8],
1921    _target: &Locator,
1922) -> Option<Vec<u8>> {
1923    Some(bytes.to_vec())
1924}
1925
1926/// FU2 S3: data_protection-aware user DATA outbound. Encrypts the
1927/// datagram with the governance `data_protection` level. `transform_outbound_
1928/// for` ignores the `peer_key` and uses the local key — the ciphertext
1929/// is decryptable for EVERY authenticated peer (with our token),
1930/// non-authenticated peers cannot read it. A `None` level falls
1931/// back to message-level (`rtps_protection` resp. passthrough). Used for
1932/// UDP + in-process fastpath + SHM UNIFORMLY, so the
1933/// inproc path is secured too.
1934#[cfg(feature = "security")]
1935fn secure_user_outbound<'a>(
1936    rt: &DcpsRuntime,
1937    bytes: &'a [u8],
1938) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1939    let Some(gate) = &rt.config.security else {
1940        return Some(alloc::borrow::Cow::Borrowed(bytes));
1941    };
1942    let level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1943    if matches!(level, ProtectionLevel::None) {
1944        gate.transform_outbound(bytes)
1945            .ok()
1946            .map(alloc::borrow::Cow::Owned)
1947    } else {
1948        gate.transform_outbound_for(&[0u8; 12], bytes, level)
1949            .ok()
1950            .map(alloc::borrow::Cow::Owned)
1951    }
1952}
1953
1954#[cfg(not(feature = "security"))]
1955fn secure_user_outbound<'a>(
1956    _rt: &DcpsRuntime,
1957    bytes: &'a [u8],
1958) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1959    Some(alloc::borrow::Cow::Borrowed(bytes))
1960}
1961
1962/// Sends `bytes` to `target` on the matching interface socket.
1963/// Falls back to `rt.user_unicast` if no
1964/// pool is configured or no binding matches the target range
1965/// and no default binding is set either.
1966#[cfg(feature = "security")]
1967fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1968    if let Some(pool) = &rt.outbound_pool {
1969        if let Some((socket, _iface)) = pool.route(target) {
1970            let _ = socket.send(target, bytes);
1971            return;
1972        }
1973    }
1974    let _ = rt.user_unicast.send(target, bytes);
1975}
1976
1977#[cfg(not(feature = "security"))]
1978fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1979    let _ = rt.user_unicast.send(target, bytes);
1980}
1981
1982/// User-writer slot in the runtime. Carries ReliableWriter + topic meta +
1983/// fragment size (from QoS).
1984struct UserWriterSlot {
1985    writer: ReliableWriter,
1986    topic_name: String,
1987    type_name: String,
1988    reliable: bool,
1989    durability: zerodds_qos::DurabilityKind,
1990    /// Deadline period in nanoseconds (0 == INFINITE, no monitoring).
1991    deadline_nanos: u64,
1992    /// Last successful `write` relative to `DcpsRuntime::start_instant`.
1993    last_write: Option<Duration>,
1994    /// Counter for missed deadlines (Spec §2.2.4.2.9).
1995    offered_deadline_missed_count: u64,
1996    /// Counter for LivelinessLost detections from the writer's view
1997    /// (Spec §2.2.4.2.10). Incremented in `check_writer_liveliness` on
1998    /// manual-lease overrun. 0 == not monitored.
1999    liveliness_lost_count: u64,
2000    /// Last assert time (manual liveliness). `None` == never.
2001    last_liveliness_assert: Option<Duration>,
2002    /// Per-policy counter for offered_incompatible_qos. Spec
2003    /// §2.2.4.2.4.2 — writer side. Incremented on
2004    /// `wire_writer_to_remote_reader` reject.
2005    offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus,
2006    /// Lifespan duration in nanoseconds (0 == INFINITE, no expiry).
2007    lifespan_nanos: u64,
2008    /// Per sample SN the insert time (relative to start_instant).
2009    /// Removed from front on expiry — SNs are monotonic, lifespan
2010    /// is constant, so the expiry prefix is always front.
2011    sample_insert_times:
2012        alloc::collections::VecDeque<(zerodds_rtps::wire_types::SequenceNumber, Duration)>,
2013    /// Liveliness kind (Automatic / ManualByParticipant / ManualByTopic).
2014    liveliness_kind: zerodds_qos::LivelinessKind,
2015    /// Lease duration in nanoseconds (0 == INFINITE).
2016    liveliness_lease_nanos: u64,
2017    /// Ownership mode.
2018    ownership: zerodds_qos::OwnershipKind,
2019    /// Ownership strength (Spec §2.2.3.2). Mirrored in the same-runtime
2020    /// dispatch into `UserSample::Alive.writer_strength`, so that
2021    /// EXCLUSIVE ownership logic in the reader also works for intra-process
2022    /// loopback.
2023    ownership_strength: i32,
2024    /// Partition list.
2025    partition: Vec<String>,
2026    /// Per-matched-reader ProtectionLevel. Derived at the
2027    /// SEDP match from `sub.security_info`. `None` entries
2028    /// for legacy readers. Empty for writers without matched
2029    /// security peers — then the hot path is unchanged.
2030    #[cfg(feature = "security")]
2031    reader_protection: BTreeMap<[u8; 12], ProtectionLevel>,
2032    /// Mapping Locator → GuidPrefix for the writer tick loop, so that
2033    /// `secure_outbound_for_target` can look up the protection per target
2034    /// without breaking the writer-tick API (`dg.targets` are
2035    /// locator lists today).
2036    #[cfg(feature = "security")]
2037    locator_to_peer: BTreeMap<Locator, [u8; 12]>,
2038    /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the writer type
2039    /// (from `T::TYPE_IDENTIFIER` in `UserWriterConfig`).
2040    type_identifier: zerodds_types::TypeIdentifier,
2041    /// D.5g — per-writer override for the DataRepresentation offer.
2042    /// `None` = runtime default. `Some(vec)` = hardcoded per writer.
2043    data_rep_offer_override: Option<Vec<i16>>,
2044    /// Type extensibility of the writer type (FINAL/APPENDABLE/MUTABLE).
2045    /// Together with the offer `first` element it determines the
2046    /// encapsulation header of the user payload (see
2047    /// [`user_payload_encap`]). Default `Final`; set by codegen/FFI via
2048    /// `set_user_writer_wire_extensibility` when the type
2049    /// is appendable/mutable (relevant for XCDR2 wire: D_CDR2/PL_CDR2).
2050    wire_extensibility: zerodds_types::qos::ExtensibilityForRepr,
2051    /// Emit the big-endian encapsulation variant (`_BE`, RTPS 2.5 §10.5)
2052    /// instead of the little-endian default. Set by the durability service
2053    /// replay path so a big-endian peer's stored sample is re-published with a
2054    /// matching BE encap header (the body bytes are already big-endian). `false`
2055    /// = little-endian (the canonical wire for a normal writer).
2056    big_endian_override: bool,
2057    /// Spec §2.2.3.5 DurabilityService — with Durability=Transient/
2058    /// Persistent the backend holds in addition to the writer's own
2059    /// HistoryCache. On the first late-joiner match in
2060    /// `wire_writer_to_remote_reader` the backend samples are
2061    /// (re-)injected into the HistoryCache, so that the RTPS reliable
2062    /// path delivers them to the reader. `None` for Volatile/
2063    /// TransientLocal (the cache suffices).
2064    durability_backend: Option<alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>>,
2065    /// `true` as soon as the backend has been replayed once into the
2066    /// HistoryCache. Prevents repeated re-injection on further matches.
2067    backend_primed: bool,
2068    /// HISTORY KeepLast depth (DDS 1.4 §2.2.3.18). Per-instance retained-sample
2069    /// depth for the same-runtime durability replay path. Default
2070    /// [`DEFAULT_INTRA_HISTORY_DEPTH`]. KeepAll is modelled as `usize::MAX`.
2071    /// Settable via [`DcpsRuntime::set_user_writer_history_depth`].
2072    history_depth: usize,
2073    /// TRANSIENT_LOCAL retained samples (DDS 1.4 §2.2.3.4) for the
2074    /// same-runtime late-joiner replay path. Holds the *most recent*
2075    /// `history_depth` Alive samples **per instance key** plus any
2076    /// terminal lifecycle marker for an instance. A reader that joins an
2077    /// intra-runtime route AFTER these writes replays this buffer so it sees
2078    /// the retained history (the wire/SEDP path is separate, see
2079    /// `wire_writer_to_remote_reader`). Empty unless durability is
2080    /// TransientLocal or stronger.
2081    retained: alloc::collections::VecDeque<RetainedSample>,
2082    /// Set of intra-runtime reader EntityIds that have already received the
2083    /// TransientLocal retained-sample replay, so a route recompute does not
2084    /// replay the same history twice to the same reader.
2085    intra_replayed_readers: alloc::collections::BTreeSet<EntityId>,
2086}
2087
2088/// Default same-runtime HISTORY KeepLast depth when the user has not called
2089/// [`DcpsRuntime::set_user_writer_history_depth`]. Mirrors the DDS spec default
2090/// of `depth = 1` for KEEP_LAST (DDS 1.4 §2.2.3.18 Table).
2091const DEFAULT_INTRA_HISTORY_DEPTH: usize = 1;
2092
2093/// One retained sample for the same-runtime TransientLocal replay path.
2094#[derive(Debug, Clone)]
2095struct RetainedSample {
2096    /// Instance key hash (16 byte). All-zero for NoKey topics / unknown key.
2097    key_hash: [u8; 16],
2098    /// CDR body without encapsulation header.
2099    payload: Vec<u8>,
2100    /// XCDR version tag (`0` = XCDR1, `1` = XCDR2).
2101    representation: u8,
2102    /// Writer ownership strength at write time.
2103    strength: i32,
2104    /// `Some(kind)` if this entry is a terminal lifecycle marker
2105    /// (dispose / unregister) rather than an alive sample.
2106    lifecycle: Option<zerodds_rtps::history_cache::ChangeKind>,
2107}
2108
2109/// The listener dispatch carries, alongside the `UserSample`, a
2110/// zero-copy view on the original `Arc<[u8]>` with an encap offset
2111/// (lever-E zero-copy path).
2112pub type UserSampleWithEncap = (UserSample, Option<(Arc<[u8]>, usize)>);
2113
2114/// Sample channel item: either data payload or lifecycle marker.
2115/// Lifecycle is reconstructed by the wire path as `key_hash + ChangeKind` from
2116/// the PID_STATUS_INFO header; the DataReader DCPS layer
2117/// translates that into `__push_lifecycle`.
2118#[derive(Debug, Clone)]
2119pub enum UserSample {
2120    /// Normal sample with payload (CDR-encoded application type).
2121    /// `writer_guid` is the 16-byte GUID of the emitting writer
2122    /// — needed by the subscriber for exclusive-ownership resolution
2123    /// (DDS 1.4 §2.2.3.23 / §2.2.2.5.5).
2124    Alive {
2125        /// CDR payload (without encapsulation header). Zero-copy container:
2126        /// typically holds an `Arc<[u8]>` slice into the RTPS wire datagram
2127        /// without a heap re-alloc. See `docs/specs/zerodds-zero-copy-1.0.md`.
2128        payload: crate::sample_bytes::SampleBytes,
2129        /// Writer GUID — for strongest-writer selection.
2130        writer_guid: [u8; 16],
2131        /// Writer `ownership_strength` at the time of receipt.
2132        /// `0` if the writer is not yet known via discovery
2133        /// (the reader treats this as default strength = spec-conformant
2134        /// for shared-ownership topics; for exclusive the
2135        /// reader filters the real strength against the current owner).
2136        writer_strength: i32,
2137        /// XCDR version of the `payload` — extracted from the encapsulation
2138        /// header of the wire sample (RTPS 2.5 §10.5) BEFORE the
2139        /// header was stripped: `0` = XCDR1 (CDR/PL_CDR), `1` =
2140        /// XCDR2 (CDR2/D_CDR2/PL_CDR2). The typed consumer
2141        /// needs this to decode the body with the correct alignment rule
2142        /// (XTypes 1.3 §7.4.3.4.2).
2143        representation: u8,
2144        /// Byte order of the `payload` — extracted from the encapsulation
2145        /// representation identifier's low bit (RTPS 2.5 §10.5: the `_BE`
2146        /// variants 0x0000/0x0002/0x0006/0x0008/0x000a are even, the `_LE`
2147        /// variants odd). `false` = little-endian (the canonical wire and the
2148        /// intra-runtime default), `true` = big-endian. The typed consumer
2149        /// dispatches `DdsType::decode` vs `decode_be` on this.
2150        big_endian: bool,
2151        /// Source timestamp from the writer's INFO_TS submessage (DDSI-RTPS
2152        /// §8.7.3), if any. Threaded into `SampleInfo.source_timestamp` and the
2153        /// `DESTINATION_ORDER = BY_SOURCE_TIMESTAMP` decision. `None` ⇒ the
2154        /// reader uses reception order.
2155        source_timestamp: Option<zerodds_rtps::header_extension::HeTimestamp>,
2156        /// Source sequence number — the emitting writer's RTPS
2157        /// `CacheChange.sequence_number` (DDSI-RTPS §8.3.5.4). Together with
2158        /// `writer_guid` this is the globally-unique source identity of the
2159        /// sample, which a durability service uses to dedup a writer's history
2160        /// against its live stream (O2 P5). `SEQUENCENUMBER_UNKNOWN` (`-1`) when
2161        /// the delivery path has no source sequence (e.g. raw test injection).
2162        source_sequence_number: i64,
2163    },
2164    /// Lifecycle marker (dispose / unregister) — the reader sets
2165    /// InstanceState accordingly.
2166    Lifecycle {
2167        /// Key hash of the affected instance (16 byte).
2168        key_hash: [u8; 16],
2169        /// `NotAliveDisposed` / `NotAliveUnregistered` /
2170        /// `NotAliveDisposedUnregistered`.
2171        kind: zerodds_rtps::history_cache::ChangeKind,
2172    },
2173}
2174
2175/// User-reader slot. ReliableReader + topic meta + channel to the
2176/// DataReader (DCPS API side).
2177/// Listener callback for sample arrival.
2178///
2179/// Fired synchronously by `recv_user_data_loop` in the recv-thread
2180/// context as soon as an alive sample lands in the reader HistoryCache.
2181/// Eliminates the polling latency of `zerodds_reader_take()` —
2182/// the listener path typically saves 50-100 µs per side.
2183///
2184/// **Contract** (analogous to DDS spec §2.2.4.4 listener semantics):
2185/// * The callback runs on the recv thread, NOT the user thread.
2186/// * Short and non-blocking. No I/O, no locks, no
2187///   ZeroDDS API calls inside.
2188/// * `bytes` points to the CDR payload of the alive sample (without
2189///   encapsulation header). Lifetime only for the duration of the
2190///   callback; copy if needed beyond the call.
2191/// * Disposed/unregistered lifecycle events do NOT fire the listener
2192///   (only `Alive` samples) — for lifecycle tracking
2193///   keep using `zerodds_reader_take()` or add a
2194///   lifecycle-listener API.
2195///
2196/// Data-available listener. Arguments: CDR body (without encapsulation
2197/// header) and the XCDR version of the sample (`0` = XCDR1, `1` = XCDR2)
2198/// — the typed consumer needs the latter for the alignment
2199/// rule on decode (XTypes 1.3 §7.4.3.4.2).
2200pub type UserReaderListener = alloc::boxed::Box<dyn Fn(&[u8], u8, u8) + Send + Sync + 'static>;
2201
2202struct UserReaderSlot {
2203    reader: ReliableReader,
2204    topic_name: String,
2205    type_name: String,
2206    sample_tx: mpsc::Sender<UserSample>,
2207    /// Spec §3 zerodds-async-1.0: async waker slot. Registered by the
2208    /// async reader; on `sample_tx.send` we call
2209    /// `waker.wake()`. `None` if no async reader is active.
2210    async_waker: alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>,
2211    /// Listener callback for alive samples.
2212    /// Fired synchronously by `recv_user_data_loop`. `None` =
2213    /// no listener registered (the user polls via
2214    /// `zerodds_reader_take()`). Arc, so the recv thread can
2215    /// execute the callback cloned without another lock (minimize lock
2216    /// hold time).
2217    listener: Option<alloc::sync::Arc<UserReaderListener>>,
2218    durability: zerodds_qos::DurabilityKind,
2219    /// Deadline period in nanoseconds (0 == INFINITE).
2220    deadline_nanos: u64,
2221    /// Time of the last received sample relative to runtime start.
2222    last_sample_received: Option<Duration>,
2223    /// Counter for missed deadline expectations (Spec §2.2.4.2.11).
2224    requested_deadline_missed_count: u64,
2225    /// Per-policy counter for requested_incompatible_qos. Spec
2226    /// §2.2.4.2.6.5 — reader side. Incremented on
2227    /// `wire_reader_to_remote_writer` reject.
2228    requested_incompatible_qos: crate::status::RequestedIncompatibleQosStatus,
2229    /// Sample-lost counter (Spec §2.2.4.2.6.2). Incremented
2230    /// by `record_sample_lost`.
2231    sample_lost_count: u64,
2232    /// Sample-rejected counter (Spec §2.2.4.2.6.3). Incremented
2233    /// by `record_sample_rejected`.
2234    sample_rejected: crate::status::SampleRejectedStatus,
2235    /// Monotonically increasing count of alive samples delivered to the
2236    /// user. Serves as a non-consuming data-availability detector for
2237    /// `on_data_available` (DDS 1.4 §2.2.4.2.6.1) — unlike
2238    /// `last_sample_received`, this counter is only bumped on real sample
2239    /// delivery, never by the deadline path. Read via
2240    /// [`DcpsRuntime::user_reader_samples_delivered`].
2241    samples_delivered_count: u64,
2242    /// Reader-side requested liveliness lease (0 == INFINITE).
2243    liveliness_lease_nanos: u64,
2244    /// Reader-side requested liveliness kind.
2245    liveliness_kind: zerodds_qos::LivelinessKind,
2246    /// Counter: how often the writer was marked "alive"
2247    /// (Spec §2.2.4.2.14 alive_count).
2248    liveliness_alive_count: u64,
2249    /// Counter: how often it was marked "not_alive" (lease expired).
2250    liveliness_not_alive_count: u64,
2251    /// Current "alive/not-alive" state from the reader's view.
2252    liveliness_alive: bool,
2253    /// QR-cluster (e): set of writer GUIDs the reader currently considers alive
2254    /// via an AUTOMATIC-liveliness same-runtime match. Used to bump
2255    /// `liveliness_alive_count` exactly once per writer-alive transition on the
2256    /// intra-runtime path (the wire DATA path tracks this via reader proxies).
2257    liveliness_alive_writers: alloc::collections::BTreeSet<[u8; 16]>,
2258    /// Ownership.
2259    ownership: zerodds_qos::OwnershipKind,
2260    /// Partition.
2261    partition: Vec<String>,
2262    /// Per-writer strength cache for exclusive-ownership resolution
2263    /// (DDS 1.4 §2.2.3.23). Filled by `wire_reader_to_remote_writer`
2264    /// from each `PublicationBuiltinTopicData.ownership_strength`;
2265    /// `delivered_to_user_sample` looks it up here to pack the
2266    /// strength into `UserSample::Alive`.
2267    writer_strengths: alloc::collections::BTreeMap<[u8; 16], i32>,
2268    /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the reader type
2269    /// (from `T::TYPE_IDENTIFIER` in `UserReaderConfig`). Default
2270    /// `TypeIdentifier::None` signals "no TypeIdentifier" —
2271    /// the match falls back to a pure `type_name` comparison
2272    /// (DDS 1.4 §2.2.3 default path).
2273    type_identifier: zerodds_types::TypeIdentifier,
2274    /// XTypes 1.3 §7.6.3.7 — TCE policy controlling the strictness
2275    /// of the XTypes match path.
2276    type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2277    /// A2 — TIME_BASED_FILTER `minimum_separation` (DDS 1.4 §2.2.3.12), in
2278    /// nanoseconds, for the runtime/C-FFI delivery path. `0` (default) = off.
2279    /// Set via [`DcpsRuntime::set_user_reader_time_based_filter`] (the
2280    /// `rmw_zerodds` / C-FFI path; the typed entity reader enforces TBF on its
2281    /// own QoS). See [`UserReaderSlot::tbf_should_deliver`].
2282    tbf_min_separation_nanos: u128,
2283    /// Per-instance last-delivered timestamp (nanoseconds since runtime start),
2284    /// keyed by the sample KeyHash (keyless types share the all-zero key). Only
2285    /// populated when `tbf_min_separation_nanos > 0`.
2286    tbf_last_delivered: alloc::collections::BTreeMap<[u8; 16], u128>,
2287}
2288
2289impl UserReaderSlot {
2290    /// A2 — TIME_BASED_FILTER gate (DDS 1.4 §2.2.3.12) for the runtime delivery
2291    /// path: returns `true` if a sample of the given instance may be delivered,
2292    /// i.e. at least `minimum_separation` has elapsed since the last delivered
2293    /// sample of that instance. The first sample of an instance always passes.
2294    /// `key_hash = None` (keyless type) collapses to a single instance. A
2295    /// `minimum_separation` of 0 disables the filter (always `true`).
2296    fn tbf_should_deliver(&mut self, key_hash: Option<[u8; 16]>, now_nanos: u128) -> bool {
2297        if self.tbf_min_separation_nanos == 0 {
2298            return true;
2299        }
2300        let inst = key_hash.unwrap_or([0u8; 16]);
2301        match self.tbf_last_delivered.get(&inst) {
2302            Some(&last) if now_nanos.saturating_sub(last) < self.tbf_min_separation_nanos => false,
2303            _ => {
2304                self.tbf_last_delivered.insert(inst, now_nanos);
2305                true
2306            }
2307        }
2308    }
2309}
2310
2311/// Helper struct for announcing a local publication/subscription
2312/// as SEDP BuiltinTopicData. The caller creates it once per
2313/// writer/reader registration and passes it to SedpStack.
2314/// QoS config for registering a user writer with the runtime.
2315/// Bundles all policies that go on the wire via SEDP plus the local
2316/// Per-endpoint discovery info for ROS 2 endpoint-info-by-topic introspection
2317/// (`rmw_get_publishers_info_by_topic` / `rmw_get_subscriptions_info_by_topic`,
2318/// the data behind `ros2 topic info -v`). Covers local user endpoints plus
2319/// remote SEDP-discovered ones. QoS is best-effort from what discovery carries
2320/// (history/depth are not on the wire, so the consumer fills rmw defaults).
2321#[derive(Debug, Clone)]
2322pub struct DiscoveredEndpointInfo {
2323    /// DDS topic name (raw, un-demangled).
2324    pub topic_name: String,
2325    /// IDL type name (raw).
2326    pub type_name: String,
2327    /// 16-byte endpoint GUID: 12-byte participant prefix + 4-byte entity id.
2328    /// Bytes 0..12 identify the owning participant (for node-name lookup).
2329    pub endpoint_guid: [u8; 16],
2330    /// RELIABLE (`true`) vs BEST_EFFORT (`false`).
2331    pub reliable: bool,
2332    /// TRANSIENT_LOCAL or stronger (`true`) vs VOLATILE (`false`).
2333    pub transient_local: bool,
2334    /// Deadline period in whole seconds (0 == INFINITE).
2335    pub deadline_seconds: i32,
2336    /// Lifespan in whole seconds (0 == INFINITE; always 0 for subscriptions).
2337    pub lifespan_seconds: i32,
2338    /// Liveliness lease in whole seconds (0 == INFINITE).
2339    pub liveliness_lease_seconds: i32,
2340}
2341
2342/// Packs an RTPS [`Guid`] into the 16-byte wire form (prefix ++ entity id).
2343fn guid_to_16(g: Guid) -> [u8; 16] {
2344    let mut b = [0u8; 16];
2345    b[..12].copy_from_slice(&g.prefix.to_bytes());
2346    b[12..].copy_from_slice(&g.entity_id.to_bytes());
2347    b
2348}
2349
2350/// monitoring. Avoids 10+-argument functions.
2351#[derive(Debug, Clone)]
2352pub struct UserWriterConfig {
2353    /// Topic name (DDS topic).
2354    pub topic_name: String,
2355    /// IDL type name.
2356    pub type_name: String,
2357    /// `true` = RELIABLE, `false` = BEST_EFFORT.
2358    pub reliable: bool,
2359    /// Durability.
2360    pub durability: zerodds_qos::DurabilityKind,
2361    /// Deadline period (offered).
2362    pub deadline: zerodds_qos::DeadlineQosPolicy,
2363    /// Lifespan duration (writer-only).
2364    pub lifespan: zerodds_qos::LifespanQosPolicy,
2365    /// Liveliness (offered).
2366    pub liveliness: zerodds_qos::LivelinessQosPolicy,
2367    /// Ownership mode (Shared / Exclusive).
2368    pub ownership: zerodds_qos::OwnershipKind,
2369    /// Strength for Exclusive (ignored for Shared).
2370    pub ownership_strength: i32,
2371    /// Partition list. Empty == default partition (`""`).
2372    pub partition: Vec<String>,
2373    /// UserData QoS (Spec §2.2.3.1) — opaque `sequence<octet>`, propagated
2374    /// via discovery.
2375    pub user_data: Vec<u8>,
2376    /// TopicData QoS (Spec §2.2.3.3).
2377    pub topic_data: Vec<u8>,
2378    /// GroupData QoS (Spec §2.2.3.2).
2379    pub group_data: Vec<u8>,
2380    /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up). Default
2381    /// `TypeIdentifier::None` for the `T::TYPE_IDENTIFIER` default.
2382    pub type_identifier: zerodds_types::TypeIdentifier,
2383
2384    /// D.5g — per-writer override of the DataRepresentation offer list.
2385    /// `None` = use `RuntimeConfig::data_representation_offer`.
2386    /// `Some(vec)` = overridden per writer (e.g. `[XCDR2]` for
2387    /// a modern-only pub).
2388    pub data_representation_offer: Option<Vec<i16>>,
2389}
2390
2391/// QoS config for registering a user reader.
2392#[derive(Debug, Clone)]
2393pub struct UserReaderConfig {
2394    /// Topic name.
2395    pub topic_name: String,
2396    /// IDL type name.
2397    pub type_name: String,
2398    /// `true` = RELIABLE, `false` = BEST_EFFORT.
2399    pub reliable: bool,
2400    /// Durability (requested).
2401    pub durability: zerodds_qos::DurabilityKind,
2402    /// Deadline (requested).
2403    pub deadline: zerodds_qos::DeadlineQosPolicy,
2404    /// Liveliness (requested).
2405    pub liveliness: zerodds_qos::LivelinessQosPolicy,
2406    /// Ownership.
2407    pub ownership: zerodds_qos::OwnershipKind,
2408    /// Partition.
2409    pub partition: Vec<String>,
2410    /// UserData QoS (Spec §2.2.3.1).
2411    pub user_data: Vec<u8>,
2412    /// TopicData QoS (Spec §2.2.3.3).
2413    pub topic_data: Vec<u8>,
2414    /// GroupData QoS (Spec §2.2.3.2).
2415    pub group_data: Vec<u8>,
2416    /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up).
2417    pub type_identifier: zerodds_types::TypeIdentifier,
2418    /// TypeConsistencyEnforcement (XTypes §7.6.3.7) — controls how strictly
2419    /// the reader match checks XTypes compatibility.
2420    pub type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2421
2422    /// D.5g — per-reader override of the DataRepresentation accept list.
2423    /// `None` = use `RuntimeConfig::data_representation_offer`.
2424    /// `Some(vec)` = overridden per reader (e.g. `[XCDR1]` for
2425    /// a reader that accepts only legacy XCDR1 wire).
2426    pub data_representation_offer: Option<Vec<i16>>,
2427}
2428
2429fn build_publication_data(
2430    owner_prefix: GuidPrefix,
2431    writer_eid: EntityId,
2432    cfg: &UserWriterConfig,
2433    runtime_offer: &[i16],
2434    user_locator: Locator,
2435) -> zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2436    use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2437    zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2438        key: Guid::new(owner_prefix, writer_eid),
2439        participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2440        topic_name: cfg.topic_name.clone(),
2441        type_name: cfg.type_name.clone(),
2442        durability: cfg.durability,
2443        reliability: ReliabilityQosPolicy {
2444            kind: if cfg.reliable {
2445                ReliabilityKind::Reliable
2446            } else {
2447                ReliabilityKind::BestEffort
2448            },
2449            max_blocking_time: QosDuration::from_millis(100_i32),
2450        },
2451        ownership: cfg.ownership,
2452        ownership_strength: cfg.ownership_strength,
2453        liveliness: cfg.liveliness,
2454        deadline: cfg.deadline,
2455        lifespan: cfg.lifespan,
2456        partition: cfg.partition.clone(),
2457        user_data: cfg.user_data.clone(),
2458        topic_data: cfg.topic_data.clone(),
2459        group_data: cfg.group_data.clone(),
2460        type_information: None,
2461        // D.5g — PID_DATA_REPRESENTATION (XTypes 1.3 §7.6.3.1.1, RTPS 2.5
2462        // PID 0x0073). Per-Writer-Override (cfg.data_representation_offer)
2463        // overrides the RuntimeConfig default.
2464        data_representation: cfg
2465            .data_representation_offer
2466            .clone()
2467            .unwrap_or_else(|| runtime_offer.to_vec()),
2468        // Security: the PolicyEngine fills this later. Default
2469        // None = legacy behavior (no EndpointSecurityInfo PID).
2470        security_info: None,
2471        // .B — RPC discovery PIDs. Default None: no RPC endpoint;
2472        // the RpcEndpoint builder fills these fields.
2473        service_instance_name: None,
2474        related_entity_guid: None,
2475        topic_aliases: None,
2476        // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2477        type_identifier: cfg.type_identifier.clone(),
2478        // DDSI-RTPS 2.5 §8.5.3.3: endpoint locator. All user endpoints
2479        // share the one `user_unicast` socket — hence the
2480        // endpoint locator equals the resolved participant locator.
2481        unicast_locators: alloc::vec![user_locator],
2482        multicast_locators: Vec::new(),
2483    }
2484}
2485
2486/// The `DataRepresentation` set a **DataReader** announces (PID_DATA_REPRESENTATION
2487/// in its SEDP subscription). Per OMG XTypes 1.3 §7.6.2, a reader with the
2488/// default (empty) policy accepts **both** XCDR1 and XCDR2 — and ZeroDDS decodes
2489/// both (the read path dispatches on the per-sample encapsulation id). So the
2490/// reader advertises every representation it can decode, not just the writer's
2491/// preferred one.
2492///
2493/// This matters cross-vendor: CycloneDDS (and legacy RTI / OpenDDS < 3.16)
2494/// default their *writers* to **XCDR1** for `@final` types (non-XTypes backward
2495/// compat). A reader that only announces XCDR2 makes those writers fail the
2496/// `DataRepresentation` RxO check — the writer never forms a connection, and the
2497/// samples are dropped before any are sent. We therefore start from the
2498/// configured offer (which fixes the *preferred* order) and ensure both XCDR2
2499/// and XCDR1 are present. A WRITER keeps the narrow offer (`build_publication_data`),
2500/// because the generated encoder emits one representation.
2501fn reader_accept_repr(configured_offer: &[i16]) -> Vec<i16> {
2502    use zerodds_rtps::publication_data::data_representation as dr;
2503    let mut out: Vec<i16> = configured_offer.to_vec();
2504    for id in [dr::XCDR2, dr::XCDR] {
2505        if !out.contains(&id) {
2506            out.push(id);
2507        }
2508    }
2509    out
2510}
2511
2512fn build_subscription_data(
2513    owner_prefix: GuidPrefix,
2514    reader_eid: EntityId,
2515    cfg: &UserReaderConfig,
2516    runtime_offer: &[i16],
2517    user_locator: Locator,
2518) -> zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2519    use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2520    zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2521        key: Guid::new(owner_prefix, reader_eid),
2522        participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2523        topic_name: cfg.topic_name.clone(),
2524        type_name: cfg.type_name.clone(),
2525        durability: cfg.durability,
2526        reliability: ReliabilityQosPolicy {
2527            kind: if cfg.reliable {
2528                ReliabilityKind::Reliable
2529            } else {
2530                ReliabilityKind::BestEffort
2531            },
2532            max_blocking_time: QosDuration::from_millis(100_i32),
2533        },
2534        ownership: cfg.ownership,
2535        liveliness: cfg.liveliness,
2536        deadline: cfg.deadline,
2537        partition: cfg.partition.clone(),
2538        user_data: cfg.user_data.clone(),
2539        topic_data: cfg.topic_data.clone(),
2540        group_data: cfg.group_data.clone(),
2541        type_information: None,
2542        // D.5g — PID_DATA_REPRESENTATION (see build_publication_data).
2543        // A per-reader override overrides the RuntimeConfig default.
2544        data_representation: cfg
2545            .data_representation_offer
2546            .clone()
2547            .unwrap_or_else(|| runtime_offer.to_vec()),
2548        content_filter: None,
2549        security_info: None,
2550        service_instance_name: None,
2551        related_entity_guid: None,
2552        topic_aliases: None,
2553        // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2554        type_identifier: cfg.type_identifier.clone(),
2555        // DDSI-RTPS 2.5 §8.5.3.2: endpoint locator (see
2556        // build_publication_data).
2557        unicast_locators: alloc::vec![user_locator],
2558        multicast_locators: Vec::new(),
2559    }
2560}
2561
2562/// The runtime of a `DomainParticipant`. Hosts all background
2563/// threads and UDP sockets.
2564pub struct DcpsRuntime {
2565    /// Participant GUID prefix (12-byte identifier, random per instance).
2566    pub guid_prefix: GuidPrefix,
2567    /// Domain id.
2568    pub domain_id: i32,
2569    /// SPDP multicast receiver socket.
2570    pub spdp_multicast_rx: Arc<UdpTransport>,
2571    /// SPDP unicast socket (for bidirectional SPDP, B2).
2572    pub spdp_unicast: Arc<UdpTransport>,
2573    /// User-data unicast transport (default user unicast, where peers
2574    /// send matched samples). Trait object: can be UDP/v4 or /v6,
2575    /// and in phase C additionally TCP or SHM (env var
2576    /// `ZERODDS_USER_TRANSPORT`). Discovery (SPDP/SEDP) stays UDP-only.
2577    pub user_unicast: Arc<dyn Transport + Send + Sync>,
2578    /// Resolved user-unicast locator (routable interface address,
2579    /// not `0.0.0.0`). Written as `PID_UNICAST_LOCATOR` into EVERY
2580    /// SEDP pub/sub announce (DDSI-RTPS 2.5 §8.5.3.2/3)
2581    /// and as the participant `DEFAULT_UNICAST_LOCATOR` in SPDP. Precomputed
2582    /// via `announce_locator`, so the endpoint and participant locators
2583    /// are guaranteed identical.
2584    pub user_announce_locator: Locator,
2585    /// Sender socket for the SPDP multicast announce (separate UdpSocket
2586    /// without SO_REUSE/SO_BIND_IP_MULTICAST, so send_to routes cleanly).
2587    spdp_mc_tx: Arc<UdpTransport>,
2588    /// SPDP beacon (sends periodic announces).
2589    spdp_beacon: Mutex<SpdpBeacon>,
2590    /// Own participant data (SPDP self-view). Handed by the in-process
2591    /// discovery fastpath as a `DiscoveredParticipant` to same-process
2592    /// peers (see [`crate::inproc`]).
2593    participant_data: ParticipantBuiltinTopicData,
2594    /// Stash of all locally announced publications/subscriptions —
2595    /// so a peer runtime starting later in the same process
2596    /// can pull our endpoints via `inproc_snapshot`
2597    /// (pull-on-creation of the in-process discovery fastpath).
2598    /// Append-only; a future patch for endpoint deletion would
2599    /// remove by GUID here.
2600    announced_pubs: Mutex<Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>>,
2601    announced_subs: Mutex<Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>>,
2602    /// SPDP reader (parses incoming beacons).
2603    spdp_reader: SpdpReader,
2604    /// Discovered remote participants (prefix → data).
2605    discovered: Arc<Mutex<DiscoveredParticipantsCache>>,
2606    /// A1 discovery-server relay: cache of the last raw SPDP datagram per
2607    /// discovered participant prefix. Only populated in `discovery_server` mode;
2608    /// used to forward a newly-joined client the SPDP of every already-known
2609    /// client (and vice versa). Empty otherwise.
2610    spdp_relay_cache: Mutex<alloc::collections::BTreeMap<GuidPrefix, Vec<u8>>>,
2611    /// SEDP stack for publication/subscription announce + discovery.
2612    pub sedp: Arc<Mutex<SedpStack>>,
2613    /// TypeLookup-Service Builtin-Endpoint-GUIDs (XTypes 1.3 §7.6.3.3.4).
2614    pub type_lookup_endpoints: TypeLookupEndpoints,
2615    /// TypeLookup server (server-side handler over the local
2616    /// TypeRegistry).
2617    pub type_lookup_server: Arc<Mutex<TypeLookupServer>>,
2618    /// TypeLookup client (client-side correlation table for outstanding
2619    /// requests).
2620    pub type_lookup_client: Arc<Mutex<TypeLookupClient>>,
2621    /// Monotonically increasing sequence number of the TL_SVC_REPLY_WRITER. Reply DATA
2622    /// carry their OWN writer_sn (instead of echoing the request SN) — the
2623    /// correlation runs via PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2),
2624    /// so a reliable cross-vendor reply reader sees no SN jumps.
2625    tl_reply_sn: core::sync::atomic::AtomicU64,
2626    /// Security builtin endpoint stack
2627    /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
2628    /// MessageSecure`). `None` as long as no security plugin is active
2629    /// — the hot path then skips any security-builtin
2630    /// demux. `Some` is set via [`DcpsRuntime::enable_security_builtins`]
2631    /// as soon as the factory has registered a plugin.
2632    pub security_builtin: Mutex<Option<Arc<Mutex<SecurityBuiltinStack>>>>,
2633    /// Monotonic "start time" — for SEDP tick clocks.
2634    start_instant: Instant,
2635    /// Local user-writer registry (EntityId → writer state).
2636    user_writers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserWriterSlot>>>>>,
2637    /// ADR-0006 side map: per user writer an optional ShmLocator bytes
2638    /// value (PID_SHM_LOCATOR in the SEDP sample). `None` = no
2639    /// same-host backend attached. The wire encoder consults
2640    /// this map on the SEDP push.
2641    shm_locators: Arc<RwLock<BTreeMap<EntityId, Vec<u8>>>>,
2642    /// Wave 4 (Spec `zerodds-zero-copy-1.0` §6): tracker for
2643    /// same-host (writer, reader) pairs. The SEDP match hook registers
2644    /// here every pair whose remote prefix carries the same host-id prefix
2645    /// as the local participant. The hot-path send consults
2646    /// the tracker and routes over SHM instead of UDP in the `Bound` state.
2647    pub same_host: Arc<crate::same_host::SameHostTracker>,
2648    /// Local user-reader registry (EntityId → reader state).
2649    user_readers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserReaderSlot>>>>>,
2650    /// Cross-vendor step 6b: peers to whom we have already sent per-endpoint
2651    /// crypto tokens (datawriter/datareader). Prevents spam on the
2652    /// repeated receipt of cyclone's tokens; sending happens only once the
2653    /// user endpoints exist (the bench creates them after handshake start).
2654    #[cfg(feature = "security")]
2655    /// Already-sent per-endpoint crypto tokens, per dedup key
2656    /// (source_endpoint ++ destination_endpoint, see `endpoint_token_key`).
2657    /// Per-token instead of per-peer, so late-matched user endpoints still
2658    /// get their tokens (#29).
2659    endpoint_tokens_sent: Arc<RwLock<alloc::collections::BTreeSet<[u8; 32]>>>,
2660    /// Peers (prefix) to whom our SEDP endpoint records have already been
2661    /// re-announced after a completed crypto-token exchange. Under rtps_/discovery_
2662    /// protection the initial SEDP burst is discarded by the peer (no key), until
2663    /// the participant crypto token arrives via Volatile; a one-time
2664    /// re-announce from that moment (the peer can now decode) brings the
2665    /// dropped SEDP up (OpenDDS flow; cyclone/FastDDS converge anyway).
2666    #[cfg(feature = "security")]
2667    sedp_reannounced: Arc<RwLock<alloc::collections::BTreeSet<[u8; 12]>>>,
2668    /// Per-endpoint crypto (DDS-Security §9.5.3.3): per local writer/reader
2669    /// EntityId its own crypto slot handle (its own key material, not the
2670    /// participant key). Used for the per-endpoint token (prepare_endpoint_
2671    /// crypto_tokens) AND the per-endpoint encode (protect_user_datagram)
2672    /// — the same key on both sides. Get-or-register lazily via
2673    /// `local_endpoint_crypto_handle`.
2674    #[cfg(feature = "security")]
2675    endpoint_crypto:
2676        Arc<RwLock<alloc::collections::BTreeMap<EntityId, zerodds_security::crypto::CryptoHandle>>>,
2677    /// Same-runtime writer→reader routes: per local writer the list
2678    /// of local readers subscribed to the same topic+type.
2679    /// Rebuilt in `recompute_intra_runtime_routes` on every
2680    /// register/unregister. Looked up in the write hot path,
2681    /// to push samples directly into the reader slot's `sample_tx`
2682    /// (intra-process loopback without an RTPS roundtrip, in parallel to the
2683    /// inproc peer path that only serves cross-runtime peers).
2684    intra_runtime_routes: Arc<RwLock<BTreeMap<EntityId, Vec<EntityId>>>>,
2685    /// Entity key counter (3 byte, incrementing). User writers use
2686    /// `0xC2` (with-key, user), user readers `0xC7`.
2687    entity_counter: AtomicU32,
2688    /// Configuration (cloned from RuntimeConfig).
2689    pub config: RuntimeConfig,
2690    /// Per-interface outbound socket pool. `None`
2691    /// when `config.interface_bindings` is empty — then
2692    /// `user_unicast` stays the only outbound socket (v1.4 path).
2693    #[cfg(feature = "security")]
2694    outbound_pool: Option<Arc<OutboundSocketPool>>,
2695    /// Writer-Liveliness-Protocol endpoint (RTPS 2.5 §8.4.13).
2696    /// Sends periodic `ParticipantMessageData` heartbeats and
2697    /// tracks last-seen per remote participant.
2698    pub wlp: Arc<Mutex<crate::wlp::WlpEndpoint>>,
2699    /// Builtin-topic reader sinks (DDS 1.4 §2.2.5). Set by the
2700    /// `DomainParticipant` constructor via `attach_builtin_sinks`;
2701    /// before that this is `None` and the discovery hot path
2702    /// drops samples silently (e.g. when the runtime is
2703    /// started directly for internal tests, without a participant).
2704    builtin_sinks: Mutex<Option<crate::builtin_subscriber::BuiltinSinks>>,
2705    /// Ignore filter (DDS 1.4 §2.2.2.2.1.14-17). Set by the
2706    /// `DomainParticipant` constructor via `attach_ignore_filter`.
2707    /// `None` means: no participant hook → no
2708    /// filtering.
2709    ignore_filter: Mutex<Option<crate::participant::IgnoreFilter>>,
2710    /// Stop flag for all worker threads (recv loops + tick loop).
2711    stop: Arc<AtomicBool>,
2712    /// Monotonic count of completed tick iterations. Incremented once per
2713    /// [`run_tick_iteration`], regardless of whether the tick is driven by the
2714    /// internal `zdds-tick` thread or an external executor (zerodds-async-1.0
2715    /// §4 `spawn_in_tokio`). Diagnostic: a stalled count means the tick loop
2716    /// stopped advancing. Read via [`DcpsRuntime::tick_count`].
2717    tick_seq: AtomicU64,
2718    /// Total SPDP announces emitted (multicast + unicast fan-out count as one).
2719    /// Diagnostic for the C3 initial-announcement burst — a fresh, peer-less
2720    /// participant should advance this fast initially. Read via
2721    /// [`DcpsRuntime::spdp_announce_count`].
2722    spdp_announce_seq: AtomicU64,
2723    /// Inconsistent-topic counter (DDS 1.4 §2.2.4.2.4). Incremented when
2724    /// matching discovers a remote endpoint carrying the same `topic_name`
2725    /// but a differing `type_name` in the SEDP cache. Read via
2726    /// [`DcpsRuntime::inconsistent_topic_count`].
2727    inconsistent_topic_seq: AtomicU64,
2728    /// D.5e Phase 3 — wake handle for the event-driven scheduler tick. `Some`
2729    /// only when started with `scheduler_tick`. Recv loops + the write path call
2730    /// [`DcpsRuntime::raise_tick_wake`] to wake the worker immediately on new
2731    /// work (so HEARTBEAT/ACKNACK/HB processing does not wait for a deadline).
2732    tick_wake: Mutex<Option<crate::scheduler::SchedulerHandle<TickEvent>>>,
2733    /// Coalesces wake raises: many incoming datagrams collapse into one wake.
2734    tick_wake_pending: AtomicBool,
2735    /// Worker thread JoinHandles. Per-socket recv threads + tick thread,
2736    /// all terminated together via `stop` (Sprint D.5b — previously
2737    /// a single single-threaded `event_loop`).
2738    handles: Mutex<Vec<JoinHandle<()>>>,
2739    /// Match-event notifier (D.5e Phase-1 quick win). Notified by the
2740    /// SEDP match path after `add_reader_proxy` / `add_writer_proxy`;
2741    /// `wait_for_matched_*` parks on it instead of polling every 20 ms.
2742    /// The mutex content is only a lock anchor for the Condvar API; there is
2743    /// no state protected by it (the count is read independently
2744    /// via `user_*_matched_count`).
2745    match_event: Arc<(Mutex<()>, Condvar)>,
2746    /// Acknowledgments event notifier. Notified when a writer
2747    /// receives an ACKNACK that advances its acked-base.
2748    /// `wait_for_acknowledgments` parks on it instead of polling every 50 ms.
2749    ack_event: Arc<(Mutex<()>, Condvar)>,
2750}
2751
2752impl core::fmt::Debug for DcpsRuntime {
2753    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2754        f.debug_struct("DcpsRuntime")
2755            .field("domain_id", &self.domain_id)
2756            .field("guid_prefix", &self.guid_prefix)
2757            .field("spdp_group", &self.config.spdp_multicast_group)
2758            .finish_non_exhaustive()
2759    }
2760}
2761
2762/// Type alias: Arc-shared slot handles from the per-slot mutex
2763/// architecture.
2764type WriterSlotArc = Arc<Mutex<UserWriterSlot>>;
2765type ReaderSlotArc = Arc<Mutex<UserReaderSlot>>;
2766
2767impl DcpsRuntime {
2768    /// The 16-byte RTPS GUID of a local writer with EntityId `eid` (this
2769    /// participant's GUID prefix ++ the entity id). Used by the cross-vendor
2770    /// iceoryx-cyclone bridge to stamp the PSMX chunk with the writer's real
2771    /// GUID so a peer that discovered the writer over RTPS SEDP associates the
2772    /// shared-memory sample with it.
2773    #[must_use]
2774    pub fn writer_guid(&self, eid: EntityId) -> [u8; 16] {
2775        Guid::new(self.guid_prefix, eid).to_bytes()
2776    }
2777
2778    // ========================================================================
2779    // --- Per-Slot-Mutex-Helpers
2780    //
2781    // The `user_writers`/`user_readers` registry is `RwLock<BTreeMap<EntityId,
2782    // Arc<Mutex<Slot>>>>`. Hot-path accesses take the read lock briefly, clone
2783    // the slot Arc and release the read lock before taking the per-slot mutex.
2784    // Parallel writes to **different** slots thereby run
2785    // without global contention.
2786    //
2787    // Slot creation/deletion takes the write lock; that is rare and
2788    // amortizes out.
2789    // ========================================================================
2790
2791    /// Returns the slot Arc for a user writer, if present.
2792    /// Hot-path form: a single read lock + Arc clone, no
2793    /// per-slot mutex. The caller takes the mutex itself.
2794    fn writer_slot(&self, eid: EntityId) -> Option<WriterSlotArc> {
2795        self.user_writers
2796            .read()
2797            .ok()
2798            .and_then(|w| w.get(&eid).cloned())
2799    }
2800
2801    /// Returns the slot Arc for a user reader, if present.
2802    fn reader_slot(&self, eid: EntityId) -> Option<ReaderSlotArc> {
2803        self.user_readers
2804            .read()
2805            .ok()
2806            .and_then(|r| r.get(&eid).cloned())
2807    }
2808
2809    /// Snapshot of all writer slots as `Vec<(EntityId, Arc)>`. Allows
2810    /// iteration without holding the registry read lock — e.g. for
2811    /// the heartbeat tick or liveliness sweep, where we potentially take every
2812    /// slot's mutex.
2813    fn writer_slots_snapshot(&self) -> Vec<(EntityId, WriterSlotArc)> {
2814        match self.user_writers.read() {
2815            Ok(w) => w.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2816            Err(_) => Vec::new(),
2817        }
2818    }
2819
2820    /// Snapshot of all reader slots — symmetric to writer_slots_snapshot.
2821    fn reader_slots_snapshot(&self) -> Vec<(EntityId, ReaderSlotArc)> {
2822        match self.user_readers.read() {
2823            Ok(r) => r.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2824            Err(_) => Vec::new(),
2825        }
2826    }
2827
2828    /// Returns the list of EntityIds of all registered writers.
2829    /// Very lightweight — no slot-Arc clone, just EntityIds.
2830    fn writer_eids(&self) -> Vec<EntityId> {
2831        match self.user_writers.read() {
2832            Ok(w) => w.keys().copied().collect(),
2833            Err(_) => Vec::new(),
2834        }
2835    }
2836
2837    /// Returns the list of EntityIds of all registered readers.
2838    fn reader_eids(&self) -> Vec<EntityId> {
2839        match self.user_readers.read() {
2840            Ok(r) => r.keys().copied().collect(),
2841            Err(_) => Vec::new(),
2842        }
2843    }
2844
2845    /// Starts a new runtime for a participant.
2846    ///
2847    /// # Errors
2848    /// `TransportError` if one of the 3 UDP sockets fails to bind
2849    /// (e.g. a port collision on the SPDP multicast port in another
2850    /// SO_REUSE-less DDS instance).
2851    pub fn start(
2852        domain_id: i32,
2853        guid_prefix: GuidPrefix,
2854        mut config: RuntimeConfig,
2855    ) -> Result<Arc<Self>> {
2856        // C1 multicast-free discovery: merge the domain-aware env `ZERODDS_PEERS`
2857        // into the (programmatic) `config.initial_peers`. Default
2858        // is both empty → pure multicast behavior.
2859        config
2860            .initial_peers
2861            .extend(parse_initial_peers_env(domain_id as u32));
2862        // SPDP multicast receiver on the spec port.
2863        // u32 → u16 enforcing, the spec port is always < 65536.
2864        let spdp_port = u16::try_from(spdp_multicast_port(domain_id as u32)).map_err(|_| {
2865            DdsError::BadParameter {
2866                what: "domain_id too large for SPDP port mapping",
2867            }
2868        })?;
2869        // Enforce the opt-in multicast allowlist: the SPDP group is the only
2870        // multicast ZeroDDS joins, so if the operator set an allowlist that
2871        // does not include it, refuse rather than silently touching a group
2872        // they excluded.
2873        if !config.multicast_allowed(config.spdp_multicast_group) {
2874            return Err(DdsError::BadParameter {
2875                what: "spdp_multicast_group is not in multicast_allowlist",
2876            });
2877        }
2878        let spdp_mc = UdpTransport::bind_multicast_v4(
2879            config.spdp_multicast_group,
2880            spdp_port,
2881            config.multicast_interface,
2882        )
2883        .map_err(|_| DdsError::TransportError {
2884            label: "spdp multicast bind",
2885        })?
2886        // Sprint D.5b: recv sockets have their own thread that
2887        // blocks waiting for data. Timeout 1 s = stop-flag polling
2888        // granularity at shutdown, NOT the tick rhythm.
2889        .with_timeout(Some(Duration::from_secs(1)))
2890        .map_err(|_| DdsError::TransportError {
2891            label: "spdp multicast set_timeout",
2892        })?;
2893
2894        // SPDP unicast: bind to the **well-known** RTPS port
2895        // (7400+250*domain+10+2*pid, Spec §9.6.1.4.1), so a
2896        // configured unicast initial peer can reach this participant
2897        // WITHOUT prior multicast (C1 multicast-free
2898        // discovery). Participant index 0,1,2,… until a free port
2899        // is found (multiple participants per host, also alongside
2900        // Cyclone/FastDDS). Fallback ephemeral if all well-known
2901        // ports are taken (then multicast discovery only).
2902        // Interface pinning (ZERODDS_INTERFACE): UNSPECIFIED = auto. If
2903        // set, ALL IP sockets bind (SPDP-uc, SPDP-mc-tx, user UDP/TCP)
2904        // to this IP → announce + egress + receive on exactly this
2905        // interface (multi-homed robustness, cf. Cyclone `NetworkInterface`).
2906        let pinned = config.multicast_interface;
2907        let (spdp_uc_raw, _spdp_participant_id) = {
2908            let mut bound = None;
2909            for pid in 0u32..120 {
2910                let Ok(port) = u16::try_from(spdp_unicast_port(domain_id as u32, pid)) else {
2911                    break;
2912                };
2913                if let Ok(sock) = UdpTransport::bind_v4(pinned, port) {
2914                    bound = Some((sock, pid));
2915                    break;
2916                }
2917            }
2918            match bound {
2919                Some(b) => b,
2920                None => (
2921                    UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2922                        label: "spdp unicast bind",
2923                    })?,
2924                    u32::MAX,
2925                ),
2926            }
2927        };
2928        let spdp_uc = spdp_uc_raw
2929            .with_timeout(Some(Duration::from_secs(1)))
2930            .map_err(|_| DdsError::TransportError {
2931                label: "spdp unicast set_timeout",
2932            })?;
2933
2934        // User-data unicast (ephemeral port). Transport choice primarily via
2935        // `RuntimeConfig::user_transport`, fallback to the env var
2936        // `ZERODDS_USER_TRANSPORT` (bench binaries), otherwise UDPv4.
2937        // SPDP multicast stays UDPv4 — the DDSI-RTPS spec mandates
2938        // 239.255.0.1 for cross-vendor discovery; v6-only hosts
2939        // cannot discover cross-vendor (its own sprint).
2940        let (user_uc, tcp_accept_handle): (Arc<dyn Transport + Send + Sync>, _) =
2941            if !config.user_transports.is_empty() {
2942                // Multi-transport: build each kind and layer them. Preference =
2943                // config order (first match by destination locator kind wins).
2944                let mut legs: alloc::vec::Vec<Arc<dyn Transport + Send + Sync>> =
2945                    alloc::vec::Vec::new();
2946                let mut tcp_handle = None;
2947                for kind in &config.user_transports {
2948                    let (leg, tcp) = select_user_transport(*kind, guid_prefix, domain_id, pinned)?;
2949                    legs.push(leg);
2950                    if tcp.is_some() {
2951                        tcp_handle = tcp;
2952                    }
2953                }
2954                let layered = Arc::new(crate::layered_transport::LayeredUserTransport::new(legs));
2955                (layered, tcp_handle)
2956            } else {
2957                let user_transport_kind = config
2958                    .user_transport
2959                    .or_else(parse_user_transport_env)
2960                    .unwrap_or(UserTransportKind::UdpV4);
2961                select_user_transport(user_transport_kind, guid_prefix, domain_id, pinned)?
2962            };
2963
2964        // Separate sender socket for the SPDP announce. Ephemeral port; with
2965        // interface pinning it binds to the pinned IP (egress source), otherwise
2966        // `0.0.0.0` (the kernel picks the outgoing interface per route).
2967        let spdp_mc_tx =
2968            UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2969                label: "spdp mc-tx bind",
2970            })?;
2971
2972        let stop = Arc::new(AtomicBool::new(false));
2973
2974        // Materialize beacon locators for cross-host interop:
2975        // with a `0.0.0.0` bind address (UNSPECIFIED) the peer would
2976        // otherwise learn a non-routable address. We resolve UNSPECIFIED
2977        // via a UDP connect probe to a non-routable IP
2978        // (no traffic, just the routing table) and announce the
2979        // resulting local interface address — cross-host-capable
2980        // without an external crate dependency.
2981        let user_locator = announce_locator(&*user_uc, config.multicast_interface);
2982        let spdp_uc_locator = announce_locator(&spdp_uc, config.multicast_interface);
2983        let participant_data = ParticipantBuiltinTopicData {
2984            guid: Guid::new(guid_prefix, EntityId::PARTICIPANT),
2985            protocol_version: ProtocolVersion::V2_5,
2986            vendor_id: VendorId::ZERODDS,
2987            default_unicast_locator: Some(user_locator),
2988            default_multicast_locator: None,
2989            metatraffic_unicast_locator: Some(spdp_uc_locator),
2990            metatraffic_multicast_locator: Some(Locator {
2991                kind: LocatorKind::UdpV4,
2992                port: u32::from(spdp_port),
2993                address: {
2994                    let mut a = [0u8; 16];
2995                    a[12..].copy_from_slice(&config.spdp_multicast_group.octets());
2996                    a
2997                },
2998            }),
2999            domain_id: Some(domain_id as u32),
3000            // We announce the endpoints we actually
3001            // implement: SPDP (participant ann/det) + SEDP
3002            // (publications/subscriptions ann+det) + WLP (10/11) +
3003            // TypeLookup service (12/13). Cyclone/Fast-DDS filter
3004            // their proxy setup by these flags — without them
3005            // we get no SEDP/WLP peers. SEDP topic
3006            // endpoints (bits 28/29) are optional per RTPS 2.5 §8.5.4.4
3007            // and covered in ZeroDDS via synthetic DCPSTopic
3008            // derivation from pub/sub — we do not announce them,
3009            // otherwise we promise peers a non-existent
3010            // endpoint pairing. When the caller sets
3011            // `announce_secure_endpoints = true` (security
3012            // factory path), we additionally mix in the 12 secure
3013            // discovery bits (16..27, DDS-Security 1.2 §7.4.7.1).
3014            builtin_endpoint_set: {
3015                let mut mask = endpoint_flag::ALL_STANDARD;
3016                if config.announce_secure_endpoints {
3017                    mask |= endpoint_flag::ALL_SECURE;
3018                }
3019                mask
3020            },
3021            // Spec default lease = 100 s; configurable via
3022            // `RuntimeConfig::participant_lease_duration`.
3023            lease_duration: qos_duration_from_std(config.participant_lease_duration),
3024            // UserData on the participant — filled from
3025            // `DomainParticipantQos::user_data` via RuntimeConfig.
3026            user_data: config.user_data.clone(),
3027            // PROPERTY_LIST: security fills this with security caps
3028            // once a PolicyEngine is configured. Default-empty
3029            // stays backward-compatible with legacy peers.
3030            properties: Default::default(),
3031            // IdentityToken/PermissionsToken are filled by the security
3032            // layer once authentication + access control are
3033            // initialized. Default `None` = legacy announce.
3034            identity_token: None,
3035            permissions_token: None,
3036            identity_status_token: None,
3037            sig_algo_info: None,
3038            kx_algo_info: None,
3039            sym_cipher_algo_info: None,
3040            // Filled by the security layer (enable_security_builtins*) —
3041            // without PID_PARTICIPANT_SECURITY_INFO foreign vendors classify
3042            // us as non-secure. Default None = legacy/plain.
3043            participant_security_info: None,
3044        };
3045        let beacon = SpdpBeacon::new(participant_data.clone());
3046        let sedp = SedpStack::new(guid_prefix, VendorId::ZERODDS);
3047        // In-process discovery fastpath: remember the multicast group before
3048        // `config` is moved into the struct literal.
3049        let inproc_group = config.spdp_multicast_group;
3050
3051        #[cfg(feature = "security")]
3052        let outbound_pool = if config.interface_bindings.is_empty() {
3053            None
3054        } else {
3055            Some(Arc::new(OutboundSocketPool::bind_all(
3056                &config.interface_bindings,
3057            )?))
3058        };
3059
3060        // WLP endpoint (RTPS 2.5 §8.4.13). The tick period is explicit
3061        // `wlp_period`, or `lease/3` when `wlp_period == ZERO`
3062        // (spec recommendation: three misses before the reader marks the
3063        // writer as not-alive).
3064        let wlp_tick_period = if config.wlp_period.is_zero() {
3065            config.participant_lease_duration / 3
3066        } else {
3067            config.wlp_period
3068        };
3069        let wlp = crate::wlp::WlpEndpoint::new(guid_prefix, VendorId::ZERODDS, wlp_tick_period);
3070
3071        let rt = Arc::new(Self {
3072            guid_prefix,
3073            domain_id,
3074            spdp_multicast_rx: Arc::new(spdp_mc),
3075            spdp_unicast: Arc::new(spdp_uc),
3076            user_unicast: user_uc,
3077            user_announce_locator: user_locator,
3078            spdp_mc_tx: Arc::new(spdp_mc_tx),
3079            spdp_beacon: Mutex::new(beacon),
3080            participant_data,
3081            announced_pubs: Mutex::new(Vec::new()),
3082            announced_subs: Mutex::new(Vec::new()),
3083            spdp_reader: SpdpReader::new(),
3084            discovered: Arc::new(Mutex::new(DiscoveredParticipantsCache::new())),
3085            spdp_relay_cache: Mutex::new(alloc::collections::BTreeMap::new()),
3086            sedp: Arc::new(Mutex::new(sedp)),
3087            type_lookup_endpoints: TypeLookupEndpoints::new(guid_prefix),
3088            type_lookup_server: Arc::new(Mutex::new(TypeLookupServer::new())),
3089            type_lookup_client: Arc::new(Mutex::new(TypeLookupClient::new())),
3090            tl_reply_sn: core::sync::atomic::AtomicU64::new(0),
3091            security_builtin: Mutex::new(None),
3092            start_instant: Instant::now(),
3093            user_writers: Arc::new(RwLock::new(BTreeMap::new())),
3094            shm_locators: Arc::new(RwLock::new(BTreeMap::new())),
3095            same_host: Arc::new(crate::same_host::SameHostTracker::new()),
3096            user_readers: Arc::new(RwLock::new(BTreeMap::new())),
3097            #[cfg(feature = "security")]
3098            endpoint_tokens_sent: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
3099            #[cfg(feature = "security")]
3100            sedp_reannounced: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
3101            #[cfg(feature = "security")]
3102            endpoint_crypto: Arc::new(RwLock::new(alloc::collections::BTreeMap::new())),
3103            intra_runtime_routes: Arc::new(RwLock::new(BTreeMap::new())),
3104            entity_counter: AtomicU32::new(1),
3105            config,
3106            stop: stop.clone(),
3107            tick_seq: AtomicU64::new(0),
3108            spdp_announce_seq: AtomicU64::new(0),
3109            inconsistent_topic_seq: AtomicU64::new(0),
3110            tick_wake: Mutex::new(None),
3111            tick_wake_pending: AtomicBool::new(false),
3112            handles: Mutex::new(Vec::new()),
3113            match_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
3114            ack_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
3115            #[cfg(feature = "security")]
3116            outbound_pool,
3117            wlp: Arc::new(Mutex::new(wlp)),
3118            builtin_sinks: Mutex::new(None),
3119            ignore_filter: Mutex::new(None),
3120        });
3121
3122        // In-process discovery fastpath: register the runtime in the process
3123        // registry so same-process+domain peers find each other
3124        // deterministically (see `crate::inproc`). Right
3125        // after, `pull-on-creation`: pull all already-announced endpoints
3126        // of existing peers into our SEDP cache — otherwise
3127        // we see peers that announced endpoints BEFORE us
3128        // only via the (lossy) UDP SEDP path.
3129        crate::inproc::register(&rt, domain_id, inproc_group);
3130        rt.inproc_pull_from_peers();
3131
3132        // Per-socket recv threads + one tick thread (Sprint D.5b).
3133        //
3134        // Previously the entire stack ran in a single event loop
3135        // that went through three blocking `recv()`s with a `tick_period`
3136        // timeout (50 ms) sequentially per iteration. On a
3137        // roundtrip each stage waited up to 50 ms for timeouts of the
3138        // front sockets before its own datagram got its turn —
3139        // yielded 5-14 ms p50.
3140        //
3141        // Refit: every relevant recv path has its own thread
3142        // that sits directly blocking on its socket and dispatches
3143        // immediately when data arrives. The tick thread does the
3144        // periodic outbound work (HEARTBEAT/resend/ACKNACK/
3145        // SPDP announce/deadline/lifespan/liveliness) and sleeps
3146        // `tick_period` between iterations.
3147        //
3148        // Lock order (deadlock avoidance): the tick thread and
3149        // recv threads contend for `rt.sedp.lock()` / `rt.wlp.lock()`.
3150        // Convention: keep lock-hold times short (handle_datagram /
3151        // tick are both fast), do not take a sub-lock under the `sedp`
3152        // or `wlp` lock.
3153        let mut handles_init: Vec<JoinHandle<()>> = Vec::with_capacity(4);
3154
3155        let rt_recv_spdp_mc = Arc::clone(&rt);
3156        let stop_recv_spdp_mc = stop.clone();
3157        handles_init.push(
3158            thread::Builder::new()
3159                .name(String::from("zdds-recv-spdp-mc"))
3160                .spawn(move || recv_spdp_multicast_loop(rt_recv_spdp_mc, stop_recv_spdp_mc))
3161                .map_err(|_| DdsError::PreconditionNotMet {
3162                    reason: "spawn zdds-recv-spdp-mc thread",
3163                })?,
3164        );
3165
3166        let rt_recv_meta = Arc::clone(&rt);
3167        let stop_recv_meta = stop.clone();
3168        handles_init.push(
3169            thread::Builder::new()
3170                .name(String::from("zdds-recv-meta"))
3171                .spawn(move || recv_metatraffic_loop(rt_recv_meta, stop_recv_meta))
3172                .map_err(|_| DdsError::PreconditionNotMet {
3173                    reason: "spawn zdds-recv-meta thread",
3174                })?,
3175        );
3176
3177        let rt_recv_user = Arc::clone(&rt);
3178        let stop_recv_user = stop.clone();
3179        let primary_socket = Arc::clone(&rt.user_unicast);
3180        handles_init.push(
3181            thread::Builder::new()
3182                .name(String::from("zdds-recv-user"))
3183                .spawn(move || recv_user_data_loop(rt_recv_user, primary_socket, stop_recv_user))
3184                .map_err(|_| DdsError::PreconditionNotMet {
3185                    reason: "spawn zdds-recv-user thread",
3186                })?,
3187        );
3188
3189        // TCPv4 variant: a separate accept worker (TcpTransport has
3190        // no implicit accept thread in the constructor — accept_one()
3191        // must be called explicitly).
3192        if let Some(tcp_arc) = tcp_accept_handle {
3193            let stop_accept = stop.clone();
3194            handles_init.push(
3195                thread::Builder::new()
3196                    .name(String::from("zdds-tcp-accept"))
3197                    .spawn(move || {
3198                        while !stop_accept.load(Ordering::Relaxed) {
3199                            // accept_one() blocks until connection +
3200                            // handshake; on EOF it returns Ok(()) and
3201                            // we accept the next peer.
3202                            let _ = tcp_arc.accept_one();
3203                        }
3204                    })
3205                    .map_err(|_| DdsError::PreconditionNotMet {
3206                        reason: "spawn zdds-tcp-accept thread",
3207                    })?,
3208            );
3209        }
3210
3211        // Opt-3 (Spec `zerodds-zero-copy-1.0` §9): additional
3212        // SO_REUSEPORT recv workers. Each binds to the same
3213        // user_unicast port; the kernel distributes incoming datagrams via
3214        // flow hash. On a bind error (e.g. a platform without
3215        // SO_REUSEPORT support) the worker is skipped and the
3216        // runtime continues with the available workers.
3217        if rt.config.extra_recv_threads > 0 {
3218            let user_port = u16::try_from(rt.user_unicast.local_locator().port).unwrap_or(0);
3219            // With an active security config, share the first interface bind address;
3220            // otherwise INADDR_ANY (the kernel chooses).
3221            #[cfg(feature = "security")]
3222            let bind_addr = rt
3223                .config
3224                .interface_bindings
3225                .first()
3226                .map(|spec| spec.bind_addr)
3227                .unwrap_or(Ipv4Addr::UNSPECIFIED);
3228            #[cfg(not(feature = "security"))]
3229            let bind_addr = Ipv4Addr::UNSPECIFIED;
3230            for i in 0..rt.config.extra_recv_threads {
3231                let extra_socket =
3232                    match UdpTransport::bind_v4_reuse(bind_addr, user_port) {
3233                        Ok(t) => Arc::new(t.with_timeout(Some(Duration::from_secs(1))).map_err(
3234                            |_| DdsError::TransportError {
3235                                label: "extra-recv set_timeout failed",
3236                            },
3237                        )?),
3238                        Err(_) => break, // SO_REUSEPORT not available — skip.
3239                    };
3240                let rt_extra = Arc::clone(&rt);
3241                let stop_extra = stop.clone();
3242                let name = format!("zdds-recv-user-{}", i + 1);
3243                handles_init.push(
3244                    thread::Builder::new()
3245                        .name(name)
3246                        .spawn(move || recv_user_data_loop(rt_extra, extra_socket, stop_extra))
3247                        .map_err(|_| DdsError::PreconditionNotMet {
3248                            reason: "spawn zdds-recv-user-N thread",
3249                        })?,
3250                );
3251            }
3252        }
3253
3254        // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6): per-owner
3255        // SHM recv loop. Polls all bound consumer entries of the
3256        // SameHostTracker round-robin and dispatches incoming
3257        // frames analogous to the UDP path. Only compiled when
3258        // the `same-host-shm` feature is on.
3259        #[cfg(feature = "same-host-shm")]
3260        {
3261            let rt_recv_shm = Arc::clone(&rt);
3262            let stop_recv_shm = stop.clone();
3263            handles_init.push(
3264                thread::Builder::new()
3265                    .name(String::from("zdds-recv-shm"))
3266                    .spawn(move || recv_user_shm_loop(rt_recv_shm, stop_recv_shm))
3267                    .map_err(|_| DdsError::PreconditionNotMet {
3268                        reason: "spawn zdds-recv-shm thread",
3269                    })?,
3270            );
3271        }
3272
3273        // zerodds-async-1.0 §4: with `external_tick`, the tick loop is driven
3274        // by an external executor (tokio via `spawn_in_tokio`) rather than a
3275        // dedicated thread — so we skip the spawn here. `stop` is dropped; the
3276        // driver observes shutdown via `rt.stop` (set in `Drop`/`stop()`).
3277        if rt.config.external_tick {
3278            drop(stop);
3279        } else if rt.config.scheduler_tick {
3280            // D.5e Phase 3 — event-driven scheduler tick. Create the scheduler
3281            // up front, publish its wake handle so recv loops + the write path
3282            // can `raise_tick_wake`, then drive the (unchanged) tick from the
3283            // deadline-heap worker.
3284            let (scheduler, handle) =
3285                crate::scheduler::Scheduler::<TickEvent>::new(SCHEDULER_IDLE_FLOOR);
3286            if let Ok(mut g) = rt.tick_wake.lock() {
3287                *g = Some(handle.clone());
3288            }
3289            let rt_tick = Arc::clone(&rt);
3290            let stop_tick = stop;
3291            handles_init.push(
3292                thread::Builder::new()
3293                    .name(String::from("zdds-tick-sched"))
3294                    .spawn(move || scheduler_tick_loop(rt_tick, stop_tick, scheduler, handle))
3295                    .map_err(|_| DdsError::PreconditionNotMet {
3296                        reason: "spawn zdds-tick-sched thread",
3297                    })?,
3298            );
3299        } else {
3300            let rt_tick = Arc::clone(&rt);
3301            let stop_tick = stop;
3302            handles_init.push(
3303                thread::Builder::new()
3304                    .name(String::from("zdds-tick"))
3305                    .spawn(move || tick_loop(rt_tick, stop_tick))
3306                    .map_err(|_| DdsError::PreconditionNotMet {
3307                        reason: "spawn zdds-tick thread",
3308                    })?,
3309            );
3310        }
3311
3312        let mut guard = rt
3313            .handles
3314            .lock()
3315            .map_err(|_| DdsError::PreconditionNotMet {
3316                reason: "runtime handles mutex poisoned",
3317            })?;
3318        *guard = handles_init;
3319        drop(guard);
3320
3321        Ok(rt)
3322    }
3323
3324    /// Local unicast locator for user data (announced in SPDP).
3325    #[must_use]
3326    pub fn user_locator(&self) -> zerodds_rtps::wire_types::Locator {
3327        self.user_unicast.local_locator()
3328    }
3329
3330    /// Local unicast locator for SPDP metatraffic.
3331    #[must_use]
3332    pub fn spdp_unicast_locator(&self) -> zerodds_rtps::wire_types::Locator {
3333        self.spdp_unicast.local_locator()
3334    }
3335
3336    /// Returns the `BuiltinEndpointSet` bitmask that the runtime
3337    /// currently announces in the SPDP beacon. Used for tests + diagnostics;
3338    /// production consumers should decode the SPDP beacon
3339    /// themselves.
3340    #[must_use]
3341    pub fn announced_builtin_endpoint_set(&self) -> u32 {
3342        self.spdp_beacon
3343            .lock()
3344            .map(|b| b.data.builtin_endpoint_set)
3345            .unwrap_or(0)
3346    }
3347
3348    /// Registers a `TypeObject` in the local TypeLookup server
3349    /// registry. Other participants can then query this type via
3350    /// a `getTypes` request (XTypes 1.3 §7.6.3.3.4).
3351    ///
3352    /// Returns the `EquivalenceHash` of the registered type
3353    /// (the caller can embed it e.g. in `PublicationBuiltinTopicData` as a
3354    /// PID_TYPE_INFORMATION hint).
3355    ///
3356    /// # Errors
3357    /// `DdsError::PreconditionNotMet` on lock poisoning or a hash
3358    /// computation error.
3359    pub fn register_type_object(
3360        &self,
3361        obj: zerodds_types::type_object::TypeObject,
3362    ) -> Result<zerodds_types::EquivalenceHash> {
3363        let hash = zerodds_types::compute_hash(&obj).map_err(|_| DdsError::PreconditionNotMet {
3364            reason: "type hash computation failed",
3365        })?;
3366        let mut server =
3367            self.type_lookup_server
3368                .lock()
3369                .map_err(|_| DdsError::PreconditionNotMet {
3370                    reason: "type_lookup_server mutex poisoned",
3371                })?;
3372        match obj {
3373            zerodds_types::type_object::TypeObject::Minimal(m) => {
3374                server.registry.insert_minimal(hash, m);
3375            }
3376            zerodds_types::type_object::TypeObject::Complete(c) => {
3377                server.registry.insert_complete(hash, c);
3378            }
3379            _ => {
3380                return Err(DdsError::PreconditionNotMet {
3381                    reason: "unknown TypeObject variant",
3382                });
3383            }
3384        }
3385        Ok(hash)
3386    }
3387
3388    /// Sends a `getTypes` request to a discovered peer and
3389    /// returns a `RequestId` with which the caller can correlate the
3390    /// asynchronous reply later (XTypes 1.3
3391    /// §7.6.3.3.4 + `TypeLookupClient::handle_reply`).
3392    ///
3393    /// `peer` must be in `discovered_participants()` — otherwise
3394    /// `None` is returned (no known peer locator). On a
3395    /// successful send the request sample-identity sequence
3396    /// is returned as the `RequestId`; an incoming reply is correlated on
3397    /// this sequence ID.
3398    ///
3399    /// # Errors
3400    /// `DdsError::PreconditionNotMet` on encode errors or lock
3401    /// poisoning.
3402    pub fn send_type_lookup_request(
3403        &self,
3404        peer: zerodds_rtps::wire_types::GuidPrefix,
3405        type_hashes: &[zerodds_types::EquivalenceHash],
3406    ) -> Result<Option<zerodds_discovery::type_lookup::RequestId>> {
3407        use alloc::sync::Arc as AllocArc;
3408        use zerodds_discovery::type_lookup::request_types_payload;
3409        use zerodds_rtps::datagram::encode_data_datagram;
3410        use zerodds_rtps::header::RtpsHeader;
3411        use zerodds_rtps::submessages::DataSubmessage;
3412        use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber};
3413
3414        // Find peer's user-unicast locator (default-unicast first;
3415        // fallback metatraffic-unicast). TypeLookup datagrams go via
3416        // the user-unicast path — the peer DCPS runtime has a
3417        // shared receive loop there for SEDP/user data/TypeLookup.
3418        let target = {
3419            let discovered = self
3420                .discovered
3421                .lock()
3422                .map_err(|_| DdsError::PreconditionNotMet {
3423                    reason: "discovered mutex poisoned",
3424                })?;
3425            let Some(dp) = discovered.get(&peer) else {
3426                return Ok(None);
3427            };
3428            dp.data
3429                .default_unicast_locator
3430                .or(dp.data.metatraffic_unicast_locator)
3431        };
3432        let Some(target) = target else {
3433            return Ok(None);
3434        };
3435
3436        // Allocate RequestId (client-side incrementing sequence). Reply
3437        // correlation runs via the `handle_reply` callback. We
3438        // register a callback that feeds the returned
3439        // TypeObjects into the local `TypeLookupServer.registry`
3440        // (XTypes 1.3 §7.6.3.3.4): hash-by-hash, separately
3441        // for Minimal and Complete variants. This way a hash that
3442        // was resolved once is recognized for future `has_type_for_hash`
3443        // checks (= no re-requests).
3444        let mut client =
3445            self.type_lookup_client
3446                .lock()
3447                .map_err(|_| DdsError::PreconditionNotMet {
3448                    reason: "type_lookup_client mutex poisoned",
3449                })?;
3450        let type_ids: alloc::vec::Vec<zerodds_types::TypeIdentifier> = type_hashes
3451            .iter()
3452            .map(|h| zerodds_types::TypeIdentifier::EquivalenceHashMinimal(*h))
3453            .collect();
3454        let server_for_cb = Arc::clone(&self.type_lookup_server);
3455        let cb = Box::new(
3456            move |reply: zerodds_discovery::type_lookup::TypeLookupReply| {
3457                let zerodds_discovery::type_lookup::TypeLookupReply::Types(types_reply) = reply
3458                else {
3459                    return;
3460                };
3461                let Ok(mut server) = server_for_cb.lock() else {
3462                    return;
3463                };
3464                for t in &types_reply.types {
3465                    match t {
3466                        zerodds_types::type_lookup::ReplyTypeObject::Minimal(m) => {
3467                            let to = zerodds_types::type_object::TypeObject::Minimal(m.clone());
3468                            if let Ok(h) = zerodds_types::compute_hash(&to) {
3469                                server.registry.insert_minimal(h, m.clone());
3470                            }
3471                        }
3472                        zerodds_types::type_lookup::ReplyTypeObject::Complete(c) => {
3473                            let to = zerodds_types::type_object::TypeObject::Complete(c.clone());
3474                            if let Ok(h) = zerodds_types::compute_hash(&to) {
3475                                server.registry.insert_complete(h, c.clone());
3476                            }
3477                        }
3478                    }
3479                }
3480            },
3481        );
3482        let request_id = client.request_types(type_ids.clone(), cb);
3483        drop(client);
3484
3485        // Encode the wire request payload (PL_CDR_LE-Encapsulation).
3486        let body = request_types_payload(&type_ids).map_err(|_| DdsError::PreconditionNotMet {
3487            reason: "type_lookup request payload encode failed",
3488        })?;
3489        let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
3490        payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
3491        payload.extend_from_slice(&body);
3492
3493        // Use the RequestId as the writer_sn so the peer-side reply can
3494        // echo it for correlation (XTypes §7.6.3.3.3 Sample-Identity).
3495        let id_u64 = request_id.0;
3496        let sn =
3497            SequenceNumber::from_high_low((id_u64 >> 32) as i32, (id_u64 & 0xFFFF_FFFF) as u32);
3498        let header = RtpsHeader {
3499            protocol_version: ProtocolVersion::CURRENT,
3500            vendor_id: VendorId::ZERODDS,
3501            guid_prefix: self.guid_prefix,
3502        };
3503        let data = DataSubmessage {
3504            extra_flags: 0,
3505            reader_id: EntityId::TL_SVC_REQ_READER,
3506            writer_id: EntityId::TL_SVC_REQ_WRITER,
3507            writer_sn: sn,
3508            inline_qos: None,
3509            key_flag: false,
3510            non_standard_flag: false,
3511            serialized_payload: AllocArc::from(payload.into_boxed_slice()),
3512        };
3513        let datagram =
3514            encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
3515                reason: "type_lookup request datagram encode failed",
3516            })?;
3517
3518        if is_routable_user_locator(&target) {
3519            let _ = self.user_unicast.send(&target, &datagram);
3520        }
3521        Ok(Some(request_id))
3522    }
3523
3524    /// Activates the security builtin endpoint stack
3525    /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
3526    /// MessageSecure`). Typically called by the factory
3527    /// once a security plugin is registered on the participant.
3528    /// Idempotent: a second call has no effect. Returns the (possibly
3529    /// freshly created) stack handle.
3530    pub fn enable_security_builtins(
3531        &self,
3532        vendor_id: VendorId,
3533    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3534        self.install_security_stack(SecurityBuiltinStack::new(self.guid_prefix, vendor_id))
3535    }
3536
3537    /// Like [`enable_security_builtins`](Self::enable_security_builtins),
3538    /// but with an active auth-handshake driver (FU2 Gap 4). The stack
3539    /// is built via [`SecurityBuiltinStack::with_auth`]: the shared
3540    /// `auth` plugin (= the same instance that hangs on the crypto gate as
3541    /// the `SharedSecretProvider`) drives the PKI handshake as soon as
3542    /// a peer with stateless bits + identity token is discovered.
3543    ///
3544    /// `local_identity` comes from `validate_local_identity`; the local
3545    /// 16-byte participant GUID is derived from the `guid_prefix`.
3546    ///
3547    /// Idempotent (first-wins): if a stack is already active — even one
3548    /// built without auth — that one is returned and the freshly
3549    /// built one discarded.
3550    #[cfg(feature = "security")]
3551    pub fn enable_security_builtins_with_auth(
3552        self: &Arc<Self>,
3553        vendor_id: VendorId,
3554        auth: Arc<Mutex<dyn zerodds_security::authentication::AuthenticationPlugin>>,
3555        local_identity: zerodds_security::authentication::IdentityHandle,
3556    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3557        let local_guid = Guid::new(self.guid_prefix, EntityId::PARTICIPANT).to_bytes();
3558        // Announce the local IdentityToken in the SPDP beacon (PID_IDENTITY_TOKEN,
3559        // FU2 Gap 7c) + set the stateless/volatile-secure bits, so peers
3560        // initiate the auth handshake. Before moving `auth` into the stack.
3561        if let Ok(mut plugin) = auth.lock() {
3562            if let Ok(token) = plugin.get_identity_token(local_identity) {
3563                // PID_PERMISSIONS_TOKEN (§7.4.1.5, S4 point 1): secure
3564                // vendors (cyclone/FastDDS) start validate_remote_identity
3565                // only when SPDP carries identity_token AND permissions_token;
3566                // otherwise we stay non-secure and all endpoints are "not
3567                // allowed". Empty if no permissions are configured.
3568                let perm_token = plugin.get_permissions_token();
3569                let pdata = if let Ok(mut beacon) = self.spdp_beacon.lock() {
3570                    if !token.is_empty() {
3571                        beacon.data.identity_token = Some(token);
3572                    }
3573                    if !perm_token.is_empty() {
3574                        beacon.data.permissions_token = Some(perm_token);
3575                    }
3576                    // Full secure builtin endpoint set (§7.4.7.1): stateless +
3577                    // VolatileSecure (22-25) PLUS secure SEDP (16-19),
3578                    // secure ParticipantMessage (20-21) and DCPSParticipantsSecure
3579                    // (26-27). cyclone starts validate_remote_identity + creates the
3580                    // secure builtin proxies ONLY when the remote announces the full
3581                    // secure set (cyclone-trace-verified) — only
3582                    // 22-25 → "Non secure remote ... not allowed", no handshake.
3583                    beacon.data.builtin_endpoint_set |= endpoint_flag::PUBLICATIONS_SECURE_WRITER
3584                        | endpoint_flag::PUBLICATIONS_SECURE_READER
3585                        | endpoint_flag::SUBSCRIPTIONS_SECURE_WRITER
3586                        | endpoint_flag::SUBSCRIPTIONS_SECURE_READER
3587                        | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_WRITER
3588                        | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_READER
3589                        | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
3590                        | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
3591                        | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
3592                        | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
3593                        | endpoint_flag::PARTICIPANT_SECURE_WRITER
3594                        | endpoint_flag::PARTICIPANT_SECURE_READER;
3595                    // PID_PARTICIPANT_SECURITY_INFO (§7.4.1.6): marks us as a
3596                    // secure participant — mandatory, otherwise cyclone/
3597                    // FastDDS treat us as non-secure and reject all endpoints.
3598                    // IS_VALID on both masks; derive the participant-level
3599                    // ParticipantSecurityAttributes (§9.4.2.4) from the governance:
3600                    // is_{rtps,discovery,liveliness}_protected in the
3601                    // attr mask, is_*_encrypted in the plugin mask. cyclone
3602                    // matches the announced bits against its own governance —
3603                    // a null mask with e.g. discovery=ENCRYPT is a policy
3604                    // mismatch and cyclone then establishes NO secured
3605                    // participant crypto handshake (bug source protected discovery).
3606                    use zerodds_rtps::participant_security_info::{
3607                        ParticipantSecurityInfo, attrs, plugin_attrs,
3608                    };
3609                    let (mut a, mut p) = (attrs::IS_VALID, plugin_attrs::IS_VALID);
3610                    if let Some(gate) = self.config.security.as_ref() {
3611                        let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
3612                        let disc = gate.discovery_protection().unwrap_or(ProtectionLevel::None);
3613                        let live = gate
3614                            .liveliness_protection()
3615                            .unwrap_or(ProtectionLevel::None);
3616                        if rtps != ProtectionLevel::None {
3617                            a |= attrs::IS_RTPS_PROTECTED;
3618                        }
3619                        if disc != ProtectionLevel::None {
3620                            a |= attrs::IS_DISCOVERY_PROTECTED;
3621                        }
3622                        if live != ProtectionLevel::None {
3623                            a |= attrs::IS_LIVELINESS_PROTECTED;
3624                        }
3625                        if rtps == ProtectionLevel::Encrypt {
3626                            p |= plugin_attrs::IS_RTPS_ENCRYPTED;
3627                        }
3628                        if disc == ProtectionLevel::Encrypt {
3629                            p |= plugin_attrs::IS_DISCOVERY_ENCRYPTED;
3630                        }
3631                        if live == ProtectionLevel::Encrypt {
3632                            p |= plugin_attrs::IS_LIVELINESS_ENCRYPTED;
3633                        }
3634                    }
3635                    beacon.data.participant_security_info = Some(ParticipantSecurityInfo {
3636                        participant_security_attributes: a,
3637                        plugin_participant_security_attributes: p,
3638                    });
3639                    // c.pdata (§9.3.2.5.2, S4 root 6+7): our own
3640                    // ParticipantBuiltinTopicData as PL_CDR_**BE** — the replier
3641                    // (cyclone) deserializes c.pdata strictly as a big-endian
3642                    // ParameterList and binds the participant_guid to the
3643                    // authenticated identity. LE → "payload too long".
3644                    Some(beacon.data.to_pl_cdr_be())
3645                } else {
3646                    None
3647                };
3648                if let Some(pd) = pdata {
3649                    plugin.set_local_participant_data(pd);
3650                }
3651            }
3652        }
3653        let stack = self.install_security_stack(SecurityBuiltinStack::with_auth(
3654            self.guid_prefix,
3655            vendor_id,
3656            auth,
3657            local_identity,
3658            local_guid,
3659        ));
3660        // FU2 S3: kick off in-process participant discovery + handshake trigger
3661        // deterministically — decouples the secured discovery from the
3662        // flaky multicast path (codepit LXC). Bidirectional, idempotent.
3663        self.inproc_announce_participant();
3664        // FU2 S3: immediate token-carrying SPDP re-announce (event-driven).
3665        self.announce_spdp_now();
3666        stack
3667    }
3668
3669    /// Installs a freshly built `SecurityBuiltinStack` into the
3670    /// runtime slot (idempotent) and catches up on peers already
3671    /// discovered via SPDP. Shared core of
3672    /// [`enable_security_builtins`](Self::enable_security_builtins) and
3673    /// [`enable_security_builtins_with_auth`](Self::enable_security_builtins_with_auth).
3674    fn install_security_stack(
3675        &self,
3676        fresh: SecurityBuiltinStack,
3677    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3678        // Lock poisoning is a bug indicator here (an earlier panic in the
3679        // hot path). In that case we return a fresh, isolated
3680        // stack — the caller gets at least a
3681        // functional slot, but the hot path writes its mutations
3682        // into the unlocked original. In production code this does not happen;
3683        // in tests (where poisoning can occur) this is a
3684        // best-effort recovery.
3685        let mut slot = match self.security_builtin.lock() {
3686            Ok(g) => g,
3687            Err(_) => {
3688                return Arc::new(Mutex::new(fresh));
3689            }
3690        };
3691        if let Some(existing) = slot.as_ref() {
3692            return Arc::clone(existing);
3693        }
3694        let stack = Arc::new(Mutex::new(fresh));
3695        // Catch up on already-discovered peers (discovery may have already
3696        // seen SPDP beacons before the plugin was activated).
3697        if let Ok(cache) = self.discovered.lock() {
3698            if let Ok(mut s) = stack.lock() {
3699                for peer in cache.iter() {
3700                    s.handle_remote_endpoints(peer);
3701                }
3702            }
3703        }
3704        *slot = Some(Arc::clone(&stack));
3705        // Protected discovery (DDS-Security §8.4.2.4): if the governance demands
3706        // `discovery_protection_kind != NONE`, the SedpStack routes secured
3707        // endpoints via the secure SEDP (DCPSPublicationsSecure/Subscriptions
3708        // Secure) instead of plaintext — the runtime send path protects their DATA/
3709        // HEARTBEAT/GAP with the participant data key. Set before the first
3710        // announce_* (endpoint creation follows the security activation).
3711        #[cfg(feature = "security")]
3712        if let Some(gate) = self.config.security.as_ref() {
3713            let protected = gate
3714                .discovery_protection()
3715                .map(|l| l != ProtectionLevel::None)
3716                .unwrap_or(false);
3717            if protected {
3718                if let Ok(mut sedp) = self.sedp.lock() {
3719                    sedp.set_discovery_protected(true);
3720                }
3721            }
3722        }
3723        stack
3724    }
3725
3726    /// Snapshot handle on the security builtin stack. `None` if
3727    /// [`enable_security_builtins`](Self::enable_security_builtins)
3728    /// has not been called yet.
3729    #[must_use]
3730    pub fn security_builtin_snapshot(&self) -> Option<Arc<Mutex<SecurityBuiltinStack>>> {
3731        self.security_builtin.lock().ok()?.as_ref().map(Arc::clone)
3732    }
3733
3734    /// `assert_liveliness()` on the `DomainParticipant` (DCPS 1.4
3735    /// §2.2.3.11 MANUAL_BY_PARTICIPANT). Sends exactly one WLP heartbeat
3736    /// with `kind = MANUAL_BY_PARTICIPANT` on the next tick;
3737    /// all readers matching this participant refresh their
3738    /// last-seen timestamp. Idempotent — multiple calls within
3739    /// one tick period result in multiple wire sends up to the
3740    /// cap (`MAX_QUEUED_PULSES = 32`).
3741    pub fn assert_liveliness(&self) {
3742        if let Ok(mut wlp) = self.wlp.lock() {
3743            wlp.assert_participant();
3744        }
3745    }
3746
3747    /// `assert_liveliness()` on a `DataWriter` (DCPS 1.4 §2.2.3.11
3748    /// MANUAL_BY_TOPIC). `topic_token` is an opaque token that
3749    /// matching readers can use to associate the pulse with a concrete
3750    /// topic. We use the ZeroDDS vendor kind (Cyclone /
3751    /// Fast-DDS ignore the vendor kind, which is spec-conformant —
3752    /// MSB-set in `kind` requests "ignore unknown" behavior).
3753    pub fn assert_writer_liveliness(&self, topic_token: Vec<u8>) {
3754        if let Ok(mut wlp) = self.wlp.lock() {
3755            wlp.assert_topic(topic_token);
3756        }
3757    }
3758
3759    /// Current WLP last-seen timestamp of a remote peer (relative
3760    /// to runtime start). `None` if the peer has not sent a WLP
3761    /// heartbeat yet.
3762    #[must_use]
3763    pub fn peer_liveliness_last_seen(&self, prefix: &GuidPrefix) -> Option<Duration> {
3764        self.wlp
3765            .lock()
3766            .ok()
3767            .and_then(|w| w.peer_state(prefix).map(|s| s.last_seen))
3768    }
3769
3770    /// Returns the [`zerodds_discovery::PeerCapabilities`] of a remote
3771    /// peer, based on its most recently received SPDP beacon.
3772    /// `None` if the peer has not been discovered via SPDP yet.
3773    #[must_use]
3774    pub fn peer_capabilities(
3775        &self,
3776        prefix: &GuidPrefix,
3777    ) -> Option<zerodds_discovery::PeerCapabilities> {
3778        self.discovered
3779            .lock()
3780            .ok()
3781            .and_then(|d| d.get(prefix).map(|p| p.data.builtin_endpoint_set))
3782            .map(zerodds_discovery::PeerCapabilities::from_bits)
3783    }
3784
3785    /// Snapshot of the currently discovered remote participants.
3786    /// Key = GUID prefix, value = last seen beacon content.
3787    #[must_use]
3788    pub fn discovered_participants(&self) -> Vec<DiscoveredParticipant> {
3789        self.discovered
3790            .lock()
3791            .map(|cache| cache.iter().cloned().collect())
3792            .unwrap_or_default()
3793    }
3794
3795    /// Wires the `BuiltinSinks` of the `DomainParticipant` into the
3796    /// discovery hot path. From this
3797    /// call on, all SPDP/SEDP receive events land as samples in
3798    /// the 4 builtin-topic readers.
3799    ///
3800    /// Called by the `DomainParticipant` constructor exactly once during
3801    /// setup.
3802    pub fn attach_builtin_sinks(&self, sinks: crate::builtin_subscriber::BuiltinSinks) {
3803        if let Ok(mut guard) = self.builtin_sinks.lock() {
3804            *guard = Some(sinks);
3805        }
3806    }
3807
3808    /// Snapshot of the currently wired BuiltinSinks (internal, for the
3809    /// hot path).
3810    pub(crate) fn builtin_sinks_snapshot(&self) -> Option<crate::builtin_subscriber::BuiltinSinks> {
3811        self.builtin_sinks.lock().ok().and_then(|g| g.clone())
3812    }
3813
3814    /// Wires the `IgnoreFilter` of the `DomainParticipant` into the
3815    /// discovery hot path. From
3816    /// this call on, SPDP/SEDP receive events are checked against the
3817    /// filter before being pushed as a builtin sample or used as an
3818    /// SEDP match source.
3819    ///
3820    /// Called by the `DomainParticipant` constructor exactly once during
3821    /// setup.
3822    pub fn attach_ignore_filter(&self, filter: crate::participant::IgnoreFilter) {
3823        if let Ok(mut guard) = self.ignore_filter.lock() {
3824            *guard = Some(filter);
3825        }
3826    }
3827
3828    /// Snapshot of the currently wired IgnoreFilter (internal, for
3829    /// the hot path).
3830    pub(crate) fn ignore_filter_snapshot(&self) -> Option<crate::participant::IgnoreFilter> {
3831        self.ignore_filter.lock().ok().and_then(|g| g.clone())
3832    }
3833
3834    /// Synchronizes the protected-discovery flag of the `SedpStack` with the
3835    /// governance (`discovery_protection_kind`). Idempotent, called before every
3836    /// `announce_*` — so the flag is set correctly regardless of the
3837    /// order in which security activation and endpoint creation ran.
3838    #[cfg(feature = "security")]
3839    fn sync_sedp_discovery_protected(&self, sedp: &mut SedpStack) {
3840        if let Some(gate) = self.config.security.as_ref() {
3841            let protected = gate
3842                .discovery_protection()
3843                .map(|l| l != ProtectionLevel::None)
3844                .unwrap_or(false);
3845            sedp.set_discovery_protected(protected);
3846        }
3847    }
3848
3849    /// Announces a local publication via SEDP. The runtime
3850    /// sends the generated datagrams immediately to all already-
3851    /// discovered remote participants.
3852    ///
3853    /// # Errors
3854    /// `WireError` if encoding fails.
3855    pub fn announce_publication(
3856        &self,
3857        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3858    ) -> Result<()> {
3859        // In-process discovery fastpath: put it in the stash so a
3860        // peer runtime starting later in the same process can pull us
3861        // via `inproc_snapshot`.
3862        if let Ok(mut v) = self.announced_pubs.lock() {
3863            v.push(data.clone());
3864        }
3865        // ADR-0006: side-map lookup. If the local user writer has a
3866        // same-host backend attached (set_shm_locator was
3867        // called), we inject PID_SHM_LOCATOR into the SEDP
3868        // sample. Otherwise pure 1:1 spec wire.
3869        let shm = self.shm_locator(data.key.entity_id);
3870        let datagrams = {
3871            let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3872                reason: "sedp poisoned",
3873            })?;
3874            // Protected discovery (§8.4.2.4): set robustly before the announce —
3875            // independent of the order of enable_security_builtins vs.
3876            // endpoint creation. `discovery_protection_kind != NONE` routes
3877            // the announce into the secure SEDP writer.
3878            #[cfg(feature = "security")]
3879            self.sync_sedp_discovery_protected(&mut sedp);
3880            let res = if let Some(ref bytes) = shm {
3881                sedp.announce_publication_with_shm_locator(data, bytes)
3882            } else {
3883                sedp.announce_publication(data)
3884            };
3885            res.map_err(|_| DdsError::WireError {
3886                message: alloc::string::String::from("sedp announce_publication"),
3887            })?
3888        };
3889        // Send outside the lock (Rc<Vec<Locator>> is !Send,
3890        // but we are on the same thread as `self` — no
3891        // problem).
3892        for dg in datagrams {
3893            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3894                for t in dg.targets.iter() {
3895                    if is_routable_user_locator(t) {
3896                        // §8.3.7: unicast metatraffic (SEDP DATA to the remote
3897                        // metatraffic_unicast_locator) MUST go out from the metatraffic
3898                        // recv socket `spdp_unicast`, NOT from the ephemeral
3899                        // `spdp_mc_tx` — otherwise the peer sees a foreign
3900                        // source port and sends its reliable ACKNACK/resends
3901                        // to a dead port (cross-vendor SEDP stall). Identical
3902                        // to `send_discovery_datagram`.
3903                        let _ = self.spdp_unicast.send(t, &secured);
3904                    }
3905                }
3906            }
3907        }
3908        // In-process discovery fastpath: serve same-process+domain peers
3909        // synchronously + losslessly with this publication.
3910        self.inproc_announce_publication(data);
3911        Ok(())
3912    }
3913
3914    /// Announces a local subscription via SEDP. Analogous to
3915    /// `announce_publication`.
3916    ///
3917    /// # Errors
3918    /// `WireError` if encoding fails.
3919    pub fn announce_subscription(
3920        &self,
3921        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3922    ) -> Result<()> {
3923        if let Ok(mut v) = self.announced_subs.lock() {
3924            v.push(data.clone());
3925        }
3926        let datagrams = {
3927            let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3928                reason: "sedp poisoned",
3929            })?;
3930            #[cfg(feature = "security")]
3931            self.sync_sedp_discovery_protected(&mut sedp);
3932            sedp.announce_subscription(data)
3933                .map_err(|_| DdsError::WireError {
3934                    message: alloc::string::String::from("sedp announce_subscription"),
3935                })?
3936        };
3937        for dg in datagrams {
3938            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3939                for t in dg.targets.iter() {
3940                    if is_routable_user_locator(t) {
3941                        // Source port: metatraffic recv socket, not spdp_mc_tx
3942                        // (see announce_publication / send_discovery_datagram).
3943                        let _ = self.spdp_unicast.send(t, &secured);
3944                    }
3945                }
3946            }
3947        }
3948        // In-process discovery fastpath: see `announce_publication`.
3949        self.inproc_announce_subscription(data);
3950        Ok(())
3951    }
3952
3953    /// Re-announces the local SEDP endpoint records (publications +
3954    /// subscriptions) to a peer whose crypto-token exchange has just
3955    /// completed. Background: under `rtps_protection`/`discovery_
3956    /// protection` ZeroDDS wraps the SEDP message-/submessage-protected; the
3957    /// peer discards the initial SEDP burst UNTIL it has our participant crypto
3958    /// token (via Volatile). From that moment it can decode — a
3959    /// one-time re-announce brings the previously dropped SEDP up (mints fresh
3960    /// SNs; the reliable SEDP writer delivers them, HEARTBEAT/NACK retry covers a
3961    /// not-quite-ready peer timing). Once per peer (dedup).
3962    ///
3963    /// No-op without active rtps_/discovery_protection (then the announce
3964    /// went through plaintext anyway) and for already re-announced peers. Emits
3965    /// the RETAINED records directly (NO additional `announced_pubs` push).
3966    #[cfg(feature = "security")]
3967    fn re_announce_sedp_to_peer(&self, peer_prefix: GuidPrefix) {
3968        let Some(gate) = &self.config.security else {
3969            return;
3970        };
3971        let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3972        let disc =
3973            gate.discovery_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3974        if !rtps && !disc {
3975            return;
3976        }
3977        // First check whether we have any local endpoints at all — the token
3978        // exchange can complete BEFORE the user endpoint creation.
3979        // Without records do NOT mark as "re-announced" (the periodic tick
3980        // retriggers as soon as the user writer/reader is announced).
3981        let pubs = self
3982            .announced_pubs
3983            .lock()
3984            .map(|v| v.clone())
3985            .unwrap_or_default();
3986        let subs = self
3987            .announced_subs
3988            .lock()
3989            .map(|v| v.clone())
3990            .unwrap_or_default();
3991        if pubs.is_empty() && subs.is_empty() {
3992            return;
3993        }
3994        {
3995            let mut set = match self.sedp_reannounced.write() {
3996                Ok(s) => s,
3997                Err(_) => return,
3998            };
3999            if !set.insert(peer_prefix.0) {
4000                return; // already re-announced
4001            }
4002        }
4003        let send_dgs = |dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>| {
4004            for dg in dgs {
4005                if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
4006                    for t in dg.targets.iter() {
4007                        if is_routable_user_locator(t) {
4008                            let _ = self.spdp_unicast.send(t, &secured);
4009                        }
4010                    }
4011                }
4012            }
4013        };
4014        for data in &pubs {
4015            let shm = self.shm_locator(data.key.entity_id);
4016            let dgs = {
4017                let Ok(mut sedp) = self.sedp.lock() else {
4018                    continue;
4019                };
4020                self.sync_sedp_discovery_protected(&mut sedp);
4021                let res = if let Some(ref bytes) = shm {
4022                    sedp.announce_publication_with_shm_locator(data, bytes)
4023                } else {
4024                    sedp.announce_publication(data)
4025                };
4026                match res {
4027                    Ok(d) => d,
4028                    Err(_) => continue,
4029                }
4030            };
4031            send_dgs(dgs);
4032        }
4033        for data in &subs {
4034            let dgs = {
4035                let Ok(mut sedp) = self.sedp.lock() else {
4036                    continue;
4037                };
4038                self.sync_sedp_discovery_protected(&mut sedp);
4039                match sedp.announce_subscription(data) {
4040                    Ok(d) => d,
4041                    Err(_) => continue,
4042                }
4043            };
4044            send_dgs(dgs);
4045        }
4046    }
4047
4048    /// Own participant data as a `DiscoveredParticipant` — the
4049    /// self-view that the in-process fastpath hands to peers.
4050    fn self_as_discovered_participant(&self) -> zerodds_discovery::spdp::DiscoveredParticipant {
4051        // From the LIVE SPDP beacon: after `enable_security_builtins_with_auth`
4052        // it carries the `identity_token` + the secure endpoint bits that the
4053        // `participant_data` construction snapshot does NOT have. Without these the
4054        // in-process injected DP is worthless for the security handshake trigger
4055        // (`handle_remote_endpoints`/`begin_handshake_with` need
4056        // the token). Fallback to `participant_data` on lock poisoning.
4057        let data = self
4058            .spdp_beacon
4059            .lock()
4060            .map(|b| b.data.clone())
4061            .unwrap_or_else(|_| self.participant_data.clone());
4062        zerodds_discovery::spdp::DiscoveredParticipant {
4063            sender_prefix: self.guid_prefix,
4064            sender_vendor: VendorId::ZERODDS,
4065            data,
4066        }
4067    }
4068
4069    /// In-process discovery: injects the just-announced publication
4070    /// synchronously into all same-process+domain peer runtimes.
4071    fn inproc_announce_publication(
4072        &self,
4073        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
4074    ) {
4075        let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4076        let mut dp = None;
4077        for peer in peers {
4078            if peer.guid_prefix == self.guid_prefix {
4079                continue;
4080            }
4081            let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
4082            peer.inproc_inject_publication(dp, data);
4083        }
4084    }
4085
4086    /// In-process discovery: injects the just-announced subscription
4087    /// synchronously into all same-process+domain peer runtimes.
4088    fn inproc_announce_subscription(
4089        &self,
4090        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4091    ) {
4092        let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4093        let mut dp = None;
4094        for peer in peers {
4095            if peer.guid_prefix == self.guid_prefix {
4096                continue;
4097            }
4098            let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
4099            peer.inproc_inject_subscription(dp, data);
4100        }
4101    }
4102
4103    /// In-process discovery (receive side): wires the remote
4104    /// participant + injects the publication into the SEDP cache and
4105    /// matches the local readers. Idempotent — an announcement arriving
4106    /// later via UDP is thereby a no-op.
4107    fn inproc_inject_publication(
4108        self: &Arc<Self>,
4109        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4110        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
4111    ) {
4112        // §2.2.2.2.1.17: an ignored publication/participant must not be matched.
4113        // The in-process fastpath bypasses the wire match path, so the ignore
4114        // filter must be honored here too — otherwise the Durability-Service's
4115        // own two participants (ingest + replay, same process) would match and
4116        // echo-loop despite mutually ignoring each other.
4117        if let Some(filter) = self.ignore_filter_snapshot() {
4118            let pub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
4119            let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
4120            if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
4121                return;
4122            }
4123        }
4124        let now = self.start_instant.elapsed();
4125        let is_new = self
4126            .discovered
4127            .lock()
4128            .map(|mut c| c.insert(dp.clone()))
4129            .unwrap_or(false);
4130        if let Ok(mut sedp) = self.sedp.lock() {
4131            if is_new {
4132                sedp.on_participant_discovered(dp);
4133            }
4134            sedp.cache_mut().insert_publication(data.clone(), now);
4135        }
4136        run_matching_pass(self);
4137    }
4138
4139    /// In-process discovery (receive side): like `inproc_inject_publication`
4140    /// for a subscription.
4141    fn inproc_inject_subscription(
4142        self: &Arc<Self>,
4143        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4144        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4145    ) {
4146        // See `inproc_inject_publication`: honor the ignore filter on the
4147        // in-process fastpath (symmetric, subscription side).
4148        if let Some(filter) = self.ignore_filter_snapshot() {
4149            let sub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
4150            let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
4151            if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
4152                return;
4153            }
4154        }
4155        let now = self.start_instant.elapsed();
4156        let is_new = self
4157            .discovered
4158            .lock()
4159            .map(|mut c| c.insert(dp.clone()))
4160            .unwrap_or(false);
4161        if let Ok(mut sedp) = self.sedp.lock() {
4162            if is_new {
4163                sedp.on_participant_discovered(dp);
4164            }
4165            sedp.cache_mut().insert_subscription(data.clone(), now);
4166        }
4167        run_matching_pass(self);
4168    }
4169
4170    /// Snapshot of our own endpoints for the `pull-on-creation` path
4171    /// of a peer runtime starting later in the same process.
4172    fn inproc_snapshot(
4173        &self,
4174    ) -> (
4175        zerodds_discovery::spdp::DiscoveredParticipant,
4176        Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>,
4177        Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>,
4178    ) {
4179        let dp = self.self_as_discovered_participant();
4180        let pubs = self
4181            .announced_pubs
4182            .lock()
4183            .map(|v| v.clone())
4184            .unwrap_or_default();
4185        let subs = self
4186            .announced_subs
4187            .lock()
4188            .map(|v| v.clone())
4189            .unwrap_or_default();
4190        (dp, pubs, subs)
4191    }
4192
4193    /// At runtime creation: ask existing same-process+domain peers
4194    /// for their already-announced endpoints and inject these into
4195    /// our SEDP cache. Symmetric counterpart to the
4196    /// announce hook (which distributes live endpoints to peers).
4197    fn inproc_pull_from_peers(self: &Arc<Self>) {
4198        let peers: Vec<Arc<DcpsRuntime>> =
4199            crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
4200                .into_iter()
4201                .filter(|rt| rt.guid_prefix != self.guid_prefix)
4202                .collect();
4203        for peer in peers {
4204            let (dp, pubs, subs) = peer.inproc_snapshot();
4205            for p in &pubs {
4206                self.inproc_inject_publication(&dp, p);
4207            }
4208            for s in &subs {
4209                self.inproc_inject_subscription(&dp, s);
4210            }
4211        }
4212    }
4213
4214    /// FU2 S3: in-process counterpart to the security part of
4215    /// [`handle_spdp_datagram`]. Wires the secure builtin endpoints of the
4216    /// discovered peer and kicks off — if it announces an `identity_token`
4217    /// — the auth handshake; the resulting AUTH datagrams
4218    /// go to the peer via UDP **unicast** (reliable loopback).
4219    /// No-op without a local security stack or without a peer `identity_token`.
4220    #[cfg(feature = "security")]
4221    fn inproc_drive_security_handshake(
4222        self: &Arc<Self>,
4223        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4224    ) {
4225        if dp.sender_prefix == self.guid_prefix {
4226            return;
4227        }
4228        let Some(sec) = self.security_builtin_snapshot() else {
4229            return;
4230        };
4231        let dgs = if let Ok(mut s) = sec.lock() {
4232            s.note_remote_vendor(dp.sender_prefix, dp.sender_vendor);
4233            s.handle_remote_endpoints(dp);
4234            match dp.data.identity_token.as_ref() {
4235                Some(token) => s
4236                    .begin_handshake_with(dp.sender_prefix, dp.data.guid.to_bytes(), token)
4237                    .unwrap_or_default(),
4238                None => Vec::new(),
4239            }
4240        } else {
4241            Vec::new()
4242        };
4243        for dg in dgs {
4244            send_discovery_datagram(self, &dg.targets, &dg.bytes);
4245        }
4246    }
4247
4248    /// FU2 S3: in-process SPDP **participant** discovery. This was the real
4249    /// gap — `inproc_inject_publication`/`_subscription` only inject
4250    /// SEDP endpoints, the SPDP participant level (identity_token +
4251    /// `begin_handshake_with`) ran EXCLUSIVELY over the multicast path
4252    /// that is flaky on the codepit LXC. This hook, on activation of the
4253    /// security builtins, exchanges the participant DPs (with token) **bidirectionally** with
4254    /// all same-process+domain peers and kicks off the auth handshakes
4255    /// — deterministically, without a single multicast beacon.
4256    #[cfg(feature = "security")]
4257    fn inproc_announce_participant(self: &Arc<Self>) {
4258        let self_dp = self.self_as_discovered_participant();
4259        let peers: Vec<Arc<DcpsRuntime>> =
4260            crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
4261                .into_iter()
4262                .filter(|rt| rt.guid_prefix != self.guid_prefix)
4263                .collect();
4264        for peer in peers {
4265            // self → peer: the peer discovers US + triggers its handshake.
4266            let _ = peer
4267                .discovered
4268                .lock()
4269                .map(|mut c| c.insert(self_dp.clone()));
4270            peer.inproc_drive_security_handshake(&self_dp);
4271            // peer → self: WE discover the peer + trigger our handshake.
4272            let peer_dp = peer.self_as_discovered_participant();
4273            let _ = self
4274                .discovered
4275                .lock()
4276                .map(|mut c| c.insert(peer_dp.clone()));
4277            self.inproc_drive_security_handshake(&peer_dp);
4278        }
4279    }
4280
4281    /// C1 multicast-free discovery: sends a (possibly already security-
4282    /// transformed) SPDP beacon additionally to all configured
4283    /// unicast initial peers. No-op without peers → pure multicast behavior,
4284    /// no additional syscalls by default.
4285    fn send_spdp_to_initial_peers(&self, bytes: &[u8]) {
4286        for peer in &self.config.initial_peers {
4287            let _ = self.spdp_mc_tx.send(peer, bytes);
4288        }
4289    }
4290
4291    /// FU2 S3: sends an SPDP beacon IMMEDIATELY via multicast, instead of waiting
4292    /// for the next periodic `spdp_period` tick. Critical for the
4293    /// cross-process secured handshake: `DcpsRuntime::start` starts the
4294    /// beacon sender, whose first beacon (token-LESS) goes out BEFORE
4295    /// `enable_security_builtins_with_auth` sets the `identity_token` on the beacon.
4296    /// If a peer latches this token-less first beacon, it calls
4297    /// `begin_handshake_with` with `token=None` → no-op → the handshake NEVER
4298    /// starts. An immediate re-announce after setting the token ensures
4299    /// that the first token-carrying beacon goes out promptly.
4300    #[cfg(feature = "security")]
4301    fn announce_spdp_now(&self) {
4302        let mc_target = Locator {
4303            kind: LocatorKind::UdpV4,
4304            port: u32::from(
4305                u16::try_from(spdp_multicast_port(self.domain_id as u32)).unwrap_or(7400),
4306            ),
4307            address: {
4308                let mut a = [0u8; 16];
4309                a[12..].copy_from_slice(&self.config.spdp_multicast_group.octets());
4310                a
4311            },
4312        };
4313        if let Ok(mut beacon) = self.spdp_beacon.lock() {
4314            if let Ok(datagram) = beacon.serialize() {
4315                if let Some(secured) = secure_outbound_bytes(self, &datagram) {
4316                    let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4317                    // C1 multicast-free discovery: on the immediate announce too, to
4318                    // the configured initial peers (ZERODDS_PEERS).
4319                    self.send_spdp_to_initial_peers(&secured);
4320                    // Directed unicast fan-out to already-discovered peers:
4321                    // covers the order in which we discover a peer
4322                    // BEFORE our security builtins (token) are active — then the
4323                    // directed response in handle_spdp_datagram skipped tokenless;
4324                    // announce_spdp_now() (called by enable() after the token set)
4325                    // catches up with the tokened beacon promptly + LXC-multicast-
4326                    // independently. Otherwise the peer waits until spdp_period.
4327                    for loc in wlp_unicast_targets(&self.discovered_participants()) {
4328                        let _ = self.spdp_unicast.send(&loc, &secured);
4329                    }
4330                }
4331            }
4332            // FastDDS interop: additionally announce on the reliable secure SPDP
4333            // writer (0xff0101c2), so FastDDS sees our full secured
4334            // participant data over its expected channel.
4335            if self.config.enable_secure_spdp {
4336                if let Ok(datagram) = beacon.serialize_secure() {
4337                    let protected = protect_secure_spdp(self, &datagram).unwrap_or(datagram);
4338                    if let Some(secured) = secure_outbound_bytes(self, &protected) {
4339                        let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4340                    }
4341                }
4342            }
4343        }
4344    }
4345
4346    /// FU2 cross-vendor: `EndpointSecurityInfo` (PID_ENDPOINT_SECURITY_INFO,
4347    /// 0x1004) for user endpoints, derived from the governance
4348    /// `data_protection`. Foreign vendors (cyclone/FastDDS) reject, with
4349    /// `data_protection=ENCRYPT`, a user endpoint WITHOUT this PID as
4350    /// non-secure ("Non secure remote ... not allowed by security").
4351    /// `None` without an active security gate (plain).
4352    #[cfg(feature = "security")]
4353    fn user_endpoint_security_info(
4354        &self,
4355    ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4356        let gate = self.config.security.as_ref()?;
4357        let meta = gate.metadata_protection().ok()?;
4358        let data = gate.data_protection().ok()?;
4359        let disc = gate.topic_discovery_protected().unwrap_or(false);
4360        let liv = gate
4361            .liveliness_protection()
4362            .map(|l| l != ProtectionLevel::None)
4363            .unwrap_or(false);
4364        let rdp = gate.topic_read_protected().unwrap_or(false);
4365        let wrp = gate.topic_write_protected().unwrap_or(false);
4366        Some(compute_user_endpoint_attrs(meta, data, disc, liv, rdp, wrp))
4367    }
4368
4369    #[cfg(not(feature = "security"))]
4370    fn user_endpoint_security_info(
4371        &self,
4372    ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4373        None
4374    }
4375
4376    /// Registers a local user writer. The caller gets the
4377    /// writer `EntityId`; for sends via `write_user_sample(eid, ...)`.
4378    ///
4379    /// In the runtime there is **still no** automatic SEDP announce +
4380    /// matching — that comes in B4b. Currently `register_user_writer`
4381    /// is just the wiring.
4382    ///
4383    /// # Errors
4384    /// `PreconditionNotMet` if the registry mutex is poisoned.
4385    pub fn register_user_writer(&self, cfg: UserWriterConfig) -> Result<EntityId> {
4386        // Default: WithKey. Backward-compat for all test callers.
4387        self.register_user_writer_kind(cfg, true)
4388    }
4389
4390    /// Like [`register_user_writer`] but with an explicit NoKey/WithKey
4391    /// flag. Cross-vendor interop needs it: if the IDL type has no
4392    /// `@key`, the writer MUST set `is_keyed=false`, otherwise
4393    /// a remote reader rejects the DATA submessage due to an
4394    /// entityKind mismatch (Spec §9.3.1.2 table 9.1: 0x02=WithKey
4395    /// vs 0x03=NoKey).
4396    pub fn register_user_writer_kind(
4397        &self,
4398        cfg: UserWriterConfig,
4399        is_keyed: bool,
4400    ) -> Result<EntityId> {
4401        let now = self.start_instant.elapsed();
4402        let key = self.next_entity_key();
4403        let eid = if is_keyed {
4404            EntityId::user_writer_with_key(key)
4405        } else {
4406            EntityId::user_writer_no_key(key)
4407        };
4408        let writer = ReliableWriter::new(ReliableWriterConfig {
4409            guid: Guid::new(self.guid_prefix, eid),
4410            vendor_id: VendorId::ZERODDS,
4411            reader_proxies: Vec::new(),
4412            max_samples: 1024,
4413            history_kind: HistoryKind::KeepLast { depth: 32 },
4414            heartbeat_period: DEFAULT_HEARTBEAT_PERIOD,
4415            // Ethernet-safe default; the value is raised at the reader match
4416            // if all readers are same-host (see the
4417            // set_fragmentation call after add_reader_proxy).
4418            fragment_size: DEFAULT_FRAGMENT_SIZE,
4419            mtu: DEFAULT_MTU,
4420        });
4421        let mut pub_data = build_publication_data(
4422            self.guid_prefix,
4423            eid,
4424            &cfg,
4425            &self.config.data_representation_offer,
4426            self.user_announce_locator,
4427        );
4428        // FU2 cross-vendor: EndpointSecurityInfo from the governance
4429        // data_protection — otherwise cyclone/FastDDS reject the user endpoint
4430        // with data_protection=ENCRYPT as non-secure.
4431        pub_data.security_info = self.user_endpoint_security_info();
4432        self.user_writers
4433            .write()
4434            .map_err(|_| DdsError::PreconditionNotMet {
4435                reason: "user_writers poisoned",
4436            })?
4437            .insert(
4438                eid,
4439                Arc::new(Mutex::new(UserWriterSlot {
4440                    writer,
4441                    topic_name: cfg.topic_name.clone(),
4442                    type_name: cfg.type_name.clone(),
4443                    reliable: cfg.reliable,
4444                    durability: cfg.durability,
4445                    deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4446                    // Initial `None`: the deadline window starts only on the
4447                    // first real write. Prevents false misses due to
4448                    // slow entity setup (e.g. Linux CI container)
4449                    // before the app does its first write(). On the
4450                    // first write() `last_write = Some(now)` is set,
4451                    // and from then the deadline counter ticks.
4452                    last_write: None,
4453                    offered_deadline_missed_count: 0,
4454                    liveliness_lost_count: 0,
4455                    last_liveliness_assert: Some(now),
4456                    offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus::default(
4457                    ),
4458                    lifespan_nanos: qos_duration_to_nanos(cfg.lifespan.duration),
4459                    sample_insert_times: alloc::collections::VecDeque::new(),
4460                    liveliness_kind: cfg.liveliness.kind,
4461                    liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4462                    ownership: cfg.ownership,
4463                    ownership_strength: cfg.ownership_strength,
4464                    partition: cfg.partition.clone(),
4465                    #[cfg(feature = "security")]
4466                    reader_protection: BTreeMap::new(),
4467                    #[cfg(feature = "security")]
4468                    locator_to_peer: BTreeMap::new(),
4469                    type_identifier: cfg.type_identifier.clone(),
4470                    data_rep_offer_override: cfg.data_representation_offer.clone(),
4471                    // Default FINAL: irrelevant for XCDR1 (default offer)
4472                    // (final==appendable==CDR_LE), correct for XCDR2 for
4473                    // @final types. Appendable/mutable types set this later via
4474                    // set_user_writer_wire_extensibility.
4475                    wire_extensibility: zerodds_types::qos::ExtensibilityForRepr::Final,
4476                    big_endian_override: false,
4477                    durability_backend: None,
4478                    backend_primed: false,
4479                    history_depth: DEFAULT_INTRA_HISTORY_DEPTH,
4480                    retained: alloc::collections::VecDeque::new(),
4481                    intra_replayed_readers: alloc::collections::BTreeSet::new(),
4482                })),
4483            );
4484        // FIRST match locally, THEN announce — symmetric to
4485        // register_user_reader_kind. Avoids a peer-side match
4486        // triggered by our announce_publication
4487        // starting a data flow to us before we have wired the
4488        // ReaderProxies.
4489        self.match_local_writer_against_cache(eid);
4490        let _ = self.announce_publication(&pub_data);
4491        // Intra-runtime routing: scan local readers for a match on
4492        // (topic, type). Applies to bridge daemons with writer+reader in
4493        // the same runtime (WS/MQTT/CoAP/AMQP bridges). Without this
4494        // route the local reader gets no samples from the local
4495        // writer — the `inproc` fastpath explicitly skips self, UDP loopback
4496        // is not guaranteed, and SEDP match paths go via
4497        // the discovered cache, which does not contain self.
4498        self.recompute_intra_runtime_routes();
4499        // FU2 F-ECHO-WRITE: a user writer created AFTER handshake completion
4500        // (e.g. the event-driven echo writer in the responder/pong) must send its
4501        // per-endpoint datawriter_crypto_tokens IMMEDIATELY to the already-
4502        // authenticated peers — not only on the next tick. Otherwise
4503        // cyclone's reader stays in "waiting for approval by security" beyond
4504        // its match deadline (the event-driven pong may not tick
4505        // in time) → flaky sub=0. Idempotent via endpoint_tokens_sent dedup.
4506        #[cfg(feature = "security")]
4507        self.flush_late_endpoint_tokens();
4508        // Observability event.
4509        self.config.observability.record(
4510            &zerodds_foundation::observability::Event::new(
4511                zerodds_foundation::observability::Level::Info,
4512                zerodds_foundation::observability::Component::Dcps,
4513                "user_writer.created",
4514            )
4515            .with_attr("topic", cfg.topic_name.as_str())
4516            .with_attr("type", cfg.type_name.as_str())
4517            .with_attr("reliable", if cfg.reliable { "true" } else { "false" }),
4518        );
4519        Ok(eid)
4520    }
4521
4522    /// FU2 F-ECHO-WRITE: sends pending per-endpoint crypto tokens IMMEDIATELY to all
4523    /// already-authenticated peers. For user endpoints created AFTER handshake
4524    /// completion (event-driven echo writer in the responder): their token
4525    /// must go out before cyclone's reader match deadline expires — the periodic
4526    /// tick (or a VolatileSecure recv) is otherwise possibly too late. Idempotent
4527    /// via `endpoint_tokens_sent` dedup (double-send with the tick excluded).
4528    #[cfg(feature = "security")]
4529    fn flush_late_endpoint_tokens(&self) {
4530        let Some(stack) = self.security_builtin_snapshot() else {
4531            return;
4532        };
4533        let Ok(mut s) = stack.lock() else {
4534            return;
4535        };
4536        let now = self.start_instant.elapsed();
4537        let peers: alloc::vec::Vec<GuidPrefix> = self
4538            .config
4539            .security
4540            .as_ref()
4541            .map(|g| {
4542                g.authenticated_peer_prefixes()
4543                    .into_iter()
4544                    .map(GuidPrefix::from_bytes)
4545                    .collect()
4546            })
4547            .unwrap_or_default();
4548        for prefix in peers {
4549            let already = self
4550                .endpoint_tokens_sent
4551                .read()
4552                .map(|set| set.clone())
4553                .unwrap_or_default();
4554            let pending =
4555                pending_endpoint_tokens(prepare_endpoint_crypto_tokens(self, prefix), &already);
4556            for ep_msg in pending {
4557                let key = endpoint_token_key(&ep_msg);
4558                let dgs = protect_volatile_outbound(
4559                    self,
4560                    prefix,
4561                    s.volatile_writer
4562                        .write_with_heartbeat(&ep_msg, now)
4563                        .unwrap_or_default(),
4564                );
4565                for dg in dgs {
4566                    for t in dg.targets.iter() {
4567                        let _ = self.spdp_unicast.send(t, &dg.bytes);
4568                    }
4569                }
4570                if let Ok(mut set) = self.endpoint_tokens_sent.write() {
4571                    set.insert(key);
4572                }
4573            }
4574            // Periodic re-announce retrigger: as soon as the user writer/reader
4575            // is announced (announced_pubs/subs not empty), this catches up the
4576            // SEDP initially dropped under rtps_/discovery_protection to this
4577            // (now tokened) peer. Once per peer (dedup in the method).
4578            self.re_announce_sedp_to_peer(prefix);
4579        }
4580    }
4581
4582    /// Spec §2.2.3.5 — registers a durability-service backend on
4583    /// a writer already registered via [`register_user_writer`].
4584    /// With Durability=Transient/Persistent the backend is replayed into the
4585    /// HistoryCache on the first late-joiner match in
4586    /// `wire_writer_to_remote_reader`, so the reader gets all samples —
4587    /// including those no longer in the writer cache due to history eviction
4588    /// or those that have survived a writer restart.
4589    pub fn attach_durability_backend(
4590        &self,
4591        eid: EntityId,
4592        backend: alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>,
4593    ) -> Result<()> {
4594        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4595            what: "attach_durability_backend: unknown writer entity id",
4596        })?;
4597        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4598            reason: "user_writer slot poisoned",
4599        })?;
4600        slot.durability_backend = Some(backend);
4601        slot.backend_primed = false;
4602        Ok(())
4603    }
4604
4605    /// Sets the type extensibility of a writer (FINAL/APPENDABLE/
4606    /// MUTABLE). Affects exclusively the encapsulation header
4607    /// of the user payload (see [`user_payload_encap`]) — relevant for
4608    /// XCDR2 wire, where @appendable requires a `D_CDR2_LE` and @mutable a
4609    /// `PL_CDR2_LE` header. The codegen/FFI calls this after
4610    /// `register_user_writer*` when the type is not @final.
4611    /// Does NOT change the SEDP announce offer list.
4612    ///
4613    /// # Errors
4614    /// `BadParameter` on an unknown EntityId, `PreconditionNotMet` on a
4615    /// poisoned slot mutex.
4616    pub fn set_user_writer_wire_extensibility(
4617        &self,
4618        eid: EntityId,
4619        ext: zerodds_types::qos::ExtensibilityForRepr,
4620    ) -> Result<()> {
4621        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4622            what: "set_user_writer_wire_extensibility: unknown writer entity id",
4623        })?;
4624        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4625            reason: "user_writer slot poisoned",
4626        })?;
4627        slot.wire_extensibility = ext;
4628        Ok(())
4629    }
4630
4631    /// Registers a local user reader. Returns the reader EntityId
4632    /// and an `mpsc::Receiver` through which DataReader handles
4633    /// consume incoming samples.
4634    ///
4635    /// # Errors
4636    /// `PreconditionNotMet` if the registry mutex is poisoned.
4637    /// Registers a user reader. Returns the EntityId and an
4638    /// `mpsc::Receiver<UserSample>` — alive samples deliver payload,
4639    /// lifecycle markers carry key hash + ChangeKind.
4640    pub fn register_user_reader(
4641        &self,
4642        cfg: UserReaderConfig,
4643    ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4644        // Default: WithKey. Backward-compat for all test callers.
4645        self.register_user_reader_kind(cfg, true)
4646    }
4647
4648    /// Like [`register_user_reader`] but with an explicit NoKey/WithKey
4649    /// flag. Symmetric to [`register_user_writer_kind`] — the reader kind
4650    /// must match the writer kind.
4651    pub fn register_user_reader_kind(
4652        &self,
4653        cfg: UserReaderConfig,
4654        is_keyed: bool,
4655    ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4656        let now = self.start_instant.elapsed();
4657        let key = self.next_entity_key();
4658        let eid = if is_keyed {
4659            EntityId::user_reader_with_key(key)
4660        } else {
4661            EntityId::user_reader_no_key(key)
4662        };
4663        let reader = ReliableReader::new(ReliableReaderConfig {
4664            guid: Guid::new(self.guid_prefix, eid),
4665            vendor_id: VendorId::ZERODDS,
4666            writer_proxies: Vec::new(),
4667            max_samples_per_proxy: 256,
4668            // D.5e: 0ms = synchronous ACK response (Cyclone parity).
4669            // Previously 200ms = pre-1.0 default without spec justification.
4670            heartbeat_response_delay:
4671                zerodds_rtps::reliable_reader::DEFAULT_HEARTBEAT_RESPONSE_DELAY,
4672            // C3: ROS-realistic reassembly cap (PointCloud2/Image),
4673            // instead of the conservative rtps 1-MiB default.
4674            assembler_caps: AssemblerCaps {
4675                max_sample_bytes: self.config.max_reassembly_sample_bytes,
4676                ..AssemblerCaps::default()
4677            },
4678        });
4679        // BEST_EFFORT readers must not block delivery on a leading sequence-number
4680        // gap (RTPS §8.4.12.1) — they don't NACK to repair it, so waiting would
4681        // deadlock against e.g. an OpenDDS/RTI writer whose first sample to us is
4682        // mid-stream. Reliable readers keep strict in-order delivery.
4683        let mut reader = reader;
4684        reader.set_best_effort(!cfg.reliable);
4685        let (tx, rx) = mpsc::channel();
4686        // A DataReader announces every representation it can decode (XCDR2 +
4687        // XCDR1), not just the writer-preferred one — see `reader_accept_repr`.
4688        let reader_repr = reader_accept_repr(&self.config.data_representation_offer);
4689        let mut sub_data = build_subscription_data(
4690            self.guid_prefix,
4691            eid,
4692            &cfg,
4693            &reader_repr,
4694            self.user_announce_locator,
4695        );
4696        // FU2 cross-vendor: EndpointSecurityInfo from the governance (see writer).
4697        sub_data.security_info = self.user_endpoint_security_info();
4698        self.user_readers
4699            .write()
4700            .map_err(|_| DdsError::PreconditionNotMet {
4701                reason: "user_readers poisoned",
4702            })?
4703            .insert(
4704                eid,
4705                Arc::new(Mutex::new(UserReaderSlot {
4706                    reader,
4707                    topic_name: cfg.topic_name.clone(),
4708                    type_name: cfg.type_name.clone(),
4709                    sample_tx: tx,
4710                    async_waker: Arc::new(std::sync::Mutex::new(None)),
4711                    listener: None,
4712                    durability: cfg.durability,
4713                    deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4714                    // Start time as reference (see register_user_writer).
4715                    last_sample_received: Some(now),
4716                    requested_deadline_missed_count: 0,
4717                    requested_incompatible_qos:
4718                        crate::status::RequestedIncompatibleQosStatus::default(),
4719                    sample_lost_count: 0,
4720                    sample_rejected: crate::status::SampleRejectedStatus::default(),
4721                    samples_delivered_count: 0,
4722                    liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4723                    liveliness_kind: cfg.liveliness.kind,
4724                    liveliness_alive_count: 0,
4725                    liveliness_not_alive_count: 0,
4726                    // Optimistic init: we see the writer via SEDP,
4727                    // until the lease expires it counts as alive.
4728                    liveliness_alive: true,
4729                    liveliness_alive_writers: alloc::collections::BTreeSet::new(),
4730                    ownership: cfg.ownership,
4731                    partition: cfg.partition.clone(),
4732                    writer_strengths: alloc::collections::BTreeMap::new(),
4733                    type_identifier: cfg.type_identifier.clone(),
4734                    type_consistency: cfg.type_consistency,
4735                    // A2 — TIME_BASED_FILTER off by default; the C-FFI/rmw path
4736                    // arms it via `set_user_reader_time_based_filter`.
4737                    tbf_min_separation_nanos: 0,
4738                    tbf_last_delivered: alloc::collections::BTreeMap::new(),
4739                })),
4740            );
4741        // FIRST match locally (create the writer proxy on the reader),
4742        // THEN announce. Otherwise our announce_subscription triggers a
4743        // backend replay at the peer via the in-process fastpath
4744        // (Spec §2.2.3.5), which injects DATA into *our* reader
4745        // before we have wired the matching WriterProxies — the
4746        // samples are then discarded as unknown-source
4747        // (tests `{transient,persistent}_late_joiner_receives_backend_replay`).
4748        self.match_local_reader_against_cache(eid);
4749        let _ = self.announce_subscription(&sub_data);
4750        // Intra-runtime routing: see `register_user_writer_kind`.
4751        self.recompute_intra_runtime_routes();
4752        // Observability event.
4753        self.config.observability.record(
4754            &zerodds_foundation::observability::Event::new(
4755                zerodds_foundation::observability::Level::Info,
4756                zerodds_foundation::observability::Component::Dcps,
4757                "user_reader.created",
4758            )
4759            .with_attr("topic", cfg.topic_name.as_str())
4760            .with_attr("type", cfg.type_name.as_str()),
4761        );
4762        Ok((eid, rx))
4763    }
4764
4765    /// Tears down a deleted local user **writer** (DataWriter delete / drop):
4766    /// removes its slot — which drops the contained RTPS `ReliableWriter` and
4767    /// with it the heartbeat schedule + reader proxies — rebuilds the
4768    /// intra-runtime routes, stops announcing it to late in-process joiners, and
4769    /// sends an SEDP **dispose** so remote peers drop the matched writer at once
4770    /// instead of waiting for a liveliness timeout.
4771    pub(crate) fn remove_user_writer(&self, eid: EntityId) {
4772        if let Ok(mut w) = self.user_writers.write() {
4773            w.remove(&eid);
4774        }
4775        self.recompute_intra_runtime_routes();
4776        if let Ok(mut v) = self.announced_pubs.lock() {
4777            v.retain(|p| p.key.entity_id != eid);
4778        }
4779        self.send_endpoint_dispose(eid, true);
4780    }
4781
4782    /// Tears down a deleted local user **reader** — symmetric to
4783    /// [`Self::remove_user_writer`] (drops the slot, rebuilds routes, stops
4784    /// announcing, and disposes the SEDP subscription).
4785    pub(crate) fn remove_user_reader(&self, eid: EntityId) {
4786        if let Ok(mut r) = self.user_readers.write() {
4787            r.remove(&eid);
4788        }
4789        self.recompute_intra_runtime_routes();
4790        if let Ok(mut v) = self.announced_subs.lock() {
4791            v.retain(|s| s.key.entity_id != eid);
4792        }
4793        self.send_endpoint_dispose(eid, false);
4794    }
4795
4796    /// Sends the SEDP dispose datagrams for a deleted endpoint, mirroring the
4797    /// send path of `announce_publication` (secure-wrap + routable-locator
4798    /// filter + metatraffic unicast socket).
4799    fn send_endpoint_dispose(&self, eid: EntityId, is_publication: bool) {
4800        let guid = Guid::new(self.guid_prefix, eid);
4801        let datagrams = {
4802            let mut sedp = match self.sedp.lock() {
4803                Ok(s) => s,
4804                Err(_) => return,
4805            };
4806            #[cfg(feature = "security")]
4807            self.sync_sedp_discovery_protected(&mut sedp);
4808            let res = if is_publication {
4809                sedp.dispose_publication(guid)
4810            } else {
4811                sedp.dispose_subscription(guid)
4812            };
4813            match res {
4814                Ok(d) => d,
4815                Err(_) => return,
4816            }
4817        };
4818        for dg in datagrams {
4819            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
4820                for t in dg.targets.iter() {
4821                    if is_routable_user_locator(t) {
4822                        let _ = self.spdp_unicast.send(t, &secured);
4823                    }
4824                }
4825            }
4826        }
4827    }
4828
4829    /// Unmatches a remote **writer** from every local user reader — the inverse
4830    /// of `wire_reader_to_remote_writer`/`add_writer_proxy` (`runtime.rs:5661`).
4831    ///
4832    /// Called when a remote writer is lost: SEDP dispose of its publication
4833    /// (the immediate driver), and — once wired — also participant-lost and a
4834    /// liveliness-lease expiry. ZeroDDS previously had **no** unmatch path at
4835    /// all (the matching subsystem was add-only), so a deleted remote writer
4836    /// lingered as a stale `WriterProxy` until the local reader was itself
4837    /// dropped. Removing the proxy here makes the reader's
4838    /// `subscription_matched_status` / `discovered_publications` drop
4839    /// immediately instead of after the liveliness timeout.
4840    ///
4841    /// Idempotent: a GUID that matches no local reader is a silent no-op.
4842    pub(crate) fn remove_remote_writer(&self, guid: Guid) {
4843        for (reader_eid, r_arc) in self.reader_slots_snapshot() {
4844            let Ok(mut slot) = r_arc.lock() else { continue };
4845            if slot.reader.remove_writer_proxy(guid).is_some() {
4846                // Drop the per-writer caches keyed by the remote GUID so a
4847                // later writer reusing the same GUID starts clean (ownership
4848                // strength + intra-runtime liveliness tracking).
4849                let key = guid.to_bytes();
4850                slot.writer_strengths.remove(&key);
4851                slot.liveliness_alive_writers.remove(&key);
4852                // Release the same-host SHM pairing, if one was registered.
4853                let local_reader_guid = Guid::new(self.guid_prefix, reader_eid);
4854                self.same_host.remove(guid, local_reader_guid);
4855            }
4856        }
4857    }
4858
4859    /// Unmatches a remote **reader** from every local user writer — the inverse
4860    /// of `wire_writer_to_remote_reader`/`add_reader_proxy`. Symmetric to
4861    /// [`Self::remove_remote_writer`]; driven by an SEDP dispose of the remote
4862    /// subscription. Idempotent.
4863    pub(crate) fn remove_remote_reader(&self, guid: Guid) {
4864        for (_writer_eid, w_arc) in self.writer_slots_snapshot() {
4865            let Ok(mut slot) = w_arc.lock() else { continue };
4866            slot.writer.remove_reader_proxy(guid);
4867        }
4868    }
4869
4870    /// Rebuilds the same-runtime writer→reader routing table.
4871    /// Called in `register_user_writer_kind` and `register_user_reader_kind`
4872    /// after every endpoint create, and on endpoint removal via
4873    /// [`Self::remove_user_writer`] / [`Self::remove_user_reader`]. Per local
4874    /// writer it collects all local readers that have exactly the same
4875    /// `topic_name` and `type_name`. The lookup in the write hot path
4876    /// (`write_user_sample_borrowed`) is read-locked and cheap
4877    /// (BTreeMap lookup → Vec clone).
4878    fn recompute_intra_runtime_routes(&self) {
4879        let writer_snap = self.writer_slots_snapshot();
4880        let reader_snap = self.reader_slots_snapshot();
4881        let mut new_map: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
4882        // QR-cluster (b): writers whose route gained a new reader and which are
4883        // TransientLocal must replay their retained history to those readers.
4884        // (writer_eid, reader_eid) pairs collected here, replayed after the
4885        // routing lock is released.
4886        let mut replay_targets: Vec<(EntityId, EntityId)> = Vec::new();
4887        for (writer_eid, w_arc) in writer_snap {
4888            let (w_topic, w_type, w_partition, w_transient_local) = match w_arc.lock() {
4889                Ok(s) => (
4890                    s.topic_name.clone(),
4891                    s.type_name.clone(),
4892                    s.partition.clone(),
4893                    !matches!(s.durability, zerodds_qos::DurabilityKind::Volatile),
4894                ),
4895                Err(_) => continue,
4896            };
4897            let mut readers: Vec<EntityId> = Vec::new();
4898            for (reader_eid, r_arc) in &reader_snap {
4899                let matches = match r_arc.lock() {
4900                    Ok(s) => {
4901                        s.topic_name == w_topic
4902                            && s.type_name == w_type
4903                            // QR-cluster (c): PARTITION gates the same-runtime
4904                            // match exactly as on the wire (DDS 1.4 §2.2.3.13).
4905                            && partitions_overlap(&w_partition, &s.partition)
4906                    }
4907                    Err(_) => false,
4908                };
4909                if matches {
4910                    readers.push(*reader_eid);
4911                    // Schedule a TransientLocal replay if this writer has not yet
4912                    // replayed to this reader.
4913                    if w_transient_local {
4914                        let already = w_arc
4915                            .lock()
4916                            .map(|s| s.intra_replayed_readers.contains(reader_eid))
4917                            .unwrap_or(true);
4918                        if !already {
4919                            replay_targets.push((writer_eid, *reader_eid));
4920                        }
4921                    }
4922                }
4923            }
4924            if !readers.is_empty() {
4925                new_map.insert(writer_eid, readers);
4926            }
4927        }
4928        // Perform the TransientLocal retained-sample replay to each new reader
4929        // (DDS 1.4 §2.2.3.4 late-joiner delivery). Done outside the routing
4930        // lock; the per-writer slot lock guards `retained` + the
4931        // already-replayed dedup set.
4932        for (writer_eid, reader_eid) in replay_targets {
4933            self.intra_runtime_replay_retained(writer_eid, reader_eid);
4934        }
4935        let changed = match self.intra_runtime_routes.write() {
4936            Ok(mut g) => {
4937                let changed = *g != new_map;
4938                *g = new_map;
4939                changed
4940            }
4941            Err(_) => false,
4942        };
4943        // A new/changed intra-runtime route is a same-participant
4944        // match → wake the `wait_for_matched_{subscription,publication}` waiter
4945        // (the matched count now includes these routes).
4946        if changed {
4947            self.match_event.1.notify_all();
4948        }
4949    }
4950
4951    /// QR-cluster (b): replays a TransientLocal writer's retained samples
4952    /// (DDS 1.4 §2.2.3.4) to a single late-joining intra-runtime reader. Each
4953    /// retained Alive entry is delivered as `UserSample::Alive`; each terminal
4954    /// lifecycle entry as `UserSample::Lifecycle`, so the reader's instance
4955    /// state reflects the most recent NOT_ALIVE_DISPOSED / NOT_ALIVE_NO_WRITERS.
4956    /// Idempotent via the per-writer `intra_replayed_readers` set.
4957    fn intra_runtime_replay_retained(&self, writer_eid: EntityId, reader_eid: EntityId) {
4958        // Snapshot retained under the writer lock, mark replayed, then release.
4959        let samples: Vec<RetainedSample> = {
4960            let Some(w_arc) = self.writer_slot(writer_eid) else {
4961                return;
4962            };
4963            let Ok(mut w) = w_arc.lock() else {
4964                return;
4965            };
4966            if !w.intra_replayed_readers.insert(reader_eid) {
4967                return; // already replayed to this reader
4968            }
4969            w.retained.iter().cloned().collect()
4970        };
4971        if samples.is_empty() {
4972            return;
4973        }
4974        let Some(r_arc) = self.reader_slot(reader_eid) else {
4975            return;
4976        };
4977        let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
4978        let (listener, waker, sender) = {
4979            let Ok(r) = r_arc.lock() else {
4980                return;
4981            };
4982            (
4983                r.listener.clone(),
4984                Arc::clone(&r.async_waker),
4985                r.sample_tx.clone(),
4986            )
4987        };
4988        for s in samples {
4989            match s.lifecycle {
4990                None => {
4991                    if let Some(l) = &listener {
4992                        // Durability replay is little-endian (the store does not
4993                        // retain the original byte order) → big_endian = 0.
4994                        l(&s.payload, s.representation, 0);
4995                    } else {
4996                        let sample = UserSample::Alive {
4997                            payload: crate::sample_bytes::SampleBytes::from_vec(s.payload.clone()),
4998                            writer_guid,
4999                            writer_strength: s.strength,
5000                            representation: s.representation,
5001                            // Durability replay: the store does not yet retain
5002                            // the original byte order (ZeroDDS-internal samples
5003                            // are little-endian); a big-endian peer's durable
5004                            // sample would replay LE. Tracked as a durability
5005                            // followup, not part of the live RTPS BE path.
5006                            big_endian: false,
5007                            // Durability replay: original source timestamp not
5008                            // retained in the store today → reception order.
5009                            source_timestamp: None,
5010                            // Intra-runtime TransientLocal replay: the retained
5011                            // entry does not keep the writer's original RTPS
5012                            // sequence → no source sequence to forward.
5013                            source_sequence_number: -1,
5014                        };
5015                        let _ = sender.send(sample);
5016                        wake_async_waker(&waker);
5017                    }
5018                }
5019                Some(kind) => {
5020                    // Lifecycle markers always go to the MPSC channel (the
5021                    // alive-only listener does not carry instance state).
5022                    let _ = sender.send(UserSample::Lifecycle {
5023                        key_hash: s.key_hash,
5024                        kind,
5025                    });
5026                    wake_async_waker(&waker);
5027                }
5028            }
5029        }
5030    }
5031
5032    /// QR-cluster (d): delivers a lifecycle marker (dispose / unregister) to all
5033    /// matched intra-runtime readers (DDS 1.4 §2.2.2.4.2.10 / §2.2.2.4.2.7) so
5034    /// their instance state becomes NOT_ALIVE_DISPOSED / NOT_ALIVE_NO_WRITERS,
5035    /// and records it in the writer's retained buffer so a later late joiner
5036    /// also observes the terminal state. The wire path is handled separately by
5037    /// [`Self::write_user_lifecycle`].
5038    fn intra_runtime_dispatch_lifecycle(
5039        &self,
5040        writer_eid: EntityId,
5041        key_hash: [u8; 16],
5042        kind: zerodds_rtps::history_cache::ChangeKind,
5043    ) {
5044        // Record in retained (terminal marker for the key) so future late
5045        // joiners observe the NOT_ALIVE state.
5046        if let Some(w_arc) = self.writer_slot(writer_eid) {
5047            if let Ok(mut w) = w_arc.lock() {
5048                if !matches!(w.durability, zerodds_qos::DurabilityKind::Volatile) {
5049                    // Replace any prior terminal marker for this key; keep the
5050                    // retained alive samples (the reader saw them already, but a
5051                    // brand-new late joiner needs both the data and the state).
5052                    w.retained
5053                        .retain(|s| !(s.lifecycle.is_some() && s.key_hash == key_hash));
5054                    w.retained.push_back(RetainedSample {
5055                        key_hash,
5056                        payload: Vec::new(),
5057                        representation: 0,
5058                        strength: 0,
5059                        lifecycle: Some(kind),
5060                    });
5061                }
5062            }
5063        }
5064        let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
5065            Ok(g) => match g.get(&writer_eid) {
5066                Some(v) => v.clone(),
5067                None => return,
5068            },
5069            Err(_) => return,
5070        };
5071        for reader_eid in routes {
5072            let Some(slot_arc) = self.reader_slot(reader_eid) else {
5073                continue;
5074            };
5075            let (waker, sender) = {
5076                let Ok(slot) = slot_arc.lock() else {
5077                    continue;
5078                };
5079                (Arc::clone(&slot.async_waker), slot.sample_tx.clone())
5080            };
5081            let _ = sender.send(UserSample::Lifecycle { key_hash, kind });
5082            wake_async_waker(&waker);
5083        }
5084    }
5085
5086    /// Same-runtime direct dispatch: pushes the just-written
5087    /// sample directly into the `sample_tx` channel of all local readers
5088    /// on the same topic+type. Avoids an RTPS wire roundtrip + UDP
5089    /// loopback for the bridge-daemon case (writer+reader in the same
5090    /// `DcpsRuntime`). Called by the write hot path after the normal
5091    /// wire dispatch.
5092    fn intra_runtime_dispatch_alive(
5093        &self,
5094        writer_eid: EntityId,
5095        payload: &[u8],
5096        writer_strength: i32,
5097        // XCDR version tag of the writer's effective offer (`0` = XCDR1,
5098        // `1` = XCDR2), matching `encap_representation`'s convention on the
5099        // wire-receive path. On the wire path the reader recovers this from
5100        // byte[1] of the encap header; here the intra-runtime payload carries
5101        // no encap header, so the writer's actual representation must be
5102        // threaded through explicitly (Bug R4 — previously hardcoded `0`,
5103        // losing the XCDR version on the same-runtime loopback path).
5104        representation: u8,
5105    ) {
5106        let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
5107            Ok(g) => match g.get(&writer_eid) {
5108                Some(v) => v.clone(),
5109                None => return,
5110            },
5111            Err(_) => return,
5112        };
5113        if routes.is_empty() {
5114            return;
5115        }
5116        let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
5117        // QR-cluster (e): LIVELINESS AUTOMATIC auto-renew. A delivered sample
5118        // proves the matched writer is alive (DDS 1.4 §2.2.3.11). For AUTOMATIC
5119        // kind the infrastructure renews liveliness implicitly, so each
5120        // intra-runtime delivery marks the writer alive at the reader.
5121        let writer_liveliness_automatic = self
5122            .writer_slot(writer_eid)
5123            .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_kind))
5124            .map(|k| matches!(k, zerodds_qos::LivelinessKind::Automatic))
5125            .unwrap_or(false);
5126        for reader_eid in routes {
5127            let Some(slot_arc) = self.reader_slot(reader_eid) else {
5128                continue;
5129            };
5130            // Hold the slot lock only for the listener/sender clone, dispatch
5131            // outside (symmetric to the data-receive path above, which
5132            // preserves exactly the same order in the DATA arm).
5133            let listener;
5134            let waker;
5135            let sender;
5136            {
5137                let Ok(mut slot) = slot_arc.lock() else {
5138                    continue;
5139                };
5140                // Liveliness renew: bump alive_count once per writer-alive
5141                // transition (the reader sees this writer become alive).
5142                if writer_liveliness_automatic {
5143                    let newly_alive = slot.liveliness_alive_writers.insert(writer_guid);
5144                    if newly_alive {
5145                        slot.liveliness_alive = true;
5146                        slot.liveliness_alive_count = slot.liveliness_alive_count.saturating_add(1);
5147                    }
5148                }
5149                listener = slot.listener.clone();
5150                waker = Arc::clone(&slot.async_waker);
5151                sender = slot.sample_tx.clone();
5152            }
5153            // Listener and MPSC are exclusive (see the data-arm comment):
5154            // if a listener is set, the sample only goes to it;
5155            // otherwise to the MPSC receiver.
5156            if let Some(l) = listener {
5157                // The listener signature is `(payload, representation, big_endian)`.
5158                // Intra-runtime: no encap header, so carry the writer's
5159                // actual representation tag (Bug R4); same-process delivery is
5160                // always native little-endian → big_endian = 0.
5161                l(payload, representation, 0);
5162            } else {
5163                let sample = UserSample::Alive {
5164                    payload: crate::sample_bytes::SampleBytes::from_vec(payload.to_vec()),
5165                    writer_guid,
5166                    writer_strength,
5167                    representation,
5168                    // Intra-runtime same-process delivery always produces the
5169                    // native little-endian wire.
5170                    big_endian: false,
5171                    // Intra-runtime same-process delivery bypasses the INFO_TS
5172                    // wire path → reception order.
5173                    source_timestamp: None,
5174                    // Intra-runtime handoff has no RTPS sequence (the SN is only
5175                    // assigned on the wire path); a same-process durability
5176                    // service cannot dedup by it. Wire-delivered samples do.
5177                    source_sequence_number: -1,
5178                };
5179                let _ = sender.send(sample);
5180                wake_async_waker(&waker);
5181            }
5182        }
5183    }
5184
5185    /// On registration / SEDP event: for a local writer `eid`
5186    /// go through all subscriptions known in the cache; on a topic+type
5187    /// match add a `ReaderProxy` to the local ReliableWriter.
5188    fn match_local_writer_against_cache(&self, eid: EntityId) {
5189        let (topic, type_name) = {
5190            let Some(arc) = self.writer_slot(eid) else {
5191                return;
5192            };
5193            let Ok(s) = arc.lock() else {
5194                return;
5195            };
5196            (s.topic_name.clone(), s.type_name.clone())
5197        };
5198        let (matches, conflict): (Vec<_>, bool) = {
5199            let sedp = match self.sedp.lock() {
5200                Ok(s) => s,
5201                Err(_) => return,
5202            };
5203            let matches = sedp
5204                .cache()
5205                .match_subscriptions(&topic, &type_name)
5206                .map(|s| s.data.clone())
5207                .collect();
5208            let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
5209            (matches, conflict)
5210        };
5211        if conflict {
5212            self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
5213        }
5214        for sub in matches {
5215            self.wire_writer_to_remote_reader(eid, &sub);
5216        }
5217    }
5218
5219    fn match_local_reader_against_cache(&self, eid: EntityId) {
5220        let (topic, type_name) = {
5221            let Some(arc) = self.reader_slot(eid) else {
5222                return;
5223            };
5224            let Ok(s) = arc.lock() else {
5225                return;
5226            };
5227            (s.topic_name.clone(), s.type_name.clone())
5228        };
5229        let (matches, conflict): (Vec<_>, bool) = {
5230            let sedp = match self.sedp.lock() {
5231                Ok(s) => s,
5232                Err(_) => return,
5233            };
5234            let matches = sedp
5235                .cache()
5236                .match_publications(&topic, &type_name)
5237                .map(|p| p.data.clone())
5238                .collect();
5239            let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
5240            (matches, conflict)
5241        };
5242        if conflict {
5243            self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
5244        }
5245        for pubd in matches {
5246            self.wire_reader_to_remote_writer(eid, &pubd);
5247        }
5248    }
5249
5250    fn wire_writer_to_remote_reader(
5251        &self,
5252        writer_eid: EntityId,
5253        sub: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
5254    ) {
5255        // §2.2.2.2.1.16: an ignored subscription must not be MATCHED (symmetric
5256        // to the publication gate in `wire_reader_to_remote_writer`). The
5257        // Durability-Service ignores its own ingest reader here so the replay
5258        // writer never delivers back to it (echo loop).
5259        if let Some(filter) = self.ignore_filter_snapshot() {
5260            let sub_h = crate::instance_handle::InstanceHandle::from_guid(sub.key);
5261            let part_h = crate::instance_handle::InstanceHandle::from_guid(sub.participant_key);
5262            if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
5263                return;
5264            }
5265        }
5266        let locators =
5267            endpoint_or_default_locators(&sub.unicast_locators, sub.key.prefix, &self.discovered);
5268        if locators.is_empty() {
5269            return;
5270        }
5271        // Backend replay datagrams (Spec §2.2.3.5). Sent after
5272        // the slot-lock release, so the send path does not run under
5273        // the slot mutex.
5274        let mut replay_dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram> = Vec::new();
5275        if let Some(slot_arc) = self.writer_slot(writer_eid) {
5276            if let Ok(mut slot) = slot_arc.lock() {
5277                let slot = &mut *slot;
5278                // Idempotency gate: if a ReaderProxy already exists for this
5279                // remote reader, the match has already run
5280                // once. A re-wire (e.g. when the SEDP announcement
5281                // arrives at the writer both via the in-process fastpath and via UDP)
5282                // would REPLACE the proxy via
5283                // `add_reader_proxy` — and thereby reset
5284                // `highest_acked_sn`/`highest_sent_sn`.
5285                // The next tick then emits an invalid HEARTBEAT
5286                // with `first_sn > last_sn` (cache_min=N, highest_acked+1=N+1),
5287                // the reader interprets this as "everything before first_sn is
5288                // lost" and advances `delivered_up_to` past not-yet-
5289                // delivered backend replay samples (tests
5290                // `{transient,persistent}_late_joiner_receives_backend_replay`
5291                // — 3% flake without the gate).
5292                if slot
5293                    .writer
5294                    .reader_proxies()
5295                    .iter()
5296                    .any(|p| p.remote_reader_guid == sub.key)
5297                {
5298                    return;
5299                }
5300                // --- QoS-Compatibility ---
5301                // Spec OMG DDS 1.4 §2.2.3.6: Writer offered >= Reader requested.
5302                //
5303                // Per reject, bump the responsible policy ID in
5304                // `offered_incompatible_qos.policies`, so the
5305                // DataWriter listener is triggered via `dispatch_offered_incompatible_qos`.
5306                // We track the *first* faulty
5307                // policy as `last_policy_id` (Spec §2.2.4.1: most-recent).
5308                use crate::psm_constants::qos_policy_id as qid;
5309                use crate::status::bump_policy_count;
5310                // C2 "loud instead of silent": an incompatible QoS match is
5311                // not only kept as a pollable status (Spec §2.2.4.1),
5312                // but logged loudly IMMEDIATELY. The central ROS-DDS
5313                // pain point is that QoS mismatches are silently discarded
5314                // (e.g. Cyclone's `DDS_INVALID_QOS_POLICY_ID` without a
5315                // log) — exactly that made the ROS-2 entityKind diagnosis so
5316                // expensive. The reject names the topic, remote reader and
5317                // the exact policy.
5318                let obs = self.config.observability.clone();
5319                let topic_for_log = slot.topic_name.clone();
5320                let remote_for_log = alloc::format!("{:?}", sub.key);
5321                let bump = |slot: &mut UserWriterSlot, pid: u32| {
5322                    slot.offered_incompatible_qos.total_count =
5323                        slot.offered_incompatible_qos.total_count.saturating_add(1);
5324                    slot.offered_incompatible_qos.last_policy_id = pid;
5325                    bump_policy_count(&mut slot.offered_incompatible_qos.policies, pid);
5326                    obs.record(
5327                        &zerodds_foundation::observability::Event::new(
5328                            zerodds_foundation::observability::Level::Warn,
5329                            zerodds_foundation::observability::Component::Dcps,
5330                            "qos.incompatible.offered",
5331                        )
5332                        .with_attr("topic", topic_for_log.as_str())
5333                        .with_attr("remote_reader", remote_for_log.as_str())
5334                        .with_attr("policy", qos_policy_id_name(pid)),
5335                    );
5336                };
5337
5338                // Durability rank: Volatile < TransientLocal < Transient <
5339                // Persistent. The writer may offer more than the reader requests.
5340                if (slot.durability as u8) < (sub.durability as u8) {
5341                    bump(slot, qid::DURABILITY);
5342                    return;
5343                }
5344                // Deadline: writer period <= reader period (the writer promises
5345                // to write faster than the reader expects).
5346                if !deadline_compat(
5347                    slot.deadline_nanos,
5348                    qos_duration_to_nanos(sub.deadline.period),
5349                ) {
5350                    bump(slot, qid::DEADLINE);
5351                    return;
5352                }
5353                // Liveliness-Kind: Automatic < ManualByParticipant < ManualByTopic.
5354                // Writer-Kind >= Reader-Kind. Lease: writer.lease <= reader.lease.
5355                if (slot.liveliness_kind as u8) < (sub.liveliness.kind as u8) {
5356                    bump(slot, qid::LIVELINESS);
5357                    return;
5358                }
5359                if !deadline_compat(
5360                    slot.liveliness_lease_nanos,
5361                    qos_duration_to_nanos(sub.liveliness.lease_duration),
5362                ) {
5363                    bump(slot, qid::LIVELINESS);
5364                    return;
5365                }
5366                // Ownership: both must be equal (Spec §2.2.3.6 Table:
5367                // no "compatible" case except exactly equal).
5368                if slot.ownership != sub.ownership {
5369                    bump(slot, qid::OWNERSHIP);
5370                    return;
5371                }
5372                // Partition: at least one common partition — or
5373                // both empty (default partition "").
5374                if !partitions_overlap(&slot.partition, &sub.partition) {
5375                    bump(slot, qid::PARTITION);
5376                    return;
5377                }
5378                // F-TYPES-3 XTypes-1.3 §7.6.3.7 symmetric writer-side check.
5379                // If both sides carry a TypeIdentifier (≠ None),
5380                // we check compatibility. The reader's TCE policy is not
5381                // directly available here; we take the default TCE
5382                // (AllowTypeCoercion without PreventWidening) — the reader-
5383                // side check in `wire_reader_to_remote_writer` validates
5384                // with the real reader TCE.
5385                if slot.type_identifier != zerodds_types::TypeIdentifier::None
5386                    && sub.type_identifier != zerodds_types::TypeIdentifier::None
5387                    // Equal TypeIdentifiers are by definition the same type
5388                    // (XTypes 1.3 §7.2.4.1 identity). This is the typed-endpoint
5389                    // case: writer + reader of the same generated type carry the
5390                    // same (possibly complete) TypeIdentifier, whose TypeObject
5391                    // is NOT in this fresh registry. Without this short-circuit a
5392                    // complete-hash type-id would fail the assignability lookup
5393                    // (Bug QT). Skip the registry-backed structural check when the
5394                    // ids are identical.
5395                    && slot.type_identifier != sub.type_identifier
5396                {
5397                    let registry = zerodds_types::resolve::TypeRegistry::new();
5398                    let tce = zerodds_types::qos::TypeConsistencyEnforcement::default();
5399                    let matcher = zerodds_types::type_matcher::TypeMatcher::new(&tce);
5400                    if !matcher
5401                        .match_types(&slot.type_identifier, &sub.type_identifier, &registry)
5402                        .is_match()
5403                    {
5404                        bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5405                        return;
5406                    }
5407                }
5408
5409                let mut proxy = zerodds_rtps::reader_proxy::ReaderProxy::new(
5410                    sub.key,
5411                    locators.clone(),
5412                    Vec::new(),
5413                    slot.reliable,
5414                );
5415                // D.5g — Per-Peer DataRepresentation negotiation
5416                // (XTypes 1.3 §7.6.3.1.2). Writer-offered = Per-Writer-
5417                // Override (slot.data_rep_offer_override) ODER Runtime-
5418                // Default. Reader-accepted = sub.data_representation
5419                // (spec default `[XCDR1]` if empty). Match mode from
5420                // RuntimeConfig.
5421                {
5422                    use zerodds_rtps::publication_data::data_representation as dr;
5423                    let writer_offered: Vec<i16> = slot
5424                        .data_rep_offer_override
5425                        .clone()
5426                        .unwrap_or_else(|| self.config.data_representation_offer.clone());
5427                    let mode = self.config.data_rep_match_mode;
5428                    if let Some(negotiated) =
5429                        dr::negotiate(&writer_offered, &sub.data_representation, mode)
5430                    {
5431                        proxy.set_negotiated_data_representation(negotiated);
5432                    } else {
5433                        // No overlap → SEDP match spec violation.
5434                        // We add the proxy anyway for best-effort
5435                        // compat; the wire-format default stays XCDR2.
5436                        // A spec-strict caller should reject the match.
5437                    }
5438                }
5439                // Spec §2.2.3.4 Tab. 16: cache replay suppression. For
5440                // Volatile the reader must not see any late-joiner history
5441                // → skip up to `cache.max_sn`. For Transient/Persistent
5442                // the backend is authoritative — we deliver the history
5443                // via the backend replay path with NEW SNs; the
5444                // writer's own cache (especially gappy under KeepLast
5445                // eviction) must not serve the reader twice.
5446                // TransientLocal is the only tier where the
5447                // writer cache is the real history anchor.
5448                if !matches!(slot.durability, zerodds_qos::DurabilityKind::TransientLocal) {
5449                    if let Some(max) = slot.writer.cache().max_sn() {
5450                        proxy.skip_samples_up_to(max);
5451                    }
5452                }
5453                // Spec §2.2.3.5 — Durability=Transient/Persistent:
5454                // on the first late-joiner match, re-inject the backend samples
5455                // into the HistoryCache. The existing
5456                // reliable-reader path then delivers them via DATA +
5457                // heartbeat/AckNack. Idempotent via the
5458                // `backend_primed` flag.
5459                let backend_writes: Vec<Vec<u8>> = if !slot.backend_primed
5460                    && (slot.durability == zerodds_qos::DurabilityKind::Transient
5461                        || slot.durability == zerodds_qos::DurabilityKind::Persistent)
5462                {
5463                    slot.durability_backend
5464                        .as_ref()
5465                        .and_then(|b| b.replay_for_topic(&slot.topic_name).ok())
5466                        .unwrap_or_default()
5467                        .into_iter()
5468                        .map(|s| s.payload)
5469                        .collect()
5470                } else {
5471                    Vec::new()
5472                };
5473                slot.writer.add_reader_proxy(proxy);
5474                // Path-MTU-aware fragmentation: if ALL matched
5475                // readers run on the same host, traffic goes via
5476                // loopback (MTU 65536) — then one datagram per sample
5477                // instead of N 1344-B fragments (halves the 8-kB roundtrip
5478                // latency). As soon as a reader is remote, it stays
5479                // Ethernet-safe at DEFAULT_FRAGMENT_SIZE, so no
5480                // oversized datagram gets IP-fragmented on the 1500-byte
5481                // path.
5482                let all_same_host = slot
5483                    .writer
5484                    .reader_proxies()
5485                    .iter()
5486                    .all(|p| self.guid_prefix.is_same_host(p.remote_reader_guid.prefix));
5487                if all_same_host {
5488                    slot.writer
5489                        .set_fragmentation(LOOPBACK_FRAGMENT_SIZE, LOOPBACK_MTU);
5490                } else {
5491                    slot.writer
5492                        .set_fragmentation(DEFAULT_FRAGMENT_SIZE, DEFAULT_MTU);
5493                }
5494                // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): if the
5495                // remote reader runs on the same host (matching
5496                // GuidPrefix host-id, wave 4a), register the pair in the
5497                // SameHostTracker. Wave 4b.3 (feature `same-host-shm`):
5498                // additionally try to set up a PosixShmTransport owner
5499                // segment — on success `mark_bound(Owner)`,
5500                // otherwise `mark_failed` and UDP fallback.
5501                if self.guid_prefix.is_same_host(sub.key.prefix) {
5502                    let local_writer_guid =
5503                        zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5504                    self.same_host.register_pending(local_writer_guid, sub.key);
5505                    #[cfg(feature = "same-host-shm")]
5506                    {
5507                        match crate::same_host_shm::open_owner_segment(
5508                            self.guid_prefix,
5509                            local_writer_guid,
5510                            sub.key,
5511                        ) {
5512                            Ok(t) => self.same_host.mark_bound(
5513                                local_writer_guid,
5514                                sub.key,
5515                                t,
5516                                crate::same_host::Role::Owner,
5517                            ),
5518                            Err(reason) => {
5519                                self.same_host
5520                                    .mark_failed(local_writer_guid, sub.key, reason)
5521                            }
5522                        }
5523                    }
5524                }
5525                // Inject the backend replay into the HistoryCache (within
5526                // the slot lock). Important: with `KeepLast(N)` and a small N
5527                // the cache would immediately evict every replay sample
5528                // again — the subsequent writer tick then sees
5529                // SN=4,5 as "not in cache" and sends GAPs to the
5530                // reader, which marks our replay samples as irrelevant.
5531                // Solution: temporarily expand the cache to `KeepAll` with
5532                // a sufficient cap, for the duration of the
5533                // burst, then restore the user QoS.
5534                // Backend samples are in **raw** format (that is how
5535                // `DataWriter::write` in publisher.rs stores them) — before the
5536                // writer.write we must prepend the USER_PAYLOAD_ENCAP framing,
5537                // so the reader recognizes the stream value spec-conformantly
5538                // (see `validate_user_encap_offset`).
5539                let now_replay = self.start_instant.elapsed();
5540                if !backend_writes.is_empty() {
5541                    // Same encap header as in the live-write path
5542                    // (offer `first` + extensibility), so replay samples
5543                    // declare the same wire encoding.
5544                    let replay_encap = {
5545                        let offer_first = slot
5546                            .data_rep_offer_override
5547                            .as_ref()
5548                            .and_then(|v| v.first().copied())
5549                            .or_else(|| self.config.data_representation_offer.first().copied())
5550                            .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
5551                        user_payload_encap(
5552                            offer_first,
5553                            slot.wire_extensibility,
5554                            slot.big_endian_override,
5555                        )
5556                    };
5557                    let original_kind = slot.writer.cache().kind();
5558                    let original_max = slot.writer.cache().max_samples();
5559                    let burst_max = original_max
5560                        .saturating_add(backend_writes.len())
5561                        .max(backend_writes.len() + 16);
5562                    slot.writer.set_cache_kind_and_max(
5563                        zerodds_rtps::history_cache::HistoryKind::KeepAll,
5564                        burst_max,
5565                    );
5566                    for raw_payload in &backend_writes {
5567                        let mut framed = Vec::with_capacity(replay_encap.len() + raw_payload.len());
5568                        framed.extend_from_slice(&replay_encap);
5569                        framed.extend_from_slice(raw_payload);
5570                        if let Ok(out) = slot.writer.write_with_heartbeat(&framed, now_replay) {
5571                            replay_dgs.extend(out);
5572                        }
5573                    }
5574                    slot.writer
5575                        .set_cache_kind_and_max(original_kind, original_max);
5576                    slot.backend_primed = true;
5577                }
5578                // D.5e Phase-1: wake `wait_for_matched_subscription`-waiters.
5579                self.match_event.1.notify_all();
5580
5581                // Security: derive the per-reader protection level from
5582                // security_info and build the locator lookup map,
5583                // so the writer tick can serialize per target
5584                // individually.
5585                #[cfg(feature = "security")]
5586                {
5587                    let peer_key = sub.key.prefix.0;
5588                    // Set the per-reader level ONLY for an EXPLICITLY announced
5589                    // `PID_ENDPOINT_SECURITY_INFO`. If it is missing (OpenDDS does not
5590                    // send it — it relies on the domain governance), NO
5591                    // None override: then the governance `data_protection` FLOOR
5592                    // applies in `secure_outbound_for_target`. An authenticated peer
5593                    // in a data_protection=ENCRYPT domain expects the encrypted
5594                    // payload; a None override would leak plaintext (cyclone/
5595                    // FastDDS announce security_info → unchanged).
5596                    if let Some(info) = sub.security_info.as_ref() {
5597                        let level = EndpointProtection::from_info(Some(info)).level;
5598                        slot.reader_protection.insert(peer_key, level);
5599                    }
5600                    for loc in &locators {
5601                        slot.locator_to_peer.insert(*loc, peer_key);
5602                    }
5603                }
5604            }
5605        }
5606        // Send the backend replay datagrams (Spec §2.2.3.5). The slot mutex
5607        // is released here; the send path mirrors the pattern from
5608        // `write_user_sample` — including the in-process fastpath for
5609        // same-process peers (otherwise UDP loopback loss under load can
5610        // swallow the Transient/Persistent replay samples).
5611        let inproc_peers: Vec<Arc<DcpsRuntime>> = {
5612            let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
5613            all.into_iter()
5614                .filter(|rt| rt.guid_prefix != self.guid_prefix)
5615                .collect()
5616        };
5617        let now_send = self.start_instant.elapsed();
5618        for dg in &replay_dgs {
5619            for t in dg.targets.iter() {
5620                if is_routable_user_locator(t) {
5621                    let _ = self.user_unicast.send(t, &dg.bytes);
5622                }
5623            }
5624            for peer in &inproc_peers {
5625                handle_user_datagram(peer, &dg.bytes, now_send);
5626            }
5627        }
5628        // Emit the match event outside the slot mutex.
5629        self.config.observability.record(
5630            &zerodds_foundation::observability::Event::new(
5631                zerodds_foundation::observability::Level::Info,
5632                zerodds_foundation::observability::Component::Discovery,
5633                "writer.matched_remote_reader",
5634            )
5635            .with_attr("writer_eid", alloc::format!("{writer_eid:?}")),
5636        );
5637    }
5638
5639    fn wire_reader_to_remote_writer(
5640        &self,
5641        reader_eid: EntityId,
5642        pubd: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
5643    ) {
5644        // §2.2.2.2.1.17: an ignored publication must not be MATCHED, not merely
5645        // hidden from the DCPSPublication builtin reader. The Durability-Service
5646        // relies on this to avoid ingesting its own replay writer (echo loop).
5647        if let Some(filter) = self.ignore_filter_snapshot() {
5648            let pub_h = crate::instance_handle::InstanceHandle::from_guid(pubd.key);
5649            let part_h = crate::instance_handle::InstanceHandle::from_guid(pubd.participant_key);
5650            if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
5651                return;
5652            }
5653        }
5654        let locators =
5655            endpoint_or_default_locators(&pubd.unicast_locators, pubd.key.prefix, &self.discovered);
5656        if locators.is_empty() {
5657            return;
5658        }
5659        if let Some(slot_arc) = self.reader_slot(reader_eid) {
5660            if let Ok(mut slot) = slot_arc.lock() {
5661                let slot = &mut *slot;
5662                // Idempotency gate (symmetric to
5663                // `wire_writer_to_remote_reader`): if a WriterProxy already
5664                // exists for this remote writer, the
5665                // match has already run. A re-wire via UDP SEDP after
5666                // an in-process pull would REPLACE via `add_writer_proxy` —
5667                // resetting `delivered_up_to`/`received` and
5668                // losing already-buffered/delivered samples.
5669                if slot
5670                    .reader
5671                    .writer_proxies()
5672                    .iter()
5673                    .any(|s| s.proxy.remote_writer_guid == pubd.key)
5674                {
5675                    return;
5676                }
5677                // Per-policy bump for requested_incompatible_qos.
5678                use crate::psm_constants::qos_policy_id as qid;
5679                use crate::status::bump_policy_count;
5680                // C2 "loud instead of silent" (symmetric to the writer side):
5681                // an incompatible QoS match is logged loudly immediately.
5682                let obs = self.config.observability.clone();
5683                let topic_for_log = slot.topic_name.clone();
5684                let remote_for_log = alloc::format!("{:?}", pubd.key);
5685                let bump = |slot: &mut UserReaderSlot, pid: u32| {
5686                    slot.requested_incompatible_qos.total_count = slot
5687                        .requested_incompatible_qos
5688                        .total_count
5689                        .saturating_add(1);
5690                    slot.requested_incompatible_qos.last_policy_id = pid;
5691                    bump_policy_count(&mut slot.requested_incompatible_qos.policies, pid);
5692                    obs.record(
5693                        &zerodds_foundation::observability::Event::new(
5694                            zerodds_foundation::observability::Level::Warn,
5695                            zerodds_foundation::observability::Component::Dcps,
5696                            "qos.incompatible.requested",
5697                        )
5698                        .with_attr("topic", topic_for_log.as_str())
5699                        .with_attr("remote_writer", remote_for_log.as_str())
5700                        .with_attr("policy", qos_policy_id_name(pid)),
5701                    );
5702                };
5703
5704                // See wire_writer... — symmetric, the writer is now remote.
5705                if (pubd.durability as u8) < (slot.durability as u8) {
5706                    bump(slot, qid::DURABILITY);
5707                    return;
5708                }
5709                if !deadline_compat(
5710                    qos_duration_to_nanos(pubd.deadline.period),
5711                    slot.deadline_nanos,
5712                ) {
5713                    bump(slot, qid::DEADLINE);
5714                    return;
5715                }
5716                if (pubd.liveliness.kind as u8) < (slot.liveliness_kind as u8) {
5717                    bump(slot, qid::LIVELINESS);
5718                    return;
5719                }
5720                if !deadline_compat(
5721                    qos_duration_to_nanos(pubd.liveliness.lease_duration),
5722                    slot.liveliness_lease_nanos,
5723                ) {
5724                    bump(slot, qid::LIVELINESS);
5725                    return;
5726                }
5727                if pubd.ownership != slot.ownership {
5728                    bump(slot, qid::OWNERSHIP);
5729                    return;
5730                }
5731                if !partitions_overlap(&pubd.partition, &slot.partition) {
5732                    bump(slot, qid::PARTITION);
5733                    return;
5734                }
5735
5736                // F-TYPES-3 XTypes-1.3 §7.6.3.7 TypeConsistencyEnforcement.
5737                // If both sides carry a TypeIdentifier (≠ None),
5738                // we check compatibility via the TypeMatcher. Otherwise
5739                // the match falls back to a pure type_name comparison (default path).
5740                if slot.type_identifier != zerodds_types::TypeIdentifier::None
5741                    && pubd.type_identifier != zerodds_types::TypeIdentifier::None
5742                    // Equal TypeIdentifiers ⇒ same type (XTypes 1.3 §7.2.4.1).
5743                    // The typed-endpoint case carries a complete TypeIdentifier
5744                    // whose TypeObject is not in this fresh registry; identity
5745                    // is decisive without a structural lookup (Bug QT).
5746                    && pubd.type_identifier != slot.type_identifier
5747                {
5748                    let registry = zerodds_types::resolve::TypeRegistry::new();
5749                    let matcher =
5750                        zerodds_types::type_matcher::TypeMatcher::new(&slot.type_consistency);
5751                    if !matcher
5752                        .match_types(&pubd.type_identifier, &slot.type_identifier, &registry)
5753                        .is_match()
5754                    {
5755                        bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5756                        return;
5757                    }
5758                }
5759
5760                slot.reader
5761                    .add_writer_proxy(zerodds_rtps::writer_proxy::WriterProxy::new(
5762                        pubd.key,
5763                        locators,
5764                        Vec::new(),
5765                        true,
5766                    ));
5767                // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): reader
5768                // side of the same-host match. If the remote writer runs on
5769                // the same host, register the pair AND
5770                // attach synchronously to the SHM segment.
5771                //
5772                // Idempotent: thanks to the `PosixShmTransport::open` refactor
5773                // (transport-shm bug fix 2026-05-19) it does not matter whether the
5774                // writer hook (open_owner) or the reader hook
5775                // (open_consumer) runs first — whoever comes first
5776                // creates the segment, whoever later attaches. Real-life
5777                // DDS has no guaranteed SEDP match order.
5778                if self.guid_prefix.is_same_host(pubd.key.prefix) {
5779                    let local_reader_guid =
5780                        zerodds_rtps::wire_types::Guid::new(self.guid_prefix, reader_eid);
5781                    self.same_host.register_pending(pubd.key, local_reader_guid);
5782                    #[cfg(feature = "same-host-shm")]
5783                    {
5784                        match crate::same_host_shm::open_consumer_segment(
5785                            self.guid_prefix,
5786                            pubd.key,
5787                            local_reader_guid,
5788                        ) {
5789                            Ok(t) => self.same_host.mark_bound(
5790                                pubd.key,
5791                                local_reader_guid,
5792                                t,
5793                                crate::same_host::Role::Consumer,
5794                            ),
5795                            Err(reason) => {
5796                                self.same_host
5797                                    .mark_failed(pubd.key, local_reader_guid, reason)
5798                            }
5799                        }
5800                    }
5801                }
5802                // D.5e Phase-1: wake `wait_for_matched_publication`-waiters.
5803                self.match_event.1.notify_all();
5804
5805                // §2.2.3.23 exclusive-ownership resolver cache:
5806                // remember the writer `ownership_strength` from discovery, so
5807                // `delivered_to_user_sample` can pack the value into every
5808                // sample.
5809                slot.writer_strengths
5810                    .insert(pubd.key.to_bytes(), pubd.ownership_strength);
5811            }
5812        }
5813    }
5814
5815    /// Writes a sample to a registered user writer and
5816    /// sends the generated datagrams.
5817    ///
5818    /// The payload is prefixed with the RTPS serialized-payload header
5819    /// (encapsulation scheme) before it goes into the DATA
5820    /// submessage. OMG RTPS 2.5 §9.4.2.13 requires exactly these
5821    /// 4 bytes at the start of every serialized user payload —
5822    /// see [`USER_PAYLOAD_ENCAP`] (`CDR_LE` / XCDR1).
5823    /// Without this header Cyclone/Fast-DDS readers refuse to
5824    /// deliver the sample (they parse the first 4 bytes as
5825    /// encapsulation kind + options and drop unknown-scheme).
5826    ///
5827    /// # Errors
5828    /// - `BadParameter` if the EntityId has no registered writer.
5829    /// - `WireError` on an encoding error.
5830    pub fn write_user_sample(&self, eid: EntityId, payload: Vec<u8>) -> Result<()> {
5831        // Vec-ownership API. The spec contract is unchanged. We delegate to
5832        // the borrowed variant; this saves a heap-allocation hop when
5833        // the caller already has a `&[u8]` (e.g. the C-FFI loan API).
5834        self.write_user_sample_borrowed(eid, &payload)
5835    }
5836
5837    /// Sets the per-writer data-representation override for a user writer. The
5838    /// next `write_user_sample*` derives its encapsulation header from this
5839    /// override's first element instead of the runtime default — so a
5840    /// representation-faithful re-publisher (e.g. the durability service
5841    /// replaying foreign-vendor XCDR1 bytes) can declare the encap that matches
5842    /// the body it holds. `None` clears the override (back to the runtime
5843    /// default). Idempotent + cheap; safe to call before every write.
5844    ///
5845    /// # Errors
5846    /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5847    /// poisoned slot lock.
5848    pub fn set_user_writer_data_rep_override(
5849        &self,
5850        eid: EntityId,
5851        offer: Option<Vec<i16>>,
5852    ) -> Result<()> {
5853        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5854            what: "unknown writer entity id",
5855        })?;
5856        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5857            reason: "user_writer slot poisoned",
5858        })?;
5859        slot.data_rep_offer_override = offer;
5860        Ok(())
5861    }
5862
5863    /// Forces the writer to emit the big-endian (`_BE`) encapsulation variant
5864    /// (RTPS 2.5 §10.5) instead of the little-endian default. Used by the
5865    /// durability service replay path: a big-endian peer's stored sample holds
5866    /// big-endian body bytes, so its replay must carry a matching BE encap
5867    /// header. `false` restores the canonical little-endian wire.
5868    ///
5869    /// # Errors
5870    /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5871    /// poisoned slot lock.
5872    pub fn set_user_writer_byte_order_override(
5873        &self,
5874        eid: EntityId,
5875        big_endian: bool,
5876    ) -> Result<()> {
5877        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5878            what: "unknown writer entity id",
5879        })?;
5880        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5881            reason: "user_writer slot poisoned",
5882        })?;
5883        slot.big_endian_override = big_endian;
5884        Ok(())
5885    }
5886
5887    /// Sets the HISTORY KeepLast depth (DDS 1.4 §2.2.3.18) for a user writer.
5888    /// This governs how many of the most-recent samples **per instance key**
5889    /// are retained for the same-runtime TransientLocal late-joiner replay path
5890    /// (`intra_runtime_dispatch_alive` retains, a new route replays). Pass
5891    /// `usize::MAX` for KeepAll. A binding maps its HistoryQosPolicy here.
5892    ///
5893    /// # Errors
5894    /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5895    /// poisoned slot lock.
5896    pub fn set_user_writer_history_depth(&self, eid: EntityId, depth: usize) -> Result<()> {
5897        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5898            what: "unknown writer entity id",
5899        })?;
5900        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5901            reason: "user_writer slot poisoned",
5902        })?;
5903        slot.history_depth = depth.max(1);
5904        // Re-enforce the new depth over the already-retained samples per key.
5905        let d = slot.history_depth;
5906        enforce_retained_depth(&mut slot.retained, d);
5907        Ok(())
5908    }
5909
5910    /// Reads the current TransientLocal retained-sample count for a user writer
5911    /// (test/introspection helper). `0` for an unknown writer.
5912    #[must_use]
5913    pub fn user_writer_retained_len(&self, eid: EntityId) -> usize {
5914        self.writer_slot(eid)
5915            .and_then(|arc| arc.lock().ok().map(|s| s.retained.len()))
5916            .unwrap_or(0)
5917    }
5918
5919    /// Writes a user sample from a borrowed byte slice.
5920    /// **Zero-copy path** for the loan API and SHM backend: avoids
5921    /// the Vec materialization when the caller holds a slot/stack buffer.
5922    ///
5923    /// Identical semantics to `write_user_sample`; it just takes no
5924    /// ownership of the buffer.
5925    ///
5926    /// # Errors
5927    /// As `write_user_sample`.
5928    pub fn write_user_sample_borrowed(&self, eid: EntityId, payload: &[u8]) -> Result<()> {
5929        self.write_user_sample_keyed(eid, payload, [0u8; 16], None)
5930    }
5931
5932    /// Like [`write_user_sample_borrowed`] but stamps the sample with an
5933    /// explicit source timestamp instead of the current wall clock. A routing
5934    /// service uses this to preserve the input sample's source timestamp on the
5935    /// forwarded output (so `DESTINATION_ORDER = BY_SOURCE_TIMESTAMP` stays
5936    /// correct end-to-end). `source_ts` is the `HeTimestamp` carried on the
5937    /// input `UserSample::Alive`.
5938    ///
5939    /// # Errors
5940    /// As [`write_user_sample_borrowed`].
5941    pub fn write_user_sample_stamped(
5942        &self,
5943        eid: EntityId,
5944        payload: &[u8],
5945        source_ts: zerodds_rtps::header_extension::HeTimestamp,
5946    ) -> Result<()> {
5947        self.write_user_sample_keyed(eid, payload, [0u8; 16], Some(source_ts))
5948    }
5949
5950    /// Like [`write_user_sample_borrowed`] but with an explicit 16-byte instance
5951    /// `key_hash` (DDS 1.4 §2.2.2.4.2 keyed topics). The key is used by the
5952    /// same-runtime TransientLocal retention path so KeepLast depth is enforced
5953    /// **per instance** and a late joiner replays the most-recent samples of
5954    /// every live instance (and any disposed/unregistered terminal marker).
5955    /// A binding that does not key its topic passes the all-zero key (one
5956    /// default instance), which is what `write_user_sample_borrowed` does.
5957    ///
5958    /// # Errors
5959    /// As [`write_user_sample_borrowed`].
5960    pub fn write_user_sample_keyed(
5961        &self,
5962        eid: EntityId,
5963        payload: &[u8],
5964        key_hash: [u8; 16],
5965        source_ts_override: Option<zerodds_rtps::header_extension::HeTimestamp>,
5966    ) -> Result<()> {
5967        let _phase_guard = if phase_timing_enabled() {
5968            Some(PhaseTimer {
5969                start: std::time::Instant::now(),
5970                ns_acc: &PHASE_WRITE_USER_NS,
5971                calls_acc: &PHASE_WRITE_USER_CALLS,
5972            })
5973        } else {
5974            None
5975        };
5976        let pt_on = phase_timing_enabled();
5977        let pt_t0 = if pt_on {
5978            Some(std::time::Instant::now())
5979        } else {
5980            None
5981        };
5982        // Hot path: for small samples (<= 1.5 kB payload)
5983        // the encap framing is copied into a stack PoolBuffer —
5984        // zero heap touch in the framing step. Large samples fall
5985        // back to Vec.
5986        let now = self.start_instant.elapsed();
5987        let total = USER_PAYLOAD_ENCAP.len() + payload.len();
5988        let pt_t2_out: Option<std::time::Instant>;
5989        // XCDR version tag of the writer's effective offer (`0` = XCDR1,
5990        // `1` = XCDR2), set below from the same `offer_first` that drives the
5991        // wire encap header. Carried into the same-runtime loopback dispatch
5992        // so the intra-runtime reader sees the writer's real representation
5993        // (Bug R4) instead of an unconditional `0`.
5994        let intra_representation: u8;
5995        let out_datagrams = {
5996            let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5997                what: "unknown writer entity id",
5998            })?;
5999            let pt_t1 = if pt_on {
6000                Some(std::time::Instant::now())
6001            } else {
6002                None
6003            };
6004            if let (Some(t0), Some(t1)) = (pt_t0, pt_t1) {
6005                PHASE_WRITE_SUB_NS[0].fetch_add(
6006                    (t1 - t0).as_nanos() as u64,
6007                    core::sync::atomic::Ordering::Relaxed,
6008                );
6009            }
6010            let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
6011                reason: "user_writer slot poisoned",
6012            })?;
6013            let pt_t2 = if pt_on {
6014                Some(std::time::Instant::now())
6015            } else {
6016                None
6017            };
6018            pt_t2_out = pt_t2;
6019            if let (Some(t1), Some(t2)) = (pt_t1, pt_t2) {
6020                PHASE_WRITE_SUB_NS[1].fetch_add(
6021                    (t2 - t1).as_nanos() as u64,
6022                    core::sync::atomic::Ordering::Relaxed,
6023                );
6024            }
6025            // Deadline timer: remember the last write for offered_deadline_missed.
6026            slot.last_write = Some(now);
6027            // Encap header from the effective offer `first` (per-writer
6028            // override else runtime default) + type extensibility. The app
6029            // encoder serializes exactly this wire format; the header must
6030            // declare it honestly (otherwise an XCDR2-only vendor
6031            // reader misparses). See `user_payload_encap`.
6032            let encap = {
6033                let offer_first = slot
6034                    .data_rep_offer_override
6035                    .as_ref()
6036                    .and_then(|v| v.first().copied())
6037                    .or_else(|| self.config.data_representation_offer.first().copied())
6038                    .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
6039                // Map the negotiated i16 DataRepresentationId to the u8 XCDR
6040                // version tag used by `UserSample::Alive.representation` /
6041                // `encap_representation` (`1` = XCDR2, `0` = XCDR1). Mirrors
6042                // the wire path where the reader derives this from the encap
6043                // header byte[1].
6044                intra_representation =
6045                    if offer_first == zerodds_rtps::publication_data::data_representation::XCDR2 {
6046                        1
6047                    } else {
6048                        0
6049                    };
6050                user_payload_encap(
6051                    offer_first,
6052                    slot.wire_extensibility,
6053                    slot.big_endian_override,
6054                )
6055            };
6056            // Spec §2.2.3.5 backend filling happens in
6057            // `DataWriter::write` (publisher.rs) with the **raw** payload —
6058            // here only the HistoryCache filling + wire send.
6059            let dgs = if total <= SMALL_FRAME_CAP {
6060                write_user_sample_pooled(
6061                    &mut slot.writer,
6062                    payload,
6063                    now,
6064                    &encap,
6065                    source_ts_override,
6066                )?
6067            } else {
6068                let mut framed = Vec::with_capacity(total);
6069                framed.extend_from_slice(&encap);
6070                framed.extend_from_slice(payload);
6071                // See write_user_sample_pooled: HB rate-limited via the
6072                // tick loop instead of per-write.
6073                let _ = now;
6074                slot.writer
6075                    .write(&framed)
6076                    .map_err(|_| DdsError::WireError {
6077                        message: String::from("user writer encode"),
6078                    })?
6079            };
6080            // Lifespan: remember the insert time of the just-written SN.
6081            if slot.lifespan_nanos != 0 {
6082                if let Some(sn) = slot.writer.cache().max_sn() {
6083                    slot.sample_insert_times.push_back((sn, now));
6084                }
6085            }
6086            // QR-cluster (a)+(b): TRANSIENT_LOCAL same-runtime retention with
6087            // per-instance HISTORY KeepLast depth (DDS 1.4 §2.2.3.4 + §2.2.3.18).
6088            // A new sample for a key clears any prior terminal lifecycle marker
6089            // for that key (the instance is alive again) and is appended; the
6090            // depth is then re-enforced per key.
6091            if !matches!(slot.durability, zerodds_qos::DurabilityKind::Volatile) {
6092                slot.retained
6093                    .retain(|s| !(s.lifecycle.is_some() && s.key_hash == key_hash));
6094                let strength = slot.ownership_strength;
6095                slot.retained.push_back(RetainedSample {
6096                    key_hash,
6097                    payload: payload.to_vec(),
6098                    representation: intra_representation,
6099                    strength,
6100                    lifecycle: None,
6101                });
6102                let depth = slot.history_depth;
6103                enforce_retained_depth(&mut slot.retained, depth);
6104            }
6105            dgs
6106        };
6107        let pt_t3 = if pt_on {
6108            Some(std::time::Instant::now())
6109        } else {
6110            None
6111        };
6112        if let (Some(t2), Some(t3)) = (pt_t2_out, pt_t3) {
6113            PHASE_WRITE_SUB_NS[2].fetch_add(
6114                (t3 - t2).as_nanos() as u64,
6115                core::sync::atomic::Ordering::Relaxed,
6116            );
6117        }
6118        // Opt-4 (Spec `zerodds-zero-copy-1.0` §9): precompute the skip set
6119        // of UDP locators occupied by a bound same-host reader.
6120        // Readers on these locators get the sample via
6121        // SHM (`same_host_send_pass` below); a UDP send would be a duplicate.
6122        #[cfg(feature = "same-host-shm")]
6123        let same_host_skip_locators: Vec<Locator> = self.same_host_udp_skip_set(eid);
6124        // In-process fastpath (same-process+domain peers): snapshot the
6125        // peer runtimes ONCE per write, then feed each datagram directly into
6126        // their recv path — no UDP loopback, no reliable
6127        // recovery race. The receiver deduplicates by SequenceNumber,
6128        // a copy arriving additionally via UDP later is a
6129        // no-op. The wire path stays untouched for cross-process.
6130        //
6131        // Hot-path fast path: lock-free registry hint. In the typical
6132        // cross-process bench (ping in process A, pong in process B)
6133        // A's registry has only A — the `peers()` lock+Vec alloc would be
6134        // pure overhead per write. Skip when count ≤ 1.
6135        let inproc_peers: Vec<Arc<DcpsRuntime>> = if crate::inproc::registry_count_hint() <= 1 {
6136            Vec::new()
6137        } else {
6138            let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
6139            all.into_iter()
6140                .filter(|rt| rt.guid_prefix != self.guid_prefix)
6141                .collect()
6142        };
6143        for dg in out_datagrams {
6144            // FU2 S3: UDP per target with per-reader data_protection
6145            // (`secure_outbound_for_target` — heterogeneously correct: legacy readers
6146            // get plaintext, secure readers SRTPS; the governance
6147            // data_protection fallback applies for readers without explicit
6148            // SEDP security_info).
6149            for t in dg.targets.iter() {
6150                if is_routable_user_locator(t) {
6151                    #[cfg(feature = "same-host-shm")]
6152                    if same_host_skip_locators.iter().any(|s| s == t) {
6153                        continue;
6154                    }
6155                    if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
6156                        #[allow(clippy::print_stderr)]
6157                        if let Err(e) = self.user_unicast.send(t, &secured) {
6158                            if std::env::var("ZERODDS_TRACE_SEND_ERR")
6159                                .map(|s| s == "1")
6160                                .unwrap_or(false)
6161                            {
6162                                eprintln!("[TRACE] user_unicast.send({t:?}) failed: {e:?}");
6163                            }
6164                        }
6165                    }
6166                }
6167            }
6168            // SHM + in-process fastpath: `secure_user_outbound` (uniform
6169            // governance data_protection level). The inproc peer runs through
6170            // its secured inbound path (decrypt or drop),
6171            // symmetric to the UDP recv — otherwise a non-
6172            // authenticated same-process peer could see encrypted data
6173            // unencrypted.
6174            if let Some(secured) = secure_user_outbound(self, &dg.bytes) {
6175                // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6):
6176                // parallel send via SHM to all bound-owner entries
6177                // for this writer. Opt-4 above filters their UDP
6178                // locators out beforehand, so nothing is sent twice.
6179                #[cfg(feature = "same-host-shm")]
6180                self.same_host_send_pass(eid, &secured);
6181                for peer in &inproc_peers {
6182                    #[cfg(feature = "security")]
6183                    {
6184                        if let Some(clear) =
6185                            secure_inbound_bytes(peer, &secured, &DEFAULT_INBOUND_IFACE)
6186                        {
6187                            handle_user_datagram(peer, &clear, now);
6188                        }
6189                    }
6190                    #[cfg(not(feature = "security"))]
6191                    handle_user_datagram(peer, &secured, now);
6192                }
6193            }
6194        }
6195        let pt_t4 = if pt_on {
6196            Some(std::time::Instant::now())
6197        } else {
6198            None
6199        };
6200        if let (Some(t3), Some(t4)) = (pt_t3, pt_t4) {
6201            PHASE_WRITE_SUB_NS[3].fetch_add(
6202                (t4 - t3).as_nanos() as u64,
6203                core::sync::atomic::Ordering::Relaxed,
6204            );
6205        }
6206        // Same-runtime writer→reader loopback: in parallel to the wire path
6207        // push directly into the `sample_tx` of all local readers on the same
6208        // topic+type. Bridge-daemon use case (writer+reader
6209        // in the same DcpsRuntime); without this hook intra-process
6210        // loopback would be completely dead, because `inproc_announce_*` skips self
6211        // and UDP multicast loopback is not guaranteed. Strength from
6212        // the writer slot.
6213        let writer_strength = self
6214            .writer_slot(eid)
6215            .and_then(|arc| arc.lock().ok().map(|s| s.ownership_strength))
6216            .unwrap_or(0);
6217        self.intra_runtime_dispatch_alive(eid, payload, writer_strength, intra_representation);
6218        // Embargo inspect tap at the DCPS layer (path-separated from the
6219        // production path). Only compiled when the `inspect` feature is
6220        // on. The topic name is fetched via a separate lookup, outside
6221        // the lock region so hooks do not run under the lock.
6222        #[cfg(feature = "inspect")]
6223        {
6224            self.dispatch_inspect_dcps_tap(eid, payload);
6225        }
6226        // D.5e Phase 3 — a freshly written sample makes a HEARTBEAT due: wake the
6227        // scheduler tick so it goes out immediately (no 5 ms tail), speeding the
6228        // reliable HB→ACKNACK handshake.
6229        self.raise_tick_wake();
6230        Ok(())
6231    }
6232
6233    /// Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) helper:
6234    /// sends `bytes` to all bound-owner entries of the [`SameHostTracker`]
6235    /// for this local writer (owner role).
6236    ///
6237    /// Called by the [`Self::write_user_sample`] hot path after the UDP send.
6238    /// Same-host readers thereby receive the sample frame
6239    /// via SHM **in addition** to the UDP path — the reader HistoryCache
6240    /// deduplicates by SequenceNumber.
6241    #[cfg(feature = "same-host-shm")]
6242    /// Opt-4 (Spec `zerodds-zero-copy-1.0` §9): locator skip set for
6243    /// the UDP send path. Returns all UDP default-unicast locators
6244    /// of the readers that have a bound same-host SHM pair with this
6245    /// writer — the hot-path caller filters these targets out of
6246    /// `dg.targets`, so the same readers are not served twice
6247    /// (UDP + SHM).
6248    #[cfg(feature = "same-host-shm")]
6249    fn same_host_udp_skip_set(&self, writer_eid: EntityId) -> Vec<Locator> {
6250        use crate::same_host::{Role, SameHostState};
6251        let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
6252        let mut skip: Vec<Locator> = Vec::new();
6253        let snapshot = self.same_host.snapshot();
6254        let discovered = self.discovered.clone();
6255        for (w, reader, state) in snapshot {
6256            if w != writer_guid {
6257                continue;
6258            }
6259            if !matches!(
6260                state,
6261                SameHostState::Bound {
6262                    role: Role::Owner,
6263                    ..
6264                }
6265            ) {
6266                continue;
6267            }
6268            // Reader prefix → default_unicast_locator from discovery.
6269            if let Ok(cache) = discovered.lock() {
6270                if let Some(p) = cache.get(&reader.prefix) {
6271                    if let Some(loc) = p.data.default_unicast_locator {
6272                        skip.push(loc);
6273                    }
6274                }
6275            }
6276        }
6277        skip
6278    }
6279
6280    #[cfg(feature = "same-host-shm")]
6281    fn same_host_send_pass(&self, writer_eid: EntityId, bytes: &[u8]) {
6282        use crate::same_host::{Role, SameHostState};
6283        use zerodds_transport::Transport;
6284        use zerodds_transport_shm::PosixShmTransport;
6285
6286        let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
6287        let snapshot = self.same_host.snapshot();
6288        let total = snapshot.len();
6289        let mut matched = 0u32;
6290        let mut owners = 0u32;
6291        let mut sent = 0u32;
6292        for (w, _reader, state) in snapshot {
6293            if w != writer_guid {
6294                continue;
6295            }
6296            matched += 1;
6297            let SameHostState::Bound { transport, role } = state else {
6298                continue;
6299            };
6300            if !matches!(role, Role::Owner) {
6301                continue;
6302            }
6303            owners += 1;
6304            let Ok(t) = transport.downcast::<PosixShmTransport>() else {
6305                continue;
6306            };
6307            // ShmTransport is 1:1: send() validates `dest ==
6308            // peer_locator`. Owner.peer_locator points to the
6309            // consumer endpoint → that is our target.
6310            let target = t.peer_locator();
6311            if t.send(&target, bytes).is_ok() {
6312                sent += 1;
6313            }
6314        }
6315        let _ = (total, matched, owners, sent); // diag counter removed after the Bug-3 fix
6316    }
6317
6318    /// Inspect-endpoint tap dispatch for DCPS publish.
6319    /// Reads the topic name separately from the WriterSlot and passes
6320    /// a frame to the zerodds-inspect-endpoint tap registry.
6321    /// **Not** the production hot path: only when the `inspect` feature is on.
6322    #[cfg(feature = "inspect")]
6323    fn dispatch_inspect_dcps_tap(&self, eid: EntityId, payload: &[u8]) {
6324        let Some(slot_arc) = self.writer_slot(eid) else {
6325            return;
6326        };
6327        let topic = match slot_arc.lock() {
6328            Ok(slot) => slot.topic_name.clone(),
6329            Err(_) => return,
6330        };
6331        let ts_ns = std::time::SystemTime::now()
6332            .duration_since(std::time::UNIX_EPOCH)
6333            .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
6334            .unwrap_or(0);
6335        let mut corr: u64 = 0;
6336        for (i, byte) in eid.entity_key.iter().enumerate() {
6337            corr |= u64::from(*byte) << (i * 8);
6338        }
6339        corr |= u64::from(eid.entity_kind as u8) << 24;
6340        let frame = zerodds_inspect_endpoint::Frame::dcps(topic, ts_ns, corr, payload.to_vec());
6341        zerodds_inspect_endpoint::tap::dispatch(&frame);
6342    }
6343
6344    /// Sends a lifecycle marker (`dispose`/`unregister_instance`) to
6345    /// all matched readers. Spec §2.2.2.4.2.10/.7 + §9.6.3.9 PID_STATUS_INFO.
6346    /// `status_bits` is the OR combination of
6347    /// `zerodds_rtps::inline_qos::status_info::DISPOSED` and/or `UNREGISTERED`.
6348    ///
6349    /// # Errors
6350    /// - `BadParameter` if the EntityId has no registered writer.
6351    /// - `WireError` on an encode error.
6352    pub fn write_user_lifecycle(
6353        &self,
6354        eid: EntityId,
6355        key_hash: [u8; 16],
6356        status_bits: u32,
6357    ) -> Result<()> {
6358        let out_datagrams = {
6359            let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
6360                what: "unknown writer entity id",
6361            })?;
6362            let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
6363                reason: "user_writer slot poisoned",
6364            })?;
6365            slot.writer
6366                .write_lifecycle(key_hash, status_bits)
6367                .map_err(|_| DdsError::WireError {
6368                    message: String::from("user writer lifecycle encode"),
6369                })?
6370        };
6371        for dg in out_datagrams {
6372            // FU2 S3: lifecycle DATA (dispose/unregister) per-target
6373            // data_protection-aware (heterogeneously correct like the immediate send).
6374            for t in dg.targets.iter() {
6375                if is_routable_user_locator(t) {
6376                    if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
6377                        let _ = self.user_unicast.send(t, &secured);
6378                    }
6379                }
6380            }
6381        }
6382        // QR-cluster (d): also deliver the lifecycle marker to matched
6383        // same-runtime readers — the wire targets above never include
6384        // intra-runtime local readers (those go via the direct dispatch path).
6385        // Map the PID_STATUS_INFO bits to the HistoryCache ChangeKind.
6386        use zerodds_rtps::inline_qos::status_info;
6387        let disposed = status_bits & status_info::DISPOSED != 0;
6388        let unregistered = status_bits & status_info::UNREGISTERED != 0;
6389        let kind = match (disposed, unregistered) {
6390            (true, true) => zerodds_rtps::history_cache::ChangeKind::NotAliveDisposedUnregistered,
6391            (true, false) => zerodds_rtps::history_cache::ChangeKind::NotAliveDisposed,
6392            (false, true) => zerodds_rtps::history_cache::ChangeKind::NotAliveUnregistered,
6393            // No status bits set: nothing to deliver as a lifecycle marker.
6394            (false, false) => return Ok(()),
6395        };
6396        self.intra_runtime_dispatch_lifecycle(eid, key_hash, kind);
6397        Ok(())
6398    }
6399
6400    /// Generates a 3-byte entity key for new user endpoints.
6401    fn next_entity_key(&self) -> [u8; 3] {
6402        let n = self.entity_counter.fetch_add(1, Ordering::Relaxed);
6403        [(n >> 16) as u8, (n >> 8) as u8, n as u8]
6404    }
6405
6406    /// Snapshot of all currently known remote publications (topic
6407    /// name + type name + writer GUID).
6408    #[must_use]
6409    pub fn discovered_publications_count(&self) -> usize {
6410        self.sedp
6411            .lock()
6412            .map(|s| s.cache().publications_len())
6413            .unwrap_or(0)
6414    }
6415
6416    /// Snapshot of every publication on this domain as `(topic_name,
6417    /// type_name)` — raw DDS topic/type strings — for graph introspection
6418    /// (`rmw_get_topic_names_and_types`, `rmw_count_publishers`). Includes BOTH
6419    /// this participant's LOCAL user writers AND the remote publications from
6420    /// SEDP, so a node sees its own topics as well as its peers'.
6421    #[must_use]
6422    pub fn discovered_publication_topics(&self) -> Vec<(String, String)> {
6423        let mut out: Vec<(String, String)> = Vec::new();
6424        if let Ok(map) = self.user_writers.read() {
6425            for slot in map.values() {
6426                if let Ok(s) = slot.lock() {
6427                    out.push((s.topic_name.clone(), s.type_name.clone()));
6428                }
6429            }
6430        }
6431        if let Ok(s) = self.sedp.lock() {
6432            out.extend(
6433                s.cache()
6434                    .publications()
6435                    .map(|p| (p.data.topic_name.clone(), p.data.type_name.clone())),
6436            );
6437        }
6438        out
6439    }
6440
6441    /// Snapshot of every subscription on this domain as `(topic_name,
6442    /// type_name)` (local user readers + remote SEDP). Counterpart to
6443    /// [`Self::discovered_publication_topics`].
6444    #[must_use]
6445    pub fn discovered_subscription_topics(&self) -> Vec<(String, String)> {
6446        let mut out: Vec<(String, String)> = Vec::new();
6447        if let Ok(map) = self.user_readers.read() {
6448            for slot in map.values() {
6449                if let Ok(s) = slot.lock() {
6450                    out.push((s.topic_name.clone(), s.type_name.clone()));
6451                }
6452            }
6453        }
6454        if let Ok(s) = self.sedp.lock() {
6455            out.extend(
6456                s.cache()
6457                    .subscriptions()
6458                    .map(|s| (s.data.topic_name.clone(), s.data.type_name.clone())),
6459            );
6460        }
6461        out
6462    }
6463
6464    /// Snapshot of all currently known remote subscriptions.
6465    #[must_use]
6466    pub fn discovered_subscriptions_count(&self) -> usize {
6467        self.sedp
6468            .lock()
6469            .map(|s| s.cache().subscriptions_len())
6470            .unwrap_or(0)
6471    }
6472
6473    /// Per-endpoint snapshot of every publication on this domain (local user
6474    /// writers + remote SEDP), for ROS 2 `rmw_get_publishers_info_by_topic`.
6475    #[must_use]
6476    pub fn discovered_publication_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
6477        let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
6478        let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
6479        if let Ok(map) = self.user_writers.read() {
6480            for slot in map.values() {
6481                if let Ok(s) = slot.lock() {
6482                    out.push(DiscoveredEndpointInfo {
6483                        topic_name: s.topic_name.clone(),
6484                        type_name: s.type_name.clone(),
6485                        endpoint_guid: guid_to_16(s.writer.guid()),
6486                        reliable: s.reliable,
6487                        transient_local: !matches!(
6488                            s.durability,
6489                            zerodds_qos::DurabilityKind::Volatile
6490                        ),
6491                        deadline_seconds: secs(s.deadline_nanos),
6492                        lifespan_seconds: secs(s.lifespan_nanos),
6493                        liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
6494                    });
6495                }
6496            }
6497        }
6498        if let Ok(s) = self.sedp.lock() {
6499            for p in s.cache().publications() {
6500                out.push(DiscoveredEndpointInfo {
6501                    topic_name: p.data.topic_name.clone(),
6502                    type_name: p.data.type_name.clone(),
6503                    endpoint_guid: guid_to_16(p.data.key),
6504                    reliable: matches!(
6505                        p.data.reliability.kind,
6506                        zerodds_qos::ReliabilityKind::Reliable
6507                    ),
6508                    transient_local: !matches!(
6509                        p.data.durability,
6510                        zerodds_qos::DurabilityKind::Volatile
6511                    ),
6512                    deadline_seconds: p.data.deadline.period.seconds,
6513                    lifespan_seconds: p.data.lifespan.duration.seconds,
6514                    liveliness_lease_seconds: p.data.liveliness.lease_duration.seconds,
6515                });
6516            }
6517        }
6518        out
6519    }
6520
6521    /// Counterpart to [`Self::discovered_publication_endpoints`] for
6522    /// subscriptions (`rmw_get_subscriptions_info_by_topic`).
6523    #[must_use]
6524    pub fn discovered_subscription_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
6525        let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
6526        let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
6527        if let Ok(map) = self.user_readers.read() {
6528            for slot in map.values() {
6529                if let Ok(s) = slot.lock() {
6530                    out.push(DiscoveredEndpointInfo {
6531                        topic_name: s.topic_name.clone(),
6532                        type_name: s.type_name.clone(),
6533                        endpoint_guid: guid_to_16(s.reader.guid()),
6534                        // Reader requested-reliability is not retained in the
6535                        // slot; RELIABLE is the rmw default (best-effort field).
6536                        reliable: true,
6537                        transient_local: !matches!(
6538                            s.durability,
6539                            zerodds_qos::DurabilityKind::Volatile
6540                        ),
6541                        deadline_seconds: secs(s.deadline_nanos),
6542                        lifespan_seconds: 0,
6543                        liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
6544                    });
6545                }
6546            }
6547        }
6548        if let Ok(s) = self.sedp.lock() {
6549            for sub in s.cache().subscriptions() {
6550                out.push(DiscoveredEndpointInfo {
6551                    topic_name: sub.data.topic_name.clone(),
6552                    type_name: sub.data.type_name.clone(),
6553                    endpoint_guid: guid_to_16(sub.data.key),
6554                    reliable: matches!(
6555                        sub.data.reliability.kind,
6556                        zerodds_qos::ReliabilityKind::Reliable
6557                    ),
6558                    transient_local: !matches!(
6559                        sub.data.durability,
6560                        zerodds_qos::DurabilityKind::Volatile
6561                    ),
6562                    deadline_seconds: sub.data.deadline.period.seconds,
6563                    lifespan_seconds: 0,
6564                    liveliness_lease_seconds: sub.data.liveliness.lease_duration.seconds,
6565                });
6566            }
6567        }
6568        out
6569    }
6570
6571    /// Number of matched remote readers for a local user writer.
6572    /// Polled by `DataWriter::wait_for_matched_subscription`.
6573    #[must_use]
6574    pub fn user_writer_matched_count(&self, eid: EntityId) -> usize {
6575        // Distinct matched subscriptions = remote/cross-participant reader
6576        // proxies UNION same-participant (intra-runtime) local readers. The
6577        // intra-runtime self-match path delivers samples without adding a wire
6578        // reader-proxy (avoids UDP-to-self double-delivery), so its matches
6579        // would otherwise be invisible to `wait_for_matched_subscription`.
6580        self.user_writer_matched_subscription_handles(eid).len()
6581    }
6582
6583    /// List of `InstanceHandle`s of all matched readers for a local
6584    /// user writer (Spec §2.2.2.4.2.x `get_matched_subscriptions`): remote/
6585    /// cross-participant readers (reader proxies) plus the same-participant
6586    /// readers from the intra-runtime routes, deduplicated by GUID.
6587    #[must_use]
6588    pub fn user_writer_matched_subscription_handles(
6589        &self,
6590        eid: EntityId,
6591    ) -> Vec<crate::instance_handle::InstanceHandle> {
6592        let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
6593            .writer_slot(eid)
6594            .and_then(|arc| {
6595                arc.lock().ok().map(|s| {
6596                    s.writer
6597                        .reader_proxies()
6598                        .iter()
6599                        .map(|p| {
6600                            crate::instance_handle::InstanceHandle::from_guid(p.remote_reader_guid)
6601                        })
6602                        .collect::<Vec<_>>()
6603                })
6604            })
6605            .unwrap_or_default();
6606        for h in self.intra_runtime_writer_matched_readers(eid) {
6607            if !handles.contains(&h) {
6608                handles.push(h);
6609            }
6610        }
6611        handles
6612    }
6613
6614    /// Same-participant readers that the local writer `eid` delivers to via
6615    /// an intra-runtime route (as matched-subscription handles).
6616    fn intra_runtime_writer_matched_readers(
6617        &self,
6618        writer_eid: EntityId,
6619    ) -> Vec<crate::instance_handle::InstanceHandle> {
6620        match self.intra_runtime_routes.read() {
6621            Ok(g) => g
6622                .get(&writer_eid)
6623                .map(|readers| {
6624                    readers
6625                        .iter()
6626                        .map(|reid| {
6627                            crate::instance_handle::InstanceHandle::from_guid(Guid::new(
6628                                self.guid_prefix,
6629                                *reid,
6630                            ))
6631                        })
6632                        .collect()
6633                })
6634                .unwrap_or_default(),
6635            Err(_) => Vec::new(),
6636        }
6637    }
6638
6639    /// Same-participant writers that deliver to the local
6640    /// reader `reader_eid` via an intra-runtime route (as matched-publication handles).
6641    fn intra_runtime_reader_matched_writers(
6642        &self,
6643        reader_eid: EntityId,
6644    ) -> Vec<crate::instance_handle::InstanceHandle> {
6645        match self.intra_runtime_routes.read() {
6646            Ok(g) => g
6647                .iter()
6648                .filter(|(_, readers)| readers.contains(&reader_eid))
6649                .map(|(weid, _)| {
6650                    crate::instance_handle::InstanceHandle::from_guid(Guid::new(
6651                        self.guid_prefix,
6652                        *weid,
6653                    ))
6654                })
6655                .collect(),
6656            Err(_) => Vec::new(),
6657        }
6658    }
6659
6660    /// List of `InstanceHandle`s of all matched remote writers for a
6661    /// local user reader (Spec §2.2.2.5.x `get_matched_publications`).
6662    #[must_use]
6663    pub fn user_reader_matched_publication_handles(
6664        &self,
6665        eid: EntityId,
6666    ) -> Vec<crate::instance_handle::InstanceHandle> {
6667        let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
6668            .reader_slot(eid)
6669            .and_then(|arc| {
6670                arc.lock().ok().map(|s| {
6671                    s.reader
6672                        .writer_proxies()
6673                        .iter()
6674                        .map(|p| {
6675                            crate::instance_handle::InstanceHandle::from_guid(
6676                                p.proxy.remote_writer_guid,
6677                            )
6678                        })
6679                        .collect::<Vec<_>>()
6680                })
6681            })
6682            .unwrap_or_default();
6683        for h in self.intra_runtime_reader_matched_writers(eid) {
6684            if !handles.contains(&h) {
6685                handles.push(h);
6686            }
6687        }
6688        handles
6689    }
6690
6691    /// Counter for missed offered deadlines on the user writer.
6692    /// Spec OMG DDS 1.4 §2.2.4.2.9 `OFFERED_DEADLINE_MISSED_STATUS`.
6693    #[must_use]
6694    pub fn user_writer_offered_deadline_missed(&self, eid: EntityId) -> u64 {
6695        self.writer_slot(eid)
6696            .and_then(|arc| arc.lock().ok().map(|s| s.offered_deadline_missed_count))
6697            .unwrap_or(0)
6698    }
6699
6700    /// Counter for missed requested deadlines on the user reader.
6701    /// Spec §2.2.4.2.11 `REQUESTED_DEADLINE_MISSED_STATUS`.
6702    #[must_use]
6703    pub fn user_reader_requested_deadline_missed(&self, eid: EntityId) -> u64 {
6704        self.reader_slot(eid)
6705            .and_then(|arc| arc.lock().ok().map(|s| s.requested_deadline_missed_count))
6706            .unwrap_or(0)
6707    }
6708
6709    /// Current liveliness status of a local user reader.
6710    /// Spec §2.2.4.2.14 `LIVELINESS_CHANGED_STATUS`:
6711    /// `(alive, alive_count, not_alive_count)`.
6712    #[must_use]
6713    pub fn user_reader_liveliness_status(&self, eid: EntityId) -> (bool, u64, u64) {
6714        self.reader_slot(eid)
6715            .and_then(|arc| {
6716                arc.lock().ok().map(|s| {
6717                    (
6718                        s.liveliness_alive,
6719                        s.liveliness_alive_count,
6720                        s.liveliness_not_alive_count,
6721                    )
6722                })
6723            })
6724            .unwrap_or((false, 0, 0))
6725    }
6726
6727    /// LivelinessLost counter on the user writer (Spec §2.2.4.2.10).
6728    /// Incremented by `check_writer_liveliness`.
6729    #[must_use]
6730    pub fn user_writer_liveliness_lost(&self, eid: EntityId) -> u64 {
6731        self.writer_slot(eid)
6732            .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_lost_count))
6733            .unwrap_or(0)
6734    }
6735
6736    /// Snapshot of OfferedIncompatibleQosStatus on the writer.
6737    #[must_use]
6738    pub fn user_writer_offered_incompatible_qos(
6739        &self,
6740        eid: EntityId,
6741    ) -> crate::status::OfferedIncompatibleQosStatus {
6742        self.writer_slot(eid)
6743            .and_then(|arc| arc.lock().ok().map(|s| s.offered_incompatible_qos.clone()))
6744            .unwrap_or_default()
6745    }
6746
6747    /// Snapshot of RequestedIncompatibleQosStatus on the reader.
6748    #[must_use]
6749    pub fn user_reader_requested_incompatible_qos(
6750        &self,
6751        eid: EntityId,
6752    ) -> crate::status::RequestedIncompatibleQosStatus {
6753        self.reader_slot(eid)
6754            .and_then(|arc| {
6755                arc.lock()
6756                    .ok()
6757                    .map(|s| s.requested_incompatible_qos.clone())
6758            })
6759            .unwrap_or_default()
6760    }
6761
6762    /// Sample-lost counter (reader side). Spec §2.2.4.2.6.2.
6763    #[must_use]
6764    pub fn user_reader_sample_lost(&self, eid: EntityId) -> u64 {
6765        self.reader_slot(eid)
6766            .and_then(|arc| arc.lock().ok().map(|s| s.sample_lost_count))
6767            .unwrap_or(0)
6768    }
6769
6770    /// Monotonically increasing count of alive samples delivered to the
6771    /// user (Spec §2.2.4.2.6.1 `on_data_available` detector). A delta
6772    /// against the last poll snapshot means "new data available".
6773    #[must_use]
6774    pub fn user_reader_samples_delivered(&self, eid: EntityId) -> u64 {
6775        self.reader_slot(eid)
6776            .and_then(|arc| arc.lock().ok().map(|s| s.samples_delivered_count))
6777            .unwrap_or(0)
6778    }
6779
6780    /// A2 — arm TIME_BASED_FILTER (DDS 1.4 §2.2.3.12) on a runtime/C-FFI user
6781    /// reader: it then receives at most one sample per instance per
6782    /// `min_separation_nanos`; closer-spaced samples are dropped before they
6783    /// reach the reader's channel. `0` disables the filter. Returns `true` if
6784    /// the reader exists. This is the seam `rmw_zerodds` uses to rate-limit ROS-2
6785    /// subscriptions (`rmw_qos_profile_t` carries no TIME_BASED_FILTER field).
6786    pub fn set_user_reader_time_based_filter(
6787        &self,
6788        eid: EntityId,
6789        min_separation_nanos: u128,
6790    ) -> bool {
6791        let Some(arc) = self.reader_slot(eid) else {
6792            return false;
6793        };
6794        let Ok(mut slot) = arc.lock() else {
6795            return false;
6796        };
6797        slot.tbf_min_separation_nanos = min_separation_nanos;
6798        if min_separation_nanos == 0 {
6799            slot.tbf_last_delivered.clear();
6800        }
6801        true
6802    }
6803
6804    /// Bug-2 diagnosis (2026-05-19): number of submessages dropped
6805    /// because of an unknown writer_id. If this value is incremented
6806    /// after a write, it indicates an SEDP match
6807    /// race (writer_proxy not yet added when DATA is received).
6808    #[must_use]
6809    pub fn user_reader_unknown_src_count(&self, eid: EntityId) -> u64 {
6810        self.reader_slot(eid)
6811            .and_then(|arc| arc.lock().ok().map(|s| s.reader.unknown_src_count()))
6812            .unwrap_or(0)
6813    }
6814
6815    /// Sample-rejected status (reader side). Spec §2.2.4.2.6.3.
6816    #[must_use]
6817    pub fn user_reader_sample_rejected(
6818        &self,
6819        eid: EntityId,
6820    ) -> crate::status::SampleRejectedStatus {
6821        self.reader_slot(eid)
6822            .and_then(|arc| arc.lock().ok().map(|s| s.sample_rejected))
6823            .unwrap_or_default()
6824    }
6825
6826    /// Records a lost sample on the user reader. Called
6827    /// by resource-limit or decode-failure paths — the
6828    /// detector is application-external, because sample-lost depending on the
6829    /// implementation comes from several sources (cache drop, decode
6830    /// fail, sequence-number gap drop).
6831    pub fn record_sample_lost(&self, eid: EntityId, count: u32) {
6832        if count == 0 {
6833            return;
6834        }
6835        if let Some(arc) = self.reader_slot(eid) {
6836            if let Ok(mut slot) = arc.lock() {
6837                slot.sample_lost_count = slot.sample_lost_count.saturating_add(u64::from(count));
6838            }
6839        }
6840    }
6841
6842    /// Records a rejected sample on the user reader.
6843    pub fn record_sample_rejected(
6844        &self,
6845        eid: EntityId,
6846        kind: crate::status::SampleRejectedStatusKind,
6847        instance: crate::instance_handle::InstanceHandle,
6848    ) {
6849        if let Some(arc) = self.reader_slot(eid) {
6850            if let Ok(mut slot) = arc.lock() {
6851                slot.sample_rejected.total_count =
6852                    slot.sample_rejected.total_count.saturating_add(1);
6853                slot.sample_rejected.last_reason = kind;
6854                slot.sample_rejected.last_instance_handle = instance;
6855            }
6856        }
6857    }
6858
6859    /// Manual liveliness assert on the user writer. Sets the
6860    /// `last_liveliness_assert` timestamp. For `LivelinessKind::Automatic`
6861    /// `last_write` is also set — the liveliness path
6862    /// otherwise never falls through the `assert` trigger, because every successful
6863    /// `write` already takes over the liveliness tick.
6864    pub fn assert_writer_liveliness_eid(&self, eid: EntityId) {
6865        let now = self.start_instant.elapsed();
6866        if let Some(arc) = self.writer_slot(eid) {
6867            if let Ok(mut slot) = arc.lock() {
6868                slot.last_liveliness_assert = Some(now);
6869                if slot.liveliness_kind == zerodds_qos::LivelinessKind::Automatic {
6870                    slot.last_write = Some(now);
6871                }
6872            }
6873        }
6874    }
6875
6876    /// True if all matched readers have acknowledged all samples written
6877    /// so far. Empty cache or no proxies → true.
6878    #[must_use]
6879    pub fn user_writer_all_acknowledged(&self, eid: EntityId) -> bool {
6880        self.writer_slot(eid)
6881            .and_then(|arc| arc.lock().ok().map(|s| s.writer.all_samples_acknowledged()))
6882            .unwrap_or(true)
6883    }
6884
6885    /// Test helper — pushes a synthetic `UserSample::Alive`
6886    /// directly into the `mpsc::Sender` of the given reader, without
6887    /// going through the wire/discovery path. Enables end-to-end tests of
6888    /// downstream consumers (e.g. bridge-daemon pumps) that otherwise
6889    /// become flaky in CI containers due to multicast-loopback limits.
6890    /// **Not** for production code.
6891    ///
6892    /// `writer_guid` and `writer_strength` are set to default values
6893    /// (shared-ownership assumption).
6894    ///
6895    /// Returns `true` if the reader slot exists and the push
6896    /// succeeded, `false` if the EID is unknown or the channel is
6897    /// closed.
6898    #[doc(hidden)]
6899    pub fn test_inject_user_alive(&self, eid: EntityId, payload: Vec<u8>) -> bool {
6900        let Some(arc) = self.reader_slot(eid) else {
6901            return false;
6902        };
6903        let Ok(mut slot) = arc.lock() else {
6904            return false;
6905        };
6906        let sent = slot
6907            .sample_tx
6908            .send(UserSample::Alive {
6909                payload: crate::sample_bytes::SampleBytes::from_vec(payload),
6910                writer_guid: [0u8; 16],
6911                writer_strength: 0,
6912                representation: 0,
6913                big_endian: false,
6914                source_timestamp: None,
6915                source_sequence_number: -1,
6916            })
6917            .is_ok();
6918        if sent {
6919            slot.samples_delivered_count = slot.samples_delivered_count.saturating_add(1);
6920        }
6921        sent
6922    }
6923
6924    /// Test helper — bumps the inconsistent-topic counter as if matching had
6925    /// discovered a remote endpoint with the same `topic_name` but a
6926    /// different `type_name`. Lets listener-FFI tests exercise the
6927    /// `on_inconsistent_topic` poll path without standing up two
6928    /// participants with a real SEDP type mismatch. **Not** for production.
6929    #[doc(hidden)]
6930    pub fn test_bump_inconsistent_topic(&self) {
6931        self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
6932    }
6933
6934    /// Spec §3.1 zerodds-async-1.0: registers the waker of an
6935    /// async reader in the UserReaderSlot. On `sample_tx.send`
6936    /// the waker is woken. `None` as the argument clears the waker
6937    /// (e.g. after the async reader is dropped).
6938    pub fn register_user_reader_waker(&self, eid: EntityId, waker: Option<core::task::Waker>) {
6939        if let Some(arc) = self.reader_slot(eid) {
6940            if let Ok(slot) = arc.lock() {
6941                if let Ok(mut g) = slot.async_waker.lock() {
6942                    *g = waker;
6943                }
6944            }
6945        }
6946    }
6947
6948    /// Register a listener callback for alive-sample
6949    /// arrival on the user reader. `None` clears an
6950    /// existing listener.
6951    ///
6952    /// The listener fires synchronously on the recv thread of
6953    /// `recv_user_data_loop` — see the contract doc on the
6954    /// [`UserReaderListener`] type. Eliminates the user-polling
6955    /// latency (~50-100 µs) compared to `sample_tx.recv()`.
6956    ///
6957    /// Returns `true` if the reader slot exists and the listener
6958    /// was set, `false` if the EID is not a known user reader.
6959    pub fn set_user_reader_listener(
6960        &self,
6961        eid: EntityId,
6962        listener: Option<UserReaderListener>,
6963    ) -> bool {
6964        let Some(arc) = self.reader_slot(eid) else {
6965            return false;
6966        };
6967        let Ok(mut slot) = arc.lock() else {
6968            return false;
6969        };
6970        slot.listener = listener.map(alloc::sync::Arc::new);
6971        true
6972    }
6973
6974    /// Number of matched writers for a local user reader: remote/cross-
6975    /// participant writers (writer proxies) plus same-participant writers from the
6976    /// intra-runtime routes, deduplicated by GUID (symmetric to the writer).
6977    #[must_use]
6978    pub fn user_reader_matched_count(&self, eid: EntityId) -> usize {
6979        self.user_reader_matched_publication_handles(eid).len()
6980    }
6981
6982    /// D.5e Phase-1 — waits until a match event occurs or the timeout
6983    /// is reached. Replaces 20-ms polling in `DataReader::wait_for_matched_*`
6984    /// and `DataWriter::wait_for_matched_*`.
6985    ///
6986    /// The caller checks the match count itself (via `user_*_matched_count`)
6987    /// before and after the wait — this function is only the block mechanics.
6988    /// Returns `false` if the timeout is reached, `true` if a notify came.
6989    #[cfg(feature = "std")]
6990    pub fn wait_match_event(&self, timeout: core::time::Duration) -> bool {
6991        let (lock, cvar) = &*self.match_event;
6992        let Ok(guard) = lock.lock() else { return false };
6993        match cvar.wait_timeout(guard, timeout) {
6994            Ok((_, t)) => !t.timed_out(),
6995            Err(_) => false,
6996        }
6997    }
6998
6999    /// D.5e Phase-1 — waits until an ACK event occurs or a timeout.
7000    /// Replaces 50-ms polling in `DataWriter::wait_for_acknowledgments`.
7001    #[cfg(feature = "std")]
7002    pub fn wait_ack_event(&self, timeout: core::time::Duration) -> bool {
7003        let (lock, cvar) = &*self.ack_event;
7004        let Ok(guard) = lock.lock() else { return false };
7005        match cvar.wait_timeout(guard, timeout) {
7006            Ok((_, t)) => !t.timed_out(),
7007            Err(_) => false,
7008        }
7009    }
7010
7011    /// D.5e Phase-1 — notify helper for the ACK event. Called by the reliable
7012    /// writer path when an ACKNACK advances the acked-base.
7013    #[cfg(feature = "std")]
7014    pub(crate) fn notify_ack_event(&self) {
7015        self.ack_event.1.notify_all();
7016    }
7017
7018    /// ADR-0006 — sets the PID_SHM_LOCATOR bytes for a local
7019    /// user writer in the side map. Called by the DataWriter
7020    /// once `set_flat_backend` has attached a same-host backend (POSIX shm /
7021    /// Iceoryx2). On the next SEDP push the wire encoder
7022    /// injects PID 0x8001 into the `PublicationData`.
7023    pub fn set_shm_locator(&self, eid: EntityId, bytes: Vec<u8>) {
7024        if let Ok(mut g) = self.shm_locators.write() {
7025            g.insert(eid, bytes);
7026        }
7027    }
7028
7029    /// ADR-0006 — reads the PID_SHM_LOCATOR bytes for a local
7030    /// user writer from the side map. Returns `None` if no
7031    /// same-host backend is set.
7032    #[must_use]
7033    pub fn shm_locator(&self, eid: EntityId) -> Option<Vec<u8>> {
7034        self.shm_locators.read().ok()?.get(&eid).cloned()
7035    }
7036
7037    /// ADR-0006 — removes the PID_SHM_LOCATOR entry (e.g. when the
7038    /// user writer is reconfigured without a backend).
7039    pub fn clear_shm_locator(&self, eid: EntityId) {
7040        if let Ok(mut g) = self.shm_locators.write() {
7041            g.remove(&eid);
7042        }
7043    }
7044
7045    /// Stops all worker threads (recv loops + tick loop) and joins
7046    /// them. Idempotent — repeated calls are no-ops.
7047    ///
7048    /// Shutdown delay: up to ~1 s, because the recv threads sit in
7049    /// `recv()` with a 1 s read timeout. After the
7050    /// current recv() call finishes they check the stop flag and
7051    /// terminate.
7052    pub fn shutdown(&self) {
7053        self.stop.store(true, Ordering::Relaxed);
7054        // D.5e Phase 3 — wake the scheduler tick worker so it observes `stop`
7055        // immediately instead of parking up to the idle floor.
7056        if let Ok(guard) = self.tick_wake.lock() {
7057            if let Some(h) = guard.as_ref() {
7058                h.stop();
7059            }
7060        }
7061        if let Ok(mut guard) = self.handles.lock() {
7062            for h in guard.drain(..) {
7063                let _ = h.join();
7064            }
7065        }
7066    }
7067}
7068
7069impl Drop for DcpsRuntime {
7070    // ZERODDS_PHASE_DUMP=1 is on-demand debug telemetry for
7071    // phase-latency profiling. eprintln is semantically correct here
7072    // (stderr diagnostics), no log-crate dependency wanted.
7073    #[allow(clippy::print_stderr)]
7074    fn drop(&mut self) {
7075        if std::env::var("ZERODDS_PHASE_DUMP")
7076            .map(|s| s == "1")
7077            .unwrap_or(false)
7078        {
7079            let hu_ns = PHASE_HANDLE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
7080            let hu_n = PHASE_HANDLE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
7081            let wu_ns = PHASE_WRITE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
7082            let wu_n = PHASE_WRITE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
7083            let hu_us = if hu_n > 0 {
7084                hu_ns as f64 / hu_n as f64 / 1000.0
7085            } else {
7086                0.0
7087            };
7088            let wu_us = if wu_n > 0 {
7089                wu_ns as f64 / wu_n as f64 / 1000.0
7090            } else {
7091                0.0
7092            };
7093            eprintln!(
7094                "[ZERODDS_PHASE] handle_user_datagram:  N={}  avg={:.3}us  total={:.1}ms",
7095                hu_n,
7096                hu_us,
7097                hu_ns as f64 / 1_000_000.0
7098            );
7099            eprintln!(
7100                "[ZERODDS_PHASE] write_user_sample:      N={}  avg={:.3}us  total={:.1}ms",
7101                wu_n,
7102                wu_us,
7103                wu_ns as f64 / 1_000_000.0
7104            );
7105            // Sub-phases of write_user_sample_borrowed.
7106            // [0] slot_lookup, [1] slot_lock_acquire,
7107            // [2] writer.write + framing, [3] dispatch (UDP + inproc).
7108            const SUB_LABELS: [&str; 4] = [
7109                "  ├─ slot_lookup       ",
7110                "  ├─ slot_lock_acquire ",
7111                "  ├─ writer.write+frame",
7112                "  └─ dispatch (UDP+...)",
7113            ];
7114            for (i, label) in SUB_LABELS.iter().enumerate() {
7115                let s_ns = PHASE_WRITE_SUB_NS[i].load(core::sync::atomic::Ordering::Relaxed);
7116                if s_ns > 0 && wu_n > 0 {
7117                    let s_us = s_ns as f64 / wu_n as f64 / 1000.0;
7118                    eprintln!(
7119                        "[ZERODDS_PHASE] {} avg={:.3}us  total={:.1}ms",
7120                        label,
7121                        s_us,
7122                        s_ns as f64 / 1_000_000.0
7123                    );
7124                }
7125            }
7126        }
7127        self.shutdown();
7128    }
7129}
7130
7131// ---------------------------------------------------------------------
7132// Worker threads (Sprint D.5b — per-socket recv + central tick).
7133//
7134// Before: a single `event_loop` that went through three sequential
7135// blocking `recv()`s with a `tick_period` timeout (50 ms) per iteration.
7136// Roundtrip latency: 5-14 ms p50 (CFS drift + sequential wait stages).
7137//
7138// Now: four dedicated threads.
7139//   * recv_spdp_multicast_loop  — blocks on the SPDP multicast socket
7140//   * recv_metatraffic_loop     — blocks on SPDP unicast (= metatraffic)
7141//   * recv_user_data_loop       — blocks on user-data unicast
7142//   * tick_loop                 — periodic outbound tasks +
7143//                                 per-interface inbound (non-blocking) +
7144//                                 deadline/lifespan/liveliness
7145//
7146// Lock discipline: the recv threads and the tick thread contend for
7147// `rt.sedp.lock()` / `rt.wlp.lock()` / per-slot `slot.lock()`.
7148// Convention: keep lock-hold times short (handle_datagram + tick each
7149// have only single-pass logic), no sub-lock under sedp/wlp.
7150// ---------------------------------------------------------------------
7151
7152/// Sprint D.5d lever C — applies SCHED_FIFO + CPU affinity to the
7153/// calling thread. Linux-only; no-op on macOS/Windows.
7154///
7155/// Called by every worker loop right at the start, so
7156/// the syscalls run on the actual worker thread
7157/// (`pthread_self()` must come from the thread itself).
7158///
7159/// Failures are logged to stderr but are not fatal — if
7160/// the process has no `CAP_SYS_NICE`, the runtime continues with
7161/// the CFS default scheduler.
7162#[allow(unused_variables)]
7163fn apply_thread_tuning(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
7164    #[cfg(target_os = "linux")]
7165    rt_pinning::apply(label, priority, cpus);
7166}
7167
7168/// Linux-only `pthread_setschedparam` + `sched_setaffinity` wrapper.
7169/// A dedicated module encapsulates the `unsafe` locally with safety notes; the
7170/// crate-level `#![deny(unsafe_code)]` stays active for the rest of the dcps
7171/// codebase.
7172#[cfg(target_os = "linux")]
7173#[allow(unsafe_code, clippy::print_stderr)]
7174mod rt_pinning {
7175    pub(super) fn apply(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
7176        if let Some(prio) = priority {
7177            // SAFETY: libc FFI with an owned `param` struct. The self-thread via
7178            // `pthread_self()` is always valid.
7179            // musl libc has additional `sched_ss_*` fields (POSIX
7180            // sporadic-server) that we do not set — `mem::zeroed`
7181            // initializes them cleanly to 0.
7182            unsafe {
7183                let mut param: libc::sched_param = core::mem::zeroed();
7184                param.sched_priority = prio;
7185                let rc = libc::pthread_setschedparam(
7186                    libc::pthread_self(),
7187                    libc::SCHED_FIFO,
7188                    &raw const param,
7189                );
7190                if rc != 0 {
7191                    eprintln!(
7192                        "zdds[{label}]: pthread_setschedparam SCHED_FIFO {prio} \
7193                         failed (rc={rc}). Need CAP_SYS_NICE or RLIMIT_RTPRIO."
7194                    );
7195                }
7196            }
7197        }
7198        if let Some(cpu_list) = cpus {
7199            // SAFETY: cpu_set_t is POD; CPU_ZERO/SET are libc inline
7200            // functions without lifetime requirements.
7201            unsafe {
7202                let mut set: libc::cpu_set_t = core::mem::zeroed();
7203                libc::CPU_ZERO(&mut set);
7204                for &cpu in cpu_list {
7205                    if cpu < libc::CPU_SETSIZE as usize {
7206                        libc::CPU_SET(cpu, &mut set);
7207                    }
7208                }
7209                let rc = libc::sched_setaffinity(
7210                    0,
7211                    core::mem::size_of::<libc::cpu_set_t>(),
7212                    &raw const set,
7213                );
7214                if rc != 0 {
7215                    eprintln!("zdds[{label}]: sched_setaffinity({cpu_list:?}) failed.");
7216                }
7217            }
7218        }
7219    }
7220}
7221
7222/// FastDDS interop (phase 2): acknowledges FastDDS' reliable secure SPDP writer
7223/// (0xff0101c2). FastDDS heartbeats its secure SPDP reliably and sends the
7224/// `participant_crypto_tokens` only once our 0xff0101c7 reader has acked its writer
7225/// (fast<->fast reference pcap: ACKNACK on 0xff0101c7). We respond to
7226/// every incoming secure-SPDP HEARTBEAT with an ACKNACK (base = last+1,
7227/// final), addressed via INFO_DST to the sender prefix. Gated on
7228/// `enable_secure_spdp`.
7229#[cfg(feature = "security")]
7230fn secure_spdp_reader_acks(rt: &DcpsRuntime, clear: &[u8]) -> Vec<Vec<u8>> {
7231    use zerodds_rtps::header::RtpsHeader;
7232    use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
7233    use zerodds_rtps::submessages::{AckNackSubmessage, HeartbeatSubmessage, SequenceNumberSet};
7234    use zerodds_rtps::wire_types::SequenceNumber;
7235    if !rt.config.enable_secure_spdp {
7236        return Vec::new();
7237    }
7238    let Ok(parsed) = decode_datagram(clear) else {
7239        return Vec::new();
7240    };
7241    let peer_prefix = parsed.header.guid_prefix;
7242    let mut out = Vec::new();
7243    let mut count = 0i32;
7244    let secure_writer = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER;
7245    let secure_reader = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER;
7246    // Header + INFO_DST(peer) + submessage. INFO_DST is mandatory, otherwise the
7247    // dest prefix is UNKNOWN -> FastDDS discards it as "not a connection".
7248    let wrap = |id: SubmessageId, body: &[u8], flags: u8| -> Option<Vec<u8>> {
7249        let blen = u16::try_from(body.len()).ok()?;
7250        let header = RtpsHeader::new(VendorId::ZERODDS, rt.guid_prefix);
7251        let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
7252        dg.extend_from_slice(&header.to_bytes());
7253        let info = SubmessageHeader {
7254            submessage_id: SubmessageId::InfoDst,
7255            flags: FLAG_E_LITTLE_ENDIAN,
7256            octets_to_next_header: 12,
7257        };
7258        dg.extend_from_slice(&info.to_bytes());
7259        dg.extend_from_slice(&peer_prefix.to_bytes());
7260        let sh = SubmessageHeader {
7261            submessage_id: id,
7262            flags: flags | FLAG_E_LITTLE_ENDIAN,
7263            octets_to_next_header: blen,
7264        };
7265        dg.extend_from_slice(&sh.to_bytes());
7266        dg.extend_from_slice(body);
7267        Some(dg)
7268    };
7269    for sub in &parsed.submessages {
7270        match sub {
7271            // FastDDS' secure-SPDP writer HEARTBEAT -> we ack (reader 0xff0101c7).
7272            ParsedSubmessage::Heartbeat(hb) if hb.writer_id == secure_writer => {
7273                count = count.wrapping_add(1);
7274                let ack = AckNackSubmessage {
7275                    reader_id: secure_reader,
7276                    writer_id: secure_writer,
7277                    reader_sn_state: SequenceNumberSet {
7278                        bitmap_base: SequenceNumber(hb.last_sn.0 + 1),
7279                        num_bits: 0,
7280                        bitmap: Vec::new(),
7281                    },
7282                    count,
7283                    final_flag: true,
7284                };
7285                let (body, flags) = ack.write_body(true);
7286                if let Some(dg) = wrap(SubmessageId::AckNack, &body, flags) {
7287                    out.push(dg);
7288                }
7289            }
7290            // FastDDS' reader requests (preemptive ACKNACK to our 0xff0101c2
7291            // writer) our secure-SPDP data reliably -> deliver DATA(SN=1) +
7292            // HEARTBEAT(1,1), otherwise FastDDS' reader never matches and
7293            // sends no crypto_tokens.
7294            ParsedSubmessage::AckNack(a) if a.writer_id == secure_writer => {
7295                if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7296                    if let Ok(data_dg) = beacon.serialize_secure() {
7297                        out.push(protect_secure_spdp(rt, &data_dg).unwrap_or(data_dg));
7298                    }
7299                }
7300                count = count.wrapping_add(1);
7301                let hbsm = HeartbeatSubmessage {
7302                    reader_id: secure_reader,
7303                    writer_id: secure_writer,
7304                    first_sn: SequenceNumber(1),
7305                    last_sn: SequenceNumber(1),
7306                    count,
7307                    final_flag: false,
7308                    liveliness_flag: false,
7309                    group_info: None,
7310                };
7311                let (body, flags) = hbsm.write_body(true);
7312                if let Some(dg) = wrap(SubmessageId::Heartbeat, &body, flags) {
7313                    out.push(dg);
7314                }
7315            }
7316            _ => {}
7317        }
7318    }
7319    out
7320}
7321
7322/// FastDDS interop (phase 2b): builds a secure-SPDP HEARTBEAT (writer
7323/// 0xff0101c2, first=1/last=1) with INFO_DST to `peer_prefix`. Sent periodically per
7324/// discovered peer, so FastDDS' reliable secure-SPDP reader is solicited to a
7325/// (preemptive) ACKNACK and matches our writer.
7326#[cfg(feature = "security")]
7327fn build_secure_spdp_heartbeat(
7328    local_prefix: GuidPrefix,
7329    peer_prefix: GuidPrefix,
7330    count: i32,
7331) -> Option<Vec<u8>> {
7332    use zerodds_rtps::header::RtpsHeader;
7333    use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
7334    use zerodds_rtps::submessages::HeartbeatSubmessage;
7335    use zerodds_rtps::wire_types::SequenceNumber;
7336    let hb = HeartbeatSubmessage {
7337        reader_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
7338        writer_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
7339        first_sn: SequenceNumber(1),
7340        last_sn: SequenceNumber(1),
7341        count,
7342        final_flag: false,
7343        liveliness_flag: false,
7344        group_info: None,
7345    };
7346    let (body, flags) = hb.write_body(true);
7347    let blen = u16::try_from(body.len()).ok()?;
7348    let header = RtpsHeader::new(VendorId::ZERODDS, local_prefix);
7349    let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
7350    dg.extend_from_slice(&header.to_bytes());
7351    let info = SubmessageHeader {
7352        submessage_id: SubmessageId::InfoDst,
7353        flags: FLAG_E_LITTLE_ENDIAN,
7354        octets_to_next_header: 12,
7355    };
7356    dg.extend_from_slice(&info.to_bytes());
7357    dg.extend_from_slice(&peer_prefix.to_bytes());
7358    let sh = SubmessageHeader {
7359        submessage_id: SubmessageId::Heartbeat,
7360        flags: flags | FLAG_E_LITTLE_ENDIAN,
7361        octets_to_next_header: blen,
7362    };
7363    dg.extend_from_slice(&sh.to_bytes());
7364    dg.extend_from_slice(&body);
7365    Some(dg)
7366}
7367
7368/// FastDDS interop: SEC-protects the secure-SPDP DATA (0xff0101c2) under
7369/// `discovery_protection != NONE` — FastDDS then encrypts the secure-SPDP DATA
7370/// (like the secure SEDP), and a PLAIN secure SPDP is discarded. Wraps
7371/// the DATA submessage with the per-endpoint writer key (0xff0101c2) as
7372/// SEC_PREFIX/BODY/POSTFIX; framing submessages (INFO_*) stay. Without
7373/// discovery_protection (common subset) passthrough. `None` on a crypto error.
7374#[cfg(feature = "security")]
7375fn protect_secure_spdp(rt: &DcpsRuntime, datagram: &[u8]) -> Option<Vec<u8>> {
7376    let gate = rt.config.security.as_ref()?;
7377    if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None
7378        || datagram.len() < 20
7379    {
7380        return Some(datagram.to_vec());
7381    }
7382    let h = local_endpoint_crypto_handle(
7383        rt,
7384        EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
7385        true,
7386    )?;
7387    let mut out = datagram[..20].to_vec();
7388    for (id, start, total) in walk_submessages(datagram) {
7389        let submsg = &datagram[start..start + total];
7390        if id == SMID_DATA {
7391            match gate.encode_data_datawriter_by_handle(h, submsg) {
7392                Ok(s) => out.extend_from_slice(&s),
7393                Err(_) => return None,
7394            }
7395        } else {
7396            out.extend_from_slice(submsg);
7397        }
7398    }
7399    Some(out)
7400}
7401
7402/// Worker: blocks on the SPDP multicast socket, dispatches SPDP beacons +
7403/// WLP heartbeats that come in over multicast.
7404fn recv_spdp_multicast_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7405    apply_thread_tuning(
7406        "recv-spdp-mc",
7407        rt.config.recv_thread_priority,
7408        rt.config.recv_thread_cpus.as_deref(),
7409    );
7410    while !stop.load(Ordering::Relaxed) {
7411        let elapsed = rt.start_instant.elapsed();
7412        let sedp_now = Duration::from_secs(elapsed.as_secs())
7413            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7414        let Ok(dg) = rt.spdp_multicast_rx.recv() else {
7415            continue;
7416        };
7417        #[cfg(feature = "security")]
7418        let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7419        #[cfg(not(feature = "security"))]
7420        let clear = secure_inbound_bytes(&rt, &dg.data);
7421        if let Some(clear) = clear {
7422            handle_spdp_datagram(&rt, &clear);
7423            // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
7424            // reliably, otherwise FastDDS sends no crypto_tokens.
7425            #[cfg(feature = "security")]
7426            for ack in secure_spdp_reader_acks(&rt, &clear) {
7427                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7428                    let _ = rt.spdp_unicast.send(&loc, &ack);
7429                }
7430            }
7431            // WLP heartbeats arrive on the SPDP multicast socket
7432            // (the sender sends them to the SPDP multicast group).
7433            // handle_spdp_datagram ignores them, so we also feed
7434            // the same buffer into the WLP endpoint. A
7435            // secure-WLP DATA is participant-key SEC-protected → decode
7436            // it first (like secure SEDP in the metatraffic loop), otherwise
7437            // wlp.handle_datagram would only see the SEC block.
7438            #[cfg(feature = "security")]
7439            let wlp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
7440                let mut pk = [0u8; 12];
7441                pk.copy_from_slice(&clear[8..20]);
7442                unprotect_user_datagram(&rt, &clear, &pk)
7443            } else {
7444                None
7445            };
7446            #[cfg(feature = "security")]
7447            let wlp_input: &[u8] = wlp_decoded.as_deref().unwrap_or(&clear);
7448            #[cfg(not(feature = "security"))]
7449            let wlp_input: &[u8] = &clear;
7450            if let Ok(mut wlp) = rt.wlp.lock() {
7451                let _ = wlp.handle_datagram(wlp_input, sedp_now);
7452            }
7453        }
7454    }
7455}
7456
7457/// Worker: blocks on SPDP unicast (= metatraffic socket), dispatches
7458/// SPDP reverse beacons + SEDP + WLP + security builtin.
7459fn recv_metatraffic_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7460    apply_thread_tuning(
7461        "recv-meta",
7462        rt.config.recv_thread_priority,
7463        rt.config.recv_thread_cpus.as_deref(),
7464    );
7465    while !stop.load(Ordering::Relaxed) {
7466        let elapsed = rt.start_instant.elapsed();
7467        let sedp_now = Duration::from_secs(elapsed.as_secs())
7468            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7469        let Ok(dg) = rt.spdp_unicast.recv() else {
7470            continue;
7471        };
7472        #[cfg(feature = "security")]
7473        let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7474        #[cfg(not(feature = "security"))]
7475        let clear = secure_inbound_bytes(&rt, &dg.data);
7476        if let Some(clear) = clear {
7477            // A single recv call, both handlers on the same
7478            // datagram. SPDP first (Cyclone reverse beacons), then
7479            // SEDP, then WLP, then security builtin.
7480            handle_spdp_datagram(&rt, &clear);
7481            // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
7482            // reliably (they arrive unicast over the metatraffic socket).
7483            #[cfg(feature = "security")]
7484            for ack in secure_spdp_reader_acks(&rt, &clear) {
7485                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7486                    let _ = rt.spdp_unicast.send(&loc, &ack);
7487                }
7488            }
7489            // Protected discovery: secure-SEDP DATA is SEC_* submessage-
7490            // protected (the sender's participant data key). Before the SEDP parse
7491            // decode it with the sender prefix (RTPS header bytes[8..20]); for
7492            // plaintext SEDP (no SEC_*) unprotect_user_datagram returns None
7493            // and we use `clear` unchanged.
7494            #[cfg(feature = "security")]
7495            let sedp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
7496                let mut pk = [0u8; 12];
7497                pk.copy_from_slice(&clear[8..20]);
7498                unprotect_user_datagram(&rt, &clear, &pk)
7499            } else {
7500                None
7501            };
7502            // OPEN (phase 3, internal/security/per-endpoint-crypto-followup.md):
7503            // if `unprotect_user_datagram` fails for a secure-SEDP DATA
7504            // (cyclone's per-endpoint token not yet installed — race),
7505            // `sedp_input` falls back to the SEC_* bytes and the DATA is discarded.
7506            // Cross-vendor (discovery=ENCRYPT) must make this deterministic:
7507            // treat the reliable secure-SEDP DATA as not-received (NACK,
7508            // no SN advance), so the re-send after token install decodes.
7509            #[cfg(feature = "security")]
7510            let sedp_input: &[u8] = sedp_decoded.as_deref().unwrap_or(&clear);
7511            #[cfg(not(feature = "security"))]
7512            let sedp_input: &[u8] = &clear;
7513            let events = {
7514                if let Ok(mut sedp) = rt.sedp.lock() {
7515                    sedp.handle_datagram(sedp_input, sedp_now).ok()
7516                } else {
7517                    None
7518                }
7519            };
7520            if let Some(ev) = events {
7521                if !ev.is_empty() {
7522                    run_matching_pass(&rt);
7523                    apply_sedp_removals(&rt, &ev);
7524                    push_sedp_events_to_builtin_readers(&rt, &ev);
7525                }
7526            }
7527
7528            // Secure WLP (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER) is, like
7529            // secure SEDP, participant-key SEC-protected → feed the decoded variant
7530            // (sedp_input), not the still SEC-wrapped `clear`. For
7531            // plaintext WLP, sedp_input == clear.
7532            let wlp_resends = if let Ok(mut wlp) = rt.wlp.lock() {
7533                let _ = wlp.handle_datagram(sedp_input, sedp_now);
7534                // Reliable resend: if the peer NACKs our (secure-)WLP writer,
7535                // we re-emit the missing beats (cyclone treats WLP as
7536                // reliable; without a resend it would never get the liveliness assertion).
7537                wlp.wlp_acknack_resends(sedp_input)
7538            } else {
7539                Vec::new()
7540            };
7541            for beat in wlp_resends {
7542                if let Some(secured) = protect_wlp_outbound(&rt, &beat) {
7543                    for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7544                        let _ = rt.spdp_unicast.send(&loc, &secured);
7545                    }
7546                }
7547            }
7548            for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
7549                send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7550            }
7551        }
7552    }
7553}
7554
7555/// Supervisor: wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) — same-host SHM
7556/// receive. Spawns **one dedicated receive thread per bound SHM consumer**, so
7557/// each blocks on its own segment futex ([`PosixShmTransport::recv`] →
7558/// `wait_for_frame`) and dispatches a sample the instant it lands.
7559///
7560/// History: the original single loop iterated all consumers round-robin and
7561/// called the blocking `recv()` on each in turn. With N consumers the
7562/// worst-case per-sample latency was `(N-1) × recv_timeout` (1 ms each, see
7563/// [`crate::same_host_shm::shm_config_for_pair`]) — a single thread cannot wait
7564/// on N futexes at once, so the active consumer's sample waited while the loop
7565/// sat in idle consumers' `recv()` timeouts. Fine for 1-2 same-host peers, but
7566/// it dominated at the many-endpoint scale ROS hits (user topics +
7567/// `ros_discovery_info` + parameter services + `rosout` = a dozen same-host SHM
7568/// consumers), turning a ~30 µs delivery into ~570 µs. The documented fix
7569/// ("multiple threads or epoll-style multiplexing") is realized here as one
7570/// thread per consumer. The supervisor only polls *membership* (discovery-rate,
7571/// not the data path) to spawn/reap workers.
7572#[cfg(feature = "same-host-shm")]
7573fn recv_user_shm_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7574    use crate::same_host::{Role, SameHostState};
7575    use zerodds_transport_shm::PosixShmTransport;
7576
7577    apply_thread_tuning(
7578        "recv-shm-sup",
7579        rt.config.recv_thread_priority,
7580        rt.config.recv_thread_cpus.as_deref(),
7581    );
7582    // segment-id → (per-worker stop flag, join handle).
7583    let mut workers: std::collections::HashMap<
7584        [u8; 16],
7585        (Arc<AtomicBool>, thread::JoinHandle<()>),
7586    > = std::collections::HashMap::new();
7587    // Membership poll is discovery-rate, NOT the data path: it only detects
7588    // newly-bound / vanished consumers to spawn / reap their worker thread.
7589    let membership_poll = Duration::from_millis(100);
7590    while !stop.load(Ordering::Relaxed) {
7591        let mut live: std::collections::HashSet<[u8; 16]> = std::collections::HashSet::new();
7592        for (w, r, state) in rt.same_host.snapshot() {
7593            let SameHostState::Bound { transport, role } = state else {
7594                continue;
7595            };
7596            if !matches!(role, Role::Consumer) {
7597                continue;
7598            }
7599            let Ok(consumer) = transport.downcast::<PosixShmTransport>() else {
7600                continue;
7601            };
7602            let key = crate::same_host::shm_segment_id_for_pair(w, r);
7603            live.insert(key);
7604            if workers.contains_key(&key) {
7605                continue;
7606            }
7607            let wstop = Arc::new(AtomicBool::new(false));
7608            let (rt_w, stop_w, wstop_w) = (Arc::clone(&rt), Arc::clone(&stop), Arc::clone(&wstop));
7609            if let Ok(h) = thread::Builder::new()
7610                .name(String::from("zdds-recv-shm-c"))
7611                .spawn(move || shm_consumer_recv_loop(rt_w, consumer, stop_w, wstop_w))
7612            {
7613                workers.insert(key, (wstop, h));
7614            }
7615        }
7616        // Reap workers whose consumer disappeared.
7617        let gone: Vec<[u8; 16]> = workers
7618            .keys()
7619            .filter(|k| !live.contains(*k))
7620            .copied()
7621            .collect();
7622        for k in gone {
7623            if let Some((wstop, h)) = workers.remove(&k) {
7624                wstop.store(true, Ordering::Relaxed);
7625                let _ = h.join();
7626            }
7627        }
7628        thread::sleep(membership_poll);
7629    }
7630    // Shutdown: stop + join every worker (each wakes within its 1 ms recv_timeout).
7631    for (_, (wstop, h)) in workers {
7632        wstop.store(true, Ordering::Relaxed);
7633        let _ = h.join();
7634    }
7635}
7636
7637/// One per-consumer SHM receive thread: blocks on this segment's futex and
7638/// dispatches each frame the instant it arrives — no cross-consumer
7639/// serialization. Exits when the runtime stops, the worker is reaped, or the
7640/// segment dies. See [`recv_user_shm_loop`].
7641#[cfg(feature = "same-host-shm")]
7642fn shm_consumer_recv_loop(
7643    rt: Arc<DcpsRuntime>,
7644    consumer: Arc<zerodds_transport_shm::PosixShmTransport>,
7645    stop: Arc<AtomicBool>,
7646    wstop: Arc<AtomicBool>,
7647) {
7648    use zerodds_transport::Transport;
7649    apply_thread_tuning(
7650        "recv-shm-c",
7651        rt.config.recv_thread_priority,
7652        rt.config.recv_thread_cpus.as_deref(),
7653    );
7654    while !stop.load(Ordering::Relaxed) && !wstop.load(Ordering::Relaxed) {
7655        match consumer.recv() {
7656            Ok(dg) => {
7657                let elapsed = rt.start_instant.elapsed();
7658                let sedp_now = Duration::from_secs(elapsed.as_secs())
7659                    + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7660                // Security gate (analogous to the UDP path). SHM is
7661                // same-host-only — if the policy allows plaintext, the
7662                // datagram comes through unchanged.
7663                #[cfg(feature = "security")]
7664                let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7665                #[cfg(not(feature = "security"))]
7666                let clear = secure_inbound_bytes(&rt, &dg.data);
7667                if let Some(clear) = clear {
7668                    handle_user_datagram(&rt, &clear, sedp_now);
7669                }
7670            }
7671            // A timeout is normal — the 1 ms recv_timeout just lets the loop
7672            // re-check the stop flags; an empty segment is not an error.
7673            Err(zerodds_transport::RecvError::Timeout) => {}
7674            // Hard error (broken segment / peer crashed): drop this worker; the
7675            // supervisor respawns if the segment is re-bound, UDP stays fallback.
7676            Err(_) => break,
7677        }
7678    }
7679}
7680
7681/// Worker: blocks on the user-data unicast socket, dispatches
7682/// TypeLookup service replies + user-sample datagrams.
7683///
7684/// Int-1 (Spec `zerodds-zero-copy-1.0` §9): with the feature
7685/// `recvmmsg-batch` on Linux the loop uses `recv_batch_linux` and
7686/// fetches up to 32 datagrams per syscall — a 7-8x throughput boost.
7687/// On an empty batch the path falls back to single-recv() so
7688/// the recv thread does not spin in a busy loop at low traffic.
7689fn recv_user_data_loop(
7690    rt: Arc<DcpsRuntime>,
7691    socket: Arc<dyn Transport + Send + Sync>,
7692    stop: Arc<AtomicBool>,
7693) {
7694    apply_thread_tuning(
7695        "recv-user",
7696        rt.config.recv_thread_priority,
7697        rt.config.recv_thread_cpus.as_deref(),
7698    );
7699    // recvmmsg-batch (Linux + feature) needs the concrete UdpSocket
7700    // under the trait. With a trait-object transport this is not directly
7701    // accessible — we fall back to single-recv(). recvmmsg is
7702    // a UDP optimization; once TCP/SHM transports are to be mixed,
7703    // it is no longer worth it. For a pure UDPv4 user transport
7704    // this costs ~5-10% throughput in Linux batch mode (measured 2026-05).
7705    while !stop.load(Ordering::Relaxed) {
7706        let elapsed = rt.start_instant.elapsed();
7707        let sedp_now = Duration::from_secs(elapsed.as_secs())
7708            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7709        let Ok(dg) = socket.recv() else {
7710            continue;
7711        };
7712        dispatch_user_datagram(&rt, &dg, sedp_now);
7713        // D.5e Phase 3 — incoming user data may solicit an ACKNACK or advance a
7714        // reliable reader: wake the scheduler tick immediately (no 5 ms tail).
7715        rt.raise_tick_wake();
7716    }
7717}
7718
7719/// Helper: dispatches a single user datagram through the security gate +
7720/// TypeLookup + handle_user_datagram. Shared by the single-recv and the
7721/// recvmmsg batch path.
7722fn dispatch_user_datagram(
7723    rt: &Arc<DcpsRuntime>,
7724    dg: &zerodds_transport::ReceivedDatagram,
7725    sedp_now: Duration,
7726) {
7727    #[cfg(feature = "security")]
7728    let clear = secure_inbound_bytes(rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7729    #[cfg(not(feature = "security"))]
7730    let clear = secure_inbound_bytes(rt, &dg.data);
7731    if let Some(clear) = clear {
7732        // TypeLookup service first — if the frame is addressed to
7733        // TL_SVC_*_READER, it does not go to a
7734        // user reader. Other frames fall through.
7735        if !dispatch_type_lookup_datagram(rt, &clear, &dg.source) {
7736            handle_user_datagram(rt, &clear, sedp_now);
7737        }
7738    }
7739}
7740
7741/// Worker: periodic outbound tasks + per-interface inbound
7742/// (non-blocking) + housekeeping. Sleeps `tick_period` between
7743/// iterations.
7744fn tick_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7745    apply_thread_tuning(
7746        "tick",
7747        rt.config.tick_thread_priority,
7748        rt.config.tick_thread_cpus.as_deref(),
7749    );
7750    let mut st = TickState::new(&rt);
7751    while !stop.load(Ordering::Relaxed) {
7752        run_tick_iteration(Arc::clone(&rt), &mut st);
7753        // Housekeeping runs inline here in the classic fixed-period path,
7754        // exactly as before (every `tick_period`, same cadence).
7755        tick_housekeep(&rt, rt.start_instant.elapsed());
7756        std::thread::sleep(rt.config.tick_period);
7757    }
7758}
7759
7760/// D.5e Phase 3 — idle park cap for a discovery-only participant (no user
7761/// endpoints): how long the scheduler tick worker may sleep when nothing but
7762/// SPDP/WLP is pending. SPDP/WLP fire on their own (longer) periods, so this is
7763/// just a safety heartbeat — well above the 5 ms poll it replaces.
7764const SCHEDULER_IDLE_FLOOR: Duration = Duration::from_millis(250);
7765
7766/// Earliest instant the scheduler tick worker must next run `run_tick_iteration`
7767/// so no periodic work is delayed: never past the next SPDP announce, and —
7768/// while user endpoints exist — capped at `tick_period` so HEARTBEAT/ACKNACK/
7769/// deadline/lifespan/liveliness keep their current cadence (identical wire
7770/// behaviour). With no user endpoints, parks up to [`SCHEDULER_IDLE_FLOOR`].
7771/// Active traffic is handled out-of-band by `raise_tick_wake` (immediate).
7772fn next_tick_deadline(rt: &Arc<DcpsRuntime>, st: &TickState) -> Instant {
7773    let now = Instant::now();
7774    let fine_cap = if rt.has_user_endpoints() {
7775        rt.config.tick_period
7776    } else {
7777        SCHEDULER_IDLE_FLOOR
7778    };
7779    st.next_announce.min(now + fine_cap).max(now)
7780}
7781
7782/// D.5e Phase 3 B-2 — the kinds of work the deadline-heap scheduler fires as
7783/// distinct heap events, each re-armed at its own next deadline.
7784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7785enum TickEvent {
7786    /// Periodic SPDP announce + reliable outbound (SEDP / WLP / user HEARTBEAT /
7787    /// ACKNACK) + secondary inbound poll — the wire-producing tick
7788    /// ([`run_tick_iteration`]), re-armed at [`next_tick_deadline`].
7789    Tick,
7790    /// Deadline / lifespan / liveliness housekeeping ([`tick_housekeep`]),
7791    /// re-armed at the **exact** next QoS due-instant (no fixed quantum).
7792    Housekeep,
7793}
7794
7795/// D.5e Phase 3 — event-driven scheduler tick worker. Replaces the fixed-period
7796/// `tick_loop` sleep with a deadline-heap park. Two independent event streams:
7797/// [`TickEvent::Tick`] drives the **unchanged** `run_tick_iteration` (wire
7798/// output byte-identical to `tick_loop`), re-armed at [`next_tick_deadline`];
7799/// [`TickEvent::Housekeep`] runs the QoS checks, re-armed at their exact next
7800/// due-instant so a deadline/lifespan/liveliness fires on time instead of up to
7801/// one `tick_period` late, and an idle participant parks long. A write/recv
7802/// `raise_tick_wake` wakes **both** immediately, so freshly-armed QoS windows
7803/// are picked up without delay.
7804fn scheduler_tick_loop(
7805    rt: Arc<DcpsRuntime>,
7806    stop: Arc<AtomicBool>,
7807    mut scheduler: crate::scheduler::Scheduler<TickEvent>,
7808    handle: crate::scheduler::SchedulerHandle<TickEvent>,
7809) {
7810    apply_thread_tuning(
7811        "tick",
7812        rt.config.tick_thread_priority,
7813        rt.config.tick_thread_cpus.as_deref(),
7814    );
7815    let mut st = TickState::new(&rt);
7816    // Prime both event streams immediately.
7817    handle.raise_now(TickEvent::Tick);
7818    handle.raise_now(TickEvent::Housekeep);
7819    loop {
7820        let (due, stopped) = scheduler.park_due_batch();
7821        if stopped || stop.load(Ordering::Relaxed) {
7822            break;
7823        }
7824        if due.is_empty() {
7825            continue; // woken with nothing due yet — re-evaluate.
7826        }
7827        // Coalesce: a batch of wakes maps to at most ONE run of each kind.
7828        let mut do_tick = false;
7829        let mut do_housekeep = false;
7830        for ev in due {
7831            match ev {
7832                TickEvent::Tick => do_tick = true,
7833                TickEvent::Housekeep => do_housekeep = true,
7834            }
7835        }
7836        if do_tick {
7837            rt.tick_wake_pending.store(false, Ordering::Release);
7838            run_tick_iteration(Arc::clone(&rt), &mut st);
7839            if stop.load(Ordering::Relaxed) {
7840                break;
7841            }
7842            handle.raise_at(next_tick_deadline(&rt, &st), TickEvent::Tick);
7843        }
7844        if do_housekeep {
7845            let next = tick_housekeep(&rt, rt.start_instant.elapsed());
7846            if stop.load(Ordering::Relaxed) {
7847                break;
7848            }
7849            // Park exactly until the next QoS due-instant; nothing pending →
7850            // idle floor (a later write re-arms via `raise_tick_wake`).
7851            let deadline = match next {
7852                Some(due_nanos) => rt.start_instant + Duration::from_nanos(due_nanos),
7853                None => Instant::now() + SCHEDULER_IDLE_FLOOR,
7854            };
7855            handle.raise_at(deadline, TickEvent::Housekeep);
7856        }
7857    }
7858}
7859
7860/// Per-iteration mutable state of the runtime tick. Held across iterations so
7861/// the same body ([`run_tick_iteration`]) can be driven from either the
7862/// dedicated `zdds-tick` thread (default) or an external executor — tokio via
7863/// [`DcpsRuntime::tick_driver`] / async `spawn_in_tokio`
7864/// (zerodds-async-1.0 §4).
7865struct TickState {
7866    /// Multicast target locator to which we send SPDP beacons.
7867    mc_target: Locator,
7868    /// Next instant at which a periodic SPDP announce is due.
7869    next_announce: Instant,
7870    /// Number of SPDP announces already sent. Drives the C3 initial
7871    /// announcement burst: as long as `< initial_announce_count` **and** no
7872    /// peer discovered yet, announces happen at `initial_announce_period` cadence
7873    /// instead of the full `spdp_period` — so discovery over lossy/power-save WiFi
7874    /// does not fail on lost first beacons.
7875    announces_done: u32,
7876    /// FastDDS interop: count for the periodic secure-SPDP HEARTBEATs
7877    /// (0xff0101c2). Must increase, otherwise FastDDS' reader ignores follow-up HBs.
7878    #[cfg(feature = "security")]
7879    secure_hb_count: i32,
7880}
7881
7882impl TickState {
7883    fn new(rt: &Arc<DcpsRuntime>) -> Self {
7884        let mc_target = Locator {
7885            kind: LocatorKind::UdpV4,
7886            port: u32::from(
7887                u16::try_from(spdp_multicast_port(rt.domain_id as u32)).unwrap_or(7400),
7888            ),
7889            address: {
7890                let mut a = [0u8; 16];
7891                a[12..].copy_from_slice(&rt.config.spdp_multicast_group.octets());
7892                a
7893            },
7894        };
7895        Self {
7896            mc_target,
7897            next_announce: Instant::now(), // immediately at start
7898            announces_done: 0,
7899            #[cfg(feature = "security")]
7900            secure_hb_count: 0,
7901        }
7902    }
7903}
7904
7905/// One iteration of the runtime's **wire** tick: periodic SPDP announce,
7906/// SEDP/WLP ticks, per-user-writer + per-user-reader ticks, secondary inbound
7907/// poll. QoS housekeeping (deadline/lifespan/liveliness) is **not** part of this
7908/// — each driver calls [`tick_housekeep`] separately (D.5e Phase 3 B-2), so the
7909/// event-driven scheduler can fire it on its own exact-deadline schedule.
7910/// Mutable per-iteration state lives in `st`; the caller waits `tick_period`
7911/// between calls. Factored out of [`tick_loop`] so an external executor can
7912/// drive the tick without the dedicated thread (zerodds-async-1.0 §4).
7913fn run_tick_iteration(rt: Arc<DcpsRuntime>, st: &mut TickState) {
7914    // Monotonic clock relative to runtime start. Used by the SEDP,
7915    // WLP and user tick alike.
7916    let elapsed_since_start = rt.start_instant.elapsed();
7917    let sedp_now = Duration::from_secs(elapsed_since_start.as_secs())
7918        + Duration::from_nanos(u64::from(elapsed_since_start.subsec_nanos()));
7919
7920    // --- Periodic SPDP announce ---
7921    // FU2 cross-vendor (cyclone-trace-documented): a secured participant MUST
7922    // NOT announce before its security builtins are enabled — otherwise
7923    // a token-less/non-secure first beacon goes out, which foreign vendors
7924    // (cyclone: "Non secure remote ... not allowed by security") latch as
7925    // non-secure and, on the later token beacon, treat ONLY as a QoS update
7926    // (no security re-evaluation) → the handshake never starts.
7927    // `config.security.is_some()` = secured runtime; until
7928    // `enable_security_builtins*` installs the stack (snapshot Some) +
7929    // sets the token/security-info on the beacon, we hold the beacon
7930    // back. enable() triggers the first token-carrying beacon via
7931    // `announce_spdp_now()`. Plain runtimes (security None) announce
7932    // immediately as before.
7933    #[cfg(feature = "security")]
7934    let security_pending = rt.config.security.is_some() && rt.security_builtin_snapshot().is_none();
7935    #[cfg(not(feature = "security"))]
7936    let security_pending = false;
7937    if Instant::now() >= st.next_announce && !security_pending {
7938        let secured_beacon: Option<Vec<u8>> = {
7939            if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7940                beacon
7941                    .serialize()
7942                    .ok()
7943                    .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7944            } else {
7945                None
7946            }
7947        };
7948        if let Some(secured) = secured_beacon {
7949            let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7950            // C1 multicast-free discovery: additionally to all configured
7951            // initial peers (ZERODDS_PEERS) — bootstrap without multicast.
7952            rt.send_spdp_to_initial_peers(&secured);
7953            // SPDP unicast fan-out to discovered peers (analogous to WLP-M-2/H-3-H-4):
7954            // codepit-LXC multicast is flaky; if it loses the tokened
7955            // secure beacon, the peer never discovers ZeroDDS as secure and
7956            // NEVER starts the auth handshake (cyclone→ZeroDDS responder hung
7957            // exactly here: HS_DISPATCH=0). From the metatraffic recv socket
7958            // (spdp_unicast), so the source port is correct.
7959            // Periodic directed unicast fan-out to discovered peers:
7960            // codepit-LXC multicast is flaky; if it loses the tokened
7961            // beacon, the peer never discovers ZeroDDS as secure and never starts
7962            // the auth handshake. The unicast refresh (every spdp_period) robustly
7963            // covers lost multicasts + late joiners. (Previously disabled for a
7964            // flaky-diag experiment — reactivated as a regular path,
7965            // complements the event-driven directed response in handle_spdp_datagram.)
7966            for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7967                let _ = rt.spdp_unicast.send(&loc, &secured);
7968            }
7969        }
7970        // FastDDS interop: announce in parallel on the reliable secure-SPDP writer
7971        // (0xff0101c2). FastDDS announces its full secured
7972        // participant data over this channel and gates the crypto-token
7973        // reciprocation on it; without our secure SPDP it never sees ZeroDDS there
7974        // and reciprocates no datawriter/datareader tokens.
7975        #[cfg(feature = "security")]
7976        if rt.config.enable_secure_spdp {
7977            let secure_beacon: Option<Vec<u8>> = {
7978                if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7979                    beacon
7980                        .serialize_secure()
7981                        .ok()
7982                        .and_then(|d| protect_secure_spdp(&rt, &d))
7983                        .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7984                } else {
7985                    None
7986                }
7987            };
7988            if let Some(secured) = secure_beacon {
7989                let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7990                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7991                    let _ = rt.spdp_unicast.send(&loc, &secured);
7992                }
7993            }
7994            // Secure-SPDP HEARTBEAT per peer (INFO_DST), so FastDDS' reader
7995            // — even as a late joiner — is solicited to a (preemptive) ACKNACK
7996            // and matches our 0xff0101c2 writer. Without a HEARTBEAT
7997            // FastDDS does not engage our writer (fastdds->zerodds: 0 ACKNACK).
7998            st.secure_hb_count = st.secure_hb_count.wrapping_add(1);
7999            for p in rt.discovered_participants() {
8000                let peer_prefix = p.data.guid.prefix;
8001                if let Some(hb) =
8002                    build_secure_spdp_heartbeat(rt.guid_prefix, peer_prefix, st.secure_hb_count)
8003                {
8004                    for loc in wlp_unicast_targets(core::slice::from_ref(&p)) {
8005                        let _ = rt.spdp_unicast.send(&loc, &hb);
8006                    }
8007                }
8008            }
8009        }
8010        // C3 WiFi robustness — initial announcement burst: as long as we have
8011        // not discovered a peer yet and the burst count is not exhausted,
8012        // announce at the fast `initial_announce_period` cadence. Over
8013        // lossy/power-save WiFi the first beacons often get lost in the cold-start
8014        // or sleep window; a single announce + 5s period
8015        // then leads to `participants=0`. The burst keeps the NIC awake through
8016        // frequent TX, keeps the stateful-firewall pinhole open and
8017        // elicits directed SPDP responses that arrive in the wake windows
8018        // — analogous to FastDDS `initial_announcements`. As soon as a peer
8019        // is discovered, the cadence falls back to the full `spdp_period`.
8020        st.announces_done = st.announces_done.saturating_add(1);
8021        rt.spdp_announce_seq.fetch_add(1, Ordering::Relaxed);
8022        let still_searching = st.announces_done < rt.config.initial_announce_count
8023            && rt.discovered_participants().is_empty();
8024        let period = if still_searching {
8025            rt.config.initial_announce_period
8026        } else {
8027            rt.config.spdp_period
8028        };
8029        st.next_announce = Instant::now() + period;
8030    }
8031
8032    // (SPDP multicast recv: now in `recv_spdp_multicast_loop`.)
8033
8034    // --- SEDP-Tick (outbound HEARTBEAT/Resend/ACKNACK) ---
8035    let sedp_outbound = {
8036        if let Ok(mut sedp) = rt.sedp.lock() {
8037            sedp.tick(sedp_now).unwrap_or_default()
8038        } else {
8039            Vec::new()
8040        }
8041    };
8042    for dg in sedp_outbound {
8043        // Protected discovery: SEC_*-protect secure-SEDP DATA/HEARTBEAT/GAP
8044        // (participant data key). Non-secure SEDP goes unchanged; on a
8045        // crypto error on secure SEDP it is dropped (no plaintext leak).
8046        #[cfg(feature = "security")]
8047        {
8048            if let Some(inner) = protect_sedp_outbound(&rt, &dg.bytes) {
8049                // discovery_protection has SEC-wrapped the secure SEDP per-submessage
8050                // (SEC_PREFIX/BODY/POSTFIX, per-endpoint key). Under
8051                // rtps_protection SRTPS MUST additionally go on top — BOTH layers,
8052                // like cyclone<->cyclone (reference pcap: 0x "clear submsg from
8053                // protected src"). send_discovery_datagram -> secure_outbound_bytes
8054                // would classify the SEC_PREFIX datagram as volatile-Kx (which is
8055                // RIGHTLY SRTPS-exempt, because its key only comes over the volatile
8056                // itself) and skip SRTPS -> cyclone would see the
8057                // secure SEDP clear, discard ACKNACK/HEARTBEAT as "clear submsg
8058                // from protected src" and never re-send the SubscriptionData ->
8059                // ZeroDDS' writer never matches cyclone's reader (wait_for_matched
8060                // timeout). Hence wrap SRTPS EXPLICITLY here instead of via the
8061                // generic exempt heuristic.
8062                let final_bytes: Option<Vec<u8>> = match &rt.config.security {
8063                    Some(gate)
8064                        if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
8065                            != ProtectionLevel::None =>
8066                    {
8067                        gate.transform_outbound(&inner).ok()
8068                    }
8069                    _ => Some(inner),
8070                };
8071                if let Some(fb) = final_bytes {
8072                    for t in dg.targets.iter() {
8073                        if is_routable_user_locator(t) {
8074                            let _ = rt.spdp_unicast.send(t, &fb);
8075                        }
8076                    }
8077                }
8078            }
8079        }
8080        #[cfg(not(feature = "security"))]
8081        send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8082    }
8083
8084    // --- Security-Builtin-Tick ---
8085    // Volatile-Secure-Writer heartbeats + Volatile-Secure-Reader
8086    // ACKNACK/NACK_FRAG. Stateless hat keinen Tick (BestEffort).
8087    if let Some(stack) = rt.security_builtin_snapshot() {
8088        let outbound = {
8089            if let Ok(mut s) = stack.lock() {
8090                // `out` is only mutated under feature="security" (reassign +
8091                // extend in the cfg block below); otherwise unused_mut in the no-security build.
8092                #[allow(unused_mut)]
8093                let mut out = s.poll(sedp_now).unwrap_or_default();
8094                #[cfg(feature = "security")]
8095                if rt.config.security.is_some() {
8096                    // STABLE peer list: `completed_peer_prefixes()` reads
8097                    // `self.handshakes`, which is GC'd after handshake completion
8098                    // → the LATE volatile RESENDS/HEARTBEATs (tick, long after
8099                    // completion) would then find NO peer anymore (`peers.len()!=1`)
8100                    // and go out CLEAR → cyclone discards them as "clear
8101                    // submsg from protected src". The stabler `authenticated_peer_
8102                    // prefixes()` (the installed Kx key stays) — identical to the
8103                    // token-send tick further below.
8104                    let peers: Vec<GuidPrefix> = rt
8105                        .config
8106                        .security
8107                        .as_ref()
8108                        .map(|g| {
8109                            g.authenticated_peer_prefixes()
8110                                .into_iter()
8111                                .map(GuidPrefix::from_bytes)
8112                                .collect()
8113                        })
8114                        .unwrap_or_default();
8115                    // The reliable volatile submessages from poll() (DATA RESENDS
8116                    // + HEARTBEAT + GAP) must — like the first send — be SEC_*-
8117                    // protected (§8.4.2.4, all writer submessages incl.
8118                    // HEARTBEAT). protect_volatile_datagram now protects all
8119                    // is_protected_writer_submessage. With exactly one peer
8120                    // (bench) with its Kx key.
8121                    if peers.len() == 1 {
8122                        let pk = peers[0].to_bytes();
8123                        out = out
8124                            .into_iter()
8125                            .filter_map(|dg| {
8126                                protect_volatile_datagram(&rt, &dg.bytes, &pk).map(|bytes| {
8127                                    zerodds_rtps::message_builder::OutboundDatagram {
8128                                        bytes,
8129                                        targets: dg.targets,
8130                                    }
8131                                })
8132                            })
8133                            .collect();
8134                    }
8135                    // FU2 step 6b: send per-endpoint datawriter/datareader crypto
8136                    // tokens to every authenticated peer as soon as the
8137                    // local user endpoints exist.
8138                    //
8139                    // STABLE peer list instead of `completed_peer_prefixes()`: the
8140                    // handshake entry is GC'd after completion, so a
8141                    // late-matching user writer/reader (user endpoints match
8142                    // AFTER the secure SEDP) would find no tick window in which
8143                    // its per-endpoint token would go out — the peer could then never
8144                    // decode ZeroDDS' user DATA (#29). `authenticated_peer_
8145                    // prefixes()` (the installed data key) stays.
8146                    let token_peers: Vec<GuidPrefix> = rt
8147                        .config
8148                        .security
8149                        .as_ref()
8150                        .map(|g| {
8151                            g.authenticated_peer_prefixes()
8152                                .into_iter()
8153                                .map(GuidPrefix::from_bytes)
8154                                .collect()
8155                        })
8156                        .unwrap_or_default();
8157                    for prefix in token_peers {
8158                        // Per-token dedup (#29): each per-endpoint token
8159                        // exactly once — builtins early, user endpoints
8160                        // as soon as they match. A per-peer guard would
8161                        // block late-matched user endpoints forever.
8162                        let already = rt
8163                            .endpoint_tokens_sent
8164                            .read()
8165                            .map(|set| set.clone())
8166                            .unwrap_or_default();
8167                        let pending = pending_endpoint_tokens(
8168                            prepare_endpoint_crypto_tokens(&rt, prefix),
8169                            &already,
8170                        );
8171                        for ep_msg in pending {
8172                            let key = endpoint_token_key(&ep_msg);
8173                            out.extend(protect_volatile_outbound(
8174                                &rt,
8175                                prefix,
8176                                s.volatile_writer
8177                                    .write_with_heartbeat(&ep_msg, sedp_now)
8178                                    .unwrap_or_default(),
8179                            ));
8180                            if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
8181                                set.insert(key);
8182                            }
8183                        }
8184                    }
8185                }
8186                out
8187            } else {
8188                Vec::new()
8189            }
8190        };
8191        for dg in outbound {
8192            send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8193        }
8194    }
8195
8196    // --- WLP-Tick (Writer-Liveliness-Protocol Heartbeats) ---
8197    //
8198    // RTPS 2.5 §8.4.13: WLP heartbeats are metatraffic.
8199    // Spec recommendation: multicast to all known peers, one
8200    // heartbeat per `lease_duration / 3`. We send via the
8201    // SPDP multicast sender — that is the same socket that
8202    // sends out the SPDP beacons, and it ensures that all
8203    // peers see the WLP pulses without the runtime having to
8204    // look up a unicast locator per peer.
8205    let wlp_outbound = {
8206        if let Ok(mut wlp) = rt.wlp.lock() {
8207            // Use the secure-WLP entity when liveliness_protection != NONE
8208            // (set idempotently per tick — follows the current governance).
8209            wlp.set_secure(wlp_liveliness_protected(&rt));
8210            wlp.tick(sedp_now).unwrap_or(None)
8211        } else {
8212            None
8213        }
8214    };
8215    if let Some(bytes) = wlp_outbound {
8216        // Under liveliness_protection != NONE the secure-WLP DATA is protected
8217        // with the participant key (§8.4.2.4); otherwise rtps-level/plaintext.
8218        if let Some(secured) = protect_wlp_outbound(&rt, &bytes) {
8219            // Multicast to all peers (spec recommendation §8.4.13)...
8220            let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
8221            // ...plus unicast to every discovered peer (M-2), so WLP also
8222            // arrives without multicast (container/cloud). From the metatraffic recv
8223            // socket (spdp_unicast), so the source port is correct (cf. H-3/H-4).
8224            for loc in wlp_unicast_targets(&rt.discovered_participants()) {
8225                let _ = rt.spdp_unicast.send(&loc, &secured);
8226            }
8227        }
8228    }
8229
8230    // (Metatraffic unicast recv: now in `recv_metatraffic_loop`.)
8231
8232    // --- User-Writer-Tick (HEARTBEAT + Resends) ---
8233    //
8234    // Security: per-target serializer. A datagram can go to
8235    // multiple reader locators. Per target we pull it
8236    // individually through `secure_outbound_for_target`, so the
8237    // wire payload matches the protection class of the respective reader.
8238    let user_writer_outbound: Vec<(EntityId, _)> = {
8239        let mut all = Vec::new();
8240        for (eid, arc) in rt.writer_slots_snapshot() {
8241            if let Ok(mut slot) = arc.lock() {
8242                if let Ok(dgs) = slot.writer.tick(sedp_now) {
8243                    for dg in dgs {
8244                        all.push((eid, dg));
8245                    }
8246                }
8247            }
8248        }
8249        all
8250    };
8251    for (writer_eid, dg) in user_writer_outbound {
8252        for t in dg.targets.iter() {
8253            if !is_routable_user_locator(t) {
8254                continue;
8255            }
8256            if let Some(secured) = secure_outbound_for_target(&rt, writer_eid, &dg.bytes, t) {
8257                send_on_best_interface(&rt, t, &secured);
8258            }
8259        }
8260    }
8261
8262    // --- User-Reader-Tick-Outbound (ACKNACK / NACK_FRAG) ---
8263    let user_reader_outbound: Vec<_> = {
8264        let mut all = Vec::new();
8265        for (_eid, arc) in rt.reader_slots_snapshot() {
8266            if let Ok(mut slot) = arc.lock() {
8267                if let Ok(dgs) = slot.reader.tick_outbound(sedp_now) {
8268                    all.extend(dgs);
8269                }
8270            }
8271        }
8272        all
8273    };
8274    for dg in user_reader_outbound {
8275        if let Some(secured) = protect_user_reader_datagram(&rt, &dg.bytes) {
8276            for t in dg.targets.iter() {
8277                if is_routable_user_locator(t) {
8278                    let _ = rt.user_unicast.send(t, &secured);
8279                }
8280            }
8281        }
8282    }
8283
8284    // (User-data unicast recv: now in `recv_user_data_loop`.)
8285
8286    // --- Per-interface inbound ---
8287    //
8288    // Each pool binding is polled non-blocking; the
8289    // received datagram goes through `secure_inbound_bytes` with
8290    // the matching NetInterface class. This lets the
8291    // PolicyEngine make interface-specific decisions
8292    // (e.g. accept loopback-plain on a protected domain).
8293    //
8294    // The non-blocking semantics are achieved by each socket
8295    // in `bind_all` holding a short read timeout — see
8296    // `OutboundSocketPool::bind_all`. Without a timeout the
8297    // event loop would hang on an empty binding per tick.
8298    #[cfg(feature = "security")]
8299    if let Some(pool) = &rt.outbound_pool {
8300        for binding in &pool.bindings {
8301            while let Ok(dg) = binding.socket.recv() {
8302                let iface = binding.spec.kind.clone();
8303                if let Some(clear) = secure_inbound_bytes(&rt, &dg.data, &iface) {
8304                    // Try SPDP first (reverse beacons), then
8305                    // SEDP, then user data — same dispatch as
8306                    // for the legacy sockets.
8307                    handle_spdp_datagram(&rt, &clear);
8308                    let events = rt
8309                        .sedp
8310                        .lock()
8311                        .ok()
8312                        .and_then(|mut s| s.handle_datagram(&clear, sedp_now).ok());
8313                    if let Some(ev) = events {
8314                        if !ev.is_empty() {
8315                            run_matching_pass(&rt);
8316                            apply_sedp_removals(&rt, &ev);
8317                            push_sedp_events_to_builtin_readers(&rt, &ev);
8318                        }
8319                    }
8320                    if !dispatch_type_lookup_datagram(&rt, &clear, &dg.source) {
8321                        handle_user_datagram(&rt, &clear, sedp_now);
8322                    }
8323                    // DDS-Security 1.2 §7.4.2 Builtin-Endpoints
8324                    for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
8325                        send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8326                    }
8327                }
8328            }
8329        }
8330    }
8331
8332    // Housekeeping (deadline/lifespan/liveliness) runs as a separate
8333    // `tick_housekeep` call of the respective driver (tick_loop /
8334    // tick_driver / scheduler_tick_loop) — see `tick_housekeep`.
8335
8336    // Diagnostic: mark this iteration complete so `tick_count()` advances
8337    // whether driven by the internal thread or an external executor.
8338    rt.tick_seq.fetch_add(1, Ordering::Relaxed);
8339}
8340
8341/// Min tracker for the earliest "next-due" instant (nanos in the runtime
8342/// `elapsed` time base) across multiple housekeeping sources.
8343struct NextDue(Option<u64>);
8344
8345impl NextDue {
8346    fn new() -> Self {
8347        Self(None)
8348    }
8349    fn note(&mut self, due_nanos: u64) {
8350        self.0 = Some(self.0.map_or(due_nanos, |e| e.min(due_nanos)));
8351    }
8352    fn into_inner(self) -> Option<u64> {
8353        self.0
8354    }
8355}
8356
8357/// D.5e Phase 3 B-2 — the time-driven housekeeping checks, factored out of
8358/// [`run_tick_iteration`], so the event-driven scheduler can fire them
8359/// as its own [`TickEvent::Housekeep`] heap event exactly at the next
8360/// due-instant (and `tick_loop`/`tick_driver` call them inline).
8361/// Pure reader/writer-side bookkeeping — **no** cross-vendor wire
8362/// output, the cadence is purely internal.
8363///
8364/// Return value: the earliest instant (nanos in the `elapsed` time base) at which
8365/// a check is due again, or `None` if nothing is currently pending
8366/// (no active deadline/lifespan/liveliness slot) — then the
8367/// scheduler parks until the idle floor resp. until a `raise_tick_wake` signals new
8368/// work.
8369fn tick_housekeep(rt: &Arc<DcpsRuntime>, elapsed: Duration) -> Option<u64> {
8370    let mut next_due = NextDue::new();
8371    // --- Deadline-Monitoring ---
8372    if let Some(d) = check_deadlines(rt, elapsed) {
8373        next_due.note(d);
8374    }
8375    // --- Lifespan-Expire ---
8376    if let Some(d) = expire_by_lifespan(rt, elapsed) {
8377        next_due.note(d);
8378    }
8379    // --- Liveliness lease check (reader side) ---
8380    if let Some(d) = check_liveliness(rt, elapsed) {
8381        next_due.note(d);
8382    }
8383    // --- Writer-side liveliness-lost check ---
8384    if let Some(d) = check_writer_liveliness(rt, elapsed) {
8385        next_due.note(d);
8386    }
8387    next_due.into_inner()
8388}
8389
8390impl DcpsRuntime {
8391    /// Number of completed tick iterations since `start()`. Advances once per
8392    /// tick regardless of whether the internal `zdds-tick` thread or an
8393    /// external executor ([`DcpsRuntime::tick_driver`]) drives it — a stalled
8394    /// value means the periodic tick stopped. Diagnostic only.
8395    #[must_use]
8396    pub fn tick_count(&self) -> u64 {
8397        self.tick_seq.load(Ordering::Relaxed)
8398    }
8399
8400    /// Number of SPDP announces emitted since `start()`. Diagnostic for the C3
8401    /// initial-announcement burst: a fresh participant with no discovered peer
8402    /// advances this at [`RuntimeConfig::initial_announce_period`] for the first
8403    /// [`RuntimeConfig::initial_announce_count`] announces, then slows to
8404    /// `spdp_period`.
8405    #[must_use]
8406    pub fn spdp_announce_count(&self) -> u64 {
8407        self.spdp_announce_seq.load(Ordering::Relaxed)
8408    }
8409
8410    /// Number of discovered topic inconsistencies (DDS 1.4 §2.2.4.2.4).
8411    /// Bumped during matching against the SEDP cache whenever a remote
8412    /// endpoint carries the same `topic_name` but a differing `type_name`
8413    /// than a local endpoint. A delta against the last poll snapshot
8414    /// triggers `on_inconsistent_topic`.
8415    #[must_use]
8416    pub fn inconsistent_topic_count(&self) -> u64 {
8417        self.inconsistent_topic_seq.load(Ordering::Relaxed)
8418    }
8419
8420    /// External tick driver (zerodds-async-1.0 §4). Only meaningful when the
8421    /// runtime was started with [`RuntimeConfig::external_tick`] = `true`,
8422    /// which suppresses the dedicated `zdds-tick` thread. Each
8423    /// [`DcpsTickDriver::tick`] call runs exactly one tick iteration; the
8424    /// caller schedules the next after [`DcpsTickDriver::tick_period`]. The
8425    /// async API's `spawn_in_tokio` uses this to multiplex many participants'
8426    /// tick loops onto a tokio runtime instead of one std::thread each.
8427    #[must_use]
8428    pub fn tick_driver(self: &Arc<Self>) -> DcpsTickDriver {
8429        DcpsTickDriver {
8430            st: TickState::new(self),
8431            rt: Arc::clone(self),
8432        }
8433    }
8434
8435    /// D.5e Phase 3 — wake the scheduler tick worker immediately (new work:
8436    /// a sample written, a HEARTBEAT/DATA/ACKNACK received). Coalesced: many
8437    /// raises between two worker passes collapse into a single wake, so a
8438    /// datagram storm does not flood the channel. No-op unless started with
8439    /// `scheduler_tick`.
8440    pub fn raise_tick_wake(&self) {
8441        // Only the first raiser since the last pass actually sends.
8442        if self.tick_wake_pending.swap(true, Ordering::AcqRel) {
8443            return;
8444        }
8445        if let Ok(guard) = self.tick_wake.lock() {
8446            if let Some(h) = guard.as_ref() {
8447                // Active traffic wakes the reliable tick AND re-evaluates
8448                // housekeeping, so a freshly-armed deadline/lifespan/liveliness
8449                // window is scheduled at once instead of waiting out the park.
8450                h.raise_now(TickEvent::Tick);
8451                h.raise_now(TickEvent::Housekeep);
8452            }
8453        }
8454    }
8455
8456    /// `true` if this participant has any user DataWriter or DataReader — i.e.
8457    /// the fine-grained periodic work (HEARTBEAT / ACKNACK / deadline / lifespan
8458    /// / liveliness) may be due and the scheduler keeps a fine cadence. A pure
8459    /// discovery-only participant parks long.
8460    fn has_user_endpoints(&self) -> bool {
8461        self.user_writers
8462            .read()
8463            .map(|m| !m.is_empty())
8464            .unwrap_or(true)
8465            || self
8466                .user_readers
8467                .read()
8468                .map(|m| !m.is_empty())
8469                .unwrap_or(true)
8470    }
8471}
8472
8473/// Drives a runtime's periodic tick from an external executor (tokio, an
8474/// embedded scheduler, a manual test loop). Obtained via
8475/// [`DcpsRuntime::tick_driver`]; only does useful work when the runtime was
8476/// started with [`RuntimeConfig::external_tick`] = `true`.
8477///
8478/// Typical loop (the async crate's `spawn_in_tokio` shape):
8479///
8480/// ```ignore
8481/// let mut driver = runtime.tick_driver();
8482/// let period = driver.tick_period();
8483/// while !driver.is_stopped() {
8484///     driver.tick();
8485///     tokio::time::sleep(period).await;
8486/// }
8487/// ```
8488pub struct DcpsTickDriver {
8489    rt: Arc<DcpsRuntime>,
8490    st: TickState,
8491}
8492
8493impl DcpsTickDriver {
8494    /// Period the caller should wait between consecutive [`Self::tick`] calls
8495    /// (mirrors the internal `zdds-tick` thread's `tick_period`).
8496    #[must_use]
8497    pub fn tick_period(&self) -> Duration {
8498        self.rt.config.tick_period
8499    }
8500
8501    /// `true` once the runtime is shutting down (set by `Drop`/`stop()`). The
8502    /// driving task must then stop calling [`Self::tick`] and return so the
8503    /// runtime can be dropped cleanly.
8504    #[must_use]
8505    pub fn is_stopped(&self) -> bool {
8506        self.rt.stop.load(Ordering::Relaxed)
8507    }
8508
8509    /// Run one tick iteration: periodic SPDP announce, SEDP/WLP ticks,
8510    /// per-user-writer ticks, deadline/lifespan/liveliness checks. Equivalent
8511    /// to one pass of the internal `zdds-tick` loop body.
8512    pub fn tick(&mut self) {
8513        run_tick_iteration(Arc::clone(&self.rt), &mut self.st);
8514        tick_housekeep(&self.rt, self.rt.start_instant.elapsed());
8515    }
8516}
8517
8518/// Writer-side liveliness-lost detection. Spec §2.2.4.2.10.
8519///
8520/// For all user writers: if a lease duration is set and more time
8521/// has elapsed since the last assert (Automatic = `last_write`, Manual =
8522/// `last_liveliness_assert`) than the
8523/// lease duration allows, the writer counts as
8524/// "not-alive" from the DDS view — `liveliness_lost_count++` and reset the window.
8525///
8526/// Note: with pure best-effort tests + `Automatic` the
8527/// counter typically does not advance — Automatic asserts with every
8528/// `write_user_sample`. Manual mode requires an explicit
8529/// `assert_liveliness` (comes with .4b — until then we already provide
8530/// the detection here, the hot-path trigger triggers it).
8531fn check_writer_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8532    let now_nanos = now.as_nanos() as u64;
8533    let mut next_due = NextDue::new();
8534    for (_eid, arc) in rt.writer_slots_snapshot() {
8535        let Ok(mut slot) = arc.lock() else { continue };
8536        if slot.liveliness_lease_nanos == 0 {
8537            continue;
8538        }
8539        let last = match slot.liveliness_kind {
8540            zerodds_qos::LivelinessKind::Automatic => slot.last_write,
8541            _ => slot.last_liveliness_assert,
8542        };
8543        let last_nanos = match last {
8544            Some(t) => t.as_nanos() as u64,
8545            None => continue,
8546        };
8547        if now_nanos.saturating_sub(last_nanos) >= slot.liveliness_lease_nanos {
8548            slot.liveliness_lost_count = slot.liveliness_lost_count.saturating_add(1);
8549            // Reset the window, so the same lease-window
8550            // overrun does not count in an infinite loop.
8551            // Spec §2.2.3.11: "lease has elapsed" — `>=` is boundary-
8552            // stable and avoids flakiness when tick_period == lease.
8553            slot.last_liveliness_assert = Some(now);
8554            slot.last_write = Some(now);
8555            next_due.note(now_nanos.saturating_add(slot.liveliness_lease_nanos));
8556        } else {
8557            next_due.note(last_nanos.saturating_add(slot.liveliness_lease_nanos));
8558        }
8559    }
8560    next_due.into_inner()
8561}
8562
8563/// Checks for all user readers whether the writer has delivered no sample
8564/// for longer than `lease_duration`. If so: transition
8565/// alive → not_alive, `not_alive_count++`.
8566///
8567/// Automatic liveliness (§2.2.3.11): every write is an implicit assert.
8568/// So we check the reader-side `last_sample_received`.
8569/// Manual kinds come with .4b (explicit assert messages).
8570fn check_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8571    let now_nanos = now.as_nanos() as u64;
8572    let mut next_due = NextDue::new();
8573    for (_eid, arc) in rt.reader_slots_snapshot() {
8574        let Ok(mut slot) = arc.lock() else { continue };
8575        if slot.liveliness_lease_nanos == 0 {
8576            continue;
8577        }
8578        // Until the first sample: consider it alive (optimistic).
8579        let last = match slot.last_sample_received {
8580            Some(t) => t.as_nanos() as u64,
8581            None => continue,
8582        };
8583        // Only a still-alive reader can transition; one already
8584        // not_alive stays so until a new sample arrives (event-driven
8585        // via the recv path) — so no re-schedule needed.
8586        if !slot.liveliness_alive {
8587            continue;
8588        }
8589        if now_nanos.saturating_sub(last) >= slot.liveliness_lease_nanos {
8590            slot.liveliness_alive = false;
8591            slot.liveliness_not_alive_count = slot.liveliness_not_alive_count.saturating_add(1);
8592        } else {
8593            next_due.note(last.saturating_add(slot.liveliness_lease_nanos));
8594        }
8595    }
8596    next_due.into_inner()
8597}
8598
8599/// For all user writers: remove samples from the HistoryCache whose
8600/// insert time + lifespan has elapsed. OMG DDS 1.4 §2.2.3.16:
8601/// "If the duration...elapses and the sample is still in the cache...
8602/// the sample is no longer available to any future DataReaders".
8603///
8604/// Implementation: `sample_insert_times` is a VecDeque, sorted
8605/// by insert time (= SN, because monotonic). Front-pop while expired;
8606/// the highest expired SN runs through via `cache.remove_up_to(sn + 1)`.
8607fn expire_by_lifespan(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8608    let now_nanos = now.as_nanos() as u64;
8609    let mut next_due = NextDue::new();
8610    for (_eid, arc) in rt.writer_slots_snapshot() {
8611        let Ok(mut slot) = arc.lock() else { continue };
8612        if slot.lifespan_nanos == 0 {
8613            continue;
8614        }
8615        let mut highest_expired = None;
8616        while let Some(&(sn, inserted)) = slot.sample_insert_times.front() {
8617            let inserted_nanos = inserted.as_nanos() as u64;
8618            if now_nanos.saturating_sub(inserted_nanos) >= slot.lifespan_nanos {
8619                highest_expired = Some(sn);
8620                slot.sample_insert_times.pop_front();
8621            } else {
8622                break;
8623            }
8624        }
8625        if let Some(sn) = highest_expired {
8626            let _removed = slot
8627                .writer
8628                .remove_samples_up_to(zerodds_rtps::wire_types::SequenceNumber(sn.0 + 1));
8629        }
8630        // Next lifespan due = expiry of the now-oldest sample still
8631        // remaining in the cache. Empty deque → nothing due,
8632        // until a new sample is written (raise_tick_wake covers that).
8633        if let Some(&(_sn, inserted)) = slot.sample_insert_times.front() {
8634            next_due.note((inserted.as_nanos() as u64).saturating_add(slot.lifespan_nanos));
8635        }
8636    }
8637    next_due.into_inner()
8638}
8639
8640/// Checks for all user writers + user readers whether the deadline period
8641/// has been exceeded since the last sample. Every exceedance
8642/// increments the corresponding missed counter by exactly 1
8643/// — regardless of how often `check_deadlines` is called within an
8644/// elapsed window, because we keep setting `last_*`
8645/// to "now" after we have counted.
8646///
8647/// **Init-state semantics:** as long as `last_write`/`last_sample_received`
8648/// is `None` (no real write/sample yet), the deadline
8649/// check does not count. Only after the first real data point does the
8650/// deadline window start. This prevents false misses due to slow
8651/// entity setup (Linux CI/container) before the app even issues a
8652/// write.
8653fn check_deadlines(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8654    let now_nanos = now.as_nanos() as u64;
8655    let mut next_due = NextDue::new();
8656    for (_eid, arc) in rt.writer_slots_snapshot() {
8657        let Ok(mut slot) = arc.lock() else { continue };
8658        if slot.deadline_nanos == 0 {
8659            continue;
8660        }
8661        let Some(last) = slot.last_write.map(|d| d.as_nanos() as u64) else {
8662            // Never written yet → deadline window not active.
8663            continue;
8664        };
8665        if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
8666            slot.offered_deadline_missed_count =
8667                slot.offered_deadline_missed_count.saturating_add(1);
8668            // Reset the window: the next deadline is counted relative
8669            // to the current tick. `>=` is boundary-stable
8670            // (Spec §2.2.3.7: "deadline has elapsed").
8671            slot.last_write = Some(now);
8672            next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
8673        } else {
8674            next_due.note(last.saturating_add(slot.deadline_nanos));
8675        }
8676    }
8677    for (_eid, arc) in rt.reader_slots_snapshot() {
8678        let Ok(mut slot) = arc.lock() else { continue };
8679        if slot.deadline_nanos == 0 {
8680            continue;
8681        }
8682        let Some(last) = slot.last_sample_received.map(|d| d.as_nanos() as u64) else {
8683            continue;
8684        };
8685        if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
8686            slot.requested_deadline_missed_count =
8687                slot.requested_deadline_missed_count.saturating_add(1);
8688            slot.last_sample_received = Some(now);
8689            next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
8690        } else {
8691            next_due.note(last.saturating_add(slot.deadline_nanos));
8692        }
8693    }
8694    next_due.into_inner()
8695}
8696
8697/// For all local writers + readers: matching against the current
8698/// SEDP cache. A cheap re-run when SEDP events came in — idempotent,
8699/// because ReliableWriter/Reader add_*_proxy are idempotent (same
8700/// GUID → replaced).
8701fn run_matching_pass(rt: &Arc<DcpsRuntime>) {
8702    let writer_ids: Vec<EntityId> = rt.writer_eids();
8703    for eid in writer_ids {
8704        rt.match_local_writer_against_cache(eid);
8705    }
8706    let reader_ids: Vec<EntityId> = rt.reader_eids();
8707    for eid in reader_ids {
8708        rt.match_local_reader_against_cache(eid);
8709    }
8710}
8711
8712/// Returns the default-unicast locator of a discovered remote
8713/// participant.
8714fn remote_user_locators(
8715    prefix: GuidPrefix,
8716    discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
8717) -> Vec<Locator> {
8718    match discovered.lock() {
8719        Ok(cache) => cache
8720            .get(&prefix)
8721            .and_then(|p| p.data.default_unicast_locator)
8722            .into_iter()
8723            .collect(),
8724        Err(_) => Vec::new(),
8725    }
8726}
8727
8728/// Determine the destination for user traffic to a remote endpoint.
8729///
8730/// DDSI-RTPS 2.5 §8.5.3.2/§8.5.3.3: the per-endpoint `unicastLocatorList`
8731/// from the SEDP announce is authoritative. §8.5.5: only when it is empty
8732/// does the sender fall back to the participant `DEFAULT_UNICAST_LOCATOR` from
8733/// SPDP.
8734///
8735/// Before this fix ZeroDDS *always* used the participant default — which
8736/// broke OpenDDS interop: OpenDDS stores only the
8737/// placeholder 127.0.0.1:12345 as the participant default and announces the real user locator
8738/// exclusively per-endpoint.
8739fn endpoint_or_default_locators(
8740    endpoint: &[Locator],
8741    prefix: GuidPrefix,
8742    discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
8743) -> Vec<Locator> {
8744    if !endpoint.is_empty() {
8745        return endpoint.to_vec();
8746    }
8747    remote_user_locators(prefix, discovered)
8748}
8749
8750/// Dispatches a received RTPS datagram to matching user readers.
8751/// Decides, based on the `reader_id` in DATA/DATA_FRAG/HEARTBEAT/GAP,
8752/// which local reader is responsible.
8753/// Strip the 4-byte encapsulation header off the received sample payload.
8754/// Returns `None` if the payload is < 4 bytes or carries an unknown
8755/// scheme (PL_CDR variants would not get here; they go via
8756/// SEDP — if we see such a thing on user endpoints, it is garbage).
8757/// Spec §3.2 zerodds-async-1.0: wakes a registered waker
8758/// after every `sample_tx.send`. `take` consumes the waker, to
8759/// avoid double wakeups — the caller registers a new one after
8760/// every `Pending` result.
8761fn wake_async_waker(slot: &alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>) {
8762    if let Ok(mut g) = slot.lock() {
8763        if let Some(w) = g.take() {
8764            w.wake();
8765        }
8766    }
8767}
8768
8769/// Converts a sample delivered by the ReliableReader into a
8770/// `UserSample` channel entry. For `ChangeKind::Alive` the
8771/// CDR encapsulation header is stripped; for lifecycle markers
8772/// the key hash is reconstructed from the bytes.
8773/// Inspect-endpoint tap dispatch for the DCPS receive path.
8774///
8775/// Called in `handle_user_datagram` when a sample is delivered to
8776/// a user reader. Only when the `inspect` feature is
8777/// on; without the feature no code, no branch.
8778#[cfg(feature = "inspect")]
8779fn dispatch_inspect_dcps_receive_tap(topic: &str, reader_id: EntityId, item: &UserSample) {
8780    let payload: Vec<u8> = match item {
8781        UserSample::Alive { payload, .. } => payload.to_vec(),
8782        UserSample::Lifecycle { key_hash, .. } => key_hash.to_vec(),
8783    };
8784    let ts_ns = std::time::SystemTime::now()
8785        .duration_since(std::time::UNIX_EPOCH)
8786        .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
8787        .unwrap_or(0);
8788    let mut corr: u64 = 0;
8789    for (i, byte) in reader_id.entity_key.iter().enumerate() {
8790        corr |= u64::from(*byte) << (i * 8);
8791    }
8792    corr |= u64::from(reader_id.entity_kind as u8) << 24;
8793    let frame = zerodds_inspect_endpoint::Frame::dcps(topic.to_owned(), ts_ns, corr, payload);
8794    zerodds_inspect_endpoint::tap::dispatch(&frame);
8795}
8796
8797fn delivered_to_user_sample(
8798    sample: &zerodds_rtps::reliable_reader::DeliveredSample,
8799    writer_strengths: &alloc::collections::BTreeMap<[u8; 16], i32>,
8800) -> Option<UserSample> {
8801    use zerodds_rtps::history_cache::ChangeKind;
8802    match sample.kind {
8803        ChangeKind::Alive | ChangeKind::AliveFiltered => {
8804            let writer_guid = sample.writer_guid.to_bytes();
8805            let writer_strength = writer_strengths.get(&writer_guid).copied().unwrap_or(0);
8806            // Encapsulation representation from byte[1] of the header
8807            // (RTPS 2.5 §10.5) — BEFORE stripping. 0x00–0x03 = XCDR1
8808            // (CDR/PL_CDR), 0x06–0x0b = XCDR2 (CDR2/D_CDR2/PL_CDR2).
8809            let representation = encap_representation(&sample.payload);
8810            let big_endian = encap_big_endian(&sample.payload);
8811            strip_user_encap_arc(&sample.payload).map(|payload| UserSample::Alive {
8812                payload,
8813                writer_guid,
8814                writer_strength,
8815                representation,
8816                big_endian,
8817                source_timestamp: sample.source_timestamp,
8818                // O2 P5: the wire path carries the writer's RTPS sequence number
8819                // straight off the DeliveredSample, so a durability service can
8820                // dedup this writer's history against its live stream.
8821                source_sequence_number: sample.sequence_number.0,
8822            })
8823        }
8824        ChangeKind::NotAliveDisposed
8825        | ChangeKind::NotAliveUnregistered
8826        | ChangeKind::NotAliveDisposedUnregistered => {
8827            // Lifecycle marker: Spec §9.6.4.8 + §9.6.3.9 requires
8828            // `PID_KEY_HASH` in the inline QoS — the reader reads it
8829            // and propagates it via `DeliveredSample.key_hash`.
8830            // Fallback: with non-spec-conformant writers the
8831            // hash falls back to the first 16 bytes of the key-only payload
8832            // (PLAIN_CDR2-BE key holder).
8833            let kh = sample.key_hash.unwrap_or_else(|| {
8834                let mut h = [0u8; 16];
8835                let n = sample.payload.len().min(16);
8836                h[..n].copy_from_slice(&sample.payload[..n]);
8837                h
8838            });
8839            Some(UserSample::Lifecycle {
8840                key_hash: kh,
8841                kind: sample.kind,
8842            })
8843        }
8844    }
8845}
8846
8847/// Returns the XCDR version from the 4-byte encapsulation header
8848/// (RTPS 2.5 §10.5): `0` = XCDR1 (CDR/PL_CDR, encap byte 0x00–0x05),
8849/// `1` = XCDR2 (CDR2/DELIMITED_CDR2/PL_CDR2, encap byte 0x06–0x0b).
8850/// Default `0` for a too-short payload — XCDR1 is the spec baseline.
8851fn encap_representation(payload: &[u8]) -> u8 {
8852    if payload.len() >= 2 && payload[1] >= 0x06 {
8853        1
8854    } else {
8855        0
8856    }
8857}
8858
8859/// Returns the byte order from the 4-byte encapsulation representation
8860/// identifier (RTPS 2.5 §10.5). The repr-id is a big-endian `uint16`; its low
8861/// bit selects the byte order — the `_BE` variants (CDR_BE 0x0000, PL_CDR_BE
8862/// 0x0002, CDR2_BE 0x0006, D_CDR2_BE 0x0008, PL_CDR2_BE 0x000a) are even, the
8863/// `_LE` variants odd. `true` ⇒ big-endian. A too-short / header-less payload
8864/// (e.g. the intra-runtime bare body) defaults to little-endian (`false`).
8865fn encap_big_endian(payload: &[u8]) -> bool {
8866    payload.len() >= 2 && (payload[1] & 0x01) == 0
8867}
8868
8869/// Checks whether `payload` has a known 4-byte encapsulation header.
8870/// Returns `Some(4)` if so (= offset behind the header), `None` if
8871/// no known scheme. Separated in use from [`strip_user_encap`]:
8872/// here only validation without allocation, for the listener zero-copy
8873/// path (lever E / Sprint D.5d).
8874fn validate_user_encap_offset(payload: &[u8]) -> Option<usize> {
8875    if payload.len() < 4 {
8876        return None;
8877    }
8878    // Accept all data-representation schemes (RTPS 2.5 §10.5,
8879    // table 10.3): byte0 = 0x00, byte1 in:
8880    //   0x00/0x01 CDR_BE/LE        (XCDR1 PLAIN_CDR)
8881    //   0x02/0x03 PL_CDR_BE/LE     (XCDR1 parameter list, key serial.)
8882    //   0x06/0x07 CDR2_BE/LE       (XCDR2 PLAIN_CDR2)
8883    //   0x08/0x09 D_CDR2_BE/LE     (XCDR2 DELIMITED_CDR2, @appendable)
8884    //   0x0a/0x0b PL_CDR2_BE/LE    (XCDR2 PL_CDR2, @mutable)
8885    // Cyclone often sends XCDR1, OpenDDS/FastDDS XCDR2. We pass
8886    // all through; the typed decoder picks the correct alignment rule
8887    // based on the `representation` (see `encap_representation`).
8888    if payload[0] != 0x00 {
8889        return None;
8890    }
8891    match payload[1] {
8892        0x00..=0x03 | 0x06..=0x0b => Some(4),
8893        _ => None,
8894    }
8895}
8896
8897/// Zero-copy variant: strips the encap header via range slicing
8898/// on the refcounted `Arc<[u8]>` backing store. No heap alloc.
8899/// Spec: `docs/specs/zerodds-zero-copy-1.0.md` §6 wave 2.
8900fn strip_user_encap_arc(
8901    payload: &alloc::sync::Arc<[u8]>,
8902) -> Option<crate::sample_bytes::SampleBytes> {
8903    validate_user_encap_offset(payload).map(|off| {
8904        crate::sample_bytes::SampleBytes::from_arc_slice(
8905            alloc::sync::Arc::clone(payload),
8906            off..payload.len(),
8907        )
8908    })
8909}
8910
8911#[cfg(test)]
8912fn strip_user_encap(payload: &[u8]) -> Option<alloc::vec::Vec<u8>> {
8913    validate_user_encap_offset(payload).map(|off| payload[off..].to_vec())
8914}
8915
8916/// Bench-only phase-timing accumulators. Active with env
8917/// `ZERODDS_PHASE_TIMING=1`. With `ZERODDS_PHASE_DUMP=1` the
8918/// atexit hook prints the totals on drop of the first runtime.
8919#[doc(hidden)]
8920pub static PHASE_HANDLE_USER_NS: core::sync::atomic::AtomicU64 =
8921    core::sync::atomic::AtomicU64::new(0);
8922#[doc(hidden)]
8923pub static PHASE_HANDLE_USER_CALLS: core::sync::atomic::AtomicU64 =
8924    core::sync::atomic::AtomicU64::new(0);
8925#[doc(hidden)]
8926pub static PHASE_WRITE_USER_NS: core::sync::atomic::AtomicU64 =
8927    core::sync::atomic::AtomicU64::new(0);
8928#[doc(hidden)]
8929pub static PHASE_WRITE_USER_CALLS: core::sync::atomic::AtomicU64 =
8930    core::sync::atomic::AtomicU64::new(0);
8931
8932/// Sub-phases in the `handle_user_datagram` receive hot path:
8933/// 0=decode_datagram, 1=slot-lookup+lock, 2=reader.handle_data,
8934/// 3=delivered_to_user_sample, 4=listener+sender-dispatch.
8935/// Active under `ZERODDS_PHASE_TIMING=1`. Each `Instant::now()` bracket
8936/// costs ~50 ns; at a ~3 µs handle that is ~1.6% per sub-phase.
8937#[doc(hidden)]
8938pub static PHASE_HANDLE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8939    core::sync::atomic::AtomicU64::new(0),
8940    core::sync::atomic::AtomicU64::new(0),
8941    core::sync::atomic::AtomicU64::new(0),
8942    core::sync::atomic::AtomicU64::new(0),
8943    core::sync::atomic::AtomicU64::new(0),
8944];
8945
8946/// Sub-phases in `write_user_sample_borrowed` (sender hot path):
8947/// 0=lookup, 1=lock, 2=write_with_heartbeat, 3=send-loop, 4=reserved.
8948/// The detail drilldown into socket.send_to vs. inproc-peer dispatch was
8949/// done once for the connected-UDP lever (showed send_to as
8950/// 97% of the dispatch path); not permanent in the code, because per-phase
8951/// `Instant::now()` itself costs ~50 ns — at a 6 µs send that
8952/// would be 1% overhead and skews the calibrated measurement.
8953#[doc(hidden)]
8954pub static PHASE_WRITE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8955    core::sync::atomic::AtomicU64::new(0),
8956    core::sync::atomic::AtomicU64::new(0),
8957    core::sync::atomic::AtomicU64::new(0),
8958    core::sync::atomic::AtomicU64::new(0),
8959    core::sync::atomic::AtomicU64::new(0),
8960];
8961
8962fn phase_timing_enabled() -> bool {
8963    static CACHE: core::sync::atomic::AtomicI8 = core::sync::atomic::AtomicI8::new(-1);
8964    let v = CACHE.load(core::sync::atomic::Ordering::Relaxed);
8965    if v >= 0 {
8966        return v == 1;
8967    }
8968    let on = std::env::var("ZERODDS_PHASE_TIMING")
8969        .map(|s| s == "1")
8970        .unwrap_or(false);
8971    CACHE.store(
8972        if on { 1 } else { 0 },
8973        core::sync::atomic::Ordering::Relaxed,
8974    );
8975    on
8976}
8977
8978struct PhaseTimer {
8979    start: std::time::Instant,
8980    ns_acc: &'static core::sync::atomic::AtomicU64,
8981    calls_acc: &'static core::sync::atomic::AtomicU64,
8982}
8983
8984impl Drop for PhaseTimer {
8985    fn drop(&mut self) {
8986        let ns = self.start.elapsed().as_nanos() as u64;
8987        self.ns_acc
8988            .fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8989        self.calls_acc
8990            .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
8991    }
8992}
8993
8994fn handle_user_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], now: Duration) {
8995    let _phase_guard = if phase_timing_enabled() {
8996        Some(PhaseTimer {
8997            start: std::time::Instant::now(),
8998            ns_acc: &PHASE_HANDLE_USER_NS,
8999            calls_acc: &PHASE_HANDLE_USER_CALLS,
9000        })
9001    } else {
9002        None
9003    };
9004    let pt_on = phase_timing_enabled();
9005    let pt_t0 = if pt_on {
9006        Some(std::time::Instant::now())
9007    } else {
9008        None
9009    };
9010    let parsed = match decode_datagram(bytes) {
9011        Ok(p) => p,
9012        Err(_) => return,
9013    };
9014    // DDSI-RTPS §8.3.4: the effective source of each writer submessage is the
9015    // sourceGuidPrefix from the RTPS header. The reader demux needs it to
9016    // distinguish writer proxies with the same EntityId but a different participant
9017    // (fan-in / multiple publishers on the same topic).
9018    let src_prefix = parsed.header.guid_prefix;
9019    if let (Some(t0), true) = (pt_t0, pt_on) {
9020        let ns = t0.elapsed().as_nanos() as u64;
9021        PHASE_HANDLE_SUB_NS[0].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9022    }
9023    // Per-submessage: take the matching slot mutex individually per
9024    // submessage — no global user_writers/user_readers lock anymore.
9025    // With per-submessage granularity, reader datagrams can be processed in parallel
9026    // to writer AckNacks.
9027    //
9028    // RTPS-F1 (DDSI-RTPS §8.3.4 ReceiverState.haveTimestamp): an INFO_TS
9029    // submessage sets the source timestamp applied to every following DATA in
9030    // the same message, until another INFO_TS (or an I-flag clears it). We
9031    // carry it forward and hand it to the reader so it lands in
9032    // `SampleInfo.source_timestamp`.
9033    let mut cur_source_ts: Option<zerodds_rtps::header_extension::HeTimestamp> = None;
9034    for sub in parsed.submessages {
9035        match sub {
9036            ParsedSubmessage::InfoTimestamp(its) => {
9037                cur_source_ts = if its.invalidate {
9038                    None
9039                } else {
9040                    Some(its.timestamp)
9041                };
9042            }
9043            ParsedSubmessage::Data(d) => {
9044                // Sprint D.5d lever B — collect-then-dispatch:
9045                // sample conversion + liveliness update inside slot.lock,
9046                // then listener fire + channel send + waker wake
9047                // OUTSIDE the lock.
9048                //
9049                // Cross-vendor fix 2026-05-19: when reader_id ==
9050                // ENTITYID_UNKNOWN (RTPS spec §8.3.7.2: "deliver to all
9051                // matched readers on this topic"), we iterate over
9052                // ALL reader slots and let `handle_data` filter by
9053                // writer_proxies. Cyclone DDS/FastDDS/RTI send
9054                // user DATA with reader_id=UNKNOWN; without this fan-out
9055                // ZeroDDS would drop every such DATA.
9056                let pt_t1 = if pt_on {
9057                    Some(std::time::Instant::now())
9058                } else {
9059                    None
9060                };
9061                let target_slots: Vec<ReaderSlotArc> = if d.reader_id == EntityId::UNKNOWN {
9062                    let snap = rt.reader_slots_snapshot();
9063                    let mut v = Vec::with_capacity(snap.len());
9064                    v.extend(snap.into_iter().map(|(_, arc)| arc));
9065                    v
9066                } else {
9067                    let mut v = Vec::with_capacity(1);
9068                    if let Some(arc) = rt.reader_slot(d.reader_id) {
9069                        v.push(arc);
9070                    }
9071                    v
9072                };
9073                if let (Some(t1), true) = (pt_t1, pt_on) {
9074                    let ns = t1.elapsed().as_nanos() as u64;
9075                    PHASE_HANDLE_SUB_NS[1].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9076                }
9077                for arc in target_slots {
9078                    // Lever E: alongside the UserSample we carry a
9079                    // zero-copy view on the original `Arc<[u8]>` with
9080                    // the encap offset — the listener can thereby read into
9081                    // the payload without allocation.
9082                    let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
9083                    let listener;
9084                    let waker;
9085                    let sender;
9086                    #[cfg(feature = "inspect")]
9087                    let topic_name;
9088                    let pt_t2 = if pt_on {
9089                        Some(std::time::Instant::now())
9090                    } else {
9091                        None
9092                    };
9093                    {
9094                        let Ok(mut slot) = arc.lock() else { continue };
9095                        let hd_samples: Vec<_> = slot
9096                            .reader
9097                            .handle_data(src_prefix, &d, cur_source_ts)
9098                            .into_iter()
9099                            .collect();
9100                        for sample in hd_samples {
9101                            // A2 TIME_BASED_FILTER (§2.2.3.12): drop alive samples
9102                            // that arrive within minimum_separation of the last
9103                            // delivered sample of the same instance. No-op when
9104                            // the filter is disabled (tbf_min_separation_nanos==0).
9105                            if matches!(
9106                                sample.kind,
9107                                zerodds_rtps::history_cache::ChangeKind::Alive
9108                                    | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9109                            ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9110                            {
9111                                continue;
9112                            }
9113                            // Listener zero-copy view only for alive samples
9114                            // with a valid encap header. Arc::clone is
9115                            // an atomic refcount inc, no data copy.
9116                            let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
9117                                zerodds_rtps::history_cache::ChangeKind::Alive
9118                                | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
9119                                    validate_user_encap_offset(&sample.payload)
9120                                        .map(|off| (Arc::clone(&sample.payload), off))
9121                                }
9122                                _ => None,
9123                            };
9124                            if let Some(item) =
9125                                delivered_to_user_sample(&sample, &slot.writer_strengths)
9126                            {
9127                                items.push((item, listener_view));
9128                            }
9129                        }
9130                        if !items.is_empty() {
9131                            slot.last_sample_received = Some(now);
9132                            slot.samples_delivered_count = slot
9133                                .samples_delivered_count
9134                                .saturating_add(items.len() as u64);
9135                            if !slot.liveliness_alive {
9136                                slot.liveliness_alive = true;
9137                                slot.liveliness_alive_count =
9138                                    slot.liveliness_alive_count.saturating_add(1);
9139                            }
9140                        }
9141                        listener = slot.listener.clone();
9142                        waker = Arc::clone(&slot.async_waker);
9143                        sender = slot.sample_tx.clone();
9144                        #[cfg(feature = "inspect")]
9145                        {
9146                            topic_name = slot.topic_name.clone();
9147                        }
9148                    }
9149                    if let (Some(t2), true) = (pt_t2, pt_on) {
9150                        let ns = t2.elapsed().as_nanos() as u64;
9151                        PHASE_HANDLE_SUB_NS[2].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9152                    }
9153                    let pt_t3 = if pt_on {
9154                        Some(std::time::Instant::now())
9155                    } else {
9156                        None
9157                    };
9158                    // --- Outside slot.lock: dispatch ---
9159                    //
9160                    // Listener and MPSC are exclusive: if a listener
9161                    // (callback) is set, the consumer is on the
9162                    // callback path — the additional `sender.send` +
9163                    // `wake_async_waker` would be pure overhead AND
9164                    // would grow the channel buffer unboundedly
9165                    // (memory leak in callback-only apps). We
9166                    // dispatch either the callback OR the MPSC, not
9167                    // both. A caller (Rust API) that wants take()+listener
9168                    // at the same time simply sets NO listener
9169                    // and polls via take().
9170                    for (item, listener_view) in items {
9171                        let (item_repr, item_be) = if let UserSample::Alive {
9172                            representation,
9173                            big_endian,
9174                            ..
9175                        } = &item
9176                        {
9177                            (*representation, u8::from(*big_endian))
9178                        } else {
9179                            (0, 0)
9180                        };
9181                        #[cfg(feature = "inspect")]
9182                        dispatch_inspect_dcps_receive_tap(&topic_name, d.reader_id, &item);
9183                        if let Some(ref l) = listener {
9184                            if let Some((arc_payload, off)) = listener_view {
9185                                // Zero-copy: slice view into the original Arc.
9186                                l(&arc_payload[off..], item_repr, item_be);
9187                            }
9188                        } else {
9189                            let _ = sender.send(item);
9190                            wake_async_waker(&waker);
9191                        }
9192                    }
9193                    if let (Some(t3), true) = (pt_t3, pt_on) {
9194                        let ns = t3.elapsed().as_nanos() as u64;
9195                        PHASE_HANDLE_SUB_NS[4].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9196                    }
9197                } // for arc in target_slots
9198            }
9199            ParsedSubmessage::DataFrag(df) => {
9200                // Lever B+E — see the Data arm above.
9201                // Cross-vendor: same UNKNOWN fan-out as for Data.
9202                let target_slots: Vec<ReaderSlotArc> = if df.reader_id == EntityId::UNKNOWN {
9203                    rt.reader_slots_snapshot()
9204                        .into_iter()
9205                        .map(|(_, arc)| arc)
9206                        .collect()
9207                } else {
9208                    rt.reader_slot(df.reader_id).into_iter().collect()
9209                };
9210                for arc in target_slots {
9211                    let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
9212                    let listener;
9213                    let waker;
9214                    let sender;
9215                    #[cfg(feature = "inspect")]
9216                    let topic_name;
9217                    {
9218                        let Ok(mut slot) = arc.lock() else { continue };
9219                        for sample in
9220                            slot.reader
9221                                .handle_data_frag(src_prefix, &df, now, cur_source_ts)
9222                        {
9223                            // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9224                            if matches!(
9225                                sample.kind,
9226                                zerodds_rtps::history_cache::ChangeKind::Alive
9227                                    | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9228                            ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9229                            {
9230                                continue;
9231                            }
9232                            let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
9233                                zerodds_rtps::history_cache::ChangeKind::Alive
9234                                | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
9235                                    validate_user_encap_offset(&sample.payload)
9236                                        .map(|off| (Arc::clone(&sample.payload), off))
9237                                }
9238                                _ => None,
9239                            };
9240                            if let Some(item) =
9241                                delivered_to_user_sample(&sample, &slot.writer_strengths)
9242                            {
9243                                items.push((item, listener_view));
9244                            }
9245                        }
9246                        if !items.is_empty() {
9247                            slot.last_sample_received = Some(now);
9248                            slot.samples_delivered_count = slot
9249                                .samples_delivered_count
9250                                .saturating_add(items.len() as u64);
9251                            if !slot.liveliness_alive {
9252                                slot.liveliness_alive = true;
9253                                slot.liveliness_alive_count =
9254                                    slot.liveliness_alive_count.saturating_add(1);
9255                            }
9256                        }
9257                        listener = slot.listener.clone();
9258                        waker = Arc::clone(&slot.async_waker);
9259                        sender = slot.sample_tx.clone();
9260                        #[cfg(feature = "inspect")]
9261                        {
9262                            topic_name = slot.topic_name.clone();
9263                        }
9264                    }
9265                    for (item, listener_view) in items {
9266                        let (item_repr, item_be) = if let UserSample::Alive {
9267                            representation,
9268                            big_endian,
9269                            ..
9270                        } = &item
9271                        {
9272                            (*representation, u8::from(*big_endian))
9273                        } else {
9274                            (0, 0)
9275                        };
9276                        #[cfg(feature = "inspect")]
9277                        dispatch_inspect_dcps_receive_tap(&topic_name, df.reader_id, &item);
9278                        // See the Data arm: listener and MPSC are exclusive.
9279                        if let Some(ref l) = listener {
9280                            if let Some((arc_payload, off)) = listener_view {
9281                                l(&arc_payload[off..], item_repr, item_be);
9282                            }
9283                        } else {
9284                            let _ = sender.send(item);
9285                            wake_async_waker(&waker);
9286                        }
9287                    }
9288                } // for arc in target_slots (DataFrag)
9289            }
9290            ParsedSubmessage::Heartbeat(h) => {
9291                // Lever B — collect-then-dispatch like the Data arm. An HB can
9292                // unlock samples that were waiting on a hole fill
9293                // (volatile skip, historic eviction).
9294                //
9295                // D.5e Phase-2: synchronous ACKNACK emit on HB receipt
9296                // instead of deferred-via-tick. With `heartbeat_response_delay=0`
9297                // (D.5e default) `tick_outbound(now)` flushes the
9298                // ACKNACK directly for all pending writer_proxies — the tick loop
9299                // no longer has to wait 5 ms.
9300                // Cross-vendor: a HEARTBEAT with reader_id=UNKNOWN is
9301                // "to all matched readers". Cyclone often packs this into
9302                // DATA+HB submessage bundles.
9303                let target_slots: Vec<ReaderSlotArc> = if h.reader_id == EntityId::UNKNOWN {
9304                    rt.reader_slots_snapshot()
9305                        .into_iter()
9306                        .map(|(_, arc)| arc)
9307                        .collect()
9308                } else {
9309                    rt.reader_slot(h.reader_id).into_iter().collect()
9310                };
9311                for arc in target_slots {
9312                    let mut items: Vec<UserSample> = Vec::new();
9313                    let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
9314                        Vec::new();
9315                    let waker;
9316                    let sender;
9317                    {
9318                        let Ok(mut slot) = arc.lock() else { continue };
9319                        for sample in slot.reader.handle_heartbeat(src_prefix, &h, now) {
9320                            // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9321                            if matches!(
9322                                sample.kind,
9323                                zerodds_rtps::history_cache::ChangeKind::Alive
9324                                    | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9325                            ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9326                            {
9327                                continue;
9328                            }
9329                            if let Some(item) =
9330                                delivered_to_user_sample(&sample, &slot.writer_strengths)
9331                            {
9332                                items.push(item);
9333                            }
9334                        }
9335                        if !items.is_empty() {
9336                            slot.last_sample_received = Some(now);
9337                            slot.samples_delivered_count = slot
9338                                .samples_delivered_count
9339                                .saturating_add(items.len() as u64);
9340                            if !slot.liveliness_alive {
9341                                slot.liveliness_alive = true;
9342                                slot.liveliness_alive_count =
9343                                    slot.liveliness_alive_count.saturating_add(1);
9344                            }
9345                        }
9346                        // D.5e Phase-2: synchronous ACKNACK directly in the recv thread.
9347                        if let Ok(dgs) = slot.reader.tick_outbound(now) {
9348                            sync_outbound = dgs;
9349                        }
9350                        waker = Arc::clone(&slot.async_waker);
9351                        sender = slot.sample_tx.clone();
9352                    }
9353                    for item in items {
9354                        let _ = sender.send(item);
9355                        wake_async_waker(&waker);
9356                    }
9357                    // Send ACKNACK datagrams synchronously — no tick-quantization tax.
9358                    for dg in sync_outbound {
9359                        if let Some(secured) = protect_user_reader_datagram(rt, &dg.bytes) {
9360                            for t in dg.targets.iter() {
9361                                if is_routable_user_locator(t) {
9362                                    let _ = rt.user_unicast.send(t, &secured);
9363                                }
9364                            }
9365                        }
9366                    }
9367                } // for arc in target_slots (Heartbeat)
9368            }
9369            ParsedSubmessage::Gap(g) => {
9370                // Cross-vendor: Gap with UNKNOWN reader → fan-out.
9371                let target_slots: Vec<ReaderSlotArc> = if g.reader_id == EntityId::UNKNOWN {
9372                    rt.reader_slots_snapshot()
9373                        .into_iter()
9374                        .map(|(_, arc)| arc)
9375                        .collect()
9376                } else {
9377                    rt.reader_slot(g.reader_id).into_iter().collect()
9378                };
9379                for arc in target_slots {
9380                    if let Ok(mut slot) = arc.lock() {
9381                        for sample in slot.reader.handle_gap(src_prefix, &g) {
9382                            // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9383                            if matches!(
9384                                sample.kind,
9385                                zerodds_rtps::history_cache::ChangeKind::Alive
9386                                    | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9387                            ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9388                            {
9389                                continue;
9390                            }
9391                            if let Some(item) =
9392                                delivered_to_user_sample(&sample, &slot.writer_strengths)
9393                            {
9394                                let _ = slot.sample_tx.send(item);
9395                                wake_async_waker(&slot.async_waker);
9396                            }
9397                        }
9398                    }
9399                }
9400            }
9401            ParsedSubmessage::AckNack(ack) => {
9402                if let Some(arc) = rt.writer_slot(ack.writer_id) {
9403                    let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
9404                        Vec::new();
9405                    if let Ok(mut slot) = arc.lock() {
9406                        let base = ack.reader_sn_state.bitmap_base;
9407                        let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
9408                        let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
9409                        slot.writer.handle_acknack(src, base, requested);
9410                        // D.5e Phase-2: synchronous resend on NACK receipt.
9411                        // An ACKNACK may have listed requested SNs for resend;
9412                        // tick delivers the resend datagrams directly in the recv thread.
9413                        if let Ok(dgs) = slot.writer.tick(now) {
9414                            sync_outbound = dgs;
9415                        }
9416                    }
9417                    // ACK-Event-Cvar: wake `wait_for_acknowledgments`-waiters.
9418                    rt.notify_ack_event();
9419                    // Send sync resends (no more tick wait). FU2 S3:
9420                    // per-target data_protection (a reliable resend of user DATA
9421                    // must be encrypted just like the immediate send).
9422                    for dg in sync_outbound {
9423                        for t in dg.targets.iter() {
9424                            if is_routable_user_locator(t) {
9425                                if let Some(secured) =
9426                                    secure_outbound_for_target(rt, ack.writer_id, &dg.bytes, t)
9427                                {
9428                                    let _ = rt.user_unicast.send(t, &secured);
9429                                }
9430                            }
9431                        }
9432                    }
9433                }
9434            }
9435            ParsedSubmessage::NackFrag(nf) => {
9436                if let Some(arc) = rt.writer_slot(nf.writer_id) {
9437                    if let Ok(mut slot) = arc.lock() {
9438                        let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
9439                        slot.writer.handle_nackfrag(src, &nf);
9440                    }
9441                }
9442            }
9443            _ => {}
9444        }
9445    }
9446}
9447
9448/// Test hook: allows a direct call of `handle_spdp_datagram` from
9449/// other modules without spinning up the whole event loop.
9450/// For internal tests only.
9451#[cfg(test)]
9452pub(crate) fn handle_spdp_datagram_for_test(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
9453    handle_spdp_datagram(rt, bytes);
9454}
9455
9456fn handle_spdp_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
9457    let parsed = match rt.spdp_reader.parse_datagram(bytes) {
9458        Ok(p) => p,
9459        Err(_) => return, // not SPDP or wire error — swallow
9460    };
9461    // Self-discovery filter: ignore our own beacons.
9462    if parsed.sender_prefix == rt.guid_prefix {
9463        return;
9464    }
9465    let is_new = {
9466        if let Ok(mut cache) = rt.discovered.lock() {
9467            cache.insert(parsed.clone())
9468        } else {
9469            false
9470        }
9471    };
9472    // On first discovery: wire the SEDP stack + send out initial
9473    // announcements.
9474    if is_new {
9475        // A1 discovery-server relay: bridge the newly-joined client to every
9476        // already-known client over a single well-known address. Forwards ONLY
9477        // raw SPDP (participant locators) — SEDP (endpoint discovery, incl. ROS-2
9478        // Action endpoints) then proceeds DIRECTLY peer-to-peer, which is exactly
9479        // why Actions keep working (unlike a SEDP-proxying discovery server).
9480        // Plain discovery only; secured relay is a follow-up.
9481        #[cfg(feature = "security")]
9482        let relay_plain = rt.config.security.is_none();
9483        #[cfg(not(feature = "security"))]
9484        let relay_plain = true;
9485        if rt.config.discovery_server && relay_plain {
9486            let new_client = wlp_unicast_targets(core::slice::from_ref(&parsed));
9487            let others: Vec<_> = rt
9488                .discovered_participants()
9489                .into_iter()
9490                .filter(|dp| dp.sender_prefix != parsed.sender_prefix)
9491                .collect();
9492            if let Ok(mut relay) = rt.spdp_relay_cache.lock() {
9493                // 1) tell the new client about every already-known client.
9494                for dp in &others {
9495                    if let Some(raw) = relay.get(&dp.sender_prefix) {
9496                        for loc in &new_client {
9497                            let _ = rt.spdp_unicast.send(loc, raw);
9498                        }
9499                    }
9500                }
9501                // 2) tell every already-known client about the new client.
9502                for dp in &others {
9503                    for loc in wlp_unicast_targets(core::slice::from_ref(dp)) {
9504                        let _ = rt.spdp_unicast.send(&loc, bytes);
9505                    }
9506                }
9507                // 3) remember the new client's SPDP for future joiners.
9508                relay.insert(parsed.sender_prefix, bytes.to_vec());
9509            }
9510        }
9511        if let Ok(mut sedp) = rt.sedp.lock() {
9512            sedp.on_participant_discovered(&parsed);
9513        }
9514        // Event-driven directed SPDP response (§8.5.3): send OUR own
9515        // SPDP IMMEDIATELY unicast to the newly discovered peer, instead of letting it
9516        // wait for our next periodic multicast beacon (spdp_period=5s, codepit-LXC
9517        // multicast flaky). A spec-conformant peer (OpenDDS)
9518        // processes our auth request ONLY once it has our identity_token from
9519        // our SPDP — without this directed response it waits up to
9520        // spdp_period (seconds latency → cross-vendor ping wait_for_matched
9521        // timeout). NO timeout band-aid: the seconds latency was the missing
9522        // discovery event. Token-less first beacons (security not yet enabled)
9523        // are NOT sent (see security_pending in the announce loop) — the
9524        // periodic/announce_spdp_now path catches up.
9525        #[cfg(feature = "security")]
9526        let beacon_ready =
9527            !(rt.config.security.is_some() && rt.security_builtin_snapshot().is_none());
9528        #[cfg(not(feature = "security"))]
9529        let beacon_ready = true;
9530        if beacon_ready {
9531            let targets = wlp_unicast_targets(core::slice::from_ref(&parsed));
9532            if !targets.is_empty() {
9533                if let Some(secured) = rt
9534                    .spdp_beacon
9535                    .lock()
9536                    .ok()
9537                    .and_then(|mut b| b.serialize().ok())
9538                    .and_then(|d| secure_outbound_bytes(rt, &d).map(|c| c.to_vec()))
9539                {
9540                    for loc in &targets {
9541                        let _ = rt.spdp_unicast.send(loc, &secured);
9542                    }
9543                }
9544            }
9545        }
9546    }
9547    // FU2: wire the security builtin stack + kick off the auth handshake.
9548    // On EVERY beacon (not only is_new): `handle_remote_endpoints` and
9549    // `begin_handshake_with` are idempotent. This also covers the case
9550    // that the peer was discovered before the auth plugin was active via
9551    // `enable_security_builtins_with_auth` — the next
9552    // beacon refresh then kicks off the handshake. No-op without a plugin,
9553    // without security bits or without an announced identity_token.
9554    if let Some(sec) = rt.security_builtin_snapshot() {
9555        let handshake_dgs = if let Ok(mut s) = sec.lock() {
9556            s.note_remote_vendor(parsed.sender_prefix, parsed.sender_vendor);
9557            s.handle_remote_endpoints(&parsed);
9558            match parsed.data.identity_token.as_ref() {
9559                Some(token) => s
9560                    .begin_handshake_with(parsed.sender_prefix, parsed.data.guid.to_bytes(), token)
9561                    .unwrap_or_default(),
9562                None => Vec::new(),
9563            }
9564        } else {
9565            Vec::new()
9566        };
9567        for dg in handshake_dgs {
9568            send_discovery_datagram(rt, &dg.targets, &dg.bytes);
9569        }
9570    }
9571    //  Mirror the SPDP receive into the builtin DCPSParticipant reader.
9572    // We send on every beacon (also refresh) — Spec §2.2.5.1
9573    // allows it, take() returns the respective current
9574    // data to the user. A reader with KEEP_LAST(1) receives only the newest.
9575    if let Some(sinks) = rt.builtin_sinks_snapshot() {
9576        let dcps_sample =
9577            crate::builtin_topics::ParticipantBuiltinTopicData::from_wire(&parsed.data);
9578        // .7 §2.2.2.2.1.14: drop ignored participants before
9579        // they fall into the builtin reader.
9580        if let Some(filter) = rt.ignore_filter_snapshot() {
9581            let h = crate::instance_handle::InstanceHandle::from_guid(dcps_sample.key);
9582            if filter.is_participant_ignored(h) {
9583                return;
9584            }
9585        }
9586        let _ = sinks.push_participant(&dcps_sample);
9587    }
9588}
9589
9590/// Pushes SEDP events (new pubs/subs) into the 4 builtin-topic
9591/// readers. A new pub/sub produces **two** samples:
9592///
9593/// 1. a `DCPSPublication`/`DCPSSubscription` sample,
9594/// 2. a `DCPSTopic` sample (synthetic from topic name + type name).
9595///
9596/// The native SEDP-topics endpoints (RTPS 2.5 §9.3.2.12 bits 28/29)
9597/// are optional per Spec §8.5.4.4 and covered in ZeroDDS via this
9598/// synthetic derivation — see also
9599/// `endpoint_flag::ALL_STANDARD`, which deliberately omits the
9600/// topics bits. Cyclone/Fast-DDS peers that send their own topic
9601/// announces are ignored (no reader endpoint).
9602fn push_sedp_events_to_builtin_readers(
9603    rt: &Arc<DcpsRuntime>,
9604    events: &zerodds_discovery::sedp::SedpEvents,
9605) {
9606    let Some(sinks) = rt.builtin_sinks_snapshot() else {
9607        return;
9608    };
9609    let filter = rt.ignore_filter_snapshot();
9610    for w in &events.new_publications {
9611        let pub_sample = crate::builtin_topics::PublicationBuiltinTopicData::from_wire(w);
9612        let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_publication(w);
9613        // .7 §2.2.2.2.1.14/.16: consult the participant + publication +
9614        // topic ignore filters.
9615        if let Some(f) = &filter {
9616            let part_h = crate::instance_handle::InstanceHandle::from_guid(w.participant_key);
9617            let pub_h = crate::instance_handle::InstanceHandle::from_guid(w.key);
9618            let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
9619            if f.is_participant_ignored(part_h) || f.is_publication_ignored(pub_h) {
9620                continue;
9621            }
9622            let _ = sinks.push_publication(&pub_sample);
9623            if !f.is_topic_ignored(topic_h) {
9624                let _ = sinks.push_topic(&topic_sample);
9625            }
9626        } else {
9627            let _ = sinks.push_publication(&pub_sample);
9628            let _ = sinks.push_topic(&topic_sample);
9629        }
9630    }
9631    for r in &events.new_subscriptions {
9632        let sub_sample = crate::builtin_topics::SubscriptionBuiltinTopicData::from_wire(r);
9633        let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_subscription(r);
9634        if let Some(f) = &filter {
9635            let part_h = crate::instance_handle::InstanceHandle::from_guid(r.participant_key);
9636            let sub_h = crate::instance_handle::InstanceHandle::from_guid(r.key);
9637            let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
9638            if f.is_participant_ignored(part_h) || f.is_subscription_ignored(sub_h) {
9639                continue;
9640            }
9641            let _ = sinks.push_subscription(&sub_sample);
9642            if !f.is_topic_ignored(topic_h) {
9643                let _ = sinks.push_topic(&topic_sample);
9644            }
9645        } else {
9646            let _ = sinks.push_subscription(&sub_sample);
9647            let _ = sinks.push_topic(&topic_sample);
9648        }
9649    }
9650    // Endpoint deletion (SEDP dispose): mark the matching built-in instance
9651    // disposed so a DCPSPublication/DCPSSubscription observer sees the remote
9652    // endpoint vanish (DDSI-RTPS §8.5.4). The unmatch of local user endpoints
9653    // is driven separately via `apply_sedp_removals`.
9654    for g in &events.removed_publications {
9655        sinks.dispose_publication(*g);
9656    }
9657    for g in &events.removed_subscriptions {
9658        sinks.dispose_subscription(*g);
9659    }
9660}
9661
9662/// Drives the unmatch of local user endpoints when SEDP reported a remote
9663/// endpoint deletion: each removed remote publication is removed from every
9664/// local reader's proxy set, each removed remote subscription from every local
9665/// writer's. See [`DcpsRuntime::remove_remote_writer`] /
9666/// [`DcpsRuntime::remove_remote_reader`].
9667fn apply_sedp_removals(rt: &Arc<DcpsRuntime>, events: &zerodds_discovery::sedp::SedpEvents) {
9668    for g in &events.removed_publications {
9669        rt.remove_remote_writer(*g);
9670    }
9671    for g in &events.removed_subscriptions {
9672        rt.remove_remote_reader(*g);
9673    }
9674}
9675
9676/// Binary-property name of the crypto key material in the CryptoToken DataHolder
9677/// (DDS-Security §9.5.2.1.1, cyclone-verified: `dds.cryp.keymat`).
9678#[cfg(feature = "security")]
9679const CRYPTO_TOKEN_PROP: &str = "dds.cryp.keymat";
9680
9681/// CryptoToken `class_id` (§9.5.2.1: `DDS:Crypto:AES_GCM_GMAC` — underscores,
9682/// **not** the plugin-class string with hyphens).
9683#[cfg(feature = "security")]
9684const CRYPTO_TOKEN_CLASS_ID: &str = "DDS:Crypto:AES_GCM_GMAC";
9685
9686/// Builds the `PARTICIPANT_CRYPTO_TOKENS` VolatileSecure message with the
9687/// Kx-encrypted token as a binary property (FU2 S1.4).
9688#[cfg(feature = "security")]
9689fn build_crypto_token_message(
9690    rt: &DcpsRuntime,
9691    remote_prefix: GuidPrefix,
9692    kx_token: Vec<u8>,
9693) -> zerodds_security::generic_message::ParticipantGenericMessage {
9694    use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
9695    use zerodds_security::token::DataHolder;
9696    ParticipantGenericMessage {
9697        message_identity: MessageIdentity {
9698            source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
9699            sequence_number: 1,
9700        },
9701        related_message_identity: MessageIdentity::default(),
9702        destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
9703        destination_endpoint_key: [0; 16],
9704        source_endpoint_key: [0; 16],
9705        message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
9706        message_data: alloc::vec![
9707            DataHolder::new(CRYPTO_TOKEN_CLASS_ID)
9708                .with_binary_property(CRYPTO_TOKEN_PROP, kx_token)
9709        ],
9710    }
9711}
9712
9713/// FU2 S1.4 (send): after handshake completion Kx-encrypt the local data token
9714/// (`gate.local_token`) and send it as
9715/// `PARTICIPANT_CRYPTO_TOKENS` over VolatileSecure.
9716/// Registers the peer's Kx key in the gate beforehand. `None` without a gate
9717/// or on error (drop instead of leak).
9718#[cfg(feature = "security")]
9719fn prepare_crypto_token(
9720    rt: &DcpsRuntime,
9721    remote_prefix: GuidPrefix,
9722    remote_identity: zerodds_security::authentication::IdentityHandle,
9723    secret: zerodds_security::authentication::SharedSecretHandle,
9724) -> Option<zerodds_security::generic_message::ParticipantGenericMessage> {
9725    let gate = rt.config.security.as_ref()?;
9726    let peer_key = remote_prefix.to_bytes();
9727    // ALWAYS register the peer's Kx key — even with rtps=NONE: the per-endpoint
9728    // tokens (discovery_/data_protection) travel Kx-protected over the volatile,
9729    // protect_volatile_datagram needs this key.
9730    gate.register_remote_by_guid_from_secret(peer_key, remote_identity, secret)
9731        .ok()?;
9732    // BUT: send the ParticipantCryptoToken (= SRTPS keymat) ONLY when
9733    // rtps_protection != NONE. With rtps=NONE there is no SRTPS; OpenDDS rejects the
9734    // token (Spdp.cpp:1966 `crypto_handle_==NIL` -> "not configured for RTPS
9735    // Protection", logs `handle_participant_crypto_tokens failed`) and OpenDDS-self
9736    // also does NOT exchange it with rtps=NONE. None here = no participant
9737    // token send; the per-endpoint tokens continue over the separate path.
9738    if gate.rtps_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
9739        return None;
9740    }
9741    // Cross-vendor: the data token travels in PLAINTEXT in the
9742    // ParticipantGenericMessage — it becomes confidential only through the
9743    // SEC_PREFIX/BODY/POSTFIX submessage protection of the whole volatile
9744    // DATA (see protect_volatile_datagram). The `register_*` line above
9745    // created the peer's Kx key in the gate that this protection uses.
9746    let token = gate.local_token().ok()?;
9747    Some(build_crypto_token_message(rt, remote_prefix, token))
9748}
9749
9750/// Per-endpoint crypto handle for a local writer/reader (get-or-register).
9751/// DDS-Security §9.5.3.3: each endpoint has its OWN key material. Registration
9752/// under the write lock (race-free). `None` without an active gate.
9753#[cfg(feature = "security")]
9754fn local_endpoint_crypto_handle(
9755    rt: &DcpsRuntime,
9756    eid: EntityId,
9757    is_writer: bool,
9758) -> Option<zerodds_security::crypto::CryptoHandle> {
9759    let gate = rt.config.security.as_ref()?;
9760    {
9761        let map = rt.endpoint_crypto.read().ok()?;
9762        if let Some(h) = map.get(&eid) {
9763            return Some(*h);
9764        }
9765    }
9766    let mut map = rt.endpoint_crypto.write().ok()?;
9767    if let Some(h) = map.get(&eid) {
9768        return Some(*h);
9769    }
9770    let h = gate.register_local_endpoint(is_writer).ok()?;
9771    map.insert(eid, h);
9772    Some(h)
9773}
9774
9775/// Cross-vendor step 6b (send): per-endpoint `datawriter_crypto_tokens` (for
9776/// every local user writer) + `datareader_crypto_tokens` (for every local
9777/// user reader) to the peer. cyclone needs these to approve the user-endpoint
9778/// match and decode ZeroDDS' user DATA. `source_endpoint_key` = the
9779/// local endpoint GUID; the keymat is the local data key (one key per
9780/// participant in the bench). Empty list without a gate / without user endpoints.
9781#[cfg(feature = "security")]
9782fn prepare_endpoint_crypto_tokens(
9783    rt: &DcpsRuntime,
9784    remote_prefix: GuidPrefix,
9785) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
9786    use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
9787    use zerodds_security::token::DataHolder;
9788    let Some(gate) = rt.config.security.as_ref() else {
9789        return Vec::new();
9790    };
9791    let mut out = Vec::new();
9792    // cyclone associates a datawriter/datareader token via the pair
9793    // (source_endpoint, destination_endpoint). Hence per local endpoint ONE
9794    // token PER matched remote endpoint of **this** peer, with the concrete
9795    // remote GUID as destination_endpoint_key (dst=0 would make cyclone discard it).
9796    //
9797    // §9.5.3.3: the token carries the **per-endpoint** key material of the
9798    // `source_eid` (not the participant key) — the same key with which
9799    // ZeroDDS encodes this endpoint's submessages (protect_user_datagram).
9800    let build = |class: &str,
9801                 source_eid: EntityId,
9802                 dst: [u8; 16]|
9803     -> Option<ParticipantGenericMessage> {
9804        let is_writer = class == class_id::DATAWRITER_CRYPTO_TOKENS;
9805        let handle = local_endpoint_crypto_handle(rt, source_eid, is_writer)?;
9806        let token = gate.create_endpoint_token(handle).ok()?;
9807        // Dual key (metadata != data, meta-sign-data): cyclone expects
9808        // num_key_mat=2 — submessage keymat (metadata kind) + payload keymat
9809        // (data kind) as TWO DataHolders in this order. Single key
9810        // (all other profiles): only the submessage/endpoint keymat.
9811        let mut dhs = alloc::vec![
9812            DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, token)
9813        ];
9814        if let Some(pay) = gate.endpoint_payload_token(handle) {
9815            dhs.push(
9816                DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, pay),
9817            );
9818        }
9819        Some(ParticipantGenericMessage {
9820            message_identity: MessageIdentity {
9821                source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
9822                sequence_number: 1,
9823            },
9824            related_message_identity: MessageIdentity::default(),
9825            destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
9826            destination_endpoint_key: dst,
9827            source_endpoint_key: Guid::new(rt.guid_prefix, source_eid).to_bytes(),
9828            message_class_id: class.into(),
9829            message_data: dhs,
9830        })
9831    };
9832    // datawriter tokens: per local writer for every matched remote reader
9833    // of this peer (dst = reader GUID).
9834    for (weid, warc) in rt.writer_slots_snapshot() {
9835        if let Ok(slot) = warc.lock() {
9836            for proxy in slot.writer.reader_proxies() {
9837                if proxy.remote_reader_guid.prefix == remote_prefix {
9838                    out.extend(build(
9839                        class_id::DATAWRITER_CRYPTO_TOKENS,
9840                        weid,
9841                        proxy.remote_reader_guid.to_bytes(),
9842                    ));
9843                }
9844            }
9845        }
9846    }
9847    // datareader tokens: per local reader for every matched remote writer
9848    // of this peer (dst = writer GUID).
9849    for (reid, rarc) in rt.reader_slots_snapshot() {
9850        if let Ok(slot) = rarc.lock() {
9851            for ws in slot.reader.writer_proxies() {
9852                if ws.proxy.remote_writer_guid.prefix == remote_prefix {
9853                    out.extend(build(
9854                        class_id::DATAREADER_CRYPTO_TOKENS,
9855                        reid,
9856                        ws.proxy.remote_writer_guid.to_bytes(),
9857                    ));
9858                }
9859            }
9860        }
9861    }
9862    // Protected discovery (§8.4.2.4): the secure builtin SEDP endpoints
9863    // (DCPSPublications/SubscriptionsSecure) also need crypto tokens,
9864    // so the peer associates ZeroDDS' data key with them + decodes the secure-SEDP
9865    // submessages. cyclone exchanges these builtin-endpoint tokens
9866    // the same way over the volatile (ff0003c2/c7 + ff0004c2/c7).
9867    if gate
9868        .discovery_protection()
9869        .map(|l| l != ProtectionLevel::None)
9870        .unwrap_or(false)
9871    {
9872        let builtin_pairs = [
9873            (
9874                class_id::DATAWRITER_CRYPTO_TOKENS,
9875                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
9876                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
9877            ),
9878            (
9879                class_id::DATAREADER_CRYPTO_TOKENS,
9880                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
9881                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
9882            ),
9883            (
9884                class_id::DATAWRITER_CRYPTO_TOKENS,
9885                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
9886                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
9887            ),
9888            (
9889                class_id::DATAREADER_CRYPTO_TOKENS,
9890                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
9891                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
9892            ),
9893        ];
9894        for (class, src_eid, dst_eid) in builtin_pairs {
9895            out.extend(build(
9896                class,
9897                src_eid,
9898                Guid::new(remote_prefix, dst_eid).to_bytes(),
9899            ));
9900        }
9901    }
9902    // FastDDS interop: the reliable secure-SPDP builtin (DCPSParticipantsSecure,
9903    // ff0101c2/c7) needs per-endpoint crypto tokens when FastDDS SEC-encrypts the secure-
9904    // SPDP DATA under discovery_protection — otherwise the peer cannot
9905    // decode our secure SPDP -> no secure participant discovery ->
9906    // no token reciprocation. Gated on enable_secure_spdp.
9907    if rt.config.enable_secure_spdp {
9908        let spdp_pairs = [
9909            (
9910                class_id::DATAWRITER_CRYPTO_TOKENS,
9911                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
9912                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
9913            ),
9914            (
9915                class_id::DATAREADER_CRYPTO_TOKENS,
9916                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
9917                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
9918            ),
9919        ];
9920        for (class, src_eid, dst_eid) in spdp_pairs {
9921            out.extend(build(
9922                class,
9923                src_eid,
9924                Guid::new(remote_prefix, dst_eid).to_bytes(),
9925            ));
9926        }
9927    }
9928    // Liveliness protection (§8.4.2.4): the secure-WLP builtin endpoints
9929    // (BuiltinParticipantMessageSecure, ff0200c2/c7) also need per-
9930    // endpoint crypto tokens. cyclone gates the participant security approval
9931    // (and thus the user-endpoint connection) on it — without these tokens
9932    // "connect ... waiting for approval by security" stays hung.
9933    if gate
9934        .liveliness_protection()
9935        .map(|l| l != ProtectionLevel::None)
9936        .unwrap_or(false)
9937    {
9938        let wlp_pairs = [
9939            (
9940                class_id::DATAWRITER_CRYPTO_TOKENS,
9941                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
9942                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
9943            ),
9944            (
9945                class_id::DATAREADER_CRYPTO_TOKENS,
9946                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
9947                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
9948            ),
9949        ];
9950        for (class, src_eid, dst_eid) in wlp_pairs {
9951            out.extend(build(
9952                class,
9953                src_eid,
9954                Guid::new(remote_prefix, dst_eid).to_bytes(),
9955            ));
9956        }
9957    }
9958    out
9959}
9960
9961/// Dedup key of a per-endpoint crypto token: the pair
9962/// (source_endpoint, destination_endpoint). cyclone associates a
9963/// datawriter/datareader token via exactly this pair (§9.5.3.3), so it is
9964/// also the right granularity to remember which tokens have gone out.
9965#[cfg(feature = "security")]
9966fn endpoint_token_key(
9967    m: &zerodds_security::generic_message::ParticipantGenericMessage,
9968) -> [u8; 32] {
9969    let mut k = [0u8; 32];
9970    k[..16].copy_from_slice(&m.source_endpoint_key);
9971    k[16..].copy_from_slice(&m.destination_endpoint_key);
9972    k
9973}
9974
9975/// Filters out the per-endpoint tokens not yet sent. The previously
9976/// used **per-peer** once-guard was too coarse: it snapped shut as soon as the
9977/// participant/secure-SEDP builtin tokens were out — but user endpoints match
9978/// only later (after the secure SEDP). Their tokens then never went out,
9979/// and the peer could never decode ZeroDDS' user DATA. Per-token dedup
9980/// (peer+source+dest) sends each token exactly once — builtins early,
9981/// user endpoints as soon as they match.
9982#[cfg(feature = "security")]
9983fn pending_endpoint_tokens(
9984    msgs: Vec<zerodds_security::generic_message::ParticipantGenericMessage>,
9985    already_sent: &alloc::collections::BTreeSet<[u8; 32]>,
9986) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
9987    msgs.into_iter()
9988        .filter(|m| !already_sent.contains(&endpoint_token_key(m)))
9989        .collect()
9990}
9991
9992/// FU2 S1.4 (recv): Kx-decrypt an incoming `PARTICIPANT_CRYPTO_TOKENS` message
9993/// and install the peer's data key in the gate.
9994/// Afterwards secured user DATA round-trips with this peer.
9995#[cfg(feature = "security")]
9996fn install_crypto_token(
9997    rt: &DcpsRuntime,
9998    remote_prefix: GuidPrefix,
9999    msg: &zerodds_security::generic_message::ParticipantGenericMessage,
10000) {
10001    use zerodds_security::generic_message::class_id;
10002    // Cross-vendor: cyclone sends the data key both as
10003    // participant_crypto_tokens and per-endpoint as datawriter/
10004    // datareader_crypto_tokens. We install the keymat from all three
10005    // under the sender's participant slot (one user endpoint per participant
10006    // in the bench) — so decode_data_datawriter_from decodes the user DATA.
10007    if msg.message_class_id != class_id::PARTICIPANT_CRYPTO_TOKENS
10008        && msg.message_class_id != class_id::DATAWRITER_CRYPTO_TOKENS
10009        && msg.message_class_id != class_id::DATAREADER_CRYPTO_TOKENS
10010    {
10011        return;
10012    }
10013    let Some(gate) = rt.config.security.as_ref() else {
10014        return;
10015    };
10016    let peer_key = remote_prefix.to_bytes();
10017    // `message_data` is a sequence<DataHolder> (DDS-Security §7.4.4.3
10018    // ParticipantGenericMessage): cyclone packs MULTIPLE CryptoTokens (its own
10019    // key material per endpoint, different transformation_key_id) into ONE
10020    // message. Install ALL — taking only `.first()` lost the
10021    // endpoint keys (key_id 2..N) and the secure SEDP stayed undecodable.
10022    // Plaintext token (confidentiality was provided by the submessage protection of
10023    // the transporting volatile DATA, see unprotect_volatile_datagram).
10024    // DDS-Security §9.5.2 vs §9.5.3: the PARTICIPANT crypto token carries the
10025    // message-level key (SRTPS, decode_secured_rtps_message -> slots[peer]); the
10026    // datawriter/datareader tokens carry per-endpoint data keys that belong ONLY in
10027    // the key_id path (remote_by_key_id, decode_data_by_key_id). Putting both
10028    // into slots[peer] let the last-installed (datareader) overwrite the
10029    // participant key -> message-level SRTPS tag mismatch.
10030    let is_participant = msg.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS;
10031    for dh in &msg.message_data {
10032        if let Some(token) = dh.binary_property(CRYPTO_TOKEN_PROP) {
10033            let _ = if is_participant {
10034                gate.set_remote_data_token_by_guid(&peer_key, token)
10035            } else {
10036                gate.install_remote_endpoint_token(token)
10037            };
10038        }
10039    }
10040}
10041
10042// RTPS submessage IDs for the VolatileSecure submessage-protection surgery.
10043#[cfg(feature = "security")]
10044const SMID_DATA: u8 = 0x15;
10045#[cfg(feature = "security")]
10046const SMID_SEC_PREFIX: u8 = 0x31;
10047#[cfg(feature = "security")]
10048const SMID_SEC_POSTFIX: u8 = 0x32;
10049// Further writer submessage IDs (DDSI-RTPS 2.5 §8.3.7). Per DDS-Security
10050// §8.4.2.4 (is_submessage_protected=TRUE, DataWriter) ALL submessages sent by the
10051// writer — not only DATA — MUST be protected via encode_datawriter_submessage.
10052// HEARTBEAT is the critical one: without it the remote
10053// reader cannot NACK a missing sequence number (= no reliable recovery).
10054#[cfg(feature = "security")]
10055const SMID_HEARTBEAT: u8 = 0x07;
10056#[cfg(feature = "security")]
10057const SMID_GAP: u8 = 0x08;
10058#[cfg(feature = "security")]
10059const SMID_DATA_FRAG: u8 = 0x16;
10060#[cfg(feature = "security")]
10061const SMID_HEARTBEAT_FRAG: u8 = 0x13;
10062// Reader submessages (DDSI-RTPS 2.5 §8.3.7): under `metadata_protection_kind
10063// != NONE` to be protected via `encode_datareader_submessage` (§8.4.2.4) with the per-endpoint
10064// reader key — otherwise a spec-conformant remote writer
10065// (cyclone under discovery=ENCRYPT) discards the clear ACKNACK and never re-sends.
10066#[cfg(feature = "security")]
10067const SMID_ACKNACK: u8 = 0x06;
10068#[cfg(feature = "security")]
10069const SMID_NACK_FRAG: u8 = 0x12;
10070
10071/// `true` if the submessage ID is a submessage sent by the DataReader
10072/// (ACKNACK/NACK_FRAG) — datareader protection path.
10073#[cfg(feature = "security")]
10074fn is_protected_reader_submessage(id: u8) -> bool {
10075    matches!(id, SMID_ACKNACK | SMID_NACK_FRAG)
10076}
10077
10078/// Extracts the `reader_id` (sender) from an ACKNACK/NACK_FRAG submessage:
10079/// offset 4 (after header(4)), directly before the writer_id (offset 8).
10080#[cfg(feature = "security")]
10081fn reader_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
10082    if !is_protected_reader_submessage(id) {
10083        return None;
10084    }
10085    let raw: [u8; 4] = submsg.get(4..8)?.try_into().ok()?;
10086    Some(EntityId::from_bytes(raw))
10087}
10088
10089/// `true` if the submessage ID is a submessage sent by the DataWriter that,
10090/// under `metadata_protection_kind != NONE`, must be protected via `encode_datawriter_submessage`
10091/// (DDS-Security §8.4.2.4). ACKNACK/NACK_FRAG are
10092/// reader submessages (datareader path) and are excluded here.
10093#[cfg(feature = "security")]
10094fn is_protected_writer_submessage(id: u8) -> bool {
10095    matches!(
10096        id,
10097        SMID_DATA | SMID_DATA_FRAG | SMID_HEARTBEAT | SMID_HEARTBEAT_FRAG | SMID_GAP
10098    )
10099}
10100
10101/// Walks the submessages of an RTPS datagram from `offset` and returns
10102/// `(submessage_id, start, total_len)`. `octetsToNextHeader == 0` means
10103/// "to the end of the datagram" (RTPS §8.3.3.2.3).
10104#[cfg(feature = "security")]
10105fn walk_submessages(bytes: &[u8]) -> Vec<(u8, usize, usize)> {
10106    let mut out = Vec::new();
10107    let mut o = 20; // RTPS header
10108    while o + 4 <= bytes.len() {
10109        let id = bytes[o];
10110        let le = bytes[o + 1] & 0x01 != 0;
10111        let raw = if le {
10112            u16::from_le_bytes([bytes[o + 2], bytes[o + 3]])
10113        } else {
10114            u16::from_be_bytes([bytes[o + 2], bytes[o + 3]])
10115        } as usize;
10116        let body = if raw == 0 { bytes.len() - (o + 4) } else { raw };
10117        let total = 4 + body;
10118        if o + total > bytes.len() {
10119            break;
10120        }
10121        out.push((id, o, total));
10122        o += total;
10123    }
10124    out
10125}
10126
10127/// Cross-vendor VolatileSecure (send): replaces every DATA submessage in the
10128/// datagram with the cyclone-conformant `SEC_PREFIX`/`SEC_BODY`/`SEC_POSTFIX`
10129/// sequence (encrypted with the peer's Kx key). Other submessages
10130/// (INFO_DST/INFO_TS/HEARTBEAT) stay unchanged. Returns the datagram
10131/// unchanged if no DATA submessage is present (e.g. a pure
10132/// HEARTBEAT tick). `None` only on a crypto error (drop instead of leak).
10133#[cfg(feature = "security")]
10134fn protect_volatile_datagram(
10135    rt: &DcpsRuntime,
10136    bytes: &[u8],
10137    peer_key: &[u8; 12],
10138) -> Option<Vec<u8>> {
10139    let gate = rt.config.security.as_ref()?;
10140    if bytes.len() < 20 {
10141        return Some(bytes.to_vec());
10142    }
10143    let subs = walk_submessages(bytes);
10144    // DDS-Security §8.4.2.4: ParticipantVolatileMessageSecure is submessage-
10145    // protected — ALL submessages sent by the endpoint MUST be protected with the Kx key,
10146    // not only DATA. This holds for BOTH directions:
10147    //  * writer submessages (DATA, DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP)
10148    //  * reader submessages (ACKNACK, NACK_FRAG)
10149    // cyclone/FastDDS otherwise discard the WHOLE volatile sample with "clear
10150    // submsg from protected src" → the crypto-token exchange over the volatile
10151    // stalls. write_with_heartbeat bundles DATA+HEARTBEAT into ONE datagram; if
10152    // the HEARTBEAT stayed clear, the whole token sample was lost (cross-vendor
10153    // cyclone→ZeroDDS responder).
10154    // The reader ACKNACK: OpenDDS' RtpsUdpReceiveStrategy::check_encoded requires
10155    // protection for the volatile reader (ff0202c4, is_submessage_protected=TRUE) and
10156    // otherwise drops the clear ACKNACK ("Submessage requires protection") → its
10157    // volatile WRITER never gets an ACK → considers the token delivery
10158    // unacknowledged → zerodds NEVER sends the SRTPS-protected secure SEDP → no
10159    // user-endpoint match. The volatile channel uses ONE shared Kx session key
10160    // (KDF from the shared secret, §9.5.3.3.4.4), symmetric for both directions
10161    // → protect the ACKNACK with the same Kx key as the DATA.
10162    if !subs.iter().any(|(id, _, _)| {
10163        is_protected_writer_submessage(*id) || is_protected_reader_submessage(*id)
10164    }) {
10165        return Some(bytes.to_vec()); // no protection-worthy submessage -> unchanged
10166    }
10167    let mut out = Vec::with_capacity(bytes.len() + 64);
10168    out.extend_from_slice(&bytes[..20]);
10169    for (id, start, total) in subs {
10170        let submsg = &bytes[start..start + total];
10171        if is_protected_writer_submessage(id) || is_protected_reader_submessage(id) {
10172            match gate.encode_kx_datawriter_for(peer_key, submsg) {
10173                Ok(sec) => out.extend_from_slice(&sec),
10174                Err(_) => return None, // drop instead of plaintext leak
10175            }
10176        } else {
10177            out.extend_from_slice(submsg);
10178        }
10179    }
10180    Some(out)
10181}
10182
10183/// Cross-vendor VolatileSecure (recv): recognizes a `SEC_PREFIX`/`SEC_BODY`/
10184/// `SEC_POSTFIX` sequence, decodes it with the peer's Kx key to the
10185/// original DATA submessage and builds a plain RTPS datagram for the
10186/// `volatile_reader`. `None` if no SEC_* sequence is present (then the normal
10187/// path) or on a crypto error.
10188#[cfg(feature = "security")]
10189fn unprotect_volatile_datagram(
10190    rt: &DcpsRuntime,
10191    bytes: &[u8],
10192    peer_key: &[u8; 12],
10193) -> Option<Vec<u8>> {
10194    let gate = rt.config.security.as_ref()?;
10195    if bytes.len() < 20 {
10196        return None;
10197    }
10198    let subs = walk_submessages(bytes);
10199    // Cyclone/FastDDS bundle, via xpack, MULTIPLE SEC_*-protected volatile
10200    // submessages (all with the Kx key) into ONE datagram. So there can be
10201    // multiple SEC_PREFIX/BODY/POSTFIX triples — transform ALL back
10202    // (like unprotect_user_datagram). Decoding only the first block (an earlier
10203    // bug) left every bundled token sample after the first encrypted;
10204    // the VOLATILE writer does not retransmit them → deterministic
10205    // token loss (no "flaky" transport, all same-host). `None` if there is no
10206    // SEC_PREFIX at all (plaintext) or the Kx decode fails (= not a volatile datagram,
10207    // e.g. secure SEDP with a per-endpoint key).
10208    if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
10209        return None;
10210    }
10211    let mut out = Vec::with_capacity(bytes.len());
10212    out.extend_from_slice(&bytes[..20]);
10213    let mut i = 0;
10214    while i < subs.len() {
10215        let (id, start, total) = subs[i];
10216        if id == SMID_SEC_PREFIX {
10217            let postfix_idx = subs[i..]
10218                .iter()
10219                .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
10220                .map(|off| i + off)?;
10221            let (_, q_start, q_total) = subs[postfix_idx];
10222            let sec_wire = &bytes[start..q_start + q_total];
10223            let submsg = gate.decode_kx_datawriter_from(peer_key, sec_wire).ok()?;
10224            out.extend_from_slice(&submsg);
10225            i = postfix_idx + 1;
10226        } else {
10227            out.extend_from_slice(&bytes[start..start + total]);
10228            i += 1;
10229        }
10230    }
10231    Some(out)
10232}
10233
10234/// Protects a peer's volatile outbound datagrams (DATA -> SEC_*).
10235/// HEARTBEAT/ACKNACK datagrams (without DATA) stay unchanged; datagrams
10236/// with a crypto error are dropped.
10237#[cfg(feature = "security")]
10238fn protect_volatile_outbound(
10239    rt: &DcpsRuntime,
10240    remote_prefix: GuidPrefix,
10241    dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>,
10242) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
10243    let peer_key = remote_prefix.to_bytes();
10244    dgs.into_iter()
10245        .filter_map(|dg| {
10246            protect_volatile_datagram(rt, &dg.bytes, &peer_key).map(|bytes| {
10247                zerodds_rtps::message_builder::OutboundDatagram {
10248                    bytes,
10249                    targets: dg.targets,
10250                }
10251            })
10252        })
10253        .collect()
10254}
10255
10256/// Cross-vendor (send): replaces EVERY submessage sent by the DataWriter (DATA,
10257/// DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP) with the cyclone-conformant
10258/// SEC_PREFIX/BODY/POSTFIX sequence, encrypted with the **local data key**.
10259/// DDS-Security §8.4.2.4 (`is_submessage_protected=TRUE`, DataWriter): ALL
10260/// writer submessages MUST be protected via `encode_datawriter_submessage`
10261/// — in particular the HEARTBEAT, otherwise the remote reader cannot NACK missing
10262/// sequence numbers (no reliable recovery). Framing submessages
10263/// (INFO_TS/INFO_DST/...) stay unchanged; `None` on a crypto error.
10264#[cfg(feature = "security")]
10265fn protect_user_datagram(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10266    let gate = rt.config.security.as_ref()?;
10267    if bytes.len() < 20 {
10268        return Some(bytes.to_vec());
10269    }
10270    let subs = walk_submessages(bytes);
10271    if !subs
10272        .iter()
10273        .any(|(id, _, _)| is_protected_writer_submessage(*id))
10274    {
10275        return Some(bytes.to_vec());
10276    }
10277    // §9.5.3.3 per-endpoint key: all writer submessages of a datagram
10278    // come from the same writer. Take the writer_id from the first protected
10279    // submessage + look up the per-endpoint handle. No handle
10280    // (unregistered endpoint) → participant-key fallback.
10281    let endpoint_handle = subs
10282        .iter()
10283        .find(|(id, _, _)| is_protected_writer_submessage(*id))
10284        .and_then(|&(id, start, total)| writer_eid_in_submessage(&bytes[start..start + total], id))
10285        .and_then(|weid| local_endpoint_crypto_handle(rt, weid, true));
10286    let mut out = Vec::with_capacity(bytes.len() + 64);
10287    out.extend_from_slice(&bytes[..20]);
10288    for (id, start, total) in subs {
10289        let submsg = &bytes[start..start + total];
10290        if is_protected_writer_submessage(id) {
10291            let sec = match endpoint_handle {
10292                Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
10293                None => gate.encode_data_datawriter_local(submsg),
10294            };
10295            match sec {
10296                Ok(s) => out.extend_from_slice(&s),
10297                Err(_) => return None,
10298            }
10299        } else {
10300            out.extend_from_slice(submsg);
10301        }
10302    }
10303    Some(out)
10304}
10305
10306/// Extracts the `writer_id` from an RTPS writer submessage. DATA/DATA_FRAG:
10307/// offset 12 (header(4)+extraFlags(2)+octetsToInlineQos(2)+readerId(4));
10308/// HEARTBEAT/GAP/HEARTBEAT_FRAG: offset 8 (header(4)+readerId(4)).
10309#[cfg(feature = "security")]
10310fn writer_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
10311    let off = match id {
10312        SMID_DATA | SMID_DATA_FRAG => 12,
10313        SMID_HEARTBEAT | SMID_GAP | SMID_HEARTBEAT_FRAG => 8,
10314        _ => return None,
10315    };
10316    let raw: [u8; 4] = submsg.get(off..off + 4)?.try_into().ok()?;
10317    Some(EntityId::from_bytes(raw))
10318}
10319
10320/// Cross-vendor user DATA (recv): decodes the SEC_* sequence with the sender's
10321/// data key (`peer_key` = sender GuidPrefix) back to the DATA submessage.
10322/// `None` if no SEC_* sequence is present (normal path) or on a crypto error.
10323#[cfg(feature = "security")]
10324fn unprotect_user_datagram(rt: &DcpsRuntime, bytes: &[u8], peer_key: &[u8; 12]) -> Option<Vec<u8>> {
10325    let gate = rt.config.security.as_ref()?;
10326    if bytes.len() < 20 {
10327        return None;
10328    }
10329    let subs = walk_submessages(bytes);
10330    // §8.4.2.4: the peer SEC_*-wrapped EVERY writer submessage individually
10331    // (DATA, HEARTBEAT, GAP, ...). So there can be MULTIPLE SEC_PREFIX/BODY/
10332    // POSTFIX triples in the same datagram — transform them all back. `None`
10333    // only if there is no SEC_* sequence at all (normal/plaintext path).
10334    if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
10335        return None;
10336    }
10337    let mut out = Vec::with_capacity(bytes.len());
10338    out.extend_from_slice(&bytes[..20]);
10339    let mut i = 0;
10340    while i < subs.len() {
10341        let (id, start, total) = subs[i];
10342        if id == SMID_SEC_PREFIX {
10343            // Find the matching SEC_POSTFIX from i; the block is [prefix..postfix].
10344            let postfix_idx = subs[i..]
10345                .iter()
10346                .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
10347                .map(|off| i + off)?;
10348            let (_, q_start, q_total) = subs[postfix_idx];
10349            let sec_wire = &bytes[start..q_start + q_total];
10350            // key_id-based decode: the peer has, per endpoint (user +
10351            // secure-builtin discovery), its own key material; the correct
10352            // key is found via the transformation_key_id in the CryptoHeader.
10353            // Fallback for transformation_key_id=0: this is NOT a per-
10354            // endpoint token key, but the participant-level key derived from the
10355            // SharedSecret (DDS-Security Tab.73, AES256-GCM, sender_key_id
10356            // =0) — cyclone protects with it under rtps_protection. That one is decoded by the
10357            // Kx path (peer-prefix-indexed SharedSecret key).
10358            let mut submsg = gate
10359                .decode_data_by_key_id(sec_wire)
10360                .or_else(|_| gate.decode_data_datawriter_from(peer_key, sec_wire))
10361                .or_else(|_| gate.decode_kx_datawriter_from(peer_key, sec_wire))
10362                .ok()?;
10363            // Correct octetsToNextHeader to the real body length: cyclone
10364            // wraps every writer submessage INDIVIDUALLY; within its SEC_BODY
10365            // it is the last one -> octetsToNextHeader=0 ("to the end of the message").
10366            // When concatenating multiple decoded blocks (e.g. DATA + piggybacked
10367            // HEARTBEAT), otn=0 would make the strict decode_datagram swallow the following
10368            // submessage as payload -> the reader would never see the
10369            // HEARTBEAT and would block as a late joiner on the SN gap.
10370            if submsg.len() >= 4 {
10371                let le = submsg[1] & zerodds_rtps::FLAG_E_LITTLE_ENDIAN != 0;
10372                let otn = u16::try_from(submsg.len() - 4).unwrap_or(0);
10373                let b = if le {
10374                    otn.to_le_bytes()
10375                } else {
10376                    otn.to_be_bytes()
10377                };
10378                submsg[2] = b[0];
10379                submsg[3] = b[1];
10380            }
10381            out.extend_from_slice(&submsg);
10382            i = postfix_idx + 1;
10383        } else {
10384            out.extend_from_slice(&bytes[start..start + total]);
10385            i += 1;
10386        }
10387    }
10388    Some(out)
10389}
10390
10391/// §8.5.1.9.1 / §9.5.3.3.1 data_protection (send): encrypts ONLY the
10392/// SerializedPayload INSIDE each DATA submessage (payload layer). The
10393/// submessage header, octetsToInlineQos, InlineQoS and the flags (E/Q/D/K)
10394/// stay byte-identical; only the N-flag (NonStandardPayload, §8.3.8.2) is
10395/// set and octetsToNextHeader adjusted to the new payload length. This is
10396/// the spec-conformant + cyclone-interop form of data_protection (counterpart:
10397/// metadata_protection = whole submessage SEC_*-wrapped). Applied as the INNER
10398/// layer BEFORE the submessage/message protection. `None` on a
10399/// crypto error (drop instead of leak); a datagram without DATA stays unchanged.
10400#[cfg(feature = "security")]
10401fn protect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10402    use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
10403    use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
10404    let gate = rt.config.security.as_ref()?;
10405    if bytes.len() < 20 {
10406        return Some(bytes.to_vec());
10407    }
10408    let subs = walk_submessages(bytes);
10409    if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
10410        return Some(bytes.to_vec());
10411    }
10412    let mut out = Vec::with_capacity(bytes.len() + 64);
10413    out.extend_from_slice(&bytes[..20]);
10414    for (id, start, total) in subs {
10415        let submsg = &bytes[start..start + total];
10416        if id != SMID_DATA {
10417            out.extend_from_slice(submsg);
10418            continue;
10419        }
10420        let flags = submsg[1];
10421        let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
10422        // data_protection payload key: the **per-endpoint DataWriter key**
10423        // (§9.5.3.3.1). cyclone associates the DataWriter strictly with its
10424        // datawriter_crypto_handle and decodes the SerializedPayload ONLY with
10425        // this key — the participant key yields "Invalid Crypto
10426        // Handle" in cyclone. The key is sent to the peer as a datawriter_crypto_token;
10427        // the reader finds it via the transformation_key_id in the CryptoHeader.
10428        let handle = writer_eid_in_submessage(submsg, id)
10429            .and_then(|w| local_endpoint_crypto_handle(rt, w, true))?;
10430        // Payload boundary: read_body_with_flags returns serialized_payload as
10431        // an Arc of body[pos..] -> payload = the last plen bytes of the submessage.
10432        let body = &submsg[4..];
10433        let ds = DataSubmessage::read_body_with_flags(body, le, flags).ok()?;
10434        let plen = ds.serialized_payload.len();
10435        let payload_off = submsg.len() - plen;
10436        let enc = gate
10437            .encode_serialized_payload(handle, &ds.serialized_payload)
10438            .ok()?;
10439        let new_body_len = (payload_off - 4) + enc.len();
10440        if new_body_len > u16::MAX as usize {
10441            return None;
10442        }
10443        out.push(submsg[0]);
10444        out.push(flags | DATA_FLAG_NON_STANDARD);
10445        let otn = new_body_len as u16;
10446        if le {
10447            out.extend_from_slice(&otn.to_le_bytes());
10448        } else {
10449            out.extend_from_slice(&otn.to_be_bytes());
10450        }
10451        // Body prefix (extraFlags..InlineQoS) verbatim, then encrypted payload.
10452        out.extend_from_slice(&submsg[4..payload_off]);
10453        out.extend_from_slice(&enc);
10454    }
10455    Some(out)
10456}
10457
10458/// Result of the inner payload layer on receipt (§8.5.1.9.4).
10459#[cfg(feature = "security")]
10460enum PayloadDecode {
10461    /// No DATA submessage carries the N-flag — plaintext path, pass the datagram
10462    /// on unchanged.
10463    NotEncrypted,
10464    /// Successfully decrypted — use the plaintext datagram.
10465    Decoded(Vec<u8>),
10466    /// N-flag set, but decryption failed. The datagram MUST
10467    /// be discarded — passing an undecodable encrypted payload as
10468    /// ciphertext gives the reader garbage (§8.5: reject). The
10469    /// reliable re-send catches up on the sample once the key is installed
10470    /// resp. another (e.g. inproc/message-level) copy delivers it.
10471    Failed,
10472}
10473
10474/// `true` if the SerializedPayload begins with a CryptoHeader (§9.5.3.3.1):
10475/// the first 4 bytes are a CryptoTransformKind != NONE
10476/// (AES128_GMAC/GCM, AES256_GMAC/GCM = `[0,0,0,1..=4]`). A plaintext CDR
10477/// encapsulation carries either a different first byte pair (CDR_LE `[0,1]`,
10478/// XCDR2 `[0,6/7]`, PL_CDR `[0,2/3]`) or — for CDR_BE `[0,0]` — options
10479/// `[0,0]`, so it does not collide with the transform kinds 1..=4. Serves as
10480/// detection for vendors (cyclone) that encrypt the data_protection payload
10481/// without setting the N-flag of the DATA submessage.
10482#[cfg(feature = "security")]
10483fn payload_has_crypto_header(payload: &[u8]) -> bool {
10484    matches!(payload, [0, 0, 0, 1..=4, ..])
10485}
10486
10487/// §8.5.1.9.4 / §9.5.3.3.1 data_protection (recv): decrypts the
10488/// SerializedPayload of each DATA submessage whose payload begins with a CryptoHeader
10489/// — recognized by the set N-flag (zero↔zero, [`protect_user_payload`])
10490/// OR by the CryptoTransformKind signature (cyclone does not set the N-flag).
10491/// The tag verification of the GCM open IS the detection: if the decode fails
10492/// and the N-flag was not set, the submessage is passed through as plaintext
10493/// (false positive of the signature heuristic). The key is found via the
10494/// `transformation_key_id` (key_id), the sender prefix (peer slot) or — for
10495/// key_id=0 (participant/Kx key, cyclone) — the Kx key material.
10496/// `NotEncrypted` if no DATA submessage was decrypted; `Failed` only
10497/// on an N-flag decode error (§8.5: reject undecryptable).
10498#[cfg(feature = "security")]
10499fn unprotect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> PayloadDecode {
10500    use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
10501    use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
10502    let Some(gate) = rt.config.security.as_ref() else {
10503        return PayloadDecode::NotEncrypted;
10504    };
10505    if bytes.len() < 20 {
10506        return PayloadDecode::NotEncrypted;
10507    }
10508    // Sender prefix (RTPS header bytes[8..20]) as a fallback key index, if the
10509    // transformation_key_id in the CryptoHeader is not uniquely in the remote index
10510    // (zero↔zero indexed via the peer slot, cyclone strictly via key_id).
10511    let mut peer_key = [0u8; 12];
10512    peer_key.copy_from_slice(&bytes[8..20]);
10513    let subs = walk_submessages(bytes);
10514    let mut out = Vec::with_capacity(bytes.len());
10515    out.extend_from_slice(&bytes[..20]);
10516    let mut did_decode = false;
10517    for (id, start, total) in subs {
10518        let submsg = &bytes[start..start + total];
10519        if id != SMID_DATA {
10520            out.extend_from_slice(submsg);
10521            continue;
10522        }
10523        let flags = submsg[1];
10524        let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
10525        let nflag = flags & DATA_FLAG_NON_STANDARD != 0;
10526        let body = &submsg[4..];
10527        let Ok(ds) = DataSubmessage::read_body_with_flags(body, le, flags) else {
10528            // Parse error of a DATA marked as encrypted -> drop;
10529            // a pure plaintext DATA never made read_body_with_flags fail,
10530            // so a set N-flag is the only reason here.
10531            if nflag {
10532                return PayloadDecode::Failed;
10533            }
10534            out.extend_from_slice(submsg);
10535            continue;
10536        };
10537        // Only attempt when the payload is recognizable as encrypted:
10538        // N-flag (zero↔zero) or CryptoHeader signature (cyclone without an N-flag).
10539        if !nflag && !payload_has_crypto_header(&ds.serialized_payload) {
10540            out.extend_from_slice(submsg);
10541            continue;
10542        }
10543        let plen = ds.serialized_payload.len();
10544        let payload_off = submsg.len() - plen;
10545        let pdec = gate
10546            .decode_serialized_payload(&ds.serialized_payload)
10547            .or_else(|_| gate.decode_serialized_payload_from(&peer_key, &ds.serialized_payload))
10548            .or_else(|_| gate.decode_serialized_payload_kx(&peer_key, &ds.serialized_payload));
10549        let Ok(dec) = pdec else {
10550            // §8.5: if the N-flag was set, the payload is surely encrypted
10551            // and the reader would get garbage -> drop (reliable re-send catches it
10552            // up after key install). If only the signature heuristic was the trigger
10553            // (no N-flag), it is a plaintext CDR_BE payload whose options
10554            // happen to look like a TransformKind -> pass through unchanged.
10555            if nflag {
10556                return PayloadDecode::Failed;
10557            }
10558            out.extend_from_slice(submsg);
10559            continue;
10560        };
10561        let new_body_len = (payload_off - 4) + dec.len();
10562        if new_body_len > u16::MAX as usize {
10563            return PayloadDecode::Failed;
10564        }
10565        out.push(submsg[0]);
10566        out.push(flags & !DATA_FLAG_NON_STANDARD);
10567        let otn = new_body_len as u16;
10568        if le {
10569            out.extend_from_slice(&otn.to_le_bytes());
10570        } else {
10571            out.extend_from_slice(&otn.to_be_bytes());
10572        }
10573        out.extend_from_slice(&submsg[4..payload_off]);
10574        out.extend_from_slice(&dec);
10575        did_decode = true;
10576    }
10577    if did_decode {
10578        PayloadDecode::Decoded(out)
10579    } else {
10580        PayloadDecode::NotEncrypted
10581    }
10582}
10583
10584/// `true` if the EntityId is one of the four secure-SEDP discovery endpoints
10585/// (DCPSPublicationsSecure/DCPSSubscriptionsSecure, EntityIds ff0003c2/c7 +
10586/// ff0004c2/c7). Controls whether a SEDP datagram is protected-discovery traffic
10587/// and must be SEC_*-protected (DDS-Security §8.4.2.4).
10588#[cfg(feature = "security")]
10589fn is_secure_sedp_entity(e: EntityId) -> bool {
10590    e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER
10591        || e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER
10592        || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER
10593        || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER
10594}
10595
10596/// `true` if the datagram carries a submessage to/from a secure-SEDP endpoint
10597/// — then it is protected-discovery traffic.
10598#[cfg(feature = "security")]
10599fn is_secure_sedp_datagram(bytes: &[u8]) -> bool {
10600    let Ok(parsed) = decode_datagram(bytes) else {
10601        return false;
10602    };
10603    parsed.submessages.iter().any(|s| {
10604        let ids = match s {
10605            ParsedSubmessage::Data(d) => [Some(d.writer_id), Some(d.reader_id)],
10606            ParsedSubmessage::DataFrag(d) => [Some(d.writer_id), Some(d.reader_id)],
10607            ParsedSubmessage::Heartbeat(h) => [Some(h.writer_id), Some(h.reader_id)],
10608            ParsedSubmessage::Gap(g) => [Some(g.writer_id), Some(g.reader_id)],
10609            ParsedSubmessage::AckNack(a) => [Some(a.writer_id), Some(a.reader_id)],
10610            ParsedSubmessage::NackFrag(n) => [Some(n.writer_id), Some(n.reader_id)],
10611            _ => [None, None],
10612        };
10613        ids.into_iter().flatten().any(is_secure_sedp_entity)
10614    })
10615}
10616
10617/// Protected discovery (DDS-Security §8.4.2.4) send: secure-SEDP datagrams
10618/// (DATA/HEARTBEAT/GAP of the secure writers) are
10619/// `encode_datawriter_submessage`-protected with the participant data key — the same key the peer installs via
10620/// `participant_crypto_tokens`. Non-secure SEDP goes through unchanged.
10621/// `None` ⟹ crypto error on secure SEDP → drop the datagram instead of a
10622/// plaintext leak.
10623#[cfg(feature = "security")]
10624fn protect_sedp_outbound(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10625    let Some(gate) = rt.config.security.as_ref() else {
10626        return Some(bytes.to_vec());
10627    };
10628    if !is_secure_sedp_datagram(bytes) || bytes.len() < 20 {
10629        return Some(bytes.to_vec());
10630    }
10631    // Governance §8.4.2.4: discovery_protection_kind=NONE -> NO discovery
10632    // protection. Secure-SEDP entities (ff0003c7/ff0004c7) must then NOT
10633    // be per-endpoint-protected; otherwise their ACKNACKs leak as message-
10634    // level SEC_PREFIX with a never-exchanged per-endpoint key that a
10635    // peer (cyclone uses plain SEDP under discovery=NONE) discards as "Invalid Crypto
10636    // Handle". Pass through plain -> the outer rtps_protection
10637    // layer (SRTPS via secure_outbound_bytes) wraps the whole message correctly.
10638    if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
10639        return Some(bytes.to_vec());
10640    }
10641    // §8.4.2.4: protect BOTH directions — writer submessages (DATA/HEARTBEAT/
10642    // GAP) with the per-endpoint writer key (encode_datawriter_submessage), reader
10643    // submessages (ACKNACK/NACK_FRAG) with the per-endpoint reader key
10644    // (encode_datareader_submessage). A spec-conformant cyclone under
10645    // discovery=ENCRYPT discards a CLEAR ACKNACK of the secure-SEDP reader →
10646    // never re-sends the SubscriptionData → ZeroDDS never discovers the reader. The
10647    // per-endpoint key (same as in the sent datareader_crypto_token)
10648    // makes the ACKNACK decodable for cyclone.
10649    let subs = walk_submessages(bytes);
10650    let mut out = Vec::with_capacity(bytes.len() + 64);
10651    out.extend_from_slice(&bytes[..20]);
10652    for (id, start, total) in subs {
10653        let submsg = &bytes[start..start + total];
10654        let handle = if is_protected_writer_submessage(id) {
10655            writer_eid_in_submessage(submsg, id)
10656                .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
10657        } else if is_protected_reader_submessage(id) {
10658            reader_eid_in_submessage(submsg, id)
10659                .and_then(|r| local_endpoint_crypto_handle(rt, r, false))
10660        } else {
10661            // Framing submessage (INFO_TS/INFO_DST/...) — unchanged.
10662            out.extend_from_slice(submsg);
10663            continue;
10664        };
10665        let sec = match handle {
10666            Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
10667            // No per-endpoint handle (should not occur for secure SEDP)
10668            // → participant-key fallback, so no plaintext leak arises.
10669            None => gate.encode_data_datawriter_local(submsg),
10670        };
10671        match sec {
10672            Ok(s) => out.extend_from_slice(&s),
10673            Err(_) => return None,
10674        }
10675    }
10676    Some(out)
10677}
10678
10679/// Protects a user-reader outbound datagram (ACKNACK/NACK_FRAG) on the
10680/// send direction (DDS-Security §8.4.2.4). Counterpart to the writer-DATA layer:
10681/// under `metadata_protection != NONE` the reader submessage too MUST be protected with the
10682/// per-endpoint reader key, otherwise a spec-strict
10683/// peer writer (cyclone/FastDDS) discards the CLEAR ACKNACK → the SN gap is never
10684/// re-sent → permanent reliable stall. Only needed when
10685/// **rtps_protection** does NOT already wrap the message as an SRTPS whole; otherwise
10686/// (and with metadata=NONE) the function delegates to `secure_outbound_bytes`.
10687#[cfg(feature = "security")]
10688fn protect_user_reader_datagram<'a>(
10689    rt: &DcpsRuntime,
10690    bytes: &'a [u8],
10691) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10692    let Some(gate) = rt.config.security.as_ref() else {
10693        return Some(alloc::borrow::Cow::Borrowed(bytes));
10694    };
10695    let metadata = gate.metadata_protection().unwrap_or(ProtectionLevel::None);
10696    let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
10697    // rtps != None → SRTPS wraps the whole message incl. ACKNACK; metadata ==
10698    // None → no submessage protection configured. secure_outbound_bytes
10699    // (transform_outbound) covers both cases correctly.
10700    if metadata == ProtectionLevel::None || rtps != ProtectionLevel::None || bytes.len() < 20 {
10701        return secure_outbound_bytes(rt, bytes);
10702    }
10703    let subs = walk_submessages(bytes);
10704    let mut out = Vec::with_capacity(bytes.len() + 64);
10705    out.extend_from_slice(&bytes[..20]);
10706    for (id, start, total) in subs {
10707        let submsg = &bytes[start..start + total];
10708        if is_protected_reader_submessage(id) {
10709            let handle = reader_eid_in_submessage(submsg, id)
10710                .and_then(|r| local_endpoint_crypto_handle(rt, r, false));
10711            match handle {
10712                Some(h) => match gate.encode_data_datawriter_by_handle(h, submsg) {
10713                    Ok(s) => out.extend_from_slice(&s),
10714                    Err(_) => return None,
10715                },
10716                // No per-endpoint reader key yet (the endpoint matches only after
10717                // secure SEDP) → pass through plaintext; the reader tick re-sends
10718                // the ACKNACK once the key is installed.
10719                None => out.extend_from_slice(submsg),
10720            }
10721        } else {
10722            // Framing submessage (INFO_DST/INFO_TS/...) — unchanged.
10723            out.extend_from_slice(submsg);
10724        }
10725    }
10726    Some(alloc::borrow::Cow::Owned(out))
10727}
10728
10729#[cfg(not(feature = "security"))]
10730fn protect_user_reader_datagram<'a>(
10731    rt: &DcpsRuntime,
10732    bytes: &'a [u8],
10733) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10734    secure_outbound_bytes(rt, bytes)
10735}
10736
10737/// `true` if `liveliness_protection != NONE` is configured — then WLP runs
10738/// over the secure entity + participant-key protection (§8.4.2.4).
10739#[cfg(feature = "security")]
10740fn wlp_liveliness_protected(rt: &DcpsRuntime) -> bool {
10741    rt.config.security.as_ref().is_some_and(|gate| {
10742        gate.liveliness_protection()
10743            .unwrap_or(ProtectionLevel::None)
10744            != ProtectionLevel::None
10745    })
10746}
10747
10748#[cfg(not(feature = "security"))]
10749fn wlp_liveliness_protected(_rt: &DcpsRuntime) -> bool {
10750    false
10751}
10752
10753/// Protects a WLP outbound datagram (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER
10754/// DATA) under `liveliness_protection != NONE` with the **participant data key**
10755/// (§8.4.2.4 / §7.4.7.1 Tab.7). WLP is participant-level (no per-endpoint key)
10756/// — analogous to the participant-key fallback in `protect_sedp_outbound`. If
10757/// `rtps_protection` already covers the message as SRTPS (or liveliness=NONE),
10758/// the function delegates to `secure_outbound_bytes`.
10759#[cfg(feature = "security")]
10760fn protect_wlp_outbound<'a>(
10761    rt: &DcpsRuntime,
10762    bytes: &'a [u8],
10763) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10764    let Some(gate) = rt.config.security.as_ref() else {
10765        return Some(alloc::borrow::Cow::Borrowed(bytes));
10766    };
10767    let live = gate
10768        .liveliness_protection()
10769        .unwrap_or(ProtectionLevel::None);
10770    let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
10771    // liveliness=NONE: no inner SEC layer -> secure_outbound_bytes covers
10772    // rtps_protection (SRTPS) resp. passthrough. PREVIOUSLY this branch
10773    // also delegated with rtps!=None and thus left out the liveliness SEC -> cyclone
10774    // saw the WLP DATA "clear submsg from protected src" -> no liveliness.
10775    if live == ProtectionLevel::None || bytes.len() < 20 {
10776        return secure_outbound_bytes(rt, bytes);
10777    }
10778    let subs = walk_submessages(bytes);
10779    let mut out = Vec::with_capacity(bytes.len() + 64);
10780    out.extend_from_slice(&bytes[..20]);
10781    for (id, start, total) in subs {
10782        let submsg = &bytes[start..start + total];
10783        if id == SMID_DATA {
10784            // Protect the secure-WLP DATA with the per-endpoint key of the secure-WLP writer
10785            // (ff0200c2) — the same key ZeroDDS sends the peer via the
10786            // datawriter_crypto_token (prepare_endpoint_crypto_tokens
10787            // liveliness block). encode_data_datawriter_local took the participant
10788            // key, which cyclone does NOT associate with ff0200c2 -> undecodable ->
10789            // no liveliness -> peer approval of the user endpoints hangs.
10790            let sec = writer_eid_in_submessage(submsg, id)
10791                .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
10792                .and_then(|h| gate.encode_data_datawriter_by_handle(h, submsg).ok());
10793            match sec {
10794                Some(s) => out.extend_from_slice(&s),
10795                None => return None,
10796            }
10797        } else {
10798            out.extend_from_slice(submsg);
10799        }
10800    }
10801    // Under additional rtps_protection, message-level SRTPS MUST go around the
10802    // liveliness-SEC-wrapped WLP (both layers, like cyclone<->cyclone) —
10803    // otherwise cyclone would see only the SRTPS shell OR (with the old logic) the
10804    // clear DATA. First inner SEC (above), then SRTPS (here).
10805    if rtps != ProtectionLevel::None {
10806        return gate
10807            .transform_outbound(&out)
10808            .ok()
10809            .map(alloc::borrow::Cow::Owned);
10810    }
10811    Some(alloc::borrow::Cow::Owned(out))
10812}
10813
10814#[cfg(not(feature = "security"))]
10815fn protect_wlp_outbound<'a>(
10816    rt: &DcpsRuntime,
10817    bytes: &'a [u8],
10818) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10819    secure_outbound_bytes(rt, bytes)
10820}
10821
10822/// Wire demux for the security builtin topics. Routes an
10823/// incoming RTPS submessage sequence to the `SecurityBuiltinStack`,
10824/// if the stack is active. No-op if the datagram does not address a security
10825/// builtin reader or the plugin is not enabled.
10826///
10827/// Called by the metatraffic receive path — stateless +
10828/// VolatileSecure run over the SPDP unicast locators (PID 0x0032),
10829/// not over `user_unicast`.
10830fn dispatch_security_builtin_datagram(
10831    rt: &Arc<DcpsRuntime>,
10832    bytes: &[u8],
10833    now: Duration,
10834) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
10835    // `mut` only needed on the security path (the handshake reply is appended
10836    // there); without the feature the list stays empty.
10837    #[cfg(feature = "security")]
10838    let mut outbound = Vec::new();
10839    #[cfg(not(feature = "security"))]
10840    let outbound = Vec::new();
10841    let Some(stack) = rt.security_builtin_snapshot() else {
10842        return outbound;
10843    };
10844    // Cross-vendor VolatileSecure: cyclone protects the volatile DATA as a
10845    // SEC_PREFIX/SEC_BODY/SEC_POSTFIX sequence. Before the submessage parse,
10846    // transform the sequence with the sender's Kx key (GuidPrefix = RTPS header bytes[8..20])
10847    // back to the original DATA submessage. `None` = no SEC_*
10848    // sequence (normal path) resp. crypto error.
10849    #[cfg(feature = "security")]
10850    let unprotected: Option<Vec<u8>> = if bytes.len() >= 20 {
10851        let mut pk = [0u8; 12];
10852        pk.copy_from_slice(&bytes[8..20]);
10853        unprotect_volatile_datagram(rt, bytes, &pk)
10854    } else {
10855        None
10856    };
10857    #[cfg(feature = "security")]
10858    let bytes: &[u8] = unprotected.as_deref().unwrap_or(bytes);
10859    let Ok(parsed) = decode_datagram(bytes) else {
10860        return outbound;
10861    };
10862    // sourceGuidPrefix of the datagram (DDSI-RTPS §8.3.4) — reader demux key for
10863    // the volatile builtin readers. Used in both feature configs.
10864    let remote_prefix = parsed.header.guid_prefix;
10865    let Ok(mut s) = stack.lock() else {
10866        return outbound;
10867    };
10868    for sub in parsed.submessages {
10869        match sub {
10870            ParsedSubmessage::Data(d) => {
10871                if d.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
10872                    || d.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
10873                {
10874                    // FU2 Gap 5: decode the stateless auth and — with
10875                    // an active auth plugin — drive the handshake.
10876                    // `on_stateless_message` returns the next token
10877                    // message (reply/final), which we send back to the peer.
10878                    // Decode errors are swallowed (stateless
10879                    // has no resend path, Spec §10.3.4.1). The
10880                    // completion `(remote_identity, secret)` is stored in the stack
10881                    // (peer_secret) — the gate registration +
10882                    // crypto-token exchange follows in Gap 6.
10883                    if let Ok(msg) = s.stateless_reader.handle_data(&d) {
10884                        #[cfg(feature = "security")]
10885                        s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
10886                        #[cfg(feature = "security")]
10887                        if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, &msg) {
10888                            outbound.extend(out);
10889                            // FU2 S1.4: handshake done → register Kx +
10890                            // send the Kx-encrypted data token to the peer over Volatile-
10891                            // Secure. (the pki lock is free here:
10892                            // on_stateless_message released it.)
10893                            if let Some((remote_identity, secret)) = completed {
10894                                if let Some(token_msg) =
10895                                    prepare_crypto_token(rt, remote_prefix, remote_identity, secret)
10896                                {
10897                                    outbound.extend(protect_volatile_outbound(
10898                                        rt,
10899                                        remote_prefix,
10900                                        s.volatile_writer
10901                                            .write_with_heartbeat(&token_msg, now)
10902                                            .unwrap_or_default(),
10903                                    ));
10904                                }
10905                                // Step 6b: per-endpoint datawriter/datareader
10906                                // tokens (per-token dedup #29: the builtins go out
10907                                // here exactly once + are marked).
10908                                let already = rt
10909                                    .endpoint_tokens_sent
10910                                    .read()
10911                                    .map(|set| set.clone())
10912                                    .unwrap_or_default();
10913                                let pending = pending_endpoint_tokens(
10914                                    prepare_endpoint_crypto_tokens(rt, remote_prefix),
10915                                    &already,
10916                                );
10917                                for ep_msg in pending {
10918                                    let key = endpoint_token_key(&ep_msg);
10919                                    outbound.extend(protect_volatile_outbound(
10920                                        rt,
10921                                        remote_prefix,
10922                                        s.volatile_writer
10923                                            .write_with_heartbeat(&ep_msg, now)
10924                                            .unwrap_or_default(),
10925                                    ));
10926                                    if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10927                                        set.insert(key);
10928                                    }
10929                                }
10930                            }
10931                        }
10932                        #[cfg(not(feature = "security"))]
10933                        let _ = msg;
10934                    }
10935                } else if d.reader_id
10936                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10937                {
10938                    // FU2 S1.4: VolatileSecure carries the crypto-token
10939                    // exchange. Kx-decrypt the received PARTICIPANT_CRYPTO_TOKENS
10940                    // message + install the data key in the gate.
10941                    if let Ok(_msgs) = s.volatile_reader.handle_data(remote_prefix, &d) {
10942                        #[cfg(feature = "security")]
10943                        for m in &_msgs {
10944                            install_crypto_token(rt, remote_prefix, m);
10945                        }
10946                        // Step 6b: now (peer ready) send our per-endpoint
10947                        // tokens back. Per-token dedup (#29): builtins
10948                        // go out early here, the later-matching user-
10949                        // endpoint tokens are caught up by the tick path (no per-peer
10950                        // guard that blocks them forever).
10951                        #[cfg(feature = "security")]
10952                        {
10953                            let already = rt
10954                                .endpoint_tokens_sent
10955                                .read()
10956                                .map(|set| set.clone())
10957                                .unwrap_or_default();
10958                            let pending = pending_endpoint_tokens(
10959                                prepare_endpoint_crypto_tokens(rt, remote_prefix),
10960                                &already,
10961                            );
10962                            for ep_msg in pending {
10963                                let key = endpoint_token_key(&ep_msg);
10964                                outbound.extend(protect_volatile_outbound(
10965                                    rt,
10966                                    remote_prefix,
10967                                    s.volatile_writer
10968                                        .write_with_heartbeat(&ep_msg, now)
10969                                        .unwrap_or_default(),
10970                                ));
10971                                if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10972                                    set.insert(key);
10973                                }
10974                            }
10975                        }
10976                        // The peer now has our participant crypto token (can
10977                        // decode our SRTPS/SEC SEDP): catch up the initially dropped
10978                        // SEDP burst once (OpenDDS convergence).
10979                        #[cfg(feature = "security")]
10980                        rt.re_announce_sedp_to_peer(remote_prefix);
10981                    }
10982                }
10983            }
10984            ParsedSubmessage::DataFrag(df) => {
10985                if df.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
10986                    || df.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
10987                {
10988                    // FU2 cross-vendor: cyclone/FastDDS RTPS-fragment the
10989                    // large HandshakeReply/Final (cert + permissions over
10990                    // MTU). Reassemble the fragments + drive them through the
10991                    // handshake driver like a stateless DATA.
10992                    if let Ok(msgs) = s.stateless_reader.handle_data_frag(&df) {
10993                        #[cfg(feature = "security")]
10994                        s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
10995                        #[cfg(feature = "security")]
10996                        for msg in &msgs {
10997                            if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, msg)
10998                            {
10999                                outbound.extend(out);
11000                                if let Some((remote_identity, secret)) = completed {
11001                                    if let Some(token_msg) = prepare_crypto_token(
11002                                        rt,
11003                                        remote_prefix,
11004                                        remote_identity,
11005                                        secret,
11006                                    ) {
11007                                        outbound.extend(protect_volatile_outbound(
11008                                            rt,
11009                                            remote_prefix,
11010                                            s.volatile_writer
11011                                                .write_with_heartbeat(&token_msg, now)
11012                                                .unwrap_or_default(),
11013                                        ));
11014                                    }
11015                                    let already = rt
11016                                        .endpoint_tokens_sent
11017                                        .read()
11018                                        .map(|set| set.clone())
11019                                        .unwrap_or_default();
11020                                    let pending = pending_endpoint_tokens(
11021                                        prepare_endpoint_crypto_tokens(rt, remote_prefix),
11022                                        &already,
11023                                    );
11024                                    for ep_msg in pending {
11025                                        let key = endpoint_token_key(&ep_msg);
11026                                        outbound.extend(protect_volatile_outbound(
11027                                            rt,
11028                                            remote_prefix,
11029                                            s.volatile_writer
11030                                                .write_with_heartbeat(&ep_msg, now)
11031                                                .unwrap_or_default(),
11032                                        ));
11033                                        if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
11034                                            set.insert(key);
11035                                        }
11036                                    }
11037                                }
11038                            }
11039                        }
11040                        #[cfg(not(feature = "security"))]
11041                        let _ = msgs;
11042                    }
11043                } else if df.reader_id
11044                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
11045                {
11046                    let _ = s.volatile_reader.handle_data_frag(remote_prefix, &df, now);
11047                }
11048            }
11049            ParsedSubmessage::Heartbeat(h) => {
11050                let to_volatile_reader = h.reader_id
11051                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
11052                    || (h.reader_id == EntityId::UNKNOWN
11053                        && h.writer_id
11054                            == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER);
11055                if to_volatile_reader {
11056                    s.volatile_reader.handle_heartbeat(remote_prefix, &h, now);
11057                }
11058            }
11059            ParsedSubmessage::Gap(g) => {
11060                if g.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
11061                    let _ = s.volatile_reader.handle_gap(remote_prefix, &g);
11062                }
11063            }
11064            ParsedSubmessage::AckNack(ack) => {
11065                if ack.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
11066                    let base = ack.reader_sn_state.bitmap_base;
11067                    let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
11068                    let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
11069                    s.volatile_writer.handle_acknack(src, base, requested);
11070                }
11071            }
11072            ParsedSubmessage::NackFrag(nf) => {
11073                if nf.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
11074                    let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
11075                    s.volatile_writer.handle_nackfrag(src, &nf);
11076                }
11077            }
11078            _ => {}
11079        }
11080    }
11081    outbound
11082}
11083
11084/// Dispatches a datagram addressed to the TypeLookup service endpoints
11085/// (XTypes 1.3 §7.6.3.3.4). Handles incoming
11086/// requests (to `TL_SVC_REQ_READER`), generates replies and sends
11087/// them back to the source locator; handles incoming replies
11088/// (to `TL_SVC_REPLY_READER`), correlates with the client.
11089///
11090/// Returns `true` if the datagram was accepted by the TypeLookup path
11091/// — the caller can then skip the user-reader path.
11092fn dispatch_type_lookup_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], source: &Locator) -> bool {
11093    use zerodds_cdr::{BufferReader, Endianness};
11094    use zerodds_rtps::inline_qos::{SampleIdentityBytes, find_related_sample_identity};
11095    use zerodds_types::type_lookup::{
11096        GetTypeDependenciesReply, GetTypeDependenciesRequest, GetTypesReply, GetTypesRequest,
11097    };
11098
11099    let Ok(parsed) = decode_datagram(bytes) else {
11100        return false;
11101    };
11102    // DDS-RPC §7.8.2: the request sample identity = (request writer GUID,
11103    // request SN). The server carries it as PID_RELATED_SAMPLE_IDENTITY in the
11104    // reply inline QoS, so a client (also cross-vendor) can correlate
11105    // without relying on the echoed writer_sn.
11106    let src_prefix = parsed.header.guid_prefix;
11107
11108    let mut accepted = false;
11109
11110    for sub in &parsed.submessages {
11111        let ParsedSubmessage::Data(d) = sub else {
11112            continue;
11113        };
11114        let payload: &[u8] = &d.serialized_payload;
11115        if payload.is_empty() {
11116            continue;
11117        }
11118        // Skip CDR-Encapsulation header (4 bytes) if present.
11119        let body: &[u8] = if payload.len() >= 4 && (payload[0] == 0x00 && payload[1] == 0x01) {
11120            &payload[4..]
11121        } else {
11122            payload
11123        };
11124
11125        // Inbound Request → Server.
11126        if d.reader_id == EntityId::TL_SVC_REQ_READER {
11127            accepted = true;
11128            // Request sample identity = (request writer GUID, request SN) — mirrored
11129            // as related_sample_identity into the reply inline QoS.
11130            let (sn_hi, sn_lo) = d.writer_sn.split();
11131            let req_sn = ((u64::from(sn_hi as u32)) << 32) | u64::from(sn_lo);
11132            let related =
11133                SampleIdentityBytes::new(Guid::new(src_prefix, d.writer_id).to_bytes(), req_sn);
11134            // Try GetTypes-Request first; fall back to
11135            // GetTypeDependenciesRequest if that fails.
11136            let mut r = BufferReader::new(body, Endianness::Little);
11137            if let Ok(req) = GetTypesRequest::decode_from(&mut r) {
11138                let reply = match rt.type_lookup_server.lock() {
11139                    Ok(g) => g.handle_get_types(&req),
11140                    Err(_) => continue,
11141                };
11142                let _ = send_type_lookup_reply(
11143                    rt,
11144                    source,
11145                    TypeLookupReplyPayload::Types(reply),
11146                    related,
11147                );
11148                continue;
11149            }
11150            let mut r = BufferReader::new(body, Endianness::Little);
11151            if let Ok(req) = GetTypeDependenciesRequest::decode_from(&mut r) {
11152                let reply = match rt.type_lookup_server.lock() {
11153                    Ok(g) => g.handle_get_type_dependencies(&req),
11154                    Err(_) => continue,
11155                };
11156                let _ = send_type_lookup_reply(
11157                    rt,
11158                    source,
11159                    TypeLookupReplyPayload::Dependencies(reply),
11160                    related,
11161                );
11162                continue;
11163            }
11164        }
11165
11166        // Inbound Reply → Client.
11167        if d.reader_id == EntityId::TL_SVC_REPLY_READER {
11168            accepted = true;
11169            // Correlation prefers PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2,
11170            // cross-vendor compatible); fallback to the echoed writer_sn for
11171            // peers/legacy replies without inline QoS.
11172            let request_id = d
11173                .inline_qos
11174                .as_ref()
11175                .and_then(|pl| find_related_sample_identity(pl, true).ok().flatten())
11176                .map(|sid| zerodds_discovery::type_lookup::RequestId::from_u64(sid.sequence_number))
11177                .unwrap_or_else(|| {
11178                    let (sn_high, sn_low) = d.writer_sn.split();
11179                    let sn_u64 = ((u64::from(sn_high as u32)) << 32) | u64::from(sn_low);
11180                    zerodds_discovery::type_lookup::RequestId::from_u64(sn_u64)
11181                });
11182            let mut r = BufferReader::new(body, Endianness::Little);
11183            if let Ok(reply) = GetTypesReply::decode_from(&mut r) {
11184                if let Ok(mut client) = rt.type_lookup_client.lock() {
11185                    client.handle_reply(request_id, TypeLookupReply::Types(reply));
11186                }
11187                continue;
11188            }
11189            // M-5: the getTypeDependencies reply carries a different element type
11190            // (TypeIdentifierWithSize list) — its own decode branch, otherwise the
11191            // dependencies callback never fires.
11192            let mut r = BufferReader::new(body, Endianness::Little);
11193            if let Ok(reply) = GetTypeDependenciesReply::decode_from(&mut r) {
11194                if let Ok(mut client) = rt.type_lookup_client.lock() {
11195                    client.handle_reply(request_id, TypeLookupReply::Dependencies(reply));
11196                }
11197                continue;
11198            }
11199        }
11200    }
11201
11202    accepted
11203}
11204
11205/// Reply payload variants that the TypeLookup server can emit.
11206enum TypeLookupReplyPayload {
11207    Types(zerodds_types::type_lookup::GetTypesReply),
11208    Dependencies(zerodds_types::type_lookup::GetTypeDependenciesReply),
11209}
11210
11211/// Sends a TypeLookup reply to a peer locator as a
11212/// DATA datagram on the TL_SVC_REPLY_WRITER → peer's
11213/// TL_SVC_REPLY_READER. The sequence number echoes the request sequence
11214/// for correlation purposes (see XTypes §7.6.3.3.3 sample identity).
11215fn send_type_lookup_reply(
11216    rt: &Arc<DcpsRuntime>,
11217    target: &Locator,
11218    reply: TypeLookupReplyPayload,
11219    related: zerodds_rtps::inline_qos::SampleIdentityBytes,
11220) -> Result<()> {
11221    use alloc::sync::Arc as AllocArc;
11222    use core::sync::atomic::Ordering;
11223    use zerodds_cdr::{BufferWriter, Endianness};
11224    use zerodds_rtps::datagram::encode_data_datagram;
11225    use zerodds_rtps::header::RtpsHeader;
11226    use zerodds_rtps::submessages::DataSubmessage;
11227    use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber, VendorId};
11228
11229    // CDR-encode reply (PL_CDR_LE-Encapsulation).
11230    let mut w = BufferWriter::new(Endianness::Little);
11231    match reply {
11232        TypeLookupReplyPayload::Types(r) => {
11233            r.encode_into(&mut w)
11234                .map_err(|_| DdsError::PreconditionNotMet {
11235                    reason: "type_lookup reply encode failed",
11236                })?;
11237        }
11238        TypeLookupReplyPayload::Dependencies(r) => {
11239            r.encode_into(&mut w)
11240                .map_err(|_| DdsError::PreconditionNotMet {
11241                    reason: "type_lookup deps reply encode failed",
11242                })?;
11243        }
11244    }
11245    let body = w.into_bytes();
11246    let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
11247    payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
11248    payload.extend_from_slice(&body);
11249
11250    let header = RtpsHeader {
11251        protocol_version: ProtocolVersion::CURRENT,
11252        vendor_id: VendorId::ZERODDS,
11253        guid_prefix: rt.guid_prefix,
11254    };
11255    // Own monotonically increasing reply-writer SN (starting at 1) instead of a
11256    // request-SN echo — a reliable cross-vendor reply reader would otherwise see SN jumps.
11257    let reply_sn = rt
11258        .tl_reply_sn
11259        .fetch_add(1, Ordering::Relaxed)
11260        .wrapping_add(1);
11261    let writer_sn =
11262        SequenceNumber::from_high_low((reply_sn >> 32) as i32, (reply_sn & 0xFFFF_FFFF) as u32);
11263    let data = DataSubmessage {
11264        extra_flags: 0,
11265        reader_id: EntityId::TL_SVC_REPLY_READER,
11266        writer_id: EntityId::TL_SVC_REPLY_WRITER,
11267        writer_sn,
11268        // DDS-RPC §7.8.2: related_sample_identity couples the reply to the
11269        // request (cross-vendor correlation without a writer_sn echo).
11270        inline_qos: Some(zerodds_rtps::inline_qos::reply_inline_qos(related, true)),
11271        key_flag: false,
11272        non_standard_flag: false,
11273        serialized_payload: AllocArc::from(payload.into_boxed_slice()),
11274    };
11275    let datagram =
11276        encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
11277            reason: "type_lookup reply datagram encode failed",
11278        })?;
11279
11280    if is_routable_user_locator(target) {
11281        let _ = rt.user_unicast.send(target, &datagram);
11282    }
11283    Ok(())
11284}
11285
11286/// Sends a discovery datagram to all target locators. UDP-only
11287/// (TCPv4/SHM/UDS are not carried in discovery); non-UDP
11288/// locators are silently ignored.
11289fn send_discovery_datagram(rt: &Arc<DcpsRuntime>, targets: &[Locator], bytes: &[u8]) {
11290    let Some(secured) = secure_outbound_bytes(rt, bytes) else {
11291        return;
11292    };
11293    for t in targets {
11294        if !is_routable_user_locator(t) {
11295            continue;
11296        }
11297        // Send unicast metatraffic (SEDP responses, VolatileSecure, stateless auth)
11298        // from the **metatraffic recv socket** (`spdp_unicast`, = announced
11299        // metatraffic_unicast_locator), NOT from the ephemeral `spdp_mc_tx`.
11300        // Otherwise the peer sees a foreign source port and sends its
11301        // responses (e.g. cyclone's VolatileSecure ACKNACK to the source locator)
11302        // to a port ZeroDDS does not listen on → reliable resends stay
11303        // out (cross-vendor). `spdp_mc_tx` stays only for SPDP multicast.
11304        let _ = rt.spdp_unicast.send(t, &secured);
11305    }
11306}
11307
11308/// Default user-multicast locator for a DomainParticipant.
11309/// Not used in live mode 1 yet; SPDP-announced in B2.
11310#[must_use]
11311pub fn user_multicast_endpoint(domain_id: i32) -> SocketAddr {
11312    // Spec §9.6.1.4.1: user-multicast-port = PB + DG * d + d2
11313    //   = 7400 + 250 * d + 1
11314    let port = 7400u16.saturating_add(250u16.saturating_mul(domain_id as u16).saturating_add(1));
11315    SocketAddr::from((Ipv4Addr::from([239, 255, 0, 1]), port))
11316}
11317
11318#[cfg(test)]
11319#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
11320mod tests {
11321    use super::*;
11322
11323    /// A's the last mile of the big-endian decode path: a big-endian
11324    /// encapsulation header on a received DATA sample must route the typed
11325    /// decode to `DdsType::decode_be`, not `decode`. Builds a CDR2_BE wire
11326    /// sample, runs it through `delivered_to_user_sample` (the real recv→sample
11327    /// conversion), and asserts the resulting `big_endian` flag drives a correct
11328    /// decode — while the little-endian `decode` on the same BE body is wrong.
11329    #[test]
11330    fn big_endian_encap_routes_to_decode_be() {
11331        use crate::dds_type::{DdsType, DecodeError, EncodeError};
11332        #[derive(Debug, PartialEq, Clone)]
11333        struct BeProbe {
11334            v: i32,
11335        }
11336        impl DdsType for BeProbe {
11337            const TYPE_NAME: &'static str = "BeProbe";
11338            fn encode(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
11339                let mut w = zerodds_cdr::BufferWriter::new(zerodds_cdr::Endianness::Little).xcdr2();
11340                <i32 as zerodds_cdr::CdrEncode>::encode(&self.v, &mut w)?;
11341                out.extend_from_slice(&w.into_bytes());
11342                Ok(())
11343            }
11344            fn encode_be(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
11345                let mut w = zerodds_cdr::BufferWriter::new(zerodds_cdr::Endianness::Big).xcdr2();
11346                <i32 as zerodds_cdr::CdrEncode>::encode(&self.v, &mut w)?;
11347                out.extend_from_slice(&w.into_bytes());
11348                Ok(())
11349            }
11350            fn decode(b: &[u8]) -> core::result::Result<Self, DecodeError> {
11351                let mut r =
11352                    zerodds_cdr::BufferReader::new(b, zerodds_cdr::Endianness::Little).xcdr2();
11353                Ok(BeProbe {
11354                    v: <i32 as zerodds_cdr::CdrDecode>::decode(&mut r)?,
11355                })
11356            }
11357            fn decode_be(b: &[u8]) -> core::result::Result<Self, DecodeError> {
11358                let mut r = zerodds_cdr::BufferReader::new(b, zerodds_cdr::Endianness::Big).xcdr2();
11359                Ok(BeProbe {
11360                    v: <i32 as zerodds_cdr::CdrDecode>::decode(&mut r)?,
11361                })
11362            }
11363        }
11364
11365        // A value whose 4 LE bytes differ from its 4 BE bytes.
11366        let orig = BeProbe { v: 0x0102_0304 };
11367        let strengths = alloc::collections::BTreeMap::new();
11368
11369        let mk = |repr_lo: u8, body: Vec<u8>| {
11370            let mut wire = alloc::vec![0x00u8, repr_lo, 0x00, 0x00];
11371            wire.extend_from_slice(&body);
11372            zerodds_rtps::reliable_reader::DeliveredSample {
11373                writer_guid: Guid::new(GuidPrefix::from_bytes([0x11; 12]), EntityId::PARTICIPANT),
11374                sequence_number: zerodds_rtps::wire_types::SequenceNumber(1),
11375                payload: alloc::sync::Arc::from(wire.into_boxed_slice()),
11376                kind: zerodds_rtps::history_cache::ChangeKind::Alive,
11377                key_hash: None,
11378                source_timestamp: None,
11379            }
11380        };
11381
11382        // --- big-endian wire: CDR2_BE (repr low byte 0x06) ---
11383        let mut be_body = Vec::new();
11384        orig.encode_be(&mut be_body).unwrap();
11385        let us = delivered_to_user_sample(&mk(0x06, be_body), &strengths).expect("alive");
11386        let UserSample::Alive {
11387            payload,
11388            big_endian,
11389            representation,
11390            ..
11391        } = us
11392        else {
11393            panic!("expected Alive");
11394        };
11395        assert!(big_endian, "CDR2_BE encap must set big_endian");
11396        assert_eq!(representation, 1, "0x06 = XCDR2");
11397        // The subscriber dispatch: decode_be for a big-endian sample.
11398        let decoded = if big_endian {
11399            BeProbe::decode_be(&payload)
11400        } else {
11401            BeProbe::decode(&payload)
11402        }
11403        .unwrap();
11404        assert_eq!(decoded, orig, "BE wire decodes correctly via decode_be");
11405        // The dispatch matters: little-endian decode on the BE body is wrong.
11406        assert_ne!(BeProbe::decode(&payload).unwrap(), orig);
11407
11408        // --- little-endian control: CDR2_LE (repr low byte 0x07) ---
11409        let mut le_body = Vec::new();
11410        orig.encode(&mut le_body).unwrap();
11411        let us_le = delivered_to_user_sample(&mk(0x07, le_body), &strengths).expect("alive");
11412        let UserSample::Alive {
11413            payload: le_payload,
11414            big_endian: be_le,
11415            ..
11416        } = us_le
11417        else {
11418            panic!("expected Alive");
11419        };
11420        assert!(!be_le, "CDR2_LE encap must NOT set big_endian");
11421        assert_eq!(BeProbe::decode(&le_payload).unwrap(), orig);
11422    }
11423
11424    /// FU1 diagnosis: inject a REAL FastDDS-3.6 SPDP datagram (domain 205,
11425    /// codepit capture 2026-05-29) directly into handle_spdp_datagram
11426    /// — does the runtime register FastDDS as a peer? Separates the
11427    /// receive problem (socket) from the handle problem (parse/insert/filter).
11428    #[test]
11429    fn handle_spdp_registers_real_fastdds_participant() {
11430        fn hx(s: &str) -> Vec<u8> {
11431            (0..s.len())
11432                .step_by(2)
11433                .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
11434                .collect()
11435        }
11436        const FASTDDS_SPDP: &str = "525450530203010f010fa72bbbebc90100000000090108006850196a57e882b41505b80100001000000100c7000100c2000000000100000000030000150004000203000016000400010f000000800400030601000f000400cd00000050001000010fa72bbbebc90100000000000001c107800400010000000380280021000000653830653630353335646339343432636231306537323239313038653135666600000000320018000100000024e50000000000000000000000000000c0a8b273310018000100000025e50000000000000000000000000000c0a8b273020008001400000000000000580004003ffc0f006200140010000000525450535061727469636970616e74005900cc0004000000110000005041525449434950414e545f54595045000000000700000053494d504c4500001b000000666173746464732e706879736963616c5f646174612e686f73740000210000006538306536303533356463393434326362313065373232393130386531356666000000001b000000666173746464732e706879736963616c5f646174612e75736572000005000000726f6f74000000001e000000666173746464732e706879736963616c5f646174612e70726f636573730000000600000036303334370000000100000080013800010000001ae50000000000000000000000000000efff00016850196a72ca8cb4020000000000000030040000000000000000000000000000";
11437        let bytes = hx(FASTDDS_SPDP);
11438        let prefix = GuidPrefix::from_bytes([0x99; 12]);
11439        let rt =
11440            Arc::new(DcpsRuntime::start(205, prefix, RuntimeConfig::default()).expect("rt start"));
11441        assert_eq!(rt.discovered_participants().len(), 0, "fresh: no peers");
11442        handle_spdp_datagram_for_test(&rt, &bytes);
11443        let n = rt.discovered_participants().len();
11444        assert_eq!(
11445            n, 1,
11446            "FastDDS must be registered after handle_spdp_datagram (got {n})"
11447        );
11448    }
11449
11450    #[test]
11451    fn select_user_transport_tcpv4_yields_tcpv4_locator() {
11452        let prefix = GuidPrefix::from_bytes([1u8; 12]);
11453        let (t, accept) =
11454            select_user_transport(UserTransportKind::TcpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
11455                .expect("TcpV4 transport");
11456        assert_eq!(t.local_locator().kind, LocatorKind::Tcpv4);
11457        assert!(accept.is_some(), "TCP needs an accept handle");
11458    }
11459
11460    #[test]
11461    fn select_user_transport_udpv4_default_kind() {
11462        let prefix = GuidPrefix::from_bytes([2u8; 12]);
11463        let (t, accept) =
11464            select_user_transport(UserTransportKind::UdpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
11465                .expect("UdpV4 transport");
11466        assert_eq!(t.local_locator().kind, LocatorKind::UdpV4);
11467        assert!(accept.is_none(), "UDP needs no accept handle");
11468    }
11469
11470    #[cfg(feature = "same-host-uds")]
11471    #[test]
11472    fn select_user_transport_uds_yields_uds_locator() {
11473        let prefix = GuidPrefix::from_bytes([3u8; 12]);
11474        let (t, accept) =
11475            select_user_transport(UserTransportKind::Uds, prefix, 0, Ipv4Addr::UNSPECIFIED)
11476                .expect("Uds transport");
11477        assert_eq!(t.local_locator().kind, LocatorKind::Uds);
11478        assert!(accept.is_none(), "UDS needs no accept handle");
11479    }
11480
11481    #[test]
11482    fn strip_user_encap_xcdr2_le() {
11483        let payload = [0x00, 0x07, 0x00, 0x00, 1, 2, 3];
11484        assert_eq!(strip_user_encap(&payload), Some(alloc::vec![1, 2, 3]));
11485    }
11486
11487    #[test]
11488    fn strip_user_encap_xcdr1_le() {
11489        // Cyclone default for simple types.
11490        let payload = [0x00, 0x01, 0x00, 0x00, 0xAA];
11491        assert_eq!(strip_user_encap(&payload), Some(alloc::vec![0xAA]));
11492    }
11493
11494    #[test]
11495    fn strip_user_encap_rejects_unknown_scheme() {
11496        let payload = [0xFF, 0xFF, 0x00, 0x00, 1];
11497        assert_eq!(strip_user_encap(&payload), None);
11498    }
11499
11500    #[test]
11501    fn strip_user_encap_rejects_short() {
11502        assert_eq!(strip_user_encap(&[0x00, 0x07]), None);
11503    }
11504
11505    #[test]
11506    fn user_payload_encap_is_cdr_le() {
11507        // CDR_LE (PLAIN_CDR / XCDR1, Little-Endian) — ehrliche
11508        // Declaration of the body encoding generated by codegen.
11509        assert_eq!(USER_PAYLOAD_ENCAP, [0x00, 0x01, 0x00, 0x00]);
11510    }
11511
11512    #[test]
11513    fn data_repr_offer_str_uses_spec_ids() {
11514        use zerodds_rtps::publication_data::data_representation as dr;
11515        // XCDR1 -> Spec-Id 0 (NICHT 1 = XML); XCDR2 -> 2.
11516        assert_eq!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XCDR]));
11517        assert_eq!(parse_data_repr_offer_str("XCDR2"), Some(vec![dr::XCDR2]));
11518        assert_eq!(parse_data_repr_offer_str("xcdr2"), Some(vec![dr::XCDR2]));
11519        assert_eq!(
11520            parse_data_repr_offer_str("XCDR2,XCDR1"),
11521            Some(vec![dr::XCDR2, dr::XCDR])
11522        );
11523        assert_eq!(parse_data_repr_offer_str("bogus"), None);
11524        assert_eq!(parse_data_repr_offer_str(""), None);
11525        // XCDR1 must NOT map to the XML id (1).
11526        assert_ne!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XML]));
11527    }
11528
11529    /// A DataReader announces every representation it can decode (XCDR2 + XCDR1)
11530    /// — XTypes 1.3 §7.6.2: the default reader policy accepts both. CycloneDDS
11531    /// (and legacy RTI / OpenDDS < 3.16) default their writers to XCDR1 for
11532    /// `@final` types; without XCDR1 in the reader's announced set those writers
11533    /// fail the DataRepresentation RxO check and never deliver. Regression for
11534    /// Bug DR1.
11535    #[test]
11536    fn reader_accept_repr_always_includes_both_representations() {
11537        use zerodds_rtps::publication_data::data_representation as dr;
11538        // Default writer offer [XCDR2] -> reader must also accept XCDR1.
11539        let widened = reader_accept_repr(&[dr::XCDR2]);
11540        assert!(widened.contains(&dr::XCDR2));
11541        assert!(widened.contains(&dr::XCDR));
11542        // XCDR2 stays first (the preferred / generated encoding).
11543        assert_eq!(widened[0], dr::XCDR2);
11544        // Already-both list is preserved (idempotent, order kept).
11545        assert_eq!(
11546            reader_accept_repr(&[dr::XCDR, dr::XCDR2]),
11547            alloc::vec![dr::XCDR, dr::XCDR2]
11548        );
11549        // Empty config still yields both.
11550        let from_empty = reader_accept_repr(&[]);
11551        assert!(from_empty.contains(&dr::XCDR2) && from_empty.contains(&dr::XCDR));
11552    }
11553
11554    #[test]
11555    fn user_payload_encap_maps_repr_and_extensibility() {
11556        use zerodds_rtps::publication_data::data_representation as dr;
11557        use zerodds_types::qos::ExtensibilityForRepr as Ext;
11558        // DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 Encapsulation-IDs
11559        // (2-byte repr-id BE + 2-byte options=0), little-endian variant:
11560        //   XCDR1 final/appendable -> CDR_LE        0x0001
11561        //   XCDR1 mutable          -> PL_CDR_LE      0x0003
11562        //   XCDR2 final            -> PLAIN_CDR2_LE  0x0007
11563        //   XCDR2 appendable       -> D_CDR2_LE      0x0009
11564        //   XCDR2 mutable          -> PL_CDR2_LE     0x000b
11565        assert_eq!(
11566            user_payload_encap(dr::XCDR, Ext::Final, false),
11567            [0x00, 0x01, 0x00, 0x00]
11568        );
11569        assert_eq!(
11570            user_payload_encap(dr::XCDR, Ext::Appendable, false),
11571            [0x00, 0x01, 0x00, 0x00]
11572        );
11573        assert_eq!(
11574            user_payload_encap(dr::XCDR, Ext::Mutable, false),
11575            [0x00, 0x03, 0x00, 0x00]
11576        );
11577        assert_eq!(
11578            user_payload_encap(dr::XCDR2, Ext::Final, false),
11579            [0x00, 0x07, 0x00, 0x00]
11580        );
11581        assert_eq!(
11582            user_payload_encap(dr::XCDR2, Ext::Appendable, false),
11583            [0x00, 0x09, 0x00, 0x00]
11584        );
11585        assert_eq!(
11586            user_payload_encap(dr::XCDR2, Ext::Mutable, false),
11587            [0x00, 0x0b, 0x00, 0x00]
11588        );
11589        // The default const is exactly the (XCDR1, Final) case.
11590        assert_eq!(
11591            user_payload_encap(dr::XCDR, Ext::Final, false),
11592            USER_PAYLOAD_ENCAP
11593        );
11594        // Unknown/XML repr falls back safely to CDR_LE.
11595        assert_eq!(
11596            user_payload_encap(dr::XML, Ext::Final, false),
11597            [0x00, 0x01, 0x00, 0x00]
11598        );
11599        // big_endian=true selects the `_BE` variant (the even predecessor of
11600        // the odd `_LE` id): CDR_BE 0x00, PL_CDR_BE 0x02, PLAIN_CDR2_BE 0x06,
11601        // D_CDR2_BE 0x08, PL_CDR2_BE 0x0a (RTPS 2.5 §10.5). Used by the
11602        // durability service to replay a big-endian peer's stored sample.
11603        assert_eq!(
11604            user_payload_encap(dr::XCDR, Ext::Final, true),
11605            [0x00, 0x00, 0x00, 0x00]
11606        );
11607        assert_eq!(
11608            user_payload_encap(dr::XCDR, Ext::Mutable, true),
11609            [0x00, 0x02, 0x00, 0x00]
11610        );
11611        assert_eq!(
11612            user_payload_encap(dr::XCDR2, Ext::Final, true),
11613            [0x00, 0x06, 0x00, 0x00]
11614        );
11615        assert_eq!(
11616            user_payload_encap(dr::XCDR2, Ext::Appendable, true),
11617            [0x00, 0x08, 0x00, 0x00]
11618        );
11619        assert_eq!(
11620            user_payload_encap(dr::XCDR2, Ext::Mutable, true),
11621            [0x00, 0x0a, 0x00, 0x00]
11622        );
11623    }
11624
11625    #[test]
11626    fn observability_sink_records_writer_and_reader_creation() {
11627        // VecSink injizieren, Writer + Reader erzeugen,
11628        // check that both events arrive.
11629        use std::sync::Arc as StdArc;
11630        use zerodds_foundation::observability::{Component, Level, VecSink};
11631
11632        let sink = StdArc::new(VecSink::new());
11633        let cfg = RuntimeConfig {
11634            observability: sink.clone(),
11635            ..RuntimeConfig::default()
11636        };
11637        let rt =
11638            DcpsRuntime::start(7, GuidPrefix::from_bytes([0xAA; 12]), cfg).expect("start runtime");
11639        let _ = rt.register_user_writer(UserWriterConfig {
11640            topic_name: "ObsTopic".into(),
11641            type_name: "ObsType".into(),
11642            reliable: true,
11643            durability: zerodds_qos::DurabilityKind::Volatile,
11644            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11645            lifespan: zerodds_qos::LifespanQosPolicy::default(),
11646            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11647            ownership: zerodds_qos::OwnershipKind::Shared,
11648            ownership_strength: 0,
11649            partition: alloc::vec![],
11650            user_data: alloc::vec![],
11651            topic_data: alloc::vec![],
11652            group_data: alloc::vec![],
11653            type_identifier: zerodds_types::TypeIdentifier::None,
11654            data_representation_offer: None,
11655        });
11656        let _ = rt.register_user_reader(UserReaderConfig {
11657            topic_name: "ObsTopic".into(),
11658            type_name: "ObsType".into(),
11659            reliable: true,
11660            durability: zerodds_qos::DurabilityKind::Volatile,
11661            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11662            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11663            ownership: zerodds_qos::OwnershipKind::Shared,
11664            partition: alloc::vec![],
11665            user_data: alloc::vec![],
11666            topic_data: alloc::vec![],
11667            group_data: alloc::vec![],
11668            type_identifier: zerodds_types::TypeIdentifier::None,
11669            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11670            data_representation_offer: None,
11671        });
11672        rt.shutdown();
11673
11674        let events = sink.snapshot();
11675        assert!(
11676            events.iter().any(|e| e.name == "user_writer.created"
11677                && e.component == Component::Dcps
11678                && e.level == Level::Info),
11679            "writer-event missing: got {:?}",
11680            events.iter().map(|e| e.name).collect::<Vec<_>>()
11681        );
11682        assert!(
11683            events
11684                .iter()
11685                .any(|e| e.name == "user_reader.created" && e.component == Component::Dcps),
11686            "reader-event missing"
11687        );
11688        // The topic attribute must hang on the writer.created event.
11689        let writer_event = events
11690            .iter()
11691            .find(|e| e.name == "user_writer.created")
11692            .expect("writer event");
11693        assert!(
11694            writer_event
11695                .attrs
11696                .iter()
11697                .any(|a| a.key == "topic" && a.value == "ObsTopic"),
11698            "topic attr missing"
11699        );
11700    }
11701
11702    #[test]
11703    fn user_endpoint_entity_kind_follows_keyedness() {
11704        // Regression (ROS-2 cross-vendor): the entityKind of a user
11705        // endpoint MUST follow the type keyedness (Spec §9.3.1.2). A
11706        // a keyless type yields NoKey (Writer 0x03 / Reader 0x04), a
11707        // keyed type WithKey (0x02 / 0x07). If this does not match the
11708        // peer, CycloneDDS/ROS 2 silently rejects the endpoint match
11709        // (DDS_INVALID_QOS_POLICY_ID, no log). create_datawriter/
11710        // create_datareader derive `is_keyed` from `DdsType::HAS_KEY`.
11711        use zerodds_rtps::wire_types::EntityKind;
11712        let rt = DcpsRuntime::start(
11713            11,
11714            GuidPrefix::from_bytes([0xBC; 12]),
11715            RuntimeConfig::default(),
11716        )
11717        .expect("start runtime");
11718        let mk_w = || UserWriterConfig {
11719            topic_name: "KindTopic".into(),
11720            type_name: "KindType".into(),
11721            reliable: true,
11722            durability: zerodds_qos::DurabilityKind::Volatile,
11723            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11724            lifespan: zerodds_qos::LifespanQosPolicy::default(),
11725            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11726            ownership: zerodds_qos::OwnershipKind::Shared,
11727            ownership_strength: 0,
11728            partition: alloc::vec![],
11729            user_data: alloc::vec![],
11730            topic_data: alloc::vec![],
11731            group_data: alloc::vec![],
11732            type_identifier: zerodds_types::TypeIdentifier::None,
11733            data_representation_offer: None,
11734        };
11735        let mk_r = || UserReaderConfig {
11736            topic_name: "KindTopic".into(),
11737            type_name: "KindType".into(),
11738            reliable: true,
11739            durability: zerodds_qos::DurabilityKind::Volatile,
11740            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11741            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11742            ownership: zerodds_qos::OwnershipKind::Shared,
11743            partition: alloc::vec![],
11744            user_data: alloc::vec![],
11745            topic_data: alloc::vec![],
11746            group_data: alloc::vec![],
11747            type_identifier: zerodds_types::TypeIdentifier::None,
11748            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11749            data_representation_offer: None,
11750        };
11751        // keyless (HAS_KEY=false) -> NoKey
11752        let w_nokey = rt.register_user_writer_kind(mk_w(), false).expect("writer");
11753        assert_eq!(w_nokey.entity_kind, EntityKind::UserWriterNoKey);
11754        let (r_nokey, _) = rt.register_user_reader_kind(mk_r(), false).expect("reader");
11755        assert_eq!(r_nokey.entity_kind, EntityKind::UserReaderNoKey);
11756        // keyed (HAS_KEY=true) -> WithKey
11757        let w_key = rt.register_user_writer_kind(mk_w(), true).expect("writer");
11758        assert_eq!(w_key.entity_kind, EntityKind::UserWriterWithKey);
11759        let (r_key, _) = rt.register_user_reader_kind(mk_r(), true).expect("reader");
11760        assert_eq!(r_key.entity_kind, EntityKind::UserReaderWithKey);
11761        rt.shutdown();
11762    }
11763
11764    #[test]
11765    fn incompatible_qos_match_emits_loud_warning() {
11766        // C2 "loud instead of silent": an incompatible QoS match is logged as a
11767        // warn event with topic + policy, not silently discarded.
11768        // Setup: writer Volatile + reader TransientLocal on the same
11769        // Topic (reader requests more durability than the writer offers)
11770        // → intra-runtime match fails with policy DURABILITY.
11771        use std::sync::Arc as StdArc;
11772        use zerodds_foundation::observability::{Component, Level, VecSink};
11773
11774        let sink = StdArc::new(VecSink::new());
11775        let cfg_a = RuntimeConfig {
11776            observability: sink.clone(),
11777            tick_period: Duration::from_millis(5),
11778            ..RuntimeConfig::default()
11779        };
11780        let cfg_b = RuntimeConfig {
11781            tick_period: Duration::from_millis(5),
11782            ..RuntimeConfig::default()
11783        };
11784        // Two same-process runtimes, same domain → inproc discovery.
11785        let rt = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCE; 12]), cfg_a)
11786            .expect("start runtime a");
11787        let rt_b = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCF; 12]), cfg_b)
11788            .expect("start runtime b");
11789        let _w = rt
11790            .register_user_writer(UserWriterConfig {
11791                topic_name: "QT".into(),
11792                type_name: "QType".into(),
11793                reliable: false,
11794                durability: zerodds_qos::DurabilityKind::Volatile,
11795                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11796                lifespan: zerodds_qos::LifespanQosPolicy::default(),
11797                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11798                ownership: zerodds_qos::OwnershipKind::Shared,
11799                ownership_strength: 0,
11800                partition: alloc::vec![],
11801                user_data: alloc::vec![],
11802                topic_data: alloc::vec![],
11803                group_data: alloc::vec![],
11804                type_identifier: zerodds_types::TypeIdentifier::None,
11805                data_representation_offer: None,
11806            })
11807            .expect("writer");
11808        let _r = rt_b
11809            .register_user_reader(UserReaderConfig {
11810                topic_name: "QT".into(),
11811                type_name: "QType".into(),
11812                reliable: false,
11813                durability: zerodds_qos::DurabilityKind::TransientLocal,
11814                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11815                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11816                ownership: zerodds_qos::OwnershipKind::Shared,
11817                partition: alloc::vec![],
11818                user_data: alloc::vec![],
11819                topic_data: alloc::vec![],
11820                group_data: alloc::vec![],
11821                type_identifier: zerodds_types::TypeIdentifier::None,
11822                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11823                data_representation_offer: None,
11824            })
11825            .expect("reader");
11826        // Await the match pass.
11827        let mut found = false;
11828        for _ in 0..40 {
11829            std::thread::sleep(Duration::from_millis(25));
11830            let events = sink.snapshot();
11831            if events.iter().any(|e| {
11832                (e.name == "qos.incompatible.offered" || e.name == "qos.incompatible.requested")
11833                    && e.component == Component::Dcps
11834                    && e.level == Level::Warn
11835                    && e.attrs.iter().any(|a| a.key == "topic" && a.value == "QT")
11836                    && e.attrs
11837                        .iter()
11838                        .any(|a| a.key == "policy" && a.value == "DURABILITY")
11839            }) {
11840                found = true;
11841                break;
11842            }
11843        }
11844        rt.shutdown();
11845        rt_b.shutdown();
11846        assert!(
11847            found,
11848            "expected a loud qos.incompatible warn event with policy DURABILITY"
11849        );
11850    }
11851
11852    #[test]
11853    fn spdp_unicast_port_follows_rtps_formula() {
11854        // Spec §9.6.1.4.1: PB + DG*domain + d1 + PG*pid = 7400+250*d+10+2*pid.
11855        assert_eq!(super::spdp_unicast_port(0, 0), 7410);
11856        assert_eq!(spdp_unicast_port(0, 1), 7412);
11857        assert_eq!(spdp_unicast_port(1, 0), 7660);
11858        assert_eq!(spdp_unicast_port(7, 0), 9160);
11859    }
11860
11861    #[test]
11862    fn announce_locator_pins_interface_over_route_probe() {
11863        // Interface pinning: a set interface takes precedence over the
11864        // route probe (multi-homed robustness, cf. Cyclone NetworkInterface).
11865        let udp = UdpTransport::bind_v4(Ipv4Addr::UNSPECIFIED, 0).expect("bind");
11866        let pin = Ipv4Addr::new(10, 11, 12, 13);
11867        let loc = super::announce_locator(&udp, pin);
11868        assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
11869        assert_eq!(loc.address[12..], [10, 11, 12, 13]);
11870        // Without a pin (UNSPECIFIED) → probe/fallback does NOT return the pin IP.
11871        let auto = super::announce_locator(&udp, Ipv4Addr::UNSPECIFIED);
11872        assert_ne!(auto.address[12..], [10, 11, 12, 13]);
11873    }
11874
11875    #[test]
11876    fn expand_initial_peer_ip_only_yields_well_known_port_range() {
11877        let m = super::INITIAL_PEER_MAX_PARTICIPANTS;
11878        let mut out = Vec::new();
11879        super::expand_initial_peer("127.0.0.1", 0, m, &mut out);
11880        assert_eq!(out.len(), m as usize);
11881        assert_eq!(out[0].port, 7410);
11882        assert_eq!(out[1].port, 7412);
11883        // Larger limit → more ports (C1 dense multi-robot scenarios).
11884        let mut wide = Vec::new();
11885        super::expand_initial_peer("127.0.0.1", 0, 30, &mut wide);
11886        assert_eq!(wide.len(), 30);
11887        assert_eq!(wide[29].port, 7410 + 2 * 29);
11888        // ip:port -> exactly one exact locator.
11889        let mut one = Vec::new();
11890        super::expand_initial_peer("10.0.0.5:7410", 0, m, &mut one);
11891        assert_eq!(one.len(), 1);
11892        assert_eq!(one[0].port, 7410);
11893        assert_eq!(one[0].address[12..], [10, 0, 0, 5]);
11894        // Garbage is ignored.
11895        let mut none = Vec::new();
11896        super::expand_initial_peer("not-an-ip", 0, m, &mut none);
11897        assert!(none.is_empty());
11898    }
11899
11900    #[test]
11901    #[ignore = "heavy multi-runtime scaling test (12 runtimes); explicit: cargo test -- --ignored"]
11902    #[allow(clippy::print_stdout)]
11903    fn multicast_free_discovery_scales_to_many_participants() {
11904        // C1 scaling: N participants, each with its own multicast group
11905        // (→ separate inproc buckets) AND multicast send off → pure
11906        // Unicast discovery via an explicit well-known-port peer list. Evidence,
11907        // that multicast-free all-to-all discovery works beyond 2 participants
11908        // (the "N²-multicast-storm" pain cluster, but unicast).
11909        // N via env (ZERODDS_SCALE_N, default 12) for >50 perf demos.
11910        let n: u32 = std::env::var("ZERODDS_SCALE_N")
11911            .ok()
11912            .and_then(|s| s.parse().ok())
11913            .unwrap_or(12)
11914            .clamp(2, 120);
11915        let domain = 21;
11916        let peers: Vec<Locator> = (0..n)
11917            .map(|pid| Locator::udp_v4([127, 0, 0, 1], super::spdp_unicast_port(domain, pid)))
11918            .collect();
11919        let mut rts = Vec::new();
11920        for i in 0..n {
11921            let cfg = RuntimeConfig {
11922                tick_period: Duration::from_millis(10),
11923                spdp_period: Duration::from_millis(40),
11924                // Own group per runtime → no inproc, no multicast.
11925                spdp_multicast_group: Ipv4Addr::new(239, 255, 21, (i + 1) as u8),
11926                spdp_multicast_send: false,
11927                initial_peers: peers.clone(),
11928                ..RuntimeConfig::default()
11929            };
11930            // Unique prefix even for n>47 (two-byte index).
11931            let mut pb = [0xD0u8; 12];
11932            pb[0] = (i & 0xff) as u8;
11933            pb[1] = (i >> 8) as u8;
11934            let prefix = GuidPrefix::from_bytes(pb);
11935            rts.push(DcpsRuntime::start(domain as i32, prefix, cfg).expect("start"));
11936        }
11937        // Wait until each participant has discovered all n-1 others.
11938        // Grosszuegiges Fenster: viele Runtimes konkurrieren um CPU; break-early.
11939        let started = std::time::Instant::now();
11940        let mut all_full = false;
11941        for _ in 0..1200 {
11942            std::thread::sleep(Duration::from_millis(25));
11943            if rts
11944                .iter()
11945                .all(|rt| rt.discovered_participants().len() >= (n as usize - 1))
11946            {
11947                all_full = true;
11948                break;
11949            }
11950        }
11951        let elapsed = started.elapsed();
11952        let min_seen = rts
11953            .iter()
11954            .map(|rt| rt.discovered_participants().len())
11955            .min()
11956            .unwrap_or(0);
11957        for rt in &rts {
11958            rt.shutdown();
11959        }
11960        println!(
11961            "C1-Scaling: {n} Participants multicast-frei all-to-all in {:.2}s (min={min_seen}/{})",
11962            elapsed.as_secs_f64(),
11963            n - 1
11964        );
11965        assert!(
11966            all_full,
11967            "multicast-free all-to-all discovery does not scale: min seen = {min_seen}/{}",
11968            n - 1
11969        );
11970    }
11971
11972    #[test]
11973    fn default_reassembly_cap_is_ros_realistic() {
11974        // C3 regression: the DCPS reassembly cap must be ROS-PointCloud2/
11975        // Image-capable (several MB), not the conservative
11976        // rtps 1-MiB default that silently discards large samples.
11977        let cfg = RuntimeConfig::default();
11978        assert!(
11979            cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024,
11980            "reassembly cap too small for ROS PointCloud2/Image: {}",
11981            cfg.max_reassembly_sample_bytes
11982        );
11983    }
11984
11985    #[test]
11986    fn ros_defaults_offers_xcdr1_for_ros_writers() {
11987        // C4: the ROS profile offers [XCDR1, XCDR2] (matches ROS/Cyclone
11988        // XCDR1 writer) + keeps the ROS-realistic reassembly cap.
11989        use zerodds_rtps::publication_data::data_representation as dr;
11990        let cfg = RuntimeConfig::ros_defaults();
11991        assert_eq!(
11992            cfg.data_representation_offer,
11993            alloc::vec![dr::XCDR, dr::XCDR2]
11994        );
11995        assert!(cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024);
11996    }
11997
11998    #[test]
11999    fn multicast_free_discovery_via_initial_peers() {
12000        // C1: two runtimes with DIFFERENT multicast groups lie
12001        // in different inproc buckets AND cannot see each other via
12002        // multicast — so they discover each other EXCLUSIVELY via
12003        // the unicast initial peers (well-known SPDP ports on 127.0.0.1).
12004        let domain = 7;
12005        let mut peers = Vec::new();
12006        super::expand_initial_peer(
12007            "127.0.0.1",
12008            domain as u32,
12009            super::INITIAL_PEER_MAX_PARTICIPANTS,
12010            &mut peers,
12011        );
12012        let mk = |group: [u8; 4]| RuntimeConfig {
12013            tick_period: Duration::from_millis(10),
12014            spdp_period: Duration::from_millis(40),
12015            spdp_multicast_group: Ipv4Addr::from(group),
12016            // Multicast send fully off → rigorous unicast-only proof.
12017            spdp_multicast_send: false,
12018            initial_peers: peers.clone(),
12019            ..RuntimeConfig::default()
12020        };
12021        let a = DcpsRuntime::start(
12022            domain,
12023            GuidPrefix::from_bytes([0xA1; 12]),
12024            mk([239, 255, 7, 1]),
12025        )
12026        .expect("a");
12027        let b = DcpsRuntime::start(
12028            domain,
12029            GuidPrefix::from_bytes([0xB2; 12]),
12030            mk([239, 255, 7, 2]),
12031        )
12032        .expect("b");
12033        let mut discovered = false;
12034        for _ in 0..160 {
12035            std::thread::sleep(Duration::from_millis(25));
12036            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12037                discovered = true;
12038                break;
12039            }
12040        }
12041        a.shutdown();
12042        b.shutdown();
12043        assert!(
12044            discovered,
12045            "multicast-freie Discovery via Unicast-Initial-Peers fehlgeschlagen"
12046        );
12047    }
12048
12049    #[test]
12050    fn multi_robot_profile_is_multicast_free_and_wan_tolerant() {
12051        // C6: the named profile must be unicast-only with ROS reprs and a
12052        // WAN-tolerant lease, independent of any env.
12053        let cfg = RuntimeConfig::multi_robot();
12054        assert!(
12055            !cfg.spdp_multicast_send,
12056            "multi_robot() must disable multicast send"
12057        );
12058        assert_eq!(
12059            cfg.data_representation_offer,
12060            alloc::vec![
12061                zerodds_rtps::publication_data::data_representation::XCDR,
12062                zerodds_rtps::publication_data::data_representation::XCDR2
12063            ],
12064            "multi_robot() must offer the ROS XCDR1+XCDR2 reprs"
12065        );
12066        assert_eq!(
12067            cfg.participant_lease_duration,
12068            Duration::from_secs(300),
12069            "multi_robot() must use the WAN-tolerant 300s lease"
12070        );
12071    }
12072
12073    #[test]
12074    fn multi_robot_profile_discovers_via_unicast() {
12075        // C6 e2e: two runtimes started from the `multi_robot()` profile (whose
12076        // `spdp_multicast_send = false` is the field under test) sit in
12077        // different multicast buckets and can ONLY find each other through the
12078        // unicast initial peers — proving the profile drives multicast-free
12079        // discovery end-to-end. Only test-timing + the peer list are
12080        // overridden; `spdp_multicast_send` comes from the profile.
12081        let domain = 9;
12082        let mut peers = Vec::new();
12083        super::expand_initial_peer(
12084            "127.0.0.1",
12085            domain as u32,
12086            super::INITIAL_PEER_MAX_PARTICIPANTS,
12087            &mut peers,
12088        );
12089        let mk = |group: [u8; 4]| RuntimeConfig {
12090            tick_period: Duration::from_millis(10),
12091            spdp_period: Duration::from_millis(40),
12092            spdp_multicast_group: Ipv4Addr::from(group),
12093            initial_peers: peers.clone(),
12094            ..RuntimeConfig::multi_robot()
12095        };
12096        let a = DcpsRuntime::start(
12097            domain,
12098            GuidPrefix::from_bytes([0xC6; 12]),
12099            mk([239, 255, 9, 1]),
12100        )
12101        .expect("a");
12102        let b = DcpsRuntime::start(
12103            domain,
12104            GuidPrefix::from_bytes([0xD7; 12]),
12105            mk([239, 255, 9, 2]),
12106        )
12107        .expect("b");
12108        let mut discovered = false;
12109        for _ in 0..160 {
12110            std::thread::sleep(Duration::from_millis(25));
12111            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12112                discovered = true;
12113                break;
12114            }
12115        }
12116        a.shutdown();
12117        b.shutdown();
12118        assert!(
12119            discovered,
12120            "multi_robot() profile failed to discover via unicast initial peers"
12121        );
12122    }
12123
12124    #[test]
12125    fn intra_runtime_writer_to_reader_loopback_delivers_sample() {
12126        // Bridge daemon use case: writer and reader in the SAME
12127        // DcpsRuntime, same topic+type. Before the same-runtime loopback
12128        // hook, a write() produced NO sample at the local reader,
12129        // because `inproc_announce_*` explicitly skips self and UDP multicast
12130        // loopback is not guaranteed.
12131        let rt = DcpsRuntime::start(
12132            17,
12133            GuidPrefix::from_bytes([0x42; 12]),
12134            RuntimeConfig::default(),
12135        )
12136        .expect("start runtime");
12137        let writer_eid = rt
12138            .register_user_writer(UserWriterConfig {
12139                topic_name: "IntraTopic".into(),
12140                type_name: "IntraType".into(),
12141                reliable: true,
12142                durability: zerodds_qos::DurabilityKind::Volatile,
12143                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12144                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12145                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12146                ownership: zerodds_qos::OwnershipKind::Shared,
12147                ownership_strength: 0,
12148                partition: alloc::vec![],
12149                user_data: alloc::vec![],
12150                topic_data: alloc::vec![],
12151                group_data: alloc::vec![],
12152                type_identifier: zerodds_types::TypeIdentifier::None,
12153                data_representation_offer: None,
12154            })
12155            .expect("register writer");
12156        let (_reader_eid, rx) = rt
12157            .register_user_reader(UserReaderConfig {
12158                topic_name: "IntraTopic".into(),
12159                type_name: "IntraType".into(),
12160                reliable: true,
12161                durability: zerodds_qos::DurabilityKind::Volatile,
12162                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12163                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12164                ownership: zerodds_qos::OwnershipKind::Shared,
12165                partition: alloc::vec![],
12166                user_data: alloc::vec![],
12167                topic_data: alloc::vec![],
12168                group_data: alloc::vec![],
12169                type_identifier: zerodds_types::TypeIdentifier::None,
12170                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12171                data_representation_offer: None,
12172            })
12173            .expect("register reader");
12174
12175        rt.write_user_sample(writer_eid, b"hello-intra-runtime".to_vec())
12176            .expect("write");
12177
12178        // Same-runtime loopback is synchronous in the write_user_sample_borrowed
12179        // path — `recv_timeout` needs only microseconds, not the
12180        // wire roundtrip.
12181        let sample = rx
12182            .recv_timeout(core::time::Duration::from_millis(100))
12183            .expect("intra-runtime reader should receive sample");
12184        match sample {
12185            UserSample::Alive { payload, .. } => {
12186                assert_eq!(payload.as_ref(), b"hello-intra-runtime");
12187            }
12188            other => panic!("expected Alive, got {other:?}"),
12189        }
12190        rt.shutdown();
12191    }
12192
12193    /// Bug R4 (#63): the same-runtime writer→reader loopback path
12194    /// (`intra_runtime_dispatch_alive`) used to hardcode the XCDR
12195    /// data-representation tag = `0`, so a DataWriter and DataReader sharing
12196    /// one `DcpsRuntime` lost the writer's real representation. Asserts the
12197    /// tag is carried through: default offer (`[XCDR2]`) → `1`, and an
12198    /// explicit `[XCDR1]` per-writer override → `0`. Also confirms a typed
12199    /// sample (XCDR2-framed body) round-trips intact alongside the tag.
12200    #[test]
12201    fn intra_runtime_loopback_preserves_representation_tag() {
12202        use zerodds_rtps::publication_data::data_representation as dr;
12203
12204        fn run_case(domain: i32, prefix: u8, offer: Option<Vec<i16>>, expected_rep: u8) {
12205            let rt = DcpsRuntime::start(
12206                domain,
12207                GuidPrefix::from_bytes([prefix; 12]),
12208                RuntimeConfig::default(),
12209            )
12210            .expect("start runtime");
12211            let writer_eid = rt
12212                .register_user_writer(UserWriterConfig {
12213                    topic_name: "RepTopic".into(),
12214                    type_name: "RepType".into(),
12215                    reliable: true,
12216                    durability: zerodds_qos::DurabilityKind::Volatile,
12217                    deadline: zerodds_qos::DeadlineQosPolicy::default(),
12218                    lifespan: zerodds_qos::LifespanQosPolicy::default(),
12219                    liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12220                    ownership: zerodds_qos::OwnershipKind::Shared,
12221                    ownership_strength: 0,
12222                    partition: alloc::vec![],
12223                    user_data: alloc::vec![],
12224                    topic_data: alloc::vec![],
12225                    group_data: alloc::vec![],
12226                    type_identifier: zerodds_types::TypeIdentifier::None,
12227                    data_representation_offer: offer,
12228                })
12229                .expect("register writer");
12230            let (_reader_eid, rx) = rt
12231                .register_user_reader(UserReaderConfig {
12232                    topic_name: "RepTopic".into(),
12233                    type_name: "RepType".into(),
12234                    reliable: true,
12235                    durability: zerodds_qos::DurabilityKind::Volatile,
12236                    deadline: zerodds_qos::DeadlineQosPolicy::default(),
12237                    liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12238                    ownership: zerodds_qos::OwnershipKind::Shared,
12239                    partition: alloc::vec![],
12240                    user_data: alloc::vec![],
12241                    topic_data: alloc::vec![],
12242                    group_data: alloc::vec![],
12243                    type_identifier: zerodds_types::TypeIdentifier::None,
12244                    type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12245                    data_representation_offer: None,
12246                })
12247                .expect("register reader");
12248
12249            // Typed sample: a `struct { long seq; }` XCDR2-aligned body
12250            // (little-endian 4-byte long). The intra-runtime path carries the
12251            // RAW body (no encap header), so the representation tag is the
12252            // only carrier of the wire version — exactly the lost signal.
12253            let seq: i32 = 0x0A0B_0C0D;
12254            let typed_payload = seq.to_le_bytes().to_vec();
12255
12256            rt.write_user_sample(writer_eid, typed_payload.clone())
12257                .expect("write");
12258
12259            let sample = rx
12260                .recv_timeout(core::time::Duration::from_millis(100))
12261                .expect("intra-runtime reader should receive sample");
12262            match sample {
12263                UserSample::Alive {
12264                    payload,
12265                    representation,
12266                    ..
12267                } => {
12268                    assert_eq!(
12269                        representation, expected_rep,
12270                        "intra-runtime loopback must carry the writer's XCDR \
12271                         version tag (offer→rep), not a hardcoded 0"
12272                    );
12273                    // Typed round-trip: the recovered body decodes to the
12274                    // original long.
12275                    assert_eq!(payload.as_ref(), typed_payload.as_slice());
12276                    let recovered =
12277                        i32::from_le_bytes(payload.as_ref()[..4].try_into().expect("4-byte long"));
12278                    assert_eq!(recovered, seq, "typed sample must round-trip");
12279                }
12280                other => panic!("expected Alive, got {other:?}"),
12281            }
12282            rt.shutdown();
12283        }
12284
12285        // Default offer is `[XCDR2]` → tag `1`.
12286        run_case(19, 0x60, None, 1);
12287        // Explicit per-writer XCDR1 override → tag `0` (proves the value is
12288        // actually carried from the writer, not constant).
12289        run_case(20, 0x61, Some(alloc::vec![dr::XCDR]), 0);
12290        // Explicit per-writer XCDR2 override → tag `1`.
12291        run_case(21, 0x62, Some(alloc::vec![dr::XCDR2]), 1);
12292    }
12293
12294    #[test]
12295    fn intra_runtime_loopback_not_matched_on_different_topic() {
12296        // Negative test: writer on TopicA, reader on TopicB — no
12297        // intra-runtime match, no sample. Prevents the
12298        // routing table from topic-blindly merging everything.
12299        let rt = DcpsRuntime::start(
12300            18,
12301            GuidPrefix::from_bytes([0x43; 12]),
12302            RuntimeConfig::default(),
12303        )
12304        .expect("start runtime");
12305        let writer_eid = rt
12306            .register_user_writer(UserWriterConfig {
12307                topic_name: "TopicA".into(),
12308                type_name: "TypeA".into(),
12309                reliable: true,
12310                durability: zerodds_qos::DurabilityKind::Volatile,
12311                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12312                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12313                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12314                ownership: zerodds_qos::OwnershipKind::Shared,
12315                ownership_strength: 0,
12316                partition: alloc::vec![],
12317                user_data: alloc::vec![],
12318                topic_data: alloc::vec![],
12319                group_data: alloc::vec![],
12320                type_identifier: zerodds_types::TypeIdentifier::None,
12321                data_representation_offer: None,
12322            })
12323            .expect("register writer");
12324        let (_reader_eid, rx) = rt
12325            .register_user_reader(UserReaderConfig {
12326                topic_name: "TopicB".into(),
12327                type_name: "TypeB".into(),
12328                reliable: true,
12329                durability: zerodds_qos::DurabilityKind::Volatile,
12330                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12331                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12332                ownership: zerodds_qos::OwnershipKind::Shared,
12333                partition: alloc::vec![],
12334                user_data: alloc::vec![],
12335                topic_data: alloc::vec![],
12336                group_data: alloc::vec![],
12337                type_identifier: zerodds_types::TypeIdentifier::None,
12338                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12339                data_representation_offer: None,
12340            })
12341            .expect("register reader");
12342
12343        rt.write_user_sample(writer_eid, b"should-not-arrive".to_vec())
12344            .expect("write");
12345
12346        match rx.recv_timeout(core::time::Duration::from_millis(50)) {
12347            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { /* expected */ }
12348            other => panic!("reader on different topic must not receive: got {other:?}"),
12349        }
12350        rt.shutdown();
12351    }
12352
12353    #[test]
12354    fn runtime_starts_and_shuts_down_cleanly() {
12355        let rt = DcpsRuntime::start(
12356            42,
12357            GuidPrefix::from_bytes([7; 12]),
12358            RuntimeConfig::default(),
12359        )
12360        .expect("start runtime");
12361        assert_eq!(rt.domain_id, 42);
12362        // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): the SameHostTracker
12363        // must be initially empty and a same-host match (manually
12364        // simulated, without SEDP setup) must produce a `Pending`
12365        // entry. The real SEDP hook trigger is the job of the E2E
12366        // test in wave 4c — here only a smoke test of the wiring point.
12367        assert!(rt.same_host.is_empty(), "fresh runtime: no same-host pairs");
12368        let local_writer = zerodds_rtps::wire_types::Guid::new(
12369            rt.guid_prefix,
12370            zerodds_rtps::wire_types::EntityId::user_writer_with_key([1, 2, 3]),
12371        );
12372        let same_host_reader = zerodds_rtps::wire_types::Guid::new(
12373            rt.guid_prefix,
12374            zerodds_rtps::wire_types::EntityId::user_reader_with_key([4, 5, 6]),
12375        );
12376        rt.same_host
12377            .register_pending(local_writer, same_host_reader);
12378        assert_eq!(rt.same_host.len(), 1);
12379        assert!(matches!(
12380            rt.same_host.lookup(local_writer, same_host_reader),
12381            Some(crate::same_host::SameHostState::Pending)
12382        ));
12383        // Shutdown is idempotent.
12384        rt.shutdown();
12385        rt.shutdown();
12386    }
12387
12388    #[test]
12389    fn multicast_allowlist_empty_permits_all() {
12390        let cfg = RuntimeConfig::default();
12391        assert!(cfg.multicast_allowlist.is_empty());
12392        assert!(cfg.multicast_allowed(Ipv4Addr::new(239, 255, 0, 1)));
12393        assert!(cfg.multicast_allowed(Ipv4Addr::new(239, 1, 2, 3)));
12394    }
12395
12396    #[test]
12397    fn multicast_allowlist_restricts_to_members() {
12398        let cfg = RuntimeConfig {
12399            multicast_allowlist: vec![Ipv4Addr::new(239, 255, 0, 1)],
12400            ..RuntimeConfig::default()
12401        };
12402        assert!(cfg.multicast_allowed(Ipv4Addr::new(239, 255, 0, 1)));
12403        assert!(!cfg.multicast_allowed(Ipv4Addr::new(239, 9, 9, 9)));
12404    }
12405
12406    #[test]
12407    fn start_refuses_spdp_group_outside_allowlist() {
12408        // Opt-in allowlist that excludes the default SPDP group (239.255.0.1)
12409        // → start must refuse before it touches any multicast socket.
12410        let cfg = RuntimeConfig {
12411            multicast_allowlist: vec![Ipv4Addr::new(239, 1, 1, 1)],
12412            ..RuntimeConfig::default()
12413        };
12414        let res = DcpsRuntime::start(43, GuidPrefix::from_bytes([9; 12]), cfg);
12415        assert!(matches!(res, Err(DdsError::BadParameter { .. })));
12416    }
12417
12418    #[test]
12419    fn spdp_announces_standard_bits_by_default() {
12420        // Default config (without security): standard bits + WLP bits 10/11
12421        // + TypeLookup bits 12/13 must be announced along;
12422        // secure bits 16..27 + SEDP-topics bits 28/29 must NOT
12423        // be set. Topics bits are optional per RTPS 2.5 §8.5.4.4
12424        // — ZeroDDS does not implement the native topic endpoints
12425        // (synthetic DCPSTopic derivation from pub/sub covers the
12426        // end-user need), so we do not announce the capability
12427        // either.
12428        let rt = DcpsRuntime::start(
12429            5,
12430            GuidPrefix::from_bytes([0xC; 12]),
12431            RuntimeConfig::default(),
12432        )
12433        .expect("start");
12434        let mask = rt.announced_builtin_endpoint_set();
12435        // Standard bits + WLP + TypeLookup.
12436        assert_ne!(mask & endpoint_flag::PARTICIPANT_ANNOUNCER, 0);
12437        assert_ne!(mask & endpoint_flag::PARTICIPANT_DETECTOR, 0);
12438        assert_ne!(mask & endpoint_flag::PUBLICATIONS_ANNOUNCER, 0);
12439        assert_ne!(mask & endpoint_flag::SUBSCRIPTIONS_DETECTOR, 0);
12440        assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_WRITER, 0);
12441        assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_READER, 0);
12442        assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REQUEST, 0);
12443        assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REPLY, 0);
12444        // Do NOT set the SEDP-topics bits — covered synthetically.
12445        assert_eq!(mask & endpoint_flag::TOPICS_ANNOUNCER, 0);
12446        assert_eq!(mask & endpoint_flag::TOPICS_DETECTOR, 0);
12447        // No secure bits without explicit announce_secure_endpoints.
12448        assert_eq!(mask & endpoint_flag::ALL_SECURE, 0);
12449    }
12450
12451    #[test]
12452    fn spdp_announces_secure_bits_when_configured() {
12453        // With announce_secure_endpoints=true all 12 secure
12454        // bits (16..27) must be set.
12455        let config = RuntimeConfig {
12456            announce_secure_endpoints: true,
12457            ..Default::default()
12458        };
12459        let rt = DcpsRuntime::start(6, GuidPrefix::from_bytes([0xD; 12]), config).expect("start");
12460        let mask = rt.announced_builtin_endpoint_set();
12461        for bit in 16u32..=27 {
12462            assert!(
12463                mask & (1u32 << bit) != 0,
12464                "secure bit {bit} missing in the SPDP announce"
12465            );
12466        }
12467        // Standard bits must still be set.
12468        assert_eq!(
12469            mask & endpoint_flag::ALL_STANDARD,
12470            endpoint_flag::ALL_STANDARD
12471        );
12472    }
12473
12474    #[test]
12475    fn spdp_lease_duration_is_configurable() {
12476        // Default 100 s (spec). The override of 17 s must arrive in the beacon.
12477        let config = RuntimeConfig {
12478            participant_lease_duration: Duration::from_secs(17),
12479            ..Default::default()
12480        };
12481        let rt = DcpsRuntime::start(7, GuidPrefix::from_bytes([0xE; 12]), config).expect("start");
12482        let secs = rt
12483            .spdp_beacon
12484            .lock()
12485            .map(|b| b.data.lease_duration.seconds)
12486            .unwrap_or(0);
12487        assert_eq!(secs, 17);
12488    }
12489
12490    #[test]
12491    fn user_locator_is_udp_v4_127_0_0_x() {
12492        let rt = DcpsRuntime::start(
12493            0,
12494            GuidPrefix::from_bytes([0xA; 12]),
12495            RuntimeConfig::default(),
12496        )
12497        .expect("start");
12498        let loc = rt.user_locator();
12499        assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
12500        // Port > 0 (ephemeral).
12501        assert!(loc.port > 0);
12502    }
12503
12504    #[test]
12505    fn two_runtimes_on_same_domain_can_coexist() {
12506        // The SPDP multicast port is SO_REUSE in our bind.
12507        let a = DcpsRuntime::start(
12508            3,
12509            GuidPrefix::from_bytes([0xA; 12]),
12510            RuntimeConfig::default(),
12511        )
12512        .expect("a");
12513        let b = DcpsRuntime::start(
12514            3,
12515            GuidPrefix::from_bytes([0xB; 12]),
12516            RuntimeConfig::default(),
12517        )
12518        .expect("b");
12519        assert_eq!(a.domain_id, b.domain_id);
12520    }
12521
12522    #[test]
12523    fn peer_capabilities_unknown_peer_returns_none() {
12524        let rt = DcpsRuntime::start(
12525            10,
12526            GuidPrefix::from_bytes([0x60; 12]),
12527            RuntimeConfig::default(),
12528        )
12529        .expect("start");
12530        // A fresh runtime has discovered no peer.
12531        let caps = rt.peer_capabilities(&GuidPrefix::from_bytes([0xEE; 12]));
12532        assert!(caps.is_none());
12533    }
12534
12535    #[test]
12536    fn assert_liveliness_enqueues_wlp_pulse_without_panic() {
12537        // Smoke test: assert_liveliness() must not poison the lock
12538        // and must return synchronously.
12539        //
12540        // Isolate discovery: a UNIQUE multicast group (239.255.88.1, used by no
12541        // other test) + multicast send off, so no co-running test's participant
12542        // can announce itself into this group. Using RuntimeConfig::default()
12543        // put this runtime on the shared spec group (239.255.0.1), where a
12544        // parallel test's participant leaked in and made peer_count flaky under
12545        // the coverage run — a real test-isolation defect, following the same
12546        // unique-group convention the other multi-runtime tests already use.
12547        let cfg = RuntimeConfig {
12548            spdp_multicast_group: Ipv4Addr::new(239, 255, 88, 1),
12549            spdp_multicast_send: false,
12550            ..RuntimeConfig::default()
12551        };
12552        let rt = DcpsRuntime::start(8, GuidPrefix::from_bytes([0xF; 12]), cfg).expect("start");
12553        rt.assert_liveliness();
12554        rt.assert_writer_liveliness(alloc::vec![0xDE, 0xAD]);
12555        // Genuinely isolated now → no peer, and the lock stays usable.
12556        let count = rt.wlp.lock().map(|w| w.peer_count()).unwrap_or(usize::MAX);
12557        assert_eq!(count, 0, "isolated runtime: no peer announced itself → 0");
12558    }
12559
12560    #[test]
12561    fn wlp_period_default_is_lease_over_three() {
12562        // With the default lease of 100 s → wlp_period = 33.33 s.
12563        let rt = DcpsRuntime::start(
12564            9,
12565            GuidPrefix::from_bytes([0x10; 12]),
12566            RuntimeConfig::default(),
12567        )
12568        .expect("start");
12569        // We cannot read the value directly; but we
12570        // know: tick_period > 30 s means the default lease was
12571        // used. Enqueue a pulse and tick — it must fire,
12572        // the next AUTOMATIC comes only in 33 s.
12573        let mut wlp = rt.wlp.lock().unwrap();
12574        wlp.assert_participant();
12575        let now0 = Duration::from_secs(0);
12576        let dg = wlp.tick(now0).unwrap();
12577        assert!(dg.is_some(), "pulse is emitted immediately");
12578    }
12579
12580    // Multicast loopback is unreliable on macOS (no auto-
12581    // interface-join with bind_multicast_v4(0.0.0.0)). On Linux
12582    // it works out of the box; there the test will run in CI.
12583    #[cfg(target_os = "linux")]
12584    #[test]
12585    fn two_runtimes_exchange_wlp_heartbeat_via_multicast() {
12586        // .D-e: A sends periodic WLP heartbeats. B must
12587        // know its own WLP endpoint with A's prefix as a peer
12588        // within ~3 tick periods.
12589        let cfg = RuntimeConfig {
12590            tick_period: Duration::from_millis(20),
12591            spdp_period: Duration::from_millis(100),
12592            // Aggressive WLP period for fast tests.
12593            wlp_period: Duration::from_millis(80),
12594            participant_lease_duration: Duration::from_millis(240),
12595            ..RuntimeConfig::default()
12596        };
12597        let _a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x40; 12]), cfg.clone()).expect("a");
12598        let _b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x41; 12]), cfg).expect("b");
12599
12600        let a_prefix = GuidPrefix::from_bytes([0x40; 12]);
12601        for _ in 0..60 {
12602            thread::sleep(Duration::from_millis(50));
12603            if _b.peer_liveliness_last_seen(&a_prefix).is_some() {
12604                return;
12605            }
12606        }
12607        panic!("B did not see A's WLP heartbeat within 3 s");
12608    }
12609
12610    #[cfg(target_os = "linux")]
12611    #[test]
12612    fn two_runtimes_assert_liveliness_reaches_peer() {
12613        // The Manual-By-Participant pulse must arrive at the peer, the
12614        // last-seen timestamp must reset compared to purely Automatic
12615        // beats. Since the pulse goes out synchronously on the next
12616        // tick, a short wait suffices.
12617        let cfg = RuntimeConfig {
12618            tick_period: Duration::from_millis(20),
12619            spdp_period: Duration::from_millis(100),
12620            // WLP period large enough that no AUTOMATIC beat comes
12621            // in between within the test. The manual pulse queue
12622            // is processed before the AUTOMATIC slot.
12623            wlp_period: Duration::from_secs(3600),
12624            ..RuntimeConfig::default()
12625        };
12626        let a = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x50; 12]), cfg.clone()).expect("a");
12627        let b = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x51; 12]), cfg).expect("b");
12628
12629        a.assert_liveliness();
12630        let a_prefix = GuidPrefix::from_bytes([0x50; 12]);
12631        for _ in 0..60 {
12632            thread::sleep(Duration::from_millis(50));
12633            if b.peer_liveliness_last_seen(&a_prefix).is_some() {
12634                return;
12635            }
12636        }
12637        // In case of multicast-loopback problems, at least check A's
12638        // own pulse counter.
12639        panic!("B did not see A's manual liveliness assert within 3 s");
12640    }
12641
12642    #[cfg(target_os = "linux")]
12643    #[test]
12644    fn two_runtimes_exchange_sedp_publication_announce() {
12645        // E2E smoke: A announces a publication, B sees it
12646        // via SEDP. Assumes SPDP works (so that
12647        // the SEDP peer proxies get wired).
12648        use zerodds_qos::{DurabilityKind, ReliabilityKind};
12649        use zerodds_rtps::publication_data::PublicationBuiltinTopicData;
12650
12651        let cfg = RuntimeConfig {
12652            tick_period: Duration::from_millis(20),
12653            spdp_period: Duration::from_millis(100),
12654            ..RuntimeConfig::default()
12655        };
12656        // Own domain, so the test does not collide with the SPDP-only test
12657        // on domain 0 over the multicast port.
12658        let a = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xCC; 12]), cfg.clone()).expect("a");
12659        let b = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xDD; 12]), cfg).expect("b");
12660
12661        // Wait until both see each other via SPDP.
12662        for _ in 0..40 {
12663            thread::sleep(Duration::from_millis(50));
12664            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12665                break;
12666            }
12667        }
12668        assert!(
12669            !a.discovered_participants().is_empty(),
12670            "no SPDP discovery a"
12671        );
12672
12673        // A announces a publication for topic "Chatter" with type "RawBytes".
12674        let pub_data = PublicationBuiltinTopicData {
12675            key: Guid::new(
12676                a.guid_prefix,
12677                EntityId::user_writer_with_key([0x01, 0x02, 0x03]),
12678            ),
12679            participant_key: Guid::new(a.guid_prefix, EntityId::PARTICIPANT),
12680            topic_name: "Chatter".into(),
12681            type_name: "zerodds::RawBytes".into(),
12682            durability: DurabilityKind::Volatile,
12683            reliability: zerodds_qos::ReliabilityQosPolicy {
12684                kind: ReliabilityKind::Reliable,
12685                max_blocking_time: QosDuration::from_millis(100_i32),
12686            },
12687            ownership: zerodds_qos::OwnershipKind::Shared,
12688            ownership_strength: 0,
12689            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12690            deadline: zerodds_qos::DeadlineQosPolicy::default(),
12691            lifespan: zerodds_qos::LifespanQosPolicy::default(),
12692            partition: Vec::new(),
12693            user_data: Vec::new(),
12694            topic_data: Vec::new(),
12695            group_data: Vec::new(),
12696            type_information: None,
12697            data_representation: Vec::new(),
12698            security_info: None,
12699            service_instance_name: None,
12700            related_entity_guid: None,
12701            topic_aliases: None,
12702            type_identifier: zerodds_types::TypeIdentifier::None,
12703            unicast_locators: Vec::new(),
12704            multicast_locators: Vec::new(),
12705        };
12706        a.announce_publication(&pub_data).expect("announce");
12707
12708        // B should have the publication in the cache within ~3 s.
12709        // CI on shared runners has more jitter, 1 s was too tight.
12710        for _ in 0..60 {
12711            thread::sleep(Duration::from_millis(50));
12712            if b.discovered_publications_count() > 0 {
12713                return;
12714            }
12715        }
12716        panic!(
12717            "B did not receive SEDP publication within 3 s (pub_count={})",
12718            b.discovered_publications_count()
12719        );
12720    }
12721
12722    #[cfg(target_os = "linux")]
12723    #[test]
12724    fn two_runtimes_e2e_user_data_match_and_transfer() {
12725        // E2E smoke: full path
12726        //   Runtime-A register_user_writer(topic, type)
12727        //   Runtime-B register_user_reader(topic, type)
12728        //   SEDP match, writer add_reader_proxy, reader add_writer_proxy
12729        //   A.write_user_sample(payload) → UDP → B's mpsc::Receiver
12730        //
12731        // Eigene Domain (2) um Kollisionen zu vermeiden.
12732        let cfg = RuntimeConfig {
12733            tick_period: Duration::from_millis(20),
12734            spdp_period: Duration::from_millis(100),
12735            ..RuntimeConfig::default()
12736        };
12737        let a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xEE; 12]), cfg.clone()).expect("a");
12738        let b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xFF; 12]), cfg).expect("b");
12739
12740        // SPDP mutual — 3 s Budget.
12741        let mut spdp_ok = false;
12742        for _ in 0..60 {
12743            thread::sleep(Duration::from_millis(50));
12744            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12745                spdp_ok = true;
12746                break;
12747            }
12748        }
12749        assert!(spdp_ok, "SPDP mutual discovery did not complete in 3 s");
12750
12751        // Register endpoints. A publish, B subscribe.
12752        let wid = a
12753            .register_user_writer(UserWriterConfig {
12754                topic_name: "Chatter".into(),
12755                type_name: "zerodds::RawBytes".into(),
12756                reliable: true,
12757                durability: zerodds_qos::DurabilityKind::Volatile,
12758                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12759                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12760                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12761                ownership: zerodds_qos::OwnershipKind::Shared,
12762                ownership_strength: 0,
12763                partition: Vec::new(),
12764                user_data: Vec::new(),
12765                topic_data: Vec::new(),
12766                group_data: Vec::new(),
12767                type_identifier: zerodds_types::TypeIdentifier::None,
12768                data_representation_offer: None,
12769            })
12770            .expect("wid");
12771        let (_rid, rx) = b
12772            .register_user_reader(UserReaderConfig {
12773                topic_name: "Chatter".into(),
12774                type_name: "zerodds::RawBytes".into(),
12775                reliable: true,
12776                durability: zerodds_qos::DurabilityKind::Volatile,
12777                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12778                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12779                ownership: zerodds_qos::OwnershipKind::Shared,
12780                partition: Vec::new(),
12781                user_data: Vec::new(),
12782                topic_data: Vec::new(),
12783                group_data: Vec::new(),
12784                type_identifier: zerodds_types::TypeIdentifier::None,
12785                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12786                data_representation_offer: None,
12787            })
12788            .expect("rid");
12789
12790        // SEDP match + User-Data-Flow. `add_reader_proxy` triggert
12791        // a heartbeat immediately (RTPS §8.4.15.4), so ~tick_period
12792        // (20 ms) + response-delay (200 ms) + resend ≈ 300 ms in
12793        // idle state. A 4 s budget suffices even with CI jitter.
12794        let mut attempts = 0;
12795        loop {
12796            thread::sleep(Duration::from_millis(50));
12797            let _ = a.write_user_sample(wid, alloc::vec![0xAA, 0xBB, 0xCC]);
12798            if let Ok(sample) = rx.recv_timeout(Duration::from_millis(50)) {
12799                match sample {
12800                    UserSample::Alive { payload, .. } => {
12801                        assert_eq!(payload.as_slice(), &[0xAA, 0xBB, 0xCC][..]);
12802                        return;
12803                    }
12804                    other => panic!("expected Alive sample, got {other:?}"),
12805                }
12806            }
12807            attempts += 1;
12808            if attempts > 80 {
12809                panic!("no sample delivered within 4 s");
12810            }
12811        }
12812    }
12813
12814    #[cfg(target_os = "linux")]
12815    #[test]
12816    fn two_runtimes_discover_each_other_via_spdp() {
12817        // We use a tight SPDP period so the test does not wait 5 s.
12818        let cfg = RuntimeConfig {
12819            tick_period: Duration::from_millis(20),
12820            spdp_period: Duration::from_millis(100),
12821            ..RuntimeConfig::default()
12822        };
12823        // Eigene Domain 3 (SEDP=1, E2E=2) um Cross-Test-Kollision zu vermeiden.
12824        let a = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xAA; 12]), cfg.clone()).expect("a");
12825        let b = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xBB; 12]), cfg).expect("b");
12826
12827        // Give the loop time for 2-3 beacon rounds. Multicast on
12828        // loopback is somewhat timing-sensitive when parallel tests
12829        // share the multicast group — hence 60 iterations of 50 ms
12830        // = 3 s budget instead of 1 s.
12831        for _ in 0..60 {
12832            thread::sleep(Duration::from_millis(50));
12833            let a_sees_b = a
12834                .discovered_participants()
12835                .iter()
12836                .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xBB; 12]));
12837            let b_sees_a = b
12838                .discovered_participants()
12839                .iter()
12840                .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xAA; 12]));
12841            if a_sees_b && b_sees_a {
12842                return;
12843            }
12844        }
12845        panic!(
12846            "mutual SPDP discovery failed within 3 s (a={} b={})",
12847            a.discovered_participants().len(),
12848            b.discovered_participants().len()
12849        );
12850    }
12851
12852    // =======================================================================
12853    // Security: Writer-Side Per-Reader-Serializer
12854    // =======================================================================
12855
12856    #[cfg(feature = "security")]
12857    #[test]
12858    fn per_target_serializer_produces_different_wire_per_reader() {
12859        use zerodds_security_crypto::AesGcmCryptoPlugin;
12860        use zerodds_security_permissions::parse_governance_xml;
12861        use zerodds_security_runtime::{
12862            PeerCapabilities, ProtectionLevel as SecProtectionLevel, SharedSecurityGate,
12863        };
12864
12865        // The governance enforces ENCRYPT on domain 0 — the default
12866        // path (transform_outbound) wraps too. A per-reader override
12867        // can still deliver plaintext if the reader is legacy.
12868        const GOV: &str = r#"
12869<domain_access_rules>
12870  <domain_rule>
12871    <domains><id>0</id></domains>
12872    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12873    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12874  </domain_rule>
12875</domain_access_rules>
12876"#;
12877        let gate = SharedSecurityGate::new(
12878            0,
12879            parse_governance_xml(GOV).unwrap(),
12880            Box::new(AesGcmCryptoPlugin::new()),
12881        );
12882
12883        let cfg = RuntimeConfig {
12884            security: Some(std::sync::Arc::new(gate)),
12885            ..RuntimeConfig::default()
12886        };
12887        let rt =
12888            DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE4; 12]), cfg).expect("start runtime");
12889
12890        let wid = rt
12891            .register_user_writer(UserWriterConfig {
12892                topic_name: "HeteroTopic".into(),
12893                type_name: "zerodds::RawBytes".into(),
12894                reliable: true,
12895                durability: zerodds_qos::DurabilityKind::Volatile,
12896                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12897                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12898                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12899                ownership: zerodds_qos::OwnershipKind::Shared,
12900                ownership_strength: 0,
12901                partition: Vec::new(),
12902                user_data: Vec::new(),
12903                topic_data: Vec::new(),
12904                group_data: Vec::new(),
12905                type_identifier: zerodds_types::TypeIdentifier::None,
12906                data_representation_offer: None,
12907            })
12908            .expect("register writer");
12909
12910        // Drei fiktive Reader-Targets — eines pro Protection-Klasse.
12911        let legacy_loc = Locator::udp_v4([127, 0, 0, 11], 40001);
12912        let fast_loc = Locator::udp_v4([127, 0, 0, 12], 40002);
12913        let secure_loc = Locator::udp_v4([127, 0, 0, 13], 40003);
12914        let legacy_peer: [u8; 12] = [0x11; 12];
12915        let fast_peer: [u8; 12] = [0x22; 12];
12916        let secure_peer: [u8; 12] = [0x33; 12];
12917
12918        // Simulates the SEDP match: populate the writer-slot maps.
12919        {
12920            let arc = rt.writer_slot(wid).unwrap();
12921            let mut slot = arc.lock().unwrap();
12922            slot.reader_protection
12923                .insert(legacy_peer, SecProtectionLevel::None);
12924            slot.reader_protection
12925                .insert(fast_peer, SecProtectionLevel::Sign);
12926            slot.reader_protection
12927                .insert(secure_peer, SecProtectionLevel::Encrypt);
12928            slot.locator_to_peer.insert(legacy_loc, legacy_peer);
12929            slot.locator_to_peer.insert(fast_loc, fast_peer);
12930            slot.locator_to_peer.insert(secure_loc, secure_peer);
12931        }
12932
12933        // Fiktive Writer-Datagram-Bytes (RTPS-Header + User-Payload).
12934        let mut msg = Vec::new();
12935        msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12936        msg.extend_from_slice(&[0xE4; 12]); // GuidPrefix
12937        msg.extend_from_slice(b"HELLO-HETERO");
12938
12939        let wire_legacy =
12940            secure_outbound_for_target(&rt, wid, &msg, &legacy_loc).expect("legacy path");
12941        let wire_fast = secure_outbound_for_target(&rt, wid, &msg, &fast_loc).expect("fast path");
12942        let wire_secure =
12943            secure_outbound_for_target(&rt, wid, &msg, &secure_loc).expect("secure path");
12944
12945        // Spec §8.4.2.4: under rtps_protection_kind=ENCRYPT EVERY message MUST
12946        // be SRTPS-wrapped — even a legacy reader (data-level None) may
12947        // get NO plaintext, otherwise user DATA leaks on a protected
12948        // domain. The per-reader data level only controls the inner payload/
12949        // submessage layer, not the outer rtps_protection.
12950        assert_ne!(
12951            wire_legacy, msg,
12952            "legacy under rtps_protection=ENCRYPT MUST be SRTPS-wrapped (no plaintext leak)"
12953        );
12954        assert_ne!(wire_fast, msg, "fast reader must be protected");
12955        assert_ne!(wire_secure, msg, "secure reader must be protected");
12956
12957        // Heterogeneity proof: the three wires are pairwise
12958        // different (each with its own nonce/session counter in SRTPS).
12959        assert_ne!(wire_legacy, wire_fast);
12960        assert_ne!(wire_legacy, wire_secure);
12961        assert_ne!(wire_fast, wire_secure);
12962
12963        // Without a locator match the fallback must take the domain-rule path
12964        // — this governance requires ENCRYPT, so SRTPS-wrapped.
12965        let unknown_loc = Locator::udp_v4([127, 0, 0, 99], 40099);
12966        let wire_unknown =
12967            secure_outbound_for_target(&rt, wid, &msg, &unknown_loc).expect("fallback path");
12968        assert_ne!(
12969            wire_unknown, msg,
12970            "unknown target should be protected via the domain rule"
12971        );
12972
12973        // The absence of the PeerCapabilities type is a compile check:
12974        // the import shows that the entire per-reader structure
12975        // is available in the dcps integration.
12976        let _unused: PeerCapabilities = PeerCapabilities::default();
12977
12978        rt.shutdown();
12979    }
12980
12981    // =======================================================================
12982    // Security: Reader-Side Per-Writer-Validator + Logging
12983    // =======================================================================
12984
12985    #[cfg(feature = "security")]
12986    #[derive(Default, Clone)]
12987    struct CapturingLogger {
12988        inner: std::sync::Arc<
12989            std::sync::Mutex<Vec<(zerodds_security_runtime::LogLevel, String, String)>>,
12990        >,
12991    }
12992
12993    #[cfg(feature = "security")]
12994    impl CapturingLogger {
12995        fn events(&self) -> Vec<(zerodds_security_runtime::LogLevel, String, String)> {
12996            self.inner.lock().map(|g| g.clone()).unwrap_or_default()
12997        }
12998    }
12999
13000    #[cfg(feature = "security")]
13001    impl zerodds_security_runtime::LoggingPlugin for CapturingLogger {
13002        fn log(
13003            &self,
13004            level: zerodds_security_runtime::LogLevel,
13005            _participant: [u8; 16],
13006            category: &str,
13007            message: &str,
13008        ) {
13009            if let Ok(mut g) = self.inner.lock() {
13010                g.push((level, category.to_string(), message.to_string()));
13011            }
13012        }
13013        fn plugin_class_id(&self) -> &str {
13014            "zerodds.test.capturing_logger"
13015        }
13016    }
13017
13018    #[cfg(feature = "security")]
13019    fn build_runtime_with(
13020        gov_xml: &str,
13021        logger: std::sync::Arc<CapturingLogger>,
13022    ) -> std::sync::Arc<DcpsRuntime> {
13023        use zerodds_security_crypto::AesGcmCryptoPlugin;
13024        use zerodds_security_permissions::parse_governance_xml;
13025        use zerodds_security_runtime::{LoggingPlugin, SharedSecurityGate};
13026        let gate = SharedSecurityGate::new(
13027            0,
13028            parse_governance_xml(gov_xml).unwrap(),
13029            Box::new(AesGcmCryptoPlugin::new()),
13030        );
13031        let logger_dyn: std::sync::Arc<dyn LoggingPlugin> = logger;
13032        let cfg = RuntimeConfig {
13033            security: Some(std::sync::Arc::new(gate)),
13034            security_logger: Some(logger_dyn),
13035            ..RuntimeConfig::default()
13036        };
13037        DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE7; 12]), cfg).expect("start rt")
13038    }
13039
13040    #[cfg(feature = "security")]
13041    #[test]
13042    fn inbound_plain_on_encrypt_domain_drops_with_error_event() {
13043        // DoD plan §stage 5: writer sends plain, policy expects
13044        // ENCRYPT → Reader droppt. Ohne allow_unauthenticated ist
13045        // this a "LegacyBlocked" → error level (not warning) per
13046        // the plan spec "missing-caps = Error".
13047        const GOV_ENCRYPT: &str = r#"
13048<domain_access_rules>
13049  <domain_rule>
13050    <domains><id>0</id></domains>
13051    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13052    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13053  </domain_rule>
13054</domain_access_rules>
13055"#;
13056        let logger = std::sync::Arc::new(CapturingLogger::default());
13057        let rt = build_runtime_with(GOV_ENCRYPT, std::sync::Arc::clone(&logger));
13058
13059        // Plain-RTPS-Datagram (header + body).
13060        let mut plain = Vec::new();
13061        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13062        plain.extend_from_slice(&[0x77; 12]); // attacker guid_prefix
13063        plain.extend_from_slice(b"plaintext-on-encrypted-domain");
13064
13065        let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan);
13066        assert!(out.is_none(), "tampering packet must be dropped");
13067
13068        let events = logger.events();
13069        assert_eq!(events.len(), 1, "exactly one log event expected");
13070        let (level, category, _msg) = &events[0];
13071        assert_eq!(
13072            *level,
13073            zerodds_security_runtime::LogLevel::Error,
13074            "plain-on-protected-domain without allow_unauth = Error (LegacyBlocked)"
13075        );
13076        assert_eq!(category, "inbound.legacy_blocked");
13077        rt.shutdown();
13078    }
13079
13080    #[cfg(feature = "security")]
13081    #[test]
13082    fn inbound_legacy_peer_accepted_when_governance_allows_unauth() {
13083        // DoD plan §stage 5: the legacy peer can keep talking to the reader,
13084        // when the governance sets allow_unauthenticated_participants=true.
13085        const GOV: &str = r#"
13086<domain_access_rules>
13087  <domain_rule>
13088    <domains><id>0</id></domains>
13089    <allow_unauthenticated_participants>TRUE</allow_unauthenticated_participants>
13090    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13091    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13092  </domain_rule>
13093</domain_access_rules>
13094"#;
13095        let logger = std::sync::Arc::new(CapturingLogger::default());
13096        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
13097
13098        let mut plain = Vec::new();
13099        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13100        plain.extend_from_slice(&[0x88; 12]);
13101        plain.extend_from_slice(b"legacy-but-allowed");
13102
13103        let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan)
13104            .expect("legacy peer must be accepted");
13105        assert_eq!(out, plain, "output is byte-identical (no crypto unwrap)");
13106        assert!(
13107            logger.events().is_empty(),
13108            "no log event on the accept path"
13109        );
13110        rt.shutdown();
13111    }
13112
13113    #[cfg(feature = "security")]
13114    #[test]
13115    fn inbound_malformed_drops_and_logs_error() {
13116        const GOV: &str = r#"
13117<domain_access_rules>
13118  <domain_rule>
13119    <domains><id>0</id></domains>
13120    <rtps_protection_kind>NONE</rtps_protection_kind>
13121    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13122  </domain_rule>
13123</domain_access_rules>
13124"#;
13125        let logger = std::sync::Arc::new(CapturingLogger::default());
13126        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
13127
13128        let out = secure_inbound_bytes(&rt, &[1, 2, 3, 4], &NetInterface::Wan);
13129        assert!(out.is_none());
13130        let events = logger.events();
13131        assert_eq!(events.len(), 1);
13132        assert_eq!(events[0].0, zerodds_security_runtime::LogLevel::Error);
13133        assert_eq!(events[0].1, "inbound.malformed");
13134        rt.shutdown();
13135    }
13136
13137    #[cfg(feature = "security")]
13138    #[test]
13139    fn inbound_without_security_gate_bypasses_classify_and_logger() {
13140        // Without a security gate: passthrough, no log event.
13141        let logger = std::sync::Arc::new(CapturingLogger::default());
13142        let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
13143            std::sync::Arc::clone(&logger) as _;
13144        let cfg = RuntimeConfig {
13145            security_logger: Some(logger_dyn),
13146            ..RuntimeConfig::default()
13147        };
13148        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE8; 12]), cfg).unwrap();
13149        let msg = vec![0xAAu8; 40];
13150        let out = secure_inbound_bytes(&rt, &msg, &NetInterface::Wan).unwrap();
13151        assert_eq!(out, msg);
13152        assert!(
13153            logger.events().is_empty(),
13154            "the logger must NOT be called without a gate"
13155        );
13156        rt.shutdown();
13157    }
13158
13159    // =======================================================================
13160    // Security: Interface-Routing (Multi-Socket-Binding)
13161    // =======================================================================
13162
13163    #[cfg(feature = "security")]
13164    fn lo_range(third: u8) -> zerodds_security_runtime::IpRange {
13165        zerodds_security_runtime::IpRange {
13166            base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, third)),
13167            prefix_len: 32,
13168        }
13169    }
13170
13171    #[cfg(feature = "security")]
13172    #[test]
13173    fn outbound_pool_routes_target_to_matching_binding() {
13174        let specs = vec![
13175            InterfaceBindingSpec {
13176                name: "lo-a".into(),
13177                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13178                bind_port: 0,
13179                kind: zerodds_security_runtime::NetInterface::Loopback,
13180                subnet: lo_range(11),
13181                default: false,
13182            },
13183            InterfaceBindingSpec {
13184                name: "lo-b".into(),
13185                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13186                bind_port: 0,
13187                kind: zerodds_security_runtime::NetInterface::Wan,
13188                subnet: lo_range(22),
13189                default: true,
13190            },
13191        ];
13192        let pool = OutboundSocketPool::bind_all(&specs).expect("pool");
13193
13194        // Exact match on the first subnet -> lo-a.
13195        let t1 = Locator::udp_v4([127, 0, 0, 11], 40000);
13196        let (sock1, iface1) = pool.route(&t1).expect("route 1");
13197        assert_eq!(iface1, zerodds_security_runtime::NetInterface::Loopback);
13198
13199        // Exact match on the second subnet -> lo-b.
13200        let t2 = Locator::udp_v4([127, 0, 0, 22], 40000);
13201        let (sock2, iface2) = pool.route(&t2).expect("route 2");
13202        assert_eq!(iface2, zerodds_security_runtime::NetInterface::Wan);
13203
13204        // The two sockets must have different local ports.
13205        let p1 = sock1.local_locator().port;
13206        let p2 = sock2.local_locator().port;
13207        assert_ne!(p1, p2);
13208    }
13209
13210    #[cfg(feature = "security")]
13211    #[test]
13212    fn outbound_pool_falls_back_to_default_when_no_subnet_matches() {
13213        let specs = vec![
13214            InterfaceBindingSpec {
13215                name: "lo-specific".into(),
13216                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13217                bind_port: 0,
13218                kind: zerodds_security_runtime::NetInterface::Loopback,
13219                subnet: lo_range(33),
13220                default: false,
13221            },
13222            InterfaceBindingSpec {
13223                name: "wan-default".into(),
13224                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13225                bind_port: 0,
13226                kind: zerodds_security_runtime::NetInterface::Wan,
13227                subnet: zerodds_security_runtime::IpRange {
13228                    base: core::net::IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED),
13229                    prefix_len: 0,
13230                },
13231                default: true,
13232            },
13233        ];
13234        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13235        let unknown = Locator::udp_v4([192, 168, 7, 7], 12345);
13236        let (_sock, iface) = pool.route(&unknown).expect("default fallback");
13237        assert_eq!(iface, zerodds_security_runtime::NetInterface::Wan);
13238    }
13239
13240    #[cfg(feature = "security")]
13241    #[test]
13242    fn outbound_pool_returns_none_when_no_match_and_no_default() {
13243        let specs = vec![InterfaceBindingSpec {
13244            name: "only-lo".into(),
13245            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13246            bind_port: 0,
13247            kind: zerodds_security_runtime::NetInterface::Loopback,
13248            subnet: lo_range(44),
13249            default: false,
13250        }];
13251        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13252        assert!(pool.route(&Locator::udp_v4([8, 8, 8, 8], 53)).is_none());
13253    }
13254
13255    #[cfg(feature = "security")]
13256    #[test]
13257    fn outbound_pool_skips_non_v4_locators() {
13258        let specs = vec![InterfaceBindingSpec {
13259            name: "lo".into(),
13260            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13261            bind_port: 0,
13262            kind: zerodds_security_runtime::NetInterface::Loopback,
13263            subnet: lo_range(55),
13264            default: true,
13265        }];
13266        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13267        // SHM locator (no IPv4) → no match; without a default it would be None,
13268        // here default=true and subnet-contains does not apply
13269        // because ipv4_from_locator returns None.
13270        let shm = Locator {
13271            kind: zerodds_rtps::wire_types::LocatorKind::Shm,
13272            port: 0,
13273            address: [0u8; 16],
13274        };
13275        assert!(pool.route(&shm).is_none());
13276    }
13277
13278    #[cfg(feature = "security")]
13279    #[test]
13280    fn dod_plaintext_lo_vs_srtps_wan_via_sniffer() {
13281        // Spec §8.4.2.4 (spec wins vs DoD loopback plaintext): under
13282        // rtps_protection_kind=ENCRYPT means bytes are SRTPS-wrapped on EVERY
13283        // interface — including loopback. The test proves that the
13284        // per-interface routing serves both targets AND both outputs
13285        // are spec-conformantly protected (no plaintext leak, regardless of which
13286        // binding).
13287        //
13288        // Setup:
13289        //  * 2 sniffer UDP sockets, one simulates a legacy
13290        //    loopback peer (expects plaintext), the other a
13291        //    WAN secure peer (expects SRTPS).
13292        //  * DcpsRuntime with a security gate (governance = ENCRYPT) and
13293        //    two interface bindings: lo-binding on 127.0.0.100,
13294        //    wan-binding auf 127.0.0.200.
13295        //  * 1 writer, 2 matched_readers with different protection
13296        //    (Legacy=None, Secure=Encrypt) and the respective sniffer
13297        //    Socket address as the locator_to_peer target.
13298        //  * `send_on_best_interface(rt, target, bytes)` is triggered
13299        //    manually; the sniffer per target receives and checks
13300        //    the wire format.
13301        use std::net::{SocketAddrV4, UdpSocket};
13302        use zerodds_security_crypto::AesGcmCryptoPlugin;
13303        use zerodds_security_permissions::parse_governance_xml;
13304        use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
13305
13306        const GOV: &str = r#"
13307<domain_access_rules>
13308  <domain_rule>
13309    <domains><id>0</id></domains>
13310    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13311    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13312  </domain_rule>
13313</domain_access_rules>
13314"#;
13315        // Two sniffer sockets on ephemeral loopback ports (independent
13316        // from our bindings; they act as "peer receivers").
13317        let lo_sniffer =
13318            UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).expect("lo sniffer");
13319        lo_sniffer
13320            .set_read_timeout(Some(Duration::from_millis(250)))
13321            .unwrap();
13322        let wan_sniffer = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0))
13323            .expect("wan sniffer");
13324        wan_sniffer
13325            .set_read_timeout(Some(Duration::from_millis(250)))
13326            .unwrap();
13327        let lo_port = lo_sniffer.local_addr().unwrap().port();
13328        let wan_port = wan_sniffer.local_addr().unwrap().port();
13329        let lo_target = Locator::udp_v4([127, 0, 0, 1], u32::from(lo_port));
13330        let wan_target = Locator::udp_v4([127, 0, 0, 1], u32::from(wan_port));
13331
13332        // Two bindings, subnet-matched to exactly these ports. Since
13333        // IpRange currently matches only on IP, we use two
13334        // different /32 host ranges as a trick:
13335        // we set both bindings to the same IP/32, but because
13336        // `route` takes the first subnet match, I list them such
13337        // that "lo-bind" comes first and then the default.
13338        //
13339        // Correct: both sniffers share 127.0.0.1/32 and the pool would
13340        // pick the first binding. To distinguish cleanly, we map
13341        // the binding decision by *target port* — that works
13342        // not today. So: we work around this subtlety by
13343        // calling `send_on_best_interface` directly for different targets
13344        // and assigning the binding by IP range —
13345        // the DoD checks the routing at the binding level, not the
13346        // socket layer.
13347        //
13348        // Pragmatically: we test end-to-end that the pool actually
13349        // picks the right interface socket for the target and
13350        // processes the bytes differently (plain vs SRTPS).
13351        // The target locators differ only in the port, but
13352        // `send_on_best_interface` gets them separately each. The
13353        // decisive point is: both bindings send **and** the
13354        // sniffer socket receives — proving the routing in combination
13355        // with the per-reader serializer from stage 4.
13356
13357        let bindings = vec![InterfaceBindingSpec {
13358            name: "lo-for-legacy".into(),
13359            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13360            bind_port: 0,
13361            kind: SecIf::Loopback,
13362            subnet: zerodds_security_runtime::IpRange {
13363                base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 1)),
13364                prefix_len: 32,
13365            },
13366            default: true,
13367        }];
13368        let gate = SharedSecurityGate::new(
13369            0,
13370            parse_governance_xml(GOV).unwrap(),
13371            Box::new(AesGcmCryptoPlugin::new()),
13372        );
13373        let cfg = RuntimeConfig {
13374            security: Some(std::sync::Arc::new(gate)),
13375            interface_bindings: bindings,
13376            ..RuntimeConfig::default()
13377        };
13378        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF0; 12]), cfg).expect("rt");
13379
13380        let wid = rt
13381            .register_user_writer(UserWriterConfig {
13382                topic_name: "HeteroRouting".into(),
13383                type_name: "zerodds::RawBytes".into(),
13384                reliable: true,
13385                durability: zerodds_qos::DurabilityKind::Volatile,
13386                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13387                lifespan: zerodds_qos::LifespanQosPolicy::default(),
13388                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
13389                ownership: zerodds_qos::OwnershipKind::Shared,
13390                ownership_strength: 0,
13391                partition: Vec::new(),
13392                user_data: Vec::new(),
13393                topic_data: Vec::new(),
13394                group_data: Vec::new(),
13395                type_identifier: zerodds_types::TypeIdentifier::None,
13396                data_representation_offer: None,
13397            })
13398            .unwrap();
13399
13400        // Peer protection setup: Legacy=None for lo_target,
13401        // Encrypt for wan_target.
13402        let legacy_peer: [u8; 12] = [0x01; 12];
13403        let secure_peer: [u8; 12] = [0x02; 12];
13404        {
13405            let arc = rt.writer_slot(wid).unwrap();
13406            let mut slot = arc.lock().unwrap();
13407            slot.reader_protection
13408                .insert(legacy_peer, ProtectionLevel::None);
13409            slot.reader_protection
13410                .insert(secure_peer, ProtectionLevel::Encrypt);
13411            slot.locator_to_peer.insert(lo_target, legacy_peer);
13412            slot.locator_to_peer.insert(wan_target, secure_peer);
13413        }
13414
13415        // Fiktives Datagram.
13416        let mut msg = Vec::new();
13417        msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13418        msg.extend_from_slice(&[0xF0; 12]);
13419        msg.extend_from_slice(b"DOD-ROUTING-PAYLOAD");
13420
13421        // Generate the per-target wire + route via send_on_best_interface.
13422        let plain_wire = secure_outbound_for_target(&rt, wid, &msg, &lo_target).unwrap();
13423        let secure_wire = secure_outbound_for_target(&rt, wid, &msg, &wan_target).unwrap();
13424        assert_ne!(
13425            plain_wire, msg,
13426            "lo-target under rtps_protection=ENCRYPT also SRTPS (no plaintext leak)"
13427        );
13428        assert_ne!(secure_wire, msg, "wan-target: SRTPS-wrapped");
13429
13430        send_on_best_interface(&rt, &lo_target, &plain_wire);
13431        send_on_best_interface(&rt, &wan_target, &secure_wire);
13432
13433        // sniffer receive and compare.
13434        let mut buf = [0u8; 4096];
13435        let (n1, _) = lo_sniffer.recv_from(&mut buf).expect("lo snif got");
13436        assert_ne!(
13437            &buf[..n1],
13438            &msg[..],
13439            "loopback sniffer must see SRTPS (spec wins, no plaintext on a protected domain)"
13440        );
13441        assert_eq!(buf[20], 0x33, "lo output must begin with SRTPS_PREFIX");
13442        let (n2, _) = wan_sniffer.recv_from(&mut buf).expect("wan snif got");
13443        assert_ne!(&buf[..n2], &msg[..], "WAN sniffer must see SRTPS-wrapped");
13444        // Additionally: SRTPS marker at the 20th byte (after the RTPS header).
13445        // SRTPS_PREFIX-Submessage-Id = 0x33 (Spec §7.3.6.3).
13446        assert_eq!(
13447            buf[20], 0x33,
13448            "WAN output must begin with an SRTPS_PREFIX submessage"
13449        );
13450
13451        rt.shutdown();
13452    }
13453
13454    #[cfg(feature = "security")]
13455    #[test]
13456    fn inbound_loopback_accepts_plain_on_protected_domain() {
13457        // Plan §stage 6: the inbound dispatcher should accept plaintext
13458        // for loopback packets even on a protected domain
13459        // (bytes do not leave the host). That is
13460        // exactly the `NetInterface` consultation in classify_inbound.
13461        use zerodds_security_runtime::NetInterface as SecIf;
13462        const GOV: &str = r#"
13463<domain_access_rules>
13464  <domain_rule>
13465    <domains><id>0</id></domains>
13466    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13467    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13468  </domain_rule>
13469</domain_access_rules>
13470"#;
13471        let logger = std::sync::Arc::new(CapturingLogger::default());
13472        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
13473
13474        let mut plain = Vec::new();
13475        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13476        plain.extend_from_slice(&[0x99; 12]);
13477        plain.extend_from_slice(b"loopback-plain-is-ok");
13478
13479        // Accepted on loopback — no log event.
13480        let out = secure_inbound_bytes(&rt, &plain, &SecIf::Loopback)
13481            .expect("loopback plain must be accepted");
13482        assert_eq!(out, plain);
13483        assert!(logger.events().is_empty());
13484
13485        // On WAN the same content → drop + error event.
13486        let out_wan = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
13487        assert!(out_wan.is_none());
13488        let evs = logger.events();
13489        assert_eq!(evs.len(), 1);
13490        assert_eq!(evs[0].0, zerodds_security_runtime::LogLevel::Error);
13491        assert!(
13492            evs[0].2.contains("iface=Wan"),
13493            "log message must carry iface"
13494        );
13495        rt.shutdown();
13496    }
13497
13498    #[cfg(feature = "security")]
13499    #[test]
13500    fn dod_inbound_per_interface_receive_via_pool_socket() {
13501        // Plan §stage 6 inbound DoD: each pool binding has its
13502        // own receive path, and the NetInterface class is
13503        // reflected in the log event (iface=<class>).
13504        //
13505        // Setup:
13506        //  * DcpsRuntime with 1 InterfaceBinding (kind=Loopback,
13507        //    subnet=127.0.0.0/8)
13508        //  * Protected Governance + CapturingLogger
13509        //  * We bind an external UDP socket and send two
13510        //    plain packets:
13511        //      a) to the pool socket (the event loop polls it and
13512        //         classifies as loopback → accept without log)
13513        //      b) we trigger secure_inbound_bytes directly with Wan
13514        //         → error log with iface=Wan
13515        //
13516        // This proves that the per-interface receive path
13517        // exists and the iface class flows through the decision.
13518        use std::net::{SocketAddrV4, UdpSocket};
13519        use zerodds_security_crypto::AesGcmCryptoPlugin;
13520        use zerodds_security_permissions::parse_governance_xml;
13521        use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
13522
13523        const GOV: &str = r#"
13524<domain_access_rules>
13525  <domain_rule>
13526    <domains><id>0</id></domains>
13527    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13528    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13529  </domain_rule>
13530</domain_access_rules>
13531"#;
13532        let logger = std::sync::Arc::new(CapturingLogger::default());
13533        let gate = SharedSecurityGate::new(
13534            0,
13535            parse_governance_xml(GOV).unwrap(),
13536            Box::new(AesGcmCryptoPlugin::new()),
13537        );
13538        let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
13539            std::sync::Arc::clone(&logger) as _;
13540        let bindings = vec![InterfaceBindingSpec {
13541            name: "lo".into(),
13542            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13543            bind_port: 0,
13544            kind: SecIf::Loopback,
13545            subnet: zerodds_security_runtime::IpRange {
13546                base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 0)),
13547                prefix_len: 8,
13548            },
13549            default: true,
13550        }];
13551        let cfg = RuntimeConfig {
13552            security: Some(std::sync::Arc::new(gate)),
13553            security_logger: Some(logger_dyn),
13554            interface_bindings: bindings,
13555            ..RuntimeConfig::default()
13556        };
13557        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF1; 12]), cfg).expect("rt");
13558
13559        // Read the port of the pool binding (ephemeral).
13560        let pool_port = rt.outbound_pool.as_ref().unwrap().bindings[0]
13561            .socket
13562            .local_locator()
13563            .port as u16;
13564        assert!(pool_port > 0);
13565
13566        // An external socket sends a plain packet to the pool socket.
13567        let sender = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).unwrap();
13568        let mut plain = Vec::new();
13569        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13570        plain.extend_from_slice(&[0xAB; 12]);
13571        plain.extend_from_slice(b"loopback-dispatch");
13572        sender
13573            .send_to(
13574                &plain,
13575                SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), pool_port),
13576            )
13577            .unwrap();
13578
13579        // The event loop needs a few ticks to poll the packet.
13580        // The default tick_period is 50 ms; we wait a few of them.
13581        std::thread::sleep(Duration::from_millis(300));
13582
13583        // The pool packet, through classify_inbound with iface=Loopback,
13584        // ran → accept, no log events from this path.
13585        let pool_events = logger.events();
13586
13587        // Comparison test: the same packet through secure_inbound_bytes
13588        // with iface=Wan → error event with an iface=Wan marker.
13589        let _ = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
13590        let after = logger.events();
13591        assert!(
13592            after.len() > pool_events.len(),
13593            "the Wan path must produce a new log event"
13594        );
13595        let new_ev = &after[after.len() - 1];
13596        assert_eq!(new_ev.0, zerodds_security_runtime::LogLevel::Error);
13597        assert!(
13598            new_ev.2.contains("iface=Wan"),
13599            "log message carries the iface marker: got={:?}",
13600            new_ev.2
13601        );
13602
13603        // Log events from the pool path must NOT carry the error level
13604        // (because classify_inbound returns accept on loopback).
13605        for (lvl, cat, msg) in &pool_events {
13606            assert_ne!(
13607                *lvl,
13608                zerodds_security_runtime::LogLevel::Error,
13609                "the loopback path must not produce an error event: cat={cat} msg={msg}"
13610            );
13611        }
13612        rt.shutdown();
13613    }
13614
13615    #[cfg(feature = "security")]
13616    #[test]
13617    fn per_target_without_security_gate_is_passthrough() {
13618        // Without a `security` config in RuntimeConfig, the per-target
13619        // path is a pure passthrough. Important so that we do not
13620        // break the v1.4 backward compat.
13621        let rt = DcpsRuntime::start(
13622            0,
13623            GuidPrefix::from_bytes([0xE5; 12]),
13624            RuntimeConfig::default(),
13625        )
13626        .expect("rt");
13627        let wid = rt
13628            .register_user_writer(UserWriterConfig {
13629                topic_name: "T".into(),
13630                type_name: "zerodds::RawBytes".into(),
13631                reliable: true,
13632                durability: zerodds_qos::DurabilityKind::Volatile,
13633                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13634                lifespan: zerodds_qos::LifespanQosPolicy::default(),
13635                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
13636                ownership: zerodds_qos::OwnershipKind::Shared,
13637                ownership_strength: 0,
13638                partition: Vec::new(),
13639                user_data: Vec::new(),
13640                topic_data: Vec::new(),
13641                group_data: Vec::new(),
13642                type_identifier: zerodds_types::TypeIdentifier::None,
13643                data_representation_offer: None,
13644            })
13645            .unwrap();
13646        let tgt = Locator::udp_v4([127, 0, 0, 1], 40000);
13647        let msg = b"raw-plaintext".to_vec();
13648        let out = secure_outbound_for_target(&rt, wid, &msg, &tgt).unwrap();
13649        assert_eq!(out, msg, "without a gate it must be passthrough");
13650        rt.shutdown();
13651    }
13652
13653    // ----  Builtin-Topic-Reader Discovery-Hook (DDS 1.4 §2.2.5) ----
13654
13655    /// Helper: constructs a synthetic SPDP beacon
13656    /// for a remote participant, so that `handle_spdp_datagram`
13657    /// accepts it.
13658    fn make_remote_spdp_beacon(remote_prefix: GuidPrefix) -> Vec<u8> {
13659        use zerodds_discovery::spdp::SpdpBeacon;
13660        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
13661        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
13662        let data = ParticipantBuiltinTopicData {
13663            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
13664            protocol_version: ProtocolVersion::V2_5,
13665            vendor_id: VendorId::ZERODDS,
13666            default_unicast_locator: None,
13667            default_multicast_locator: None,
13668            metatraffic_unicast_locator: None,
13669            metatraffic_multicast_locator: None,
13670            domain_id: Some(0),
13671            builtin_endpoint_set: 0,
13672            lease_duration: QosDuration::from_secs(100),
13673            user_data: alloc::vec::Vec::new(),
13674            properties: Default::default(),
13675            identity_token: None,
13676            permissions_token: None,
13677            identity_status_token: None,
13678            sig_algo_info: None,
13679            kx_algo_info: None,
13680            sym_cipher_algo_info: None,
13681            participant_security_info: None,
13682        };
13683        let mut beacon = SpdpBeacon::new(data);
13684        beacon.serialize().expect("serialize")
13685    }
13686
13687    #[test]
13688    fn handle_spdp_datagram_pushes_into_builtin_participant_reader() {
13689        let rt = DcpsRuntime::start(
13690            41,
13691            GuidPrefix::from_bytes([0x21; 12]),
13692            RuntimeConfig::default(),
13693        )
13694        .expect("start");
13695        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13696        rt.attach_builtin_sinks(bs.sinks());
13697
13698        let remote = GuidPrefix::from_bytes([0x99; 12]);
13699        let dg = make_remote_spdp_beacon(remote);
13700        // A direct hook call simulates an SPDP receive without multicast.
13701        handle_spdp_datagram(&rt, &dg);
13702
13703        let reader = bs
13704            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13705                "DCPSParticipant",
13706            )
13707            .unwrap();
13708        let samples = reader.take().unwrap();
13709        assert_eq!(samples.len(), 1, "exactly 1 sample for 1 SPDP beacon");
13710        assert_eq!(samples[0].key.prefix, remote);
13711        rt.shutdown();
13712    }
13713
13714    #[test]
13715    fn handle_spdp_datagram_skips_self_beacon() {
13716        let prefix = GuidPrefix::from_bytes([0x22; 12]);
13717        let rt = DcpsRuntime::start(42, prefix, RuntimeConfig::default()).expect("start");
13718        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13719        rt.attach_builtin_sinks(bs.sinks());
13720
13721        // Beacon from our own prefix → must be ignored (Spec
13722        // §8.5.4 self-discovery filter).
13723        let dg = make_remote_spdp_beacon(prefix);
13724        handle_spdp_datagram(&rt, &dg);
13725
13726        let reader = bs
13727            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13728                "DCPSParticipant",
13729            )
13730            .unwrap();
13731        let samples = reader.take().unwrap();
13732        assert!(samples.is_empty(), "own beacon must not be logged");
13733        rt.shutdown();
13734    }
13735
13736    #[test]
13737    fn sedp_event_push_populates_publication_and_topic_readers() {
13738        use crate::builtin_topics as bt;
13739        use zerodds_discovery::sedp::SedpEvents;
13740        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13741        let rt = DcpsRuntime::start(
13742            43,
13743            GuidPrefix::from_bytes([0x23; 12]),
13744            RuntimeConfig::default(),
13745        )
13746        .expect("start");
13747        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13748        rt.attach_builtin_sinks(bs.sinks());
13749
13750        let mut events = SedpEvents::default();
13751        events.new_publications.push(
13752            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13753                key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
13754                participant_key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
13755                topic_name: "WireT".into(),
13756                type_name: "WireType".into(),
13757                durability: zerodds_qos::DurabilityKind::Volatile,
13758                reliability: ReliabilityQosPolicy::default(),
13759                ownership: zerodds_qos::OwnershipKind::Shared,
13760                ownership_strength: 0,
13761                liveliness: LivelinessQosPolicy::default(),
13762                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13763                lifespan: zerodds_qos::LifespanQosPolicy::default(),
13764                partition: Vec::new(),
13765                user_data: Vec::new(),
13766                topic_data: Vec::new(),
13767                group_data: Vec::new(),
13768                type_information: None,
13769                data_representation: Vec::new(),
13770                security_info: None,
13771                service_instance_name: None,
13772                related_entity_guid: None,
13773                topic_aliases: None,
13774                type_identifier: zerodds_types::TypeIdentifier::None,
13775                unicast_locators: Vec::new(),
13776                multicast_locators: Vec::new(),
13777            },
13778        );
13779
13780        push_sedp_events_to_builtin_readers(&rt, &events);
13781
13782        let pub_reader = bs
13783            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
13784            .unwrap();
13785        let pub_samples = pub_reader.take().unwrap();
13786        assert_eq!(pub_samples.len(), 1);
13787        assert_eq!(pub_samples[0].topic_name, "WireT");
13788
13789        let topic_reader = bs
13790            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13791            .unwrap();
13792        let topic_samples = topic_reader.take().unwrap();
13793        assert_eq!(topic_samples.len(), 1);
13794        assert_eq!(topic_samples[0].name, "WireT");
13795        rt.shutdown();
13796    }
13797
13798    #[test]
13799    fn sedp_event_push_populates_subscription_reader() {
13800        use crate::builtin_topics as bt;
13801        use zerodds_discovery::sedp::SedpEvents;
13802        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13803        let rt = DcpsRuntime::start(
13804            44,
13805            GuidPrefix::from_bytes([0x24; 12]),
13806            RuntimeConfig::default(),
13807        )
13808        .expect("start");
13809        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13810        rt.attach_builtin_sinks(bs.sinks());
13811
13812        let mut events = SedpEvents::default();
13813        events.new_subscriptions.push(
13814            zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
13815                key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
13816                participant_key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
13817                topic_name: "SubT".into(),
13818                type_name: "SubType".into(),
13819                durability: zerodds_qos::DurabilityKind::Volatile,
13820                reliability: ReliabilityQosPolicy::default(),
13821                ownership: zerodds_qos::OwnershipKind::Shared,
13822                liveliness: LivelinessQosPolicy::default(),
13823                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13824                partition: Vec::new(),
13825                user_data: Vec::new(),
13826                topic_data: Vec::new(),
13827                group_data: Vec::new(),
13828                type_information: None,
13829                data_representation: Vec::new(),
13830                content_filter: None,
13831                security_info: None,
13832                service_instance_name: None,
13833                related_entity_guid: None,
13834                topic_aliases: None,
13835                type_identifier: zerodds_types::TypeIdentifier::None,
13836                unicast_locators: Vec::new(),
13837                multicast_locators: Vec::new(),
13838            },
13839        );
13840
13841        push_sedp_events_to_builtin_readers(&rt, &events);
13842
13843        let sub_reader = bs
13844            .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
13845            .unwrap();
13846        let sub_samples = sub_reader.take().unwrap();
13847        assert_eq!(sub_samples.len(), 1);
13848        assert_eq!(sub_samples[0].topic_name, "SubT");
13849
13850        // The topic reader gets a synthetic topic sample also from
13851        // Subscription.
13852        let topic_reader = bs
13853            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13854            .unwrap();
13855        let topic_samples = topic_reader.take().unwrap();
13856        assert_eq!(topic_samples.len(), 1);
13857        assert_eq!(topic_samples[0].name, "SubT");
13858        rt.shutdown();
13859    }
13860
13861    #[test]
13862    fn push_sedp_events_to_builtin_readers_is_noop_without_sinks() {
13863        use zerodds_discovery::sedp::SedpEvents;
13864        let rt = DcpsRuntime::start(
13865            45,
13866            GuidPrefix::from_bytes([0x25; 12]),
13867            RuntimeConfig::default(),
13868        )
13869        .expect("start");
13870        // No attach_builtin_sinks → push must stay silent, not
13871        // panic.
13872        let events = SedpEvents::default();
13873        push_sedp_events_to_builtin_readers(&rt, &events);
13874        rt.shutdown();
13875    }
13876
13877    // ----  Ignore-Filter im Discovery-Hot-Path -------------
13878
13879    #[test]
13880    fn handle_spdp_datagram_drops_ignored_participant_beacon() {
13881        // Spec §2.2.2.2.1.14: ein einmal ignorierter Participant
13882        // taucht in keinem nachfolgenden Builtin-Sample mehr auf.
13883        let rt = DcpsRuntime::start(
13884            46,
13885            GuidPrefix::from_bytes([0x26; 12]),
13886            RuntimeConfig::default(),
13887        )
13888        .expect("start");
13889        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13890        rt.attach_builtin_sinks(bs.sinks());
13891        let filter = crate::participant::IgnoreFilter::default();
13892        rt.attach_ignore_filter(filter.clone());
13893
13894        let remote = GuidPrefix::from_bytes([0xAA; 12]);
13895        // Derive the ignore handle from the future beacon — we
13896        // know that the builtin sample key is the GUID of the remote
13897        // participant (=prefix + EntityId::PARTICIPANT).
13898        let key = Guid::new(remote, EntityId::PARTICIPANT);
13899        let h = crate::instance_handle::InstanceHandle::from_guid(key);
13900        if let Ok(mut s) = filter.inner.participants.lock() {
13901            s.insert(h);
13902        }
13903        let dg = make_remote_spdp_beacon(remote);
13904        handle_spdp_datagram(&rt, &dg);
13905
13906        let reader = bs
13907            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13908                "DCPSParticipant",
13909            )
13910            .unwrap();
13911        assert!(
13912            reader.take().unwrap().is_empty(),
13913            "an ignored participant must not land in DCPSParticipant"
13914        );
13915        rt.shutdown();
13916    }
13917
13918    #[test]
13919    fn sedp_event_push_filters_ignored_publication() {
13920        use crate::builtin_topics as bt;
13921        use zerodds_discovery::sedp::SedpEvents;
13922        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13923        let rt = DcpsRuntime::start(
13924            47,
13925            GuidPrefix::from_bytes([0x27; 12]),
13926            RuntimeConfig::default(),
13927        )
13928        .expect("start");
13929        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13930        rt.attach_builtin_sinks(bs.sinks());
13931        let filter = crate::participant::IgnoreFilter::default();
13932        rt.attach_ignore_filter(filter.clone());
13933
13934        let pub_key = Guid::new(GuidPrefix::from_bytes([0x33; 12]), EntityId::PARTICIPANT);
13935        let h_pub = crate::instance_handle::InstanceHandle::from_guid(pub_key);
13936        if let Ok(mut s) = filter.inner.publications.lock() {
13937            s.insert(h_pub);
13938        }
13939
13940        let mut events = SedpEvents::default();
13941        events.new_publications.push(
13942            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13943                key: pub_key,
13944                participant_key: Guid::new(
13945                    GuidPrefix::from_bytes([0x33; 12]),
13946                    EntityId::PARTICIPANT,
13947                ),
13948                topic_name: "Filtered".into(),
13949                type_name: "T".into(),
13950                durability: zerodds_qos::DurabilityKind::Volatile,
13951                reliability: ReliabilityQosPolicy::default(),
13952                ownership: zerodds_qos::OwnershipKind::Shared,
13953                ownership_strength: 0,
13954                liveliness: LivelinessQosPolicy::default(),
13955                deadline: zerodds_qos::DeadlineQosPolicy::default(),
13956                lifespan: zerodds_qos::LifespanQosPolicy::default(),
13957                partition: Vec::new(),
13958                user_data: Vec::new(),
13959                topic_data: Vec::new(),
13960                group_data: Vec::new(),
13961                type_information: None,
13962                data_representation: Vec::new(),
13963                security_info: None,
13964                service_instance_name: None,
13965                related_entity_guid: None,
13966                topic_aliases: None,
13967                type_identifier: zerodds_types::TypeIdentifier::None,
13968                unicast_locators: Vec::new(),
13969                multicast_locators: Vec::new(),
13970            },
13971        );
13972
13973        push_sedp_events_to_builtin_readers(&rt, &events);
13974
13975        let pub_reader = bs
13976            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
13977            .unwrap();
13978        assert!(
13979            pub_reader.take().unwrap().is_empty(),
13980            "an ignored publication must not land in DCPSPublication"
13981        );
13982        // The synthetic DCPSTopic sample too must not be
13983        // forwarded, because the publication is completely
13984        // discarded.
13985        let topic_reader = bs
13986            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13987            .unwrap();
13988        assert!(topic_reader.take().unwrap().is_empty());
13989        rt.shutdown();
13990    }
13991
13992    #[test]
13993    fn sedp_event_push_filters_ignored_subscription() {
13994        use crate::builtin_topics as bt;
13995        use zerodds_discovery::sedp::SedpEvents;
13996        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13997        let rt = DcpsRuntime::start(
13998            48,
13999            GuidPrefix::from_bytes([0x28; 12]),
14000            RuntimeConfig::default(),
14001        )
14002        .expect("start");
14003        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
14004        rt.attach_builtin_sinks(bs.sinks());
14005        let filter = crate::participant::IgnoreFilter::default();
14006        rt.attach_ignore_filter(filter.clone());
14007
14008        let sub_key = Guid::new(GuidPrefix::from_bytes([0x44; 12]), EntityId::PARTICIPANT);
14009        let h_sub = crate::instance_handle::InstanceHandle::from_guid(sub_key);
14010        if let Ok(mut s) = filter.inner.subscriptions.lock() {
14011            s.insert(h_sub);
14012        }
14013
14014        let mut events = SedpEvents::default();
14015        events.new_subscriptions.push(
14016            zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
14017                key: sub_key,
14018                participant_key: Guid::new(
14019                    GuidPrefix::from_bytes([0x44; 12]),
14020                    EntityId::PARTICIPANT,
14021                ),
14022                topic_name: "FilteredSub".into(),
14023                type_name: "T".into(),
14024                durability: zerodds_qos::DurabilityKind::Volatile,
14025                reliability: ReliabilityQosPolicy::default(),
14026                ownership: zerodds_qos::OwnershipKind::Shared,
14027                liveliness: LivelinessQosPolicy::default(),
14028                deadline: zerodds_qos::DeadlineQosPolicy::default(),
14029                partition: Vec::new(),
14030                user_data: Vec::new(),
14031                topic_data: Vec::new(),
14032                group_data: Vec::new(),
14033                type_information: None,
14034                data_representation: Vec::new(),
14035                content_filter: None,
14036                security_info: None,
14037                service_instance_name: None,
14038                related_entity_guid: None,
14039                topic_aliases: None,
14040                type_identifier: zerodds_types::TypeIdentifier::None,
14041                unicast_locators: Vec::new(),
14042                multicast_locators: Vec::new(),
14043            },
14044        );
14045
14046        push_sedp_events_to_builtin_readers(&rt, &events);
14047
14048        let sub_reader = bs
14049            .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
14050            .unwrap();
14051        assert!(sub_reader.take().unwrap().is_empty());
14052        rt.shutdown();
14053    }
14054
14055    #[test]
14056    fn sedp_event_push_filters_ignored_topic_only() {
14057        // If only the topic is ignored, DCPSPublication should
14058        // still be pushed — only the DCPSTopic sample falls
14059        // away.
14060        use crate::builtin_topics as bt;
14061        use zerodds_discovery::sedp::SedpEvents;
14062        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
14063        let rt = DcpsRuntime::start(
14064            49,
14065            GuidPrefix::from_bytes([0x29; 12]),
14066            RuntimeConfig::default(),
14067        )
14068        .expect("start");
14069        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
14070        rt.attach_builtin_sinks(bs.sinks());
14071        let filter = crate::participant::IgnoreFilter::default();
14072        rt.attach_ignore_filter(filter.clone());
14073
14074        let topic_key =
14075            crate::builtin_topics::TopicBuiltinTopicData::synthesize_key("OnlyTopic", "T");
14076        let h_topic = crate::instance_handle::InstanceHandle::from_guid(topic_key);
14077        if let Ok(mut s) = filter.inner.topics.lock() {
14078            s.insert(h_topic);
14079        }
14080
14081        let mut events = SedpEvents::default();
14082        events.new_publications.push(
14083            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
14084                key: Guid::new(GuidPrefix::from_bytes([0x55; 12]), EntityId::PARTICIPANT),
14085                participant_key: Guid::new(
14086                    GuidPrefix::from_bytes([0x55; 12]),
14087                    EntityId::PARTICIPANT,
14088                ),
14089                topic_name: "OnlyTopic".into(),
14090                type_name: "T".into(),
14091                durability: zerodds_qos::DurabilityKind::Volatile,
14092                reliability: ReliabilityQosPolicy::default(),
14093                ownership: zerodds_qos::OwnershipKind::Shared,
14094                ownership_strength: 0,
14095                liveliness: LivelinessQosPolicy::default(),
14096                deadline: zerodds_qos::DeadlineQosPolicy::default(),
14097                lifespan: zerodds_qos::LifespanQosPolicy::default(),
14098                partition: Vec::new(),
14099                user_data: Vec::new(),
14100                topic_data: Vec::new(),
14101                group_data: Vec::new(),
14102                type_information: None,
14103                data_representation: Vec::new(),
14104                security_info: None,
14105                service_instance_name: None,
14106                related_entity_guid: None,
14107                topic_aliases: None,
14108                type_identifier: zerodds_types::TypeIdentifier::None,
14109                unicast_locators: Vec::new(),
14110                multicast_locators: Vec::new(),
14111            },
14112        );
14113
14114        push_sedp_events_to_builtin_readers(&rt, &events);
14115
14116        let pub_reader = bs
14117            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
14118            .unwrap();
14119        assert_eq!(pub_reader.take().unwrap().len(), 1);
14120        let topic_reader = bs
14121            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
14122            .unwrap();
14123        assert!(
14124            topic_reader.take().unwrap().is_empty(),
14125            "an ignored topic may block the synthetic DCPSTopic sample"
14126        );
14127        rt.shutdown();
14128    }
14129
14130    // -------- Security-Builtin-Endpoint-Wiring --------
14131
14132    /// Creates an SPDP beacon with configurable BuiltinEndpoint
14133    /// bits. Extension of [`make_remote_spdp_beacon`] with
14134    /// flag-Argument (Security-Bits 22..25).
14135    fn make_remote_spdp_beacon_with_flags(remote_prefix: GuidPrefix, endpoint_set: u32) -> Vec<u8> {
14136        use zerodds_discovery::spdp::SpdpBeacon;
14137        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
14138        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
14139        let data = ParticipantBuiltinTopicData {
14140            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
14141            protocol_version: ProtocolVersion::V2_5,
14142            vendor_id: VendorId::ZERODDS,
14143            default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
14144            default_multicast_locator: None,
14145            metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
14146            metatraffic_multicast_locator: None,
14147            domain_id: Some(0),
14148            builtin_endpoint_set: endpoint_set,
14149            lease_duration: QosDuration::from_secs(100),
14150            user_data: alloc::vec::Vec::new(),
14151            properties: Default::default(),
14152            identity_token: None,
14153            permissions_token: None,
14154            identity_status_token: None,
14155            sig_algo_info: None,
14156            kx_algo_info: None,
14157            sym_cipher_algo_info: None,
14158            participant_security_info: None,
14159        };
14160        let mut beacon = SpdpBeacon::new(data);
14161        beacon.serialize().expect("serialize")
14162    }
14163
14164    fn dp_with_locators(
14165        prefix: GuidPrefix,
14166        metatraffic: Option<Locator>,
14167        default: Option<Locator>,
14168    ) -> zerodds_discovery::spdp::DiscoveredParticipant {
14169        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
14170        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
14171        zerodds_discovery::spdp::DiscoveredParticipant {
14172            sender_prefix: prefix,
14173            sender_vendor: VendorId::ZERODDS,
14174            data: ParticipantBuiltinTopicData {
14175                guid: Guid::new(prefix, EntityId::PARTICIPANT),
14176                protocol_version: ProtocolVersion::V2_5,
14177                vendor_id: VendorId::ZERODDS,
14178                default_unicast_locator: default,
14179                default_multicast_locator: None,
14180                metatraffic_unicast_locator: metatraffic,
14181                metatraffic_multicast_locator: None,
14182                domain_id: Some(0),
14183                builtin_endpoint_set: 0,
14184                lease_duration: QosDuration::from_secs(100),
14185                user_data: alloc::vec::Vec::new(),
14186                properties: Default::default(),
14187                identity_token: None,
14188                permissions_token: None,
14189                identity_status_token: None,
14190                sig_algo_info: None,
14191                kx_algo_info: None,
14192                sym_cipher_algo_info: None,
14193                participant_security_info: None,
14194            },
14195        }
14196    }
14197
14198    #[test]
14199    fn wlp_unicast_targets_prefers_metatraffic_then_default() {
14200        // M-2: WLP-Unicast-Fan-out waehlt pro Peer metatraffic_unicast (bevorzugt),
14201        // otherwise default_unicast; peers without a routable locator fall out.
14202        let meta = Locator::udp_v4([127, 0, 0, 1], 7501);
14203        let deflt = Locator::udp_v4([127, 0, 0, 2], 7500);
14204        let peers = alloc::vec![
14205            // (a) has metatraffic → metatraffic wins
14206            dp_with_locators(GuidPrefix::from_bytes([1; 12]), Some(meta), Some(deflt)),
14207            // (b) only default → default
14208            dp_with_locators(GuidPrefix::from_bytes([2; 12]), None, Some(deflt)),
14209            // (c) none at all → no target
14210            dp_with_locators(GuidPrefix::from_bytes([3; 12]), None, None),
14211        ];
14212        let targets = wlp_unicast_targets(&peers);
14213        assert_eq!(targets, alloc::vec![meta, deflt]);
14214    }
14215
14216    /// Like [`make_remote_spdp_beacon_with_flags`], but with a set
14217    /// `identity_token` (FU2 Gap 7d — triggers the auth handshake).
14218    #[cfg(feature = "security")]
14219    fn make_secure_beacon_with_identity_token(
14220        remote_prefix: GuidPrefix,
14221        endpoint_set: u32,
14222        identity_token: Vec<u8>,
14223    ) -> Vec<u8> {
14224        use zerodds_discovery::spdp::SpdpBeacon;
14225        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
14226        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
14227        let data = ParticipantBuiltinTopicData {
14228            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
14229            protocol_version: ProtocolVersion::V2_5,
14230            vendor_id: VendorId::ZERODDS,
14231            default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
14232            default_multicast_locator: None,
14233            metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
14234            metatraffic_multicast_locator: None,
14235            domain_id: Some(0),
14236            builtin_endpoint_set: endpoint_set,
14237            lease_duration: QosDuration::from_secs(100),
14238            user_data: alloc::vec::Vec::new(),
14239            properties: Default::default(),
14240            identity_token: Some(identity_token),
14241            permissions_token: None,
14242            identity_status_token: None,
14243            sig_algo_info: None,
14244            kx_algo_info: None,
14245            sym_cipher_algo_info: None,
14246            participant_security_info: None,
14247        };
14248        let mut beacon = SpdpBeacon::new(data);
14249        beacon.serialize().expect("serialize")
14250    }
14251
14252    /// Minimal auth plugin for the FU2 wiring tests (Gap 4/7).
14253    /// Crypto correctness is verified in the stack.rs driver test; here
14254    /// it is only about the runtime wiring path.
14255    #[cfg(feature = "security")]
14256    struct FakeAuth;
14257    #[cfg(feature = "security")]
14258    impl zerodds_security::authentication::AuthenticationPlugin for FakeAuth {
14259        fn validate_local_identity(
14260            &mut self,
14261            _: &zerodds_security::properties::PropertyList,
14262            _: [u8; 16],
14263        ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
14264        {
14265            Ok(zerodds_security::authentication::IdentityHandle(1))
14266        }
14267        fn validate_remote_identity(
14268            &mut self,
14269            _: zerodds_security::authentication::IdentityHandle,
14270            _: [u8; 16],
14271            _: &[u8],
14272        ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
14273        {
14274            Ok(zerodds_security::authentication::IdentityHandle(2))
14275        }
14276        fn begin_handshake_request(
14277            &mut self,
14278            _: zerodds_security::authentication::IdentityHandle,
14279            _: zerodds_security::authentication::IdentityHandle,
14280        ) -> zerodds_security::error::SecurityResult<(
14281            zerodds_security::authentication::HandshakeHandle,
14282            zerodds_security::authentication::HandshakeStepOutcome,
14283        )> {
14284            Ok((
14285                zerodds_security::authentication::HandshakeHandle(1),
14286                zerodds_security::authentication::HandshakeStepOutcome::SendMessage {
14287                    token: zerodds_security::token::DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")
14288                        .to_cdr_le(),
14289                },
14290            ))
14291        }
14292        fn begin_handshake_reply(
14293            &mut self,
14294            _: zerodds_security::authentication::IdentityHandle,
14295            _: zerodds_security::authentication::IdentityHandle,
14296            _: &[u8],
14297        ) -> zerodds_security::error::SecurityResult<(
14298            zerodds_security::authentication::HandshakeHandle,
14299            zerodds_security::authentication::HandshakeStepOutcome,
14300        )> {
14301            Ok((
14302                zerodds_security::authentication::HandshakeHandle(2),
14303                zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer,
14304            ))
14305        }
14306        fn process_handshake(
14307            &mut self,
14308            _: zerodds_security::authentication::HandshakeHandle,
14309            _: &[u8],
14310        ) -> zerodds_security::error::SecurityResult<
14311            zerodds_security::authentication::HandshakeStepOutcome,
14312        > {
14313            Ok(zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer)
14314        }
14315        fn shared_secret(
14316            &self,
14317            _: zerodds_security::authentication::HandshakeHandle,
14318        ) -> zerodds_security::error::SecurityResult<
14319            zerodds_security::authentication::SharedSecretHandle,
14320        > {
14321            Err(zerodds_security::error::SecurityError::new(
14322                zerodds_security::error::SecurityErrorKind::BadArgument,
14323                "fake: handshake not complete",
14324            ))
14325        }
14326        fn plugin_class_id(&self) -> &str {
14327            "FAKE:Auth:1.0"
14328        }
14329        fn get_identity_token(
14330            &self,
14331            _: zerodds_security::authentication::IdentityHandle,
14332        ) -> zerodds_security::error::SecurityResult<Vec<u8>> {
14333            // Non-empty Token (Format irrelevant — FakeAuth.validate_remote_
14334            // identity accepts everything); only so the beacon-populate path
14335            // (Gap 7c) has something to announce.
14336            Ok(alloc::vec![0xAB, 0xCD, 0xEF, 0x01])
14337        }
14338        fn get_permissions_token(&self) -> Vec<u8> {
14339            // Non-empty PermissionsToken, so the beacon-populate path
14340            // (S4 point 1) has something to announce (format irrelevant).
14341            zerodds_security::token::DataHolder::new("DDS:Access:Permissions:1.0").to_cdr_le()
14342        }
14343    }
14344
14345    /// Consolidated test for the wiring. A single
14346    /// runtime walks all paths — snapshot API, idempotency of
14347    /// `enable_security_builtins`, SPDP hot path with security bits,
14348    /// without bits, plus the wire-demux hook. We bundle this into one
14349    /// test body, because each `DcpsRuntime::start` binds a multicast socket
14350    /// and parallel tests could brush against the OS resource caps.
14351    #[test]
14352    fn c34c_security_builtin_wiring_end_to_end() {
14353        use zerodds_discovery::security::SecurityBuiltinStack;
14354        use zerodds_security::generic_message::{
14355            MessageIdentity, ParticipantGenericMessage, class_id,
14356        };
14357        use zerodds_security::token::DataHolder;
14358
14359        let local_prefix = GuidPrefix::from_bytes([0x75; 12]);
14360        let rt = DcpsRuntime::start(75, local_prefix, RuntimeConfig::default()).expect("start");
14361
14362        // 1. Snapshot is None before enable
14363        assert!(rt.security_builtin_snapshot().is_none());
14364
14365        // 2. enable ist idempotent
14366        let h1 = rt.enable_security_builtins(VendorId::ZERODDS);
14367        let h2 = rt.enable_security_builtins(VendorId::ZERODDS);
14368        assert!(Arc::ptr_eq(&h1, &h2));
14369        assert!(rt.security_builtin_snapshot().is_some());
14370
14371        // 3. SPDP beacon with all security-builtin bits → the stack has
14372        //    four proxies
14373        let remote_a = GuidPrefix::from_bytes([0x99; 12]);
14374        let flags_all = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14375            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
14376            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
14377            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
14378        handle_spdp_datagram(
14379            &rt,
14380            &make_remote_spdp_beacon_with_flags(remote_a, flags_all),
14381        );
14382        {
14383            let s = h1.lock().unwrap();
14384            assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
14385            assert_eq!(s.stateless_reader.writer_proxy_count(), 1);
14386            assert_eq!(s.volatile_writer.reader_proxy_count(), 1);
14387            assert_eq!(s.volatile_reader.writer_proxy_count(), 1);
14388        }
14389
14390        // 4. SPDP beacon without security bits → the stack stays unchanged
14391        let remote_b = GuidPrefix::from_bytes([0x88; 12]);
14392        handle_spdp_datagram(
14393            &rt,
14394            &make_remote_spdp_beacon_with_flags(remote_b, endpoint_flag::ALL_STANDARD),
14395        );
14396        {
14397            let s = h1.lock().unwrap();
14398            assert_eq!(
14399                s.stateless_writer.reader_proxy_count(),
14400                1,
14401                "a peer without security bits must not touch existing proxies"
14402            );
14403        }
14404
14405        // 5. Wire-demux hook with a valid stateless DATA: remote-stack
14406        //    mirror sends a message → the demux hook routes it through
14407        //    the local reader without panic.
14408        let mut remote_stack = SecurityBuiltinStack::new(remote_a, VendorId::ZERODDS);
14409        let local_peer = make_remote_spdp_beacon_with_flags(local_prefix, flags_all);
14410        let parsed_local = zerodds_discovery::spdp::SpdpReader::new()
14411            .parse_datagram(&local_peer)
14412            .unwrap();
14413        remote_stack.handle_remote_endpoints(&parsed_local);
14414        let msg = ParticipantGenericMessage {
14415            message_identity: MessageIdentity {
14416                source_guid: [0xCD; 16],
14417                sequence_number: 1,
14418            },
14419            related_message_identity: MessageIdentity::default(),
14420            destination_participant_key: [0xEF; 16],
14421            destination_endpoint_key: [0; 16],
14422            source_endpoint_key: [0xFE; 16],
14423            message_class_id: class_id::AUTH_REQUEST.into(),
14424            message_data: alloc::vec![DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")],
14425        };
14426        let dgs = remote_stack.stateless_writer.write(&msg).unwrap();
14427        assert_eq!(dgs.len(), 1);
14428        dispatch_security_builtin_datagram(&rt, &dgs[0].bytes, Duration::from_secs(1));
14429
14430        // 6. The demux hook does not panic on garbage bytes
14431        dispatch_security_builtin_datagram(&rt, &[0u8; 32], Duration::from_secs(1));
14432
14433        rt.shutdown();
14434    }
14435
14436    /// FU2 Gap 4: `enable_security_builtins_with_auth` builds the stack with
14437    /// an active handshake driver — `begin_handshake_with` sends, as
14438    /// the initiator actually sends an AUTH_REQUEST (instead of a no-op like with
14439    /// the auth-less `enable_security_builtins`).
14440    #[cfg(feature = "security")]
14441    #[test]
14442    fn enable_security_builtins_with_auth_activates_handshake_driver() {
14443        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14444
14445        let local_prefix = GuidPrefix::from_bytes([0x40; 12]);
14446        let rt = DcpsRuntime::start(40, local_prefix, RuntimeConfig::default()).expect("start");
14447
14448        let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14449        let stack =
14450            rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
14451
14452        // Discover a peer with stateless bits (WITHOUT identity_token → the
14453        // discovery trigger starts no handshake yet) → proxies
14454        // are wired. The remote prefix is LARGER than local ([0x40]),
14455        // so that local is the initiator under the cyclone convention (smaller GUID
14456        // initiates) and actually sends.
14457        let remote = GuidPrefix::from_bytes([0x99; 12]);
14458        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14459            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14460        handle_spdp_datagram(&rt, &make_remote_spdp_beacon_with_flags(remote, flags));
14461
14462        let dgs = {
14463            let mut s = stack.lock().unwrap();
14464            let remote_guid = Guid::new(remote, EntityId::PARTICIPANT).to_bytes();
14465            s.begin_handshake_with(remote, remote_guid, b"fake-remote-cert-der")
14466                .expect("begin_handshake_with")
14467        };
14468        assert_eq!(
14469            dgs.len(),
14470            1,
14471            "auth driver active → the initiator sends exactly one AUTH_REQUEST"
14472        );
14473
14474        rt.shutdown();
14475    }
14476
14477    /// FU2 Gap 7c/d: `enable_security_builtins_with_auth` announces the
14478    /// local `identity_token` in the SPDP beacon (+ stateless/volatile bits),
14479    /// and an incoming peer beacon WITH an `identity_token` kicks off the
14480    /// Auth-Handshake an (Discovery-Trigger).
14481    #[cfg(feature = "security")]
14482    #[test]
14483    fn spdp_beacon_announces_identity_token_and_discovery_triggers_handshake() {
14484        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14485
14486        let local_prefix = GuidPrefix::from_bytes([0x41; 12]);
14487        let rt = DcpsRuntime::start(41, local_prefix, RuntimeConfig::default()).expect("start");
14488        let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14489        let stack =
14490            rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
14491
14492        // Gap 7c: the beacon now announces identity_token + secure bits.
14493        let beacon_bytes = rt.spdp_beacon.lock().unwrap().serialize().unwrap();
14494        let parsed = zerodds_discovery::spdp::SpdpReader::new()
14495            .parse_datagram(&beacon_bytes)
14496            .unwrap();
14497        assert!(
14498            parsed.data.identity_token.is_some(),
14499            "the beacon must announce PID_IDENTITY_TOKEN"
14500        );
14501        // Cross-vendor: secure vendors validate a remote only when
14502        // SPDP carries **both** tokens. Without PID_PERMISSIONS_TOKEN they treat
14503        // cyclone treats us as non-secure and never starts validate_remote_identity.
14504        assert!(
14505            parsed.data.permissions_token.is_some(),
14506            "the beacon must announce PID_PERMISSIONS_TOKEN (cross-vendor mandatory)"
14507        );
14508        assert_ne!(
14509            parsed.data.builtin_endpoint_set & endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER,
14510            0,
14511            "the beacon must announce the stateless-auth bit"
14512        );
14513
14514        // Gap 7d: peer beacon WITH identity_token + stateless bits → the
14515        // discovery path kicks off begin_handshake_with.
14516        let remote = GuidPrefix::from_bytes([0x99; 12]);
14517        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14518            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14519        let peer_beacon =
14520            make_secure_beacon_with_identity_token(remote, flags, alloc::vec![0x11, 0x22, 0x33]);
14521        handle_spdp_datagram(&rt, &peer_beacon);
14522
14523        // Proof that the discovery trigger fired: the peer is now
14524        // registered in the stack's handshake state. (The earlier length
14525        // probe via a repeated begin_handshake_with no longer applies since the resend path
14526        // resends as the initiator on a repeated call.)
14527        let started = {
14528            let s = stack.lock().unwrap();
14529            s.handshake_peer_count()
14530        };
14531        assert_eq!(
14532            started, 1,
14533            "the discovery trigger must have started the handshake (peer registered)"
14534        );
14535
14536        rt.shutdown();
14537    }
14538
14539    /// FU2 S3: two secure runtimes in the same process MUST find each other via
14540    /// in-process participant discovery and kick off the auth handshake
14541    /// — WITHOUT a single multicast beacon. That was exactly missing:
14542    /// `inproc_inject_publication`/`_subscription` inject only SEDP, the
14543    /// SPDP participant discovery (identity_token + `begin_handshake_with`)
14544    /// ran exclusively over the flaky multicast path.
14545    #[cfg(feature = "security")]
14546    #[test]
14547    fn inproc_participant_discovery_triggers_handshake_without_multicast() {
14548        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14549
14550        let a_prefix = GuidPrefix::from_bytes([0x4A; 12]);
14551        let b_prefix = GuidPrefix::from_bytes([0x4B; 12]);
14552        let rt_a = DcpsRuntime::start(47, a_prefix, RuntimeConfig::default()).expect("start a");
14553        let rt_b = DcpsRuntime::start(47, b_prefix, RuntimeConfig::default()).expect("start b");
14554        let auth_a: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14555        let auth_b: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14556        let stack_a =
14557            rt_a.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_a, IdentityHandle(1));
14558        let stack_b =
14559            rt_b.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_b, IdentityHandle(1));
14560
14561        // KEIN handle_spdp_datagram / Multicast — rein in-process.
14562        let a_peers = stack_a.lock().unwrap().handshake_peer_count();
14563        let b_peers = stack_b.lock().unwrap().handshake_peer_count();
14564        assert!(
14565            a_peers >= 1,
14566            "A must have discovered B in-process + started the handshake (got {a_peers})"
14567        );
14568        assert!(
14569            b_peers >= 1,
14570            "B must have discovered A in-process + started the handshake (got {b_peers})"
14571        );
14572
14573        rt_a.shutdown();
14574        rt_b.shutdown();
14575    }
14576
14577    /// Mints a shared CA + two leaf identities (PEM) for the
14578    /// FU2-Handshake-e2e-Test.
14579    #[cfg(feature = "security")]
14580    #[allow(clippy::type_complexity)]
14581    fn mint_handshake_identities() -> ((Vec<u8>, Vec<u8>), (Vec<u8>, Vec<u8>)) {
14582        use rcgen::{CertificateParams, KeyPair};
14583        let mut ca_params =
14584            CertificateParams::new(alloc::vec![alloc::string::String::from("FU2 CA")]).unwrap();
14585        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
14586        let ca_key = KeyPair::generate().unwrap();
14587        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
14588        let ca_pem = ca_cert.pem().into_bytes();
14589        let mint = |name: &str| -> (Vec<u8>, Vec<u8>) {
14590            let mut p =
14591                CertificateParams::new(alloc::vec![alloc::string::String::from(name)]).unwrap();
14592            p.is_ca = rcgen::IsCa::NoCa;
14593            let k = KeyPair::generate().unwrap();
14594            let c = p.signed_by(&k, &ca_cert, &ca_key).unwrap();
14595            (c.pem().into_bytes(), k.serialize_pem().into_bytes())
14596        };
14597        let alice = {
14598            let (cert, key) = mint("alice");
14599            (cert, key)
14600        };
14601        let bob = {
14602            let (cert, key) = mint("bob");
14603            (cert, key)
14604        };
14605        // attach ca_pem to both, so the caller has the trust anchor.
14606        (
14607            ([alice.0, b"\n".to_vec(), ca_pem.clone()].concat(), alice.1),
14608            ([bob.0, b"\n".to_vec(), ca_pem].concat(), bob.1),
14609        )
14610    }
14611
14612    /// FU2 Gap 5 (e2e): a runtime replier (A) and an in-test initiator
14613    /// stack (B) complete a real PKI 3-round handshake via the dispatch path
14614    /// and BOTH derive the same SharedSecret.
14615    /// Verifies the dispatch wiring (`on_stateless_message` →
14616    /// reply/final → completion) in the real runtime context.
14617    #[cfg(feature = "security")]
14618    #[test]
14619    fn handshake_completes_through_runtime_dispatch_e2e() {
14620        use zerodds_discovery::security::SecurityBuiltinStack;
14621        use zerodds_security::authentication::AuthenticationPlugin;
14622        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
14623
14624        // cert_pem here contains Leaf || CA (mint_handshake_identities),
14625        // identity_ca_pem = the same bundle (CA is included).
14626        let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
14627
14628        // A = Runtime (Replier, HOEHERER Prefix). B = in-test Stack
14629        // (initiator, LOWER prefix) — cyclone convention: smaller
14630        // GUID initiiert.
14631        let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
14632        let b_prefix = GuidPrefix::from_bytes([0x10; 12]);
14633        let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
14634        let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
14635
14636        // --- A: runtime with a real PKI plugin ---
14637        let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14638        let a_local = a_pki
14639            .lock()
14640            .unwrap()
14641            .validate_with_config(
14642                IdentityConfig {
14643                    identity_cert_pem: a_cert.clone(),
14644                    identity_ca_pem: a_cert.clone(),
14645                    identity_key_pem: Some(a_key),
14646                },
14647                a_guid,
14648            )
14649            .unwrap();
14650        let a_token = a_pki.lock().unwrap().get_identity_token(a_local).unwrap();
14651        let rt = DcpsRuntime::start(42, a_prefix, RuntimeConfig::default()).expect("start");
14652        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
14653        let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
14654
14655        // --- B: in-test initiator stack with a real PKI plugin ---
14656        let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14657        let b_local = b_pki
14658            .lock()
14659            .unwrap()
14660            .validate_with_config(
14661                IdentityConfig {
14662                    identity_cert_pem: b_cert.clone(),
14663                    identity_ca_pem: b_cert.clone(),
14664                    identity_key_pem: Some(b_key),
14665                },
14666                b_guid,
14667            )
14668            .unwrap();
14669        let b_token = b_pki.lock().unwrap().get_identity_token(b_local).unwrap();
14670        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
14671        let mut b_stack =
14672            SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
14673
14674        let stateless = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14675            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14676
14677        // B discovers A (wired proxies) — via the parsed A beacon.
14678        let a_beacon = make_secure_beacon_with_identity_token(a_prefix, stateless, a_token.clone());
14679        let a_parsed = zerodds_discovery::spdp::SpdpReader::new()
14680            .parse_datagram(&a_beacon)
14681            .unwrap();
14682        b_stack.handle_remote_endpoints(&a_parsed);
14683
14684        // A discovers B → the discovery trigger creates A's peer state (A is
14685        // the replier, sends nothing).
14686        let b_beacon = make_secure_beacon_with_identity_token(b_prefix, stateless, b_token);
14687        handle_spdp_datagram(&rt, &b_beacon);
14688
14689        // B (initiator) starts → AUTH_REQUEST.
14690        let req = b_stack
14691            .begin_handshake_with(a_prefix, a_guid, &a_token)
14692            .unwrap();
14693        assert_eq!(req.len(), 1, "B sends AUTH_REQUEST");
14694
14695        // Pump: REQUEST → A.dispatch → REPLY.
14696        let reply = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
14697        assert_eq!(reply.len(), 1, "A (replier) answers with AUTH reply");
14698
14699        // REPLY → B verarbeitet → FINAL (+ B erreicht Complete).
14700        let b_msgs = b_stack
14701            .stateless_reader
14702            .handle_datagram(&reply[0].bytes)
14703            .unwrap();
14704        assert_eq!(b_msgs.len(), 1);
14705        let (final_dgs, _b_complete) = b_stack.on_stateless_message(a_prefix, &b_msgs[0]).unwrap();
14706        assert_eq!(final_dgs.len(), 1, "B sends AUTH-Final");
14707
14708        // FINAL → A.dispatch → A erreicht Complete.
14709        let _ =
14710            dispatch_security_builtin_datagram(&rt, &final_dgs[0].bytes, Duration::from_secs(1));
14711
14712        // Both sides must now have derived the same SharedSecret.
14713        let a_secret = {
14714            let s = a_stack.lock().unwrap();
14715            s.peer_secret(b_prefix)
14716                .expect("A must have authenticated B")
14717        };
14718        let b_secret = b_stack
14719            .peer_secret(a_prefix)
14720            .expect("B must have authenticated A");
14721        let a_bytes = a_pki
14722            .lock()
14723            .unwrap()
14724            .secret_bytes(a_secret)
14725            .unwrap()
14726            .to_vec();
14727        let b_bytes = b_pki
14728            .lock()
14729            .unwrap()
14730            .secret_bytes(b_secret)
14731            .unwrap()
14732            .to_vec();
14733        assert_eq!(a_bytes.len(), 32);
14734        assert_eq!(
14735            a_bytes, b_bytes,
14736            "runtime dispatch + in-test stack derive the same secret"
14737        );
14738
14739        rt.shutdown();
14740    }
14741
14742    /// FU2 S1.5 (e2e): after the auth handshake the runtime dispatch
14743    /// (A, replier) and a reference peer (B, stack+gate, initiator) over
14744    /// the Kx-protected VolatileSecure channel automatically exchange their data
14745    /// crypto tokens — afterwards secured user DATA round-trips in BOTH
14746    /// directions. **The secured-DATA proof via the runtime dispatch.**
14747    #[cfg(feature = "security")]
14748    #[test]
14749    #[serial_test::serial(dcps_security_e2e)]
14750    fn secured_data_round_trips_through_runtime_dispatch_e2e() {
14751        use zerodds_discovery::security::SecurityBuiltinStack;
14752        use zerodds_security::authentication::{AuthenticationPlugin, SharedSecretProvider};
14753        use zerodds_security::generic_message::{
14754            MessageIdentity, ParticipantGenericMessage, class_id,
14755        };
14756        use zerodds_security::token::DataHolder;
14757        use zerodds_security_crypto::{AesGcmCryptoPlugin, Suite};
14758        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
14759        use zerodds_security_runtime::{ProtectionLevel, SharedSecurityGate};
14760
14761        // Couples the pki plugin (behind a mutex) as the SharedSecretProvider to
14762        // the crypto plugin — like SecurityProfile in the FFI (Gap 1).
14763        struct PkiProvider(Arc<Mutex<PkiAuthenticationPlugin>>);
14764        impl SharedSecretProvider for PkiProvider {
14765            fn get_shared_secret(
14766                &self,
14767                h: zerodds_security::authentication::SharedSecretHandle,
14768            ) -> Option<Vec<u8>> {
14769                self.0.lock().ok()?.get_shared_secret(h)
14770            }
14771        }
14772        const GOV: &str = r#"<domain_access_rules><domain_rule><domains><id>0</id></domains><rtps_protection_kind>ENCRYPT</rtps_protection_kind><topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules></domain_rule></domain_access_rules>"#;
14773        let gov = || zerodds_security_permissions::parse_governance_xml(GOV).unwrap();
14774        let gate_with = |pki: &Arc<Mutex<PkiAuthenticationPlugin>>| {
14775            SharedSecurityGate::new(
14776                0,
14777                gov(),
14778                Box::new(AesGcmCryptoPlugin::with_secret_provider(
14779                    Suite::Aes128Gcm,
14780                    Arc::new(PkiProvider(pki.clone())) as Arc<dyn SharedSecretProvider>,
14781                )),
14782            )
14783        };
14784        let fake_rtps = |prefix: GuidPrefix, body: &[u8]| -> Vec<u8> {
14785            let mut m = Vec::new();
14786            m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
14787            m.extend_from_slice(&prefix.to_bytes());
14788            m.extend_from_slice(body);
14789            m
14790        };
14791
14792        let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
14793        let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
14794        let b_prefix = GuidPrefix::from_bytes([0x10; 12]); // B < A → B initiator (cyclone convention)
14795        let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
14796        let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
14797        let a_key_pk = a_prefix.to_bytes();
14798        let b_key_pk = b_prefix.to_bytes();
14799
14800        // --- A: runtime with auth + gate (sharing pki_a) ---
14801        let pki_a = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14802        let a_local = pki_a
14803            .lock()
14804            .unwrap()
14805            .validate_with_config(
14806                IdentityConfig {
14807                    identity_cert_pem: a_cert.clone(),
14808                    identity_ca_pem: a_cert.clone(),
14809                    identity_key_pem: Some(a_key),
14810                },
14811                a_guid,
14812            )
14813            .unwrap();
14814        let a_token = pki_a.lock().unwrap().get_identity_token(a_local).unwrap();
14815        let gate_a = Arc::new(gate_with(&pki_a));
14816        let rt = DcpsRuntime::start(
14817            43,
14818            a_prefix,
14819            RuntimeConfig {
14820                security: Some(gate_a.clone()),
14821                ..RuntimeConfig::default()
14822            },
14823        )
14824        .expect("start");
14825        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_a.clone();
14826        let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
14827
14828        // --- B: in-test Stack + Gate (sharing pki_b), Initiator ---
14829        let pki_b = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14830        let b_local = pki_b
14831            .lock()
14832            .unwrap()
14833            .validate_with_config(
14834                IdentityConfig {
14835                    identity_cert_pem: b_cert.clone(),
14836                    identity_ca_pem: b_cert.clone(),
14837                    identity_key_pem: Some(b_key),
14838                },
14839                b_guid,
14840            )
14841            .unwrap();
14842        let b_token = pki_b.lock().unwrap().get_identity_token(b_local).unwrap();
14843        let gate_b = gate_with(&pki_b);
14844        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_b.clone();
14845        let mut stack_b =
14846            SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
14847
14848        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14849            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
14850            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
14851            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
14852        let a_beacon = make_secure_beacon_with_identity_token(a_prefix, flags, a_token.clone());
14853        stack_b.handle_remote_endpoints(
14854            &zerodds_discovery::spdp::SpdpReader::new()
14855                .parse_datagram(&a_beacon)
14856                .unwrap(),
14857        );
14858        // Wire A's stack deterministically (no handle_spdp_datagram —
14859        // a running runtime + trigger otherwise produces non-deterministic
14860        // proxy wirings via parallel/loopback beacons). A is the replier:
14861        // begin_handshake_with only sets up the peer state.
14862        let b_beacon = make_secure_beacon_with_identity_token(b_prefix, flags, b_token.clone());
14863        let b_parsed = zerodds_discovery::spdp::SpdpReader::new()
14864            .parse_datagram(&b_beacon)
14865            .unwrap();
14866        {
14867            let mut s = a_stack.lock().unwrap();
14868            s.handle_remote_endpoints(&b_parsed);
14869            s.begin_handshake_with(b_prefix, b_guid, &b_token).unwrap();
14870        }
14871
14872        // --- Stateless-Handshake pumpen (B initiiert) ---
14873        // A is the replier and derives the secret already at begin_handshake_
14874        // reply → A's response to the request contains BOTH: the
14875        // AUTH reply (stateless) AND A's Kx-encrypted crypto token
14876        // (volatile, automatically via the dispatch).
14877        let decode_route = |dgs: &[zerodds_rtps::message_builder::OutboundDatagram]| {
14878            let mut stateless = Vec::new();
14879            let mut volatile = Vec::new();
14880            for dg in dgs {
14881                let parsed = zerodds_rtps::datagram::decode_datagram(&dg.bytes).unwrap();
14882                let is_vol = parsed.submessages.iter().any(|sub| {
14883                    // Cleartext path (unprotected): DATA to the VolatileSecure reader.
14884                    matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Data(d)
14885                        if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER)
14886                    // Cross-vendor path (protected): the volatile crypto-token DATA
14887                    // is SEC_*-protected (protect_volatile_outbound) -> the inner
14888                    // DATA is encrypted and recognizable only by the prepended SEC_PREFIX
14889                    // submessage (id 0x31). Stateless AUTH stays plaintext.
14890                    || matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Unknown { id: 0x31, .. })
14891                });
14892                if is_vol {
14893                    volatile.push(dg.bytes.clone());
14894                } else {
14895                    stateless.push(dg.bytes.clone());
14896                }
14897            }
14898            (stateless, volatile)
14899        };
14900
14901        let req = stack_b
14902            .begin_handshake_with(a_prefix, a_guid, &a_token)
14903            .unwrap();
14904        let a_resp = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
14905        let (a_stateless, a_volatile) = decode_route(&a_resp);
14906        assert!(
14907            !a_volatile.is_empty(),
14908            "A dispatch must send A's crypto token"
14909        );
14910
14911        // B verarbeitet A's AUTH-Reply → Final + B completes.
14912        let mut b_remote_id = None;
14913        let mut b_secret = None;
14914        let mut b_final = Vec::new();
14915        for sl in &a_stateless {
14916            for m in stack_b.stateless_reader.handle_datagram(sl).unwrap() {
14917                let (out, comp) = stack_b.on_stateless_message(a_prefix, &m).unwrap();
14918                b_final.extend(out);
14919                if let Some((id, sec)) = comp {
14920                    b_remote_id = Some(id);
14921                    b_secret = Some(sec);
14922                }
14923            }
14924        }
14925        let b_remote_id = b_remote_id.expect("B remote identity");
14926        let b_secret = b_secret.expect("B completes");
14927
14928        // B registers A's Kx, installs A's crypto token (from a_volatile).
14929        gate_b
14930            .register_remote_by_guid_from_secret(a_key_pk, b_remote_id, b_secret)
14931            .unwrap();
14932        // A's volatile crypto token is cross-vendor SEC_*-protected
14933        // (protect_volatile_outbound). B must decrypt the SEC_PREFIX/BODY/POSTFIX sequence
14934        // with A's Kx key to the inner DATA submessage before the
14935        // volatile_reader can process it — mirrors unprotect_volatile_
14936        // datagram im Live-Dispatch.
14937        let unprotect_vol_b = |bytes: &[u8]| -> Option<Vec<u8>> {
14938            let subs = walk_submessages(bytes);
14939            let prefix_pos = subs.iter().position(|(id, _, _)| *id == SMID_SEC_PREFIX)?;
14940            let postfix_idx = subs[prefix_pos..]
14941                .iter()
14942                .position(|(id, _, _)| *id == SMID_SEC_POSTFIX)
14943                .map(|i| prefix_pos + i)?;
14944            let (_, p_start, _) = subs[prefix_pos];
14945            let (_, q_start, q_total) = subs[postfix_idx];
14946            let data_submsg = gate_b
14947                .decode_kx_datawriter_from(&a_key_pk, &bytes[p_start..q_start + q_total])
14948                .ok()?;
14949            let mut out = Vec::with_capacity(bytes.len());
14950            out.extend_from_slice(&bytes[..20]);
14951            for (i, &(_, start, total)) in subs.iter().enumerate() {
14952                if i < prefix_pos || i > postfix_idx {
14953                    out.extend_from_slice(&bytes[start..start + total]);
14954                } else if i == prefix_pos {
14955                    out.extend_from_slice(&data_submsg);
14956                }
14957            }
14958            Some(out)
14959        };
14960        let mut b_installed = 0;
14961        for vol in &a_volatile {
14962            let vol_plain = unprotect_vol_b(vol).unwrap_or_else(|| vol.clone());
14963            let parsed = zerodds_rtps::datagram::decode_datagram(&vol_plain).unwrap();
14964            let vol_src = parsed.header.guid_prefix;
14965            for sub in parsed.submessages {
14966                if let zerodds_rtps::datagram::ParsedSubmessage::Data(d) = sub {
14967                    if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
14968                        for m in stack_b.volatile_reader.handle_data(vol_src, &d).unwrap() {
14969                            if m.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS {
14970                                // plaintext keymat (confidentiality was provided by the SEC_*
14971                                // protection of the volatile DATA, decrypted above) —
14972                                // install directly, no transform_kx_inbound.
14973                                let token = m.message_data[0]
14974                                    .binary_property(CRYPTO_TOKEN_PROP)
14975                                    .unwrap();
14976                                gate_b
14977                                    .set_remote_data_token_by_guid(&a_key_pk, token)
14978                                    .unwrap();
14979                                b_installed += 1;
14980                            }
14981                        }
14982                    }
14983                }
14984            }
14985        }
14986        assert!(b_installed >= 1, "B must install A's crypto token");
14987
14988        // B builds + sends its crypto token — plaintext keymat in the
14989        // ParticipantGenericMessage (cross-vendor: confidentiality via SEC_*
14990        // protection of the transporting volatile DATA, not via token-internal
14991        // Kx encryption).
14992        let b_data_token = gate_b.local_token().unwrap();
14993        let b_crypto_msg = ParticipantGenericMessage {
14994            message_identity: MessageIdentity {
14995                source_guid: b_guid,
14996                sequence_number: 1,
14997            },
14998            related_message_identity: MessageIdentity::default(),
14999            destination_participant_key: a_guid,
15000            destination_endpoint_key: [0; 16],
15001            source_endpoint_key: [0; 16],
15002            message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
15003            message_data: alloc::vec![
15004                DataHolder::new("DDS:Crypto:AES_GCM_GMAC")
15005                    .with_binary_property(CRYPTO_TOKEN_PROP, b_data_token)
15006            ],
15007        };
15008        let b_volatile = stack_b.volatile_writer.write(&b_crypto_msg).unwrap();
15009        // SEC_* submessage protection with A's Kx key (mirrors protect_volatile_
15010        // datagram in the live path): B encrypts the DATA submessage, A's
15011        // dispatch decrypts it via unprotect_volatile_datagram.
15012        let protect_vol_b = |bytes: &[u8]| -> Vec<u8> {
15013            let subs = walk_submessages(bytes);
15014            if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
15015                return bytes.to_vec();
15016            }
15017            let mut out = Vec::with_capacity(bytes.len() + 64);
15018            out.extend_from_slice(&bytes[..20]);
15019            for (id, start, total) in subs {
15020                let submsg = &bytes[start..start + total];
15021                if id == SMID_DATA {
15022                    out.extend_from_slice(
15023                        &gate_b.encode_kx_datawriter_for(&a_key_pk, submsg).unwrap(),
15024                    );
15025                } else {
15026                    out.extend_from_slice(submsg);
15027                }
15028            }
15029            out
15030        };
15031        let b_vol_protected = protect_vol_b(&b_volatile[0].bytes);
15032
15033        // B's Final + B's Crypto-Token an A's Dispatch: A installiert B's
15034        // Data token (automatically via install_crypto_token).
15035        for f in &b_final {
15036            dispatch_security_builtin_datagram(&rt, &f.bytes, Duration::from_secs(1));
15037        }
15038        dispatch_security_builtin_datagram(&rt, &b_vol_protected, Duration::from_secs(1));
15039
15040        // --- Secured DATA in both directions ---
15041        let msg_ab = fake_rtps(a_prefix, b"[A->B secured payload]");
15042        let wire_ab = gate_a
15043            .transform_outbound_for(&b_key_pk, &msg_ab, ProtectionLevel::Encrypt)
15044            .unwrap();
15045        assert_eq!(
15046            gate_b.transform_inbound_from(&a_key_pk, &wire_ab).unwrap(),
15047            msg_ab,
15048            "A->B secured DATA must round-trip"
15049        );
15050        let msg_ba = fake_rtps(b_prefix, b"[B->A secured payload]");
15051        let wire_ba = gate_b
15052            .transform_outbound_for(&a_key_pk, &msg_ba, ProtectionLevel::Encrypt)
15053            .unwrap();
15054        assert_eq!(
15055            gate_a.transform_inbound_from(&b_key_pk, &wire_ba).unwrap(),
15056            msg_ba,
15057            "B->A secured DATA must round-trip (A's dispatch installed B's token)"
15058        );
15059
15060        rt.shutdown();
15061    }
15062
15063    #[test]
15064    fn c34c_enable_security_builtins_replays_known_peers() {
15065        // Order reversed: SPDP discovery first, plugin-
15066        // activation afterward. enable_security_builtins must catch up on already-
15067        // known peers. Plus: demux without a plugin (before enable)
15068        // is a no-op + does not panic.
15069        let rt = DcpsRuntime::start(
15070            76,
15071            GuidPrefix::from_bytes([0x76; 12]),
15072            RuntimeConfig::default(),
15073        )
15074        .expect("start");
15075
15076        // Demux without a plugin: silent no-op
15077        dispatch_security_builtin_datagram(&rt, &[0u8; 16], Duration::from_secs(1));
15078
15079        let remote = GuidPrefix::from_bytes([0x77; 12]);
15080        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
15081            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
15082        let dg = make_remote_spdp_beacon_with_flags(remote, flags);
15083        handle_spdp_datagram(&rt, &dg);
15084
15085        let stack = rt.enable_security_builtins(VendorId::ZERODDS);
15086        {
15087            let s = stack.lock().unwrap();
15088            assert_eq!(
15089                s.stateless_writer.reader_proxy_count(),
15090                1,
15091                "late plugin activation must catch up on known peers"
15092            );
15093        }
15094
15095        rt.shutdown();
15096    }
15097
15098    /// #29 regression: the earlier per-peer once-guard blocked late-matched
15099    /// user-endpoint tokens. `pending_endpoint_tokens` must, with already-sent
15100    /// builtin tokens, let through EXACTLY the new user token — not treat the whole
15101    /// peer as "done".
15102    #[cfg(feature = "security")]
15103    #[test]
15104    fn pending_endpoint_tokens_keeps_late_user_token_after_builtins_sent() {
15105        use zerodds_security::generic_message::ParticipantGenericMessage;
15106        // An early-sent builtin token (secure-SEDP) ...
15107        let builtin = ParticipantGenericMessage {
15108            source_endpoint_key: [0xff; 16],
15109            destination_endpoint_key: [0xfe; 16],
15110            ..Default::default()
15111        };
15112        // ... and a late-matched user-endpoint token.
15113        let user = ParticipantGenericMessage {
15114            source_endpoint_key: [0x03; 16],
15115            destination_endpoint_key: [0x04; 16],
15116            ..Default::default()
15117        };
15118        let mut sent = alloc::collections::BTreeSet::new();
15119        sent.insert(endpoint_token_key(&builtin));
15120
15121        let pending = pending_endpoint_tokens(vec![builtin.clone(), user.clone()], &sent);
15122
15123        assert_eq!(pending.len(), 1, "only the new user token may be pending");
15124        assert_eq!(
15125            pending[0].source_endpoint_key, user.source_endpoint_key,
15126            "the let-through token must be the user-endpoint token"
15127        );
15128        // Idempotency: after sending, nothing is pending anymore.
15129        let mut sent2 = sent.clone();
15130        sent2.insert(endpoint_token_key(&user));
15131        assert!(
15132            pending_endpoint_tokens(vec![builtin, user], &sent2).is_empty(),
15133            "already-sent tokens must not become pending again"
15134        );
15135    }
15136
15137    /// QT (#76, FOUNDATIONAL): a writer and reader of the SAME type whose
15138    /// TypeIdentifier is a *complete* hash (EquivalenceHashComplete) absent from
15139    /// any registry must still match and exchange data. Before the fix the
15140    /// runtime type-consistency check resolved against a fresh empty registry
15141    /// and rejected the match with TYPE_CONSISTENCY_ENFORCEMENT.
15142    #[test]
15143    fn qt_same_complete_type_identifier_matches_and_exchanges() {
15144        let rt = DcpsRuntime::start(
15145            60,
15146            GuidPrefix::from_bytes([0x60; 12]),
15147            RuntimeConfig::default(),
15148        )
15149        .expect("start");
15150        let complete = zerodds_types::TypeIdentifier::EquivalenceHashComplete(
15151            zerodds_types::type_identifier::EquivalenceHash([0xC0; 14]),
15152        );
15153        let mut w_cfg = qr_writer_cfg(
15154            "QtTopic",
15155            zerodds_qos::DurabilityKind::Volatile,
15156            alloc::vec![],
15157            zerodds_qos::LivelinessKind::Automatic,
15158        );
15159        w_cfg.type_identifier = complete.clone();
15160        let mut r_cfg = qr_reader_cfg(
15161            "QtTopic",
15162            zerodds_qos::DurabilityKind::Volatile,
15163            alloc::vec![],
15164            zerodds_qos::LivelinessKind::Automatic,
15165        );
15166        r_cfg.type_identifier = complete;
15167        let w = rt.register_user_writer(w_cfg).expect("writer");
15168        let (_r, rx) = rt.register_user_reader(r_cfg).expect("reader");
15169        rt.write_user_sample(w, b"complete-typed".to_vec())
15170            .expect("write");
15171        let s = rx
15172            .recv_timeout(core::time::Duration::from_millis(200))
15173            .expect("complete-TypeIdentifier writer+reader must match + exchange");
15174        match s {
15175            UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"complete-typed"),
15176            other => panic!("expected Alive, got {other:?}"),
15177        }
15178        rt.shutdown();
15179    }
15180
15181    // ===================================================================
15182    // QR-cluster (#77) — same-runtime QoS behavioral regression tests.
15183    // ===================================================================
15184
15185    fn qr_writer_cfg(
15186        topic: &str,
15187        durability: zerodds_qos::DurabilityKind,
15188        partition: Vec<String>,
15189        liveliness: zerodds_qos::LivelinessKind,
15190    ) -> UserWriterConfig {
15191        UserWriterConfig {
15192            topic_name: topic.into(),
15193            type_name: "QrType".into(),
15194            reliable: true,
15195            durability,
15196            deadline: zerodds_qos::DeadlineQosPolicy::default(),
15197            lifespan: zerodds_qos::LifespanQosPolicy::default(),
15198            liveliness: zerodds_qos::LivelinessQosPolicy {
15199                kind: liveliness,
15200                lease_duration: QosDuration::INFINITE,
15201            },
15202            ownership: zerodds_qos::OwnershipKind::Shared,
15203            ownership_strength: 0,
15204            partition,
15205            user_data: alloc::vec![],
15206            topic_data: alloc::vec![],
15207            group_data: alloc::vec![],
15208            type_identifier: zerodds_types::TypeIdentifier::None,
15209            data_representation_offer: None,
15210        }
15211    }
15212
15213    fn qr_reader_cfg(
15214        topic: &str,
15215        durability: zerodds_qos::DurabilityKind,
15216        partition: Vec<String>,
15217        liveliness: zerodds_qos::LivelinessKind,
15218    ) -> UserReaderConfig {
15219        UserReaderConfig {
15220            topic_name: topic.into(),
15221            type_name: "QrType".into(),
15222            reliable: true,
15223            durability,
15224            deadline: zerodds_qos::DeadlineQosPolicy::default(),
15225            liveliness: zerodds_qos::LivelinessQosPolicy {
15226                kind: liveliness,
15227                lease_duration: QosDuration::INFINITE,
15228            },
15229            ownership: zerodds_qos::OwnershipKind::Shared,
15230            partition,
15231            user_data: alloc::vec![],
15232            topic_data: alloc::vec![],
15233            group_data: alloc::vec![],
15234            type_identifier: zerodds_types::TypeIdentifier::None,
15235            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
15236            data_representation_offer: None,
15237        }
15238    }
15239
15240    /// QR (a) HISTORY KeepLast: a TransientLocal writer with depth=2 retains
15241    /// only the last 2 samples per instance; a late-joining reader replays
15242    /// exactly those 2 (not all 3 written).
15243    #[test]
15244    fn qr_history_keep_last_depth_enforced_on_replay() {
15245        let rt = DcpsRuntime::start(
15246            61,
15247            GuidPrefix::from_bytes([0x61; 12]),
15248            RuntimeConfig::default(),
15249        )
15250        .expect("start");
15251        let w = rt
15252            .register_user_writer(qr_writer_cfg(
15253                "QrHistory",
15254                zerodds_qos::DurabilityKind::TransientLocal,
15255                alloc::vec![],
15256                zerodds_qos::LivelinessKind::Automatic,
15257            ))
15258            .expect("writer");
15259        rt.set_user_writer_history_depth(w, 2).expect("set depth");
15260
15261        // Three writes BEFORE any reader exists.
15262        rt.write_user_sample(w, b"s1".to_vec()).expect("w1");
15263        rt.write_user_sample(w, b"s2".to_vec()).expect("w2");
15264        rt.write_user_sample(w, b"s3".to_vec()).expect("w3");
15265        assert_eq!(
15266            rt.user_writer_retained_len(w),
15267            2,
15268            "KeepLast(2) must retain only the 2 most recent samples"
15269        );
15270
15271        // Late-joining reader replays exactly the last 2 (s2, s3).
15272        let (_r, rx) = rt
15273            .register_user_reader(qr_reader_cfg(
15274                "QrHistory",
15275                zerodds_qos::DurabilityKind::TransientLocal,
15276                alloc::vec![],
15277                zerodds_qos::LivelinessKind::Automatic,
15278            ))
15279            .expect("reader");
15280
15281        let mut got: Vec<Vec<u8>> = Vec::new();
15282        while let Ok(s) = rx.recv_timeout(core::time::Duration::from_millis(200)) {
15283            if let UserSample::Alive { payload, .. } = s {
15284                got.push(payload.as_ref().to_vec());
15285            }
15286            if got.len() == 2 {
15287                break;
15288            }
15289        }
15290        assert_eq!(got, alloc::vec![b"s2".to_vec(), b"s3".to_vec()]);
15291        rt.shutdown();
15292    }
15293
15294    /// QR (b) DURABILITY TRANSIENT_LOCAL: a late-joining reader receives the
15295    /// retained sample written before it matched. A VOLATILE writer replays
15296    /// nothing.
15297    #[test]
15298    fn qr_transient_local_late_join_replay_vs_volatile() {
15299        // TransientLocal: late joiner sees the prior sample.
15300        let rt = DcpsRuntime::start(
15301            62,
15302            GuidPrefix::from_bytes([0x62; 12]),
15303            RuntimeConfig::default(),
15304        )
15305        .expect("start");
15306        let w = rt
15307            .register_user_writer(qr_writer_cfg(
15308                "QrTL",
15309                zerodds_qos::DurabilityKind::TransientLocal,
15310                alloc::vec![],
15311                zerodds_qos::LivelinessKind::Automatic,
15312            ))
15313            .expect("writer");
15314        rt.write_user_sample(w, b"retained".to_vec())
15315            .expect("write");
15316        let (_r, rx) = rt
15317            .register_user_reader(qr_reader_cfg(
15318                "QrTL",
15319                zerodds_qos::DurabilityKind::TransientLocal,
15320                alloc::vec![],
15321                zerodds_qos::LivelinessKind::Automatic,
15322            ))
15323            .expect("reader");
15324        let s = rx
15325            .recv_timeout(core::time::Duration::from_millis(200))
15326            .expect("TransientLocal late joiner must replay the retained sample");
15327        match s {
15328            UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"retained"),
15329            other => panic!("expected Alive, got {other:?}"),
15330        }
15331        rt.shutdown();
15332
15333        // Volatile: late joiner gets nothing for the pre-match write.
15334        let rt2 = DcpsRuntime::start(
15335            63,
15336            GuidPrefix::from_bytes([0x63; 12]),
15337            RuntimeConfig::default(),
15338        )
15339        .expect("start");
15340        let w2 = rt2
15341            .register_user_writer(qr_writer_cfg(
15342                "QrVol",
15343                zerodds_qos::DurabilityKind::Volatile,
15344                alloc::vec![],
15345                zerodds_qos::LivelinessKind::Automatic,
15346            ))
15347            .expect("writer");
15348        rt2.write_user_sample(w2, b"lost".to_vec()).expect("write");
15349        assert_eq!(
15350            rt2.user_writer_retained_len(w2),
15351            0,
15352            "Volatile retains nothing"
15353        );
15354        let (_r2, rx2) = rt2
15355            .register_user_reader(qr_reader_cfg(
15356                "QrVol",
15357                zerodds_qos::DurabilityKind::Volatile,
15358                alloc::vec![],
15359                zerodds_qos::LivelinessKind::Automatic,
15360            ))
15361            .expect("reader");
15362        assert!(
15363            rx2.recv_timeout(core::time::Duration::from_millis(120))
15364                .is_err(),
15365            "Volatile late joiner must NOT replay a pre-match sample"
15366        );
15367        rt2.shutdown();
15368    }
15369
15370    /// QR (c) PARTITION: a writer in partition ["A"] and a reader in ["B"]
15371    /// must NOT match (no intra-runtime route); a matching partition delivers.
15372    #[test]
15373    fn qr_partition_gates_intra_runtime_match() {
15374        let rt = DcpsRuntime::start(
15375            64,
15376            GuidPrefix::from_bytes([0x64; 12]),
15377            RuntimeConfig::default(),
15378        )
15379        .expect("start");
15380        // Mismatched partitions.
15381        let w = rt
15382            .register_user_writer(qr_writer_cfg(
15383                "QrPart",
15384                zerodds_qos::DurabilityKind::Volatile,
15385                alloc::vec!["A".into()],
15386                zerodds_qos::LivelinessKind::Automatic,
15387            ))
15388            .expect("writer");
15389        let (_r_mismatch, rx_mismatch) = rt
15390            .register_user_reader(qr_reader_cfg(
15391                "QrPart",
15392                zerodds_qos::DurabilityKind::Volatile,
15393                alloc::vec!["B".into()],
15394                zerodds_qos::LivelinessKind::Automatic,
15395            ))
15396            .expect("reader");
15397        rt.write_user_sample(w, b"x".to_vec()).expect("write");
15398        assert!(
15399            rx_mismatch
15400                .recv_timeout(core::time::Duration::from_millis(120))
15401                .is_err(),
15402            "partitions [A] vs [B] must not match"
15403        );
15404
15405        // Matching partition reader added → now delivers.
15406        let (_r_match, rx_match) = rt
15407            .register_user_reader(qr_reader_cfg(
15408                "QrPart",
15409                zerodds_qos::DurabilityKind::Volatile,
15410                alloc::vec!["A".into()],
15411                zerodds_qos::LivelinessKind::Automatic,
15412            ))
15413            .expect("reader");
15414        rt.write_user_sample(w, b"y".to_vec()).expect("write");
15415        let s = rx_match
15416            .recv_timeout(core::time::Duration::from_millis(200))
15417            .expect("partition [A] vs [A] must match");
15418        match s {
15419            UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"y"),
15420            other => panic!("expected Alive, got {other:?}"),
15421        }
15422        rt.shutdown();
15423    }
15424
15425    /// QR (d) KEYED LIFECYCLE: dispose(key) delivers a Lifecycle marker to a
15426    /// matched same-runtime reader; a later late joiner observes the terminal
15427    /// NOT_ALIVE_DISPOSED state.
15428    #[test]
15429    fn qr_dispose_delivers_lifecycle_to_intra_reader() {
15430        use zerodds_rtps::history_cache::ChangeKind;
15431        use zerodds_rtps::inline_qos::status_info;
15432        let rt = DcpsRuntime::start(
15433            65,
15434            GuidPrefix::from_bytes([0x65; 12]),
15435            RuntimeConfig::default(),
15436        )
15437        .expect("start");
15438        let w = rt
15439            .register_user_writer_kind(
15440                qr_writer_cfg(
15441                    "QrLifecycle",
15442                    zerodds_qos::DurabilityKind::TransientLocal,
15443                    alloc::vec![],
15444                    zerodds_qos::LivelinessKind::Automatic,
15445                ),
15446                true,
15447            )
15448            .expect("writer");
15449        let (_r, rx) = rt
15450            .register_user_reader_kind(
15451                qr_reader_cfg(
15452                    "QrLifecycle",
15453                    zerodds_qos::DurabilityKind::TransientLocal,
15454                    alloc::vec![],
15455                    zerodds_qos::LivelinessKind::Automatic,
15456                ),
15457                true,
15458            )
15459            .expect("reader");
15460
15461        let key = [0xAB_u8; 16];
15462        rt.write_user_sample_keyed(w, b"alive", key, None)
15463            .expect("write");
15464        // First the alive sample.
15465        let first = rx
15466            .recv_timeout(core::time::Duration::from_millis(200))
15467            .expect("alive sample");
15468        assert!(matches!(first, UserSample::Alive { .. }));
15469
15470        // dispose(key) → Lifecycle marker NOT_ALIVE_DISPOSED.
15471        rt.write_user_lifecycle(w, key, status_info::DISPOSED)
15472            .expect("dispose");
15473        let life = rx
15474            .recv_timeout(core::time::Duration::from_millis(200))
15475            .expect("dispose must deliver a Lifecycle marker to the matched reader");
15476        match life {
15477            UserSample::Lifecycle { key_hash, kind } => {
15478                assert_eq!(key_hash, key);
15479                assert_eq!(kind, ChangeKind::NotAliveDisposed);
15480            }
15481            other => panic!("expected Lifecycle, got {other:?}"),
15482        }
15483
15484        // A brand-new late joiner replays the alive sample AND the terminal
15485        // disposed marker, so it learns the instance is NOT_ALIVE_DISPOSED.
15486        let (_r2, rx2) = rt
15487            .register_user_reader_kind(
15488                qr_reader_cfg(
15489                    "QrLifecycle",
15490                    zerodds_qos::DurabilityKind::TransientLocal,
15491                    alloc::vec![],
15492                    zerodds_qos::LivelinessKind::Automatic,
15493                ),
15494                true,
15495            )
15496            .expect("reader2");
15497        let mut saw_disposed = false;
15498        while let Ok(s) = rx2.recv_timeout(core::time::Duration::from_millis(200)) {
15499            if let UserSample::Lifecycle { kind, .. } = s {
15500                if kind == ChangeKind::NotAliveDisposed {
15501                    saw_disposed = true;
15502                    break;
15503                }
15504            }
15505        }
15506        assert!(
15507            saw_disposed,
15508            "late joiner must observe the terminal NOT_ALIVE_DISPOSED state"
15509        );
15510        rt.shutdown();
15511    }
15512
15513    /// QR (d) KEYED LIFECYCLE — unregister(key) maps to NOT_ALIVE (NO_WRITERS).
15514    #[test]
15515    fn qr_unregister_delivers_no_writers_lifecycle() {
15516        use zerodds_rtps::history_cache::ChangeKind;
15517        use zerodds_rtps::inline_qos::status_info;
15518        let rt = DcpsRuntime::start(
15519            66,
15520            GuidPrefix::from_bytes([0x66; 12]),
15521            RuntimeConfig::default(),
15522        )
15523        .expect("start");
15524        let w = rt
15525            .register_user_writer_kind(
15526                qr_writer_cfg(
15527                    "QrUnreg",
15528                    zerodds_qos::DurabilityKind::Volatile,
15529                    alloc::vec![],
15530                    zerodds_qos::LivelinessKind::Automatic,
15531                ),
15532                true,
15533            )
15534            .expect("writer");
15535        let (_r, rx) = rt
15536            .register_user_reader_kind(
15537                qr_reader_cfg(
15538                    "QrUnreg",
15539                    zerodds_qos::DurabilityKind::Volatile,
15540                    alloc::vec![],
15541                    zerodds_qos::LivelinessKind::Automatic,
15542                ),
15543                true,
15544            )
15545            .expect("reader");
15546        let key = [0x11_u8; 16];
15547        rt.write_user_lifecycle(w, key, status_info::UNREGISTERED)
15548            .expect("unregister");
15549        let life = rx
15550            .recv_timeout(core::time::Duration::from_millis(200))
15551            .expect("unregister must deliver a Lifecycle marker");
15552        match life {
15553            UserSample::Lifecycle { key_hash, kind } => {
15554                assert_eq!(key_hash, key);
15555                assert_eq!(kind, ChangeKind::NotAliveUnregistered);
15556            }
15557            other => panic!("expected Lifecycle, got {other:?}"),
15558        }
15559        rt.shutdown();
15560    }
15561
15562    /// QR (e) LIVELINESS AUTOMATIC: the reader's liveliness_changed alive_count
15563    /// tracks a live matched writer on the same-runtime path.
15564    #[test]
15565    fn qr_liveliness_automatic_bumps_reader_alive_count() {
15566        let rt = DcpsRuntime::start(
15567            67,
15568            GuidPrefix::from_bytes([0x67; 12]),
15569            RuntimeConfig::default(),
15570        )
15571        .expect("start");
15572        let w = rt
15573            .register_user_writer(qr_writer_cfg(
15574                "QrLive",
15575                zerodds_qos::DurabilityKind::Volatile,
15576                alloc::vec![],
15577                zerodds_qos::LivelinessKind::Automatic,
15578            ))
15579            .expect("writer");
15580        let (r, rx) = rt
15581            .register_user_reader(qr_reader_cfg(
15582                "QrLive",
15583                zerodds_qos::DurabilityKind::Volatile,
15584                alloc::vec![],
15585                zerodds_qos::LivelinessKind::Automatic,
15586            ))
15587            .expect("reader");
15588
15589        let (_alive0, count0, _na0) = rt.user_reader_liveliness_status(r);
15590        assert_eq!(count0, 0, "no writer has delivered yet");
15591
15592        rt.write_user_sample(w, b"beat".to_vec()).expect("write");
15593        let _ = rx.recv_timeout(core::time::Duration::from_millis(200));
15594
15595        let (alive, count, _na) = rt.user_reader_liveliness_status(r);
15596        assert!(alive, "AUTOMATIC writer keeps the reader's match alive");
15597        assert_eq!(
15598            count, 1,
15599            "alive_count must bump to 1 for the live matched AUTOMATIC writer"
15600        );
15601
15602        // A second write from the same writer does NOT double-count.
15603        rt.write_user_sample(w, b"beat2".to_vec()).expect("write");
15604        let _ = rx.recv_timeout(core::time::Duration::from_millis(200));
15605        let (_a, count2, _n) = rt.user_reader_liveliness_status(r);
15606        assert_eq!(count2, 1, "same writer must not bump alive_count twice");
15607        rt.shutdown();
15608    }
15609}