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 "").
109fn partitions_overlap(offered: &[String], requested: &[String]) -> bool {
110    if offered.is_empty() && requested.is_empty() {
111        return true;
112    }
113    // An empty list is treated as ["" (default)].
114    let off_default = offered.is_empty();
115    let req_default = requested.is_empty();
116    if off_default && requested.iter().any(|s| s.is_empty()) {
117        return true;
118    }
119    if req_default && offered.iter().any(|s| s.is_empty()) {
120        return true;
121    }
122    // Both non-default: intersect.
123    offered.iter().any(|o| requested.iter().any(|r| r == o))
124}
125
126/// Materializes the locator address that we announce in the SPDP beacon
127/// from an UdpTransport bound to UNSPECIFIED.
128///
129/// Binding to `0.0.0.0` yields `local_addr() == 0.0.0.0:port`, which is
130/// not routable for peers. Via a UDP connect probe to a non-routable
131/// address we resolve the outbound interface address (no traffic —
132/// `connect()` on a UDP socket only sets the routing information). Falls
133/// back to `multicast_interface` (RuntimeConfig) if the probe fails, or
134/// to the unchanged locator as a last resort.
135#[cfg(feature = "std")]
136fn announce_locator(uc: &(dyn Transport + Send + Sync), hint: Ipv4Addr) -> Locator {
137    let raw = uc.local_locator();
138    // Keep the port from the bound socket.
139    let port = raw.port;
140    // V6 resolution: with a `::` bind, announce `::1` (loopback) as a
141    // sensible default reachability. Cross-host v6 is its own sprint
142    // (needs a v6 interface probe analogous to the v4 path below).
143    if raw.kind == LocatorKind::UdpV6 || raw.kind == LocatorKind::Tcpv6 {
144        let all_zero = raw.address.iter().all(|b| *b == 0);
145        if all_zero {
146            let mut loopback_addr = [0u8; 16];
147            loopback_addr[15] = 1;
148            return match raw.kind {
149                LocatorKind::Tcpv6 => Locator::tcp_v6(loopback_addr, port),
150                _ => Locator::udp_v6(loopback_addr, port),
151            };
152        }
153        return raw;
154    }
155    // V4 resolution: only meaningful for UDPv4/TCPv4 locators with an
156    // UNSPECIFIED bind. For SHM, return raw — the locator kind has its
157    // own pairing resolution (its own sprint).
158    if raw.kind != LocatorKind::UdpV4 && raw.kind != LocatorKind::Tcpv4 {
159        return raw;
160    }
161    // Extract the address — only the last 4 bytes are the IPv4.
162    let ip = Ipv4Addr::new(
163        raw.address[12],
164        raw.address[13],
165        raw.address[14],
166        raw.address[15],
167    );
168    if !ip.is_unspecified() {
169        return raw;
170    }
171    // Helper: construct a locator with the original kind (UdpV4 or
172    // Tcpv4) and the now-resolved v4 address.
173    let to_locator = |octets: [u8; 4]| -> Locator {
174        match raw.kind {
175            LocatorKind::Tcpv4 => Locator::tcp_v4(octets, port),
176            _ => Locator::udp_v4(octets, port),
177        }
178    };
179    // Interface pinning: an explicitly set interface
180    // (`ZERODDS_INTERFACE` / `RuntimeConfig.multicast_interface`) takes
181    // **precedence over the route probe**. On multi-homed hosts (VPN/
182    // Docker/macOS bridge100) the probe might otherwise pick the wrong
183    // source IP and announce an unreachable address → discovery fails.
184    if !hint.is_unspecified() {
185        return to_locator(hint.octets());
186    }
187    // Probe: temporary socket, "connect" to 192.0.2.1 (RFC 5737
188    // TEST-NET-1, guaranteed non-routable). connect only sets the routing
189    // table — no packet goes out.
190    if let Ok(probe) =
191        std::net::UdpSocket::bind(std::net::SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
192    {
193        if probe
194            .connect(std::net::SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 1), 7))
195            .is_ok()
196        {
197            if let Ok(std::net::SocketAddr::V4(local)) = probe.local_addr() {
198                let resolved = local.ip();
199                if !resolved.is_unspecified() {
200                    return to_locator(resolved.octets());
201                }
202            }
203        }
204    }
205    // Fallback: loopback (the pin hint is already handled above). Not
206    // ideal, but better than 0.0.0.0 as a locator (at least routable on
207    // the same host).
208    to_locator([127, 0, 0, 1])
209}
210
211/// Converts a `core::time::Duration` (std) to a `zerodds_qos::Duration`
212/// (spec 2^-32 fraction encoding). Saturates on overflow — `i32::MAX`
213/// seconds suffices for over 60 years of lease.
214fn qos_duration_from_std(d: Duration) -> QosDuration {
215    let secs = i32::try_from(d.as_secs()).unwrap_or(i32::MAX);
216    let nanos = d.subsec_nanos();
217    // The spec fraction is 2^-32 s; from nanos back via (nanos << 32) / 1e9.
218    let fraction = ((u64::from(nanos)) << 32) / 1_000_000_000u64;
219    QosDuration {
220        seconds: secs,
221        fraction: fraction as u32,
222    }
223}
224
225/// Converts a `zerodds_qos::Duration` to nanoseconds (0 = INFINITE,
226/// "no monitoring"). `seconds` is i32 — we clamp to non-negative.
227fn qos_duration_to_nanos(d: zerodds_qos::Duration) -> u64 {
228    if d.is_infinite() {
229        return 0;
230    }
231    let secs = d.seconds.max(0) as u64;
232    // fraction is 2^-32 s, i.e. nanos = fraction * 1e9 / 2^32.
233    let frac_nanos = ((d.fraction as u64) * 1_000_000_000u64) >> 32;
234    secs.saturating_mul(1_000_000_000u64)
235        .saturating_add(frac_nanos)
236}
237
238/// Human-readable name of a QoS policy id (Spec OMG DDS 1.4 §2.2.3,
239/// PSM ids from [`crate::psm_constants::qos_policy_id`]). Used for the
240/// C2 "loud instead of silent" log on an incompatible QoS match, so that
241/// it states in plain text *which* policy prevented the match.
242#[must_use]
243fn qos_policy_id_name(pid: u32) -> &'static str {
244    use crate::psm_constants::qos_policy_id as qid;
245    match pid {
246        qid::DURABILITY => "DURABILITY",
247        qid::PRESENTATION => "PRESENTATION",
248        qid::DEADLINE => "DEADLINE",
249        qid::LATENCY_BUDGET => "LATENCY_BUDGET",
250        qid::OWNERSHIP => "OWNERSHIP",
251        qid::OWNERSHIP_STRENGTH => "OWNERSHIP_STRENGTH",
252        qid::LIVELINESS => "LIVELINESS",
253        qid::PARTITION => "PARTITION",
254        qid::RELIABILITY => "RELIABILITY",
255        qid::DESTINATION_ORDER => "DESTINATION_ORDER",
256        qid::DURABILITY_SERVICE => "DURABILITY_SERVICE",
257        qid::TYPE_CONSISTENCY_ENFORCEMENT => "TYPE_CONSISTENCY_ENFORCEMENT",
258        qid::DATA_REPRESENTATION => "DATA_REPRESENTATION",
259        _ => "OTHER",
260    }
261}
262
263/// RTPS serialized-payload header for user samples: `CDR_LE`
264/// (PLAIN_CDR / XCDR1, little-endian) + options=0. Spec OMG RTPS 2.5
265/// §9.4.2.13.
266///
267/// Prepended to every user payload before it goes into the DATA
268/// submessage — without this header, vendor readers (Cyclone / Fast-DDS)
269/// refuse to deliver the sample.
270///
271/// **Why `0x01` (XCDR1) and not `0x07` (XCDR2):** the C++ PSM codegen
272/// (`dds/topic/xcdr2.hpp`) aligns 8-byte primitives to `sizeof` — that
273/// is the PLAIN_CDR/XCDR1 rule, NOT XCDR2 (which requires
274/// `min(sizeof,4)`). ZeroDDS therefore effectively produces an XCDR1
275/// layout; the encapsulation header must declare that honestly,
276/// otherwise the peer reads the body with the wrong alignment (e.g.
277/// OpenDDS' `dds_demarshal` fails). Full XCDR2 support is a separate
278/// codegen feature.
279pub const USER_PAYLOAD_ENCAP: [u8; 4] = [0x00, 0x01, 0x00, 0x00];
280
281/// Encapsulation header for the user payload, based on the negotiated
282/// DataRepresentation (`offer_first`: the **first** element of the
283/// writer's offer list = the wire format actually emitted by the writer)
284/// and the type extensibility. The header MUST honestly declare the body
285/// encoding produced by the codegen, otherwise the peer (e.g.
286/// FastDDS/OpenDDS XCDR2-only reader) reads the body with the wrong
287/// alignment or wrongly expects a DHEADER.
288///
289/// DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 (little-endian variant):
290///   XCDR1 final/appendable -> CDR_LE        `0x0001`
291///   XCDR1 mutable          -> PL_CDR_LE      `0x0003`
292///   XCDR2 final            -> PLAIN_CDR2_LE  `0x0007`
293///   XCDR2 appendable       -> D_CDR2_LE      `0x0009`
294///   XCDR2 mutable          -> PL_CDR2_LE     `0x000b`
295#[must_use]
296fn user_payload_encap(offer_first: i16, ext: zerodds_types::qos::ExtensibilityForRepr) -> [u8; 4] {
297    use zerodds_rtps::publication_data::data_representation as dr;
298    use zerodds_types::qos::ExtensibilityForRepr::{Appendable, Final, Mutable};
299    let id: u8 = match (offer_first, ext) {
300        (dr::XCDR2, Final) => 0x07,
301        (dr::XCDR2, Appendable) => 0x09,
302        (dr::XCDR2, Mutable) => 0x0b,
303        // XCDR1: appendable is treated like final (Tab.59: XCDR1 has no
304        // dedicated APPENDABLE encoding).
305        (dr::XCDR, Mutable) => 0x03,
306        // (dr::XCDR, Final|Appendable) as well as XML/unknown -> CDR_LE.
307        _ => 0x01,
308    };
309    [0x00, id, 0x00, 0x00]
310}
311
312/// Stack PoolBuffer cap for the small-sample path in
313/// [`DcpsRuntime::write_user_sample`]. A 1.5 KiB payload + 4 B encap
314/// header fit through the framing without touching the heap.
315const SMALL_FRAME_CAP: usize = 1536;
316
317/// Small-sample hot-path helper: frames `USER_PAYLOAD_ENCAP` + payload
318/// into a stack `PoolBuffer<SMALL_FRAME_CAP>` and hands the slice to the
319/// writer. No Vec/Box/Rc/Arc allocation in this function — verified by
320/// the `dds_no_realloc_in_hot_path` lint.
321///
322/// zerodds-lint: hot-path-realloc-free
323fn write_user_sample_pooled(
324    writer: &mut ReliableWriter,
325    payload: &[u8],
326    now: Duration,
327    encap: &[u8; 4],
328) -> Result<Vec<zerodds_rtps::message_builder::OutboundDatagram>> {
329    let mut frame = zerodds_foundation::PoolBuffer::<SMALL_FRAME_CAP>::new();
330    frame
331        .extend_from_slice(encap)
332        .map_err(|_| DdsError::WireError {
333            message: String::from("user encap framing"),
334        })?;
335    frame
336        .extend_from_slice(payload)
337        .map_err(|_| DdsError::WireError {
338            message: String::from("user payload framing"),
339        })?;
340    // Hot path: only DATA, NO HEARTBEAT. Cyclone DDS rate-limits the
341    // HB piggyback (≥100 µs spacing, or a packet boundary) — so it does
342    // NOT send an HB per write. At 14k writes/sec this would fire 14k
343    // unnecessary submessages and (with an unaligned payload) 14k extra
344    // sendto syscalls. Periodic HBs are handled by the tick loop (every
345    // `heartbeat_period` ms, default 100 ms); we no longer attach `_now`
346    // to `last_heartbeat`, because we emit nothing.
347    let _ = now;
348    writer
349        .write(frame.as_slice())
350        .map_err(|_| DdsError::WireError {
351            message: String::from("user writer encode"),
352        })
353}
354
355/// Choice of transport for DCPS user traffic. Discovery (SPDP/SEDP)
356/// remains UDPv4 multicast independently of this.
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358#[non_exhaustive]
359pub enum UserTransportKind {
360    /// UDP IPv4 (default).
361    UdpV4,
362    /// UDP IPv6.
363    UdpV6,
364    /// TCP IPv4 (DDS-TCP-PSM `LOCATOR_KIND_TCPV4`).
365    TcpV4,
366    /// TCP IPv6 (DDS-TCP-PSM `LOCATOR_KIND_TCPV6`).
367    TcpV6,
368    /// POSIX shared memory (same-host). Only with the `same-host-shm`
369    /// feature.
370    #[cfg(feature = "same-host-shm")]
371    Shm,
372    /// Unix domain socket (same-host, container-friendly). Only with the
373    /// `same-host-uds` feature.
374    #[cfg(feature = "same-host-uds")]
375    Uds,
376    /// TSN L2 transport (AF_PACKET, RTPS direct on Ethernet, EtherType
377    /// 0x88B5). Only with the `tsn-live` feature on Linux. Interface/VLAN/
378    /// PCP via the env vars `ZERODDS_TSN_IFACE`/`_VLAN`/`_PCP`.
379    #[cfg(all(feature = "tsn-live", target_os = "linux"))]
380    Tsn,
381}
382
383/// Maps the `ZERODDS_USER_TRANSPORT` env var to a [`UserTransportKind`].
384/// `None` if unset or unknown — the caller then falls back to UDPv4.
385fn parse_user_transport_env() -> Option<UserTransportKind> {
386    match std::env::var("ZERODDS_USER_TRANSPORT").ok()?.as_str() {
387        "UDPv4" => Some(UserTransportKind::UdpV4),
388        "UDPv6" => Some(UserTransportKind::UdpV6),
389        "TCPv4" => Some(UserTransportKind::TcpV4),
390        "TCPv6" => Some(UserTransportKind::TcpV6),
391        #[cfg(feature = "same-host-shm")]
392        "SHM" => Some(UserTransportKind::Shm),
393        #[cfg(feature = "same-host-uds")]
394        "UDS" => Some(UserTransportKind::Uds),
395        #[cfg(all(feature = "tsn-live", target_os = "linux"))]
396        "TSN" => Some(UserTransportKind::Tsn),
397        _ => None,
398    }
399}
400
401/// Result of [`select_user_transport`]: the user-traffic transport plus
402/// an optional `TcpTransport` accept handle (only for TCP).
403type UserTransportSelection = (
404    Arc<dyn Transport + Send + Sync>,
405    Option<Arc<zerodds_transport_tcp::TcpTransport>>,
406);
407
408/// Binds the user-traffic transport for the selected
409/// [`UserTransportKind`]. Discovery (SPDP/SEDP) runs separately over
410/// UDPv4 multicast; this transport carries only the DCPS user traffic.
411///
412/// Additionally returns an optional `TcpTransport` accept handle: TCP has
413/// no implicit accept thread in the constructor, so the caller starts an
414/// `accept_one` worker for it.
415#[cfg_attr(
416    not(any(feature = "same-host-shm", feature = "same-host-uds")),
417    allow(unused_variables)
418)]
419fn select_user_transport(
420    kind: UserTransportKind,
421    guid_prefix: GuidPrefix,
422    domain_id: i32,
423    pinned: Ipv4Addr,
424) -> Result<UserTransportSelection> {
425    match kind {
426        UserTransportKind::UdpV4 => {
427            // Interface pinning: bind to the pinned IPv4 (egress + receive
428            // on exactly this interface), otherwise `0.0.0.0` (auto).
429            let udp = UdpTransport::bind_v4(pinned, 0)
430                .map_err(|_| DdsError::TransportError {
431                    label: "user unicast bind (UDPv4)",
432                })?
433                .with_timeout(Some(Duration::from_secs(1)))
434                .map_err(|_| DdsError::TransportError {
435                    label: "user unicast set_timeout (UDPv4)",
436                })?;
437            Ok((Arc::new(udp), None))
438        }
439        UserTransportKind::UdpV6 => {
440            let udp = UdpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
441                .map_err(|_| DdsError::TransportError {
442                    label: "user unicast bind (UDPv6)",
443                })?
444                .with_timeout(Some(Duration::from_secs(1)))
445                .map_err(|_| DdsError::TransportError {
446                    label: "user unicast set_timeout (UDPv6)",
447                })?;
448            Ok((Arc::new(udp), None))
449        }
450        UserTransportKind::TcpV4 => {
451            // Interface pinning analogous to UDPv4.
452            let tcp = zerodds_transport_tcp::TcpTransport::bind_v4(pinned, 0).map_err(|_| {
453                DdsError::TransportError {
454                    label: "user unicast bind (TCPv4)",
455                }
456            })?;
457            let arc = Arc::new(tcp);
458            let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
459            Ok((dynamic, Some(arc)))
460        }
461        UserTransportKind::TcpV6 => {
462            let tcp =
463                zerodds_transport_tcp::TcpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
464                    .map_err(|_| DdsError::TransportError {
465                        label: "user unicast bind (TCPv6)",
466                    })?;
467            let arc = Arc::new(tcp);
468            let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
469            Ok((dynamic, Some(arc)))
470        }
471        #[cfg(feature = "same-host-shm")]
472        UserTransportKind::Shm => {
473            // local_id = guid_prefix (12 bytes) + 4-byte domain id so that
474            // separate domains get separate segments (no cross-domain
475            // collisions).
476            let mut local_id = [0u8; 16];
477            local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
478            local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
479            let shm = crate::shm_user::ShmUserTransport::new(
480                local_id,
481                zerodds_transport_shm::posix::ShmConfig::default(),
482            );
483            Ok((Arc::new(shm), None))
484        }
485        #[cfg(feature = "same-host-uds")]
486        UserTransportKind::Uds => {
487            // local_id = guid_prefix (12 bytes) + 4-byte domain id — the
488            // peer resolves this id from the announced UDS locator into the
489            // same socket path.
490            let mut local_id = [0u8; 16];
491            local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
492            local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
493            // recv_timeout analogous to the UDP path: the recv loop must
494            // periodically check the stop flag (otherwise a thread hang on
495            // shutdown on a blocking recv).
496            let uds_cfg = zerodds_transport_uds::UdsConfig {
497                recv_timeout: Some(Duration::from_secs(1)),
498                ..zerodds_transport_uds::UdsConfig::default()
499            };
500            let uds =
501                zerodds_transport_uds::UdsTransport::bind(local_id, uds_cfg).map_err(|_| {
502                    DdsError::TransportError {
503                        label: "user unicast bind (UDS)",
504                    }
505                })?;
506            Ok((Arc::new(uds), None))
507        }
508        #[cfg(all(feature = "tsn-live", target_os = "linux"))]
509        UserTransportKind::Tsn => {
510            // Interface/VLAN/PCP via env (TSN needs a concrete interface;
511            // not bindable to 0.0.0.0 like UDP/TCP). recv_timeout 1s
512            // analogous to UDP for the stop-flag check.
513            let iface =
514                std::env::var("ZERODDS_TSN_IFACE").map_err(|_| DdsError::TransportError {
515                    label: "ZERODDS_TSN_IFACE not set (TSN transport)",
516                })?;
517            let vlan = std::env::var("ZERODDS_TSN_VLAN")
518                .ok()
519                .and_then(|s| s.parse::<u16>().ok())
520                .unwrap_or(0);
521            let pcp = std::env::var("ZERODDS_TSN_PCP")
522                .ok()
523                .and_then(|s| s.parse::<u8>().ok())
524                .unwrap_or(0);
525            let tsn = zerodds_transport_tsn::socket::TsnTransport::bind(
526                &iface,
527                vlan,
528                pcp,
529                Some(Duration::from_secs(1)),
530            )
531            .map_err(|_| DdsError::TransportError {
532                label: "user unicast bind (TSN)",
533            })?;
534            Ok((Arc::new(tsn), None))
535        }
536    }
537}
538
539/// Configuration for the runtime. Exposed via DomainParticipant factory
540/// methods.
541#[derive(Clone)]
542pub struct RuntimeConfig {
543    /// Tick period of the event loop. Default 50 ms.
544    pub tick_period: Duration,
545    /// SPDP announce period. Default 5 s.
546    pub spdp_period: Duration,
547    /// C3 WiFi-robust discovery — number of initial SPDP announces sent at the
548    /// fast [`Self::initial_announce_period`] cadence (instead of `spdp_period`)
549    /// while no peer is yet discovered. Default
550    /// [`DEFAULT_INITIAL_ANNOUNCE_COUNT`]. `0` disables the burst (legacy
551    /// single-announce-then-`spdp_period` behaviour).
552    pub initial_announce_count: u32,
553    /// Period between initial-announcement-burst SPDP sends. Default
554    /// [`DEFAULT_INITIAL_ANNOUNCE_PERIOD`].
555    pub initial_announce_period: Duration,
556    /// SPDP multicast group (IPv4). Default 239.255.0.1 (Spec §9.6.1.4.1).
557    pub spdp_multicast_group: Ipv4Addr,
558    /// Interface address for the multicast join. Default 0.0.0.0 (the
559    /// kernel picks the default interface).
560    pub multicast_interface: Ipv4Addr,
561
562    /// C1: whether SPDP beacons are sent via multicast. Default `true`
563    /// (spec behavior). `false` (env `ZERODDS_NO_MULTICAST`) → pure
564    /// unicast discovery via [`Self::initial_peers`], not a single
565    /// multicast packet — for networks that drop multicast (WiFi/cloud
566    /// VPC), and for a rigorous multicast-free discovery proof.
567    pub spdp_multicast_send: bool,
568
569    /// C3: max reassemblable sample size (DoS cap of the fragment
570    /// assembler). Larger samples are silently discarded. The rtps
571    /// default was 1 MiB (the phase-1 assumption "large images = no
572    /// use case") — too small for ROS PointCloud2/Image (often several
573    /// MB). Default here 16 MiB; env `ZERODDS_MAX_SAMPLE_BYTES` (bytes)
574    /// overrides. Still a deliberate DoS guard, just ROS-realistic.
575    pub max_reassembly_sample_bytes: usize,
576
577    /// C1 multicast-free discovery: unicast initial-peer locators to
578    /// which SPDP beacons are sent **in addition** to multicast. Default
579    /// empty (= pure multicast behavior as before). Populated via
580    /// [`RuntimeConfig::default`] from the env `ZERODDS_PEERS` (comma
581    /// list of `ip` or `ip:port`). An `ip` without a port is expanded to
582    /// the well-known SPDP unicast ports of participant indices 0..N
583    /// (see [`expand_initial_peer`]).
584    pub initial_peers: Vec<Locator>,
585
586    /// Transport for DCPS user traffic. `None` (default) → fall back to
587    /// the env var `ZERODDS_USER_TRANSPORT`, otherwise UDPv4. Discovery
588    /// (SPDP/SEDP) remains UDPv4 multicast independently of this.
589    pub user_transport: Option<UserTransportKind>,
590
591    /// Optional security gate. Active only with the `security` feature.
592    /// When set, UDP outbound messages are pulled through
593    /// [`SharedSecurityGate::transform_outbound`], and inbound messages
594    /// through [`SharedSecurityGate::transform_inbound_from`] (peer key
595    /// from RTPS header bytes 8..20).
596    #[cfg(feature = "security")]
597    pub security: Option<std::sync::Arc<zerodds_security_runtime::SharedSecurityGate>>,
598    /// Optional LoggingPlugin for security events. Called by the inbound
599    /// path when packets are dropped due to a policy violation, tampering
600    /// or a legacy block.
601    #[cfg(feature = "security")]
602    pub security_logger: Option<std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin>>,
603
604    /// Multi-interface bindings. Empty → `user_unicast` is the only
605    /// outbound socket (legacy behavior). Non-empty →
606    /// `DcpsRuntime::start` builds a dedicated UDP socket per spec and the
607    /// writer tick loop routes to the matching socket per destination
608    /// locator.
609    #[cfg(feature = "security")]
610    pub interface_bindings: Vec<InterfaceBindingSpec>,
611
612    /// `true` → the SPDP beacon additionally announces the 12 secure
613    /// discovery bits (16..27, DDS-Security 1.2 §7.4.7.1). Default
614    /// `false` — only standard bits are announced. Set by the DCPS
615    /// factory once a PolicyEngine is configured. This flag is available
616    /// even without the `security` feature, so that tests can check bit
617    /// presence without activating the whole crypto crate.
618    pub announce_secure_endpoints: bool,
619
620    /// FastDDS interop: run the reliable secure SPDP channel (0xff0101c2/c7,
621    /// `ENTITYID_SPDP_RELIABLE_BUILTIN_PARTICIPANT_SECURE_*`). FastDDS announces
622    /// its full secured participant data (identity_token/security_info) over
623    /// this channel and gates the crypto-token reciprocation/endpoint matching
624    /// on it; cyclone does NOT need it (cyclone↔zerodds runs without). Default off
625    /// — enable only for FastDDS cross-vendor.
626    pub enable_secure_spdp: bool,
627
628    /// WLP-Tick-Periode (Writer-Liveliness-Protocol, RTPS 2.5 §8.4.13).
629    /// `Duration::ZERO` → default `participant_lease_duration / 3`
630    /// (spec recommendation: three misses before the reader marks the
631    /// writer as not-alive). A direct override enables aggressive
632    /// tests.
633    pub wlp_period: Duration,
634
635    /// Lease duration announced in the SPDP beacon as
636    /// `PARTICIPANT_LEASE_DURATION` (spec default 100 s). Also used as the
637    /// basis for the AUTOMATIC WLP tick (`wlp_period =
638    /// participant_lease_duration / 3` if `wlp_period == Duration::ZERO`).
639    pub participant_lease_duration: Duration,
640
641    /// USER_DATA bytes of the participant (DDS 1.4 §2.2.3.1
642    /// `UserDataQosPolicy`). Announced in the SPDP beacon as PID_USER_DATA
643    /// (DDSI-RTPS §9.6.3.2) and exposed on the receiver side in
644    /// `ParticipantBuiltinTopicData.user_data`. Default empty.
645    pub user_data: Vec<u8>,
646
647    /// Observability sink. Default is `null_sink()` — each event emit is
648    /// then a direct return without allocation on the consumer side.
649    /// Consumers inject e.g.
650    /// [`zerodds_foundation::observability::StderrJsonSink`] (JSON lines
651    /// for Vector/fluentd/Datadog) or their own OTLP bridge.
652    pub observability: zerodds_foundation::observability::SharedSink,
653
654    /// Sprint D.5d lever C — RT pinning + priority. Linux-only; on
655    /// macOS/Windows the hooks are no-ops.
656    ///
657    /// SCHED_FIFO priority (1-99) for the three recv workers (SPDP MC,
658    /// metatraffic, user data). `None` = default scheduler (CFS).
659    /// `Some(80)` is the spec recommendation for real-time paths. Requires
660    /// `CAP_SYS_NICE` or an `RLIMIT_RTPRIO`-permitted user.
661    pub recv_thread_priority: Option<i32>,
662
663    /// Like [`Self::recv_thread_priority`], but for the tick worker.
664    pub tick_thread_priority: Option<i32>,
665
666    /// CPU affinity mask for the recv workers. `None` = no affinity (the
667    /// kernel schedules freely). A list of CPU indices, e.g.
668    /// `vec![2, 3]` for cores 2+3. Set via `sched_setaffinity`; all three
669    /// recv threads share the same mask.
670    pub recv_thread_cpus: Option<Vec<usize>>,
671
672    /// Like [`Self::recv_thread_cpus`], but for the tick worker.
673    pub tick_thread_cpus: Option<Vec<usize>>,
674
675    /// Opt-3 (Spec `zerodds-zero-copy-1.0` §9): number of additional
676    /// user-data recv workers that listen on the same port as
677    /// `user_unicast` via `SO_REUSEPORT`. `0` (default) = only the primary
678    /// `recv_user_data_loop` worker. Under high recv load the pool scales
679    /// linearly with cores (kernel flow hashing distributes incoming
680    /// datagrams). Recommended values: 1-3 additional workers per CPU
681    /// core.
682    pub extra_recv_threads: usize,
683
684    /// D.5g — default DataRepresentation list announced in SEDP
685    /// PublicationData and SEDP SubscriptionData, when not overridden
686    /// per-writer/reader (UserWriterConfig/UserReaderConfig).
687    ///
688    /// **Important**: per strict spec (XTypes 1.3 §7.6.3.1.2) the first
689    /// element is the writer's "offered" and must be in the reader's
690    /// "accepted" list for a match to happen. Default `[XCDR1, XCDR2]` =
691    /// legacy-first → max interop with the RTI Connext Shapes Demo
692    /// (XCDR1-only). Pure-XCDR2 deployments can switch this to `[XCDR2]`
693    /// or `[XCDR2, XCDR1]` for bandwidth efficiency and
694    /// @appendable/@mutable support.
695    ///
696    /// Empty (`vec![]`) is interpreted per spec as `[XCDR1]`.
697    pub data_representation_offer: Vec<i16>,
698
699    /// D.5g — default match mode for DataRepresentation negotiation.
700    ///
701    /// `Strict` (XTypes 1.3 §7.6.3.1.2 normative): writer.first ∈
702    /// reader.list = match. `Tolerant` (industry norm): any overlap =
703    /// match, picks the first overlap as the wire format.
704    ///
705    /// Default `Tolerant` because Cyclone DDS and FastDDS match this way —
706    /// maximizes interop. The strict setting is only meaningful for
707    /// formal spec-compliance tests.
708    pub data_rep_match_mode: zerodds_rtps::publication_data::data_representation::DataRepMatchMode,
709
710    /// zerodds-async-1.0 §4 — when `true`, `start()` does **not** spawn the
711    /// dedicated `zdds-tick` std::thread. The periodic tick (SPDP announce,
712    /// SEDP/WLP, deadline/lifespan/liveliness) must then be driven externally
713    /// via [`DcpsRuntime::tick_driver`]. Used by the async API's
714    /// `spawn_in_tokio`, which multiplexes many participants' tick loops onto
715    /// a tokio runtime instead of one thread each. Default `false` (internal
716    /// thread, unchanged behaviour). The recv worker threads are unaffected —
717    /// they block on socket recv and stay regardless.
718    pub external_tick: bool,
719
720    /// D.5e Phase 3 — when `true`, `start()` drives the periodic tick via the
721    /// event-driven deadline scheduler ([`crate::scheduler`]) instead of the
722    /// fixed-`tick_period` poll: the worker parks until the next due deadline
723    /// (SPDP announce, or a fine floor while user endpoints/QoS timers are
724    /// active) or until a write/recv `raise` wakes it — no busy-poll, lower idle
725    /// CPU, lower tail latency. The work done per wake is the **unchanged**
726    /// `run_tick_iteration` (identical wire output + cadence — cross-vendor
727    /// safe). **Default `true`** since D.5e Phase C (2026-06-14) — set
728    /// `ZERODDS_SCHEDULER_TICK=0` or this field to `false` for the classic
729    /// fixed-period `tick_loop`. Mutually exclusive with `external_tick`
730    /// (external wins).
731    pub scheduler_tick: bool,
732}
733
734/// Configuration entry for a physical or logical network interface.
735///
736/// A binding describes an outbound socket: which IP/port it binds to,
737/// which `NetInterface` class the interface represents, and which IP
738/// range counts as "associated peers" (routing match).
739#[cfg(feature = "security")]
740#[derive(Clone, Debug)]
741pub struct InterfaceBindingSpec {
742    /// Name for diagnostics + log attribution (e.g. `"eth0"`, `"tun0"`,
743    /// `"lo"`).
744    pub name: String,
745    /// Bind address. `0.0.0.0` leaves the interface to the kernel.
746    pub bind_addr: Ipv4Addr,
747    /// Bind port. `0` = ephemeral.
748    pub bind_port: u16,
749    /// Interface class — feeds into the PolicyEngine context.
750    pub kind: NetInterface,
751    /// Destination IP range this binding is responsible for. Example:
752    /// `127.0.0.0/8` for loopback. A target whose IP lies in this range is
753    /// routed to this binding.
754    pub subnet: IpRange,
755    /// If `true`: this binding is used when **no** other subnet match
756    /// applies. Exactly one entry should have `default = true` (usually
757    /// the WAN binding).
758    pub default: bool,
759}
760
761/// Fully bound interface with its UDP socket.
762#[cfg(feature = "security")]
763struct InterfaceBinding {
764    spec: InterfaceBindingSpec,
765    socket: Arc<UdpTransport>,
766}
767
768/// Pool of per-interface UDP sockets with target-based routing.
769///
770/// Decision:
771/// 1. Iterates over all bindings; the first whose subnet contains the
772///    target wins.
773/// 2. If no match and a default binding exists → default path.
774/// 3. No match + no default → `None`, the caller drops.
775#[cfg(feature = "security")]
776struct OutboundSocketPool {
777    bindings: Vec<InterfaceBinding>,
778    default_idx: Option<usize>,
779}
780
781#[cfg(feature = "security")]
782impl OutboundSocketPool {
783    fn bind_all(specs: &[InterfaceBindingSpec]) -> Result<Self> {
784        let mut bindings = Vec::with_capacity(specs.len());
785        for spec in specs {
786            let socket = UdpTransport::bind_v4(spec.bind_addr, spec.bind_port).map_err(|_| {
787                DdsError::TransportError {
788                    label: "interface-binding bind_v4 failed",
789                }
790            })?;
791            // Short read timeout so that the per-interface inbound poll in
792            // the event loop becomes non-blocking. 5 ms is small enough not
793            // to create latency elsewhere (the tick period defaults to
794            // 50 ms), but large enough to amortize context switches.
795            let socket = socket
796                .with_timeout(Some(Duration::from_millis(5)))
797                .map_err(|_| DdsError::TransportError {
798                    label: "interface-binding set_timeout failed",
799                })?;
800            bindings.push(InterfaceBinding {
801                spec: spec.clone(),
802                socket: Arc::new(socket),
803            });
804        }
805        let default_idx = bindings.iter().position(|b| b.spec.default);
806        Ok(Self {
807            bindings,
808            default_idx,
809        })
810    }
811
812    /// Returns `(socket, NetInterface class)` for a destination locator.
813    /// `None` if neither a subnet match nor a default binding exists.
814    fn route(&self, target: &Locator) -> Option<(&Arc<UdpTransport>, NetInterface)> {
815        let ip = ipv4_from_locator(target)?;
816        let addr = core::net::IpAddr::V4(core::net::Ipv4Addr::from(ip));
817        for b in &self.bindings {
818            if b.spec.subnet.contains(&addr) {
819                return Some((&b.socket, b.spec.kind.clone()));
820            }
821        }
822        let idx = self.default_idx?;
823        let b = self.bindings.get(idx)?;
824        Some((&b.socket, b.spec.kind.clone()))
825    }
826}
827
828/// True if the locator is routable over the user-data transport
829/// (trait object). Accepts UDPv4, UDPv6, TCPv4, Shm. The concrete
830/// transport (UdpTransport/TcpTransport/ShmUserTransport) then returns
831/// `UnsupportedLocator` for kinds it does not itself speak;
832/// the filter here only prevents sending to clearly non-IP/SHM
833/// locators like UDS (for which we have no transport plugin).
834fn is_routable_user_locator(loc: &Locator) -> bool {
835    matches!(
836        loc.kind,
837        LocatorKind::UdpV4
838            | LocatorKind::UdpV6
839            | LocatorKind::Tcpv4
840            | LocatorKind::Tcpv6
841            | LocatorKind::Shm
842            | LocatorKind::Uds
843            | LocatorKind::Tsn
844    )
845}
846
847/// Computes the user-endpoint `EndpointSecurityInfo` mask from the governance
848/// protection kinds (DDS-Security 1.2 §10.4.1.2.6 / §9.4.2.4). The wire mask
849/// MUST match cyclone/FastDDS/OpenDDS byte-exactly, otherwise the peer rejects
850/// the endpoint match with "security_attributes mismatch".
851///
852/// - metadata=SIGN/ENCRYPT → IS_SUBMESSAGE_PROTECTED (+ plugin SUBMESSAGE_ENCRYPTED on ENCRYPT)
853/// - data=SIGN    → IS_PAYLOAD_PROTECTED
854/// - data=ENCRYPT → IS_PAYLOAD_PROTECTED | **IS_KEY_PROTECTED** (+ plugin PAYLOAD_ENCRYPTED)
855/// - liveliness=SIGN/ENCRYPT → **IS_LIVELINESS_PROTECTED** (§9.4.1.3: per-endpoint!)
856/// - topic enable_discovery_protection → IS_DISCOVERY_PROTECTED
857///
858/// is_key_protected follows §10.4.1.2.6 exclusively from the **DATA** protection
859/// and only on ENCRYPT — NOT from the metadata protection. is_liveliness_protected
860/// in contrast MUST be on every user endpoint as soon as liveliness_protection is active;
861/// cyclone compares the mask at endpoint match and otherwise rejects with
862/// "security_attributes mismatch" (0x..30 vs 0x..70).
863#[cfg(feature = "security")]
864fn compute_user_endpoint_attrs(
865    meta: ProtectionLevel,
866    data: ProtectionLevel,
867    discovery_protected: bool,
868    liveliness_protected: bool,
869    read_protected: bool,
870    write_protected: bool,
871) -> zerodds_rtps::endpoint_security_info::EndpointSecurityInfo {
872    use zerodds_rtps::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
873    let mut a = attrs::IS_VALID;
874    let mut p = plugin_attrs::IS_VALID;
875    if read_protected {
876        a |= attrs::IS_READ_PROTECTED;
877    }
878    if write_protected {
879        a |= attrs::IS_WRITE_PROTECTED;
880    }
881    if meta != ProtectionLevel::None {
882        a |= attrs::IS_SUBMESSAGE_PROTECTED;
883    }
884    if meta == ProtectionLevel::Encrypt {
885        p |= plugin_attrs::IS_SUBMESSAGE_ENCRYPTED;
886    }
887    if data != ProtectionLevel::None {
888        a |= attrs::IS_PAYLOAD_PROTECTED;
889    }
890    if data == ProtectionLevel::Encrypt {
891        a |= attrs::IS_KEY_PROTECTED;
892        p |= plugin_attrs::IS_PAYLOAD_ENCRYPTED;
893    }
894    if discovery_protected {
895        a |= attrs::IS_DISCOVERY_PROTECTED;
896    }
897    if liveliness_protected {
898        a |= attrs::IS_LIVELINESS_PROTECTED;
899    }
900    EndpointSecurityInfo {
901        endpoint_security_attributes: a,
902        plugin_endpoint_security_attributes: p,
903    }
904}
905
906#[cfg(all(test, feature = "security"))]
907mod endpoint_attr_tests {
908    use super::compute_user_endpoint_attrs;
909    use zerodds_rtps::endpoint_security_info::attrs;
910    use zerodds_security_runtime::ProtectionLevel;
911
912    fn mask(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
913        compute_user_endpoint_attrs(meta, data, false, false, false, false)
914            .endpoint_security_attributes
915    }
916
917    fn mask_liv(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
918        compute_user_endpoint_attrs(meta, data, false, true, false, false)
919            .endpoint_security_attributes
920    }
921
922    #[test]
923    fn liveliness_protected_sets_0x40_per_spec_9_4_1_3() {
924        use ProtectionLevel::{Encrypt, None};
925        let v = attrs::IS_VALID;
926        let pay = attrs::IS_PAYLOAD_PROTECTED;
927        let key = attrs::IS_KEY_PROTECTED;
928        let liv = attrs::IS_LIVELINESS_PROTECTED;
929        // liveliness=ENCRYPT + data=ENCRYPT → 0x..70 (cyclone's value at match).
930        assert_eq!(mask_liv(None, Encrypt), v | pay | key | liv);
931        // without liveliness → 0x..30, NO 0x40.
932        assert_eq!(mask(None, Encrypt), v | pay | key);
933        assert_eq!(mask_liv(None, None), v | liv);
934    }
935
936    #[test]
937    fn key_protected_follows_data_encrypt_per_spec_10_4_1_2_6() {
938        use ProtectionLevel::{Encrypt, None, Sign};
939        let v = attrs::IS_VALID;
940        let sub = attrs::IS_SUBMESSAGE_PROTECTED;
941        let pay = attrs::IS_PAYLOAD_PROTECTED;
942        let key = attrs::IS_KEY_PROTECTED;
943        // §10.4.1.2.6: is_key_protected follows ONLY data=ENCRYPT.
944        // data=ENCRYPT → PAYLOAD|KEY (= cyclone's 0x30 in the common subset).
945        assert_eq!(mask(None, Encrypt), v | pay | key);
946        // data=SIGN → PAYLOAD, NO KEY.
947        assert_eq!(mask(None, Sign), v | pay);
948        // data=NONE → no payload/key bits.
949        assert_eq!(mask(None, None), v);
950        // KEY does NOT depend on metadata: meta=ENCRYPT/data=NONE → only SUBMESSAGE.
951        assert_eq!(mask(Encrypt, None), v | sub);
952        // meta=ENCRYPT + data=ENCRYPT → SUBMESSAGE|PAYLOAD|KEY (0x38).
953        assert_eq!(mask(Encrypt, Encrypt), v | sub | pay | key);
954    }
955}
956
957/// Unicast targets for the WLP heartbeat fan-out (M-2): per discovered peer the
958/// `metatraffic_unicast_locator` (fallback `default_unicast_locator`), filtered
959/// to routable kinds. WLP is metatraffic (DDSI-RTPS §8.4.13); in multicast-
960/// free environments (container/cloud) the pure multicast pulse never reaches the
961/// peer reader → the lease expires although the peer is alive. The additional
962/// unicast fan-out follows the SEDP locator model.
963fn wlp_unicast_targets(peers: &[zerodds_discovery::spdp::DiscoveredParticipant]) -> Vec<Locator> {
964    peers
965        .iter()
966        .filter_map(|dp| {
967            dp.data
968                .metatraffic_unicast_locator
969                .or(dp.data.default_unicast_locator)
970        })
971        .filter(is_routable_user_locator)
972        .collect()
973}
974
975/// Extracts the IPv4 address from a `Locator` (UDP-V4).
976/// `None` for SHM/UDS/IPv6.
977#[cfg(feature = "security")]
978fn ipv4_from_locator(loc: &Locator) -> Option<[u8; 4]> {
979    if loc.kind != LocatorKind::UdpV4 {
980        return None;
981    }
982    Some([
983        loc.address[12],
984        loc.address[13],
985        loc.address[14],
986        loc.address[15],
987    ])
988}
989
990impl core::fmt::Debug for RuntimeConfig {
991    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
992        let mut dbg = f.debug_struct("RuntimeConfig");
993        dbg.field("tick_period", &self.tick_period)
994            .field("spdp_period", &self.spdp_period)
995            .field("spdp_multicast_group", &self.spdp_multicast_group)
996            .field("multicast_interface", &self.multicast_interface);
997        #[cfg(feature = "security")]
998        {
999            dbg.field("security", &self.security.as_ref().map(|_| "<gate>"));
1000            dbg.field(
1001                "security_logger",
1002                &self.security_logger.as_ref().map(|_| "<logger>"),
1003            );
1004        }
1005        dbg.finish()
1006    }
1007}
1008
1009impl Default for RuntimeConfig {
1010    fn default() -> Self {
1011        // Env hook for bench tuning: ZERODDS_TICK_PERIOD_MS=N → overrides
1012        // the 5ms default. High (e.g. 1000) relieves the write hot path of
1013        // the periodic HB/tick overhead and makes spread spikes from tick
1014        // preemption visible. Production: do not set; the default 5 ms is
1015        // spec-compliant.
1016        let tick = std::env::var("ZERODDS_TICK_PERIOD_MS")
1017            .ok()
1018            .and_then(|s| s.parse::<u64>().ok())
1019            .map(Duration::from_millis)
1020            .unwrap_or(DEFAULT_TICK_PERIOD);
1021        // C3 WiFi-robust discovery — initial-announcement burst. Env overrides:
1022        // `ZERODDS_INITIAL_ANNOUNCE_COUNT` (0 disables) +
1023        // `ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS`.
1024        let initial_announce_count = std::env::var("ZERODDS_INITIAL_ANNOUNCE_COUNT")
1025            .ok()
1026            .and_then(|s| s.parse::<u32>().ok())
1027            .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_COUNT);
1028        let initial_announce_period = std::env::var("ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS")
1029            .ok()
1030            .and_then(|s| s.parse::<u64>().ok())
1031            .map(Duration::from_millis)
1032            .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_PERIOD);
1033        Self {
1034            tick_period: tick,
1035            spdp_period: DEFAULT_SPDP_PERIOD,
1036            initial_announce_count,
1037            initial_announce_period,
1038            // Env override `ZERODDS_SPDP_MC_GROUP` (IPv4) of the SPDP
1039            // multicast group. Two processes with different groups do NOT
1040            // see each other via multicast → enables a multicast-free C1
1041            // e2e proof (discovery then only via ZERODDS_PEERS). Default is
1042            // the spec group.
1043            spdp_multicast_group: std::env::var("ZERODDS_SPDP_MC_GROUP")
1044                .ok()
1045                .and_then(|s| s.parse::<Ipv4Addr>().ok())
1046                .unwrap_or_else(|| Ipv4Addr::from(SPDP_DEFAULT_MULTICAST_ADDRESS)),
1047            // Interface pinning (Cyclone `NetworkInterface`/FastDDS
1048            // whitelist equivalent): `ZERODDS_INTERFACE=<ipv4>` forces
1049            // announce + bind on this interface. Default UNSPECIFIED = auto
1050            // (route probe). Critical on multi-homed hosts (VPN/Docker/
1051            // macOS bridge100), where the auto choice may announce the
1052            // wrong interface.
1053            multicast_interface: std::env::var("ZERODDS_INTERFACE")
1054                .ok()
1055                .and_then(|s| s.parse::<Ipv4Addr>().ok())
1056                .unwrap_or(Ipv4Addr::UNSPECIFIED),
1057            // Multicast send on by default; `ZERODDS_NO_MULTICAST` (any
1058            // non-empty value) turns it off → pure unicast discovery.
1059            spdp_multicast_send: std::env::var("ZERODDS_NO_MULTICAST")
1060                .map(|v| v.is_empty())
1061                .unwrap_or(true),
1062            // C3: 16 MiB default (suitable for ROS PointCloud2/Image),
1063            // env override `ZERODDS_MAX_SAMPLE_BYTES`.
1064            max_reassembly_sample_bytes: std::env::var("ZERODDS_MAX_SAMPLE_BYTES")
1065                .ok()
1066                .and_then(|s| s.parse::<usize>().ok())
1067                .unwrap_or(16 * 1024 * 1024),
1068            // Programmatic default empty. The env `ZERODDS_PEERS` is
1069            // expanded domain-aware only in `DcpsRuntime::start` and merged
1070            // with this field into the effective peer list.
1071            initial_peers: Vec::new(),
1072            user_transport: None,
1073            #[cfg(feature = "security")]
1074            security: None,
1075            #[cfg(feature = "security")]
1076            security_logger: None,
1077            #[cfg(feature = "security")]
1078            interface_bindings: Vec::new(),
1079            announce_secure_endpoints: false,
1080            // Env hook for bench/FastDDS interop: ZERODDS_SECURE_SPDP=1 turns
1081            // on the reliable secure SPDP channel (0xff0101). Production sets this
1082            // explicitly via the SecurityProfile/config.
1083            enable_secure_spdp: std::env::var("ZERODDS_SECURE_SPDP").ok().as_deref() == Some("1"),
1084            wlp_period: Duration::ZERO,
1085            participant_lease_duration: Duration::from_secs(100),
1086            user_data: Vec::new(),
1087            observability: zerodds_foundation::observability::null_sink(),
1088            recv_thread_priority: None,
1089            tick_thread_priority: None,
1090            recv_thread_cpus: None,
1091            tick_thread_cpus: None,
1092            extra_recv_threads: 0,
1093            // D.5g — default `[XCDR1, XCDR2]` (legacy-first, max interop).
1094            // Env-var override `ZERODDS_DATA_REPR_OFFER` as a comma list
1095            // ("XCDR1", "XCDR2", "XCDR1,XCDR2", "XCDR2,XCDR1"). Cross-vendor
1096            // benches against strict-matching vendors (RTI) need XCDR2-only
1097            // so that every wire match happens.
1098            data_representation_offer: parse_data_repr_offer_env().unwrap_or_else(|| {
1099                zerodds_rtps::publication_data::data_representation::DEFAULT_OFFER.to_vec()
1100            }),
1101            data_rep_match_mode:
1102                zerodds_rtps::publication_data::data_representation::DataRepMatchMode::default(),
1103            external_tick: false,
1104            // D.5e Phase 3 — the event-driven deadline-heap scheduler is the
1105            // DEFAULT tick (Phase C, 2026-06-14): it parks until the next due
1106            // deadline / a write-recv raise instead of polling every 5 ms (~17×
1107            // fewer idle iterations, lower tail latency, identical wire output).
1108            // Verified cross-vendor secured (data-enc + rtps-enc all pairs) +
1109            // same_host_e2e + latency_assertions on codepit. Escape hatch:
1110            // `ZERODDS_SCHEDULER_TICK=0` restores the classic fixed-period
1111            // `tick_loop`.
1112            scheduler_tick: std::env::var("ZERODDS_SCHEDULER_TICK")
1113                .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
1114                .unwrap_or(true),
1115        }
1116    }
1117}
1118
1119impl RuntimeConfig {
1120    /// C4: robotics-capable defaults for **out-of-the-box ROS-2 interop**.
1121    /// Saves the manual env tuning otherwise needed for real ROS-2 nodes.
1122    /// Specifically, compared to [`RuntimeConfig::default`]:
1123    /// - **`data_representation_offer = [XCDR1, XCDR2]`**: `rmw_cyclonedds`/
1124    ///   `rmw_fastrtps` write XCDR1 for final/simple types (e.g.
1125    ///   `std_msgs/String`). An XCDR2-only reader does not match an XCDR1
1126    ///   writer — so the ROS reader here offers both legacy-first
1127    ///   (tolerant match is already the default). This is the clean,
1128    ///   ROS-specific variant of the `ZERODDS_DATA_REPR_OFFER` env
1129    ///   workaround, WITHOUT changing the global `DEFAULT_OFFER`
1130    ///   (XCDR2-only, deliberately for FastDDS/OpenDDS XCDR2 readers).
1131    ///
1132    /// The ROS-realistic reassembly cap (16 MiB, PointCloud2/Image) is
1133    /// already the global default and is carried over here.
1134    #[must_use]
1135    pub fn ros_defaults() -> Self {
1136        use zerodds_rtps::publication_data::data_representation as dr;
1137        Self {
1138            data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1139            ..Self::default()
1140        }
1141    }
1142
1143    /// C6 multi-robot / WAN / cross-subnet profile.
1144    ///
1145    /// A named profile for fleets that span subnets, the cloud, or WiFi —
1146    /// environments that drop IP multicast, so SPDP discovery cannot rely on
1147    /// the multicast beacon. It is the [`ros_defaults`](Self::ros_defaults)
1148    /// representation offer **plus**:
1149    ///
1150    /// - **Multicast-free discovery** (`spdp_multicast_send = false`):
1151    ///   participants find each other purely through unicast initial peers,
1152    ///   regardless of the `ZERODDS_NO_MULTICAST` env. Set the peers via
1153    ///   `ZERODDS_PEERS` (a comma list of `ip` or `ip:port`); a port-less
1154    ///   `ip` is expanded to the well-known SPDP unicast ports of the first
1155    ///   N participant indices (`ZERODDS_MAX_PEER_PARTICIPANTS`).
1156    /// - **WAN-tolerant liveliness**: a longer participant lease (300 s vs
1157    ///   the 100 s spec default) so transient cross-subnet RTT spikes or
1158    ///   brief link drops do not trigger a false liveliness loss.
1159    ///
1160    /// **Domain isolation** is the caller's lever: pass a fleet-dedicated
1161    /// `domain_id` to [`DcpsRuntime::start`] to keep robots off the default
1162    /// domain 0. The profile deliberately does not pick a domain for you.
1163    ///
1164    /// ```
1165    /// use zerodds_dcps::runtime::RuntimeConfig;
1166    /// let cfg = RuntimeConfig::multi_robot();
1167    /// assert!(!cfg.spdp_multicast_send); // unicast-only discovery
1168    /// ```
1169    pub fn multi_robot() -> Self {
1170        use zerodds_rtps::publication_data::data_representation as dr;
1171        Self {
1172            data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1173            spdp_multicast_send: false,
1174            participant_lease_duration: Duration::from_secs(300),
1175            ..Self::default()
1176        }
1177    }
1178}
1179
1180/// Parse the `ZERODDS_DATA_REPR_OFFER` env var. Values: "XCDR1", "XCDR2",
1181/// or a comma list. None if the env var is missing or invalid.
1182fn parse_data_repr_offer_env() -> Option<Vec<i16>> {
1183    let s = std::env::var("ZERODDS_DATA_REPR_OFFER").ok()?;
1184    parse_data_repr_offer_str(&s)
1185}
1186
1187/// Computes the **well-known** SPDP unicast discovery port for a
1188/// domain + participant index. Formula (DDSI-RTPS 2.5 §9.6.1.4.1):
1189///   port = PB + DG·domain + d1 + PG·pid = 7400 + 250·domain + 10 + 2·pid
1190///
1191/// This lets a configured unicast initial peer (multicast-free discovery)
1192/// reach a participant deterministically WITHOUT having found it via
1193/// multicast first. Defined locally in `dcps` to avoid touching
1194/// `crates/rtps` (spec constants as literals).
1195#[must_use]
1196fn spdp_unicast_port(domain_id: u32, participant_id: u32) -> u32 {
1197    7400 + 250 * domain_id + 10 + 2 * participant_id
1198}
1199
1200/// Default number of participant indices a port-less initial peer is
1201/// expanded to (Cyclone equivalent: `MaxAutoParticipantIndex`). The
1202/// beacon thereby reaches the first N participants of the peer host via
1203/// their well-known SPDP unicast ports. Overridable via the env
1204/// `ZERODDS_MAX_PEER_PARTICIPANTS` (e.g. for dense multi-robot / >10
1205/// participants-per-host scenarios). Cap 120 (= the well-known-port
1206/// allocation window).
1207const INITIAL_PEER_MAX_PARTICIPANTS: u32 = 10;
1208
1209/// Effective peer-expansion limit: env `ZERODDS_MAX_PEER_PARTICIPANTS`
1210/// or [`INITIAL_PEER_MAX_PARTICIPANTS`], clamped to 1..=120.
1211fn initial_peer_max_participants() -> u32 {
1212    std::env::var("ZERODDS_MAX_PEER_PARTICIPANTS")
1213        .ok()
1214        .and_then(|s| s.parse::<u32>().ok())
1215        .unwrap_or(INITIAL_PEER_MAX_PARTICIPANTS)
1216        .clamp(1, 120)
1217}
1218
1219/// C1 multicast-free discovery: parses the env `ZERODDS_PEERS` (comma
1220/// list of `ip` or `ip:port`) into SPDP unicast initial-peer locators for
1221/// `domain_id`. Empty/invalid → empty list.
1222fn parse_initial_peers_env(domain_id: u32) -> Vec<Locator> {
1223    let mut out = Vec::new();
1224    let max = initial_peer_max_participants();
1225    if let Ok(s) = std::env::var("ZERODDS_PEERS") {
1226        for entry in s.split(',') {
1227            expand_initial_peer(entry.trim(), domain_id, max, &mut out);
1228        }
1229    }
1230    out
1231}
1232
1233/// Expands a single peer spec into locator(s) and appends them to `out`.
1234/// `ip:port` → exact locator. Just `ip` → well-known SPDP unicast ports
1235/// of participant indices `0..max_participants` (Spec §9.6.1.4.1).
1236/// Invalid specs are ignored.
1237fn expand_initial_peer(spec: &str, domain_id: u32, max_participants: u32, out: &mut Vec<Locator>) {
1238    if spec.is_empty() {
1239        return;
1240    }
1241    if let Some((ip_s, port_s)) = spec.rsplit_once(':') {
1242        if let (Ok(ip), Ok(port)) = (ip_s.parse::<Ipv4Addr>(), port_s.parse::<u16>()) {
1243            out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1244            return;
1245        }
1246    }
1247    if let Ok(ip) = spec.parse::<Ipv4Addr>() {
1248        for pid in 0..max_participants {
1249            if let Ok(port) = u16::try_from(spdp_unicast_port(domain_id, pid)) {
1250                out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1251            }
1252        }
1253    }
1254}
1255
1256/// Pure parser for the `ZERODDS_DATA_REPR_OFFER` syntax (testable without
1257/// env). Returns the DataRepresentationId list with the **spec values**
1258/// `XCDR=0`, `XCDR2=2` (XTypes 1.3 §7.6.3.1.2) — NOT version numbers.
1259/// `None` on empty/invalid input.
1260fn parse_data_repr_offer_str(s: &str) -> Option<Vec<i16>> {
1261    use zerodds_rtps::publication_data::data_representation as dr;
1262    let mut out = Vec::new();
1263    for tok in s.split(',').map(str::trim) {
1264        let v = match tok.to_ascii_uppercase().as_str() {
1265            "XCDR1" | "XCDR" | "1" => dr::XCDR,
1266            "XCDR2" | "2" => dr::XCDR2,
1267            _ => return None,
1268        };
1269        out.push(v);
1270    }
1271    if out.is_empty() { None } else { Some(out) }
1272}
1273
1274// ---------------------------------------------------------------------------
1275// Security-gate helpers
1276// ---------------------------------------------------------------------------
1277
1278/// Pull outbound UDP bytes through the security gate (when configured).
1279/// Without the `security` feature or without a gate: pass-through (clone
1280/// as Vec).
1281///
1282/// Errors in the gate are logged silently and the packet is **not** sent —
1283/// better to drop than leak plaintext.
1284/// DDS-Security 8.4.2.4: the RTPS message protection (message-level SRTPS)
1285/// does NOT apply to bootstrap traffic that must flow BEFORE the participant
1286/// crypto-key exchange: SPDP (participant discovery, to everyone) and the
1287/// ParticipantStatelessMessage (auth handshake). Wrapping them in SRTPS would
1288/// mean a not-yet-authenticated peer could not decrypt them
1289/// (no key) -> discovery/auth breaks (match timeout pub=0 sub=0). Detection
1290/// via the writer EntityId of the DATA/DATA_FRAG submessages.
1291#[cfg(feature = "security")]
1292fn rtps_message_protection_exempt(
1293    bytes: &[u8],
1294    discovery_plain: bool,
1295    liveliness_plain: bool,
1296) -> bool {
1297    use zerodds_rtps::wire_types::EntityId;
1298    // Bootstrap endpoints (§8.4.2.4): SPDP/Stateless/Volatile ALWAYS flow
1299    // plain (before/during key exchange resp. their own submessage protection).
1300    // Discovery plane (SEDP pub/sub, TypeLookup) is plain when discovery_
1301    // protection_kind=NONE; WLP (ParticipantMessage) plain when liveliness_
1302    // protection_kind=NONE. cyclone<->cyclone reference capture: under rtps_
1303    // protection=ENCRYPT + discovery=NONE cyclone sends the ENTIRE discovery
1304    // plane (DATA+HEARTBEAT+ACKNACK) PLAINTEXT — only user DATA is SRTPS-
1305    // wrapped. ZeroDDS must mirror this, otherwise it drops cyclone's plain
1306    // SubscriptionData as legacy_blocked -> no user-endpoint match.
1307    let entity_exempt = |e: EntityId| -> bool {
1308        matches!(
1309            e,
1310            EntityId::SPDP_BUILTIN_PARTICIPANT_WRITER
1311                | EntityId::SPDP_BUILTIN_PARTICIPANT_READER
1312                | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
1313                | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
1314                | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
1315                | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
1316        ) || (discovery_plain
1317            && matches!(
1318                e,
1319                EntityId::SEDP_BUILTIN_PUBLICATIONS_WRITER
1320                    | EntityId::SEDP_BUILTIN_PUBLICATIONS_READER
1321                    | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_WRITER
1322                    | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_READER
1323                    | EntityId::TL_SVC_REQ_WRITER
1324                    | EntityId::TL_SVC_REQ_READER
1325                    | EntityId::TL_SVC_REPLY_WRITER
1326                    | EntityId::TL_SVC_REPLY_READER
1327            ))
1328            || (liveliness_plain
1329                && matches!(
1330                    e,
1331                    EntityId::BUILTIN_PARTICIPANT_MESSAGE_WRITER
1332                        | EntityId::BUILTIN_PARTICIPANT_MESSAGE_READER
1333                ))
1334    };
1335    let Ok(parsed) = decode_datagram(bytes) else {
1336        return false;
1337    };
1338    // Datagram exempt if it has at least one relevant submessage AND
1339    // ALL relevant ones are exempt (.all) — otherwise a bundled
1340    // exempt+non-exempt datagram leaks the protection-worthy submessage.
1341    let relevant: alloc::vec::Vec<bool> = parsed
1342        .submessages
1343        .iter()
1344        .filter_map(|sm| match sm {
1345            ParsedSubmessage::Data(d) => {
1346                Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1347            }
1348            ParsedSubmessage::DataFrag(d) => {
1349                Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1350            }
1351            ParsedSubmessage::Heartbeat(h) => {
1352                Some(entity_exempt(h.reader_id) || entity_exempt(h.writer_id))
1353            }
1354            ParsedSubmessage::AckNack(a) => {
1355                Some(entity_exempt(a.reader_id) || entity_exempt(a.writer_id))
1356            }
1357            ParsedSubmessage::Gap(g) => {
1358                Some(entity_exempt(g.reader_id) || entity_exempt(g.writer_id))
1359            }
1360            ParsedSubmessage::NackFrag(n) => {
1361                Some(entity_exempt(n.reader_id) || entity_exempt(n.writer_id))
1362            }
1363            // SEC_PREFIX (Kx-Volatile, inner writer-id encrypted) -> exempt.
1364            ParsedSubmessage::Unknown { id: 0x31, .. } => Some(true),
1365            // Framing (INFO_DST/INFO_TS/...) -> neutral.
1366            _ => None,
1367        })
1368        .collect();
1369    !relevant.is_empty() && relevant.iter().all(|&b| b)
1370}
1371
1372#[cfg(feature = "security")]
1373fn secure_outbound_bytes<'a>(
1374    rt: &DcpsRuntime,
1375    bytes: &'a [u8],
1376) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1377    match &rt.config.security {
1378        // OUTBOUND is spec-strict (DDS-Security 8.4.2.4 Table 27 is_rtps_protected):
1379        // under rtps_protection the ENTIRE RTPS message is SRTPS-wrapped; ONLY the
1380        // "separate messages" (SPDP/Stateless/Volatile) flow plain. SEDP/WLP/
1381        // TypeLookup are NOT among them and must be wrapped — independent
1382        // of discovery_/liveliness_protection (those are orthogonal submessage layers).
1383        // -> discovery_plain=false, liveliness_plain=false forces the wrap.
1384        // OpenDDS' RtpsUdpReceiveStrategy::check_encoded otherwise drops every plain SEDP
1385        // as "Full message requires protection". cyclone does take the shortcut
1386        // (sends SEDP plain), but accepts wrapped SEDP inbound without issue.
1387        // The INBOUND path (secure_inbound_bytes) deliberately stays lenient and still
1388        // accepts cyclone's plain SEDP — the asymmetry is intentional.
1389        Some(gate) if rtps_message_protection_exempt(bytes, false, false) => {
1390            let _ = gate;
1391            Some(alloc::borrow::Cow::Borrowed(bytes))
1392        }
1393        Some(gate) => gate
1394            .transform_outbound(bytes)
1395            .ok()
1396            .map(alloc::borrow::Cow::Owned),
1397        None => Some(alloc::borrow::Cow::Borrowed(bytes)),
1398    }
1399}
1400
1401// Security off: no clone — the caller borrows the datagram bytes
1402// directly (copy 6 of the zero-copy audit eliminated).
1403#[cfg(not(feature = "security"))]
1404fn secure_outbound_bytes<'a>(
1405    _rt: &DcpsRuntime,
1406    bytes: &'a [u8],
1407) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1408    Some(alloc::borrow::Cow::Borrowed(bytes))
1409}
1410
1411/// Pull inbound UDP bytes through the security gate.
1412///
1413/// Expects an RTPS header with the GuidPrefix at bytes 8..20.
1414/// `None` → drop the packet.
1415///
1416/// Security: drop reasons are forwarded, differentiated, to the
1417/// configured `LoggingPlugin`:
1418/// * `Malformed`       → `Error`
1419/// * `LegacyBlocked`   → `Error`
1420/// * `PolicyViolation` → `Warning` (possible tampering)
1421/// * `CryptoError`     → `Warning` (tag mismatch, replay etc.)
1422#[cfg(feature = "security")]
1423fn secure_inbound_bytes<'a>(
1424    rt: &DcpsRuntime,
1425    bytes: &'a [u8],
1426    iface: &NetInterface,
1427) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1428    use zerodds_security_runtime::{InboundVerdict, LogLevel};
1429    let Some(gate) = &rt.config.security else {
1430        return Some(alloc::borrow::Cow::Borrowed(bytes));
1431    };
1432    // DDS-Security 8.4.2.4 (symmetric to outbound): SPDP/Stateless are
1433    // message-protection-exempt and ALWAYS arrive plain (also from cyclone). Without
1434    // this exception classify_inbound discards plain SPDP on the WAN iface under
1435    // rtps_protection as LegacyBlocked -> no discovery (match timeout).
1436    {
1437        let looks_srtps = bytes.len() > 20usize && bytes[20usize] == 0x33;
1438        if !looks_srtps
1439            && rtps_message_protection_exempt(
1440                bytes,
1441                gate.discovery_protection().unwrap_or(ProtectionLevel::None)
1442                    == ProtectionLevel::None,
1443                gate.liveliness_protection()
1444                    .unwrap_or(ProtectionLevel::None)
1445                    == ProtectionLevel::None,
1446            )
1447        {
1448            // SRTPS-exempt. BUT metadata_protection user DATA carries per-submessage
1449            // SEC_PREFIX/BODY/POSTFIX (§9.5.3.3, NO SRTPS) — that must still be
1450            // decrypted per-endpoint here, otherwise the reader gets the
1451            // SEC wrapper instead of the DATA. Volatile-Kx-SEC fails with None
1452            // (key_id not in user-remote_by_key_id) -> unchanged for the
1453            // Volatile handler in the metatraffic loop.
1454            if walk_submessages(bytes)
1455                .iter()
1456                .any(|(id, _, _)| *id == SMID_SEC_PREFIX)
1457            {
1458                let mut pk = [0u8; 12];
1459                pk.copy_from_slice(&bytes[8..20]);
1460                if let Some(mut dg) = unprotect_user_datagram(rt, bytes, &pk) {
1461                    match unprotect_user_payload(rt, &dg) {
1462                        PayloadDecode::Decoded(clear) => dg = clear,
1463                        PayloadDecode::Failed => return None,
1464                        PayloadDecode::NotEncrypted => {}
1465                    }
1466                    return Some(alloc::borrow::Cow::Owned(dg));
1467                }
1468            }
1469            return Some(alloc::borrow::Cow::Borrowed(bytes));
1470        }
1471    }
1472    let verdict = gate.classify_inbound(bytes, iface);
1473    let category = verdict.category();
1474    let (level, message): (LogLevel, String) = match &verdict {
1475        InboundVerdict::Accept(out) => {
1476            // Cross-vendor user DATA: cyclone protects the DATA submessage as a
1477            // SEC_PREFIX/BODY/POSTFIX sequence (metadata_protection=ENCRYPT). Before
1478            // the submessage parse, transform it back with the sender's data key
1479            // (GuidPrefix = bytes[8..20]). `unprotect_user_datagram` returns
1480            // `None` when no SEC_* sequence is present → normal accept path.
1481            // OUTER layer first (metadata_protection, SEC_PREFIX/BODY/
1482            // POSTFIX), then the INNER one (data_protection, encrypted
1483            // SerializedPayload §9.5.3.3.1). Both can be active at once
1484            // (full secure profile); each returns `None` when its layer
1485            // is not present -> then the datagram stays unchanged.
1486            let mut dg: alloc::vec::Vec<u8> = out.clone();
1487            if dg.len() >= 20 {
1488                let mut pk = [0u8; 12];
1489                pk.copy_from_slice(&dg[8..20]);
1490                if let Some(clear) = unprotect_user_datagram(rt, &dg, &pk) {
1491                    dg = clear;
1492                }
1493            }
1494            match unprotect_user_payload(rt, &dg) {
1495                PayloadDecode::Decoded(clear) => dg = clear,
1496                // Undecodable encrypted payload -> discard the datagram
1497                // (no ciphertext garbage to the reader; reliable re-send resp. another
1498                // copy delivers the sample later).
1499                PayloadDecode::Failed => return None,
1500                PayloadDecode::NotEncrypted => {}
1501            }
1502            return Some(alloc::borrow::Cow::Owned(dg));
1503        }
1504        InboundVerdict::Malformed => (
1505            LogLevel::Error,
1506            alloc::format!(
1507                "inbound datagram too short ({} bytes, iface={:?})",
1508                bytes.len(),
1509                iface
1510            ),
1511        ),
1512        InboundVerdict::LegacyBlocked => (
1513            LogLevel::Error,
1514            alloc::format!(
1515                "legacy plaintext peer on protected domain \
1516                 (iface={iface:?}, allow_unauthenticated_participants=false)"
1517            ),
1518        ),
1519        InboundVerdict::PolicyViolation(msg) => {
1520            (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1521        }
1522        InboundVerdict::CryptoError(msg) => {
1523            (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1524        }
1525    };
1526    if let Some(logger) = &rt.config.security_logger {
1527        // Participant ident: GuidPrefix (or 0-padding for Malformed).
1528        let mut participant = [0u8; 16];
1529        if bytes.len() >= 20 {
1530            participant[..12].copy_from_slice(&bytes[8..20]);
1531        }
1532        logger.log(level, participant, category, &message);
1533    }
1534    None
1535}
1536
1537#[cfg(not(feature = "security"))]
1538fn secure_inbound_bytes<'a>(
1539    _rt: &DcpsRuntime,
1540    bytes: &'a [u8],
1541) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1542    Some(alloc::borrow::Cow::Borrowed(bytes))
1543}
1544
1545/// Default interface class for inbound dispatch when the socket does not
1546/// belong to the `outbound_pool`. In the v1.4 setup (without
1547/// `interface_bindings`), all packets run through `user_unicast` and are
1548/// classified as `Wan` — the most conservative assumption (protection
1549/// rules apply as in the single-interface case).
1550#[cfg(feature = "security")]
1551const DEFAULT_INBOUND_IFACE: NetInterface = NetInterface::Wan;
1552
1553/// Per-reader outbound transform.
1554///
1555/// Looks up in the writer slot which `ProtectionLevel` the matched reader
1556/// expects at the given `target` locator, then pulls the datagram through
1557/// the security gate individually. This way each reader gets a wire
1558/// payload matching its security profile (Legacy=plain, Fast=Sign,
1559/// Secure=Encrypt).
1560///
1561/// Fallback paths:
1562/// * No security gate configured → passthrough.
1563/// * No `locator_to_peer` entry (reader not yet matched via SEDP) →
1564///   `transform_outbound` with the domain rule — that is the homogeneous
1565///   v1.4 path.
1566/// * The gate returns an error → `None` (the caller drops — better no
1567///   plaintext leak).
1568#[cfg(feature = "security")]
1569fn secure_outbound_for_target(
1570    rt: &DcpsRuntime,
1571    writer_eid: EntityId,
1572    bytes: &[u8],
1573    target: &Locator,
1574) -> Option<Vec<u8>> {
1575    let Some(gate) = &rt.config.security else {
1576        return Some(bytes.to_vec());
1577    };
1578    // FU2 S3: fallback level from our own governance (data_protection_
1579    // kind), in case the matched reader did not announce an explicit SEDP
1580    // security_info level. This way user data to an authenticated peer is
1581    // encrypted per our own governance, while SPDP/SEDP metatraffic
1582    // bootstraps plaintext over rtps_protection_kind=NONE.
1583    // Governance `data_protection` is a FLOOR, not a mere fallback: a
1584    // per-reader level can only STRENGTHEN (e.g. legacy plaintext is only
1585    // allowed if the domain policy itself permits plaintext), never fall
1586    // below the domain policy. Otherwise a matched-but-not-authenticated
1587    // peer (foreign CA, SEDP match over plaintext discovery,
1588    // reader_protection=None) leaks plaintext user data.
1589    let gov_data_level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1590    // metadata_protection (§8.4.2.4 / §9.5.3.3): EVERY writer submessage (DATA,
1591    // HEARTBEAT, GAP) is SEC_PREFIX/BODY/POSTFIX-wrapped per-submessage —
1592    // TARGET-INDEPENDENT, since the per-endpoint writer key is local (the peer fetches
1593    // it via datawriter_crypto_token). Must take effect BEFORE the locator-based reader
1594    // resolution: otherwise tick HEARTBEATs/GAPs to not-yet-locator-
1595    // matched targets fall into the None branch -> with rtps=NONE PLAIN -> leak + no
1596    // reliable recovery (breaks already zero<->zero). data_protection (inner
1597    // payload layer) first, then the outer submessage layer.
1598    if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1599        let inner = if gov_data_level != ProtectionLevel::None {
1600            protect_user_payload(rt, bytes)?
1601        } else {
1602            bytes.to_vec()
1603        };
1604        let meta_sec = protect_user_datagram(rt, &inner)?;
1605        // Under rtps_protection message-level SRTPS MUST additionally go on top —
1606        // BOTH layers, like cyclone<->cyclone. Without it the peer would see the
1607        // metadata-SEC-DATA as "clear submsg from protected src" and discard it.
1608        if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1609            return gate.transform_outbound(&meta_sec).ok();
1610        }
1611        return Some(meta_sec);
1612    }
1613    let resolved = rt.writer_slot(writer_eid).and_then(|arc| {
1614        arc.lock().ok().and_then(|slot| {
1615            let pk = slot.locator_to_peer.get(target).copied()?;
1616            // An EXPLICITLY negotiated per-reader level is respected: a
1617            // legacy-v1.4 reader has reader_protection=None and MUST get plaintext,
1618            // otherwise it cannot decode (heterogeneous domain). Only
1619            // when NO entry exists (matched via plaintext discovery, but
1620            // no level negotiated -> potentially unauthenticated) does the
1621            // governance data_protection FLOOR apply as leak protection.
1622            // Governance data_protection is a FLOOR (§8.4.2.4, memory-documented):
1623            // a per-reader level can only STRENGTHEN, never fall below the domain
1624            // policy. A reader discovered via secure SEDP whose security_info parses
1625            // to `Some(None)` (no is_payload_protected bit detected, discovery=
1626            // ENCRYPT) would otherwise yield level=None -> Some(None) arm -> PLAINTEXT
1627            // leak, although the domain requires data_protection=ENCRYPT (disc-data-
1628            // enc: zerodds sent user DATA without the N-flag -> OpenDDS decode_serialized_
1629            // payload=0 -> no echo). `.max` enforces at least the governance FLOOR.
1630            // With gov=None legacy plaintext (reader_lv) stays allowed.
1631            let level = match slot.reader_protection.get(&pk).copied() {
1632                Some(reader_lv) => reader_lv.max(gov_data_level),
1633                None => gov_data_level,
1634            };
1635            Some((pk, level))
1636        })
1637    });
1638    match resolved {
1639        // Matched reader with Sign/Encrypt: cyclone-conformant SUBMESSAGE
1640        // protection (SEC_PREFIX/BODY/POSTFIX around the DATA submessage, local
1641        // data key) instead of message-level SRTPS — `metadata_protection_kind=
1642        // ENCRYPT`, §9.5.3.3. cyclone decodes with the key sent via datawriter_crypto_
1643        // tokens. `None` level = byte-identical passthrough.
1644        Some((peer_key, level)) if level != ProtectionLevel::None => {
1645            // Layer choice per governance (DDS-Security §8.4.2.4 vs §7.3.7):
1646            //  * metadata_protection_kind != NONE -> per-submessage protection
1647            //    (`encode_datawriter_submessage`, SEC_PREFIX/BODY/POSTFIX) for
1648            //    EVERY writer submessage (DATA, HEARTBEAT, GAP, ...). This is the
1649            //    cyclone interop path: cyclone expects HEARTBEAT/GAP SEC_*-
1650            //    wrapped too, otherwise its reader never NACKs (no reliable recovery).
1651            //  * otherwise (only rtps_protection_kind != NONE) -> message-level SRTPS
1652            //    via `transform_outbound_for` (whole message, §7.3.7).
1653            // INNER layer (§9.5.3.3.1): data_protection encrypts ONLY the
1654            // SerializedPayload of each DATA submessage. Applied BEFORE the outer
1655            // submessage/message layer — cyclone-conformant
1656            // nesting (§9.5.3.3): data_protection (inner) + metadata_
1657            // protection (outer). With pure data_protection this is the
1658            // only + complete protection.
1659            let inner: Vec<u8> = if gov_data_level != ProtectionLevel::None {
1660                // Crypto error -> drop instead of leak (None propagated via `?`).
1661                protect_user_payload(rt, bytes)?
1662            } else {
1663                bytes.to_vec()
1664            };
1665            // OUTER layer choice (DDS-Security §8.4.2.4 / §7.3.7):
1666            if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None
1667            {
1668                // metadata_protection -> per-submessage protection (DATA, HEARTBEAT,
1669                // GAP, ...) with the per-endpoint writer key (cyclone interop path).
1670                // Under additional rtps_protection message-level SRTPS MUST go on top
1671                // (both layers) — otherwise "clear submsg from protected src".
1672                match protect_user_datagram(rt, &inner) {
1673                    Some(ms)
1674                        if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1675                            != ProtectionLevel::None =>
1676                    {
1677                        gate.transform_outbound(&ms).ok()
1678                    }
1679                    other => other,
1680                }
1681            } else if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1682                != ProtectionLevel::None
1683            {
1684                // rtps_protection -> message-level SRTPS (whole message, §7.3.7),
1685                // per-reader key.
1686                gate.transform_outbound_for(&peer_key, &inner, level).ok()
1687            } else {
1688                // only data_protection -> the payload layer is already the
1689                // complete protection (§9.5.3.3.1). Header/InlineQoS stay
1690                // plaintext, the encrypted payload carries the N-flag.
1691                Some(inner)
1692            }
1693        }
1694        // Matched reader with level None: a legacy-v1.4 reader (explicit
1695        // SEDP legacy or NONE governance) gets byte-identical plaintext —
1696        // message-level SRTPS would make it undecryptable.
1697        Some(_) => {
1698            // Matched reader with data level None: under rtps_protection the
1699            // message MUST still be message-level-SRTPS-wrapped (§8.4.2.4) —
1700            // the data_protection level only controls the payload/submessage layer.
1701            // Without it user DATA/HEARTBEAT leaks plain, although the domain
1702            // requires rtps_protection=ENCRYPT (the peer discards it as legacy).
1703            if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1704                gate.transform_outbound(bytes).ok()
1705            } else {
1706                Some(bytes.to_vec())
1707            }
1708        }
1709        // No locator-resolved reader: multicast/meta bootstrap OR a
1710        // user reader whose locator is (not yet) in `locator_to_peer`
1711        // (e.g. discovered via secure SEDP, discovery_protection=ENCRYPT). The
1712        // data_protection (inner payload layer §9.5.3.3.1) is TARGET-INDEPENDENT
1713        // (local writer key) and MUST still apply for a user writer —
1714        // otherwise under data_protection=ENCRYPT the user DATA leaks PLAINTEXT (N-flag
1715        // missing -> a spec-conformant remote reader never calls `decode_serialized_payload`
1716        // -> no sample, no echo; disc-data-enc stall, source-documented: OpenDDS
1717        // decode_serialized_payload=0). ONLY for user writers — SPDP/SEDP builtin DATA
1718        // must bootstrap plaintext (otherwise undecodable before key exchange).
1719        None => {
1720            use zerodds_rtps::wire_types::EntityKind;
1721            let is_user_writer = matches!(
1722                writer_eid.entity_kind,
1723                EntityKind::UserWriterWithKey | EntityKind::UserWriterNoKey
1724            );
1725            if is_user_writer && gov_data_level != ProtectionLevel::None {
1726                let inner = protect_user_payload(rt, bytes)?;
1727                gate.transform_outbound(&inner).ok()
1728            } else {
1729                gate.transform_outbound(bytes).ok()
1730            }
1731        }
1732    }
1733}
1734
1735#[cfg(not(feature = "security"))]
1736fn secure_outbound_for_target(
1737    _rt: &DcpsRuntime,
1738    _writer_eid: EntityId,
1739    bytes: &[u8],
1740    _target: &Locator,
1741) -> Option<Vec<u8>> {
1742    Some(bytes.to_vec())
1743}
1744
1745/// FU2 S3: data_protection-aware user DATA outbound. Encrypts the
1746/// datagram with the governance `data_protection` level. `transform_outbound_
1747/// for` ignores the `peer_key` and uses the local key — the ciphertext
1748/// is decryptable for EVERY authenticated peer (with our token),
1749/// non-authenticated peers cannot read it. A `None` level falls
1750/// back to message-level (`rtps_protection` resp. passthrough). Used for
1751/// UDP + in-process fastpath + SHM UNIFORMLY, so the
1752/// inproc path is secured too.
1753#[cfg(feature = "security")]
1754fn secure_user_outbound<'a>(
1755    rt: &DcpsRuntime,
1756    bytes: &'a [u8],
1757) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1758    let Some(gate) = &rt.config.security else {
1759        return Some(alloc::borrow::Cow::Borrowed(bytes));
1760    };
1761    let level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1762    if matches!(level, ProtectionLevel::None) {
1763        gate.transform_outbound(bytes)
1764            .ok()
1765            .map(alloc::borrow::Cow::Owned)
1766    } else {
1767        gate.transform_outbound_for(&[0u8; 12], bytes, level)
1768            .ok()
1769            .map(alloc::borrow::Cow::Owned)
1770    }
1771}
1772
1773#[cfg(not(feature = "security"))]
1774fn secure_user_outbound<'a>(
1775    _rt: &DcpsRuntime,
1776    bytes: &'a [u8],
1777) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1778    Some(alloc::borrow::Cow::Borrowed(bytes))
1779}
1780
1781/// Sends `bytes` to `target` on the matching interface socket.
1782/// Falls back to `rt.user_unicast` if no
1783/// pool is configured or no binding matches the target range
1784/// and no default binding is set either.
1785#[cfg(feature = "security")]
1786fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1787    if let Some(pool) = &rt.outbound_pool {
1788        if let Some((socket, _iface)) = pool.route(target) {
1789            let _ = socket.send(target, bytes);
1790            return;
1791        }
1792    }
1793    let _ = rt.user_unicast.send(target, bytes);
1794}
1795
1796#[cfg(not(feature = "security"))]
1797fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1798    let _ = rt.user_unicast.send(target, bytes);
1799}
1800
1801/// User-writer slot in the runtime. Carries ReliableWriter + topic meta +
1802/// fragment size (from QoS).
1803struct UserWriterSlot {
1804    writer: ReliableWriter,
1805    topic_name: String,
1806    type_name: String,
1807    reliable: bool,
1808    durability: zerodds_qos::DurabilityKind,
1809    /// Deadline period in nanoseconds (0 == INFINITE, no monitoring).
1810    deadline_nanos: u64,
1811    /// Last successful `write` relative to `DcpsRuntime::start_instant`.
1812    last_write: Option<Duration>,
1813    /// Counter for missed deadlines (Spec §2.2.4.2.9).
1814    offered_deadline_missed_count: u64,
1815    /// Counter for LivelinessLost detections from the writer's view
1816    /// (Spec §2.2.4.2.10). Incremented in `check_writer_liveliness` on
1817    /// manual-lease overrun. 0 == not monitored.
1818    liveliness_lost_count: u64,
1819    /// Last assert time (manual liveliness). `None` == never.
1820    last_liveliness_assert: Option<Duration>,
1821    /// Per-policy counter for offered_incompatible_qos. Spec
1822    /// §2.2.4.2.4.2 — writer side. Incremented on
1823    /// `wire_writer_to_remote_reader` reject.
1824    offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus,
1825    /// Lifespan duration in nanoseconds (0 == INFINITE, no expiry).
1826    lifespan_nanos: u64,
1827    /// Per sample SN the insert time (relative to start_instant).
1828    /// Removed from front on expiry — SNs are monotonic, lifespan
1829    /// is constant, so the expiry prefix is always front.
1830    sample_insert_times:
1831        alloc::collections::VecDeque<(zerodds_rtps::wire_types::SequenceNumber, Duration)>,
1832    /// Liveliness kind (Automatic / ManualByParticipant / ManualByTopic).
1833    liveliness_kind: zerodds_qos::LivelinessKind,
1834    /// Lease duration in nanoseconds (0 == INFINITE).
1835    liveliness_lease_nanos: u64,
1836    /// Ownership mode.
1837    ownership: zerodds_qos::OwnershipKind,
1838    /// Ownership strength (Spec §2.2.3.2). Mirrored in the same-runtime
1839    /// dispatch into `UserSample::Alive.writer_strength`, so that
1840    /// EXCLUSIVE ownership logic in the reader also works for intra-process
1841    /// loopback.
1842    ownership_strength: i32,
1843    /// Partition list.
1844    partition: Vec<String>,
1845    /// Per-matched-reader ProtectionLevel. Derived at the
1846    /// SEDP match from `sub.security_info`. `None` entries
1847    /// for legacy readers. Empty for writers without matched
1848    /// security peers — then the hot path is unchanged.
1849    #[cfg(feature = "security")]
1850    reader_protection: BTreeMap<[u8; 12], ProtectionLevel>,
1851    /// Mapping Locator → GuidPrefix for the writer tick loop, so that
1852    /// `secure_outbound_for_target` can look up the protection per target
1853    /// without breaking the writer-tick API (`dg.targets` are
1854    /// locator lists today).
1855    #[cfg(feature = "security")]
1856    locator_to_peer: BTreeMap<Locator, [u8; 12]>,
1857    /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the writer type
1858    /// (from `T::TYPE_IDENTIFIER` in `UserWriterConfig`).
1859    type_identifier: zerodds_types::TypeIdentifier,
1860    /// D.5g — per-writer override for the DataRepresentation offer.
1861    /// `None` = runtime default. `Some(vec)` = hardcoded per writer.
1862    data_rep_offer_override: Option<Vec<i16>>,
1863    /// Type extensibility of the writer type (FINAL/APPENDABLE/MUTABLE).
1864    /// Together with the offer `first` element it determines the
1865    /// encapsulation header of the user payload (see
1866    /// [`user_payload_encap`]). Default `Final`; set by codegen/FFI via
1867    /// `set_user_writer_wire_extensibility` when the type
1868    /// is appendable/mutable (relevant for XCDR2 wire: D_CDR2/PL_CDR2).
1869    wire_extensibility: zerodds_types::qos::ExtensibilityForRepr,
1870    /// Spec §2.2.3.5 DurabilityService — with Durability=Transient/
1871    /// Persistent the backend holds in addition to the writer's own
1872    /// HistoryCache. On the first late-joiner match in
1873    /// `wire_writer_to_remote_reader` the backend samples are
1874    /// (re-)injected into the HistoryCache, so that the RTPS reliable
1875    /// path delivers them to the reader. `None` for Volatile/
1876    /// TransientLocal (the cache suffices).
1877    durability_backend: Option<alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>>,
1878    /// `true` as soon as the backend has been replayed once into the
1879    /// HistoryCache. Prevents repeated re-injection on further matches.
1880    backend_primed: bool,
1881}
1882
1883/// The listener dispatch carries, alongside the `UserSample`, a
1884/// zero-copy view on the original `Arc<[u8]>` with an encap offset
1885/// (lever-E zero-copy path).
1886pub type UserSampleWithEncap = (UserSample, Option<(Arc<[u8]>, usize)>);
1887
1888/// Sample channel item: either data payload or lifecycle marker.
1889/// Lifecycle is reconstructed by the wire path as `key_hash + ChangeKind` from
1890/// the PID_STATUS_INFO header; the DataReader DCPS layer
1891/// translates that into `__push_lifecycle`.
1892#[derive(Debug, Clone)]
1893pub enum UserSample {
1894    /// Normal sample with payload (CDR-encoded application type).
1895    /// `writer_guid` is the 16-byte GUID of the emitting writer
1896    /// — needed by the subscriber for exclusive-ownership resolution
1897    /// (DDS 1.4 §2.2.3.23 / §2.2.2.5.5).
1898    Alive {
1899        /// CDR payload (without encapsulation header). Zero-copy container:
1900        /// typically holds an `Arc<[u8]>` slice into the RTPS wire datagram
1901        /// without a heap re-alloc. See `docs/specs/zerodds-zero-copy-1.0.md`.
1902        payload: crate::sample_bytes::SampleBytes,
1903        /// Writer GUID — for strongest-writer selection.
1904        writer_guid: [u8; 16],
1905        /// Writer `ownership_strength` at the time of receipt.
1906        /// `0` if the writer is not yet known via discovery
1907        /// (the reader treats this as default strength = spec-conformant
1908        /// for shared-ownership topics; for exclusive the
1909        /// reader filters the real strength against the current owner).
1910        writer_strength: i32,
1911        /// XCDR version of the `payload` — extracted from the encapsulation
1912        /// header of the wire sample (RTPS 2.5 §10.5) BEFORE the
1913        /// header was stripped: `0` = XCDR1 (CDR/PL_CDR), `1` =
1914        /// XCDR2 (CDR2/D_CDR2/PL_CDR2). The typed consumer
1915        /// needs this to decode the body with the correct alignment rule
1916        /// (XTypes 1.3 §7.4.3.4.2).
1917        representation: u8,
1918    },
1919    /// Lifecycle marker (dispose / unregister) — the reader sets
1920    /// InstanceState accordingly.
1921    Lifecycle {
1922        /// Key hash of the affected instance (16 byte).
1923        key_hash: [u8; 16],
1924        /// `NotAliveDisposed` / `NotAliveUnregistered` /
1925        /// `NotAliveDisposedUnregistered`.
1926        kind: zerodds_rtps::history_cache::ChangeKind,
1927    },
1928}
1929
1930/// User-reader slot. ReliableReader + topic meta + channel to the
1931/// DataReader (DCPS API side).
1932/// Listener callback for sample arrival.
1933///
1934/// Fired synchronously by `recv_user_data_loop` in the recv-thread
1935/// context as soon as an alive sample lands in the reader HistoryCache.
1936/// Eliminates the polling latency of `zerodds_reader_take()` —
1937/// the listener path typically saves 50-100 µs per side.
1938///
1939/// **Contract** (analogous to DDS spec §2.2.4.4 listener semantics):
1940/// * The callback runs on the recv thread, NOT the user thread.
1941/// * Short and non-blocking. No I/O, no locks, no
1942///   ZeroDDS API calls inside.
1943/// * `bytes` points to the CDR payload of the alive sample (without
1944///   encapsulation header). Lifetime only for the duration of the
1945///   callback; copy if needed beyond the call.
1946/// * Disposed/unregistered lifecycle events do NOT fire the listener
1947///   (only `Alive` samples) — for lifecycle tracking
1948///   keep using `zerodds_reader_take()` or add a
1949///   lifecycle-listener API.
1950///
1951/// Data-available listener. Arguments: CDR body (without encapsulation
1952/// header) and the XCDR version of the sample (`0` = XCDR1, `1` = XCDR2)
1953/// — the typed consumer needs the latter for the alignment
1954/// rule on decode (XTypes 1.3 §7.4.3.4.2).
1955pub type UserReaderListener = alloc::boxed::Box<dyn Fn(&[u8], u8) + Send + Sync + 'static>;
1956
1957struct UserReaderSlot {
1958    reader: ReliableReader,
1959    topic_name: String,
1960    type_name: String,
1961    sample_tx: mpsc::Sender<UserSample>,
1962    /// Spec §3 zerodds-async-1.0: async waker slot. Registered by the
1963    /// async reader; on `sample_tx.send` we call
1964    /// `waker.wake()`. `None` if no async reader is active.
1965    async_waker: alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>,
1966    /// Listener callback for alive samples.
1967    /// Fired synchronously by `recv_user_data_loop`. `None` =
1968    /// no listener registered (the user polls via
1969    /// `zerodds_reader_take()`). Arc, so the recv thread can
1970    /// execute the callback cloned without another lock (minimize lock
1971    /// hold time).
1972    listener: Option<alloc::sync::Arc<UserReaderListener>>,
1973    durability: zerodds_qos::DurabilityKind,
1974    /// Deadline period in nanoseconds (0 == INFINITE).
1975    deadline_nanos: u64,
1976    /// Time of the last received sample relative to runtime start.
1977    last_sample_received: Option<Duration>,
1978    /// Counter for missed deadline expectations (Spec §2.2.4.2.11).
1979    requested_deadline_missed_count: u64,
1980    /// Per-policy counter for requested_incompatible_qos. Spec
1981    /// §2.2.4.2.6.5 — reader side. Incremented on
1982    /// `wire_reader_to_remote_writer` reject.
1983    requested_incompatible_qos: crate::status::RequestedIncompatibleQosStatus,
1984    /// Sample-lost counter (Spec §2.2.4.2.6.2). Incremented
1985    /// by `record_sample_lost`.
1986    sample_lost_count: u64,
1987    /// Sample-rejected counter (Spec §2.2.4.2.6.3). Incremented
1988    /// by `record_sample_rejected`.
1989    sample_rejected: crate::status::SampleRejectedStatus,
1990    /// Monotonically increasing count of alive samples delivered to the
1991    /// user. Serves as a non-consuming data-availability detector for
1992    /// `on_data_available` (DDS 1.4 §2.2.4.2.6.1) — unlike
1993    /// `last_sample_received`, this counter is only bumped on real sample
1994    /// delivery, never by the deadline path. Read via
1995    /// [`DcpsRuntime::user_reader_samples_delivered`].
1996    samples_delivered_count: u64,
1997    /// Reader-side requested liveliness lease (0 == INFINITE).
1998    liveliness_lease_nanos: u64,
1999    /// Reader-side requested liveliness kind.
2000    liveliness_kind: zerodds_qos::LivelinessKind,
2001    /// Counter: how often the writer was marked "alive"
2002    /// (Spec §2.2.4.2.14 alive_count).
2003    liveliness_alive_count: u64,
2004    /// Counter: how often it was marked "not_alive" (lease expired).
2005    liveliness_not_alive_count: u64,
2006    /// Current "alive/not-alive" state from the reader's view.
2007    liveliness_alive: bool,
2008    /// Ownership.
2009    ownership: zerodds_qos::OwnershipKind,
2010    /// Partition.
2011    partition: Vec<String>,
2012    /// Per-writer strength cache for exclusive-ownership resolution
2013    /// (DDS 1.4 §2.2.3.23). Filled by `wire_reader_to_remote_writer`
2014    /// from each `PublicationBuiltinTopicData.ownership_strength`;
2015    /// `delivered_to_user_sample` looks it up here to pack the
2016    /// strength into `UserSample::Alive`.
2017    writer_strengths: alloc::collections::BTreeMap<[u8; 16], i32>,
2018    /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the reader type
2019    /// (from `T::TYPE_IDENTIFIER` in `UserReaderConfig`). Default
2020    /// `TypeIdentifier::None` signals "no TypeIdentifier" —
2021    /// the match falls back to a pure `type_name` comparison
2022    /// (DDS 1.4 §2.2.3 default path).
2023    type_identifier: zerodds_types::TypeIdentifier,
2024    /// XTypes 1.3 §7.6.3.7 — TCE policy controlling the strictness
2025    /// of the XTypes match path.
2026    type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2027}
2028
2029/// Helper struct for announcing a local publication/subscription
2030/// as SEDP BuiltinTopicData. The caller creates it once per
2031/// writer/reader registration and passes it to SedpStack.
2032/// QoS config for registering a user writer with the runtime.
2033/// Bundles all policies that go on the wire via SEDP plus the local
2034/// Per-endpoint discovery info for ROS 2 endpoint-info-by-topic introspection
2035/// (`rmw_get_publishers_info_by_topic` / `rmw_get_subscriptions_info_by_topic`,
2036/// the data behind `ros2 topic info -v`). Covers local user endpoints plus
2037/// remote SEDP-discovered ones. QoS is best-effort from what discovery carries
2038/// (history/depth are not on the wire, so the consumer fills rmw defaults).
2039#[derive(Debug, Clone)]
2040pub struct DiscoveredEndpointInfo {
2041    /// DDS topic name (raw, un-demangled).
2042    pub topic_name: String,
2043    /// IDL type name (raw).
2044    pub type_name: String,
2045    /// 16-byte endpoint GUID: 12-byte participant prefix + 4-byte entity id.
2046    /// Bytes 0..12 identify the owning participant (for node-name lookup).
2047    pub endpoint_guid: [u8; 16],
2048    /// RELIABLE (`true`) vs BEST_EFFORT (`false`).
2049    pub reliable: bool,
2050    /// TRANSIENT_LOCAL or stronger (`true`) vs VOLATILE (`false`).
2051    pub transient_local: bool,
2052    /// Deadline period in whole seconds (0 == INFINITE).
2053    pub deadline_seconds: i32,
2054    /// Lifespan in whole seconds (0 == INFINITE; always 0 for subscriptions).
2055    pub lifespan_seconds: i32,
2056    /// Liveliness lease in whole seconds (0 == INFINITE).
2057    pub liveliness_lease_seconds: i32,
2058}
2059
2060/// Packs an RTPS [`Guid`] into the 16-byte wire form (prefix ++ entity id).
2061fn guid_to_16(g: Guid) -> [u8; 16] {
2062    let mut b = [0u8; 16];
2063    b[..12].copy_from_slice(&g.prefix.to_bytes());
2064    b[12..].copy_from_slice(&g.entity_id.to_bytes());
2065    b
2066}
2067
2068/// monitoring. Avoids 10+-argument functions.
2069#[derive(Debug, Clone)]
2070pub struct UserWriterConfig {
2071    /// Topic name (DDS topic).
2072    pub topic_name: String,
2073    /// IDL type name.
2074    pub type_name: String,
2075    /// `true` = RELIABLE, `false` = BEST_EFFORT.
2076    pub reliable: bool,
2077    /// Durability.
2078    pub durability: zerodds_qos::DurabilityKind,
2079    /// Deadline period (offered).
2080    pub deadline: zerodds_qos::DeadlineQosPolicy,
2081    /// Lifespan duration (writer-only).
2082    pub lifespan: zerodds_qos::LifespanQosPolicy,
2083    /// Liveliness (offered).
2084    pub liveliness: zerodds_qos::LivelinessQosPolicy,
2085    /// Ownership mode (Shared / Exclusive).
2086    pub ownership: zerodds_qos::OwnershipKind,
2087    /// Strength for Exclusive (ignored for Shared).
2088    pub ownership_strength: i32,
2089    /// Partition list. Empty == default partition (`""`).
2090    pub partition: Vec<String>,
2091    /// UserData QoS (Spec §2.2.3.1) — opaque `sequence<octet>`, propagated
2092    /// via discovery.
2093    pub user_data: Vec<u8>,
2094    /// TopicData QoS (Spec §2.2.3.3).
2095    pub topic_data: Vec<u8>,
2096    /// GroupData QoS (Spec §2.2.3.2).
2097    pub group_data: Vec<u8>,
2098    /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up). Default
2099    /// `TypeIdentifier::None` for the `T::TYPE_IDENTIFIER` default.
2100    pub type_identifier: zerodds_types::TypeIdentifier,
2101
2102    /// D.5g — per-writer override of the DataRepresentation offer list.
2103    /// `None` = use `RuntimeConfig::data_representation_offer`.
2104    /// `Some(vec)` = overridden per writer (e.g. `[XCDR2]` for
2105    /// a modern-only pub).
2106    pub data_representation_offer: Option<Vec<i16>>,
2107}
2108
2109/// QoS config for registering a user reader.
2110#[derive(Debug, Clone)]
2111pub struct UserReaderConfig {
2112    /// Topic name.
2113    pub topic_name: String,
2114    /// IDL type name.
2115    pub type_name: String,
2116    /// `true` = RELIABLE, `false` = BEST_EFFORT.
2117    pub reliable: bool,
2118    /// Durability (requested).
2119    pub durability: zerodds_qos::DurabilityKind,
2120    /// Deadline (requested).
2121    pub deadline: zerodds_qos::DeadlineQosPolicy,
2122    /// Liveliness (requested).
2123    pub liveliness: zerodds_qos::LivelinessQosPolicy,
2124    /// Ownership.
2125    pub ownership: zerodds_qos::OwnershipKind,
2126    /// Partition.
2127    pub partition: Vec<String>,
2128    /// UserData QoS (Spec §2.2.3.1).
2129    pub user_data: Vec<u8>,
2130    /// TopicData QoS (Spec §2.2.3.3).
2131    pub topic_data: Vec<u8>,
2132    /// GroupData QoS (Spec §2.2.3.2).
2133    pub group_data: Vec<u8>,
2134    /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up).
2135    pub type_identifier: zerodds_types::TypeIdentifier,
2136    /// TypeConsistencyEnforcement (XTypes §7.6.3.7) — controls how strictly
2137    /// the reader match checks XTypes compatibility.
2138    pub type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2139
2140    /// D.5g — per-reader override of the DataRepresentation accept list.
2141    /// `None` = use `RuntimeConfig::data_representation_offer`.
2142    /// `Some(vec)` = overridden per reader (e.g. `[XCDR1]` for
2143    /// a reader that accepts only legacy XCDR1 wire).
2144    pub data_representation_offer: Option<Vec<i16>>,
2145}
2146
2147fn build_publication_data(
2148    owner_prefix: GuidPrefix,
2149    writer_eid: EntityId,
2150    cfg: &UserWriterConfig,
2151    runtime_offer: &[i16],
2152    user_locator: Locator,
2153) -> zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2154    use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2155    zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2156        key: Guid::new(owner_prefix, writer_eid),
2157        participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2158        topic_name: cfg.topic_name.clone(),
2159        type_name: cfg.type_name.clone(),
2160        durability: cfg.durability,
2161        reliability: ReliabilityQosPolicy {
2162            kind: if cfg.reliable {
2163                ReliabilityKind::Reliable
2164            } else {
2165                ReliabilityKind::BestEffort
2166            },
2167            max_blocking_time: QosDuration::from_millis(100_i32),
2168        },
2169        ownership: cfg.ownership,
2170        ownership_strength: cfg.ownership_strength,
2171        liveliness: cfg.liveliness,
2172        deadline: cfg.deadline,
2173        lifespan: cfg.lifespan,
2174        partition: cfg.partition.clone(),
2175        user_data: cfg.user_data.clone(),
2176        topic_data: cfg.topic_data.clone(),
2177        group_data: cfg.group_data.clone(),
2178        type_information: None,
2179        // D.5g — PID_DATA_REPRESENTATION (XTypes 1.3 §7.6.3.1.1, RTPS 2.5
2180        // PID 0x0073). Per-Writer-Override (cfg.data_representation_offer)
2181        // overrides the RuntimeConfig default.
2182        data_representation: cfg
2183            .data_representation_offer
2184            .clone()
2185            .unwrap_or_else(|| runtime_offer.to_vec()),
2186        // Security: the PolicyEngine fills this later. Default
2187        // None = legacy behavior (no EndpointSecurityInfo PID).
2188        security_info: None,
2189        // .B — RPC discovery PIDs. Default None: no RPC endpoint;
2190        // the RpcEndpoint builder fills these fields.
2191        service_instance_name: None,
2192        related_entity_guid: None,
2193        topic_aliases: None,
2194        // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2195        type_identifier: cfg.type_identifier.clone(),
2196        // DDSI-RTPS 2.5 §8.5.3.3: endpoint locator. All user endpoints
2197        // share the one `user_unicast` socket — hence the
2198        // endpoint locator equals the resolved participant locator.
2199        unicast_locators: alloc::vec![user_locator],
2200        multicast_locators: Vec::new(),
2201    }
2202}
2203
2204fn build_subscription_data(
2205    owner_prefix: GuidPrefix,
2206    reader_eid: EntityId,
2207    cfg: &UserReaderConfig,
2208    runtime_offer: &[i16],
2209    user_locator: Locator,
2210) -> zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2211    use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2212    zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2213        key: Guid::new(owner_prefix, reader_eid),
2214        participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2215        topic_name: cfg.topic_name.clone(),
2216        type_name: cfg.type_name.clone(),
2217        durability: cfg.durability,
2218        reliability: ReliabilityQosPolicy {
2219            kind: if cfg.reliable {
2220                ReliabilityKind::Reliable
2221            } else {
2222                ReliabilityKind::BestEffort
2223            },
2224            max_blocking_time: QosDuration::from_millis(100_i32),
2225        },
2226        ownership: cfg.ownership,
2227        liveliness: cfg.liveliness,
2228        deadline: cfg.deadline,
2229        partition: cfg.partition.clone(),
2230        user_data: cfg.user_data.clone(),
2231        topic_data: cfg.topic_data.clone(),
2232        group_data: cfg.group_data.clone(),
2233        type_information: None,
2234        // D.5g — PID_DATA_REPRESENTATION (see build_publication_data).
2235        // A per-reader override overrides the RuntimeConfig default.
2236        data_representation: cfg
2237            .data_representation_offer
2238            .clone()
2239            .unwrap_or_else(|| runtime_offer.to_vec()),
2240        content_filter: None,
2241        security_info: None,
2242        service_instance_name: None,
2243        related_entity_guid: None,
2244        topic_aliases: None,
2245        // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2246        type_identifier: cfg.type_identifier.clone(),
2247        // DDSI-RTPS 2.5 §8.5.3.2: endpoint locator (see
2248        // build_publication_data).
2249        unicast_locators: alloc::vec![user_locator],
2250        multicast_locators: Vec::new(),
2251    }
2252}
2253
2254/// The runtime of a `DomainParticipant`. Hosts all background
2255/// threads and UDP sockets.
2256pub struct DcpsRuntime {
2257    /// Participant GUID prefix (12-byte identifier, random per instance).
2258    pub guid_prefix: GuidPrefix,
2259    /// Domain id.
2260    pub domain_id: i32,
2261    /// SPDP multicast receiver socket.
2262    pub spdp_multicast_rx: Arc<UdpTransport>,
2263    /// SPDP unicast socket (for bidirectional SPDP, B2).
2264    pub spdp_unicast: Arc<UdpTransport>,
2265    /// User-data unicast transport (default user unicast, where peers
2266    /// send matched samples). Trait object: can be UDP/v4 or /v6,
2267    /// and in phase C additionally TCP or SHM (env var
2268    /// `ZERODDS_USER_TRANSPORT`). Discovery (SPDP/SEDP) stays UDP-only.
2269    pub user_unicast: Arc<dyn Transport + Send + Sync>,
2270    /// Resolved user-unicast locator (routable interface address,
2271    /// not `0.0.0.0`). Written as `PID_UNICAST_LOCATOR` into EVERY
2272    /// SEDP pub/sub announce (DDSI-RTPS 2.5 §8.5.3.2/3)
2273    /// and as the participant `DEFAULT_UNICAST_LOCATOR` in SPDP. Precomputed
2274    /// via `announce_locator`, so the endpoint and participant locators
2275    /// are guaranteed identical.
2276    pub user_announce_locator: Locator,
2277    /// Sender socket for the SPDP multicast announce (separate UdpSocket
2278    /// without SO_REUSE/SO_BIND_IP_MULTICAST, so send_to routes cleanly).
2279    spdp_mc_tx: Arc<UdpTransport>,
2280    /// SPDP beacon (sends periodic announces).
2281    spdp_beacon: Mutex<SpdpBeacon>,
2282    /// Own participant data (SPDP self-view). Handed by the in-process
2283    /// discovery fastpath as a `DiscoveredParticipant` to same-process
2284    /// peers (see [`crate::inproc`]).
2285    participant_data: ParticipantBuiltinTopicData,
2286    /// Stash of all locally announced publications/subscriptions —
2287    /// so a peer runtime starting later in the same process
2288    /// can pull our endpoints via `inproc_snapshot`
2289    /// (pull-on-creation of the in-process discovery fastpath).
2290    /// Append-only; a future patch for endpoint deletion would
2291    /// remove by GUID here.
2292    announced_pubs: Mutex<Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>>,
2293    announced_subs: Mutex<Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>>,
2294    /// SPDP reader (parses incoming beacons).
2295    spdp_reader: SpdpReader,
2296    /// Discovered remote participants (prefix → data).
2297    discovered: Arc<Mutex<DiscoveredParticipantsCache>>,
2298    /// SEDP stack for publication/subscription announce + discovery.
2299    pub sedp: Arc<Mutex<SedpStack>>,
2300    /// TypeLookup-Service Builtin-Endpoint-GUIDs (XTypes 1.3 §7.6.3.3.4).
2301    pub type_lookup_endpoints: TypeLookupEndpoints,
2302    /// TypeLookup server (server-side handler over the local
2303    /// TypeRegistry).
2304    pub type_lookup_server: Arc<Mutex<TypeLookupServer>>,
2305    /// TypeLookup client (client-side correlation table for outstanding
2306    /// requests).
2307    pub type_lookup_client: Arc<Mutex<TypeLookupClient>>,
2308    /// Monotonically increasing sequence number of the TL_SVC_REPLY_WRITER. Reply DATA
2309    /// carry their OWN writer_sn (instead of echoing the request SN) — the
2310    /// correlation runs via PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2),
2311    /// so a reliable cross-vendor reply reader sees no SN jumps.
2312    tl_reply_sn: core::sync::atomic::AtomicU64,
2313    /// Security builtin endpoint stack
2314    /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
2315    /// MessageSecure`). `None` as long as no security plugin is active
2316    /// — the hot path then skips any security-builtin
2317    /// demux. `Some` is set via [`DcpsRuntime::enable_security_builtins`]
2318    /// as soon as the factory has registered a plugin.
2319    pub security_builtin: Mutex<Option<Arc<Mutex<SecurityBuiltinStack>>>>,
2320    /// Monotonic "start time" — for SEDP tick clocks.
2321    start_instant: Instant,
2322    /// Local user-writer registry (EntityId → writer state).
2323    user_writers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserWriterSlot>>>>>,
2324    /// ADR-0006 side map: per user writer an optional ShmLocator bytes
2325    /// value (PID_SHM_LOCATOR in the SEDP sample). `None` = no
2326    /// same-host backend attached. The wire encoder consults
2327    /// this map on the SEDP push.
2328    shm_locators: Arc<RwLock<BTreeMap<EntityId, Vec<u8>>>>,
2329    /// Wave 4 (Spec `zerodds-zero-copy-1.0` §6): tracker for
2330    /// same-host (writer, reader) pairs. The SEDP match hook registers
2331    /// here every pair whose remote prefix carries the same host-id prefix
2332    /// as the local participant. The hot-path send consults
2333    /// the tracker and routes over SHM instead of UDP in the `Bound` state.
2334    pub same_host: Arc<crate::same_host::SameHostTracker>,
2335    /// Local user-reader registry (EntityId → reader state).
2336    user_readers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserReaderSlot>>>>>,
2337    /// Cross-vendor step 6b: peers to whom we have already sent per-endpoint
2338    /// crypto tokens (datawriter/datareader). Prevents spam on the
2339    /// repeated receipt of cyclone's tokens; sending happens only once the
2340    /// user endpoints exist (the bench creates them after handshake start).
2341    #[cfg(feature = "security")]
2342    /// Already-sent per-endpoint crypto tokens, per dedup key
2343    /// (source_endpoint ++ destination_endpoint, see `endpoint_token_key`).
2344    /// Per-token instead of per-peer, so late-matched user endpoints still
2345    /// get their tokens (#29).
2346    endpoint_tokens_sent: Arc<RwLock<alloc::collections::BTreeSet<[u8; 32]>>>,
2347    /// Peers (prefix) to whom our SEDP endpoint records have already been
2348    /// re-announced after a completed crypto-token exchange. Under rtps_/discovery_
2349    /// protection the initial SEDP burst is discarded by the peer (no key), until
2350    /// the participant crypto token arrives via Volatile; a one-time
2351    /// re-announce from that moment (the peer can now decode) brings the
2352    /// dropped SEDP up (OpenDDS flow; cyclone/FastDDS converge anyway).
2353    #[cfg(feature = "security")]
2354    sedp_reannounced: Arc<RwLock<alloc::collections::BTreeSet<[u8; 12]>>>,
2355    /// Per-endpoint crypto (DDS-Security §9.5.3.3): per local writer/reader
2356    /// EntityId its own crypto slot handle (its own key material, not the
2357    /// participant key). Used for the per-endpoint token (prepare_endpoint_
2358    /// crypto_tokens) AND the per-endpoint encode (protect_user_datagram)
2359    /// — the same key on both sides. Get-or-register lazily via
2360    /// `local_endpoint_crypto_handle`.
2361    #[cfg(feature = "security")]
2362    endpoint_crypto:
2363        Arc<RwLock<alloc::collections::BTreeMap<EntityId, zerodds_security::crypto::CryptoHandle>>>,
2364    /// Same-runtime writer→reader routes: per local writer the list
2365    /// of local readers subscribed to the same topic+type.
2366    /// Rebuilt in `recompute_intra_runtime_routes` on every
2367    /// register/unregister. Looked up in the write hot path,
2368    /// to push samples directly into the reader slot's `sample_tx`
2369    /// (intra-process loopback without an RTPS roundtrip, in parallel to the
2370    /// inproc peer path that only serves cross-runtime peers).
2371    intra_runtime_routes: Arc<RwLock<BTreeMap<EntityId, Vec<EntityId>>>>,
2372    /// Entity key counter (3 byte, incrementing). User writers use
2373    /// `0xC2` (with-key, user), user readers `0xC7`.
2374    entity_counter: AtomicU32,
2375    /// Configuration (cloned from RuntimeConfig).
2376    pub config: RuntimeConfig,
2377    /// Per-interface outbound socket pool. `None`
2378    /// when `config.interface_bindings` is empty — then
2379    /// `user_unicast` stays the only outbound socket (v1.4 path).
2380    #[cfg(feature = "security")]
2381    outbound_pool: Option<Arc<OutboundSocketPool>>,
2382    /// Writer-Liveliness-Protocol endpoint (RTPS 2.5 §8.4.13).
2383    /// Sends periodic `ParticipantMessageData` heartbeats and
2384    /// tracks last-seen per remote participant.
2385    pub wlp: Arc<Mutex<crate::wlp::WlpEndpoint>>,
2386    /// Builtin-topic reader sinks (DDS 1.4 §2.2.5). Set by the
2387    /// `DomainParticipant` constructor via `attach_builtin_sinks`;
2388    /// before that this is `None` and the discovery hot path
2389    /// drops samples silently (e.g. when the runtime is
2390    /// started directly for internal tests, without a participant).
2391    builtin_sinks: Mutex<Option<crate::builtin_subscriber::BuiltinSinks>>,
2392    /// Ignore filter (DDS 1.4 §2.2.2.2.1.14-17). Set by the
2393    /// `DomainParticipant` constructor via `attach_ignore_filter`.
2394    /// `None` means: no participant hook → no
2395    /// filtering.
2396    ignore_filter: Mutex<Option<crate::participant::IgnoreFilter>>,
2397    /// Stop flag for all worker threads (recv loops + tick loop).
2398    stop: Arc<AtomicBool>,
2399    /// Monotonic count of completed tick iterations. Incremented once per
2400    /// [`run_tick_iteration`], regardless of whether the tick is driven by the
2401    /// internal `zdds-tick` thread or an external executor (zerodds-async-1.0
2402    /// §4 `spawn_in_tokio`). Diagnostic: a stalled count means the tick loop
2403    /// stopped advancing. Read via [`DcpsRuntime::tick_count`].
2404    tick_seq: AtomicU64,
2405    /// Total SPDP announces emitted (multicast + unicast fan-out count as one).
2406    /// Diagnostic for the C3 initial-announcement burst — a fresh, peer-less
2407    /// participant should advance this fast initially. Read via
2408    /// [`DcpsRuntime::spdp_announce_count`].
2409    spdp_announce_seq: AtomicU64,
2410    /// Inconsistent-topic counter (DDS 1.4 §2.2.4.2.4). Incremented when
2411    /// matching discovers a remote endpoint carrying the same `topic_name`
2412    /// but a differing `type_name` in the SEDP cache. Read via
2413    /// [`DcpsRuntime::inconsistent_topic_count`].
2414    inconsistent_topic_seq: AtomicU64,
2415    /// D.5e Phase 3 — wake handle for the event-driven scheduler tick. `Some`
2416    /// only when started with `scheduler_tick`. Recv loops + the write path call
2417    /// [`DcpsRuntime::raise_tick_wake`] to wake the worker immediately on new
2418    /// work (so HEARTBEAT/ACKNACK/HB processing does not wait for a deadline).
2419    tick_wake: Mutex<Option<crate::scheduler::SchedulerHandle<TickEvent>>>,
2420    /// Coalesces wake raises: many incoming datagrams collapse into one wake.
2421    tick_wake_pending: AtomicBool,
2422    /// Worker thread JoinHandles. Per-socket recv threads + tick thread,
2423    /// all terminated together via `stop` (Sprint D.5b — previously
2424    /// a single single-threaded `event_loop`).
2425    handles: Mutex<Vec<JoinHandle<()>>>,
2426    /// Match-event notifier (D.5e Phase-1 quick win). Notified by the
2427    /// SEDP match path after `add_reader_proxy` / `add_writer_proxy`;
2428    /// `wait_for_matched_*` parks on it instead of polling every 20 ms.
2429    /// The mutex content is only a lock anchor for the Condvar API; there is
2430    /// no state protected by it (the count is read independently
2431    /// via `user_*_matched_count`).
2432    match_event: Arc<(Mutex<()>, Condvar)>,
2433    /// Acknowledgments event notifier. Notified when a writer
2434    /// receives an ACKNACK that advances its acked-base.
2435    /// `wait_for_acknowledgments` parks on it instead of polling every 50 ms.
2436    ack_event: Arc<(Mutex<()>, Condvar)>,
2437}
2438
2439impl core::fmt::Debug for DcpsRuntime {
2440    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2441        f.debug_struct("DcpsRuntime")
2442            .field("domain_id", &self.domain_id)
2443            .field("guid_prefix", &self.guid_prefix)
2444            .field("spdp_group", &self.config.spdp_multicast_group)
2445            .finish_non_exhaustive()
2446    }
2447}
2448
2449/// Type alias: Arc-shared slot handles from the per-slot mutex
2450/// architecture.
2451type WriterSlotArc = Arc<Mutex<UserWriterSlot>>;
2452type ReaderSlotArc = Arc<Mutex<UserReaderSlot>>;
2453
2454impl DcpsRuntime {
2455    // ========================================================================
2456    // --- Per-Slot-Mutex-Helpers
2457    //
2458    // The `user_writers`/`user_readers` registry is `RwLock<BTreeMap<EntityId,
2459    // Arc<Mutex<Slot>>>>`. Hot-path accesses take the read lock briefly, clone
2460    // the slot Arc and release the read lock before taking the per-slot mutex.
2461    // Parallel writes to **different** slots thereby run
2462    // without global contention.
2463    //
2464    // Slot creation/deletion takes the write lock; that is rare and
2465    // amortizes out.
2466    // ========================================================================
2467
2468    /// Returns the slot Arc for a user writer, if present.
2469    /// Hot-path form: a single read lock + Arc clone, no
2470    /// per-slot mutex. The caller takes the mutex itself.
2471    fn writer_slot(&self, eid: EntityId) -> Option<WriterSlotArc> {
2472        self.user_writers
2473            .read()
2474            .ok()
2475            .and_then(|w| w.get(&eid).cloned())
2476    }
2477
2478    /// Returns the slot Arc for a user reader, if present.
2479    fn reader_slot(&self, eid: EntityId) -> Option<ReaderSlotArc> {
2480        self.user_readers
2481            .read()
2482            .ok()
2483            .and_then(|r| r.get(&eid).cloned())
2484    }
2485
2486    /// Snapshot of all writer slots as `Vec<(EntityId, Arc)>`. Allows
2487    /// iteration without holding the registry read lock — e.g. for
2488    /// the heartbeat tick or liveliness sweep, where we potentially take every
2489    /// slot's mutex.
2490    fn writer_slots_snapshot(&self) -> Vec<(EntityId, WriterSlotArc)> {
2491        match self.user_writers.read() {
2492            Ok(w) => w.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2493            Err(_) => Vec::new(),
2494        }
2495    }
2496
2497    /// Snapshot of all reader slots — symmetric to writer_slots_snapshot.
2498    fn reader_slots_snapshot(&self) -> Vec<(EntityId, ReaderSlotArc)> {
2499        match self.user_readers.read() {
2500            Ok(r) => r.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2501            Err(_) => Vec::new(),
2502        }
2503    }
2504
2505    /// Returns the list of EntityIds of all registered writers.
2506    /// Very lightweight — no slot-Arc clone, just EntityIds.
2507    fn writer_eids(&self) -> Vec<EntityId> {
2508        match self.user_writers.read() {
2509            Ok(w) => w.keys().copied().collect(),
2510            Err(_) => Vec::new(),
2511        }
2512    }
2513
2514    /// Returns the list of EntityIds of all registered readers.
2515    fn reader_eids(&self) -> Vec<EntityId> {
2516        match self.user_readers.read() {
2517            Ok(r) => r.keys().copied().collect(),
2518            Err(_) => Vec::new(),
2519        }
2520    }
2521
2522    /// Starts a new runtime for a participant.
2523    ///
2524    /// # Errors
2525    /// `TransportError` if one of the 3 UDP sockets fails to bind
2526    /// (e.g. a port collision on the SPDP multicast port in another
2527    /// SO_REUSE-less DDS instance).
2528    pub fn start(
2529        domain_id: i32,
2530        guid_prefix: GuidPrefix,
2531        mut config: RuntimeConfig,
2532    ) -> Result<Arc<Self>> {
2533        // C1 multicast-free discovery: merge the domain-aware env `ZERODDS_PEERS`
2534        // into the (programmatic) `config.initial_peers`. Default
2535        // is both empty → pure multicast behavior.
2536        config
2537            .initial_peers
2538            .extend(parse_initial_peers_env(domain_id as u32));
2539        // SPDP multicast receiver on the spec port.
2540        // u32 → u16 enforcing, the spec port is always < 65536.
2541        let spdp_port = u16::try_from(spdp_multicast_port(domain_id as u32)).map_err(|_| {
2542            DdsError::BadParameter {
2543                what: "domain_id too large for SPDP port mapping",
2544            }
2545        })?;
2546        let spdp_mc = UdpTransport::bind_multicast_v4(
2547            config.spdp_multicast_group,
2548            spdp_port,
2549            config.multicast_interface,
2550        )
2551        .map_err(|_| DdsError::TransportError {
2552            label: "spdp multicast bind",
2553        })?
2554        // Sprint D.5b: recv sockets have their own thread that
2555        // blocks waiting for data. Timeout 1 s = stop-flag polling
2556        // granularity at shutdown, NOT the tick rhythm.
2557        .with_timeout(Some(Duration::from_secs(1)))
2558        .map_err(|_| DdsError::TransportError {
2559            label: "spdp multicast set_timeout",
2560        })?;
2561
2562        // SPDP unicast: bind to the **well-known** RTPS port
2563        // (7400+250*domain+10+2*pid, Spec §9.6.1.4.1), so a
2564        // configured unicast initial peer can reach this participant
2565        // WITHOUT prior multicast (C1 multicast-free
2566        // discovery). Participant index 0,1,2,… until a free port
2567        // is found (multiple participants per host, also alongside
2568        // Cyclone/FastDDS). Fallback ephemeral if all well-known
2569        // ports are taken (then multicast discovery only).
2570        // Interface pinning (ZERODDS_INTERFACE): UNSPECIFIED = auto. If
2571        // set, ALL IP sockets bind (SPDP-uc, SPDP-mc-tx, user UDP/TCP)
2572        // to this IP → announce + egress + receive on exactly this
2573        // interface (multi-homed robustness, cf. Cyclone `NetworkInterface`).
2574        let pinned = config.multicast_interface;
2575        let (spdp_uc_raw, _spdp_participant_id) = {
2576            let mut bound = None;
2577            for pid in 0u32..120 {
2578                let Ok(port) = u16::try_from(spdp_unicast_port(domain_id as u32, pid)) else {
2579                    break;
2580                };
2581                if let Ok(sock) = UdpTransport::bind_v4(pinned, port) {
2582                    bound = Some((sock, pid));
2583                    break;
2584                }
2585            }
2586            match bound {
2587                Some(b) => b,
2588                None => (
2589                    UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2590                        label: "spdp unicast bind",
2591                    })?,
2592                    u32::MAX,
2593                ),
2594            }
2595        };
2596        let spdp_uc = spdp_uc_raw
2597            .with_timeout(Some(Duration::from_secs(1)))
2598            .map_err(|_| DdsError::TransportError {
2599                label: "spdp unicast set_timeout",
2600            })?;
2601
2602        // User-data unicast (ephemeral port). Transport choice primarily via
2603        // `RuntimeConfig::user_transport`, fallback to the env var
2604        // `ZERODDS_USER_TRANSPORT` (bench binaries), otherwise UDPv4.
2605        // SPDP multicast stays UDPv4 — the DDSI-RTPS spec mandates
2606        // 239.255.0.1 for cross-vendor discovery; v6-only hosts
2607        // cannot discover cross-vendor (its own sprint).
2608        let user_transport_kind = config
2609            .user_transport
2610            .or_else(parse_user_transport_env)
2611            .unwrap_or(UserTransportKind::UdpV4);
2612        let (user_uc, tcp_accept_handle) =
2613            select_user_transport(user_transport_kind, guid_prefix, domain_id, pinned)?;
2614
2615        // Separate sender socket for the SPDP announce. Ephemeral port; with
2616        // interface pinning it binds to the pinned IP (egress source), otherwise
2617        // `0.0.0.0` (the kernel picks the outgoing interface per route).
2618        let spdp_mc_tx =
2619            UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2620                label: "spdp mc-tx bind",
2621            })?;
2622
2623        let stop = Arc::new(AtomicBool::new(false));
2624
2625        // Materialize beacon locators for cross-host interop:
2626        // with a `0.0.0.0` bind address (UNSPECIFIED) the peer would
2627        // otherwise learn a non-routable address. We resolve UNSPECIFIED
2628        // via a UDP connect probe to a non-routable IP
2629        // (no traffic, just the routing table) and announce the
2630        // resulting local interface address — cross-host-capable
2631        // without an external crate dependency.
2632        let user_locator = announce_locator(&*user_uc, config.multicast_interface);
2633        let spdp_uc_locator = announce_locator(&spdp_uc, config.multicast_interface);
2634        let participant_data = ParticipantBuiltinTopicData {
2635            guid: Guid::new(guid_prefix, EntityId::PARTICIPANT),
2636            protocol_version: ProtocolVersion::V2_5,
2637            vendor_id: VendorId::ZERODDS,
2638            default_unicast_locator: Some(user_locator),
2639            default_multicast_locator: None,
2640            metatraffic_unicast_locator: Some(spdp_uc_locator),
2641            metatraffic_multicast_locator: Some(Locator {
2642                kind: LocatorKind::UdpV4,
2643                port: u32::from(spdp_port),
2644                address: {
2645                    let mut a = [0u8; 16];
2646                    a[12..].copy_from_slice(&config.spdp_multicast_group.octets());
2647                    a
2648                },
2649            }),
2650            domain_id: Some(domain_id as u32),
2651            // We announce the endpoints we actually
2652            // implement: SPDP (participant ann/det) + SEDP
2653            // (publications/subscriptions ann+det) + WLP (10/11) +
2654            // TypeLookup service (12/13). Cyclone/Fast-DDS filter
2655            // their proxy setup by these flags — without them
2656            // we get no SEDP/WLP peers. SEDP topic
2657            // endpoints (bits 28/29) are optional per RTPS 2.5 §8.5.4.4
2658            // and covered in ZeroDDS via synthetic DCPSTopic
2659            // derivation from pub/sub — we do not announce them,
2660            // otherwise we promise peers a non-existent
2661            // endpoint pairing. When the caller sets
2662            // `announce_secure_endpoints = true` (security
2663            // factory path), we additionally mix in the 12 secure
2664            // discovery bits (16..27, DDS-Security 1.2 §7.4.7.1).
2665            builtin_endpoint_set: {
2666                let mut mask = endpoint_flag::ALL_STANDARD;
2667                if config.announce_secure_endpoints {
2668                    mask |= endpoint_flag::ALL_SECURE;
2669                }
2670                mask
2671            },
2672            // Spec default lease = 100 s; configurable via
2673            // `RuntimeConfig::participant_lease_duration`.
2674            lease_duration: qos_duration_from_std(config.participant_lease_duration),
2675            // UserData on the participant — filled from
2676            // `DomainParticipantQos::user_data` via RuntimeConfig.
2677            user_data: config.user_data.clone(),
2678            // PROPERTY_LIST: security fills this with security caps
2679            // once a PolicyEngine is configured. Default-empty
2680            // stays backward-compatible with legacy peers.
2681            properties: Default::default(),
2682            // IdentityToken/PermissionsToken are filled by the security
2683            // layer once authentication + access control are
2684            // initialized. Default `None` = legacy announce.
2685            identity_token: None,
2686            permissions_token: None,
2687            identity_status_token: None,
2688            sig_algo_info: None,
2689            kx_algo_info: None,
2690            sym_cipher_algo_info: None,
2691            // Filled by the security layer (enable_security_builtins*) —
2692            // without PID_PARTICIPANT_SECURITY_INFO foreign vendors classify
2693            // us as non-secure. Default None = legacy/plain.
2694            participant_security_info: None,
2695        };
2696        let beacon = SpdpBeacon::new(participant_data.clone());
2697        let sedp = SedpStack::new(guid_prefix, VendorId::ZERODDS);
2698        // In-process discovery fastpath: remember the multicast group before
2699        // `config` is moved into the struct literal.
2700        let inproc_group = config.spdp_multicast_group;
2701
2702        #[cfg(feature = "security")]
2703        let outbound_pool = if config.interface_bindings.is_empty() {
2704            None
2705        } else {
2706            Some(Arc::new(OutboundSocketPool::bind_all(
2707                &config.interface_bindings,
2708            )?))
2709        };
2710
2711        // WLP endpoint (RTPS 2.5 §8.4.13). The tick period is explicit
2712        // `wlp_period`, or `lease/3` when `wlp_period == ZERO`
2713        // (spec recommendation: three misses before the reader marks the
2714        // writer as not-alive).
2715        let wlp_tick_period = if config.wlp_period.is_zero() {
2716            config.participant_lease_duration / 3
2717        } else {
2718            config.wlp_period
2719        };
2720        let wlp = crate::wlp::WlpEndpoint::new(guid_prefix, VendorId::ZERODDS, wlp_tick_period);
2721
2722        let rt = Arc::new(Self {
2723            guid_prefix,
2724            domain_id,
2725            spdp_multicast_rx: Arc::new(spdp_mc),
2726            spdp_unicast: Arc::new(spdp_uc),
2727            user_unicast: user_uc,
2728            user_announce_locator: user_locator,
2729            spdp_mc_tx: Arc::new(spdp_mc_tx),
2730            spdp_beacon: Mutex::new(beacon),
2731            participant_data,
2732            announced_pubs: Mutex::new(Vec::new()),
2733            announced_subs: Mutex::new(Vec::new()),
2734            spdp_reader: SpdpReader::new(),
2735            discovered: Arc::new(Mutex::new(DiscoveredParticipantsCache::new())),
2736            sedp: Arc::new(Mutex::new(sedp)),
2737            type_lookup_endpoints: TypeLookupEndpoints::new(guid_prefix),
2738            type_lookup_server: Arc::new(Mutex::new(TypeLookupServer::new())),
2739            type_lookup_client: Arc::new(Mutex::new(TypeLookupClient::new())),
2740            tl_reply_sn: core::sync::atomic::AtomicU64::new(0),
2741            security_builtin: Mutex::new(None),
2742            start_instant: Instant::now(),
2743            user_writers: Arc::new(RwLock::new(BTreeMap::new())),
2744            shm_locators: Arc::new(RwLock::new(BTreeMap::new())),
2745            same_host: Arc::new(crate::same_host::SameHostTracker::new()),
2746            user_readers: Arc::new(RwLock::new(BTreeMap::new())),
2747            #[cfg(feature = "security")]
2748            endpoint_tokens_sent: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
2749            #[cfg(feature = "security")]
2750            sedp_reannounced: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
2751            #[cfg(feature = "security")]
2752            endpoint_crypto: Arc::new(RwLock::new(alloc::collections::BTreeMap::new())),
2753            intra_runtime_routes: Arc::new(RwLock::new(BTreeMap::new())),
2754            entity_counter: AtomicU32::new(1),
2755            config,
2756            stop: stop.clone(),
2757            tick_seq: AtomicU64::new(0),
2758            spdp_announce_seq: AtomicU64::new(0),
2759            inconsistent_topic_seq: AtomicU64::new(0),
2760            tick_wake: Mutex::new(None),
2761            tick_wake_pending: AtomicBool::new(false),
2762            handles: Mutex::new(Vec::new()),
2763            match_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
2764            ack_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
2765            #[cfg(feature = "security")]
2766            outbound_pool,
2767            wlp: Arc::new(Mutex::new(wlp)),
2768            builtin_sinks: Mutex::new(None),
2769            ignore_filter: Mutex::new(None),
2770        });
2771
2772        // In-process discovery fastpath: register the runtime in the process
2773        // registry so same-process+domain peers find each other
2774        // deterministically (see `crate::inproc`). Right
2775        // after, `pull-on-creation`: pull all already-announced endpoints
2776        // of existing peers into our SEDP cache — otherwise
2777        // we see peers that announced endpoints BEFORE us
2778        // only via the (lossy) UDP SEDP path.
2779        crate::inproc::register(&rt, domain_id, inproc_group);
2780        rt.inproc_pull_from_peers();
2781
2782        // Per-socket recv threads + one tick thread (Sprint D.5b).
2783        //
2784        // Previously the entire stack ran in a single event loop
2785        // that went through three blocking `recv()`s with a `tick_period`
2786        // timeout (50 ms) sequentially per iteration. On a
2787        // roundtrip each stage waited up to 50 ms for timeouts of the
2788        // front sockets before its own datagram got its turn —
2789        // yielded 5-14 ms p50.
2790        //
2791        // Refit: every relevant recv path has its own thread
2792        // that sits directly blocking on its socket and dispatches
2793        // immediately when data arrives. The tick thread does the
2794        // periodic outbound work (HEARTBEAT/resend/ACKNACK/
2795        // SPDP announce/deadline/lifespan/liveliness) and sleeps
2796        // `tick_period` between iterations.
2797        //
2798        // Lock order (deadlock avoidance): the tick thread and
2799        // recv threads contend for `rt.sedp.lock()` / `rt.wlp.lock()`.
2800        // Convention: keep lock-hold times short (handle_datagram /
2801        // tick are both fast), do not take a sub-lock under the `sedp`
2802        // or `wlp` lock.
2803        let mut handles_init: Vec<JoinHandle<()>> = Vec::with_capacity(4);
2804
2805        let rt_recv_spdp_mc = Arc::clone(&rt);
2806        let stop_recv_spdp_mc = stop.clone();
2807        handles_init.push(
2808            thread::Builder::new()
2809                .name(String::from("zdds-recv-spdp-mc"))
2810                .spawn(move || recv_spdp_multicast_loop(rt_recv_spdp_mc, stop_recv_spdp_mc))
2811                .map_err(|_| DdsError::PreconditionNotMet {
2812                    reason: "spawn zdds-recv-spdp-mc thread",
2813                })?,
2814        );
2815
2816        let rt_recv_meta = Arc::clone(&rt);
2817        let stop_recv_meta = stop.clone();
2818        handles_init.push(
2819            thread::Builder::new()
2820                .name(String::from("zdds-recv-meta"))
2821                .spawn(move || recv_metatraffic_loop(rt_recv_meta, stop_recv_meta))
2822                .map_err(|_| DdsError::PreconditionNotMet {
2823                    reason: "spawn zdds-recv-meta thread",
2824                })?,
2825        );
2826
2827        let rt_recv_user = Arc::clone(&rt);
2828        let stop_recv_user = stop.clone();
2829        let primary_socket = Arc::clone(&rt.user_unicast);
2830        handles_init.push(
2831            thread::Builder::new()
2832                .name(String::from("zdds-recv-user"))
2833                .spawn(move || recv_user_data_loop(rt_recv_user, primary_socket, stop_recv_user))
2834                .map_err(|_| DdsError::PreconditionNotMet {
2835                    reason: "spawn zdds-recv-user thread",
2836                })?,
2837        );
2838
2839        // TCPv4 variant: a separate accept worker (TcpTransport has
2840        // no implicit accept thread in the constructor — accept_one()
2841        // must be called explicitly).
2842        if let Some(tcp_arc) = tcp_accept_handle {
2843            let stop_accept = stop.clone();
2844            handles_init.push(
2845                thread::Builder::new()
2846                    .name(String::from("zdds-tcp-accept"))
2847                    .spawn(move || {
2848                        while !stop_accept.load(Ordering::Relaxed) {
2849                            // accept_one() blocks until connection +
2850                            // handshake; on EOF it returns Ok(()) and
2851                            // we accept the next peer.
2852                            let _ = tcp_arc.accept_one();
2853                        }
2854                    })
2855                    .map_err(|_| DdsError::PreconditionNotMet {
2856                        reason: "spawn zdds-tcp-accept thread",
2857                    })?,
2858            );
2859        }
2860
2861        // Opt-3 (Spec `zerodds-zero-copy-1.0` §9): additional
2862        // SO_REUSEPORT recv workers. Each binds to the same
2863        // user_unicast port; the kernel distributes incoming datagrams via
2864        // flow hash. On a bind error (e.g. a platform without
2865        // SO_REUSEPORT support) the worker is skipped and the
2866        // runtime continues with the available workers.
2867        if rt.config.extra_recv_threads > 0 {
2868            let user_port = u16::try_from(rt.user_unicast.local_locator().port).unwrap_or(0);
2869            // With an active security config, share the first interface bind address;
2870            // otherwise INADDR_ANY (the kernel chooses).
2871            #[cfg(feature = "security")]
2872            let bind_addr = rt
2873                .config
2874                .interface_bindings
2875                .first()
2876                .map(|spec| spec.bind_addr)
2877                .unwrap_or(Ipv4Addr::UNSPECIFIED);
2878            #[cfg(not(feature = "security"))]
2879            let bind_addr = Ipv4Addr::UNSPECIFIED;
2880            for i in 0..rt.config.extra_recv_threads {
2881                let extra_socket =
2882                    match UdpTransport::bind_v4_reuse(bind_addr, user_port) {
2883                        Ok(t) => Arc::new(t.with_timeout(Some(Duration::from_secs(1))).map_err(
2884                            |_| DdsError::TransportError {
2885                                label: "extra-recv set_timeout failed",
2886                            },
2887                        )?),
2888                        Err(_) => break, // SO_REUSEPORT not available — skip.
2889                    };
2890                let rt_extra = Arc::clone(&rt);
2891                let stop_extra = stop.clone();
2892                let name = format!("zdds-recv-user-{}", i + 1);
2893                handles_init.push(
2894                    thread::Builder::new()
2895                        .name(name)
2896                        .spawn(move || recv_user_data_loop(rt_extra, extra_socket, stop_extra))
2897                        .map_err(|_| DdsError::PreconditionNotMet {
2898                            reason: "spawn zdds-recv-user-N thread",
2899                        })?,
2900                );
2901            }
2902        }
2903
2904        // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6): per-owner
2905        // SHM recv loop. Polls all bound consumer entries of the
2906        // SameHostTracker round-robin and dispatches incoming
2907        // frames analogous to the UDP path. Only compiled when
2908        // the `same-host-shm` feature is on.
2909        #[cfg(feature = "same-host-shm")]
2910        {
2911            let rt_recv_shm = Arc::clone(&rt);
2912            let stop_recv_shm = stop.clone();
2913            handles_init.push(
2914                thread::Builder::new()
2915                    .name(String::from("zdds-recv-shm"))
2916                    .spawn(move || recv_user_shm_loop(rt_recv_shm, stop_recv_shm))
2917                    .map_err(|_| DdsError::PreconditionNotMet {
2918                        reason: "spawn zdds-recv-shm thread",
2919                    })?,
2920            );
2921        }
2922
2923        // zerodds-async-1.0 §4: with `external_tick`, the tick loop is driven
2924        // by an external executor (tokio via `spawn_in_tokio`) rather than a
2925        // dedicated thread — so we skip the spawn here. `stop` is dropped; the
2926        // driver observes shutdown via `rt.stop` (set in `Drop`/`stop()`).
2927        if rt.config.external_tick {
2928            drop(stop);
2929        } else if rt.config.scheduler_tick {
2930            // D.5e Phase 3 — event-driven scheduler tick. Create the scheduler
2931            // up front, publish its wake handle so recv loops + the write path
2932            // can `raise_tick_wake`, then drive the (unchanged) tick from the
2933            // deadline-heap worker.
2934            let (scheduler, handle) =
2935                crate::scheduler::Scheduler::<TickEvent>::new(SCHEDULER_IDLE_FLOOR);
2936            if let Ok(mut g) = rt.tick_wake.lock() {
2937                *g = Some(handle.clone());
2938            }
2939            let rt_tick = Arc::clone(&rt);
2940            let stop_tick = stop;
2941            handles_init.push(
2942                thread::Builder::new()
2943                    .name(String::from("zdds-tick-sched"))
2944                    .spawn(move || scheduler_tick_loop(rt_tick, stop_tick, scheduler, handle))
2945                    .map_err(|_| DdsError::PreconditionNotMet {
2946                        reason: "spawn zdds-tick-sched thread",
2947                    })?,
2948            );
2949        } else {
2950            let rt_tick = Arc::clone(&rt);
2951            let stop_tick = stop;
2952            handles_init.push(
2953                thread::Builder::new()
2954                    .name(String::from("zdds-tick"))
2955                    .spawn(move || tick_loop(rt_tick, stop_tick))
2956                    .map_err(|_| DdsError::PreconditionNotMet {
2957                        reason: "spawn zdds-tick thread",
2958                    })?,
2959            );
2960        }
2961
2962        let mut guard = rt
2963            .handles
2964            .lock()
2965            .map_err(|_| DdsError::PreconditionNotMet {
2966                reason: "runtime handles mutex poisoned",
2967            })?;
2968        *guard = handles_init;
2969        drop(guard);
2970
2971        Ok(rt)
2972    }
2973
2974    /// Local unicast locator for user data (announced in SPDP).
2975    #[must_use]
2976    pub fn user_locator(&self) -> zerodds_rtps::wire_types::Locator {
2977        self.user_unicast.local_locator()
2978    }
2979
2980    /// Local unicast locator for SPDP metatraffic.
2981    #[must_use]
2982    pub fn spdp_unicast_locator(&self) -> zerodds_rtps::wire_types::Locator {
2983        self.spdp_unicast.local_locator()
2984    }
2985
2986    /// Returns the `BuiltinEndpointSet` bitmask that the runtime
2987    /// currently announces in the SPDP beacon. Used for tests + diagnostics;
2988    /// production consumers should decode the SPDP beacon
2989    /// themselves.
2990    #[must_use]
2991    pub fn announced_builtin_endpoint_set(&self) -> u32 {
2992        self.spdp_beacon
2993            .lock()
2994            .map(|b| b.data.builtin_endpoint_set)
2995            .unwrap_or(0)
2996    }
2997
2998    /// Registers a `TypeObject` in the local TypeLookup server
2999    /// registry. Other participants can then query this type via
3000    /// a `getTypes` request (XTypes 1.3 §7.6.3.3.4).
3001    ///
3002    /// Returns the `EquivalenceHash` of the registered type
3003    /// (the caller can embed it e.g. in `PublicationBuiltinTopicData` as a
3004    /// PID_TYPE_INFORMATION hint).
3005    ///
3006    /// # Errors
3007    /// `DdsError::PreconditionNotMet` on lock poisoning or a hash
3008    /// computation error.
3009    pub fn register_type_object(
3010        &self,
3011        obj: zerodds_types::type_object::TypeObject,
3012    ) -> Result<zerodds_types::EquivalenceHash> {
3013        let hash = zerodds_types::compute_hash(&obj).map_err(|_| DdsError::PreconditionNotMet {
3014            reason: "type hash computation failed",
3015        })?;
3016        let mut server =
3017            self.type_lookup_server
3018                .lock()
3019                .map_err(|_| DdsError::PreconditionNotMet {
3020                    reason: "type_lookup_server mutex poisoned",
3021                })?;
3022        match obj {
3023            zerodds_types::type_object::TypeObject::Minimal(m) => {
3024                server.registry.insert_minimal(hash, m);
3025            }
3026            zerodds_types::type_object::TypeObject::Complete(c) => {
3027                server.registry.insert_complete(hash, c);
3028            }
3029            _ => {
3030                return Err(DdsError::PreconditionNotMet {
3031                    reason: "unknown TypeObject variant",
3032                });
3033            }
3034        }
3035        Ok(hash)
3036    }
3037
3038    /// Sends a `getTypes` request to a discovered peer and
3039    /// returns a `RequestId` with which the caller can correlate the
3040    /// asynchronous reply later (XTypes 1.3
3041    /// §7.6.3.3.4 + `TypeLookupClient::handle_reply`).
3042    ///
3043    /// `peer` must be in `discovered_participants()` — otherwise
3044    /// `None` is returned (no known peer locator). On a
3045    /// successful send the request sample-identity sequence
3046    /// is returned as the `RequestId`; an incoming reply is correlated on
3047    /// this sequence ID.
3048    ///
3049    /// # Errors
3050    /// `DdsError::PreconditionNotMet` on encode errors or lock
3051    /// poisoning.
3052    pub fn send_type_lookup_request(
3053        &self,
3054        peer: zerodds_rtps::wire_types::GuidPrefix,
3055        type_hashes: &[zerodds_types::EquivalenceHash],
3056    ) -> Result<Option<zerodds_discovery::type_lookup::RequestId>> {
3057        use alloc::sync::Arc as AllocArc;
3058        use zerodds_discovery::type_lookup::request_types_payload;
3059        use zerodds_rtps::datagram::encode_data_datagram;
3060        use zerodds_rtps::header::RtpsHeader;
3061        use zerodds_rtps::submessages::DataSubmessage;
3062        use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber};
3063
3064        // Find peer's user-unicast locator (default-unicast first;
3065        // fallback metatraffic-unicast). TypeLookup datagrams go via
3066        // the user-unicast path — the peer DCPS runtime has a
3067        // shared receive loop there for SEDP/user data/TypeLookup.
3068        let target = {
3069            let discovered = self
3070                .discovered
3071                .lock()
3072                .map_err(|_| DdsError::PreconditionNotMet {
3073                    reason: "discovered mutex poisoned",
3074                })?;
3075            let Some(dp) = discovered.get(&peer) else {
3076                return Ok(None);
3077            };
3078            dp.data
3079                .default_unicast_locator
3080                .or(dp.data.metatraffic_unicast_locator)
3081        };
3082        let Some(target) = target else {
3083            return Ok(None);
3084        };
3085
3086        // Allocate RequestId (client-side incrementing sequence). Reply
3087        // correlation runs via the `handle_reply` callback. We
3088        // register a callback that feeds the returned
3089        // TypeObjects into the local `TypeLookupServer.registry`
3090        // (XTypes 1.3 §7.6.3.3.4): hash-by-hash, separately
3091        // for Minimal and Complete variants. This way a hash that
3092        // was resolved once is recognized for future `has_type_for_hash`
3093        // checks (= no re-requests).
3094        let mut client =
3095            self.type_lookup_client
3096                .lock()
3097                .map_err(|_| DdsError::PreconditionNotMet {
3098                    reason: "type_lookup_client mutex poisoned",
3099                })?;
3100        let type_ids: alloc::vec::Vec<zerodds_types::TypeIdentifier> = type_hashes
3101            .iter()
3102            .map(|h| zerodds_types::TypeIdentifier::EquivalenceHashMinimal(*h))
3103            .collect();
3104        let server_for_cb = Arc::clone(&self.type_lookup_server);
3105        let cb = Box::new(
3106            move |reply: zerodds_discovery::type_lookup::TypeLookupReply| {
3107                let zerodds_discovery::type_lookup::TypeLookupReply::Types(types_reply) = reply
3108                else {
3109                    return;
3110                };
3111                let Ok(mut server) = server_for_cb.lock() else {
3112                    return;
3113                };
3114                for t in &types_reply.types {
3115                    match t {
3116                        zerodds_types::type_lookup::ReplyTypeObject::Minimal(m) => {
3117                            let to = zerodds_types::type_object::TypeObject::Minimal(m.clone());
3118                            if let Ok(h) = zerodds_types::compute_hash(&to) {
3119                                server.registry.insert_minimal(h, m.clone());
3120                            }
3121                        }
3122                        zerodds_types::type_lookup::ReplyTypeObject::Complete(c) => {
3123                            let to = zerodds_types::type_object::TypeObject::Complete(c.clone());
3124                            if let Ok(h) = zerodds_types::compute_hash(&to) {
3125                                server.registry.insert_complete(h, c.clone());
3126                            }
3127                        }
3128                    }
3129                }
3130            },
3131        );
3132        let request_id = client.request_types(type_ids.clone(), cb);
3133        drop(client);
3134
3135        // Encode the wire request payload (PL_CDR_LE-Encapsulation).
3136        let body = request_types_payload(&type_ids).map_err(|_| DdsError::PreconditionNotMet {
3137            reason: "type_lookup request payload encode failed",
3138        })?;
3139        let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
3140        payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
3141        payload.extend_from_slice(&body);
3142
3143        // Use the RequestId as the writer_sn so the peer-side reply can
3144        // echo it for correlation (XTypes §7.6.3.3.3 Sample-Identity).
3145        let id_u64 = request_id.0;
3146        let sn =
3147            SequenceNumber::from_high_low((id_u64 >> 32) as i32, (id_u64 & 0xFFFF_FFFF) as u32);
3148        let header = RtpsHeader {
3149            protocol_version: ProtocolVersion::CURRENT,
3150            vendor_id: VendorId::ZERODDS,
3151            guid_prefix: self.guid_prefix,
3152        };
3153        let data = DataSubmessage {
3154            extra_flags: 0,
3155            reader_id: EntityId::TL_SVC_REQ_READER,
3156            writer_id: EntityId::TL_SVC_REQ_WRITER,
3157            writer_sn: sn,
3158            inline_qos: None,
3159            key_flag: false,
3160            non_standard_flag: false,
3161            serialized_payload: AllocArc::from(payload.into_boxed_slice()),
3162        };
3163        let datagram =
3164            encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
3165                reason: "type_lookup request datagram encode failed",
3166            })?;
3167
3168        if is_routable_user_locator(&target) {
3169            let _ = self.user_unicast.send(&target, &datagram);
3170        }
3171        Ok(Some(request_id))
3172    }
3173
3174    /// Activates the security builtin endpoint stack
3175    /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
3176    /// MessageSecure`). Typically called by the factory
3177    /// once a security plugin is registered on the participant.
3178    /// Idempotent: a second call has no effect. Returns the (possibly
3179    /// freshly created) stack handle.
3180    pub fn enable_security_builtins(
3181        &self,
3182        vendor_id: VendorId,
3183    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3184        self.install_security_stack(SecurityBuiltinStack::new(self.guid_prefix, vendor_id))
3185    }
3186
3187    /// Like [`enable_security_builtins`](Self::enable_security_builtins),
3188    /// but with an active auth-handshake driver (FU2 Gap 4). The stack
3189    /// is built via [`SecurityBuiltinStack::with_auth`]: the shared
3190    /// `auth` plugin (= the same instance that hangs on the crypto gate as
3191    /// the `SharedSecretProvider`) drives the PKI handshake as soon as
3192    /// a peer with stateless bits + identity token is discovered.
3193    ///
3194    /// `local_identity` comes from `validate_local_identity`; the local
3195    /// 16-byte participant GUID is derived from the `guid_prefix`.
3196    ///
3197    /// Idempotent (first-wins): if a stack is already active — even one
3198    /// built without auth — that one is returned and the freshly
3199    /// built one discarded.
3200    #[cfg(feature = "security")]
3201    pub fn enable_security_builtins_with_auth(
3202        self: &Arc<Self>,
3203        vendor_id: VendorId,
3204        auth: Arc<Mutex<dyn zerodds_security::authentication::AuthenticationPlugin>>,
3205        local_identity: zerodds_security::authentication::IdentityHandle,
3206    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3207        let local_guid = Guid::new(self.guid_prefix, EntityId::PARTICIPANT).to_bytes();
3208        // Announce the local IdentityToken in the SPDP beacon (PID_IDENTITY_TOKEN,
3209        // FU2 Gap 7c) + set the stateless/volatile-secure bits, so peers
3210        // initiate the auth handshake. Before moving `auth` into the stack.
3211        if let Ok(mut plugin) = auth.lock() {
3212            if let Ok(token) = plugin.get_identity_token(local_identity) {
3213                // PID_PERMISSIONS_TOKEN (§7.4.1.5, S4 point 1): secure
3214                // vendors (cyclone/FastDDS) start validate_remote_identity
3215                // only when SPDP carries identity_token AND permissions_token;
3216                // otherwise we stay non-secure and all endpoints are "not
3217                // allowed". Empty if no permissions are configured.
3218                let perm_token = plugin.get_permissions_token();
3219                let pdata = if let Ok(mut beacon) = self.spdp_beacon.lock() {
3220                    if !token.is_empty() {
3221                        beacon.data.identity_token = Some(token);
3222                    }
3223                    if !perm_token.is_empty() {
3224                        beacon.data.permissions_token = Some(perm_token);
3225                    }
3226                    // Full secure builtin endpoint set (§7.4.7.1): stateless +
3227                    // VolatileSecure (22-25) PLUS secure SEDP (16-19),
3228                    // secure ParticipantMessage (20-21) and DCPSParticipantsSecure
3229                    // (26-27). cyclone starts validate_remote_identity + creates the
3230                    // secure builtin proxies ONLY when the remote announces the full
3231                    // secure set (cyclone-trace-verified) — only
3232                    // 22-25 → "Non secure remote ... not allowed", no handshake.
3233                    beacon.data.builtin_endpoint_set |= endpoint_flag::PUBLICATIONS_SECURE_WRITER
3234                        | endpoint_flag::PUBLICATIONS_SECURE_READER
3235                        | endpoint_flag::SUBSCRIPTIONS_SECURE_WRITER
3236                        | endpoint_flag::SUBSCRIPTIONS_SECURE_READER
3237                        | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_WRITER
3238                        | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_READER
3239                        | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
3240                        | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
3241                        | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
3242                        | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
3243                        | endpoint_flag::PARTICIPANT_SECURE_WRITER
3244                        | endpoint_flag::PARTICIPANT_SECURE_READER;
3245                    // PID_PARTICIPANT_SECURITY_INFO (§7.4.1.6): marks us as a
3246                    // secure participant — mandatory, otherwise cyclone/
3247                    // FastDDS treat us as non-secure and reject all endpoints.
3248                    // IS_VALID on both masks; derive the participant-level
3249                    // ParticipantSecurityAttributes (§9.4.2.4) from the governance:
3250                    // is_{rtps,discovery,liveliness}_protected in the
3251                    // attr mask, is_*_encrypted in the plugin mask. cyclone
3252                    // matches the announced bits against its own governance —
3253                    // a null mask with e.g. discovery=ENCRYPT is a policy
3254                    // mismatch and cyclone then establishes NO secured
3255                    // participant crypto handshake (bug source protected discovery).
3256                    use zerodds_rtps::participant_security_info::{
3257                        ParticipantSecurityInfo, attrs, plugin_attrs,
3258                    };
3259                    let (mut a, mut p) = (attrs::IS_VALID, plugin_attrs::IS_VALID);
3260                    if let Some(gate) = self.config.security.as_ref() {
3261                        let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
3262                        let disc = gate.discovery_protection().unwrap_or(ProtectionLevel::None);
3263                        let live = gate
3264                            .liveliness_protection()
3265                            .unwrap_or(ProtectionLevel::None);
3266                        if rtps != ProtectionLevel::None {
3267                            a |= attrs::IS_RTPS_PROTECTED;
3268                        }
3269                        if disc != ProtectionLevel::None {
3270                            a |= attrs::IS_DISCOVERY_PROTECTED;
3271                        }
3272                        if live != ProtectionLevel::None {
3273                            a |= attrs::IS_LIVELINESS_PROTECTED;
3274                        }
3275                        if rtps == ProtectionLevel::Encrypt {
3276                            p |= plugin_attrs::IS_RTPS_ENCRYPTED;
3277                        }
3278                        if disc == ProtectionLevel::Encrypt {
3279                            p |= plugin_attrs::IS_DISCOVERY_ENCRYPTED;
3280                        }
3281                        if live == ProtectionLevel::Encrypt {
3282                            p |= plugin_attrs::IS_LIVELINESS_ENCRYPTED;
3283                        }
3284                    }
3285                    beacon.data.participant_security_info = Some(ParticipantSecurityInfo {
3286                        participant_security_attributes: a,
3287                        plugin_participant_security_attributes: p,
3288                    });
3289                    // c.pdata (§9.3.2.5.2, S4 root 6+7): our own
3290                    // ParticipantBuiltinTopicData as PL_CDR_**BE** — the replier
3291                    // (cyclone) deserializes c.pdata strictly as a big-endian
3292                    // ParameterList and binds the participant_guid to the
3293                    // authenticated identity. LE → "payload too long".
3294                    Some(beacon.data.to_pl_cdr_be())
3295                } else {
3296                    None
3297                };
3298                if let Some(pd) = pdata {
3299                    plugin.set_local_participant_data(pd);
3300                }
3301            }
3302        }
3303        let stack = self.install_security_stack(SecurityBuiltinStack::with_auth(
3304            self.guid_prefix,
3305            vendor_id,
3306            auth,
3307            local_identity,
3308            local_guid,
3309        ));
3310        // FU2 S3: kick off in-process participant discovery + handshake trigger
3311        // deterministically — decouples the secured discovery from the
3312        // flaky multicast path (codepit LXC). Bidirectional, idempotent.
3313        self.inproc_announce_participant();
3314        // FU2 S3: immediate token-carrying SPDP re-announce (event-driven).
3315        self.announce_spdp_now();
3316        stack
3317    }
3318
3319    /// Installs a freshly built `SecurityBuiltinStack` into the
3320    /// runtime slot (idempotent) and catches up on peers already
3321    /// discovered via SPDP. Shared core of
3322    /// [`enable_security_builtins`](Self::enable_security_builtins) and
3323    /// [`enable_security_builtins_with_auth`](Self::enable_security_builtins_with_auth).
3324    fn install_security_stack(
3325        &self,
3326        fresh: SecurityBuiltinStack,
3327    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3328        // Lock poisoning is a bug indicator here (an earlier panic in the
3329        // hot path). In that case we return a fresh, isolated
3330        // stack — the caller gets at least a
3331        // functional slot, but the hot path writes its mutations
3332        // into the unlocked original. In production code this does not happen;
3333        // in tests (where poisoning can occur) this is a
3334        // best-effort recovery.
3335        let mut slot = match self.security_builtin.lock() {
3336            Ok(g) => g,
3337            Err(_) => {
3338                return Arc::new(Mutex::new(fresh));
3339            }
3340        };
3341        if let Some(existing) = slot.as_ref() {
3342            return Arc::clone(existing);
3343        }
3344        let stack = Arc::new(Mutex::new(fresh));
3345        // Catch up on already-discovered peers (discovery may have already
3346        // seen SPDP beacons before the plugin was activated).
3347        if let Ok(cache) = self.discovered.lock() {
3348            if let Ok(mut s) = stack.lock() {
3349                for peer in cache.iter() {
3350                    s.handle_remote_endpoints(peer);
3351                }
3352            }
3353        }
3354        *slot = Some(Arc::clone(&stack));
3355        // Protected discovery (DDS-Security §8.4.2.4): if the governance demands
3356        // `discovery_protection_kind != NONE`, the SedpStack routes secured
3357        // endpoints via the secure SEDP (DCPSPublicationsSecure/Subscriptions
3358        // Secure) instead of plaintext — the runtime send path protects their DATA/
3359        // HEARTBEAT/GAP with the participant data key. Set before the first
3360        // announce_* (endpoint creation follows the security activation).
3361        #[cfg(feature = "security")]
3362        if let Some(gate) = self.config.security.as_ref() {
3363            let protected = gate
3364                .discovery_protection()
3365                .map(|l| l != ProtectionLevel::None)
3366                .unwrap_or(false);
3367            if protected {
3368                if let Ok(mut sedp) = self.sedp.lock() {
3369                    sedp.set_discovery_protected(true);
3370                }
3371            }
3372        }
3373        stack
3374    }
3375
3376    /// Snapshot handle on the security builtin stack. `None` if
3377    /// [`enable_security_builtins`](Self::enable_security_builtins)
3378    /// has not been called yet.
3379    #[must_use]
3380    pub fn security_builtin_snapshot(&self) -> Option<Arc<Mutex<SecurityBuiltinStack>>> {
3381        self.security_builtin.lock().ok()?.as_ref().map(Arc::clone)
3382    }
3383
3384    /// `assert_liveliness()` on the `DomainParticipant` (DCPS 1.4
3385    /// §2.2.3.11 MANUAL_BY_PARTICIPANT). Sends exactly one WLP heartbeat
3386    /// with `kind = MANUAL_BY_PARTICIPANT` on the next tick;
3387    /// all readers matching this participant refresh their
3388    /// last-seen timestamp. Idempotent — multiple calls within
3389    /// one tick period result in multiple wire sends up to the
3390    /// cap (`MAX_QUEUED_PULSES = 32`).
3391    pub fn assert_liveliness(&self) {
3392        if let Ok(mut wlp) = self.wlp.lock() {
3393            wlp.assert_participant();
3394        }
3395    }
3396
3397    /// `assert_liveliness()` on a `DataWriter` (DCPS 1.4 §2.2.3.11
3398    /// MANUAL_BY_TOPIC). `topic_token` is an opaque token that
3399    /// matching readers can use to associate the pulse with a concrete
3400    /// topic. We use the ZeroDDS vendor kind (Cyclone /
3401    /// Fast-DDS ignore the vendor kind, which is spec-conformant —
3402    /// MSB-set in `kind` requests "ignore unknown" behavior).
3403    pub fn assert_writer_liveliness(&self, topic_token: Vec<u8>) {
3404        if let Ok(mut wlp) = self.wlp.lock() {
3405            wlp.assert_topic(topic_token);
3406        }
3407    }
3408
3409    /// Current WLP last-seen timestamp of a remote peer (relative
3410    /// to runtime start). `None` if the peer has not sent a WLP
3411    /// heartbeat yet.
3412    #[must_use]
3413    pub fn peer_liveliness_last_seen(&self, prefix: &GuidPrefix) -> Option<Duration> {
3414        self.wlp
3415            .lock()
3416            .ok()
3417            .and_then(|w| w.peer_state(prefix).map(|s| s.last_seen))
3418    }
3419
3420    /// Returns the [`zerodds_discovery::PeerCapabilities`] of a remote
3421    /// peer, based on its most recently received SPDP beacon.
3422    /// `None` if the peer has not been discovered via SPDP yet.
3423    #[must_use]
3424    pub fn peer_capabilities(
3425        &self,
3426        prefix: &GuidPrefix,
3427    ) -> Option<zerodds_discovery::PeerCapabilities> {
3428        self.discovered
3429            .lock()
3430            .ok()
3431            .and_then(|d| d.get(prefix).map(|p| p.data.builtin_endpoint_set))
3432            .map(zerodds_discovery::PeerCapabilities::from_bits)
3433    }
3434
3435    /// Snapshot of the currently discovered remote participants.
3436    /// Key = GUID prefix, value = last seen beacon content.
3437    #[must_use]
3438    pub fn discovered_participants(&self) -> Vec<DiscoveredParticipant> {
3439        self.discovered
3440            .lock()
3441            .map(|cache| cache.iter().cloned().collect())
3442            .unwrap_or_default()
3443    }
3444
3445    /// Wires the `BuiltinSinks` of the `DomainParticipant` into the
3446    /// discovery hot path. From this
3447    /// call on, all SPDP/SEDP receive events land as samples in
3448    /// the 4 builtin-topic readers.
3449    ///
3450    /// Called by the `DomainParticipant` constructor exactly once during
3451    /// setup.
3452    pub fn attach_builtin_sinks(&self, sinks: crate::builtin_subscriber::BuiltinSinks) {
3453        if let Ok(mut guard) = self.builtin_sinks.lock() {
3454            *guard = Some(sinks);
3455        }
3456    }
3457
3458    /// Snapshot of the currently wired BuiltinSinks (internal, for the
3459    /// hot path).
3460    pub(crate) fn builtin_sinks_snapshot(&self) -> Option<crate::builtin_subscriber::BuiltinSinks> {
3461        self.builtin_sinks.lock().ok().and_then(|g| g.clone())
3462    }
3463
3464    /// Wires the `IgnoreFilter` of the `DomainParticipant` into the
3465    /// discovery hot path. From
3466    /// this call on, SPDP/SEDP receive events are checked against the
3467    /// filter before being pushed as a builtin sample or used as an
3468    /// SEDP match source.
3469    ///
3470    /// Called by the `DomainParticipant` constructor exactly once during
3471    /// setup.
3472    pub fn attach_ignore_filter(&self, filter: crate::participant::IgnoreFilter) {
3473        if let Ok(mut guard) = self.ignore_filter.lock() {
3474            *guard = Some(filter);
3475        }
3476    }
3477
3478    /// Snapshot of the currently wired IgnoreFilter (internal, for
3479    /// the hot path).
3480    pub(crate) fn ignore_filter_snapshot(&self) -> Option<crate::participant::IgnoreFilter> {
3481        self.ignore_filter.lock().ok().and_then(|g| g.clone())
3482    }
3483
3484    /// Synchronizes the protected-discovery flag of the `SedpStack` with the
3485    /// governance (`discovery_protection_kind`). Idempotent, called before every
3486    /// `announce_*` — so the flag is set correctly regardless of the
3487    /// order in which security activation and endpoint creation ran.
3488    #[cfg(feature = "security")]
3489    fn sync_sedp_discovery_protected(&self, sedp: &mut SedpStack) {
3490        if let Some(gate) = self.config.security.as_ref() {
3491            let protected = gate
3492                .discovery_protection()
3493                .map(|l| l != ProtectionLevel::None)
3494                .unwrap_or(false);
3495            sedp.set_discovery_protected(protected);
3496        }
3497    }
3498
3499    /// Announces a local publication via SEDP. The runtime
3500    /// sends the generated datagrams immediately to all already-
3501    /// discovered remote participants.
3502    ///
3503    /// # Errors
3504    /// `WireError` if encoding fails.
3505    pub fn announce_publication(
3506        &self,
3507        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3508    ) -> Result<()> {
3509        // In-process discovery fastpath: put it in the stash so a
3510        // peer runtime starting later in the same process can pull us
3511        // via `inproc_snapshot`.
3512        if let Ok(mut v) = self.announced_pubs.lock() {
3513            v.push(data.clone());
3514        }
3515        // ADR-0006: side-map lookup. If the local user writer has a
3516        // same-host backend attached (set_shm_locator was
3517        // called), we inject PID_SHM_LOCATOR into the SEDP
3518        // sample. Otherwise pure 1:1 spec wire.
3519        let shm = self.shm_locator(data.key.entity_id);
3520        let datagrams = {
3521            let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3522                reason: "sedp poisoned",
3523            })?;
3524            // Protected discovery (§8.4.2.4): set robustly before the announce —
3525            // independent of the order of enable_security_builtins vs.
3526            // endpoint creation. `discovery_protection_kind != NONE` routes
3527            // the announce into the secure SEDP writer.
3528            #[cfg(feature = "security")]
3529            self.sync_sedp_discovery_protected(&mut sedp);
3530            let res = if let Some(ref bytes) = shm {
3531                sedp.announce_publication_with_shm_locator(data, bytes)
3532            } else {
3533                sedp.announce_publication(data)
3534            };
3535            res.map_err(|_| DdsError::WireError {
3536                message: alloc::string::String::from("sedp announce_publication"),
3537            })?
3538        };
3539        // Send outside the lock (Rc<Vec<Locator>> is !Send,
3540        // but we are on the same thread as `self` — no
3541        // problem).
3542        for dg in datagrams {
3543            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3544                for t in dg.targets.iter() {
3545                    if is_routable_user_locator(t) {
3546                        // §8.3.7: unicast metatraffic (SEDP DATA to the remote
3547                        // metatraffic_unicast_locator) MUST go out from the metatraffic
3548                        // recv socket `spdp_unicast`, NOT from the ephemeral
3549                        // `spdp_mc_tx` — otherwise the peer sees a foreign
3550                        // source port and sends its reliable ACKNACK/resends
3551                        // to a dead port (cross-vendor SEDP stall). Identical
3552                        // to `send_discovery_datagram`.
3553                        let _ = self.spdp_unicast.send(t, &secured);
3554                    }
3555                }
3556            }
3557        }
3558        // In-process discovery fastpath: serve same-process+domain peers
3559        // synchronously + losslessly with this publication.
3560        self.inproc_announce_publication(data);
3561        Ok(())
3562    }
3563
3564    /// Announces a local subscription via SEDP. Analogous to
3565    /// `announce_publication`.
3566    ///
3567    /// # Errors
3568    /// `WireError` if encoding fails.
3569    pub fn announce_subscription(
3570        &self,
3571        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3572    ) -> Result<()> {
3573        if let Ok(mut v) = self.announced_subs.lock() {
3574            v.push(data.clone());
3575        }
3576        let datagrams = {
3577            let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3578                reason: "sedp poisoned",
3579            })?;
3580            #[cfg(feature = "security")]
3581            self.sync_sedp_discovery_protected(&mut sedp);
3582            sedp.announce_subscription(data)
3583                .map_err(|_| DdsError::WireError {
3584                    message: alloc::string::String::from("sedp announce_subscription"),
3585                })?
3586        };
3587        for dg in datagrams {
3588            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3589                for t in dg.targets.iter() {
3590                    if is_routable_user_locator(t) {
3591                        // Source port: metatraffic recv socket, not spdp_mc_tx
3592                        // (see announce_publication / send_discovery_datagram).
3593                        let _ = self.spdp_unicast.send(t, &secured);
3594                    }
3595                }
3596            }
3597        }
3598        // In-process discovery fastpath: see `announce_publication`.
3599        self.inproc_announce_subscription(data);
3600        Ok(())
3601    }
3602
3603    /// Re-announces the local SEDP endpoint records (publications +
3604    /// subscriptions) to a peer whose crypto-token exchange has just
3605    /// completed. Background: under `rtps_protection`/`discovery_
3606    /// protection` ZeroDDS wraps the SEDP message-/submessage-protected; the
3607    /// peer discards the initial SEDP burst UNTIL it has our participant crypto
3608    /// token (via Volatile). From that moment it can decode — a
3609    /// one-time re-announce brings the previously dropped SEDP up (mints fresh
3610    /// SNs; the reliable SEDP writer delivers them, HEARTBEAT/NACK retry covers a
3611    /// not-quite-ready peer timing). Once per peer (dedup).
3612    ///
3613    /// No-op without active rtps_/discovery_protection (then the announce
3614    /// went through plaintext anyway) and for already re-announced peers. Emits
3615    /// the RETAINED records directly (NO additional `announced_pubs` push).
3616    #[cfg(feature = "security")]
3617    fn re_announce_sedp_to_peer(&self, peer_prefix: GuidPrefix) {
3618        let Some(gate) = &self.config.security else {
3619            return;
3620        };
3621        let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3622        let disc =
3623            gate.discovery_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3624        if !rtps && !disc {
3625            return;
3626        }
3627        // First check whether we have any local endpoints at all — the token
3628        // exchange can complete BEFORE the user endpoint creation.
3629        // Without records do NOT mark as "re-announced" (the periodic tick
3630        // retriggers as soon as the user writer/reader is announced).
3631        let pubs = self
3632            .announced_pubs
3633            .lock()
3634            .map(|v| v.clone())
3635            .unwrap_or_default();
3636        let subs = self
3637            .announced_subs
3638            .lock()
3639            .map(|v| v.clone())
3640            .unwrap_or_default();
3641        if pubs.is_empty() && subs.is_empty() {
3642            return;
3643        }
3644        {
3645            let mut set = match self.sedp_reannounced.write() {
3646                Ok(s) => s,
3647                Err(_) => return,
3648            };
3649            if !set.insert(peer_prefix.0) {
3650                return; // already re-announced
3651            }
3652        }
3653        let send_dgs = |dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>| {
3654            for dg in dgs {
3655                if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3656                    for t in dg.targets.iter() {
3657                        if is_routable_user_locator(t) {
3658                            let _ = self.spdp_unicast.send(t, &secured);
3659                        }
3660                    }
3661                }
3662            }
3663        };
3664        for data in &pubs {
3665            let shm = self.shm_locator(data.key.entity_id);
3666            let dgs = {
3667                let Ok(mut sedp) = self.sedp.lock() else {
3668                    continue;
3669                };
3670                self.sync_sedp_discovery_protected(&mut sedp);
3671                let res = if let Some(ref bytes) = shm {
3672                    sedp.announce_publication_with_shm_locator(data, bytes)
3673                } else {
3674                    sedp.announce_publication(data)
3675                };
3676                match res {
3677                    Ok(d) => d,
3678                    Err(_) => continue,
3679                }
3680            };
3681            send_dgs(dgs);
3682        }
3683        for data in &subs {
3684            let dgs = {
3685                let Ok(mut sedp) = self.sedp.lock() else {
3686                    continue;
3687                };
3688                self.sync_sedp_discovery_protected(&mut sedp);
3689                match sedp.announce_subscription(data) {
3690                    Ok(d) => d,
3691                    Err(_) => continue,
3692                }
3693            };
3694            send_dgs(dgs);
3695        }
3696    }
3697
3698    /// Own participant data as a `DiscoveredParticipant` — the
3699    /// self-view that the in-process fastpath hands to peers.
3700    fn self_as_discovered_participant(&self) -> zerodds_discovery::spdp::DiscoveredParticipant {
3701        // From the LIVE SPDP beacon: after `enable_security_builtins_with_auth`
3702        // it carries the `identity_token` + the secure endpoint bits that the
3703        // `participant_data` construction snapshot does NOT have. Without these the
3704        // in-process injected DP is worthless for the security handshake trigger
3705        // (`handle_remote_endpoints`/`begin_handshake_with` need
3706        // the token). Fallback to `participant_data` on lock poisoning.
3707        let data = self
3708            .spdp_beacon
3709            .lock()
3710            .map(|b| b.data.clone())
3711            .unwrap_or_else(|_| self.participant_data.clone());
3712        zerodds_discovery::spdp::DiscoveredParticipant {
3713            sender_prefix: self.guid_prefix,
3714            sender_vendor: VendorId::ZERODDS,
3715            data,
3716        }
3717    }
3718
3719    /// In-process discovery: injects the just-announced publication
3720    /// synchronously into all same-process+domain peer runtimes.
3721    fn inproc_announce_publication(
3722        &self,
3723        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3724    ) {
3725        let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
3726        let mut dp = None;
3727        for peer in peers {
3728            if peer.guid_prefix == self.guid_prefix {
3729                continue;
3730            }
3731            let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
3732            peer.inproc_inject_publication(dp, data);
3733        }
3734    }
3735
3736    /// In-process discovery: injects the just-announced subscription
3737    /// synchronously into all same-process+domain peer runtimes.
3738    fn inproc_announce_subscription(
3739        &self,
3740        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3741    ) {
3742        let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
3743        let mut dp = None;
3744        for peer in peers {
3745            if peer.guid_prefix == self.guid_prefix {
3746                continue;
3747            }
3748            let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
3749            peer.inproc_inject_subscription(dp, data);
3750        }
3751    }
3752
3753    /// In-process discovery (receive side): wires the remote
3754    /// participant + injects the publication into the SEDP cache and
3755    /// matches the local readers. Idempotent — an announcement arriving
3756    /// later via UDP is thereby a no-op.
3757    fn inproc_inject_publication(
3758        self: &Arc<Self>,
3759        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
3760        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3761    ) {
3762        // §2.2.2.2.1.17: an ignored publication/participant must not be matched.
3763        // The in-process fastpath bypasses the wire match path, so the ignore
3764        // filter must be honored here too — otherwise the Durability-Service's
3765        // own two participants (ingest + replay, same process) would match and
3766        // echo-loop despite mutually ignoring each other.
3767        if let Some(filter) = self.ignore_filter_snapshot() {
3768            let pub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
3769            let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
3770            if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
3771                return;
3772            }
3773        }
3774        let now = self.start_instant.elapsed();
3775        let is_new = self
3776            .discovered
3777            .lock()
3778            .map(|mut c| c.insert(dp.clone()))
3779            .unwrap_or(false);
3780        if let Ok(mut sedp) = self.sedp.lock() {
3781            if is_new {
3782                sedp.on_participant_discovered(dp);
3783            }
3784            sedp.cache_mut().insert_publication(data.clone(), now);
3785        }
3786        run_matching_pass(self);
3787    }
3788
3789    /// In-process discovery (receive side): like `inproc_inject_publication`
3790    /// for a subscription.
3791    fn inproc_inject_subscription(
3792        self: &Arc<Self>,
3793        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
3794        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3795    ) {
3796        // See `inproc_inject_publication`: honor the ignore filter on the
3797        // in-process fastpath (symmetric, subscription side).
3798        if let Some(filter) = self.ignore_filter_snapshot() {
3799            let sub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
3800            let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
3801            if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
3802                return;
3803            }
3804        }
3805        let now = self.start_instant.elapsed();
3806        let is_new = self
3807            .discovered
3808            .lock()
3809            .map(|mut c| c.insert(dp.clone()))
3810            .unwrap_or(false);
3811        if let Ok(mut sedp) = self.sedp.lock() {
3812            if is_new {
3813                sedp.on_participant_discovered(dp);
3814            }
3815            sedp.cache_mut().insert_subscription(data.clone(), now);
3816        }
3817        run_matching_pass(self);
3818    }
3819
3820    /// Snapshot of our own endpoints for the `pull-on-creation` path
3821    /// of a peer runtime starting later in the same process.
3822    fn inproc_snapshot(
3823        &self,
3824    ) -> (
3825        zerodds_discovery::spdp::DiscoveredParticipant,
3826        Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>,
3827        Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>,
3828    ) {
3829        let dp = self.self_as_discovered_participant();
3830        let pubs = self
3831            .announced_pubs
3832            .lock()
3833            .map(|v| v.clone())
3834            .unwrap_or_default();
3835        let subs = self
3836            .announced_subs
3837            .lock()
3838            .map(|v| v.clone())
3839            .unwrap_or_default();
3840        (dp, pubs, subs)
3841    }
3842
3843    /// At runtime creation: ask existing same-process+domain peers
3844    /// for their already-announced endpoints and inject these into
3845    /// our SEDP cache. Symmetric counterpart to the
3846    /// announce hook (which distributes live endpoints to peers).
3847    fn inproc_pull_from_peers(self: &Arc<Self>) {
3848        let peers: Vec<Arc<DcpsRuntime>> =
3849            crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
3850                .into_iter()
3851                .filter(|rt| rt.guid_prefix != self.guid_prefix)
3852                .collect();
3853        for peer in peers {
3854            let (dp, pubs, subs) = peer.inproc_snapshot();
3855            for p in &pubs {
3856                self.inproc_inject_publication(&dp, p);
3857            }
3858            for s in &subs {
3859                self.inproc_inject_subscription(&dp, s);
3860            }
3861        }
3862    }
3863
3864    /// FU2 S3: in-process counterpart to the security part of
3865    /// [`handle_spdp_datagram`]. Wires the secure builtin endpoints of the
3866    /// discovered peer and kicks off — if it announces an `identity_token`
3867    /// — the auth handshake; the resulting AUTH datagrams
3868    /// go to the peer via UDP **unicast** (reliable loopback).
3869    /// No-op without a local security stack or without a peer `identity_token`.
3870    #[cfg(feature = "security")]
3871    fn inproc_drive_security_handshake(
3872        self: &Arc<Self>,
3873        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
3874    ) {
3875        if dp.sender_prefix == self.guid_prefix {
3876            return;
3877        }
3878        let Some(sec) = self.security_builtin_snapshot() else {
3879            return;
3880        };
3881        let dgs = if let Ok(mut s) = sec.lock() {
3882            s.note_remote_vendor(dp.sender_prefix, dp.sender_vendor);
3883            s.handle_remote_endpoints(dp);
3884            match dp.data.identity_token.as_ref() {
3885                Some(token) => s
3886                    .begin_handshake_with(dp.sender_prefix, dp.data.guid.to_bytes(), token)
3887                    .unwrap_or_default(),
3888                None => Vec::new(),
3889            }
3890        } else {
3891            Vec::new()
3892        };
3893        for dg in dgs {
3894            send_discovery_datagram(self, &dg.targets, &dg.bytes);
3895        }
3896    }
3897
3898    /// FU2 S3: in-process SPDP **participant** discovery. This was the real
3899    /// gap — `inproc_inject_publication`/`_subscription` only inject
3900    /// SEDP endpoints, the SPDP participant level (identity_token +
3901    /// `begin_handshake_with`) ran EXCLUSIVELY over the multicast path
3902    /// that is flaky on the codepit LXC. This hook, on activation of the
3903    /// security builtins, exchanges the participant DPs (with token) **bidirectionally** with
3904    /// all same-process+domain peers and kicks off the auth handshakes
3905    /// — deterministically, without a single multicast beacon.
3906    #[cfg(feature = "security")]
3907    fn inproc_announce_participant(self: &Arc<Self>) {
3908        let self_dp = self.self_as_discovered_participant();
3909        let peers: Vec<Arc<DcpsRuntime>> =
3910            crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
3911                .into_iter()
3912                .filter(|rt| rt.guid_prefix != self.guid_prefix)
3913                .collect();
3914        for peer in peers {
3915            // self → peer: the peer discovers US + triggers its handshake.
3916            let _ = peer
3917                .discovered
3918                .lock()
3919                .map(|mut c| c.insert(self_dp.clone()));
3920            peer.inproc_drive_security_handshake(&self_dp);
3921            // peer → self: WE discover the peer + trigger our handshake.
3922            let peer_dp = peer.self_as_discovered_participant();
3923            let _ = self
3924                .discovered
3925                .lock()
3926                .map(|mut c| c.insert(peer_dp.clone()));
3927            self.inproc_drive_security_handshake(&peer_dp);
3928        }
3929    }
3930
3931    /// C1 multicast-free discovery: sends a (possibly already security-
3932    /// transformed) SPDP beacon additionally to all configured
3933    /// unicast initial peers. No-op without peers → pure multicast behavior,
3934    /// no additional syscalls by default.
3935    fn send_spdp_to_initial_peers(&self, bytes: &[u8]) {
3936        for peer in &self.config.initial_peers {
3937            let _ = self.spdp_mc_tx.send(peer, bytes);
3938        }
3939    }
3940
3941    /// FU2 S3: sends an SPDP beacon IMMEDIATELY via multicast, instead of waiting
3942    /// for the next periodic `spdp_period` tick. Critical for the
3943    /// cross-process secured handshake: `DcpsRuntime::start` starts the
3944    /// beacon sender, whose first beacon (token-LESS) goes out BEFORE
3945    /// `enable_security_builtins_with_auth` sets the `identity_token` on the beacon.
3946    /// If a peer latches this token-less first beacon, it calls
3947    /// `begin_handshake_with` with `token=None` → no-op → the handshake NEVER
3948    /// starts. An immediate re-announce after setting the token ensures
3949    /// that the first token-carrying beacon goes out promptly.
3950    #[cfg(feature = "security")]
3951    fn announce_spdp_now(&self) {
3952        let mc_target = Locator {
3953            kind: LocatorKind::UdpV4,
3954            port: u32::from(
3955                u16::try_from(spdp_multicast_port(self.domain_id as u32)).unwrap_or(7400),
3956            ),
3957            address: {
3958                let mut a = [0u8; 16];
3959                a[12..].copy_from_slice(&self.config.spdp_multicast_group.octets());
3960                a
3961            },
3962        };
3963        if let Ok(mut beacon) = self.spdp_beacon.lock() {
3964            if let Ok(datagram) = beacon.serialize() {
3965                if let Some(secured) = secure_outbound_bytes(self, &datagram) {
3966                    let _ = self.spdp_mc_tx.send(&mc_target, &secured);
3967                    // C1 multicast-free discovery: on the immediate announce too, to
3968                    // the configured initial peers (ZERODDS_PEERS).
3969                    self.send_spdp_to_initial_peers(&secured);
3970                    // Directed unicast fan-out to already-discovered peers:
3971                    // covers the order in which we discover a peer
3972                    // BEFORE our security builtins (token) are active — then the
3973                    // directed response in handle_spdp_datagram skipped tokenless;
3974                    // announce_spdp_now() (called by enable() after the token set)
3975                    // catches up with the tokened beacon promptly + LXC-multicast-
3976                    // independently. Otherwise the peer waits until spdp_period.
3977                    for loc in wlp_unicast_targets(&self.discovered_participants()) {
3978                        let _ = self.spdp_unicast.send(&loc, &secured);
3979                    }
3980                }
3981            }
3982            // FastDDS interop: additionally announce on the reliable secure SPDP
3983            // writer (0xff0101c2), so FastDDS sees our full secured
3984            // participant data over its expected channel.
3985            if self.config.enable_secure_spdp {
3986                if let Ok(datagram) = beacon.serialize_secure() {
3987                    let protected = protect_secure_spdp(self, &datagram).unwrap_or(datagram);
3988                    if let Some(secured) = secure_outbound_bytes(self, &protected) {
3989                        let _ = self.spdp_mc_tx.send(&mc_target, &secured);
3990                    }
3991                }
3992            }
3993        }
3994    }
3995
3996    /// FU2 cross-vendor: `EndpointSecurityInfo` (PID_ENDPOINT_SECURITY_INFO,
3997    /// 0x1004) for user endpoints, derived from the governance
3998    /// `data_protection`. Foreign vendors (cyclone/FastDDS) reject, with
3999    /// `data_protection=ENCRYPT`, a user endpoint WITHOUT this PID as
4000    /// non-secure ("Non secure remote ... not allowed by security").
4001    /// `None` without an active security gate (plain).
4002    #[cfg(feature = "security")]
4003    fn user_endpoint_security_info(
4004        &self,
4005    ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4006        let gate = self.config.security.as_ref()?;
4007        let meta = gate.metadata_protection().ok()?;
4008        let data = gate.data_protection().ok()?;
4009        let disc = gate.topic_discovery_protected().unwrap_or(false);
4010        let liv = gate
4011            .liveliness_protection()
4012            .map(|l| l != ProtectionLevel::None)
4013            .unwrap_or(false);
4014        let rdp = gate.topic_read_protected().unwrap_or(false);
4015        let wrp = gate.topic_write_protected().unwrap_or(false);
4016        Some(compute_user_endpoint_attrs(meta, data, disc, liv, rdp, wrp))
4017    }
4018
4019    #[cfg(not(feature = "security"))]
4020    fn user_endpoint_security_info(
4021        &self,
4022    ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4023        None
4024    }
4025
4026    /// Registers a local user writer. The caller gets the
4027    /// writer `EntityId`; for sends via `write_user_sample(eid, ...)`.
4028    ///
4029    /// In the runtime there is **still no** automatic SEDP announce +
4030    /// matching — that comes in B4b. Currently `register_user_writer`
4031    /// is just the wiring.
4032    ///
4033    /// # Errors
4034    /// `PreconditionNotMet` if the registry mutex is poisoned.
4035    pub fn register_user_writer(&self, cfg: UserWriterConfig) -> Result<EntityId> {
4036        // Default: WithKey. Backward-compat for all test callers.
4037        self.register_user_writer_kind(cfg, true)
4038    }
4039
4040    /// Like [`register_user_writer`] but with an explicit NoKey/WithKey
4041    /// flag. Cross-vendor interop needs it: if the IDL type has no
4042    /// `@key`, the writer MUST set `is_keyed=false`, otherwise
4043    /// a remote reader rejects the DATA submessage due to an
4044    /// entityKind mismatch (Spec §9.3.1.2 table 9.1: 0x02=WithKey
4045    /// vs 0x03=NoKey).
4046    pub fn register_user_writer_kind(
4047        &self,
4048        cfg: UserWriterConfig,
4049        is_keyed: bool,
4050    ) -> Result<EntityId> {
4051        let now = self.start_instant.elapsed();
4052        let key = self.next_entity_key();
4053        let eid = if is_keyed {
4054            EntityId::user_writer_with_key(key)
4055        } else {
4056            EntityId::user_writer_no_key(key)
4057        };
4058        let writer = ReliableWriter::new(ReliableWriterConfig {
4059            guid: Guid::new(self.guid_prefix, eid),
4060            vendor_id: VendorId::ZERODDS,
4061            reader_proxies: Vec::new(),
4062            max_samples: 1024,
4063            history_kind: HistoryKind::KeepLast { depth: 32 },
4064            heartbeat_period: DEFAULT_HEARTBEAT_PERIOD,
4065            // Ethernet-safe default; the value is raised at the reader match
4066            // if all readers are same-host (see the
4067            // set_fragmentation call after add_reader_proxy).
4068            fragment_size: DEFAULT_FRAGMENT_SIZE,
4069            mtu: DEFAULT_MTU,
4070        });
4071        let mut pub_data = build_publication_data(
4072            self.guid_prefix,
4073            eid,
4074            &cfg,
4075            &self.config.data_representation_offer,
4076            self.user_announce_locator,
4077        );
4078        // FU2 cross-vendor: EndpointSecurityInfo from the governance
4079        // data_protection — otherwise cyclone/FastDDS reject the user endpoint
4080        // with data_protection=ENCRYPT as non-secure.
4081        pub_data.security_info = self.user_endpoint_security_info();
4082        self.user_writers
4083            .write()
4084            .map_err(|_| DdsError::PreconditionNotMet {
4085                reason: "user_writers poisoned",
4086            })?
4087            .insert(
4088                eid,
4089                Arc::new(Mutex::new(UserWriterSlot {
4090                    writer,
4091                    topic_name: cfg.topic_name.clone(),
4092                    type_name: cfg.type_name.clone(),
4093                    reliable: cfg.reliable,
4094                    durability: cfg.durability,
4095                    deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4096                    // Initial `None`: the deadline window starts only on the
4097                    // first real write. Prevents false misses due to
4098                    // slow entity setup (e.g. Linux CI container)
4099                    // before the app does its first write(). On the
4100                    // first write() `last_write = Some(now)` is set,
4101                    // and from then the deadline counter ticks.
4102                    last_write: None,
4103                    offered_deadline_missed_count: 0,
4104                    liveliness_lost_count: 0,
4105                    last_liveliness_assert: Some(now),
4106                    offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus::default(
4107                    ),
4108                    lifespan_nanos: qos_duration_to_nanos(cfg.lifespan.duration),
4109                    sample_insert_times: alloc::collections::VecDeque::new(),
4110                    liveliness_kind: cfg.liveliness.kind,
4111                    liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4112                    ownership: cfg.ownership,
4113                    ownership_strength: cfg.ownership_strength,
4114                    partition: cfg.partition.clone(),
4115                    #[cfg(feature = "security")]
4116                    reader_protection: BTreeMap::new(),
4117                    #[cfg(feature = "security")]
4118                    locator_to_peer: BTreeMap::new(),
4119                    type_identifier: cfg.type_identifier.clone(),
4120                    data_rep_offer_override: cfg.data_representation_offer.clone(),
4121                    // Default FINAL: irrelevant for XCDR1 (default offer)
4122                    // (final==appendable==CDR_LE), correct for XCDR2 for
4123                    // @final types. Appendable/mutable types set this later via
4124                    // set_user_writer_wire_extensibility.
4125                    wire_extensibility: zerodds_types::qos::ExtensibilityForRepr::Final,
4126                    durability_backend: None,
4127                    backend_primed: false,
4128                })),
4129            );
4130        // FIRST match locally, THEN announce — symmetric to
4131        // register_user_reader_kind. Avoids a peer-side match
4132        // triggered by our announce_publication
4133        // starting a data flow to us before we have wired the
4134        // ReaderProxies.
4135        self.match_local_writer_against_cache(eid);
4136        let _ = self.announce_publication(&pub_data);
4137        // Intra-runtime routing: scan local readers for a match on
4138        // (topic, type). Applies to bridge daemons with writer+reader in
4139        // the same runtime (WS/MQTT/CoAP/AMQP bridges). Without this
4140        // route the local reader gets no samples from the local
4141        // writer — the `inproc` fastpath explicitly skips self, UDP loopback
4142        // is not guaranteed, and SEDP match paths go via
4143        // the discovered cache, which does not contain self.
4144        self.recompute_intra_runtime_routes();
4145        // FU2 F-ECHO-WRITE: a user writer created AFTER handshake completion
4146        // (e.g. the event-driven echo writer in the responder/pong) must send its
4147        // per-endpoint datawriter_crypto_tokens IMMEDIATELY to the already-
4148        // authenticated peers — not only on the next tick. Otherwise
4149        // cyclone's reader stays in "waiting for approval by security" beyond
4150        // its match deadline (the event-driven pong may not tick
4151        // in time) → flaky sub=0. Idempotent via endpoint_tokens_sent dedup.
4152        #[cfg(feature = "security")]
4153        self.flush_late_endpoint_tokens();
4154        // Observability event.
4155        self.config.observability.record(
4156            &zerodds_foundation::observability::Event::new(
4157                zerodds_foundation::observability::Level::Info,
4158                zerodds_foundation::observability::Component::Dcps,
4159                "user_writer.created",
4160            )
4161            .with_attr("topic", cfg.topic_name.as_str())
4162            .with_attr("type", cfg.type_name.as_str())
4163            .with_attr("reliable", if cfg.reliable { "true" } else { "false" }),
4164        );
4165        Ok(eid)
4166    }
4167
4168    /// FU2 F-ECHO-WRITE: sends pending per-endpoint crypto tokens IMMEDIATELY to all
4169    /// already-authenticated peers. For user endpoints created AFTER handshake
4170    /// completion (event-driven echo writer in the responder): their token
4171    /// must go out before cyclone's reader match deadline expires — the periodic
4172    /// tick (or a VolatileSecure recv) is otherwise possibly too late. Idempotent
4173    /// via `endpoint_tokens_sent` dedup (double-send with the tick excluded).
4174    #[cfg(feature = "security")]
4175    fn flush_late_endpoint_tokens(&self) {
4176        let Some(stack) = self.security_builtin_snapshot() else {
4177            return;
4178        };
4179        let Ok(mut s) = stack.lock() else {
4180            return;
4181        };
4182        let now = self.start_instant.elapsed();
4183        let peers: alloc::vec::Vec<GuidPrefix> = self
4184            .config
4185            .security
4186            .as_ref()
4187            .map(|g| {
4188                g.authenticated_peer_prefixes()
4189                    .into_iter()
4190                    .map(GuidPrefix::from_bytes)
4191                    .collect()
4192            })
4193            .unwrap_or_default();
4194        for prefix in peers {
4195            let already = self
4196                .endpoint_tokens_sent
4197                .read()
4198                .map(|set| set.clone())
4199                .unwrap_or_default();
4200            let pending =
4201                pending_endpoint_tokens(prepare_endpoint_crypto_tokens(self, prefix), &already);
4202            for ep_msg in pending {
4203                let key = endpoint_token_key(&ep_msg);
4204                let dgs = protect_volatile_outbound(
4205                    self,
4206                    prefix,
4207                    s.volatile_writer
4208                        .write_with_heartbeat(&ep_msg, now)
4209                        .unwrap_or_default(),
4210                );
4211                for dg in dgs {
4212                    for t in dg.targets.iter() {
4213                        let _ = self.spdp_unicast.send(t, &dg.bytes);
4214                    }
4215                }
4216                if let Ok(mut set) = self.endpoint_tokens_sent.write() {
4217                    set.insert(key);
4218                }
4219            }
4220            // Periodic re-announce retrigger: as soon as the user writer/reader
4221            // is announced (announced_pubs/subs not empty), this catches up the
4222            // SEDP initially dropped under rtps_/discovery_protection to this
4223            // (now tokened) peer. Once per peer (dedup in the method).
4224            self.re_announce_sedp_to_peer(prefix);
4225        }
4226    }
4227
4228    /// Spec §2.2.3.5 — registers a durability-service backend on
4229    /// a writer already registered via [`register_user_writer`].
4230    /// With Durability=Transient/Persistent the backend is replayed into the
4231    /// HistoryCache on the first late-joiner match in
4232    /// `wire_writer_to_remote_reader`, so the reader gets all samples —
4233    /// including those no longer in the writer cache due to history eviction
4234    /// or those that have survived a writer restart.
4235    pub fn attach_durability_backend(
4236        &self,
4237        eid: EntityId,
4238        backend: alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>,
4239    ) -> Result<()> {
4240        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4241            what: "attach_durability_backend: unknown writer entity id",
4242        })?;
4243        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4244            reason: "user_writer slot poisoned",
4245        })?;
4246        slot.durability_backend = Some(backend);
4247        slot.backend_primed = false;
4248        Ok(())
4249    }
4250
4251    /// Sets the type extensibility of a writer (FINAL/APPENDABLE/
4252    /// MUTABLE). Affects exclusively the encapsulation header
4253    /// of the user payload (see [`user_payload_encap`]) — relevant for
4254    /// XCDR2 wire, where @appendable requires a `D_CDR2_LE` and @mutable a
4255    /// `PL_CDR2_LE` header. The codegen/FFI calls this after
4256    /// `register_user_writer*` when the type is not @final.
4257    /// Does NOT change the SEDP announce offer list.
4258    ///
4259    /// # Errors
4260    /// `BadParameter` on an unknown EntityId, `PreconditionNotMet` on a
4261    /// poisoned slot mutex.
4262    pub fn set_user_writer_wire_extensibility(
4263        &self,
4264        eid: EntityId,
4265        ext: zerodds_types::qos::ExtensibilityForRepr,
4266    ) -> Result<()> {
4267        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4268            what: "set_user_writer_wire_extensibility: unknown writer entity id",
4269        })?;
4270        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4271            reason: "user_writer slot poisoned",
4272        })?;
4273        slot.wire_extensibility = ext;
4274        Ok(())
4275    }
4276
4277    /// Registers a local user reader. Returns the reader EntityId
4278    /// and an `mpsc::Receiver` through which DataReader handles
4279    /// consume incoming samples.
4280    ///
4281    /// # Errors
4282    /// `PreconditionNotMet` if the registry mutex is poisoned.
4283    /// Registers a user reader. Returns the EntityId and an
4284    /// `mpsc::Receiver<UserSample>` — alive samples deliver payload,
4285    /// lifecycle markers carry key hash + ChangeKind.
4286    pub fn register_user_reader(
4287        &self,
4288        cfg: UserReaderConfig,
4289    ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4290        // Default: WithKey. Backward-compat for all test callers.
4291        self.register_user_reader_kind(cfg, true)
4292    }
4293
4294    /// Like [`register_user_reader`] but with an explicit NoKey/WithKey
4295    /// flag. Symmetric to [`register_user_writer_kind`] — the reader kind
4296    /// must match the writer kind.
4297    pub fn register_user_reader_kind(
4298        &self,
4299        cfg: UserReaderConfig,
4300        is_keyed: bool,
4301    ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4302        let now = self.start_instant.elapsed();
4303        let key = self.next_entity_key();
4304        let eid = if is_keyed {
4305            EntityId::user_reader_with_key(key)
4306        } else {
4307            EntityId::user_reader_no_key(key)
4308        };
4309        let reader = ReliableReader::new(ReliableReaderConfig {
4310            guid: Guid::new(self.guid_prefix, eid),
4311            vendor_id: VendorId::ZERODDS,
4312            writer_proxies: Vec::new(),
4313            max_samples_per_proxy: 256,
4314            // D.5e: 0ms = synchronous ACK response (Cyclone parity).
4315            // Previously 200ms = pre-1.0 default without spec justification.
4316            heartbeat_response_delay:
4317                zerodds_rtps::reliable_reader::DEFAULT_HEARTBEAT_RESPONSE_DELAY,
4318            // C3: ROS-realistic reassembly cap (PointCloud2/Image),
4319            // instead of the conservative rtps 1-MiB default.
4320            assembler_caps: AssemblerCaps {
4321                max_sample_bytes: self.config.max_reassembly_sample_bytes,
4322                ..AssemblerCaps::default()
4323            },
4324        });
4325        let (tx, rx) = mpsc::channel();
4326        let mut sub_data = build_subscription_data(
4327            self.guid_prefix,
4328            eid,
4329            &cfg,
4330            &self.config.data_representation_offer,
4331            self.user_announce_locator,
4332        );
4333        // FU2 cross-vendor: EndpointSecurityInfo from the governance (see writer).
4334        sub_data.security_info = self.user_endpoint_security_info();
4335        self.user_readers
4336            .write()
4337            .map_err(|_| DdsError::PreconditionNotMet {
4338                reason: "user_readers poisoned",
4339            })?
4340            .insert(
4341                eid,
4342                Arc::new(Mutex::new(UserReaderSlot {
4343                    reader,
4344                    topic_name: cfg.topic_name.clone(),
4345                    type_name: cfg.type_name.clone(),
4346                    sample_tx: tx,
4347                    async_waker: Arc::new(std::sync::Mutex::new(None)),
4348                    listener: None,
4349                    durability: cfg.durability,
4350                    deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4351                    // Start time as reference (see register_user_writer).
4352                    last_sample_received: Some(now),
4353                    requested_deadline_missed_count: 0,
4354                    requested_incompatible_qos:
4355                        crate::status::RequestedIncompatibleQosStatus::default(),
4356                    sample_lost_count: 0,
4357                    sample_rejected: crate::status::SampleRejectedStatus::default(),
4358                    samples_delivered_count: 0,
4359                    liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4360                    liveliness_kind: cfg.liveliness.kind,
4361                    liveliness_alive_count: 0,
4362                    liveliness_not_alive_count: 0,
4363                    // Optimistic init: we see the writer via SEDP,
4364                    // until the lease expires it counts as alive.
4365                    liveliness_alive: true,
4366                    ownership: cfg.ownership,
4367                    partition: cfg.partition.clone(),
4368                    writer_strengths: alloc::collections::BTreeMap::new(),
4369                    type_identifier: cfg.type_identifier.clone(),
4370                    type_consistency: cfg.type_consistency,
4371                })),
4372            );
4373        // FIRST match locally (create the writer proxy on the reader),
4374        // THEN announce. Otherwise our announce_subscription triggers a
4375        // backend replay at the peer via the in-process fastpath
4376        // (Spec §2.2.3.5), which injects DATA into *our* reader
4377        // before we have wired the matching WriterProxies — the
4378        // samples are then discarded as unknown-source
4379        // (tests `{transient,persistent}_late_joiner_receives_backend_replay`).
4380        self.match_local_reader_against_cache(eid);
4381        let _ = self.announce_subscription(&sub_data);
4382        // Intra-runtime routing: see `register_user_writer_kind`.
4383        self.recompute_intra_runtime_routes();
4384        // Observability event.
4385        self.config.observability.record(
4386            &zerodds_foundation::observability::Event::new(
4387                zerodds_foundation::observability::Level::Info,
4388                zerodds_foundation::observability::Component::Dcps,
4389                "user_reader.created",
4390            )
4391            .with_attr("topic", cfg.topic_name.as_str())
4392            .with_attr("type", cfg.type_name.as_str()),
4393        );
4394        Ok((eid, rx))
4395    }
4396
4397    /// Rebuilds the same-runtime writer→reader routing table.
4398    /// Called in `register_user_writer_kind` and `register_user_reader_kind`
4399    /// after every endpoint create. Per local writer it collects
4400    /// all local readers that have exactly the same `topic_name`
4401    /// and `type_name`. The lookup in the write hot path
4402    /// (`write_user_sample_borrowed`) is read-locked and cheap
4403    /// (BTreeMap lookup → Vec clone). On endpoint removal (TODO: not
4404    /// yet hooked everywhere) this would be called too.
4405    fn recompute_intra_runtime_routes(&self) {
4406        let writer_snap = self.writer_slots_snapshot();
4407        let reader_snap = self.reader_slots_snapshot();
4408        let mut new_map: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
4409        for (writer_eid, w_arc) in writer_snap {
4410            let (w_topic, w_type) = match w_arc.lock() {
4411                Ok(s) => (s.topic_name.clone(), s.type_name.clone()),
4412                Err(_) => continue,
4413            };
4414            let mut readers: Vec<EntityId> = Vec::new();
4415            for (reader_eid, r_arc) in &reader_snap {
4416                let matches = match r_arc.lock() {
4417                    Ok(s) => s.topic_name == w_topic && s.type_name == w_type,
4418                    Err(_) => false,
4419                };
4420                if matches {
4421                    readers.push(*reader_eid);
4422                }
4423            }
4424            if !readers.is_empty() {
4425                new_map.insert(writer_eid, readers);
4426            }
4427        }
4428        let changed = match self.intra_runtime_routes.write() {
4429            Ok(mut g) => {
4430                let changed = *g != new_map;
4431                *g = new_map;
4432                changed
4433            }
4434            Err(_) => false,
4435        };
4436        // A new/changed intra-runtime route is a same-participant
4437        // match → wake the `wait_for_matched_{subscription,publication}` waiter
4438        // (the matched count now includes these routes).
4439        if changed {
4440            self.match_event.1.notify_all();
4441        }
4442    }
4443
4444    /// Same-runtime direct dispatch: pushes the just-written
4445    /// sample directly into the `sample_tx` channel of all local readers
4446    /// on the same topic+type. Avoids an RTPS wire roundtrip + UDP
4447    /// loopback for the bridge-daemon case (writer+reader in the same
4448    /// `DcpsRuntime`). Called by the write hot path after the normal
4449    /// wire dispatch.
4450    fn intra_runtime_dispatch_alive(
4451        &self,
4452        writer_eid: EntityId,
4453        payload: &[u8],
4454        writer_strength: i32,
4455    ) {
4456        let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
4457            Ok(g) => match g.get(&writer_eid) {
4458                Some(v) => v.clone(),
4459                None => return,
4460            },
4461            Err(_) => return,
4462        };
4463        if routes.is_empty() {
4464            return;
4465        }
4466        let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
4467        for reader_eid in routes {
4468            let Some(slot_arc) = self.reader_slot(reader_eid) else {
4469                continue;
4470            };
4471            // Hold the slot lock only for the listener/sender clone, dispatch
4472            // outside (symmetric to the data-receive path above, which
4473            // preserves exactly the same order in the DATA arm).
4474            let listener;
4475            let waker;
4476            let sender;
4477            {
4478                let Ok(slot) = slot_arc.lock() else {
4479                    continue;
4480                };
4481                listener = slot.listener.clone();
4482                waker = Arc::clone(&slot.async_waker);
4483                sender = slot.sample_tx.clone();
4484            }
4485            // Listener and MPSC are exclusive (see the data-arm comment):
4486            // if a listener is set, the sample only goes to it;
4487            // otherwise to the MPSC receiver.
4488            if let Some(l) = listener {
4489                // The listener signature is `(payload: &[u8], representation: u8)`.
4490                // Intra-runtime: no encap header, `0` = native.
4491                l(payload, 0);
4492            } else {
4493                let sample = UserSample::Alive {
4494                    payload: crate::sample_bytes::SampleBytes::from_vec(payload.to_vec()),
4495                    writer_guid,
4496                    writer_strength,
4497                    representation: 0,
4498                };
4499                let _ = sender.send(sample);
4500                wake_async_waker(&waker);
4501            }
4502        }
4503    }
4504
4505    /// On registration / SEDP event: for a local writer `eid`
4506    /// go through all subscriptions known in the cache; on a topic+type
4507    /// match add a `ReaderProxy` to the local ReliableWriter.
4508    fn match_local_writer_against_cache(&self, eid: EntityId) {
4509        let (topic, type_name) = {
4510            let Some(arc) = self.writer_slot(eid) else {
4511                return;
4512            };
4513            let Ok(s) = arc.lock() else {
4514                return;
4515            };
4516            (s.topic_name.clone(), s.type_name.clone())
4517        };
4518        let (matches, conflict): (Vec<_>, bool) = {
4519            let sedp = match self.sedp.lock() {
4520                Ok(s) => s,
4521                Err(_) => return,
4522            };
4523            let matches = sedp
4524                .cache()
4525                .match_subscriptions(&topic, &type_name)
4526                .map(|s| s.data.clone())
4527                .collect();
4528            let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
4529            (matches, conflict)
4530        };
4531        if conflict {
4532            self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
4533        }
4534        for sub in matches {
4535            self.wire_writer_to_remote_reader(eid, &sub);
4536        }
4537    }
4538
4539    fn match_local_reader_against_cache(&self, eid: EntityId) {
4540        let (topic, type_name) = {
4541            let Some(arc) = self.reader_slot(eid) else {
4542                return;
4543            };
4544            let Ok(s) = arc.lock() else {
4545                return;
4546            };
4547            (s.topic_name.clone(), s.type_name.clone())
4548        };
4549        let (matches, conflict): (Vec<_>, bool) = {
4550            let sedp = match self.sedp.lock() {
4551                Ok(s) => s,
4552                Err(_) => return,
4553            };
4554            let matches = sedp
4555                .cache()
4556                .match_publications(&topic, &type_name)
4557                .map(|p| p.data.clone())
4558                .collect();
4559            let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
4560            (matches, conflict)
4561        };
4562        if conflict {
4563            self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
4564        }
4565        for pubd in matches {
4566            self.wire_reader_to_remote_writer(eid, &pubd);
4567        }
4568    }
4569
4570    fn wire_writer_to_remote_reader(
4571        &self,
4572        writer_eid: EntityId,
4573        sub: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4574    ) {
4575        // §2.2.2.2.1.16: an ignored subscription must not be MATCHED (symmetric
4576        // to the publication gate in `wire_reader_to_remote_writer`). The
4577        // Durability-Service ignores its own ingest reader here so the replay
4578        // writer never delivers back to it (echo loop).
4579        if let Some(filter) = self.ignore_filter_snapshot() {
4580            let sub_h = crate::instance_handle::InstanceHandle::from_guid(sub.key);
4581            let part_h = crate::instance_handle::InstanceHandle::from_guid(sub.participant_key);
4582            if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
4583                return;
4584            }
4585        }
4586        let locators =
4587            endpoint_or_default_locators(&sub.unicast_locators, sub.key.prefix, &self.discovered);
4588        if locators.is_empty() {
4589            return;
4590        }
4591        // Backend replay datagrams (Spec §2.2.3.5). Sent after
4592        // the slot-lock release, so the send path does not run under
4593        // the slot mutex.
4594        let mut replay_dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram> = Vec::new();
4595        if let Some(slot_arc) = self.writer_slot(writer_eid) {
4596            if let Ok(mut slot) = slot_arc.lock() {
4597                let slot = &mut *slot;
4598                // Idempotency gate: if a ReaderProxy already exists for this
4599                // remote reader, the match has already run
4600                // once. A re-wire (e.g. when the SEDP announcement
4601                // arrives at the writer both via the in-process fastpath and via UDP)
4602                // would REPLACE the proxy via
4603                // `add_reader_proxy` — and thereby reset
4604                // `highest_acked_sn`/`highest_sent_sn`.
4605                // The next tick then emits an invalid HEARTBEAT
4606                // with `first_sn > last_sn` (cache_min=N, highest_acked+1=N+1),
4607                // the reader interprets this as "everything before first_sn is
4608                // lost" and advances `delivered_up_to` past not-yet-
4609                // delivered backend replay samples (tests
4610                // `{transient,persistent}_late_joiner_receives_backend_replay`
4611                // — 3% flake without the gate).
4612                if slot
4613                    .writer
4614                    .reader_proxies()
4615                    .iter()
4616                    .any(|p| p.remote_reader_guid == sub.key)
4617                {
4618                    return;
4619                }
4620                // --- QoS-Compatibility ---
4621                // Spec OMG DDS 1.4 §2.2.3.6: Writer offered >= Reader requested.
4622                //
4623                // Per reject, bump the responsible policy ID in
4624                // `offered_incompatible_qos.policies`, so the
4625                // DataWriter listener is triggered via `dispatch_offered_incompatible_qos`.
4626                // We track the *first* faulty
4627                // policy as `last_policy_id` (Spec §2.2.4.1: most-recent).
4628                use crate::psm_constants::qos_policy_id as qid;
4629                use crate::status::bump_policy_count;
4630                // C2 "loud instead of silent": an incompatible QoS match is
4631                // not only kept as a pollable status (Spec §2.2.4.1),
4632                // but logged loudly IMMEDIATELY. The central ROS-DDS
4633                // pain point is that QoS mismatches are silently discarded
4634                // (e.g. Cyclone's `DDS_INVALID_QOS_POLICY_ID` without a
4635                // log) — exactly that made the ROS-2 entityKind diagnosis so
4636                // expensive. The reject names the topic, remote reader and
4637                // the exact policy.
4638                let obs = self.config.observability.clone();
4639                let topic_for_log = slot.topic_name.clone();
4640                let remote_for_log = alloc::format!("{:?}", sub.key);
4641                let bump = |slot: &mut UserWriterSlot, pid: u32| {
4642                    slot.offered_incompatible_qos.total_count =
4643                        slot.offered_incompatible_qos.total_count.saturating_add(1);
4644                    slot.offered_incompatible_qos.last_policy_id = pid;
4645                    bump_policy_count(&mut slot.offered_incompatible_qos.policies, pid);
4646                    obs.record(
4647                        &zerodds_foundation::observability::Event::new(
4648                            zerodds_foundation::observability::Level::Warn,
4649                            zerodds_foundation::observability::Component::Dcps,
4650                            "qos.incompatible.offered",
4651                        )
4652                        .with_attr("topic", topic_for_log.as_str())
4653                        .with_attr("remote_reader", remote_for_log.as_str())
4654                        .with_attr("policy", qos_policy_id_name(pid)),
4655                    );
4656                };
4657
4658                // Durability rank: Volatile < TransientLocal < Transient <
4659                // Persistent. The writer may offer more than the reader requests.
4660                if (slot.durability as u8) < (sub.durability as u8) {
4661                    bump(slot, qid::DURABILITY);
4662                    return;
4663                }
4664                // Deadline: writer period <= reader period (the writer promises
4665                // to write faster than the reader expects).
4666                if !deadline_compat(
4667                    slot.deadline_nanos,
4668                    qos_duration_to_nanos(sub.deadline.period),
4669                ) {
4670                    bump(slot, qid::DEADLINE);
4671                    return;
4672                }
4673                // Liveliness-Kind: Automatic < ManualByParticipant < ManualByTopic.
4674                // Writer-Kind >= Reader-Kind. Lease: writer.lease <= reader.lease.
4675                if (slot.liveliness_kind as u8) < (sub.liveliness.kind as u8) {
4676                    bump(slot, qid::LIVELINESS);
4677                    return;
4678                }
4679                if !deadline_compat(
4680                    slot.liveliness_lease_nanos,
4681                    qos_duration_to_nanos(sub.liveliness.lease_duration),
4682                ) {
4683                    bump(slot, qid::LIVELINESS);
4684                    return;
4685                }
4686                // Ownership: both must be equal (Spec §2.2.3.6 Table:
4687                // no "compatible" case except exactly equal).
4688                if slot.ownership != sub.ownership {
4689                    bump(slot, qid::OWNERSHIP);
4690                    return;
4691                }
4692                // Partition: at least one common partition — or
4693                // both empty (default partition "").
4694                if !partitions_overlap(&slot.partition, &sub.partition) {
4695                    bump(slot, qid::PARTITION);
4696                    return;
4697                }
4698                // F-TYPES-3 XTypes-1.3 §7.6.3.7 symmetric writer-side check.
4699                // If both sides carry a TypeIdentifier (≠ None),
4700                // we check compatibility. The reader's TCE policy is not
4701                // directly available here; we take the default TCE
4702                // (AllowTypeCoercion without PreventWidening) — the reader-
4703                // side check in `wire_reader_to_remote_writer` validates
4704                // with the real reader TCE.
4705                if slot.type_identifier != zerodds_types::TypeIdentifier::None
4706                    && sub.type_identifier != zerodds_types::TypeIdentifier::None
4707                {
4708                    let registry = zerodds_types::resolve::TypeRegistry::new();
4709                    let tce = zerodds_types::qos::TypeConsistencyEnforcement::default();
4710                    let matcher = zerodds_types::type_matcher::TypeMatcher::new(&tce);
4711                    if !matcher
4712                        .match_types(&slot.type_identifier, &sub.type_identifier, &registry)
4713                        .is_match()
4714                    {
4715                        bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
4716                        return;
4717                    }
4718                }
4719
4720                let mut proxy = zerodds_rtps::reader_proxy::ReaderProxy::new(
4721                    sub.key,
4722                    locators.clone(),
4723                    Vec::new(),
4724                    slot.reliable,
4725                );
4726                // D.5g — Per-Peer DataRepresentation negotiation
4727                // (XTypes 1.3 §7.6.3.1.2). Writer-offered = Per-Writer-
4728                // Override (slot.data_rep_offer_override) ODER Runtime-
4729                // Default. Reader-accepted = sub.data_representation
4730                // (spec default `[XCDR1]` if empty). Match mode from
4731                // RuntimeConfig.
4732                {
4733                    use zerodds_rtps::publication_data::data_representation as dr;
4734                    let writer_offered: Vec<i16> = slot
4735                        .data_rep_offer_override
4736                        .clone()
4737                        .unwrap_or_else(|| self.config.data_representation_offer.clone());
4738                    let mode = self.config.data_rep_match_mode;
4739                    if let Some(negotiated) =
4740                        dr::negotiate(&writer_offered, &sub.data_representation, mode)
4741                    {
4742                        proxy.set_negotiated_data_representation(negotiated);
4743                    } else {
4744                        // No overlap → SEDP match spec violation.
4745                        // We add the proxy anyway for best-effort
4746                        // compat; the wire-format default stays XCDR2.
4747                        // A spec-strict caller should reject the match.
4748                    }
4749                }
4750                // Spec §2.2.3.4 Tab. 16: cache replay suppression. For
4751                // Volatile the reader must not see any late-joiner history
4752                // → skip up to `cache.max_sn`. For Transient/Persistent
4753                // the backend is authoritative — we deliver the history
4754                // via the backend replay path with NEW SNs; the
4755                // writer's own cache (especially gappy under KeepLast
4756                // eviction) must not serve the reader twice.
4757                // TransientLocal is the only tier where the
4758                // writer cache is the real history anchor.
4759                if !matches!(slot.durability, zerodds_qos::DurabilityKind::TransientLocal) {
4760                    if let Some(max) = slot.writer.cache().max_sn() {
4761                        proxy.skip_samples_up_to(max);
4762                    }
4763                }
4764                // Spec §2.2.3.5 — Durability=Transient/Persistent:
4765                // on the first late-joiner match, re-inject the backend samples
4766                // into the HistoryCache. The existing
4767                // reliable-reader path then delivers them via DATA +
4768                // heartbeat/AckNack. Idempotent via the
4769                // `backend_primed` flag.
4770                let backend_writes: Vec<Vec<u8>> = if !slot.backend_primed
4771                    && (slot.durability == zerodds_qos::DurabilityKind::Transient
4772                        || slot.durability == zerodds_qos::DurabilityKind::Persistent)
4773                {
4774                    slot.durability_backend
4775                        .as_ref()
4776                        .and_then(|b| b.replay_for_topic(&slot.topic_name).ok())
4777                        .unwrap_or_default()
4778                        .into_iter()
4779                        .map(|s| s.payload)
4780                        .collect()
4781                } else {
4782                    Vec::new()
4783                };
4784                slot.writer.add_reader_proxy(proxy);
4785                // Path-MTU-aware fragmentation: if ALL matched
4786                // readers run on the same host, traffic goes via
4787                // loopback (MTU 65536) — then one datagram per sample
4788                // instead of N 1344-B fragments (halves the 8-kB roundtrip
4789                // latency). As soon as a reader is remote, it stays
4790                // Ethernet-safe at DEFAULT_FRAGMENT_SIZE, so no
4791                // oversized datagram gets IP-fragmented on the 1500-byte
4792                // path.
4793                let all_same_host = slot
4794                    .writer
4795                    .reader_proxies()
4796                    .iter()
4797                    .all(|p| self.guid_prefix.is_same_host(p.remote_reader_guid.prefix));
4798                if all_same_host {
4799                    slot.writer
4800                        .set_fragmentation(LOOPBACK_FRAGMENT_SIZE, LOOPBACK_MTU);
4801                } else {
4802                    slot.writer
4803                        .set_fragmentation(DEFAULT_FRAGMENT_SIZE, DEFAULT_MTU);
4804                }
4805                // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): if the
4806                // remote reader runs on the same host (matching
4807                // GuidPrefix host-id, wave 4a), register the pair in the
4808                // SameHostTracker. Wave 4b.3 (feature `same-host-shm`):
4809                // additionally try to set up a PosixShmTransport owner
4810                // segment — on success `mark_bound(Owner)`,
4811                // otherwise `mark_failed` and UDP fallback.
4812                if self.guid_prefix.is_same_host(sub.key.prefix) {
4813                    let local_writer_guid =
4814                        zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
4815                    self.same_host.register_pending(local_writer_guid, sub.key);
4816                    #[cfg(feature = "same-host-shm")]
4817                    {
4818                        match crate::same_host_shm::open_owner_segment(
4819                            self.guid_prefix,
4820                            local_writer_guid,
4821                            sub.key,
4822                        ) {
4823                            Ok(t) => self.same_host.mark_bound(
4824                                local_writer_guid,
4825                                sub.key,
4826                                t,
4827                                crate::same_host::Role::Owner,
4828                            ),
4829                            Err(reason) => {
4830                                self.same_host
4831                                    .mark_failed(local_writer_guid, sub.key, reason)
4832                            }
4833                        }
4834                    }
4835                }
4836                // Inject the backend replay into the HistoryCache (within
4837                // the slot lock). Important: with `KeepLast(N)` and a small N
4838                // the cache would immediately evict every replay sample
4839                // again — the subsequent writer tick then sees
4840                // SN=4,5 as "not in cache" and sends GAPs to the
4841                // reader, which marks our replay samples as irrelevant.
4842                // Solution: temporarily expand the cache to `KeepAll` with
4843                // a sufficient cap, for the duration of the
4844                // burst, then restore the user QoS.
4845                // Backend samples are in **raw** format (that is how
4846                // `DataWriter::write` in publisher.rs stores them) — before the
4847                // writer.write we must prepend the USER_PAYLOAD_ENCAP framing,
4848                // so the reader recognizes the stream value spec-conformantly
4849                // (see `validate_user_encap_offset`).
4850                let now_replay = self.start_instant.elapsed();
4851                if !backend_writes.is_empty() {
4852                    // Same encap header as in the live-write path
4853                    // (offer `first` + extensibility), so replay samples
4854                    // declare the same wire encoding.
4855                    let replay_encap = {
4856                        let offer_first = slot
4857                            .data_rep_offer_override
4858                            .as_ref()
4859                            .and_then(|v| v.first().copied())
4860                            .or_else(|| self.config.data_representation_offer.first().copied())
4861                            .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
4862                        user_payload_encap(offer_first, slot.wire_extensibility)
4863                    };
4864                    let original_kind = slot.writer.cache().kind();
4865                    let original_max = slot.writer.cache().max_samples();
4866                    let burst_max = original_max
4867                        .saturating_add(backend_writes.len())
4868                        .max(backend_writes.len() + 16);
4869                    slot.writer.set_cache_kind_and_max(
4870                        zerodds_rtps::history_cache::HistoryKind::KeepAll,
4871                        burst_max,
4872                    );
4873                    for raw_payload in &backend_writes {
4874                        let mut framed = Vec::with_capacity(replay_encap.len() + raw_payload.len());
4875                        framed.extend_from_slice(&replay_encap);
4876                        framed.extend_from_slice(raw_payload);
4877                        if let Ok(out) = slot.writer.write_with_heartbeat(&framed, now_replay) {
4878                            replay_dgs.extend(out);
4879                        }
4880                    }
4881                    slot.writer
4882                        .set_cache_kind_and_max(original_kind, original_max);
4883                    slot.backend_primed = true;
4884                }
4885                // D.5e Phase-1: wake `wait_for_matched_subscription`-waiters.
4886                self.match_event.1.notify_all();
4887
4888                // Security: derive the per-reader protection level from
4889                // security_info and build the locator lookup map,
4890                // so the writer tick can serialize per target
4891                // individually.
4892                #[cfg(feature = "security")]
4893                {
4894                    let peer_key = sub.key.prefix.0;
4895                    // Set the per-reader level ONLY for an EXPLICITLY announced
4896                    // `PID_ENDPOINT_SECURITY_INFO`. If it is missing (OpenDDS does not
4897                    // send it — it relies on the domain governance), NO
4898                    // None override: then the governance `data_protection` FLOOR
4899                    // applies in `secure_outbound_for_target`. An authenticated peer
4900                    // in a data_protection=ENCRYPT domain expects the encrypted
4901                    // payload; a None override would leak plaintext (cyclone/
4902                    // FastDDS announce security_info → unchanged).
4903                    if let Some(info) = sub.security_info.as_ref() {
4904                        let level = EndpointProtection::from_info(Some(info)).level;
4905                        slot.reader_protection.insert(peer_key, level);
4906                    }
4907                    for loc in &locators {
4908                        slot.locator_to_peer.insert(*loc, peer_key);
4909                    }
4910                }
4911            }
4912        }
4913        // Send the backend replay datagrams (Spec §2.2.3.5). The slot mutex
4914        // is released here; the send path mirrors the pattern from
4915        // `write_user_sample` — including the in-process fastpath for
4916        // same-process peers (otherwise UDP loopback loss under load can
4917        // swallow the Transient/Persistent replay samples).
4918        let inproc_peers: Vec<Arc<DcpsRuntime>> = {
4919            let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4920            all.into_iter()
4921                .filter(|rt| rt.guid_prefix != self.guid_prefix)
4922                .collect()
4923        };
4924        let now_send = self.start_instant.elapsed();
4925        for dg in &replay_dgs {
4926            for t in dg.targets.iter() {
4927                if is_routable_user_locator(t) {
4928                    let _ = self.user_unicast.send(t, &dg.bytes);
4929                }
4930            }
4931            for peer in &inproc_peers {
4932                handle_user_datagram(peer, &dg.bytes, now_send);
4933            }
4934        }
4935        // Emit the match event outside the slot mutex.
4936        self.config.observability.record(
4937            &zerodds_foundation::observability::Event::new(
4938                zerodds_foundation::observability::Level::Info,
4939                zerodds_foundation::observability::Component::Discovery,
4940                "writer.matched_remote_reader",
4941            )
4942            .with_attr("writer_eid", alloc::format!("{writer_eid:?}")),
4943        );
4944    }
4945
4946    fn wire_reader_to_remote_writer(
4947        &self,
4948        reader_eid: EntityId,
4949        pubd: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
4950    ) {
4951        // §2.2.2.2.1.17: an ignored publication must not be MATCHED, not merely
4952        // hidden from the DCPSPublication builtin reader. The Durability-Service
4953        // relies on this to avoid ingesting its own replay writer (echo loop).
4954        if let Some(filter) = self.ignore_filter_snapshot() {
4955            let pub_h = crate::instance_handle::InstanceHandle::from_guid(pubd.key);
4956            let part_h = crate::instance_handle::InstanceHandle::from_guid(pubd.participant_key);
4957            if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
4958                return;
4959            }
4960        }
4961        let locators =
4962            endpoint_or_default_locators(&pubd.unicast_locators, pubd.key.prefix, &self.discovered);
4963        if locators.is_empty() {
4964            return;
4965        }
4966        if let Some(slot_arc) = self.reader_slot(reader_eid) {
4967            if let Ok(mut slot) = slot_arc.lock() {
4968                let slot = &mut *slot;
4969                // Idempotency gate (symmetric to
4970                // `wire_writer_to_remote_reader`): if a WriterProxy already
4971                // exists for this remote writer, the
4972                // match has already run. A re-wire via UDP SEDP after
4973                // an in-process pull would REPLACE via `add_writer_proxy` —
4974                // resetting `delivered_up_to`/`received` and
4975                // losing already-buffered/delivered samples.
4976                if slot
4977                    .reader
4978                    .writer_proxies()
4979                    .iter()
4980                    .any(|s| s.proxy.remote_writer_guid == pubd.key)
4981                {
4982                    return;
4983                }
4984                // Per-policy bump for requested_incompatible_qos.
4985                use crate::psm_constants::qos_policy_id as qid;
4986                use crate::status::bump_policy_count;
4987                // C2 "loud instead of silent" (symmetric to the writer side):
4988                // an incompatible QoS match is logged loudly immediately.
4989                let obs = self.config.observability.clone();
4990                let topic_for_log = slot.topic_name.clone();
4991                let remote_for_log = alloc::format!("{:?}", pubd.key);
4992                let bump = |slot: &mut UserReaderSlot, pid: u32| {
4993                    slot.requested_incompatible_qos.total_count = slot
4994                        .requested_incompatible_qos
4995                        .total_count
4996                        .saturating_add(1);
4997                    slot.requested_incompatible_qos.last_policy_id = pid;
4998                    bump_policy_count(&mut slot.requested_incompatible_qos.policies, pid);
4999                    obs.record(
5000                        &zerodds_foundation::observability::Event::new(
5001                            zerodds_foundation::observability::Level::Warn,
5002                            zerodds_foundation::observability::Component::Dcps,
5003                            "qos.incompatible.requested",
5004                        )
5005                        .with_attr("topic", topic_for_log.as_str())
5006                        .with_attr("remote_writer", remote_for_log.as_str())
5007                        .with_attr("policy", qos_policy_id_name(pid)),
5008                    );
5009                };
5010
5011                // See wire_writer... — symmetric, the writer is now remote.
5012                if (pubd.durability as u8) < (slot.durability as u8) {
5013                    bump(slot, qid::DURABILITY);
5014                    return;
5015                }
5016                if !deadline_compat(
5017                    qos_duration_to_nanos(pubd.deadline.period),
5018                    slot.deadline_nanos,
5019                ) {
5020                    bump(slot, qid::DEADLINE);
5021                    return;
5022                }
5023                if (pubd.liveliness.kind as u8) < (slot.liveliness_kind as u8) {
5024                    bump(slot, qid::LIVELINESS);
5025                    return;
5026                }
5027                if !deadline_compat(
5028                    qos_duration_to_nanos(pubd.liveliness.lease_duration),
5029                    slot.liveliness_lease_nanos,
5030                ) {
5031                    bump(slot, qid::LIVELINESS);
5032                    return;
5033                }
5034                if pubd.ownership != slot.ownership {
5035                    bump(slot, qid::OWNERSHIP);
5036                    return;
5037                }
5038                if !partitions_overlap(&pubd.partition, &slot.partition) {
5039                    bump(slot, qid::PARTITION);
5040                    return;
5041                }
5042
5043                // F-TYPES-3 XTypes-1.3 §7.6.3.7 TypeConsistencyEnforcement.
5044                // If both sides carry a TypeIdentifier (≠ None),
5045                // we check compatibility via the TypeMatcher. Otherwise
5046                // the match falls back to a pure type_name comparison (default path).
5047                if slot.type_identifier != zerodds_types::TypeIdentifier::None
5048                    && pubd.type_identifier != zerodds_types::TypeIdentifier::None
5049                {
5050                    let registry = zerodds_types::resolve::TypeRegistry::new();
5051                    let matcher =
5052                        zerodds_types::type_matcher::TypeMatcher::new(&slot.type_consistency);
5053                    if !matcher
5054                        .match_types(&pubd.type_identifier, &slot.type_identifier, &registry)
5055                        .is_match()
5056                    {
5057                        bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5058                        return;
5059                    }
5060                }
5061
5062                slot.reader
5063                    .add_writer_proxy(zerodds_rtps::writer_proxy::WriterProxy::new(
5064                        pubd.key,
5065                        locators,
5066                        Vec::new(),
5067                        true,
5068                    ));
5069                // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): reader
5070                // side of the same-host match. If the remote writer runs on
5071                // the same host, register the pair AND
5072                // attach synchronously to the SHM segment.
5073                //
5074                // Idempotent: thanks to the `PosixShmTransport::open` refactor
5075                // (transport-shm bug fix 2026-05-19) it does not matter whether the
5076                // writer hook (open_owner) or the reader hook
5077                // (open_consumer) runs first — whoever comes first
5078                // creates the segment, whoever later attaches. Real-life
5079                // DDS has no guaranteed SEDP match order.
5080                if self.guid_prefix.is_same_host(pubd.key.prefix) {
5081                    let local_reader_guid =
5082                        zerodds_rtps::wire_types::Guid::new(self.guid_prefix, reader_eid);
5083                    self.same_host.register_pending(pubd.key, local_reader_guid);
5084                    #[cfg(feature = "same-host-shm")]
5085                    {
5086                        match crate::same_host_shm::open_consumer_segment(
5087                            self.guid_prefix,
5088                            pubd.key,
5089                            local_reader_guid,
5090                        ) {
5091                            Ok(t) => self.same_host.mark_bound(
5092                                pubd.key,
5093                                local_reader_guid,
5094                                t,
5095                                crate::same_host::Role::Consumer,
5096                            ),
5097                            Err(reason) => {
5098                                self.same_host
5099                                    .mark_failed(pubd.key, local_reader_guid, reason)
5100                            }
5101                        }
5102                    }
5103                }
5104                // D.5e Phase-1: wake `wait_for_matched_publication`-waiters.
5105                self.match_event.1.notify_all();
5106
5107                // §2.2.3.23 exclusive-ownership resolver cache:
5108                // remember the writer `ownership_strength` from discovery, so
5109                // `delivered_to_user_sample` can pack the value into every
5110                // sample.
5111                slot.writer_strengths
5112                    .insert(pubd.key.to_bytes(), pubd.ownership_strength);
5113            }
5114        }
5115    }
5116
5117    /// Writes a sample to a registered user writer and
5118    /// sends the generated datagrams.
5119    ///
5120    /// The payload is prefixed with the RTPS serialized-payload header
5121    /// (encapsulation scheme) before it goes into the DATA
5122    /// submessage. OMG RTPS 2.5 §9.4.2.13 requires exactly these
5123    /// 4 bytes at the start of every serialized user payload —
5124    /// see [`USER_PAYLOAD_ENCAP`] (`CDR_LE` / XCDR1).
5125    /// Without this header Cyclone/Fast-DDS readers refuse to
5126    /// deliver the sample (they parse the first 4 bytes as
5127    /// encapsulation kind + options and drop unknown-scheme).
5128    ///
5129    /// # Errors
5130    /// - `BadParameter` if the EntityId has no registered writer.
5131    /// - `WireError` on an encoding error.
5132    pub fn write_user_sample(&self, eid: EntityId, payload: Vec<u8>) -> Result<()> {
5133        // Vec-ownership API. The spec contract is unchanged. We delegate to
5134        // the borrowed variant; this saves a heap-allocation hop when
5135        // the caller already has a `&[u8]` (e.g. the C-FFI loan API).
5136        self.write_user_sample_borrowed(eid, &payload)
5137    }
5138
5139    /// Sets the per-writer data-representation override for a user writer. The
5140    /// next `write_user_sample*` derives its encapsulation header from this
5141    /// override's first element instead of the runtime default — so a
5142    /// representation-faithful re-publisher (e.g. the durability service
5143    /// replaying foreign-vendor XCDR1 bytes) can declare the encap that matches
5144    /// the body it holds. `None` clears the override (back to the runtime
5145    /// default). Idempotent + cheap; safe to call before every write.
5146    ///
5147    /// # Errors
5148    /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5149    /// poisoned slot lock.
5150    pub fn set_user_writer_data_rep_override(
5151        &self,
5152        eid: EntityId,
5153        offer: Option<Vec<i16>>,
5154    ) -> Result<()> {
5155        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5156            what: "unknown writer entity id",
5157        })?;
5158        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5159            reason: "user_writer slot poisoned",
5160        })?;
5161        slot.data_rep_offer_override = offer;
5162        Ok(())
5163    }
5164
5165    /// Writes a user sample from a borrowed byte slice.
5166    /// **Zero-copy path** for the loan API and SHM backend: avoids
5167    /// the Vec materialization when the caller holds a slot/stack buffer.
5168    ///
5169    /// Identical semantics to `write_user_sample`; it just takes no
5170    /// ownership of the buffer.
5171    ///
5172    /// # Errors
5173    /// As `write_user_sample`.
5174    pub fn write_user_sample_borrowed(&self, eid: EntityId, payload: &[u8]) -> Result<()> {
5175        let _phase_guard = if phase_timing_enabled() {
5176            Some(PhaseTimer {
5177                start: std::time::Instant::now(),
5178                ns_acc: &PHASE_WRITE_USER_NS,
5179                calls_acc: &PHASE_WRITE_USER_CALLS,
5180            })
5181        } else {
5182            None
5183        };
5184        let pt_on = phase_timing_enabled();
5185        let pt_t0 = if pt_on {
5186            Some(std::time::Instant::now())
5187        } else {
5188            None
5189        };
5190        // Hot path: for small samples (<= 1.5 kB payload)
5191        // the encap framing is copied into a stack PoolBuffer —
5192        // zero heap touch in the framing step. Large samples fall
5193        // back to Vec.
5194        let now = self.start_instant.elapsed();
5195        let total = USER_PAYLOAD_ENCAP.len() + payload.len();
5196        let pt_t2_out: Option<std::time::Instant>;
5197        let out_datagrams = {
5198            let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5199                what: "unknown writer entity id",
5200            })?;
5201            let pt_t1 = if pt_on {
5202                Some(std::time::Instant::now())
5203            } else {
5204                None
5205            };
5206            if let (Some(t0), Some(t1)) = (pt_t0, pt_t1) {
5207                PHASE_WRITE_SUB_NS[0].fetch_add(
5208                    (t1 - t0).as_nanos() as u64,
5209                    core::sync::atomic::Ordering::Relaxed,
5210                );
5211            }
5212            let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5213                reason: "user_writer slot poisoned",
5214            })?;
5215            let pt_t2 = if pt_on {
5216                Some(std::time::Instant::now())
5217            } else {
5218                None
5219            };
5220            pt_t2_out = pt_t2;
5221            if let (Some(t1), Some(t2)) = (pt_t1, pt_t2) {
5222                PHASE_WRITE_SUB_NS[1].fetch_add(
5223                    (t2 - t1).as_nanos() as u64,
5224                    core::sync::atomic::Ordering::Relaxed,
5225                );
5226            }
5227            // Deadline timer: remember the last write for offered_deadline_missed.
5228            slot.last_write = Some(now);
5229            // Encap header from the effective offer `first` (per-writer
5230            // override else runtime default) + type extensibility. The app
5231            // encoder serializes exactly this wire format; the header must
5232            // declare it honestly (otherwise an XCDR2-only vendor
5233            // reader misparses). See `user_payload_encap`.
5234            let encap = {
5235                let offer_first = slot
5236                    .data_rep_offer_override
5237                    .as_ref()
5238                    .and_then(|v| v.first().copied())
5239                    .or_else(|| self.config.data_representation_offer.first().copied())
5240                    .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
5241                user_payload_encap(offer_first, slot.wire_extensibility)
5242            };
5243            // Spec §2.2.3.5 backend filling happens in
5244            // `DataWriter::write` (publisher.rs) with the **raw** payload —
5245            // here only the HistoryCache filling + wire send.
5246            let dgs = if total <= SMALL_FRAME_CAP {
5247                write_user_sample_pooled(&mut slot.writer, payload, now, &encap)?
5248            } else {
5249                let mut framed = Vec::with_capacity(total);
5250                framed.extend_from_slice(&encap);
5251                framed.extend_from_slice(payload);
5252                // See write_user_sample_pooled: HB rate-limited via the
5253                // tick loop instead of per-write.
5254                let _ = now;
5255                slot.writer
5256                    .write(&framed)
5257                    .map_err(|_| DdsError::WireError {
5258                        message: String::from("user writer encode"),
5259                    })?
5260            };
5261            // Lifespan: remember the insert time of the just-written SN.
5262            if slot.lifespan_nanos != 0 {
5263                if let Some(sn) = slot.writer.cache().max_sn() {
5264                    slot.sample_insert_times.push_back((sn, now));
5265                }
5266            }
5267            dgs
5268        };
5269        let pt_t3 = if pt_on {
5270            Some(std::time::Instant::now())
5271        } else {
5272            None
5273        };
5274        if let (Some(t2), Some(t3)) = (pt_t2_out, pt_t3) {
5275            PHASE_WRITE_SUB_NS[2].fetch_add(
5276                (t3 - t2).as_nanos() as u64,
5277                core::sync::atomic::Ordering::Relaxed,
5278            );
5279        }
5280        // Opt-4 (Spec `zerodds-zero-copy-1.0` §9): precompute the skip set
5281        // of UDP locators occupied by a bound same-host reader.
5282        // Readers on these locators get the sample via
5283        // SHM (`same_host_send_pass` below); a UDP send would be a duplicate.
5284        #[cfg(feature = "same-host-shm")]
5285        let same_host_skip_locators: Vec<Locator> = self.same_host_udp_skip_set(eid);
5286        // In-process fastpath (same-process+domain peers): snapshot the
5287        // peer runtimes ONCE per write, then feed each datagram directly into
5288        // their recv path — no UDP loopback, no reliable
5289        // recovery race. The receiver deduplicates by SequenceNumber,
5290        // a copy arriving additionally via UDP later is a
5291        // no-op. The wire path stays untouched for cross-process.
5292        //
5293        // Hot-path fast path: lock-free registry hint. In the typical
5294        // cross-process bench (ping in process A, pong in process B)
5295        // A's registry has only A — the `peers()` lock+Vec alloc would be
5296        // pure overhead per write. Skip when count ≤ 1.
5297        let inproc_peers: Vec<Arc<DcpsRuntime>> = if crate::inproc::registry_count_hint() <= 1 {
5298            Vec::new()
5299        } else {
5300            let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
5301            all.into_iter()
5302                .filter(|rt| rt.guid_prefix != self.guid_prefix)
5303                .collect()
5304        };
5305        for dg in out_datagrams {
5306            // FU2 S3: UDP per target with per-reader data_protection
5307            // (`secure_outbound_for_target` — heterogeneously correct: legacy readers
5308            // get plaintext, secure readers SRTPS; the governance
5309            // data_protection fallback applies for readers without explicit
5310            // SEDP security_info).
5311            for t in dg.targets.iter() {
5312                if is_routable_user_locator(t) {
5313                    #[cfg(feature = "same-host-shm")]
5314                    if same_host_skip_locators.iter().any(|s| s == t) {
5315                        continue;
5316                    }
5317                    if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
5318                        #[allow(clippy::print_stderr)]
5319                        if let Err(e) = self.user_unicast.send(t, &secured) {
5320                            if std::env::var("ZERODDS_TRACE_SEND_ERR")
5321                                .map(|s| s == "1")
5322                                .unwrap_or(false)
5323                            {
5324                                eprintln!("[TRACE] user_unicast.send({t:?}) failed: {e:?}");
5325                            }
5326                        }
5327                    }
5328                }
5329            }
5330            // SHM + in-process fastpath: `secure_user_outbound` (uniform
5331            // governance data_protection level). The inproc peer runs through
5332            // its secured inbound path (decrypt or drop),
5333            // symmetric to the UDP recv — otherwise a non-
5334            // authenticated same-process peer could see encrypted data
5335            // unencrypted.
5336            if let Some(secured) = secure_user_outbound(self, &dg.bytes) {
5337                // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6):
5338                // parallel send via SHM to all bound-owner entries
5339                // for this writer. Opt-4 above filters their UDP
5340                // locators out beforehand, so nothing is sent twice.
5341                #[cfg(feature = "same-host-shm")]
5342                self.same_host_send_pass(eid, &secured);
5343                for peer in &inproc_peers {
5344                    #[cfg(feature = "security")]
5345                    {
5346                        if let Some(clear) =
5347                            secure_inbound_bytes(peer, &secured, &DEFAULT_INBOUND_IFACE)
5348                        {
5349                            handle_user_datagram(peer, &clear, now);
5350                        }
5351                    }
5352                    #[cfg(not(feature = "security"))]
5353                    handle_user_datagram(peer, &secured, now);
5354                }
5355            }
5356        }
5357        let pt_t4 = if pt_on {
5358            Some(std::time::Instant::now())
5359        } else {
5360            None
5361        };
5362        if let (Some(t3), Some(t4)) = (pt_t3, pt_t4) {
5363            PHASE_WRITE_SUB_NS[3].fetch_add(
5364                (t4 - t3).as_nanos() as u64,
5365                core::sync::atomic::Ordering::Relaxed,
5366            );
5367        }
5368        // Same-runtime writer→reader loopback: in parallel to the wire path
5369        // push directly into the `sample_tx` of all local readers on the same
5370        // topic+type. Bridge-daemon use case (writer+reader
5371        // in the same DcpsRuntime); without this hook intra-process
5372        // loopback would be completely dead, because `inproc_announce_*` skips self
5373        // and UDP multicast loopback is not guaranteed. Strength from
5374        // the writer slot.
5375        let writer_strength = self
5376            .writer_slot(eid)
5377            .and_then(|arc| arc.lock().ok().map(|s| s.ownership_strength))
5378            .unwrap_or(0);
5379        self.intra_runtime_dispatch_alive(eid, payload, writer_strength);
5380        // Embargo inspect tap at the DCPS layer (path-separated from the
5381        // production path). Only compiled when the `inspect` feature is
5382        // on. The topic name is fetched via a separate lookup, outside
5383        // the lock region so hooks do not run under the lock.
5384        #[cfg(feature = "inspect")]
5385        {
5386            self.dispatch_inspect_dcps_tap(eid, payload);
5387        }
5388        // D.5e Phase 3 — a freshly written sample makes a HEARTBEAT due: wake the
5389        // scheduler tick so it goes out immediately (no 5 ms tail), speeding the
5390        // reliable HB→ACKNACK handshake.
5391        self.raise_tick_wake();
5392        Ok(())
5393    }
5394
5395    /// Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) helper:
5396    /// sends `bytes` to all bound-owner entries of the [`SameHostTracker`]
5397    /// for this local writer (owner role).
5398    ///
5399    /// Called by the [`Self::write_user_sample`] hot path after the UDP send.
5400    /// Same-host readers thereby receive the sample frame
5401    /// via SHM **in addition** to the UDP path — the reader HistoryCache
5402    /// deduplicates by SequenceNumber.
5403    #[cfg(feature = "same-host-shm")]
5404    /// Opt-4 (Spec `zerodds-zero-copy-1.0` §9): locator skip set for
5405    /// the UDP send path. Returns all UDP default-unicast locators
5406    /// of the readers that have a bound same-host SHM pair with this
5407    /// writer — the hot-path caller filters these targets out of
5408    /// `dg.targets`, so the same readers are not served twice
5409    /// (UDP + SHM).
5410    #[cfg(feature = "same-host-shm")]
5411    fn same_host_udp_skip_set(&self, writer_eid: EntityId) -> Vec<Locator> {
5412        use crate::same_host::{Role, SameHostState};
5413        let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5414        let mut skip: Vec<Locator> = Vec::new();
5415        let snapshot = self.same_host.snapshot();
5416        let discovered = self.discovered.clone();
5417        for (w, reader, state) in snapshot {
5418            if w != writer_guid {
5419                continue;
5420            }
5421            if !matches!(
5422                state,
5423                SameHostState::Bound {
5424                    role: Role::Owner,
5425                    ..
5426                }
5427            ) {
5428                continue;
5429            }
5430            // Reader prefix → default_unicast_locator from discovery.
5431            if let Ok(cache) = discovered.lock() {
5432                if let Some(p) = cache.get(&reader.prefix) {
5433                    if let Some(loc) = p.data.default_unicast_locator {
5434                        skip.push(loc);
5435                    }
5436                }
5437            }
5438        }
5439        skip
5440    }
5441
5442    #[cfg(feature = "same-host-shm")]
5443    fn same_host_send_pass(&self, writer_eid: EntityId, bytes: &[u8]) {
5444        use crate::same_host::{Role, SameHostState};
5445        use zerodds_transport::Transport;
5446        use zerodds_transport_shm::PosixShmTransport;
5447
5448        let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5449        let snapshot = self.same_host.snapshot();
5450        let total = snapshot.len();
5451        let mut matched = 0u32;
5452        let mut owners = 0u32;
5453        let mut sent = 0u32;
5454        for (w, _reader, state) in snapshot {
5455            if w != writer_guid {
5456                continue;
5457            }
5458            matched += 1;
5459            let SameHostState::Bound { transport, role } = state else {
5460                continue;
5461            };
5462            if !matches!(role, Role::Owner) {
5463                continue;
5464            }
5465            owners += 1;
5466            let Ok(t) = transport.downcast::<PosixShmTransport>() else {
5467                continue;
5468            };
5469            // ShmTransport is 1:1: send() validates `dest ==
5470            // peer_locator`. Owner.peer_locator points to the
5471            // consumer endpoint → that is our target.
5472            let target = t.peer_locator();
5473            if t.send(&target, bytes).is_ok() {
5474                sent += 1;
5475            }
5476        }
5477        let _ = (total, matched, owners, sent); // diag counter removed after the Bug-3 fix
5478    }
5479
5480    /// Inspect-endpoint tap dispatch for DCPS publish.
5481    /// Reads the topic name separately from the WriterSlot and passes
5482    /// a frame to the zerodds-inspect-endpoint tap registry.
5483    /// **Not** the production hot path: only when the `inspect` feature is on.
5484    #[cfg(feature = "inspect")]
5485    fn dispatch_inspect_dcps_tap(&self, eid: EntityId, payload: &[u8]) {
5486        let Some(slot_arc) = self.writer_slot(eid) else {
5487            return;
5488        };
5489        let topic = match slot_arc.lock() {
5490            Ok(slot) => slot.topic_name.clone(),
5491            Err(_) => return,
5492        };
5493        let ts_ns = std::time::SystemTime::now()
5494            .duration_since(std::time::UNIX_EPOCH)
5495            .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
5496            .unwrap_or(0);
5497        let mut corr: u64 = 0;
5498        for (i, byte) in eid.entity_key.iter().enumerate() {
5499            corr |= u64::from(*byte) << (i * 8);
5500        }
5501        corr |= u64::from(eid.entity_kind as u8) << 24;
5502        let frame = zerodds_inspect_endpoint::Frame::dcps(topic, ts_ns, corr, payload.to_vec());
5503        zerodds_inspect_endpoint::tap::dispatch(&frame);
5504    }
5505
5506    /// Sends a lifecycle marker (`dispose`/`unregister_instance`) to
5507    /// all matched readers. Spec §2.2.2.4.2.10/.7 + §9.6.3.9 PID_STATUS_INFO.
5508    /// `status_bits` is the OR combination of
5509    /// `zerodds_rtps::inline_qos::status_info::DISPOSED` and/or `UNREGISTERED`.
5510    ///
5511    /// # Errors
5512    /// - `BadParameter` if the EntityId has no registered writer.
5513    /// - `WireError` on an encode error.
5514    pub fn write_user_lifecycle(
5515        &self,
5516        eid: EntityId,
5517        key_hash: [u8; 16],
5518        status_bits: u32,
5519    ) -> Result<()> {
5520        let out_datagrams = {
5521            let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5522                what: "unknown writer entity id",
5523            })?;
5524            let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5525                reason: "user_writer slot poisoned",
5526            })?;
5527            slot.writer
5528                .write_lifecycle(key_hash, status_bits)
5529                .map_err(|_| DdsError::WireError {
5530                    message: String::from("user writer lifecycle encode"),
5531                })?
5532        };
5533        for dg in out_datagrams {
5534            // FU2 S3: lifecycle DATA (dispose/unregister) per-target
5535            // data_protection-aware (heterogeneously correct like the immediate send).
5536            for t in dg.targets.iter() {
5537                if is_routable_user_locator(t) {
5538                    if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
5539                        let _ = self.user_unicast.send(t, &secured);
5540                    }
5541                }
5542            }
5543        }
5544        Ok(())
5545    }
5546
5547    /// Generates a 3-byte entity key for new user endpoints.
5548    fn next_entity_key(&self) -> [u8; 3] {
5549        let n = self.entity_counter.fetch_add(1, Ordering::Relaxed);
5550        [(n >> 16) as u8, (n >> 8) as u8, n as u8]
5551    }
5552
5553    /// Snapshot of all currently known remote publications (topic
5554    /// name + type name + writer GUID).
5555    #[must_use]
5556    pub fn discovered_publications_count(&self) -> usize {
5557        self.sedp
5558            .lock()
5559            .map(|s| s.cache().publications_len())
5560            .unwrap_or(0)
5561    }
5562
5563    /// Snapshot of every publication on this domain as `(topic_name,
5564    /// type_name)` — raw DDS topic/type strings — for graph introspection
5565    /// (`rmw_get_topic_names_and_types`, `rmw_count_publishers`). Includes BOTH
5566    /// this participant's LOCAL user writers AND the remote publications from
5567    /// SEDP, so a node sees its own topics as well as its peers'.
5568    #[must_use]
5569    pub fn discovered_publication_topics(&self) -> Vec<(String, String)> {
5570        let mut out: Vec<(String, String)> = Vec::new();
5571        if let Ok(map) = self.user_writers.read() {
5572            for slot in map.values() {
5573                if let Ok(s) = slot.lock() {
5574                    out.push((s.topic_name.clone(), s.type_name.clone()));
5575                }
5576            }
5577        }
5578        if let Ok(s) = self.sedp.lock() {
5579            out.extend(
5580                s.cache()
5581                    .publications()
5582                    .map(|p| (p.data.topic_name.clone(), p.data.type_name.clone())),
5583            );
5584        }
5585        out
5586    }
5587
5588    /// Snapshot of every subscription on this domain as `(topic_name,
5589    /// type_name)` (local user readers + remote SEDP). Counterpart to
5590    /// [`Self::discovered_publication_topics`].
5591    #[must_use]
5592    pub fn discovered_subscription_topics(&self) -> Vec<(String, String)> {
5593        let mut out: Vec<(String, String)> = Vec::new();
5594        if let Ok(map) = self.user_readers.read() {
5595            for slot in map.values() {
5596                if let Ok(s) = slot.lock() {
5597                    out.push((s.topic_name.clone(), s.type_name.clone()));
5598                }
5599            }
5600        }
5601        if let Ok(s) = self.sedp.lock() {
5602            out.extend(
5603                s.cache()
5604                    .subscriptions()
5605                    .map(|s| (s.data.topic_name.clone(), s.data.type_name.clone())),
5606            );
5607        }
5608        out
5609    }
5610
5611    /// Snapshot of all currently known remote subscriptions.
5612    #[must_use]
5613    pub fn discovered_subscriptions_count(&self) -> usize {
5614        self.sedp
5615            .lock()
5616            .map(|s| s.cache().subscriptions_len())
5617            .unwrap_or(0)
5618    }
5619
5620    /// Per-endpoint snapshot of every publication on this domain (local user
5621    /// writers + remote SEDP), for ROS 2 `rmw_get_publishers_info_by_topic`.
5622    #[must_use]
5623    pub fn discovered_publication_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
5624        let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
5625        let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
5626        if let Ok(map) = self.user_writers.read() {
5627            for slot in map.values() {
5628                if let Ok(s) = slot.lock() {
5629                    out.push(DiscoveredEndpointInfo {
5630                        topic_name: s.topic_name.clone(),
5631                        type_name: s.type_name.clone(),
5632                        endpoint_guid: guid_to_16(s.writer.guid()),
5633                        reliable: s.reliable,
5634                        transient_local: !matches!(
5635                            s.durability,
5636                            zerodds_qos::DurabilityKind::Volatile
5637                        ),
5638                        deadline_seconds: secs(s.deadline_nanos),
5639                        lifespan_seconds: secs(s.lifespan_nanos),
5640                        liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
5641                    });
5642                }
5643            }
5644        }
5645        if let Ok(s) = self.sedp.lock() {
5646            for p in s.cache().publications() {
5647                out.push(DiscoveredEndpointInfo {
5648                    topic_name: p.data.topic_name.clone(),
5649                    type_name: p.data.type_name.clone(),
5650                    endpoint_guid: guid_to_16(p.data.key),
5651                    reliable: matches!(
5652                        p.data.reliability.kind,
5653                        zerodds_qos::ReliabilityKind::Reliable
5654                    ),
5655                    transient_local: !matches!(
5656                        p.data.durability,
5657                        zerodds_qos::DurabilityKind::Volatile
5658                    ),
5659                    deadline_seconds: p.data.deadline.period.seconds,
5660                    lifespan_seconds: p.data.lifespan.duration.seconds,
5661                    liveliness_lease_seconds: p.data.liveliness.lease_duration.seconds,
5662                });
5663            }
5664        }
5665        out
5666    }
5667
5668    /// Counterpart to [`Self::discovered_publication_endpoints`] for
5669    /// subscriptions (`rmw_get_subscriptions_info_by_topic`).
5670    #[must_use]
5671    pub fn discovered_subscription_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
5672        let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
5673        let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
5674        if let Ok(map) = self.user_readers.read() {
5675            for slot in map.values() {
5676                if let Ok(s) = slot.lock() {
5677                    out.push(DiscoveredEndpointInfo {
5678                        topic_name: s.topic_name.clone(),
5679                        type_name: s.type_name.clone(),
5680                        endpoint_guid: guid_to_16(s.reader.guid()),
5681                        // Reader requested-reliability is not retained in the
5682                        // slot; RELIABLE is the rmw default (best-effort field).
5683                        reliable: true,
5684                        transient_local: !matches!(
5685                            s.durability,
5686                            zerodds_qos::DurabilityKind::Volatile
5687                        ),
5688                        deadline_seconds: secs(s.deadline_nanos),
5689                        lifespan_seconds: 0,
5690                        liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
5691                    });
5692                }
5693            }
5694        }
5695        if let Ok(s) = self.sedp.lock() {
5696            for sub in s.cache().subscriptions() {
5697                out.push(DiscoveredEndpointInfo {
5698                    topic_name: sub.data.topic_name.clone(),
5699                    type_name: sub.data.type_name.clone(),
5700                    endpoint_guid: guid_to_16(sub.data.key),
5701                    reliable: matches!(
5702                        sub.data.reliability.kind,
5703                        zerodds_qos::ReliabilityKind::Reliable
5704                    ),
5705                    transient_local: !matches!(
5706                        sub.data.durability,
5707                        zerodds_qos::DurabilityKind::Volatile
5708                    ),
5709                    deadline_seconds: sub.data.deadline.period.seconds,
5710                    lifespan_seconds: 0,
5711                    liveliness_lease_seconds: sub.data.liveliness.lease_duration.seconds,
5712                });
5713            }
5714        }
5715        out
5716    }
5717
5718    /// Number of matched remote readers for a local user writer.
5719    /// Polled by `DataWriter::wait_for_matched_subscription`.
5720    #[must_use]
5721    pub fn user_writer_matched_count(&self, eid: EntityId) -> usize {
5722        // Distinct matched subscriptions = remote/cross-participant reader
5723        // proxies UNION same-participant (intra-runtime) local readers. The
5724        // intra-runtime self-match path delivers samples without adding a wire
5725        // reader-proxy (avoids UDP-to-self double-delivery), so its matches
5726        // would otherwise be invisible to `wait_for_matched_subscription`.
5727        self.user_writer_matched_subscription_handles(eid).len()
5728    }
5729
5730    /// List of `InstanceHandle`s of all matched readers for a local
5731    /// user writer (Spec §2.2.2.4.2.x `get_matched_subscriptions`): remote/
5732    /// cross-participant readers (reader proxies) plus the same-participant
5733    /// readers from the intra-runtime routes, deduplicated by GUID.
5734    #[must_use]
5735    pub fn user_writer_matched_subscription_handles(
5736        &self,
5737        eid: EntityId,
5738    ) -> Vec<crate::instance_handle::InstanceHandle> {
5739        let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
5740            .writer_slot(eid)
5741            .and_then(|arc| {
5742                arc.lock().ok().map(|s| {
5743                    s.writer
5744                        .reader_proxies()
5745                        .iter()
5746                        .map(|p| {
5747                            crate::instance_handle::InstanceHandle::from_guid(p.remote_reader_guid)
5748                        })
5749                        .collect::<Vec<_>>()
5750                })
5751            })
5752            .unwrap_or_default();
5753        for h in self.intra_runtime_writer_matched_readers(eid) {
5754            if !handles.contains(&h) {
5755                handles.push(h);
5756            }
5757        }
5758        handles
5759    }
5760
5761    /// Same-participant readers that the local writer `eid` delivers to via
5762    /// an intra-runtime route (as matched-subscription handles).
5763    fn intra_runtime_writer_matched_readers(
5764        &self,
5765        writer_eid: EntityId,
5766    ) -> Vec<crate::instance_handle::InstanceHandle> {
5767        match self.intra_runtime_routes.read() {
5768            Ok(g) => g
5769                .get(&writer_eid)
5770                .map(|readers| {
5771                    readers
5772                        .iter()
5773                        .map(|reid| {
5774                            crate::instance_handle::InstanceHandle::from_guid(Guid::new(
5775                                self.guid_prefix,
5776                                *reid,
5777                            ))
5778                        })
5779                        .collect()
5780                })
5781                .unwrap_or_default(),
5782            Err(_) => Vec::new(),
5783        }
5784    }
5785
5786    /// Same-participant writers that deliver to the local
5787    /// reader `reader_eid` via an intra-runtime route (as matched-publication handles).
5788    fn intra_runtime_reader_matched_writers(
5789        &self,
5790        reader_eid: EntityId,
5791    ) -> Vec<crate::instance_handle::InstanceHandle> {
5792        match self.intra_runtime_routes.read() {
5793            Ok(g) => g
5794                .iter()
5795                .filter(|(_, readers)| readers.contains(&reader_eid))
5796                .map(|(weid, _)| {
5797                    crate::instance_handle::InstanceHandle::from_guid(Guid::new(
5798                        self.guid_prefix,
5799                        *weid,
5800                    ))
5801                })
5802                .collect(),
5803            Err(_) => Vec::new(),
5804        }
5805    }
5806
5807    /// List of `InstanceHandle`s of all matched remote writers for a
5808    /// local user reader (Spec §2.2.2.5.x `get_matched_publications`).
5809    #[must_use]
5810    pub fn user_reader_matched_publication_handles(
5811        &self,
5812        eid: EntityId,
5813    ) -> Vec<crate::instance_handle::InstanceHandle> {
5814        let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
5815            .reader_slot(eid)
5816            .and_then(|arc| {
5817                arc.lock().ok().map(|s| {
5818                    s.reader
5819                        .writer_proxies()
5820                        .iter()
5821                        .map(|p| {
5822                            crate::instance_handle::InstanceHandle::from_guid(
5823                                p.proxy.remote_writer_guid,
5824                            )
5825                        })
5826                        .collect::<Vec<_>>()
5827                })
5828            })
5829            .unwrap_or_default();
5830        for h in self.intra_runtime_reader_matched_writers(eid) {
5831            if !handles.contains(&h) {
5832                handles.push(h);
5833            }
5834        }
5835        handles
5836    }
5837
5838    /// Counter for missed offered deadlines on the user writer.
5839    /// Spec OMG DDS 1.4 §2.2.4.2.9 `OFFERED_DEADLINE_MISSED_STATUS`.
5840    #[must_use]
5841    pub fn user_writer_offered_deadline_missed(&self, eid: EntityId) -> u64 {
5842        self.writer_slot(eid)
5843            .and_then(|arc| arc.lock().ok().map(|s| s.offered_deadline_missed_count))
5844            .unwrap_or(0)
5845    }
5846
5847    /// Counter for missed requested deadlines on the user reader.
5848    /// Spec §2.2.4.2.11 `REQUESTED_DEADLINE_MISSED_STATUS`.
5849    #[must_use]
5850    pub fn user_reader_requested_deadline_missed(&self, eid: EntityId) -> u64 {
5851        self.reader_slot(eid)
5852            .and_then(|arc| arc.lock().ok().map(|s| s.requested_deadline_missed_count))
5853            .unwrap_or(0)
5854    }
5855
5856    /// Current liveliness status of a local user reader.
5857    /// Spec §2.2.4.2.14 `LIVELINESS_CHANGED_STATUS`:
5858    /// `(alive, alive_count, not_alive_count)`.
5859    #[must_use]
5860    pub fn user_reader_liveliness_status(&self, eid: EntityId) -> (bool, u64, u64) {
5861        self.reader_slot(eid)
5862            .and_then(|arc| {
5863                arc.lock().ok().map(|s| {
5864                    (
5865                        s.liveliness_alive,
5866                        s.liveliness_alive_count,
5867                        s.liveliness_not_alive_count,
5868                    )
5869                })
5870            })
5871            .unwrap_or((false, 0, 0))
5872    }
5873
5874    /// LivelinessLost counter on the user writer (Spec §2.2.4.2.10).
5875    /// Incremented by `check_writer_liveliness`.
5876    #[must_use]
5877    pub fn user_writer_liveliness_lost(&self, eid: EntityId) -> u64 {
5878        self.writer_slot(eid)
5879            .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_lost_count))
5880            .unwrap_or(0)
5881    }
5882
5883    /// Snapshot of OfferedIncompatibleQosStatus on the writer.
5884    #[must_use]
5885    pub fn user_writer_offered_incompatible_qos(
5886        &self,
5887        eid: EntityId,
5888    ) -> crate::status::OfferedIncompatibleQosStatus {
5889        self.writer_slot(eid)
5890            .and_then(|arc| arc.lock().ok().map(|s| s.offered_incompatible_qos.clone()))
5891            .unwrap_or_default()
5892    }
5893
5894    /// Snapshot of RequestedIncompatibleQosStatus on the reader.
5895    #[must_use]
5896    pub fn user_reader_requested_incompatible_qos(
5897        &self,
5898        eid: EntityId,
5899    ) -> crate::status::RequestedIncompatibleQosStatus {
5900        self.reader_slot(eid)
5901            .and_then(|arc| {
5902                arc.lock()
5903                    .ok()
5904                    .map(|s| s.requested_incompatible_qos.clone())
5905            })
5906            .unwrap_or_default()
5907    }
5908
5909    /// Sample-lost counter (reader side). Spec §2.2.4.2.6.2.
5910    #[must_use]
5911    pub fn user_reader_sample_lost(&self, eid: EntityId) -> u64 {
5912        self.reader_slot(eid)
5913            .and_then(|arc| arc.lock().ok().map(|s| s.sample_lost_count))
5914            .unwrap_or(0)
5915    }
5916
5917    /// Monotonically increasing count of alive samples delivered to the
5918    /// user (Spec §2.2.4.2.6.1 `on_data_available` detector). A delta
5919    /// against the last poll snapshot means "new data available".
5920    #[must_use]
5921    pub fn user_reader_samples_delivered(&self, eid: EntityId) -> u64 {
5922        self.reader_slot(eid)
5923            .and_then(|arc| arc.lock().ok().map(|s| s.samples_delivered_count))
5924            .unwrap_or(0)
5925    }
5926
5927    /// Bug-2 diagnosis (2026-05-19): number of submessages dropped
5928    /// because of an unknown writer_id. If this value is incremented
5929    /// after a write, it indicates an SEDP match
5930    /// race (writer_proxy not yet added when DATA is received).
5931    #[must_use]
5932    pub fn user_reader_unknown_src_count(&self, eid: EntityId) -> u64 {
5933        self.reader_slot(eid)
5934            .and_then(|arc| arc.lock().ok().map(|s| s.reader.unknown_src_count()))
5935            .unwrap_or(0)
5936    }
5937
5938    /// Sample-rejected status (reader side). Spec §2.2.4.2.6.3.
5939    #[must_use]
5940    pub fn user_reader_sample_rejected(
5941        &self,
5942        eid: EntityId,
5943    ) -> crate::status::SampleRejectedStatus {
5944        self.reader_slot(eid)
5945            .and_then(|arc| arc.lock().ok().map(|s| s.sample_rejected))
5946            .unwrap_or_default()
5947    }
5948
5949    /// Records a lost sample on the user reader. Called
5950    /// by resource-limit or decode-failure paths — the
5951    /// detector is application-external, because sample-lost depending on the
5952    /// implementation comes from several sources (cache drop, decode
5953    /// fail, sequence-number gap drop).
5954    pub fn record_sample_lost(&self, eid: EntityId, count: u32) {
5955        if count == 0 {
5956            return;
5957        }
5958        if let Some(arc) = self.reader_slot(eid) {
5959            if let Ok(mut slot) = arc.lock() {
5960                slot.sample_lost_count = slot.sample_lost_count.saturating_add(u64::from(count));
5961            }
5962        }
5963    }
5964
5965    /// Records a rejected sample on the user reader.
5966    pub fn record_sample_rejected(
5967        &self,
5968        eid: EntityId,
5969        kind: crate::status::SampleRejectedStatusKind,
5970        instance: crate::instance_handle::InstanceHandle,
5971    ) {
5972        if let Some(arc) = self.reader_slot(eid) {
5973            if let Ok(mut slot) = arc.lock() {
5974                slot.sample_rejected.total_count =
5975                    slot.sample_rejected.total_count.saturating_add(1);
5976                slot.sample_rejected.last_reason = kind;
5977                slot.sample_rejected.last_instance_handle = instance;
5978            }
5979        }
5980    }
5981
5982    /// Manual liveliness assert on the user writer. Sets the
5983    /// `last_liveliness_assert` timestamp. For `LivelinessKind::Automatic`
5984    /// `last_write` is also set — the liveliness path
5985    /// otherwise never falls through the `assert` trigger, because every successful
5986    /// `write` already takes over the liveliness tick.
5987    pub fn assert_writer_liveliness_eid(&self, eid: EntityId) {
5988        let now = self.start_instant.elapsed();
5989        if let Some(arc) = self.writer_slot(eid) {
5990            if let Ok(mut slot) = arc.lock() {
5991                slot.last_liveliness_assert = Some(now);
5992                if slot.liveliness_kind == zerodds_qos::LivelinessKind::Automatic {
5993                    slot.last_write = Some(now);
5994                }
5995            }
5996        }
5997    }
5998
5999    /// True if all matched readers have acknowledged all samples written
6000    /// so far. Empty cache or no proxies → true.
6001    #[must_use]
6002    pub fn user_writer_all_acknowledged(&self, eid: EntityId) -> bool {
6003        self.writer_slot(eid)
6004            .and_then(|arc| arc.lock().ok().map(|s| s.writer.all_samples_acknowledged()))
6005            .unwrap_or(true)
6006    }
6007
6008    /// Test helper — pushes a synthetic `UserSample::Alive`
6009    /// directly into the `mpsc::Sender` of the given reader, without
6010    /// going through the wire/discovery path. Enables end-to-end tests of
6011    /// downstream consumers (e.g. bridge-daemon pumps) that otherwise
6012    /// become flaky in CI containers due to multicast-loopback limits.
6013    /// **Not** for production code.
6014    ///
6015    /// `writer_guid` and `writer_strength` are set to default values
6016    /// (shared-ownership assumption).
6017    ///
6018    /// Returns `true` if the reader slot exists and the push
6019    /// succeeded, `false` if the EID is unknown or the channel is
6020    /// closed.
6021    #[doc(hidden)]
6022    pub fn test_inject_user_alive(&self, eid: EntityId, payload: Vec<u8>) -> bool {
6023        let Some(arc) = self.reader_slot(eid) else {
6024            return false;
6025        };
6026        let Ok(mut slot) = arc.lock() else {
6027            return false;
6028        };
6029        let sent = slot
6030            .sample_tx
6031            .send(UserSample::Alive {
6032                payload: crate::sample_bytes::SampleBytes::from_vec(payload),
6033                writer_guid: [0u8; 16],
6034                writer_strength: 0,
6035                representation: 0,
6036            })
6037            .is_ok();
6038        if sent {
6039            slot.samples_delivered_count = slot.samples_delivered_count.saturating_add(1);
6040        }
6041        sent
6042    }
6043
6044    /// Test helper — bumps the inconsistent-topic counter as if matching had
6045    /// discovered a remote endpoint with the same `topic_name` but a
6046    /// different `type_name`. Lets listener-FFI tests exercise the
6047    /// `on_inconsistent_topic` poll path without standing up two
6048    /// participants with a real SEDP type mismatch. **Not** for production.
6049    #[doc(hidden)]
6050    pub fn test_bump_inconsistent_topic(&self) {
6051        self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
6052    }
6053
6054    /// Spec §3.1 zerodds-async-1.0: registers the waker of an
6055    /// async reader in the UserReaderSlot. On `sample_tx.send`
6056    /// the waker is woken. `None` as the argument clears the waker
6057    /// (e.g. after the async reader is dropped).
6058    pub fn register_user_reader_waker(&self, eid: EntityId, waker: Option<core::task::Waker>) {
6059        if let Some(arc) = self.reader_slot(eid) {
6060            if let Ok(slot) = arc.lock() {
6061                if let Ok(mut g) = slot.async_waker.lock() {
6062                    *g = waker;
6063                }
6064            }
6065        }
6066    }
6067
6068    /// Register a listener callback for alive-sample
6069    /// arrival on the user reader. `None` clears an
6070    /// existing listener.
6071    ///
6072    /// The listener fires synchronously on the recv thread of
6073    /// `recv_user_data_loop` — see the contract doc on the
6074    /// [`UserReaderListener`] type. Eliminates the user-polling
6075    /// latency (~50-100 µs) compared to `sample_tx.recv()`.
6076    ///
6077    /// Returns `true` if the reader slot exists and the listener
6078    /// was set, `false` if the EID is not a known user reader.
6079    pub fn set_user_reader_listener(
6080        &self,
6081        eid: EntityId,
6082        listener: Option<UserReaderListener>,
6083    ) -> bool {
6084        let Some(arc) = self.reader_slot(eid) else {
6085            return false;
6086        };
6087        let Ok(mut slot) = arc.lock() else {
6088            return false;
6089        };
6090        slot.listener = listener.map(alloc::sync::Arc::new);
6091        true
6092    }
6093
6094    /// Number of matched writers for a local user reader: remote/cross-
6095    /// participant writers (writer proxies) plus same-participant writers from the
6096    /// intra-runtime routes, deduplicated by GUID (symmetric to the writer).
6097    #[must_use]
6098    pub fn user_reader_matched_count(&self, eid: EntityId) -> usize {
6099        self.user_reader_matched_publication_handles(eid).len()
6100    }
6101
6102    /// D.5e Phase-1 — waits until a match event occurs or the timeout
6103    /// is reached. Replaces 20-ms polling in `DataReader::wait_for_matched_*`
6104    /// and `DataWriter::wait_for_matched_*`.
6105    ///
6106    /// The caller checks the match count itself (via `user_*_matched_count`)
6107    /// before and after the wait — this function is only the block mechanics.
6108    /// Returns `false` if the timeout is reached, `true` if a notify came.
6109    #[cfg(feature = "std")]
6110    pub fn wait_match_event(&self, timeout: core::time::Duration) -> bool {
6111        let (lock, cvar) = &*self.match_event;
6112        let Ok(guard) = lock.lock() else { return false };
6113        match cvar.wait_timeout(guard, timeout) {
6114            Ok((_, t)) => !t.timed_out(),
6115            Err(_) => false,
6116        }
6117    }
6118
6119    /// D.5e Phase-1 — waits until an ACK event occurs or a timeout.
6120    /// Replaces 50-ms polling in `DataWriter::wait_for_acknowledgments`.
6121    #[cfg(feature = "std")]
6122    pub fn wait_ack_event(&self, timeout: core::time::Duration) -> bool {
6123        let (lock, cvar) = &*self.ack_event;
6124        let Ok(guard) = lock.lock() else { return false };
6125        match cvar.wait_timeout(guard, timeout) {
6126            Ok((_, t)) => !t.timed_out(),
6127            Err(_) => false,
6128        }
6129    }
6130
6131    /// D.5e Phase-1 — notify helper for the ACK event. Called by the reliable
6132    /// writer path when an ACKNACK advances the acked-base.
6133    #[cfg(feature = "std")]
6134    pub(crate) fn notify_ack_event(&self) {
6135        self.ack_event.1.notify_all();
6136    }
6137
6138    /// ADR-0006 — sets the PID_SHM_LOCATOR bytes for a local
6139    /// user writer in the side map. Called by the DataWriter
6140    /// once `set_flat_backend` has attached a same-host backend (POSIX shm /
6141    /// Iceoryx2). On the next SEDP push the wire encoder
6142    /// injects PID 0x8001 into the `PublicationData`.
6143    pub fn set_shm_locator(&self, eid: EntityId, bytes: Vec<u8>) {
6144        if let Ok(mut g) = self.shm_locators.write() {
6145            g.insert(eid, bytes);
6146        }
6147    }
6148
6149    /// ADR-0006 — reads the PID_SHM_LOCATOR bytes for a local
6150    /// user writer from the side map. Returns `None` if no
6151    /// same-host backend is set.
6152    #[must_use]
6153    pub fn shm_locator(&self, eid: EntityId) -> Option<Vec<u8>> {
6154        self.shm_locators.read().ok()?.get(&eid).cloned()
6155    }
6156
6157    /// ADR-0006 — removes the PID_SHM_LOCATOR entry (e.g. when the
6158    /// user writer is reconfigured without a backend).
6159    pub fn clear_shm_locator(&self, eid: EntityId) {
6160        if let Ok(mut g) = self.shm_locators.write() {
6161            g.remove(&eid);
6162        }
6163    }
6164
6165    /// Stops all worker threads (recv loops + tick loop) and joins
6166    /// them. Idempotent — repeated calls are no-ops.
6167    ///
6168    /// Shutdown delay: up to ~1 s, because the recv threads sit in
6169    /// `recv()` with a 1 s read timeout. After the
6170    /// current recv() call finishes they check the stop flag and
6171    /// terminate.
6172    pub fn shutdown(&self) {
6173        self.stop.store(true, Ordering::Relaxed);
6174        // D.5e Phase 3 — wake the scheduler tick worker so it observes `stop`
6175        // immediately instead of parking up to the idle floor.
6176        if let Ok(guard) = self.tick_wake.lock() {
6177            if let Some(h) = guard.as_ref() {
6178                h.stop();
6179            }
6180        }
6181        if let Ok(mut guard) = self.handles.lock() {
6182            for h in guard.drain(..) {
6183                let _ = h.join();
6184            }
6185        }
6186    }
6187}
6188
6189impl Drop for DcpsRuntime {
6190    // ZERODDS_PHASE_DUMP=1 is on-demand debug telemetry for
6191    // phase-latency profiling. eprintln is semantically correct here
6192    // (stderr diagnostics), no log-crate dependency wanted.
6193    #[allow(clippy::print_stderr)]
6194    fn drop(&mut self) {
6195        if std::env::var("ZERODDS_PHASE_DUMP")
6196            .map(|s| s == "1")
6197            .unwrap_or(false)
6198        {
6199            let hu_ns = PHASE_HANDLE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6200            let hu_n = PHASE_HANDLE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6201            let wu_ns = PHASE_WRITE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6202            let wu_n = PHASE_WRITE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6203            let hu_us = if hu_n > 0 {
6204                hu_ns as f64 / hu_n as f64 / 1000.0
6205            } else {
6206                0.0
6207            };
6208            let wu_us = if wu_n > 0 {
6209                wu_ns as f64 / wu_n as f64 / 1000.0
6210            } else {
6211                0.0
6212            };
6213            eprintln!(
6214                "[ZERODDS_PHASE] handle_user_datagram:  N={}  avg={:.3}us  total={:.1}ms",
6215                hu_n,
6216                hu_us,
6217                hu_ns as f64 / 1_000_000.0
6218            );
6219            eprintln!(
6220                "[ZERODDS_PHASE] write_user_sample:      N={}  avg={:.3}us  total={:.1}ms",
6221                wu_n,
6222                wu_us,
6223                wu_ns as f64 / 1_000_000.0
6224            );
6225            // Sub-phases of write_user_sample_borrowed.
6226            // [0] slot_lookup, [1] slot_lock_acquire,
6227            // [2] writer.write + framing, [3] dispatch (UDP + inproc).
6228            const SUB_LABELS: [&str; 4] = [
6229                "  ├─ slot_lookup       ",
6230                "  ├─ slot_lock_acquire ",
6231                "  ├─ writer.write+frame",
6232                "  └─ dispatch (UDP+...)",
6233            ];
6234            for (i, label) in SUB_LABELS.iter().enumerate() {
6235                let s_ns = PHASE_WRITE_SUB_NS[i].load(core::sync::atomic::Ordering::Relaxed);
6236                if s_ns > 0 && wu_n > 0 {
6237                    let s_us = s_ns as f64 / wu_n as f64 / 1000.0;
6238                    eprintln!(
6239                        "[ZERODDS_PHASE] {} avg={:.3}us  total={:.1}ms",
6240                        label,
6241                        s_us,
6242                        s_ns as f64 / 1_000_000.0
6243                    );
6244                }
6245            }
6246        }
6247        self.shutdown();
6248    }
6249}
6250
6251// ---------------------------------------------------------------------
6252// Worker threads (Sprint D.5b — per-socket recv + central tick).
6253//
6254// Before: a single `event_loop` that went through three sequential
6255// blocking `recv()`s with a `tick_period` timeout (50 ms) per iteration.
6256// Roundtrip latency: 5-14 ms p50 (CFS drift + sequential wait stages).
6257//
6258// Now: four dedicated threads.
6259//   * recv_spdp_multicast_loop  — blocks on the SPDP multicast socket
6260//   * recv_metatraffic_loop     — blocks on SPDP unicast (= metatraffic)
6261//   * recv_user_data_loop       — blocks on user-data unicast
6262//   * tick_loop                 — periodic outbound tasks +
6263//                                 per-interface inbound (non-blocking) +
6264//                                 deadline/lifespan/liveliness
6265//
6266// Lock discipline: the recv threads and the tick thread contend for
6267// `rt.sedp.lock()` / `rt.wlp.lock()` / per-slot `slot.lock()`.
6268// Convention: keep lock-hold times short (handle_datagram + tick each
6269// have only single-pass logic), no sub-lock under sedp/wlp.
6270// ---------------------------------------------------------------------
6271
6272/// Sprint D.5d lever C — applies SCHED_FIFO + CPU affinity to the
6273/// calling thread. Linux-only; no-op on macOS/Windows.
6274///
6275/// Called by every worker loop right at the start, so
6276/// the syscalls run on the actual worker thread
6277/// (`pthread_self()` must come from the thread itself).
6278///
6279/// Failures are logged to stderr but are not fatal — if
6280/// the process has no `CAP_SYS_NICE`, the runtime continues with
6281/// the CFS default scheduler.
6282#[allow(unused_variables)]
6283fn apply_thread_tuning(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
6284    #[cfg(target_os = "linux")]
6285    rt_pinning::apply(label, priority, cpus);
6286}
6287
6288/// Linux-only `pthread_setschedparam` + `sched_setaffinity` wrapper.
6289/// A dedicated module encapsulates the `unsafe` locally with safety notes; the
6290/// crate-level `#![deny(unsafe_code)]` stays active for the rest of the dcps
6291/// codebase.
6292#[cfg(target_os = "linux")]
6293#[allow(unsafe_code, clippy::print_stderr)]
6294mod rt_pinning {
6295    pub(super) fn apply(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
6296        if let Some(prio) = priority {
6297            // SAFETY: libc FFI with an owned `param` struct. The self-thread via
6298            // `pthread_self()` is always valid.
6299            // musl libc has additional `sched_ss_*` fields (POSIX
6300            // sporadic-server) that we do not set — `mem::zeroed`
6301            // initializes them cleanly to 0.
6302            unsafe {
6303                let mut param: libc::sched_param = core::mem::zeroed();
6304                param.sched_priority = prio;
6305                let rc = libc::pthread_setschedparam(
6306                    libc::pthread_self(),
6307                    libc::SCHED_FIFO,
6308                    &raw const param,
6309                );
6310                if rc != 0 {
6311                    eprintln!(
6312                        "zdds[{label}]: pthread_setschedparam SCHED_FIFO {prio} \
6313                         failed (rc={rc}). Need CAP_SYS_NICE or RLIMIT_RTPRIO."
6314                    );
6315                }
6316            }
6317        }
6318        if let Some(cpu_list) = cpus {
6319            // SAFETY: cpu_set_t is POD; CPU_ZERO/SET are libc inline
6320            // functions without lifetime requirements.
6321            unsafe {
6322                let mut set: libc::cpu_set_t = core::mem::zeroed();
6323                libc::CPU_ZERO(&mut set);
6324                for &cpu in cpu_list {
6325                    if cpu < libc::CPU_SETSIZE as usize {
6326                        libc::CPU_SET(cpu, &mut set);
6327                    }
6328                }
6329                let rc = libc::sched_setaffinity(
6330                    0,
6331                    core::mem::size_of::<libc::cpu_set_t>(),
6332                    &raw const set,
6333                );
6334                if rc != 0 {
6335                    eprintln!("zdds[{label}]: sched_setaffinity({cpu_list:?}) failed.");
6336                }
6337            }
6338        }
6339    }
6340}
6341
6342/// FastDDS interop (phase 2): acknowledges FastDDS' reliable secure SPDP writer
6343/// (0xff0101c2). FastDDS heartbeats its secure SPDP reliably and sends the
6344/// `participant_crypto_tokens` only once our 0xff0101c7 reader has acked its writer
6345/// (fast<->fast reference pcap: ACKNACK on 0xff0101c7). We respond to
6346/// every incoming secure-SPDP HEARTBEAT with an ACKNACK (base = last+1,
6347/// final), addressed via INFO_DST to the sender prefix. Gated on
6348/// `enable_secure_spdp`.
6349#[cfg(feature = "security")]
6350fn secure_spdp_reader_acks(rt: &DcpsRuntime, clear: &[u8]) -> Vec<Vec<u8>> {
6351    use zerodds_rtps::header::RtpsHeader;
6352    use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
6353    use zerodds_rtps::submessages::{AckNackSubmessage, HeartbeatSubmessage, SequenceNumberSet};
6354    use zerodds_rtps::wire_types::SequenceNumber;
6355    if !rt.config.enable_secure_spdp {
6356        return Vec::new();
6357    }
6358    let Ok(parsed) = decode_datagram(clear) else {
6359        return Vec::new();
6360    };
6361    let peer_prefix = parsed.header.guid_prefix;
6362    let mut out = Vec::new();
6363    let mut count = 0i32;
6364    let secure_writer = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER;
6365    let secure_reader = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER;
6366    // Header + INFO_DST(peer) + submessage. INFO_DST is mandatory, otherwise the
6367    // dest prefix is UNKNOWN -> FastDDS discards it as "not a connection".
6368    let wrap = |id: SubmessageId, body: &[u8], flags: u8| -> Option<Vec<u8>> {
6369        let blen = u16::try_from(body.len()).ok()?;
6370        let header = RtpsHeader::new(VendorId::ZERODDS, rt.guid_prefix);
6371        let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
6372        dg.extend_from_slice(&header.to_bytes());
6373        let info = SubmessageHeader {
6374            submessage_id: SubmessageId::InfoDst,
6375            flags: FLAG_E_LITTLE_ENDIAN,
6376            octets_to_next_header: 12,
6377        };
6378        dg.extend_from_slice(&info.to_bytes());
6379        dg.extend_from_slice(&peer_prefix.to_bytes());
6380        let sh = SubmessageHeader {
6381            submessage_id: id,
6382            flags: flags | FLAG_E_LITTLE_ENDIAN,
6383            octets_to_next_header: blen,
6384        };
6385        dg.extend_from_slice(&sh.to_bytes());
6386        dg.extend_from_slice(body);
6387        Some(dg)
6388    };
6389    for sub in &parsed.submessages {
6390        match sub {
6391            // FastDDS' secure-SPDP writer HEARTBEAT -> we ack (reader 0xff0101c7).
6392            ParsedSubmessage::Heartbeat(hb) if hb.writer_id == secure_writer => {
6393                count = count.wrapping_add(1);
6394                let ack = AckNackSubmessage {
6395                    reader_id: secure_reader,
6396                    writer_id: secure_writer,
6397                    reader_sn_state: SequenceNumberSet {
6398                        bitmap_base: SequenceNumber(hb.last_sn.0 + 1),
6399                        num_bits: 0,
6400                        bitmap: Vec::new(),
6401                    },
6402                    count,
6403                    final_flag: true,
6404                };
6405                let (body, flags) = ack.write_body(true);
6406                if let Some(dg) = wrap(SubmessageId::AckNack, &body, flags) {
6407                    out.push(dg);
6408                }
6409            }
6410            // FastDDS' reader requests (preemptive ACKNACK to our 0xff0101c2
6411            // writer) our secure-SPDP data reliably -> deliver DATA(SN=1) +
6412            // HEARTBEAT(1,1), otherwise FastDDS' reader never matches and
6413            // sends no crypto_tokens.
6414            ParsedSubmessage::AckNack(a) if a.writer_id == secure_writer => {
6415                if let Ok(mut beacon) = rt.spdp_beacon.lock() {
6416                    if let Ok(data_dg) = beacon.serialize_secure() {
6417                        out.push(protect_secure_spdp(rt, &data_dg).unwrap_or(data_dg));
6418                    }
6419                }
6420                count = count.wrapping_add(1);
6421                let hbsm = HeartbeatSubmessage {
6422                    reader_id: secure_reader,
6423                    writer_id: secure_writer,
6424                    first_sn: SequenceNumber(1),
6425                    last_sn: SequenceNumber(1),
6426                    count,
6427                    final_flag: false,
6428                    liveliness_flag: false,
6429                    group_info: None,
6430                };
6431                let (body, flags) = hbsm.write_body(true);
6432                if let Some(dg) = wrap(SubmessageId::Heartbeat, &body, flags) {
6433                    out.push(dg);
6434                }
6435            }
6436            _ => {}
6437        }
6438    }
6439    out
6440}
6441
6442/// FastDDS interop (phase 2b): builds a secure-SPDP HEARTBEAT (writer
6443/// 0xff0101c2, first=1/last=1) with INFO_DST to `peer_prefix`. Sent periodically per
6444/// discovered peer, so FastDDS' reliable secure-SPDP reader is solicited to a
6445/// (preemptive) ACKNACK and matches our writer.
6446#[cfg(feature = "security")]
6447fn build_secure_spdp_heartbeat(
6448    local_prefix: GuidPrefix,
6449    peer_prefix: GuidPrefix,
6450    count: i32,
6451) -> Option<Vec<u8>> {
6452    use zerodds_rtps::header::RtpsHeader;
6453    use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
6454    use zerodds_rtps::submessages::HeartbeatSubmessage;
6455    use zerodds_rtps::wire_types::SequenceNumber;
6456    let hb = HeartbeatSubmessage {
6457        reader_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
6458        writer_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
6459        first_sn: SequenceNumber(1),
6460        last_sn: SequenceNumber(1),
6461        count,
6462        final_flag: false,
6463        liveliness_flag: false,
6464        group_info: None,
6465    };
6466    let (body, flags) = hb.write_body(true);
6467    let blen = u16::try_from(body.len()).ok()?;
6468    let header = RtpsHeader::new(VendorId::ZERODDS, local_prefix);
6469    let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
6470    dg.extend_from_slice(&header.to_bytes());
6471    let info = SubmessageHeader {
6472        submessage_id: SubmessageId::InfoDst,
6473        flags: FLAG_E_LITTLE_ENDIAN,
6474        octets_to_next_header: 12,
6475    };
6476    dg.extend_from_slice(&info.to_bytes());
6477    dg.extend_from_slice(&peer_prefix.to_bytes());
6478    let sh = SubmessageHeader {
6479        submessage_id: SubmessageId::Heartbeat,
6480        flags: flags | FLAG_E_LITTLE_ENDIAN,
6481        octets_to_next_header: blen,
6482    };
6483    dg.extend_from_slice(&sh.to_bytes());
6484    dg.extend_from_slice(&body);
6485    Some(dg)
6486}
6487
6488/// FastDDS interop: SEC-protects the secure-SPDP DATA (0xff0101c2) under
6489/// `discovery_protection != NONE` — FastDDS then encrypts the secure-SPDP DATA
6490/// (like the secure SEDP), and a PLAIN secure SPDP is discarded. Wraps
6491/// the DATA submessage with the per-endpoint writer key (0xff0101c2) as
6492/// SEC_PREFIX/BODY/POSTFIX; framing submessages (INFO_*) stay. Without
6493/// discovery_protection (common subset) passthrough. `None` on a crypto error.
6494#[cfg(feature = "security")]
6495fn protect_secure_spdp(rt: &DcpsRuntime, datagram: &[u8]) -> Option<Vec<u8>> {
6496    let gate = rt.config.security.as_ref()?;
6497    if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None
6498        || datagram.len() < 20
6499    {
6500        return Some(datagram.to_vec());
6501    }
6502    let h = local_endpoint_crypto_handle(
6503        rt,
6504        EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
6505        true,
6506    )?;
6507    let mut out = datagram[..20].to_vec();
6508    for (id, start, total) in walk_submessages(datagram) {
6509        let submsg = &datagram[start..start + total];
6510        if id == SMID_DATA {
6511            match gate.encode_data_datawriter_by_handle(h, submsg) {
6512                Ok(s) => out.extend_from_slice(&s),
6513                Err(_) => return None,
6514            }
6515        } else {
6516            out.extend_from_slice(submsg);
6517        }
6518    }
6519    Some(out)
6520}
6521
6522/// Worker: blocks on the SPDP multicast socket, dispatches SPDP beacons +
6523/// WLP heartbeats that come in over multicast.
6524fn recv_spdp_multicast_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6525    apply_thread_tuning(
6526        "recv-spdp-mc",
6527        rt.config.recv_thread_priority,
6528        rt.config.recv_thread_cpus.as_deref(),
6529    );
6530    while !stop.load(Ordering::Relaxed) {
6531        let elapsed = rt.start_instant.elapsed();
6532        let sedp_now = Duration::from_secs(elapsed.as_secs())
6533            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6534        let Ok(dg) = rt.spdp_multicast_rx.recv() else {
6535            continue;
6536        };
6537        #[cfg(feature = "security")]
6538        let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6539        #[cfg(not(feature = "security"))]
6540        let clear = secure_inbound_bytes(&rt, &dg.data);
6541        if let Some(clear) = clear {
6542            handle_spdp_datagram(&rt, &clear);
6543            // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
6544            // reliably, otherwise FastDDS sends no crypto_tokens.
6545            #[cfg(feature = "security")]
6546            for ack in secure_spdp_reader_acks(&rt, &clear) {
6547                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
6548                    let _ = rt.spdp_unicast.send(&loc, &ack);
6549                }
6550            }
6551            // WLP heartbeats arrive on the SPDP multicast socket
6552            // (the sender sends them to the SPDP multicast group).
6553            // handle_spdp_datagram ignores them, so we also feed
6554            // the same buffer into the WLP endpoint. A
6555            // secure-WLP DATA is participant-key SEC-protected → decode
6556            // it first (like secure SEDP in the metatraffic loop), otherwise
6557            // wlp.handle_datagram would only see the SEC block.
6558            #[cfg(feature = "security")]
6559            let wlp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
6560                let mut pk = [0u8; 12];
6561                pk.copy_from_slice(&clear[8..20]);
6562                unprotect_user_datagram(&rt, &clear, &pk)
6563            } else {
6564                None
6565            };
6566            #[cfg(feature = "security")]
6567            let wlp_input: &[u8] = wlp_decoded.as_deref().unwrap_or(&clear);
6568            #[cfg(not(feature = "security"))]
6569            let wlp_input: &[u8] = &clear;
6570            if let Ok(mut wlp) = rt.wlp.lock() {
6571                let _ = wlp.handle_datagram(wlp_input, sedp_now);
6572            }
6573        }
6574    }
6575}
6576
6577/// Worker: blocks on SPDP unicast (= metatraffic socket), dispatches
6578/// SPDP reverse beacons + SEDP + WLP + security builtin.
6579fn recv_metatraffic_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6580    apply_thread_tuning(
6581        "recv-meta",
6582        rt.config.recv_thread_priority,
6583        rt.config.recv_thread_cpus.as_deref(),
6584    );
6585    while !stop.load(Ordering::Relaxed) {
6586        let elapsed = rt.start_instant.elapsed();
6587        let sedp_now = Duration::from_secs(elapsed.as_secs())
6588            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6589        let Ok(dg) = rt.spdp_unicast.recv() else {
6590            continue;
6591        };
6592        #[cfg(feature = "security")]
6593        let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6594        #[cfg(not(feature = "security"))]
6595        let clear = secure_inbound_bytes(&rt, &dg.data);
6596        if let Some(clear) = clear {
6597            // A single recv call, both handlers on the same
6598            // datagram. SPDP first (Cyclone reverse beacons), then
6599            // SEDP, then WLP, then security builtin.
6600            handle_spdp_datagram(&rt, &clear);
6601            // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
6602            // reliably (they arrive unicast over the metatraffic socket).
6603            #[cfg(feature = "security")]
6604            for ack in secure_spdp_reader_acks(&rt, &clear) {
6605                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
6606                    let _ = rt.spdp_unicast.send(&loc, &ack);
6607                }
6608            }
6609            // Protected discovery: secure-SEDP DATA is SEC_* submessage-
6610            // protected (the sender's participant data key). Before the SEDP parse
6611            // decode it with the sender prefix (RTPS header bytes[8..20]); for
6612            // plaintext SEDP (no SEC_*) unprotect_user_datagram returns None
6613            // and we use `clear` unchanged.
6614            #[cfg(feature = "security")]
6615            let sedp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
6616                let mut pk = [0u8; 12];
6617                pk.copy_from_slice(&clear[8..20]);
6618                unprotect_user_datagram(&rt, &clear, &pk)
6619            } else {
6620                None
6621            };
6622            // OPEN (phase 3, docs/security/per-endpoint-crypto-followup.md):
6623            // if `unprotect_user_datagram` fails for a secure-SEDP DATA
6624            // (cyclone's per-endpoint token not yet installed — race),
6625            // `sedp_input` falls back to the SEC_* bytes and the DATA is discarded.
6626            // Cross-vendor (discovery=ENCRYPT) must make this deterministic:
6627            // treat the reliable secure-SEDP DATA as not-received (NACK,
6628            // no SN advance), so the re-send after token install decodes.
6629            #[cfg(feature = "security")]
6630            let sedp_input: &[u8] = sedp_decoded.as_deref().unwrap_or(&clear);
6631            #[cfg(not(feature = "security"))]
6632            let sedp_input: &[u8] = &clear;
6633            let events = {
6634                if let Ok(mut sedp) = rt.sedp.lock() {
6635                    sedp.handle_datagram(sedp_input, sedp_now).ok()
6636                } else {
6637                    None
6638                }
6639            };
6640            if let Some(ev) = events {
6641                if !ev.is_empty() {
6642                    run_matching_pass(&rt);
6643                    push_sedp_events_to_builtin_readers(&rt, &ev);
6644                }
6645            }
6646
6647            // Secure WLP (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER) is, like
6648            // secure SEDP, participant-key SEC-protected → feed the decoded variant
6649            // (sedp_input), not the still SEC-wrapped `clear`. For
6650            // plaintext WLP, sedp_input == clear.
6651            let wlp_resends = if let Ok(mut wlp) = rt.wlp.lock() {
6652                let _ = wlp.handle_datagram(sedp_input, sedp_now);
6653                // Reliable resend: if the peer NACKs our (secure-)WLP writer,
6654                // we re-emit the missing beats (cyclone treats WLP as
6655                // reliable; without a resend it would never get the liveliness assertion).
6656                wlp.wlp_acknack_resends(sedp_input)
6657            } else {
6658                Vec::new()
6659            };
6660            for beat in wlp_resends {
6661                if let Some(secured) = protect_wlp_outbound(&rt, &beat) {
6662                    for loc in wlp_unicast_targets(&rt.discovered_participants()) {
6663                        let _ = rt.spdp_unicast.send(&loc, &secured);
6664                    }
6665                }
6666            }
6667            for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
6668                send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
6669            }
6670        }
6671    }
6672}
6673
6674/// Worker: wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) — per-owner
6675/// SHM recv loop. Iterates round-robin over all bound-consumer
6676/// entries of the [`SameHostTracker`](crate::same_host::SameHostTracker)
6677/// and calls `recv()` with the configured per-transport timeout
6678/// (50 ms default). On data, dispatches via [`handle_user_datagram`]
6679/// analogous to the UDP path.
6680///
6681/// Latency tradeoff: with N consumers the worst-case latency
6682/// for a sample is (N-1) × recv_timeout. Acceptable for small
6683/// N (typically <10 same-host peers); for larger topologies
6684/// this would have to be switched to multiple threads or epoll-style
6685/// multiplexing (wave 4b.4 follow-up).
6686#[cfg(feature = "same-host-shm")]
6687fn recv_user_shm_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6688    use crate::same_host::{Role, SameHostState};
6689    use zerodds_transport::Transport;
6690    use zerodds_transport_shm::PosixShmTransport;
6691
6692    apply_thread_tuning(
6693        "recv-shm",
6694        rt.config.recv_thread_priority,
6695        rt.config.recv_thread_cpus.as_deref(),
6696    );
6697    let idle_sleep = Duration::from_millis(100);
6698    while !stop.load(Ordering::Relaxed) {
6699        // SHM bind now happens synchronously in the SEDP hook (transport-shm
6700        // 2026-05-19 idempotent open_or_create). Here only the bound-
6701        // consumer drain — no lazy retry needed anymore.
6702        let consumers: Vec<Arc<PosixShmTransport>> = rt
6703            .same_host
6704            .snapshot()
6705            .into_iter()
6706            .filter_map(|(_, _, state)| match state {
6707                SameHostState::Bound { transport, role } => {
6708                    if !matches!(role, Role::Consumer) {
6709                        return None;
6710                    }
6711                    transport.downcast::<PosixShmTransport>().ok()
6712                }
6713                _ => None,
6714            })
6715            .collect();
6716        if consumers.is_empty() {
6717            thread::sleep(idle_sleep);
6718            continue;
6719        }
6720        let elapsed = rt.start_instant.elapsed();
6721        let sedp_now = Duration::from_secs(elapsed.as_secs())
6722            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6723        for consumer in &consumers {
6724            if stop.load(Ordering::Relaxed) {
6725                break;
6726            }
6727            match consumer.recv() {
6728                Ok(dg) => {
6729                    // Security gate (analogous to the UDP path). SHM is
6730                    // same-host-only — if the policy allows plaintext,
6731                    // the datagram comes through unchanged.
6732                    #[cfg(feature = "security")]
6733                    let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6734                    #[cfg(not(feature = "security"))]
6735                    let clear = secure_inbound_bytes(&rt, &dg.data);
6736                    if let Some(clear) = clear {
6737                        handle_user_datagram(&rt, &clear, sedp_now);
6738                    }
6739                }
6740                // A timeout is normal — recv has the configured
6741                // 50 ms limit, an empty segment is not an error.
6742                Err(zerodds_transport::RecvError::Timeout) => {}
6743                Err(_) => {
6744                    // Hard error (broken segment, peer crashed).
6745                    // We could set the tracker entry to
6746                    // Failed here — for the first cut we leave
6747                    // it at silence + the UDP fallback
6748                    // stays active.
6749                }
6750            }
6751        }
6752    }
6753}
6754
6755/// Worker: blocks on the user-data unicast socket, dispatches
6756/// TypeLookup service replies + user-sample datagrams.
6757///
6758/// Int-1 (Spec `zerodds-zero-copy-1.0` §9): with the feature
6759/// `recvmmsg-batch` on Linux the loop uses `recv_batch_linux` and
6760/// fetches up to 32 datagrams per syscall — a 7-8x throughput boost.
6761/// On an empty batch the path falls back to single-recv() so
6762/// the recv thread does not spin in a busy loop at low traffic.
6763fn recv_user_data_loop(
6764    rt: Arc<DcpsRuntime>,
6765    socket: Arc<dyn Transport + Send + Sync>,
6766    stop: Arc<AtomicBool>,
6767) {
6768    apply_thread_tuning(
6769        "recv-user",
6770        rt.config.recv_thread_priority,
6771        rt.config.recv_thread_cpus.as_deref(),
6772    );
6773    // recvmmsg-batch (Linux + feature) needs the concrete UdpSocket
6774    // under the trait. With a trait-object transport this is not directly
6775    // accessible — we fall back to single-recv(). recvmmsg is
6776    // a UDP optimization; once TCP/SHM transports are to be mixed,
6777    // it is no longer worth it. For a pure UDPv4 user transport
6778    // this costs ~5-10% throughput in Linux batch mode (measured 2026-05).
6779    while !stop.load(Ordering::Relaxed) {
6780        let elapsed = rt.start_instant.elapsed();
6781        let sedp_now = Duration::from_secs(elapsed.as_secs())
6782            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6783        let Ok(dg) = socket.recv() else {
6784            continue;
6785        };
6786        dispatch_user_datagram(&rt, &dg, sedp_now);
6787        // D.5e Phase 3 — incoming user data may solicit an ACKNACK or advance a
6788        // reliable reader: wake the scheduler tick immediately (no 5 ms tail).
6789        rt.raise_tick_wake();
6790    }
6791}
6792
6793/// Helper: dispatches a single user datagram through the security gate +
6794/// TypeLookup + handle_user_datagram. Shared by the single-recv and the
6795/// recvmmsg batch path.
6796fn dispatch_user_datagram(
6797    rt: &Arc<DcpsRuntime>,
6798    dg: &zerodds_transport::ReceivedDatagram,
6799    sedp_now: Duration,
6800) {
6801    #[cfg(feature = "security")]
6802    let clear = secure_inbound_bytes(rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6803    #[cfg(not(feature = "security"))]
6804    let clear = secure_inbound_bytes(rt, &dg.data);
6805    if let Some(clear) = clear {
6806        // TypeLookup service first — if the frame is addressed to
6807        // TL_SVC_*_READER, it does not go to a
6808        // user reader. Other frames fall through.
6809        if !dispatch_type_lookup_datagram(rt, &clear, &dg.source) {
6810            handle_user_datagram(rt, &clear, sedp_now);
6811        }
6812    }
6813}
6814
6815/// Worker: periodic outbound tasks + per-interface inbound
6816/// (non-blocking) + housekeeping. Sleeps `tick_period` between
6817/// iterations.
6818fn tick_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6819    apply_thread_tuning(
6820        "tick",
6821        rt.config.tick_thread_priority,
6822        rt.config.tick_thread_cpus.as_deref(),
6823    );
6824    let mut st = TickState::new(&rt);
6825    while !stop.load(Ordering::Relaxed) {
6826        run_tick_iteration(Arc::clone(&rt), &mut st);
6827        // Housekeeping runs inline here in the classic fixed-period path,
6828        // exactly as before (every `tick_period`, same cadence).
6829        tick_housekeep(&rt, rt.start_instant.elapsed());
6830        std::thread::sleep(rt.config.tick_period);
6831    }
6832}
6833
6834/// D.5e Phase 3 — idle park cap for a discovery-only participant (no user
6835/// endpoints): how long the scheduler tick worker may sleep when nothing but
6836/// SPDP/WLP is pending. SPDP/WLP fire on their own (longer) periods, so this is
6837/// just a safety heartbeat — well above the 5 ms poll it replaces.
6838const SCHEDULER_IDLE_FLOOR: Duration = Duration::from_millis(250);
6839
6840/// Earliest instant the scheduler tick worker must next run `run_tick_iteration`
6841/// so no periodic work is delayed: never past the next SPDP announce, and —
6842/// while user endpoints exist — capped at `tick_period` so HEARTBEAT/ACKNACK/
6843/// deadline/lifespan/liveliness keep their current cadence (identical wire
6844/// behaviour). With no user endpoints, parks up to [`SCHEDULER_IDLE_FLOOR`].
6845/// Active traffic is handled out-of-band by `raise_tick_wake` (immediate).
6846fn next_tick_deadline(rt: &Arc<DcpsRuntime>, st: &TickState) -> Instant {
6847    let now = Instant::now();
6848    let fine_cap = if rt.has_user_endpoints() {
6849        rt.config.tick_period
6850    } else {
6851        SCHEDULER_IDLE_FLOOR
6852    };
6853    st.next_announce.min(now + fine_cap).max(now)
6854}
6855
6856/// D.5e Phase 3 B-2 — the kinds of work the deadline-heap scheduler fires as
6857/// distinct heap events, each re-armed at its own next deadline.
6858#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6859enum TickEvent {
6860    /// Periodic SPDP announce + reliable outbound (SEDP / WLP / user HEARTBEAT /
6861    /// ACKNACK) + secondary inbound poll — the wire-producing tick
6862    /// ([`run_tick_iteration`]), re-armed at [`next_tick_deadline`].
6863    Tick,
6864    /// Deadline / lifespan / liveliness housekeeping ([`tick_housekeep`]),
6865    /// re-armed at the **exact** next QoS due-instant (no fixed quantum).
6866    Housekeep,
6867}
6868
6869/// D.5e Phase 3 — event-driven scheduler tick worker. Replaces the fixed-period
6870/// `tick_loop` sleep with a deadline-heap park. Two independent event streams:
6871/// [`TickEvent::Tick`] drives the **unchanged** `run_tick_iteration` (wire
6872/// output byte-identical to `tick_loop`), re-armed at [`next_tick_deadline`];
6873/// [`TickEvent::Housekeep`] runs the QoS checks, re-armed at their exact next
6874/// due-instant so a deadline/lifespan/liveliness fires on time instead of up to
6875/// one `tick_period` late, and an idle participant parks long. A write/recv
6876/// `raise_tick_wake` wakes **both** immediately, so freshly-armed QoS windows
6877/// are picked up without delay.
6878fn scheduler_tick_loop(
6879    rt: Arc<DcpsRuntime>,
6880    stop: Arc<AtomicBool>,
6881    mut scheduler: crate::scheduler::Scheduler<TickEvent>,
6882    handle: crate::scheduler::SchedulerHandle<TickEvent>,
6883) {
6884    apply_thread_tuning(
6885        "tick",
6886        rt.config.tick_thread_priority,
6887        rt.config.tick_thread_cpus.as_deref(),
6888    );
6889    let mut st = TickState::new(&rt);
6890    // Prime both event streams immediately.
6891    handle.raise_now(TickEvent::Tick);
6892    handle.raise_now(TickEvent::Housekeep);
6893    loop {
6894        let (due, stopped) = scheduler.park_due_batch();
6895        if stopped || stop.load(Ordering::Relaxed) {
6896            break;
6897        }
6898        if due.is_empty() {
6899            continue; // woken with nothing due yet — re-evaluate.
6900        }
6901        // Coalesce: a batch of wakes maps to at most ONE run of each kind.
6902        let mut do_tick = false;
6903        let mut do_housekeep = false;
6904        for ev in due {
6905            match ev {
6906                TickEvent::Tick => do_tick = true,
6907                TickEvent::Housekeep => do_housekeep = true,
6908            }
6909        }
6910        if do_tick {
6911            rt.tick_wake_pending.store(false, Ordering::Release);
6912            run_tick_iteration(Arc::clone(&rt), &mut st);
6913            if stop.load(Ordering::Relaxed) {
6914                break;
6915            }
6916            handle.raise_at(next_tick_deadline(&rt, &st), TickEvent::Tick);
6917        }
6918        if do_housekeep {
6919            let next = tick_housekeep(&rt, rt.start_instant.elapsed());
6920            if stop.load(Ordering::Relaxed) {
6921                break;
6922            }
6923            // Park exactly until the next QoS due-instant; nothing pending →
6924            // idle floor (a later write re-arms via `raise_tick_wake`).
6925            let deadline = match next {
6926                Some(due_nanos) => rt.start_instant + Duration::from_nanos(due_nanos),
6927                None => Instant::now() + SCHEDULER_IDLE_FLOOR,
6928            };
6929            handle.raise_at(deadline, TickEvent::Housekeep);
6930        }
6931    }
6932}
6933
6934/// Per-iteration mutable state of the runtime tick. Held across iterations so
6935/// the same body ([`run_tick_iteration`]) can be driven from either the
6936/// dedicated `zdds-tick` thread (default) or an external executor — tokio via
6937/// [`DcpsRuntime::tick_driver`] / async `spawn_in_tokio`
6938/// (zerodds-async-1.0 §4).
6939struct TickState {
6940    /// Multicast target locator to which we send SPDP beacons.
6941    mc_target: Locator,
6942    /// Next instant at which a periodic SPDP announce is due.
6943    next_announce: Instant,
6944    /// Number of SPDP announces already sent. Drives the C3 initial
6945    /// announcement burst: as long as `< initial_announce_count` **and** no
6946    /// peer discovered yet, announces happen at `initial_announce_period` cadence
6947    /// instead of the full `spdp_period` — so discovery over lossy/power-save WiFi
6948    /// does not fail on lost first beacons.
6949    announces_done: u32,
6950    /// FastDDS interop: count for the periodic secure-SPDP HEARTBEATs
6951    /// (0xff0101c2). Must increase, otherwise FastDDS' reader ignores follow-up HBs.
6952    #[cfg(feature = "security")]
6953    secure_hb_count: i32,
6954}
6955
6956impl TickState {
6957    fn new(rt: &Arc<DcpsRuntime>) -> Self {
6958        let mc_target = Locator {
6959            kind: LocatorKind::UdpV4,
6960            port: u32::from(
6961                u16::try_from(spdp_multicast_port(rt.domain_id as u32)).unwrap_or(7400),
6962            ),
6963            address: {
6964                let mut a = [0u8; 16];
6965                a[12..].copy_from_slice(&rt.config.spdp_multicast_group.octets());
6966                a
6967            },
6968        };
6969        Self {
6970            mc_target,
6971            next_announce: Instant::now(), // immediately at start
6972            announces_done: 0,
6973            #[cfg(feature = "security")]
6974            secure_hb_count: 0,
6975        }
6976    }
6977}
6978
6979/// One iteration of the runtime's **wire** tick: periodic SPDP announce,
6980/// SEDP/WLP ticks, per-user-writer + per-user-reader ticks, secondary inbound
6981/// poll. QoS housekeeping (deadline/lifespan/liveliness) is **not** part of this
6982/// — each driver calls [`tick_housekeep`] separately (D.5e Phase 3 B-2), so the
6983/// event-driven scheduler can fire it on its own exact-deadline schedule.
6984/// Mutable per-iteration state lives in `st`; the caller waits `tick_period`
6985/// between calls. Factored out of [`tick_loop`] so an external executor can
6986/// drive the tick without the dedicated thread (zerodds-async-1.0 §4).
6987fn run_tick_iteration(rt: Arc<DcpsRuntime>, st: &mut TickState) {
6988    // Monotonic clock relative to runtime start. Used by the SEDP,
6989    // WLP and user tick alike.
6990    let elapsed_since_start = rt.start_instant.elapsed();
6991    let sedp_now = Duration::from_secs(elapsed_since_start.as_secs())
6992        + Duration::from_nanos(u64::from(elapsed_since_start.subsec_nanos()));
6993
6994    // --- Periodic SPDP announce ---
6995    // FU2 cross-vendor (cyclone-trace-documented): a secured participant MUST
6996    // NOT announce before its security builtins are enabled — otherwise
6997    // a token-less/non-secure first beacon goes out, which foreign vendors
6998    // (cyclone: "Non secure remote ... not allowed by security") latch as
6999    // non-secure and, on the later token beacon, treat ONLY as a QoS update
7000    // (no security re-evaluation) → the handshake never starts.
7001    // `config.security.is_some()` = secured runtime; until
7002    // `enable_security_builtins*` installs the stack (snapshot Some) +
7003    // sets the token/security-info on the beacon, we hold the beacon
7004    // back. enable() triggers the first token-carrying beacon via
7005    // `announce_spdp_now()`. Plain runtimes (security None) announce
7006    // immediately as before.
7007    #[cfg(feature = "security")]
7008    let security_pending = rt.config.security.is_some() && rt.security_builtin_snapshot().is_none();
7009    #[cfg(not(feature = "security"))]
7010    let security_pending = false;
7011    if Instant::now() >= st.next_announce && !security_pending {
7012        let secured_beacon: Option<Vec<u8>> = {
7013            if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7014                beacon
7015                    .serialize()
7016                    .ok()
7017                    .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7018            } else {
7019                None
7020            }
7021        };
7022        if let Some(secured) = secured_beacon {
7023            let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7024            // C1 multicast-free discovery: additionally to all configured
7025            // initial peers (ZERODDS_PEERS) — bootstrap without multicast.
7026            rt.send_spdp_to_initial_peers(&secured);
7027            // SPDP unicast fan-out to discovered peers (analogous to WLP-M-2/H-3-H-4):
7028            // codepit-LXC multicast is flaky; if it loses the tokened
7029            // secure beacon, the peer never discovers ZeroDDS as secure and
7030            // NEVER starts the auth handshake (cyclone→ZeroDDS responder hung
7031            // exactly here: HS_DISPATCH=0). From the metatraffic recv socket
7032            // (spdp_unicast), so the source port is correct.
7033            // Periodic directed unicast fan-out to discovered peers:
7034            // codepit-LXC multicast is flaky; if it loses the tokened
7035            // beacon, the peer never discovers ZeroDDS as secure and never starts
7036            // the auth handshake. The unicast refresh (every spdp_period) robustly
7037            // covers lost multicasts + late joiners. (Previously disabled for a
7038            // flaky-diag experiment — reactivated as a regular path,
7039            // complements the event-driven directed response in handle_spdp_datagram.)
7040            for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7041                let _ = rt.spdp_unicast.send(&loc, &secured);
7042            }
7043        }
7044        // FastDDS interop: announce in parallel on the reliable secure-SPDP writer
7045        // (0xff0101c2). FastDDS announces its full secured
7046        // participant data over this channel and gates the crypto-token
7047        // reciprocation on it; without our secure SPDP it never sees ZeroDDS there
7048        // and reciprocates no datawriter/datareader tokens.
7049        #[cfg(feature = "security")]
7050        if rt.config.enable_secure_spdp {
7051            let secure_beacon: Option<Vec<u8>> = {
7052                if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7053                    beacon
7054                        .serialize_secure()
7055                        .ok()
7056                        .and_then(|d| protect_secure_spdp(&rt, &d))
7057                        .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7058                } else {
7059                    None
7060                }
7061            };
7062            if let Some(secured) = secure_beacon {
7063                let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7064                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7065                    let _ = rt.spdp_unicast.send(&loc, &secured);
7066                }
7067            }
7068            // Secure-SPDP HEARTBEAT per peer (INFO_DST), so FastDDS' reader
7069            // — even as a late joiner — is solicited to a (preemptive) ACKNACK
7070            // and matches our 0xff0101c2 writer. Without a HEARTBEAT
7071            // FastDDS does not engage our writer (fastdds->zerodds: 0 ACKNACK).
7072            st.secure_hb_count = st.secure_hb_count.wrapping_add(1);
7073            for p in rt.discovered_participants() {
7074                let peer_prefix = p.data.guid.prefix;
7075                if let Some(hb) =
7076                    build_secure_spdp_heartbeat(rt.guid_prefix, peer_prefix, st.secure_hb_count)
7077                {
7078                    for loc in wlp_unicast_targets(core::slice::from_ref(&p)) {
7079                        let _ = rt.spdp_unicast.send(&loc, &hb);
7080                    }
7081                }
7082            }
7083        }
7084        // C3 WiFi robustness — initial announcement burst: as long as we have
7085        // not discovered a peer yet and the burst count is not exhausted,
7086        // announce at the fast `initial_announce_period` cadence. Over
7087        // lossy/power-save WiFi the first beacons often get lost in the cold-start
7088        // or sleep window; a single announce + 5s period
7089        // then leads to `participants=0`. The burst keeps the NIC awake through
7090        // frequent TX, keeps the stateful-firewall pinhole open and
7091        // elicits directed SPDP responses that arrive in the wake windows
7092        // — analogous to FastDDS `initial_announcements`. As soon as a peer
7093        // is discovered, the cadence falls back to the full `spdp_period`.
7094        st.announces_done = st.announces_done.saturating_add(1);
7095        rt.spdp_announce_seq.fetch_add(1, Ordering::Relaxed);
7096        let still_searching = st.announces_done < rt.config.initial_announce_count
7097            && rt.discovered_participants().is_empty();
7098        let period = if still_searching {
7099            rt.config.initial_announce_period
7100        } else {
7101            rt.config.spdp_period
7102        };
7103        st.next_announce = Instant::now() + period;
7104    }
7105
7106    // (SPDP multicast recv: now in `recv_spdp_multicast_loop`.)
7107
7108    // --- SEDP-Tick (outbound HEARTBEAT/Resend/ACKNACK) ---
7109    let sedp_outbound = {
7110        if let Ok(mut sedp) = rt.sedp.lock() {
7111            sedp.tick(sedp_now).unwrap_or_default()
7112        } else {
7113            Vec::new()
7114        }
7115    };
7116    for dg in sedp_outbound {
7117        // Protected discovery: SEC_*-protect secure-SEDP DATA/HEARTBEAT/GAP
7118        // (participant data key). Non-secure SEDP goes unchanged; on a
7119        // crypto error on secure SEDP it is dropped (no plaintext leak).
7120        #[cfg(feature = "security")]
7121        {
7122            if let Some(inner) = protect_sedp_outbound(&rt, &dg.bytes) {
7123                // discovery_protection has SEC-wrapped the secure SEDP per-submessage
7124                // (SEC_PREFIX/BODY/POSTFIX, per-endpoint key). Under
7125                // rtps_protection SRTPS MUST additionally go on top — BOTH layers,
7126                // like cyclone<->cyclone (reference pcap: 0x "clear submsg from
7127                // protected src"). send_discovery_datagram -> secure_outbound_bytes
7128                // would classify the SEC_PREFIX datagram as volatile-Kx (which is
7129                // RIGHTLY SRTPS-exempt, because its key only comes over the volatile
7130                // itself) and skip SRTPS -> cyclone would see the
7131                // secure SEDP clear, discard ACKNACK/HEARTBEAT as "clear submsg
7132                // from protected src" and never re-send the SubscriptionData ->
7133                // ZeroDDS' writer never matches cyclone's reader (wait_for_matched
7134                // timeout). Hence wrap SRTPS EXPLICITLY here instead of via the
7135                // generic exempt heuristic.
7136                let final_bytes: Option<Vec<u8>> = match &rt.config.security {
7137                    Some(gate)
7138                        if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
7139                            != ProtectionLevel::None =>
7140                    {
7141                        gate.transform_outbound(&inner).ok()
7142                    }
7143                    _ => Some(inner),
7144                };
7145                if let Some(fb) = final_bytes {
7146                    for t in dg.targets.iter() {
7147                        if is_routable_user_locator(t) {
7148                            let _ = rt.spdp_unicast.send(t, &fb);
7149                        }
7150                    }
7151                }
7152            }
7153        }
7154        #[cfg(not(feature = "security"))]
7155        send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7156    }
7157
7158    // --- Security-Builtin-Tick ---
7159    // Volatile-Secure-Writer heartbeats + Volatile-Secure-Reader
7160    // ACKNACK/NACK_FRAG. Stateless hat keinen Tick (BestEffort).
7161    if let Some(stack) = rt.security_builtin_snapshot() {
7162        let outbound = {
7163            if let Ok(mut s) = stack.lock() {
7164                // `out` is only mutated under feature="security" (reassign +
7165                // extend in the cfg block below); otherwise unused_mut in the no-security build.
7166                #[allow(unused_mut)]
7167                let mut out = s.poll(sedp_now).unwrap_or_default();
7168                #[cfg(feature = "security")]
7169                if rt.config.security.is_some() {
7170                    // STABLE peer list: `completed_peer_prefixes()` reads
7171                    // `self.handshakes`, which is GC'd after handshake completion
7172                    // → the LATE volatile RESENDS/HEARTBEATs (tick, long after
7173                    // completion) would then find NO peer anymore (`peers.len()!=1`)
7174                    // and go out CLEAR → cyclone discards them as "clear
7175                    // submsg from protected src". The stabler `authenticated_peer_
7176                    // prefixes()` (the installed Kx key stays) — identical to the
7177                    // token-send tick further below.
7178                    let peers: Vec<GuidPrefix> = rt
7179                        .config
7180                        .security
7181                        .as_ref()
7182                        .map(|g| {
7183                            g.authenticated_peer_prefixes()
7184                                .into_iter()
7185                                .map(GuidPrefix::from_bytes)
7186                                .collect()
7187                        })
7188                        .unwrap_or_default();
7189                    // The reliable volatile submessages from poll() (DATA RESENDS
7190                    // + HEARTBEAT + GAP) must — like the first send — be SEC_*-
7191                    // protected (§8.4.2.4, all writer submessages incl.
7192                    // HEARTBEAT). protect_volatile_datagram now protects all
7193                    // is_protected_writer_submessage. With exactly one peer
7194                    // (bench) with its Kx key.
7195                    if peers.len() == 1 {
7196                        let pk = peers[0].to_bytes();
7197                        out = out
7198                            .into_iter()
7199                            .filter_map(|dg| {
7200                                protect_volatile_datagram(&rt, &dg.bytes, &pk).map(|bytes| {
7201                                    zerodds_rtps::message_builder::OutboundDatagram {
7202                                        bytes,
7203                                        targets: dg.targets,
7204                                    }
7205                                })
7206                            })
7207                            .collect();
7208                    }
7209                    // FU2 step 6b: send per-endpoint datawriter/datareader crypto
7210                    // tokens to every authenticated peer as soon as the
7211                    // local user endpoints exist.
7212                    //
7213                    // STABLE peer list instead of `completed_peer_prefixes()`: the
7214                    // handshake entry is GC'd after completion, so a
7215                    // late-matching user writer/reader (user endpoints match
7216                    // AFTER the secure SEDP) would find no tick window in which
7217                    // its per-endpoint token would go out — the peer could then never
7218                    // decode ZeroDDS' user DATA (#29). `authenticated_peer_
7219                    // prefixes()` (the installed data key) stays.
7220                    let token_peers: Vec<GuidPrefix> = rt
7221                        .config
7222                        .security
7223                        .as_ref()
7224                        .map(|g| {
7225                            g.authenticated_peer_prefixes()
7226                                .into_iter()
7227                                .map(GuidPrefix::from_bytes)
7228                                .collect()
7229                        })
7230                        .unwrap_or_default();
7231                    for prefix in token_peers {
7232                        // Per-token dedup (#29): each per-endpoint token
7233                        // exactly once — builtins early, user endpoints
7234                        // as soon as they match. A per-peer guard would
7235                        // block late-matched user endpoints forever.
7236                        let already = rt
7237                            .endpoint_tokens_sent
7238                            .read()
7239                            .map(|set| set.clone())
7240                            .unwrap_or_default();
7241                        let pending = pending_endpoint_tokens(
7242                            prepare_endpoint_crypto_tokens(&rt, prefix),
7243                            &already,
7244                        );
7245                        for ep_msg in pending {
7246                            let key = endpoint_token_key(&ep_msg);
7247                            out.extend(protect_volatile_outbound(
7248                                &rt,
7249                                prefix,
7250                                s.volatile_writer
7251                                    .write_with_heartbeat(&ep_msg, sedp_now)
7252                                    .unwrap_or_default(),
7253                            ));
7254                            if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
7255                                set.insert(key);
7256                            }
7257                        }
7258                    }
7259                }
7260                out
7261            } else {
7262                Vec::new()
7263            }
7264        };
7265        for dg in outbound {
7266            send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7267        }
7268    }
7269
7270    // --- WLP-Tick (Writer-Liveliness-Protocol Heartbeats) ---
7271    //
7272    // RTPS 2.5 §8.4.13: WLP heartbeats are metatraffic.
7273    // Spec recommendation: multicast to all known peers, one
7274    // heartbeat per `lease_duration / 3`. We send via the
7275    // SPDP multicast sender — that is the same socket that
7276    // sends out the SPDP beacons, and it ensures that all
7277    // peers see the WLP pulses without the runtime having to
7278    // look up a unicast locator per peer.
7279    let wlp_outbound = {
7280        if let Ok(mut wlp) = rt.wlp.lock() {
7281            // Use the secure-WLP entity when liveliness_protection != NONE
7282            // (set idempotently per tick — follows the current governance).
7283            wlp.set_secure(wlp_liveliness_protected(&rt));
7284            wlp.tick(sedp_now).unwrap_or(None)
7285        } else {
7286            None
7287        }
7288    };
7289    if let Some(bytes) = wlp_outbound {
7290        // Under liveliness_protection != NONE the secure-WLP DATA is protected
7291        // with the participant key (§8.4.2.4); otherwise rtps-level/plaintext.
7292        if let Some(secured) = protect_wlp_outbound(&rt, &bytes) {
7293            // Multicast to all peers (spec recommendation §8.4.13)...
7294            let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7295            // ...plus unicast to every discovered peer (M-2), so WLP also
7296            // arrives without multicast (container/cloud). From the metatraffic recv
7297            // socket (spdp_unicast), so the source port is correct (cf. H-3/H-4).
7298            for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7299                let _ = rt.spdp_unicast.send(&loc, &secured);
7300            }
7301        }
7302    }
7303
7304    // (Metatraffic unicast recv: now in `recv_metatraffic_loop`.)
7305
7306    // --- User-Writer-Tick (HEARTBEAT + Resends) ---
7307    //
7308    // Security: per-target serializer. A datagram can go to
7309    // multiple reader locators. Per target we pull it
7310    // individually through `secure_outbound_for_target`, so the
7311    // wire payload matches the protection class of the respective reader.
7312    let user_writer_outbound: Vec<(EntityId, _)> = {
7313        let mut all = Vec::new();
7314        for (eid, arc) in rt.writer_slots_snapshot() {
7315            if let Ok(mut slot) = arc.lock() {
7316                if let Ok(dgs) = slot.writer.tick(sedp_now) {
7317                    for dg in dgs {
7318                        all.push((eid, dg));
7319                    }
7320                }
7321            }
7322        }
7323        all
7324    };
7325    for (writer_eid, dg) in user_writer_outbound {
7326        for t in dg.targets.iter() {
7327            if !is_routable_user_locator(t) {
7328                continue;
7329            }
7330            if let Some(secured) = secure_outbound_for_target(&rt, writer_eid, &dg.bytes, t) {
7331                send_on_best_interface(&rt, t, &secured);
7332            }
7333        }
7334    }
7335
7336    // --- User-Reader-Tick-Outbound (ACKNACK / NACK_FRAG) ---
7337    let user_reader_outbound: Vec<_> = {
7338        let mut all = Vec::new();
7339        for (_eid, arc) in rt.reader_slots_snapshot() {
7340            if let Ok(mut slot) = arc.lock() {
7341                if let Ok(dgs) = slot.reader.tick_outbound(sedp_now) {
7342                    all.extend(dgs);
7343                }
7344            }
7345        }
7346        all
7347    };
7348    for dg in user_reader_outbound {
7349        if let Some(secured) = protect_user_reader_datagram(&rt, &dg.bytes) {
7350            for t in dg.targets.iter() {
7351                if is_routable_user_locator(t) {
7352                    let _ = rt.user_unicast.send(t, &secured);
7353                }
7354            }
7355        }
7356    }
7357
7358    // (User-data unicast recv: now in `recv_user_data_loop`.)
7359
7360    // --- Per-interface inbound ---
7361    //
7362    // Each pool binding is polled non-blocking; the
7363    // received datagram goes through `secure_inbound_bytes` with
7364    // the matching NetInterface class. This lets the
7365    // PolicyEngine make interface-specific decisions
7366    // (e.g. accept loopback-plain on a protected domain).
7367    //
7368    // The non-blocking semantics are achieved by each socket
7369    // in `bind_all` holding a short read timeout — see
7370    // `OutboundSocketPool::bind_all`. Without a timeout the
7371    // event loop would hang on an empty binding per tick.
7372    #[cfg(feature = "security")]
7373    if let Some(pool) = &rt.outbound_pool {
7374        for binding in &pool.bindings {
7375            while let Ok(dg) = binding.socket.recv() {
7376                let iface = binding.spec.kind.clone();
7377                if let Some(clear) = secure_inbound_bytes(&rt, &dg.data, &iface) {
7378                    // Try SPDP first (reverse beacons), then
7379                    // SEDP, then user data — same dispatch as
7380                    // for the legacy sockets.
7381                    handle_spdp_datagram(&rt, &clear);
7382                    let events = rt
7383                        .sedp
7384                        .lock()
7385                        .ok()
7386                        .and_then(|mut s| s.handle_datagram(&clear, sedp_now).ok());
7387                    if let Some(ev) = events {
7388                        if !ev.is_empty() {
7389                            run_matching_pass(&rt);
7390                            push_sedp_events_to_builtin_readers(&rt, &ev);
7391                        }
7392                    }
7393                    if !dispatch_type_lookup_datagram(&rt, &clear, &dg.source) {
7394                        handle_user_datagram(&rt, &clear, sedp_now);
7395                    }
7396                    // DDS-Security 1.2 §7.4.2 Builtin-Endpoints
7397                    for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
7398                        send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7399                    }
7400                }
7401            }
7402        }
7403    }
7404
7405    // Housekeeping (deadline/lifespan/liveliness) runs as a separate
7406    // `tick_housekeep` call of the respective driver (tick_loop /
7407    // tick_driver / scheduler_tick_loop) — see `tick_housekeep`.
7408
7409    // Diagnostic: mark this iteration complete so `tick_count()` advances
7410    // whether driven by the internal thread or an external executor.
7411    rt.tick_seq.fetch_add(1, Ordering::Relaxed);
7412}
7413
7414/// Min tracker for the earliest "next-due" instant (nanos in the runtime
7415/// `elapsed` time base) across multiple housekeeping sources.
7416struct NextDue(Option<u64>);
7417
7418impl NextDue {
7419    fn new() -> Self {
7420        Self(None)
7421    }
7422    fn note(&mut self, due_nanos: u64) {
7423        self.0 = Some(self.0.map_or(due_nanos, |e| e.min(due_nanos)));
7424    }
7425    fn into_inner(self) -> Option<u64> {
7426        self.0
7427    }
7428}
7429
7430/// D.5e Phase 3 B-2 — the time-driven housekeeping checks, factored out of
7431/// [`run_tick_iteration`], so the event-driven scheduler can fire them
7432/// as its own [`TickEvent::Housekeep`] heap event exactly at the next
7433/// due-instant (and `tick_loop`/`tick_driver` call them inline).
7434/// Pure reader/writer-side bookkeeping — **no** cross-vendor wire
7435/// output, the cadence is purely internal.
7436///
7437/// Return value: the earliest instant (nanos in the `elapsed` time base) at which
7438/// a check is due again, or `None` if nothing is currently pending
7439/// (no active deadline/lifespan/liveliness slot) — then the
7440/// scheduler parks until the idle floor resp. until a `raise_tick_wake` signals new
7441/// work.
7442fn tick_housekeep(rt: &Arc<DcpsRuntime>, elapsed: Duration) -> Option<u64> {
7443    let mut next_due = NextDue::new();
7444    // --- Deadline-Monitoring ---
7445    if let Some(d) = check_deadlines(rt, elapsed) {
7446        next_due.note(d);
7447    }
7448    // --- Lifespan-Expire ---
7449    if let Some(d) = expire_by_lifespan(rt, elapsed) {
7450        next_due.note(d);
7451    }
7452    // --- Liveliness lease check (reader side) ---
7453    if let Some(d) = check_liveliness(rt, elapsed) {
7454        next_due.note(d);
7455    }
7456    // --- Writer-side liveliness-lost check ---
7457    if let Some(d) = check_writer_liveliness(rt, elapsed) {
7458        next_due.note(d);
7459    }
7460    next_due.into_inner()
7461}
7462
7463impl DcpsRuntime {
7464    /// Number of completed tick iterations since `start()`. Advances once per
7465    /// tick regardless of whether the internal `zdds-tick` thread or an
7466    /// external executor ([`DcpsRuntime::tick_driver`]) drives it — a stalled
7467    /// value means the periodic tick stopped. Diagnostic only.
7468    #[must_use]
7469    pub fn tick_count(&self) -> u64 {
7470        self.tick_seq.load(Ordering::Relaxed)
7471    }
7472
7473    /// Number of SPDP announces emitted since `start()`. Diagnostic for the C3
7474    /// initial-announcement burst: a fresh participant with no discovered peer
7475    /// advances this at [`RuntimeConfig::initial_announce_period`] for the first
7476    /// [`RuntimeConfig::initial_announce_count`] announces, then slows to
7477    /// `spdp_period`.
7478    #[must_use]
7479    pub fn spdp_announce_count(&self) -> u64 {
7480        self.spdp_announce_seq.load(Ordering::Relaxed)
7481    }
7482
7483    /// Number of discovered topic inconsistencies (DDS 1.4 §2.2.4.2.4).
7484    /// Bumped during matching against the SEDP cache whenever a remote
7485    /// endpoint carries the same `topic_name` but a differing `type_name`
7486    /// than a local endpoint. A delta against the last poll snapshot
7487    /// triggers `on_inconsistent_topic`.
7488    #[must_use]
7489    pub fn inconsistent_topic_count(&self) -> u64 {
7490        self.inconsistent_topic_seq.load(Ordering::Relaxed)
7491    }
7492
7493    /// External tick driver (zerodds-async-1.0 §4). Only meaningful when the
7494    /// runtime was started with [`RuntimeConfig::external_tick`] = `true`,
7495    /// which suppresses the dedicated `zdds-tick` thread. Each
7496    /// [`DcpsTickDriver::tick`] call runs exactly one tick iteration; the
7497    /// caller schedules the next after [`DcpsTickDriver::tick_period`]. The
7498    /// async API's `spawn_in_tokio` uses this to multiplex many participants'
7499    /// tick loops onto a tokio runtime instead of one std::thread each.
7500    #[must_use]
7501    pub fn tick_driver(self: &Arc<Self>) -> DcpsTickDriver {
7502        DcpsTickDriver {
7503            st: TickState::new(self),
7504            rt: Arc::clone(self),
7505        }
7506    }
7507
7508    /// D.5e Phase 3 — wake the scheduler tick worker immediately (new work:
7509    /// a sample written, a HEARTBEAT/DATA/ACKNACK received). Coalesced: many
7510    /// raises between two worker passes collapse into a single wake, so a
7511    /// datagram storm does not flood the channel. No-op unless started with
7512    /// `scheduler_tick`.
7513    pub fn raise_tick_wake(&self) {
7514        // Only the first raiser since the last pass actually sends.
7515        if self.tick_wake_pending.swap(true, Ordering::AcqRel) {
7516            return;
7517        }
7518        if let Ok(guard) = self.tick_wake.lock() {
7519            if let Some(h) = guard.as_ref() {
7520                // Active traffic wakes the reliable tick AND re-evaluates
7521                // housekeeping, so a freshly-armed deadline/lifespan/liveliness
7522                // window is scheduled at once instead of waiting out the park.
7523                h.raise_now(TickEvent::Tick);
7524                h.raise_now(TickEvent::Housekeep);
7525            }
7526        }
7527    }
7528
7529    /// `true` if this participant has any user DataWriter or DataReader — i.e.
7530    /// the fine-grained periodic work (HEARTBEAT / ACKNACK / deadline / lifespan
7531    /// / liveliness) may be due and the scheduler keeps a fine cadence. A pure
7532    /// discovery-only participant parks long.
7533    fn has_user_endpoints(&self) -> bool {
7534        self.user_writers
7535            .read()
7536            .map(|m| !m.is_empty())
7537            .unwrap_or(true)
7538            || self
7539                .user_readers
7540                .read()
7541                .map(|m| !m.is_empty())
7542                .unwrap_or(true)
7543    }
7544}
7545
7546/// Drives a runtime's periodic tick from an external executor (tokio, an
7547/// embedded scheduler, a manual test loop). Obtained via
7548/// [`DcpsRuntime::tick_driver`]; only does useful work when the runtime was
7549/// started with [`RuntimeConfig::external_tick`] = `true`.
7550///
7551/// Typical loop (the async crate's `spawn_in_tokio` shape):
7552///
7553/// ```ignore
7554/// let mut driver = runtime.tick_driver();
7555/// let period = driver.tick_period();
7556/// while !driver.is_stopped() {
7557///     driver.tick();
7558///     tokio::time::sleep(period).await;
7559/// }
7560/// ```
7561pub struct DcpsTickDriver {
7562    rt: Arc<DcpsRuntime>,
7563    st: TickState,
7564}
7565
7566impl DcpsTickDriver {
7567    /// Period the caller should wait between consecutive [`Self::tick`] calls
7568    /// (mirrors the internal `zdds-tick` thread's `tick_period`).
7569    #[must_use]
7570    pub fn tick_period(&self) -> Duration {
7571        self.rt.config.tick_period
7572    }
7573
7574    /// `true` once the runtime is shutting down (set by `Drop`/`stop()`). The
7575    /// driving task must then stop calling [`Self::tick`] and return so the
7576    /// runtime can be dropped cleanly.
7577    #[must_use]
7578    pub fn is_stopped(&self) -> bool {
7579        self.rt.stop.load(Ordering::Relaxed)
7580    }
7581
7582    /// Run one tick iteration: periodic SPDP announce, SEDP/WLP ticks,
7583    /// per-user-writer ticks, deadline/lifespan/liveliness checks. Equivalent
7584    /// to one pass of the internal `zdds-tick` loop body.
7585    pub fn tick(&mut self) {
7586        run_tick_iteration(Arc::clone(&self.rt), &mut self.st);
7587        tick_housekeep(&self.rt, self.rt.start_instant.elapsed());
7588    }
7589}
7590
7591/// Writer-side liveliness-lost detection. Spec §2.2.4.2.10.
7592///
7593/// For all user writers: if a lease duration is set and more time
7594/// has elapsed since the last assert (Automatic = `last_write`, Manual =
7595/// `last_liveliness_assert`) than the
7596/// lease duration allows, the writer counts as
7597/// "not-alive" from the DDS view — `liveliness_lost_count++` and reset the window.
7598///
7599/// Note: with pure best-effort tests + `Automatic` the
7600/// counter typically does not advance — Automatic asserts with every
7601/// `write_user_sample`. Manual mode requires an explicit
7602/// `assert_liveliness` (comes with .4b — until then we already provide
7603/// the detection here, the hot-path trigger triggers it).
7604fn check_writer_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7605    let now_nanos = now.as_nanos() as u64;
7606    let mut next_due = NextDue::new();
7607    for (_eid, arc) in rt.writer_slots_snapshot() {
7608        let Ok(mut slot) = arc.lock() else { continue };
7609        if slot.liveliness_lease_nanos == 0 {
7610            continue;
7611        }
7612        let last = match slot.liveliness_kind {
7613            zerodds_qos::LivelinessKind::Automatic => slot.last_write,
7614            _ => slot.last_liveliness_assert,
7615        };
7616        let last_nanos = match last {
7617            Some(t) => t.as_nanos() as u64,
7618            None => continue,
7619        };
7620        if now_nanos.saturating_sub(last_nanos) >= slot.liveliness_lease_nanos {
7621            slot.liveliness_lost_count = slot.liveliness_lost_count.saturating_add(1);
7622            // Reset the window, so the same lease-window
7623            // overrun does not count in an infinite loop.
7624            // Spec §2.2.3.11: "lease has elapsed" — `>=` is boundary-
7625            // stable and avoids flakiness when tick_period == lease.
7626            slot.last_liveliness_assert = Some(now);
7627            slot.last_write = Some(now);
7628            next_due.note(now_nanos.saturating_add(slot.liveliness_lease_nanos));
7629        } else {
7630            next_due.note(last_nanos.saturating_add(slot.liveliness_lease_nanos));
7631        }
7632    }
7633    next_due.into_inner()
7634}
7635
7636/// Checks for all user readers whether the writer has delivered no sample
7637/// for longer than `lease_duration`. If so: transition
7638/// alive → not_alive, `not_alive_count++`.
7639///
7640/// Automatic liveliness (§2.2.3.11): every write is an implicit assert.
7641/// So we check the reader-side `last_sample_received`.
7642/// Manual kinds come with .4b (explicit assert messages).
7643fn check_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7644    let now_nanos = now.as_nanos() as u64;
7645    let mut next_due = NextDue::new();
7646    for (_eid, arc) in rt.reader_slots_snapshot() {
7647        let Ok(mut slot) = arc.lock() else { continue };
7648        if slot.liveliness_lease_nanos == 0 {
7649            continue;
7650        }
7651        // Until the first sample: consider it alive (optimistic).
7652        let last = match slot.last_sample_received {
7653            Some(t) => t.as_nanos() as u64,
7654            None => continue,
7655        };
7656        // Only a still-alive reader can transition; one already
7657        // not_alive stays so until a new sample arrives (event-driven
7658        // via the recv path) — so no re-schedule needed.
7659        if !slot.liveliness_alive {
7660            continue;
7661        }
7662        if now_nanos.saturating_sub(last) >= slot.liveliness_lease_nanos {
7663            slot.liveliness_alive = false;
7664            slot.liveliness_not_alive_count = slot.liveliness_not_alive_count.saturating_add(1);
7665        } else {
7666            next_due.note(last.saturating_add(slot.liveliness_lease_nanos));
7667        }
7668    }
7669    next_due.into_inner()
7670}
7671
7672/// For all user writers: remove samples from the HistoryCache whose
7673/// insert time + lifespan has elapsed. OMG DDS 1.4 §2.2.3.16:
7674/// "If the duration...elapses and the sample is still in the cache...
7675/// the sample is no longer available to any future DataReaders".
7676///
7677/// Implementation: `sample_insert_times` is a VecDeque, sorted
7678/// by insert time (= SN, because monotonic). Front-pop while expired;
7679/// the highest expired SN runs through via `cache.remove_up_to(sn + 1)`.
7680fn expire_by_lifespan(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7681    let now_nanos = now.as_nanos() as u64;
7682    let mut next_due = NextDue::new();
7683    for (_eid, arc) in rt.writer_slots_snapshot() {
7684        let Ok(mut slot) = arc.lock() else { continue };
7685        if slot.lifespan_nanos == 0 {
7686            continue;
7687        }
7688        let mut highest_expired = None;
7689        while let Some(&(sn, inserted)) = slot.sample_insert_times.front() {
7690            let inserted_nanos = inserted.as_nanos() as u64;
7691            if now_nanos.saturating_sub(inserted_nanos) >= slot.lifespan_nanos {
7692                highest_expired = Some(sn);
7693                slot.sample_insert_times.pop_front();
7694            } else {
7695                break;
7696            }
7697        }
7698        if let Some(sn) = highest_expired {
7699            let _removed = slot
7700                .writer
7701                .remove_samples_up_to(zerodds_rtps::wire_types::SequenceNumber(sn.0 + 1));
7702        }
7703        // Next lifespan due = expiry of the now-oldest sample still
7704        // remaining in the cache. Empty deque → nothing due,
7705        // until a new sample is written (raise_tick_wake covers that).
7706        if let Some(&(_sn, inserted)) = slot.sample_insert_times.front() {
7707            next_due.note((inserted.as_nanos() as u64).saturating_add(slot.lifespan_nanos));
7708        }
7709    }
7710    next_due.into_inner()
7711}
7712
7713/// Checks for all user writers + user readers whether the deadline period
7714/// has been exceeded since the last sample. Every exceedance
7715/// increments the corresponding missed counter by exactly 1
7716/// — regardless of how often `check_deadlines` is called within an
7717/// elapsed window, because we keep setting `last_*`
7718/// to "now" after we have counted.
7719///
7720/// **Init-state semantics:** as long as `last_write`/`last_sample_received`
7721/// is `None` (no real write/sample yet), the deadline
7722/// check does not count. Only after the first real data point does the
7723/// deadline window start. This prevents false misses due to slow
7724/// entity setup (Linux CI/container) before the app even issues a
7725/// write.
7726fn check_deadlines(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7727    let now_nanos = now.as_nanos() as u64;
7728    let mut next_due = NextDue::new();
7729    for (_eid, arc) in rt.writer_slots_snapshot() {
7730        let Ok(mut slot) = arc.lock() else { continue };
7731        if slot.deadline_nanos == 0 {
7732            continue;
7733        }
7734        let Some(last) = slot.last_write.map(|d| d.as_nanos() as u64) else {
7735            // Never written yet → deadline window not active.
7736            continue;
7737        };
7738        if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
7739            slot.offered_deadline_missed_count =
7740                slot.offered_deadline_missed_count.saturating_add(1);
7741            // Reset the window: the next deadline is counted relative
7742            // to the current tick. `>=` is boundary-stable
7743            // (Spec §2.2.3.7: "deadline has elapsed").
7744            slot.last_write = Some(now);
7745            next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
7746        } else {
7747            next_due.note(last.saturating_add(slot.deadline_nanos));
7748        }
7749    }
7750    for (_eid, arc) in rt.reader_slots_snapshot() {
7751        let Ok(mut slot) = arc.lock() else { continue };
7752        if slot.deadline_nanos == 0 {
7753            continue;
7754        }
7755        let Some(last) = slot.last_sample_received.map(|d| d.as_nanos() as u64) else {
7756            continue;
7757        };
7758        if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
7759            slot.requested_deadline_missed_count =
7760                slot.requested_deadline_missed_count.saturating_add(1);
7761            slot.last_sample_received = Some(now);
7762            next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
7763        } else {
7764            next_due.note(last.saturating_add(slot.deadline_nanos));
7765        }
7766    }
7767    next_due.into_inner()
7768}
7769
7770/// For all local writers + readers: matching against the current
7771/// SEDP cache. A cheap re-run when SEDP events came in — idempotent,
7772/// because ReliableWriter/Reader add_*_proxy are idempotent (same
7773/// GUID → replaced).
7774fn run_matching_pass(rt: &Arc<DcpsRuntime>) {
7775    let writer_ids: Vec<EntityId> = rt.writer_eids();
7776    for eid in writer_ids {
7777        rt.match_local_writer_against_cache(eid);
7778    }
7779    let reader_ids: Vec<EntityId> = rt.reader_eids();
7780    for eid in reader_ids {
7781        rt.match_local_reader_against_cache(eid);
7782    }
7783}
7784
7785/// Returns the default-unicast locator of a discovered remote
7786/// participant.
7787fn remote_user_locators(
7788    prefix: GuidPrefix,
7789    discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
7790) -> Vec<Locator> {
7791    match discovered.lock() {
7792        Ok(cache) => cache
7793            .get(&prefix)
7794            .and_then(|p| p.data.default_unicast_locator)
7795            .into_iter()
7796            .collect(),
7797        Err(_) => Vec::new(),
7798    }
7799}
7800
7801/// Determine the destination for user traffic to a remote endpoint.
7802///
7803/// DDSI-RTPS 2.5 §8.5.3.2/§8.5.3.3: the per-endpoint `unicastLocatorList`
7804/// from the SEDP announce is authoritative. §8.5.5: only when it is empty
7805/// does the sender fall back to the participant `DEFAULT_UNICAST_LOCATOR` from
7806/// SPDP.
7807///
7808/// Before this fix ZeroDDS *always* used the participant default — which
7809/// broke OpenDDS interop: OpenDDS stores only the
7810/// placeholder 127.0.0.1:12345 as the participant default and announces the real user locator
7811/// exclusively per-endpoint.
7812fn endpoint_or_default_locators(
7813    endpoint: &[Locator],
7814    prefix: GuidPrefix,
7815    discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
7816) -> Vec<Locator> {
7817    if !endpoint.is_empty() {
7818        return endpoint.to_vec();
7819    }
7820    remote_user_locators(prefix, discovered)
7821}
7822
7823/// Dispatches a received RTPS datagram to matching user readers.
7824/// Decides, based on the `reader_id` in DATA/DATA_FRAG/HEARTBEAT/GAP,
7825/// which local reader is responsible.
7826/// Strip the 4-byte encapsulation header off the received sample payload.
7827/// Returns `None` if the payload is < 4 bytes or carries an unknown
7828/// scheme (PL_CDR variants would not get here; they go via
7829/// SEDP — if we see such a thing on user endpoints, it is garbage).
7830/// Spec §3.2 zerodds-async-1.0: wakes a registered waker
7831/// after every `sample_tx.send`. `take` consumes the waker, to
7832/// avoid double wakeups — the caller registers a new one after
7833/// every `Pending` result.
7834fn wake_async_waker(slot: &alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>) {
7835    if let Ok(mut g) = slot.lock() {
7836        if let Some(w) = g.take() {
7837            w.wake();
7838        }
7839    }
7840}
7841
7842/// Converts a sample delivered by the ReliableReader into a
7843/// `UserSample` channel entry. For `ChangeKind::Alive` the
7844/// CDR encapsulation header is stripped; for lifecycle markers
7845/// the key hash is reconstructed from the bytes.
7846/// Inspect-endpoint tap dispatch for the DCPS receive path.
7847///
7848/// Called in `handle_user_datagram` when a sample is delivered to
7849/// a user reader. Only when the `inspect` feature is
7850/// on; without the feature no code, no branch.
7851#[cfg(feature = "inspect")]
7852fn dispatch_inspect_dcps_receive_tap(topic: &str, reader_id: EntityId, item: &UserSample) {
7853    let payload: Vec<u8> = match item {
7854        UserSample::Alive { payload, .. } => payload.to_vec(),
7855        UserSample::Lifecycle { key_hash, .. } => key_hash.to_vec(),
7856    };
7857    let ts_ns = std::time::SystemTime::now()
7858        .duration_since(std::time::UNIX_EPOCH)
7859        .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
7860        .unwrap_or(0);
7861    let mut corr: u64 = 0;
7862    for (i, byte) in reader_id.entity_key.iter().enumerate() {
7863        corr |= u64::from(*byte) << (i * 8);
7864    }
7865    corr |= u64::from(reader_id.entity_kind as u8) << 24;
7866    let frame = zerodds_inspect_endpoint::Frame::dcps(topic.to_owned(), ts_ns, corr, payload);
7867    zerodds_inspect_endpoint::tap::dispatch(&frame);
7868}
7869
7870fn delivered_to_user_sample(
7871    sample: &zerodds_rtps::reliable_reader::DeliveredSample,
7872    writer_strengths: &alloc::collections::BTreeMap<[u8; 16], i32>,
7873) -> Option<UserSample> {
7874    use zerodds_rtps::history_cache::ChangeKind;
7875    match sample.kind {
7876        ChangeKind::Alive | ChangeKind::AliveFiltered => {
7877            let writer_guid = sample.writer_guid.to_bytes();
7878            let writer_strength = writer_strengths.get(&writer_guid).copied().unwrap_or(0);
7879            // Encapsulation representation from byte[1] of the header
7880            // (RTPS 2.5 §10.5) — BEFORE stripping. 0x00–0x03 = XCDR1
7881            // (CDR/PL_CDR), 0x06–0x0b = XCDR2 (CDR2/D_CDR2/PL_CDR2).
7882            let representation = encap_representation(&sample.payload);
7883            strip_user_encap_arc(&sample.payload).map(|payload| UserSample::Alive {
7884                payload,
7885                writer_guid,
7886                writer_strength,
7887                representation,
7888            })
7889        }
7890        ChangeKind::NotAliveDisposed
7891        | ChangeKind::NotAliveUnregistered
7892        | ChangeKind::NotAliveDisposedUnregistered => {
7893            // Lifecycle marker: Spec §9.6.4.8 + §9.6.3.9 requires
7894            // `PID_KEY_HASH` in the inline QoS — the reader reads it
7895            // and propagates it via `DeliveredSample.key_hash`.
7896            // Fallback: with non-spec-conformant writers the
7897            // hash falls back to the first 16 bytes of the key-only payload
7898            // (PLAIN_CDR2-BE key holder).
7899            let kh = sample.key_hash.unwrap_or_else(|| {
7900                let mut h = [0u8; 16];
7901                let n = sample.payload.len().min(16);
7902                h[..n].copy_from_slice(&sample.payload[..n]);
7903                h
7904            });
7905            Some(UserSample::Lifecycle {
7906                key_hash: kh,
7907                kind: sample.kind,
7908            })
7909        }
7910    }
7911}
7912
7913/// Returns the XCDR version from the 4-byte encapsulation header
7914/// (RTPS 2.5 §10.5): `0` = XCDR1 (CDR/PL_CDR, encap byte 0x00–0x05),
7915/// `1` = XCDR2 (CDR2/DELIMITED_CDR2/PL_CDR2, encap byte 0x06–0x0b).
7916/// Default `0` for a too-short payload — XCDR1 is the spec baseline.
7917fn encap_representation(payload: &[u8]) -> u8 {
7918    if payload.len() >= 2 && payload[1] >= 0x06 {
7919        1
7920    } else {
7921        0
7922    }
7923}
7924
7925/// Checks whether `payload` has a known 4-byte encapsulation header.
7926/// Returns `Some(4)` if so (= offset behind the header), `None` if
7927/// no known scheme. Separated in use from [`strip_user_encap`]:
7928/// here only validation without allocation, for the listener zero-copy
7929/// path (lever E / Sprint D.5d).
7930fn validate_user_encap_offset(payload: &[u8]) -> Option<usize> {
7931    if payload.len() < 4 {
7932        return None;
7933    }
7934    // Accept all data-representation schemes (RTPS 2.5 §10.5,
7935    // table 10.3): byte0 = 0x00, byte1 in:
7936    //   0x00/0x01 CDR_BE/LE        (XCDR1 PLAIN_CDR)
7937    //   0x02/0x03 PL_CDR_BE/LE     (XCDR1 parameter list, key serial.)
7938    //   0x06/0x07 CDR2_BE/LE       (XCDR2 PLAIN_CDR2)
7939    //   0x08/0x09 D_CDR2_BE/LE     (XCDR2 DELIMITED_CDR2, @appendable)
7940    //   0x0a/0x0b PL_CDR2_BE/LE    (XCDR2 PL_CDR2, @mutable)
7941    // Cyclone often sends XCDR1, OpenDDS/FastDDS XCDR2. We pass
7942    // all through; the typed decoder picks the correct alignment rule
7943    // based on the `representation` (see `encap_representation`).
7944    if payload[0] != 0x00 {
7945        return None;
7946    }
7947    match payload[1] {
7948        0x00..=0x03 | 0x06..=0x0b => Some(4),
7949        _ => None,
7950    }
7951}
7952
7953/// Zero-copy variant: strips the encap header via range slicing
7954/// on the refcounted `Arc<[u8]>` backing store. No heap alloc.
7955/// Spec: `docs/specs/zerodds-zero-copy-1.0.md` §6 wave 2.
7956fn strip_user_encap_arc(
7957    payload: &alloc::sync::Arc<[u8]>,
7958) -> Option<crate::sample_bytes::SampleBytes> {
7959    validate_user_encap_offset(payload).map(|off| {
7960        crate::sample_bytes::SampleBytes::from_arc_slice(
7961            alloc::sync::Arc::clone(payload),
7962            off..payload.len(),
7963        )
7964    })
7965}
7966
7967#[cfg(test)]
7968fn strip_user_encap(payload: &[u8]) -> Option<alloc::vec::Vec<u8>> {
7969    validate_user_encap_offset(payload).map(|off| payload[off..].to_vec())
7970}
7971
7972/// Bench-only phase-timing accumulators. Active with env
7973/// `ZERODDS_PHASE_TIMING=1`. With `ZERODDS_PHASE_DUMP=1` the
7974/// atexit hook prints the totals on drop of the first runtime.
7975#[doc(hidden)]
7976pub static PHASE_HANDLE_USER_NS: core::sync::atomic::AtomicU64 =
7977    core::sync::atomic::AtomicU64::new(0);
7978#[doc(hidden)]
7979pub static PHASE_HANDLE_USER_CALLS: core::sync::atomic::AtomicU64 =
7980    core::sync::atomic::AtomicU64::new(0);
7981#[doc(hidden)]
7982pub static PHASE_WRITE_USER_NS: core::sync::atomic::AtomicU64 =
7983    core::sync::atomic::AtomicU64::new(0);
7984#[doc(hidden)]
7985pub static PHASE_WRITE_USER_CALLS: core::sync::atomic::AtomicU64 =
7986    core::sync::atomic::AtomicU64::new(0);
7987
7988/// Sub-phases in the `handle_user_datagram` receive hot path:
7989/// 0=decode_datagram, 1=slot-lookup+lock, 2=reader.handle_data,
7990/// 3=delivered_to_user_sample, 4=listener+sender-dispatch.
7991/// Active under `ZERODDS_PHASE_TIMING=1`. Each `Instant::now()` bracket
7992/// costs ~50 ns; at a ~3 µs handle that is ~1.6% per sub-phase.
7993#[doc(hidden)]
7994pub static PHASE_HANDLE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
7995    core::sync::atomic::AtomicU64::new(0),
7996    core::sync::atomic::AtomicU64::new(0),
7997    core::sync::atomic::AtomicU64::new(0),
7998    core::sync::atomic::AtomicU64::new(0),
7999    core::sync::atomic::AtomicU64::new(0),
8000];
8001
8002/// Sub-phases in `write_user_sample_borrowed` (sender hot path):
8003/// 0=lookup, 1=lock, 2=write_with_heartbeat, 3=send-loop, 4=reserved.
8004/// The detail drilldown into socket.send_to vs. inproc-peer dispatch was
8005/// done once for the connected-UDP lever (showed send_to as
8006/// 97% of the dispatch path); not permanent in the code, because per-phase
8007/// `Instant::now()` itself costs ~50 ns — at a 6 µs send that
8008/// would be 1% overhead and skews the calibrated measurement.
8009#[doc(hidden)]
8010pub static PHASE_WRITE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8011    core::sync::atomic::AtomicU64::new(0),
8012    core::sync::atomic::AtomicU64::new(0),
8013    core::sync::atomic::AtomicU64::new(0),
8014    core::sync::atomic::AtomicU64::new(0),
8015    core::sync::atomic::AtomicU64::new(0),
8016];
8017
8018fn phase_timing_enabled() -> bool {
8019    static CACHE: core::sync::atomic::AtomicI8 = core::sync::atomic::AtomicI8::new(-1);
8020    let v = CACHE.load(core::sync::atomic::Ordering::Relaxed);
8021    if v >= 0 {
8022        return v == 1;
8023    }
8024    let on = std::env::var("ZERODDS_PHASE_TIMING")
8025        .map(|s| s == "1")
8026        .unwrap_or(false);
8027    CACHE.store(
8028        if on { 1 } else { 0 },
8029        core::sync::atomic::Ordering::Relaxed,
8030    );
8031    on
8032}
8033
8034struct PhaseTimer {
8035    start: std::time::Instant,
8036    ns_acc: &'static core::sync::atomic::AtomicU64,
8037    calls_acc: &'static core::sync::atomic::AtomicU64,
8038}
8039
8040impl Drop for PhaseTimer {
8041    fn drop(&mut self) {
8042        let ns = self.start.elapsed().as_nanos() as u64;
8043        self.ns_acc
8044            .fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8045        self.calls_acc
8046            .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
8047    }
8048}
8049
8050fn handle_user_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], now: Duration) {
8051    let _phase_guard = if phase_timing_enabled() {
8052        Some(PhaseTimer {
8053            start: std::time::Instant::now(),
8054            ns_acc: &PHASE_HANDLE_USER_NS,
8055            calls_acc: &PHASE_HANDLE_USER_CALLS,
8056        })
8057    } else {
8058        None
8059    };
8060    let pt_on = phase_timing_enabled();
8061    let pt_t0 = if pt_on {
8062        Some(std::time::Instant::now())
8063    } else {
8064        None
8065    };
8066    let parsed = match decode_datagram(bytes) {
8067        Ok(p) => p,
8068        Err(_) => return,
8069    };
8070    // DDSI-RTPS §8.3.4: the effective source of each writer submessage is the
8071    // sourceGuidPrefix from the RTPS header. The reader demux needs it to
8072    // distinguish writer proxies with the same EntityId but a different participant
8073    // (fan-in / multiple publishers on the same topic).
8074    let src_prefix = parsed.header.guid_prefix;
8075    if let (Some(t0), true) = (pt_t0, pt_on) {
8076        let ns = t0.elapsed().as_nanos() as u64;
8077        PHASE_HANDLE_SUB_NS[0].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8078    }
8079    // Per-submessage: take the matching slot mutex individually per
8080    // submessage — no global user_writers/user_readers lock anymore.
8081    // With per-submessage granularity, reader datagrams can be processed in parallel
8082    // to writer AckNacks.
8083    for sub in parsed.submessages {
8084        match sub {
8085            ParsedSubmessage::Data(d) => {
8086                // Sprint D.5d lever B — collect-then-dispatch:
8087                // sample conversion + liveliness update inside slot.lock,
8088                // then listener fire + channel send + waker wake
8089                // OUTSIDE the lock.
8090                //
8091                // Cross-vendor fix 2026-05-19: when reader_id ==
8092                // ENTITYID_UNKNOWN (RTPS spec §8.3.7.2: "deliver to all
8093                // matched readers on this topic"), we iterate over
8094                // ALL reader slots and let `handle_data` filter by
8095                // writer_proxies. Cyclone DDS/FastDDS/RTI send
8096                // user DATA with reader_id=UNKNOWN; without this fan-out
8097                // ZeroDDS would drop every such DATA.
8098                let pt_t1 = if pt_on {
8099                    Some(std::time::Instant::now())
8100                } else {
8101                    None
8102                };
8103                let target_slots: Vec<ReaderSlotArc> = if d.reader_id == EntityId::UNKNOWN {
8104                    let snap = rt.reader_slots_snapshot();
8105                    let mut v = Vec::with_capacity(snap.len());
8106                    v.extend(snap.into_iter().map(|(_, arc)| arc));
8107                    v
8108                } else {
8109                    let mut v = Vec::with_capacity(1);
8110                    if let Some(arc) = rt.reader_slot(d.reader_id) {
8111                        v.push(arc);
8112                    }
8113                    v
8114                };
8115                if let (Some(t1), true) = (pt_t1, pt_on) {
8116                    let ns = t1.elapsed().as_nanos() as u64;
8117                    PHASE_HANDLE_SUB_NS[1].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8118                }
8119                for arc in target_slots {
8120                    // Lever E: alongside the UserSample we carry a
8121                    // zero-copy view on the original `Arc<[u8]>` with
8122                    // the encap offset — the listener can thereby read into
8123                    // the payload without allocation.
8124                    let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
8125                    let listener;
8126                    let waker;
8127                    let sender;
8128                    #[cfg(feature = "inspect")]
8129                    let topic_name;
8130                    let pt_t2 = if pt_on {
8131                        Some(std::time::Instant::now())
8132                    } else {
8133                        None
8134                    };
8135                    {
8136                        let Ok(mut slot) = arc.lock() else { continue };
8137                        let hd_samples: Vec<_> = slot
8138                            .reader
8139                            .handle_data(src_prefix, &d)
8140                            .into_iter()
8141                            .collect();
8142                        for sample in hd_samples {
8143                            // Listener zero-copy view only for alive samples
8144                            // with a valid encap header. Arc::clone is
8145                            // an atomic refcount inc, no data copy.
8146                            let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
8147                                zerodds_rtps::history_cache::ChangeKind::Alive
8148                                | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
8149                                    validate_user_encap_offset(&sample.payload)
8150                                        .map(|off| (Arc::clone(&sample.payload), off))
8151                                }
8152                                _ => None,
8153                            };
8154                            if let Some(item) =
8155                                delivered_to_user_sample(&sample, &slot.writer_strengths)
8156                            {
8157                                items.push((item, listener_view));
8158                            }
8159                        }
8160                        if !items.is_empty() {
8161                            slot.last_sample_received = Some(now);
8162                            slot.samples_delivered_count = slot
8163                                .samples_delivered_count
8164                                .saturating_add(items.len() as u64);
8165                            if !slot.liveliness_alive {
8166                                slot.liveliness_alive = true;
8167                                slot.liveliness_alive_count =
8168                                    slot.liveliness_alive_count.saturating_add(1);
8169                            }
8170                        }
8171                        listener = slot.listener.clone();
8172                        waker = Arc::clone(&slot.async_waker);
8173                        sender = slot.sample_tx.clone();
8174                        #[cfg(feature = "inspect")]
8175                        {
8176                            topic_name = slot.topic_name.clone();
8177                        }
8178                    }
8179                    if let (Some(t2), true) = (pt_t2, pt_on) {
8180                        let ns = t2.elapsed().as_nanos() as u64;
8181                        PHASE_HANDLE_SUB_NS[2].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8182                    }
8183                    let pt_t3 = if pt_on {
8184                        Some(std::time::Instant::now())
8185                    } else {
8186                        None
8187                    };
8188                    // --- Outside slot.lock: dispatch ---
8189                    //
8190                    // Listener and MPSC are exclusive: if a listener
8191                    // (callback) is set, the consumer is on the
8192                    // callback path — the additional `sender.send` +
8193                    // `wake_async_waker` would be pure overhead AND
8194                    // would grow the channel buffer unboundedly
8195                    // (memory leak in callback-only apps). We
8196                    // dispatch either the callback OR the MPSC, not
8197                    // both. A caller (Rust API) that wants take()+listener
8198                    // at the same time simply sets NO listener
8199                    // and polls via take().
8200                    for (item, listener_view) in items {
8201                        let item_repr = if let UserSample::Alive { representation, .. } = &item {
8202                            *representation
8203                        } else {
8204                            0
8205                        };
8206                        #[cfg(feature = "inspect")]
8207                        dispatch_inspect_dcps_receive_tap(&topic_name, d.reader_id, &item);
8208                        if let Some(ref l) = listener {
8209                            if let Some((arc_payload, off)) = listener_view {
8210                                // Zero-copy: slice view into the original Arc.
8211                                l(&arc_payload[off..], item_repr);
8212                            }
8213                        } else {
8214                            let _ = sender.send(item);
8215                            wake_async_waker(&waker);
8216                        }
8217                    }
8218                    if let (Some(t3), true) = (pt_t3, pt_on) {
8219                        let ns = t3.elapsed().as_nanos() as u64;
8220                        PHASE_HANDLE_SUB_NS[4].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8221                    }
8222                } // for arc in target_slots
8223            }
8224            ParsedSubmessage::DataFrag(df) => {
8225                // Lever B+E — see the Data arm above.
8226                // Cross-vendor: same UNKNOWN fan-out as for Data.
8227                let target_slots: Vec<ReaderSlotArc> = if df.reader_id == EntityId::UNKNOWN {
8228                    rt.reader_slots_snapshot()
8229                        .into_iter()
8230                        .map(|(_, arc)| arc)
8231                        .collect()
8232                } else {
8233                    rt.reader_slot(df.reader_id).into_iter().collect()
8234                };
8235                for arc in target_slots {
8236                    let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
8237                    let listener;
8238                    let waker;
8239                    let sender;
8240                    #[cfg(feature = "inspect")]
8241                    let topic_name;
8242                    {
8243                        let Ok(mut slot) = arc.lock() else { continue };
8244                        for sample in slot.reader.handle_data_frag(src_prefix, &df, now) {
8245                            let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
8246                                zerodds_rtps::history_cache::ChangeKind::Alive
8247                                | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
8248                                    validate_user_encap_offset(&sample.payload)
8249                                        .map(|off| (Arc::clone(&sample.payload), off))
8250                                }
8251                                _ => None,
8252                            };
8253                            if let Some(item) =
8254                                delivered_to_user_sample(&sample, &slot.writer_strengths)
8255                            {
8256                                items.push((item, listener_view));
8257                            }
8258                        }
8259                        if !items.is_empty() {
8260                            slot.last_sample_received = Some(now);
8261                            slot.samples_delivered_count = slot
8262                                .samples_delivered_count
8263                                .saturating_add(items.len() as u64);
8264                            if !slot.liveliness_alive {
8265                                slot.liveliness_alive = true;
8266                                slot.liveliness_alive_count =
8267                                    slot.liveliness_alive_count.saturating_add(1);
8268                            }
8269                        }
8270                        listener = slot.listener.clone();
8271                        waker = Arc::clone(&slot.async_waker);
8272                        sender = slot.sample_tx.clone();
8273                        #[cfg(feature = "inspect")]
8274                        {
8275                            topic_name = slot.topic_name.clone();
8276                        }
8277                    }
8278                    for (item, listener_view) in items {
8279                        let item_repr = if let UserSample::Alive { representation, .. } = &item {
8280                            *representation
8281                        } else {
8282                            0
8283                        };
8284                        #[cfg(feature = "inspect")]
8285                        dispatch_inspect_dcps_receive_tap(&topic_name, df.reader_id, &item);
8286                        // See the Data arm: listener and MPSC are exclusive.
8287                        if let Some(ref l) = listener {
8288                            if let Some((arc_payload, off)) = listener_view {
8289                                l(&arc_payload[off..], item_repr);
8290                            }
8291                        } else {
8292                            let _ = sender.send(item);
8293                            wake_async_waker(&waker);
8294                        }
8295                    }
8296                } // for arc in target_slots (DataFrag)
8297            }
8298            ParsedSubmessage::Heartbeat(h) => {
8299                // Lever B — collect-then-dispatch like the Data arm. An HB can
8300                // unlock samples that were waiting on a hole fill
8301                // (volatile skip, historic eviction).
8302                //
8303                // D.5e Phase-2: synchronous ACKNACK emit on HB receipt
8304                // instead of deferred-via-tick. With `heartbeat_response_delay=0`
8305                // (D.5e default) `tick_outbound(now)` flushes the
8306                // ACKNACK directly for all pending writer_proxies — the tick loop
8307                // no longer has to wait 5 ms.
8308                // Cross-vendor: a HEARTBEAT with reader_id=UNKNOWN is
8309                // "to all matched readers". Cyclone often packs this into
8310                // DATA+HB submessage bundles.
8311                let target_slots: Vec<ReaderSlotArc> = if h.reader_id == EntityId::UNKNOWN {
8312                    rt.reader_slots_snapshot()
8313                        .into_iter()
8314                        .map(|(_, arc)| arc)
8315                        .collect()
8316                } else {
8317                    rt.reader_slot(h.reader_id).into_iter().collect()
8318                };
8319                for arc in target_slots {
8320                    let mut items: Vec<UserSample> = Vec::new();
8321                    let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
8322                        Vec::new();
8323                    let waker;
8324                    let sender;
8325                    {
8326                        let Ok(mut slot) = arc.lock() else { continue };
8327                        for sample in slot.reader.handle_heartbeat(src_prefix, &h, now) {
8328                            if let Some(item) =
8329                                delivered_to_user_sample(&sample, &slot.writer_strengths)
8330                            {
8331                                items.push(item);
8332                            }
8333                        }
8334                        if !items.is_empty() {
8335                            slot.last_sample_received = Some(now);
8336                            slot.samples_delivered_count = slot
8337                                .samples_delivered_count
8338                                .saturating_add(items.len() as u64);
8339                            if !slot.liveliness_alive {
8340                                slot.liveliness_alive = true;
8341                                slot.liveliness_alive_count =
8342                                    slot.liveliness_alive_count.saturating_add(1);
8343                            }
8344                        }
8345                        // D.5e Phase-2: synchronous ACKNACK directly in the recv thread.
8346                        if let Ok(dgs) = slot.reader.tick_outbound(now) {
8347                            sync_outbound = dgs;
8348                        }
8349                        waker = Arc::clone(&slot.async_waker);
8350                        sender = slot.sample_tx.clone();
8351                    }
8352                    for item in items {
8353                        let _ = sender.send(item);
8354                        wake_async_waker(&waker);
8355                    }
8356                    // Send ACKNACK datagrams synchronously — no tick-quantization tax.
8357                    for dg in sync_outbound {
8358                        if let Some(secured) = protect_user_reader_datagram(rt, &dg.bytes) {
8359                            for t in dg.targets.iter() {
8360                                if is_routable_user_locator(t) {
8361                                    let _ = rt.user_unicast.send(t, &secured);
8362                                }
8363                            }
8364                        }
8365                    }
8366                } // for arc in target_slots (Heartbeat)
8367            }
8368            ParsedSubmessage::Gap(g) => {
8369                // Cross-vendor: Gap with UNKNOWN reader → fan-out.
8370                let target_slots: Vec<ReaderSlotArc> = if g.reader_id == EntityId::UNKNOWN {
8371                    rt.reader_slots_snapshot()
8372                        .into_iter()
8373                        .map(|(_, arc)| arc)
8374                        .collect()
8375                } else {
8376                    rt.reader_slot(g.reader_id).into_iter().collect()
8377                };
8378                for arc in target_slots {
8379                    if let Ok(mut slot) = arc.lock() {
8380                        for sample in slot.reader.handle_gap(src_prefix, &g) {
8381                            if let Some(item) =
8382                                delivered_to_user_sample(&sample, &slot.writer_strengths)
8383                            {
8384                                let _ = slot.sample_tx.send(item);
8385                                wake_async_waker(&slot.async_waker);
8386                            }
8387                        }
8388                    }
8389                }
8390            }
8391            ParsedSubmessage::AckNack(ack) => {
8392                if let Some(arc) = rt.writer_slot(ack.writer_id) {
8393                    let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
8394                        Vec::new();
8395                    if let Ok(mut slot) = arc.lock() {
8396                        let base = ack.reader_sn_state.bitmap_base;
8397                        let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
8398                        let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
8399                        slot.writer.handle_acknack(src, base, requested);
8400                        // D.5e Phase-2: synchronous resend on NACK receipt.
8401                        // An ACKNACK may have listed requested SNs for resend;
8402                        // tick delivers the resend datagrams directly in the recv thread.
8403                        if let Ok(dgs) = slot.writer.tick(now) {
8404                            sync_outbound = dgs;
8405                        }
8406                    }
8407                    // ACK-Event-Cvar: wake `wait_for_acknowledgments`-waiters.
8408                    rt.notify_ack_event();
8409                    // Send sync resends (no more tick wait). FU2 S3:
8410                    // per-target data_protection (a reliable resend of user DATA
8411                    // must be encrypted just like the immediate send).
8412                    for dg in sync_outbound {
8413                        for t in dg.targets.iter() {
8414                            if is_routable_user_locator(t) {
8415                                if let Some(secured) =
8416                                    secure_outbound_for_target(rt, ack.writer_id, &dg.bytes, t)
8417                                {
8418                                    let _ = rt.user_unicast.send(t, &secured);
8419                                }
8420                            }
8421                        }
8422                    }
8423                }
8424            }
8425            ParsedSubmessage::NackFrag(nf) => {
8426                if let Some(arc) = rt.writer_slot(nf.writer_id) {
8427                    if let Ok(mut slot) = arc.lock() {
8428                        let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
8429                        slot.writer.handle_nackfrag(src, &nf);
8430                    }
8431                }
8432            }
8433            _ => {}
8434        }
8435    }
8436}
8437
8438/// Test hook: allows a direct call of `handle_spdp_datagram` from
8439/// other modules without spinning up the whole event loop.
8440/// For internal tests only.
8441#[cfg(test)]
8442pub(crate) fn handle_spdp_datagram_for_test(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
8443    handle_spdp_datagram(rt, bytes);
8444}
8445
8446fn handle_spdp_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
8447    let parsed = match rt.spdp_reader.parse_datagram(bytes) {
8448        Ok(p) => p,
8449        Err(_) => return, // not SPDP or wire error — swallow
8450    };
8451    // Self-discovery filter: ignore our own beacons.
8452    if parsed.sender_prefix == rt.guid_prefix {
8453        return;
8454    }
8455    let is_new = {
8456        if let Ok(mut cache) = rt.discovered.lock() {
8457            cache.insert(parsed.clone())
8458        } else {
8459            false
8460        }
8461    };
8462    // On first discovery: wire the SEDP stack + send out initial
8463    // announcements.
8464    if is_new {
8465        if let Ok(mut sedp) = rt.sedp.lock() {
8466            sedp.on_participant_discovered(&parsed);
8467        }
8468        // Event-driven directed SPDP response (§8.5.3): send OUR own
8469        // SPDP IMMEDIATELY unicast to the newly discovered peer, instead of letting it
8470        // wait for our next periodic multicast beacon (spdp_period=5s, codepit-LXC
8471        // multicast flaky). A spec-conformant peer (OpenDDS)
8472        // processes our auth request ONLY once it has our identity_token from
8473        // our SPDP — without this directed response it waits up to
8474        // spdp_period (seconds latency → cross-vendor ping wait_for_matched
8475        // timeout). NO timeout band-aid: the seconds latency was the missing
8476        // discovery event. Token-less first beacons (security not yet enabled)
8477        // are NOT sent (see security_pending in the announce loop) — the
8478        // periodic/announce_spdp_now path catches up.
8479        #[cfg(feature = "security")]
8480        let beacon_ready =
8481            !(rt.config.security.is_some() && rt.security_builtin_snapshot().is_none());
8482        #[cfg(not(feature = "security"))]
8483        let beacon_ready = true;
8484        if beacon_ready {
8485            let targets = wlp_unicast_targets(core::slice::from_ref(&parsed));
8486            if !targets.is_empty() {
8487                if let Some(secured) = rt
8488                    .spdp_beacon
8489                    .lock()
8490                    .ok()
8491                    .and_then(|mut b| b.serialize().ok())
8492                    .and_then(|d| secure_outbound_bytes(rt, &d).map(|c| c.to_vec()))
8493                {
8494                    for loc in &targets {
8495                        let _ = rt.spdp_unicast.send(loc, &secured);
8496                    }
8497                }
8498            }
8499        }
8500    }
8501    // FU2: wire the security builtin stack + kick off the auth handshake.
8502    // On EVERY beacon (not only is_new): `handle_remote_endpoints` and
8503    // `begin_handshake_with` are idempotent. This also covers the case
8504    // that the peer was discovered before the auth plugin was active via
8505    // `enable_security_builtins_with_auth` — the next
8506    // beacon refresh then kicks off the handshake. No-op without a plugin,
8507    // without security bits or without an announced identity_token.
8508    if let Some(sec) = rt.security_builtin_snapshot() {
8509        let handshake_dgs = if let Ok(mut s) = sec.lock() {
8510            s.note_remote_vendor(parsed.sender_prefix, parsed.sender_vendor);
8511            s.handle_remote_endpoints(&parsed);
8512            match parsed.data.identity_token.as_ref() {
8513                Some(token) => s
8514                    .begin_handshake_with(parsed.sender_prefix, parsed.data.guid.to_bytes(), token)
8515                    .unwrap_or_default(),
8516                None => Vec::new(),
8517            }
8518        } else {
8519            Vec::new()
8520        };
8521        for dg in handshake_dgs {
8522            send_discovery_datagram(rt, &dg.targets, &dg.bytes);
8523        }
8524    }
8525    //  Mirror the SPDP receive into the builtin DCPSParticipant reader.
8526    // We send on every beacon (also refresh) — Spec §2.2.5.1
8527    // allows it, take() returns the respective current
8528    // data to the user. A reader with KEEP_LAST(1) receives only the newest.
8529    if let Some(sinks) = rt.builtin_sinks_snapshot() {
8530        let dcps_sample =
8531            crate::builtin_topics::ParticipantBuiltinTopicData::from_wire(&parsed.data);
8532        // .7 §2.2.2.2.1.14: drop ignored participants before
8533        // they fall into the builtin reader.
8534        if let Some(filter) = rt.ignore_filter_snapshot() {
8535            let h = crate::instance_handle::InstanceHandle::from_guid(dcps_sample.key);
8536            if filter.is_participant_ignored(h) {
8537                return;
8538            }
8539        }
8540        let _ = sinks.push_participant(&dcps_sample);
8541    }
8542}
8543
8544/// Pushes SEDP events (new pubs/subs) into the 4 builtin-topic
8545/// readers. A new pub/sub produces **two** samples:
8546///
8547/// 1. a `DCPSPublication`/`DCPSSubscription` sample,
8548/// 2. a `DCPSTopic` sample (synthetic from topic name + type name).
8549///
8550/// The native SEDP-topics endpoints (RTPS 2.5 §9.3.2.12 bits 28/29)
8551/// are optional per Spec §8.5.4.4 and covered in ZeroDDS via this
8552/// synthetic derivation — see also
8553/// `endpoint_flag::ALL_STANDARD`, which deliberately omits the
8554/// topics bits. Cyclone/Fast-DDS peers that send their own topic
8555/// announces are ignored (no reader endpoint).
8556fn push_sedp_events_to_builtin_readers(
8557    rt: &Arc<DcpsRuntime>,
8558    events: &zerodds_discovery::sedp::SedpEvents,
8559) {
8560    let Some(sinks) = rt.builtin_sinks_snapshot() else {
8561        return;
8562    };
8563    let filter = rt.ignore_filter_snapshot();
8564    for w in &events.new_publications {
8565        let pub_sample = crate::builtin_topics::PublicationBuiltinTopicData::from_wire(w);
8566        let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_publication(w);
8567        // .7 §2.2.2.2.1.14/.16: consult the participant + publication +
8568        // topic ignore filters.
8569        if let Some(f) = &filter {
8570            let part_h = crate::instance_handle::InstanceHandle::from_guid(w.participant_key);
8571            let pub_h = crate::instance_handle::InstanceHandle::from_guid(w.key);
8572            let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
8573            if f.is_participant_ignored(part_h) || f.is_publication_ignored(pub_h) {
8574                continue;
8575            }
8576            let _ = sinks.push_publication(&pub_sample);
8577            if !f.is_topic_ignored(topic_h) {
8578                let _ = sinks.push_topic(&topic_sample);
8579            }
8580        } else {
8581            let _ = sinks.push_publication(&pub_sample);
8582            let _ = sinks.push_topic(&topic_sample);
8583        }
8584    }
8585    for r in &events.new_subscriptions {
8586        let sub_sample = crate::builtin_topics::SubscriptionBuiltinTopicData::from_wire(r);
8587        let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_subscription(r);
8588        if let Some(f) = &filter {
8589            let part_h = crate::instance_handle::InstanceHandle::from_guid(r.participant_key);
8590            let sub_h = crate::instance_handle::InstanceHandle::from_guid(r.key);
8591            let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
8592            if f.is_participant_ignored(part_h) || f.is_subscription_ignored(sub_h) {
8593                continue;
8594            }
8595            let _ = sinks.push_subscription(&sub_sample);
8596            if !f.is_topic_ignored(topic_h) {
8597                let _ = sinks.push_topic(&topic_sample);
8598            }
8599        } else {
8600            let _ = sinks.push_subscription(&sub_sample);
8601            let _ = sinks.push_topic(&topic_sample);
8602        }
8603    }
8604}
8605
8606/// Binary-property name of the crypto key material in the CryptoToken DataHolder
8607/// (DDS-Security §9.5.2.1.1, cyclone-verified: `dds.cryp.keymat`).
8608#[cfg(feature = "security")]
8609const CRYPTO_TOKEN_PROP: &str = "dds.cryp.keymat";
8610
8611/// CryptoToken `class_id` (§9.5.2.1: `DDS:Crypto:AES_GCM_GMAC` — underscores,
8612/// **not** the plugin-class string with hyphens).
8613#[cfg(feature = "security")]
8614const CRYPTO_TOKEN_CLASS_ID: &str = "DDS:Crypto:AES_GCM_GMAC";
8615
8616/// Builds the `PARTICIPANT_CRYPTO_TOKENS` VolatileSecure message with the
8617/// Kx-encrypted token as a binary property (FU2 S1.4).
8618#[cfg(feature = "security")]
8619fn build_crypto_token_message(
8620    rt: &DcpsRuntime,
8621    remote_prefix: GuidPrefix,
8622    kx_token: Vec<u8>,
8623) -> zerodds_security::generic_message::ParticipantGenericMessage {
8624    use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
8625    use zerodds_security::token::DataHolder;
8626    ParticipantGenericMessage {
8627        message_identity: MessageIdentity {
8628            source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
8629            sequence_number: 1,
8630        },
8631        related_message_identity: MessageIdentity::default(),
8632        destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
8633        destination_endpoint_key: [0; 16],
8634        source_endpoint_key: [0; 16],
8635        message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
8636        message_data: alloc::vec![
8637            DataHolder::new(CRYPTO_TOKEN_CLASS_ID)
8638                .with_binary_property(CRYPTO_TOKEN_PROP, kx_token)
8639        ],
8640    }
8641}
8642
8643/// FU2 S1.4 (send): after handshake completion Kx-encrypt the local data token
8644/// (`gate.local_token`) and send it as
8645/// `PARTICIPANT_CRYPTO_TOKENS` over VolatileSecure.
8646/// Registers the peer's Kx key in the gate beforehand. `None` without a gate
8647/// or on error (drop instead of leak).
8648#[cfg(feature = "security")]
8649fn prepare_crypto_token(
8650    rt: &DcpsRuntime,
8651    remote_prefix: GuidPrefix,
8652    remote_identity: zerodds_security::authentication::IdentityHandle,
8653    secret: zerodds_security::authentication::SharedSecretHandle,
8654) -> Option<zerodds_security::generic_message::ParticipantGenericMessage> {
8655    let gate = rt.config.security.as_ref()?;
8656    let peer_key = remote_prefix.to_bytes();
8657    // ALWAYS register the peer's Kx key — even with rtps=NONE: the per-endpoint
8658    // tokens (discovery_/data_protection) travel Kx-protected over the volatile,
8659    // protect_volatile_datagram needs this key.
8660    gate.register_remote_by_guid_from_secret(peer_key, remote_identity, secret)
8661        .ok()?;
8662    // BUT: send the ParticipantCryptoToken (= SRTPS keymat) ONLY when
8663    // rtps_protection != NONE. With rtps=NONE there is no SRTPS; OpenDDS rejects the
8664    // token (Spdp.cpp:1966 `crypto_handle_==NIL` -> "not configured for RTPS
8665    // Protection", logs `handle_participant_crypto_tokens failed`) and OpenDDS-self
8666    // also does NOT exchange it with rtps=NONE. None here = no participant
8667    // token send; the per-endpoint tokens continue over the separate path.
8668    if gate.rtps_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
8669        return None;
8670    }
8671    // Cross-vendor: the data token travels in PLAINTEXT in the
8672    // ParticipantGenericMessage — it becomes confidential only through the
8673    // SEC_PREFIX/BODY/POSTFIX submessage protection of the whole volatile
8674    // DATA (see protect_volatile_datagram). The `register_*` line above
8675    // created the peer's Kx key in the gate that this protection uses.
8676    let token = gate.local_token().ok()?;
8677    Some(build_crypto_token_message(rt, remote_prefix, token))
8678}
8679
8680/// Per-endpoint crypto handle for a local writer/reader (get-or-register).
8681/// DDS-Security §9.5.3.3: each endpoint has its OWN key material. Registration
8682/// under the write lock (race-free). `None` without an active gate.
8683#[cfg(feature = "security")]
8684fn local_endpoint_crypto_handle(
8685    rt: &DcpsRuntime,
8686    eid: EntityId,
8687    is_writer: bool,
8688) -> Option<zerodds_security::crypto::CryptoHandle> {
8689    let gate = rt.config.security.as_ref()?;
8690    {
8691        let map = rt.endpoint_crypto.read().ok()?;
8692        if let Some(h) = map.get(&eid) {
8693            return Some(*h);
8694        }
8695    }
8696    let mut map = rt.endpoint_crypto.write().ok()?;
8697    if let Some(h) = map.get(&eid) {
8698        return Some(*h);
8699    }
8700    let h = gate.register_local_endpoint(is_writer).ok()?;
8701    map.insert(eid, h);
8702    Some(h)
8703}
8704
8705/// Cross-vendor step 6b (send): per-endpoint `datawriter_crypto_tokens` (for
8706/// every local user writer) + `datareader_crypto_tokens` (for every local
8707/// user reader) to the peer. cyclone needs these to approve the user-endpoint
8708/// match and decode ZeroDDS' user DATA. `source_endpoint_key` = the
8709/// local endpoint GUID; the keymat is the local data key (one key per
8710/// participant in the bench). Empty list without a gate / without user endpoints.
8711#[cfg(feature = "security")]
8712fn prepare_endpoint_crypto_tokens(
8713    rt: &DcpsRuntime,
8714    remote_prefix: GuidPrefix,
8715) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
8716    use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
8717    use zerodds_security::token::DataHolder;
8718    let Some(gate) = rt.config.security.as_ref() else {
8719        return Vec::new();
8720    };
8721    let mut out = Vec::new();
8722    // cyclone associates a datawriter/datareader token via the pair
8723    // (source_endpoint, destination_endpoint). Hence per local endpoint ONE
8724    // token PER matched remote endpoint of **this** peer, with the concrete
8725    // remote GUID as destination_endpoint_key (dst=0 would make cyclone discard it).
8726    //
8727    // §9.5.3.3: the token carries the **per-endpoint** key material of the
8728    // `source_eid` (not the participant key) — the same key with which
8729    // ZeroDDS encodes this endpoint's submessages (protect_user_datagram).
8730    let build = |class: &str,
8731                 source_eid: EntityId,
8732                 dst: [u8; 16]|
8733     -> Option<ParticipantGenericMessage> {
8734        let is_writer = class == class_id::DATAWRITER_CRYPTO_TOKENS;
8735        let handle = local_endpoint_crypto_handle(rt, source_eid, is_writer)?;
8736        let token = gate.create_endpoint_token(handle).ok()?;
8737        // Dual key (metadata != data, meta-sign-data): cyclone expects
8738        // num_key_mat=2 — submessage keymat (metadata kind) + payload keymat
8739        // (data kind) as TWO DataHolders in this order. Single key
8740        // (all other profiles): only the submessage/endpoint keymat.
8741        let mut dhs = alloc::vec![
8742            DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, token)
8743        ];
8744        if let Some(pay) = gate.endpoint_payload_token(handle) {
8745            dhs.push(
8746                DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, pay),
8747            );
8748        }
8749        Some(ParticipantGenericMessage {
8750            message_identity: MessageIdentity {
8751                source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
8752                sequence_number: 1,
8753            },
8754            related_message_identity: MessageIdentity::default(),
8755            destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
8756            destination_endpoint_key: dst,
8757            source_endpoint_key: Guid::new(rt.guid_prefix, source_eid).to_bytes(),
8758            message_class_id: class.into(),
8759            message_data: dhs,
8760        })
8761    };
8762    // datawriter tokens: per local writer for every matched remote reader
8763    // of this peer (dst = reader GUID).
8764    for (weid, warc) in rt.writer_slots_snapshot() {
8765        if let Ok(slot) = warc.lock() {
8766            for proxy in slot.writer.reader_proxies() {
8767                if proxy.remote_reader_guid.prefix == remote_prefix {
8768                    out.extend(build(
8769                        class_id::DATAWRITER_CRYPTO_TOKENS,
8770                        weid,
8771                        proxy.remote_reader_guid.to_bytes(),
8772                    ));
8773                }
8774            }
8775        }
8776    }
8777    // datareader tokens: per local reader for every matched remote writer
8778    // of this peer (dst = writer GUID).
8779    for (reid, rarc) in rt.reader_slots_snapshot() {
8780        if let Ok(slot) = rarc.lock() {
8781            for ws in slot.reader.writer_proxies() {
8782                if ws.proxy.remote_writer_guid.prefix == remote_prefix {
8783                    out.extend(build(
8784                        class_id::DATAREADER_CRYPTO_TOKENS,
8785                        reid,
8786                        ws.proxy.remote_writer_guid.to_bytes(),
8787                    ));
8788                }
8789            }
8790        }
8791    }
8792    // Protected discovery (§8.4.2.4): the secure builtin SEDP endpoints
8793    // (DCPSPublications/SubscriptionsSecure) also need crypto tokens,
8794    // so the peer associates ZeroDDS' data key with them + decodes the secure-SEDP
8795    // submessages. cyclone exchanges these builtin-endpoint tokens
8796    // the same way over the volatile (ff0003c2/c7 + ff0004c2/c7).
8797    if gate
8798        .discovery_protection()
8799        .map(|l| l != ProtectionLevel::None)
8800        .unwrap_or(false)
8801    {
8802        let builtin_pairs = [
8803            (
8804                class_id::DATAWRITER_CRYPTO_TOKENS,
8805                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
8806                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
8807            ),
8808            (
8809                class_id::DATAREADER_CRYPTO_TOKENS,
8810                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
8811                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
8812            ),
8813            (
8814                class_id::DATAWRITER_CRYPTO_TOKENS,
8815                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
8816                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
8817            ),
8818            (
8819                class_id::DATAREADER_CRYPTO_TOKENS,
8820                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
8821                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
8822            ),
8823        ];
8824        for (class, src_eid, dst_eid) in builtin_pairs {
8825            out.extend(build(
8826                class,
8827                src_eid,
8828                Guid::new(remote_prefix, dst_eid).to_bytes(),
8829            ));
8830        }
8831    }
8832    // FastDDS interop: the reliable secure-SPDP builtin (DCPSParticipantsSecure,
8833    // ff0101c2/c7) needs per-endpoint crypto tokens when FastDDS SEC-encrypts the secure-
8834    // SPDP DATA under discovery_protection — otherwise the peer cannot
8835    // decode our secure SPDP -> no secure participant discovery ->
8836    // no token reciprocation. Gated on enable_secure_spdp.
8837    if rt.config.enable_secure_spdp {
8838        let spdp_pairs = [
8839            (
8840                class_id::DATAWRITER_CRYPTO_TOKENS,
8841                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
8842                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
8843            ),
8844            (
8845                class_id::DATAREADER_CRYPTO_TOKENS,
8846                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
8847                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
8848            ),
8849        ];
8850        for (class, src_eid, dst_eid) in spdp_pairs {
8851            out.extend(build(
8852                class,
8853                src_eid,
8854                Guid::new(remote_prefix, dst_eid).to_bytes(),
8855            ));
8856        }
8857    }
8858    // Liveliness protection (§8.4.2.4): the secure-WLP builtin endpoints
8859    // (BuiltinParticipantMessageSecure, ff0200c2/c7) also need per-
8860    // endpoint crypto tokens. cyclone gates the participant security approval
8861    // (and thus the user-endpoint connection) on it — without these tokens
8862    // "connect ... waiting for approval by security" stays hung.
8863    if gate
8864        .liveliness_protection()
8865        .map(|l| l != ProtectionLevel::None)
8866        .unwrap_or(false)
8867    {
8868        let wlp_pairs = [
8869            (
8870                class_id::DATAWRITER_CRYPTO_TOKENS,
8871                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
8872                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
8873            ),
8874            (
8875                class_id::DATAREADER_CRYPTO_TOKENS,
8876                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
8877                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
8878            ),
8879        ];
8880        for (class, src_eid, dst_eid) in wlp_pairs {
8881            out.extend(build(
8882                class,
8883                src_eid,
8884                Guid::new(remote_prefix, dst_eid).to_bytes(),
8885            ));
8886        }
8887    }
8888    out
8889}
8890
8891/// Dedup key of a per-endpoint crypto token: the pair
8892/// (source_endpoint, destination_endpoint). cyclone associates a
8893/// datawriter/datareader token via exactly this pair (§9.5.3.3), so it is
8894/// also the right granularity to remember which tokens have gone out.
8895#[cfg(feature = "security")]
8896fn endpoint_token_key(
8897    m: &zerodds_security::generic_message::ParticipantGenericMessage,
8898) -> [u8; 32] {
8899    let mut k = [0u8; 32];
8900    k[..16].copy_from_slice(&m.source_endpoint_key);
8901    k[16..].copy_from_slice(&m.destination_endpoint_key);
8902    k
8903}
8904
8905/// Filters out the per-endpoint tokens not yet sent. The previously
8906/// used **per-peer** once-guard was too coarse: it snapped shut as soon as the
8907/// participant/secure-SEDP builtin tokens were out — but user endpoints match
8908/// only later (after the secure SEDP). Their tokens then never went out,
8909/// and the peer could never decode ZeroDDS' user DATA. Per-token dedup
8910/// (peer+source+dest) sends each token exactly once — builtins early,
8911/// user endpoints as soon as they match.
8912#[cfg(feature = "security")]
8913fn pending_endpoint_tokens(
8914    msgs: Vec<zerodds_security::generic_message::ParticipantGenericMessage>,
8915    already_sent: &alloc::collections::BTreeSet<[u8; 32]>,
8916) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
8917    msgs.into_iter()
8918        .filter(|m| !already_sent.contains(&endpoint_token_key(m)))
8919        .collect()
8920}
8921
8922/// FU2 S1.4 (recv): Kx-decrypt an incoming `PARTICIPANT_CRYPTO_TOKENS` message
8923/// and install the peer's data key in the gate.
8924/// Afterwards secured user DATA round-trips with this peer.
8925#[cfg(feature = "security")]
8926fn install_crypto_token(
8927    rt: &DcpsRuntime,
8928    remote_prefix: GuidPrefix,
8929    msg: &zerodds_security::generic_message::ParticipantGenericMessage,
8930) {
8931    use zerodds_security::generic_message::class_id;
8932    // Cross-vendor: cyclone sends the data key both as
8933    // participant_crypto_tokens and per-endpoint as datawriter/
8934    // datareader_crypto_tokens. We install the keymat from all three
8935    // under the sender's participant slot (one user endpoint per participant
8936    // in the bench) — so decode_data_datawriter_from decodes the user DATA.
8937    if msg.message_class_id != class_id::PARTICIPANT_CRYPTO_TOKENS
8938        && msg.message_class_id != class_id::DATAWRITER_CRYPTO_TOKENS
8939        && msg.message_class_id != class_id::DATAREADER_CRYPTO_TOKENS
8940    {
8941        return;
8942    }
8943    let Some(gate) = rt.config.security.as_ref() else {
8944        return;
8945    };
8946    let peer_key = remote_prefix.to_bytes();
8947    // `message_data` is a sequence<DataHolder> (DDS-Security §7.4.4.3
8948    // ParticipantGenericMessage): cyclone packs MULTIPLE CryptoTokens (its own
8949    // key material per endpoint, different transformation_key_id) into ONE
8950    // message. Install ALL — taking only `.first()` lost the
8951    // endpoint keys (key_id 2..N) and the secure SEDP stayed undecodable.
8952    // Plaintext token (confidentiality was provided by the submessage protection of
8953    // the transporting volatile DATA, see unprotect_volatile_datagram).
8954    // DDS-Security §9.5.2 vs §9.5.3: the PARTICIPANT crypto token carries the
8955    // message-level key (SRTPS, decode_secured_rtps_message -> slots[peer]); the
8956    // datawriter/datareader tokens carry per-endpoint data keys that belong ONLY in
8957    // the key_id path (remote_by_key_id, decode_data_by_key_id). Putting both
8958    // into slots[peer] let the last-installed (datareader) overwrite the
8959    // participant key -> message-level SRTPS tag mismatch.
8960    let is_participant = msg.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS;
8961    for dh in &msg.message_data {
8962        if let Some(token) = dh.binary_property(CRYPTO_TOKEN_PROP) {
8963            let _ = if is_participant {
8964                gate.set_remote_data_token_by_guid(&peer_key, token)
8965            } else {
8966                gate.install_remote_endpoint_token(token)
8967            };
8968        }
8969    }
8970}
8971
8972// RTPS submessage IDs for the VolatileSecure submessage-protection surgery.
8973#[cfg(feature = "security")]
8974const SMID_DATA: u8 = 0x15;
8975#[cfg(feature = "security")]
8976const SMID_SEC_PREFIX: u8 = 0x31;
8977#[cfg(feature = "security")]
8978const SMID_SEC_POSTFIX: u8 = 0x32;
8979// Further writer submessage IDs (DDSI-RTPS 2.5 §8.3.7). Per DDS-Security
8980// §8.4.2.4 (is_submessage_protected=TRUE, DataWriter) ALL submessages sent by the
8981// writer — not only DATA — MUST be protected via encode_datawriter_submessage.
8982// HEARTBEAT is the critical one: without it the remote
8983// reader cannot NACK a missing sequence number (= no reliable recovery).
8984#[cfg(feature = "security")]
8985const SMID_HEARTBEAT: u8 = 0x07;
8986#[cfg(feature = "security")]
8987const SMID_GAP: u8 = 0x08;
8988#[cfg(feature = "security")]
8989const SMID_DATA_FRAG: u8 = 0x16;
8990#[cfg(feature = "security")]
8991const SMID_HEARTBEAT_FRAG: u8 = 0x13;
8992// Reader submessages (DDSI-RTPS 2.5 §8.3.7): under `metadata_protection_kind
8993// != NONE` to be protected via `encode_datareader_submessage` (§8.4.2.4) with the per-endpoint
8994// reader key — otherwise a spec-conformant remote writer
8995// (cyclone under discovery=ENCRYPT) discards the clear ACKNACK and never re-sends.
8996#[cfg(feature = "security")]
8997const SMID_ACKNACK: u8 = 0x06;
8998#[cfg(feature = "security")]
8999const SMID_NACK_FRAG: u8 = 0x12;
9000
9001/// `true` if the submessage ID is a submessage sent by the DataReader
9002/// (ACKNACK/NACK_FRAG) — datareader protection path.
9003#[cfg(feature = "security")]
9004fn is_protected_reader_submessage(id: u8) -> bool {
9005    matches!(id, SMID_ACKNACK | SMID_NACK_FRAG)
9006}
9007
9008/// Extracts the `reader_id` (sender) from an ACKNACK/NACK_FRAG submessage:
9009/// offset 4 (after header(4)), directly before the writer_id (offset 8).
9010#[cfg(feature = "security")]
9011fn reader_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
9012    if !is_protected_reader_submessage(id) {
9013        return None;
9014    }
9015    let raw: [u8; 4] = submsg.get(4..8)?.try_into().ok()?;
9016    Some(EntityId::from_bytes(raw))
9017}
9018
9019/// `true` if the submessage ID is a submessage sent by the DataWriter that,
9020/// under `metadata_protection_kind != NONE`, must be protected via `encode_datawriter_submessage`
9021/// (DDS-Security §8.4.2.4). ACKNACK/NACK_FRAG are
9022/// reader submessages (datareader path) and are excluded here.
9023#[cfg(feature = "security")]
9024fn is_protected_writer_submessage(id: u8) -> bool {
9025    matches!(
9026        id,
9027        SMID_DATA | SMID_DATA_FRAG | SMID_HEARTBEAT | SMID_HEARTBEAT_FRAG | SMID_GAP
9028    )
9029}
9030
9031/// Walks the submessages of an RTPS datagram from `offset` and returns
9032/// `(submessage_id, start, total_len)`. `octetsToNextHeader == 0` means
9033/// "to the end of the datagram" (RTPS §8.3.3.2.3).
9034#[cfg(feature = "security")]
9035fn walk_submessages(bytes: &[u8]) -> Vec<(u8, usize, usize)> {
9036    let mut out = Vec::new();
9037    let mut o = 20; // RTPS header
9038    while o + 4 <= bytes.len() {
9039        let id = bytes[o];
9040        let le = bytes[o + 1] & 0x01 != 0;
9041        let raw = if le {
9042            u16::from_le_bytes([bytes[o + 2], bytes[o + 3]])
9043        } else {
9044            u16::from_be_bytes([bytes[o + 2], bytes[o + 3]])
9045        } as usize;
9046        let body = if raw == 0 { bytes.len() - (o + 4) } else { raw };
9047        let total = 4 + body;
9048        if o + total > bytes.len() {
9049            break;
9050        }
9051        out.push((id, o, total));
9052        o += total;
9053    }
9054    out
9055}
9056
9057/// Cross-vendor VolatileSecure (send): replaces every DATA submessage in the
9058/// datagram with the cyclone-conformant `SEC_PREFIX`/`SEC_BODY`/`SEC_POSTFIX`
9059/// sequence (encrypted with the peer's Kx key). Other submessages
9060/// (INFO_DST/INFO_TS/HEARTBEAT) stay unchanged. Returns the datagram
9061/// unchanged if no DATA submessage is present (e.g. a pure
9062/// HEARTBEAT tick). `None` only on a crypto error (drop instead of leak).
9063#[cfg(feature = "security")]
9064fn protect_volatile_datagram(
9065    rt: &DcpsRuntime,
9066    bytes: &[u8],
9067    peer_key: &[u8; 12],
9068) -> Option<Vec<u8>> {
9069    let gate = rt.config.security.as_ref()?;
9070    if bytes.len() < 20 {
9071        return Some(bytes.to_vec());
9072    }
9073    let subs = walk_submessages(bytes);
9074    // DDS-Security §8.4.2.4: ParticipantVolatileMessageSecure is submessage-
9075    // protected — ALL submessages sent by the endpoint MUST be protected with the Kx key,
9076    // not only DATA. This holds for BOTH directions:
9077    //  * writer submessages (DATA, DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP)
9078    //  * reader submessages (ACKNACK, NACK_FRAG)
9079    // cyclone/FastDDS otherwise discard the WHOLE volatile sample with "clear
9080    // submsg from protected src" → the crypto-token exchange over the volatile
9081    // stalls. write_with_heartbeat bundles DATA+HEARTBEAT into ONE datagram; if
9082    // the HEARTBEAT stayed clear, the whole token sample was lost (cross-vendor
9083    // cyclone→ZeroDDS responder).
9084    // The reader ACKNACK: OpenDDS' RtpsUdpReceiveStrategy::check_encoded requires
9085    // protection for the volatile reader (ff0202c4, is_submessage_protected=TRUE) and
9086    // otherwise drops the clear ACKNACK ("Submessage requires protection") → its
9087    // volatile WRITER never gets an ACK → considers the token delivery
9088    // unacknowledged → zerodds NEVER sends the SRTPS-protected secure SEDP → no
9089    // user-endpoint match. The volatile channel uses ONE shared Kx session key
9090    // (KDF from the shared secret, §9.5.3.3.4.4), symmetric for both directions
9091    // → protect the ACKNACK with the same Kx key as the DATA.
9092    if !subs.iter().any(|(id, _, _)| {
9093        is_protected_writer_submessage(*id) || is_protected_reader_submessage(*id)
9094    }) {
9095        return Some(bytes.to_vec()); // no protection-worthy submessage -> unchanged
9096    }
9097    let mut out = Vec::with_capacity(bytes.len() + 64);
9098    out.extend_from_slice(&bytes[..20]);
9099    for (id, start, total) in subs {
9100        let submsg = &bytes[start..start + total];
9101        if is_protected_writer_submessage(id) || is_protected_reader_submessage(id) {
9102            match gate.encode_kx_datawriter_for(peer_key, submsg) {
9103                Ok(sec) => out.extend_from_slice(&sec),
9104                Err(_) => return None, // drop instead of plaintext leak
9105            }
9106        } else {
9107            out.extend_from_slice(submsg);
9108        }
9109    }
9110    Some(out)
9111}
9112
9113/// Cross-vendor VolatileSecure (recv): recognizes a `SEC_PREFIX`/`SEC_BODY`/
9114/// `SEC_POSTFIX` sequence, decodes it with the peer's Kx key to the
9115/// original DATA submessage and builds a plain RTPS datagram for the
9116/// `volatile_reader`. `None` if no SEC_* sequence is present (then the normal
9117/// path) or on a crypto error.
9118#[cfg(feature = "security")]
9119fn unprotect_volatile_datagram(
9120    rt: &DcpsRuntime,
9121    bytes: &[u8],
9122    peer_key: &[u8; 12],
9123) -> Option<Vec<u8>> {
9124    let gate = rt.config.security.as_ref()?;
9125    if bytes.len() < 20 {
9126        return None;
9127    }
9128    let subs = walk_submessages(bytes);
9129    // Cyclone/FastDDS bundle, via xpack, MULTIPLE SEC_*-protected volatile
9130    // submessages (all with the Kx key) into ONE datagram. So there can be
9131    // multiple SEC_PREFIX/BODY/POSTFIX triples — transform ALL back
9132    // (like unprotect_user_datagram). Decoding only the first block (an earlier
9133    // bug) left every bundled token sample after the first encrypted;
9134    // the VOLATILE writer does not retransmit them → deterministic
9135    // token loss (no "flaky" transport, all same-host). `None` if there is no
9136    // SEC_PREFIX at all (plaintext) or the Kx decode fails (= not a volatile datagram,
9137    // e.g. secure SEDP with a per-endpoint key).
9138    if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
9139        return None;
9140    }
9141    let mut out = Vec::with_capacity(bytes.len());
9142    out.extend_from_slice(&bytes[..20]);
9143    let mut i = 0;
9144    while i < subs.len() {
9145        let (id, start, total) = subs[i];
9146        if id == SMID_SEC_PREFIX {
9147            let postfix_idx = subs[i..]
9148                .iter()
9149                .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
9150                .map(|off| i + off)?;
9151            let (_, q_start, q_total) = subs[postfix_idx];
9152            let sec_wire = &bytes[start..q_start + q_total];
9153            let submsg = gate.decode_kx_datawriter_from(peer_key, sec_wire).ok()?;
9154            out.extend_from_slice(&submsg);
9155            i = postfix_idx + 1;
9156        } else {
9157            out.extend_from_slice(&bytes[start..start + total]);
9158            i += 1;
9159        }
9160    }
9161    Some(out)
9162}
9163
9164/// Protects a peer's volatile outbound datagrams (DATA -> SEC_*).
9165/// HEARTBEAT/ACKNACK datagrams (without DATA) stay unchanged; datagrams
9166/// with a crypto error are dropped.
9167#[cfg(feature = "security")]
9168fn protect_volatile_outbound(
9169    rt: &DcpsRuntime,
9170    remote_prefix: GuidPrefix,
9171    dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>,
9172) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
9173    let peer_key = remote_prefix.to_bytes();
9174    dgs.into_iter()
9175        .filter_map(|dg| {
9176            protect_volatile_datagram(rt, &dg.bytes, &peer_key).map(|bytes| {
9177                zerodds_rtps::message_builder::OutboundDatagram {
9178                    bytes,
9179                    targets: dg.targets,
9180                }
9181            })
9182        })
9183        .collect()
9184}
9185
9186/// Cross-vendor (send): replaces EVERY submessage sent by the DataWriter (DATA,
9187/// DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP) with the cyclone-conformant
9188/// SEC_PREFIX/BODY/POSTFIX sequence, encrypted with the **local data key**.
9189/// DDS-Security §8.4.2.4 (`is_submessage_protected=TRUE`, DataWriter): ALL
9190/// writer submessages MUST be protected via `encode_datawriter_submessage`
9191/// — in particular the HEARTBEAT, otherwise the remote reader cannot NACK missing
9192/// sequence numbers (no reliable recovery). Framing submessages
9193/// (INFO_TS/INFO_DST/...) stay unchanged; `None` on a crypto error.
9194#[cfg(feature = "security")]
9195fn protect_user_datagram(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
9196    let gate = rt.config.security.as_ref()?;
9197    if bytes.len() < 20 {
9198        return Some(bytes.to_vec());
9199    }
9200    let subs = walk_submessages(bytes);
9201    if !subs
9202        .iter()
9203        .any(|(id, _, _)| is_protected_writer_submessage(*id))
9204    {
9205        return Some(bytes.to_vec());
9206    }
9207    // §9.5.3.3 per-endpoint key: all writer submessages of a datagram
9208    // come from the same writer. Take the writer_id from the first protected
9209    // submessage + look up the per-endpoint handle. No handle
9210    // (unregistered endpoint) → participant-key fallback.
9211    let endpoint_handle = subs
9212        .iter()
9213        .find(|(id, _, _)| is_protected_writer_submessage(*id))
9214        .and_then(|&(id, start, total)| writer_eid_in_submessage(&bytes[start..start + total], id))
9215        .and_then(|weid| local_endpoint_crypto_handle(rt, weid, true));
9216    let mut out = Vec::with_capacity(bytes.len() + 64);
9217    out.extend_from_slice(&bytes[..20]);
9218    for (id, start, total) in subs {
9219        let submsg = &bytes[start..start + total];
9220        if is_protected_writer_submessage(id) {
9221            let sec = match endpoint_handle {
9222                Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
9223                None => gate.encode_data_datawriter_local(submsg),
9224            };
9225            match sec {
9226                Ok(s) => out.extend_from_slice(&s),
9227                Err(_) => return None,
9228            }
9229        } else {
9230            out.extend_from_slice(submsg);
9231        }
9232    }
9233    Some(out)
9234}
9235
9236/// Extracts the `writer_id` from an RTPS writer submessage. DATA/DATA_FRAG:
9237/// offset 12 (header(4)+extraFlags(2)+octetsToInlineQos(2)+readerId(4));
9238/// HEARTBEAT/GAP/HEARTBEAT_FRAG: offset 8 (header(4)+readerId(4)).
9239#[cfg(feature = "security")]
9240fn writer_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
9241    let off = match id {
9242        SMID_DATA | SMID_DATA_FRAG => 12,
9243        SMID_HEARTBEAT | SMID_GAP | SMID_HEARTBEAT_FRAG => 8,
9244        _ => return None,
9245    };
9246    let raw: [u8; 4] = submsg.get(off..off + 4)?.try_into().ok()?;
9247    Some(EntityId::from_bytes(raw))
9248}
9249
9250/// Cross-vendor user DATA (recv): decodes the SEC_* sequence with the sender's
9251/// data key (`peer_key` = sender GuidPrefix) back to the DATA submessage.
9252/// `None` if no SEC_* sequence is present (normal path) or on a crypto error.
9253#[cfg(feature = "security")]
9254fn unprotect_user_datagram(rt: &DcpsRuntime, bytes: &[u8], peer_key: &[u8; 12]) -> Option<Vec<u8>> {
9255    let gate = rt.config.security.as_ref()?;
9256    if bytes.len() < 20 {
9257        return None;
9258    }
9259    let subs = walk_submessages(bytes);
9260    // §8.4.2.4: the peer SEC_*-wrapped EVERY writer submessage individually
9261    // (DATA, HEARTBEAT, GAP, ...). So there can be MULTIPLE SEC_PREFIX/BODY/
9262    // POSTFIX triples in the same datagram — transform them all back. `None`
9263    // only if there is no SEC_* sequence at all (normal/plaintext path).
9264    if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
9265        return None;
9266    }
9267    let mut out = Vec::with_capacity(bytes.len());
9268    out.extend_from_slice(&bytes[..20]);
9269    let mut i = 0;
9270    while i < subs.len() {
9271        let (id, start, total) = subs[i];
9272        if id == SMID_SEC_PREFIX {
9273            // Find the matching SEC_POSTFIX from i; the block is [prefix..postfix].
9274            let postfix_idx = subs[i..]
9275                .iter()
9276                .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
9277                .map(|off| i + off)?;
9278            let (_, q_start, q_total) = subs[postfix_idx];
9279            let sec_wire = &bytes[start..q_start + q_total];
9280            // key_id-based decode: the peer has, per endpoint (user +
9281            // secure-builtin discovery), its own key material; the correct
9282            // key is found via the transformation_key_id in the CryptoHeader.
9283            // Fallback for transformation_key_id=0: this is NOT a per-
9284            // endpoint token key, but the participant-level key derived from the
9285            // SharedSecret (DDS-Security Tab.73, AES256-GCM, sender_key_id
9286            // =0) — cyclone protects with it under rtps_protection. That one is decoded by the
9287            // Kx path (peer-prefix-indexed SharedSecret key).
9288            let mut submsg = gate
9289                .decode_data_by_key_id(sec_wire)
9290                .or_else(|_| gate.decode_data_datawriter_from(peer_key, sec_wire))
9291                .or_else(|_| gate.decode_kx_datawriter_from(peer_key, sec_wire))
9292                .ok()?;
9293            // Correct octetsToNextHeader to the real body length: cyclone
9294            // wraps every writer submessage INDIVIDUALLY; within its SEC_BODY
9295            // it is the last one -> octetsToNextHeader=0 ("to the end of the message").
9296            // When concatenating multiple decoded blocks (e.g. DATA + piggybacked
9297            // HEARTBEAT), otn=0 would make the strict decode_datagram swallow the following
9298            // submessage as payload -> the reader would never see the
9299            // HEARTBEAT and would block as a late joiner on the SN gap.
9300            if submsg.len() >= 4 {
9301                let le = submsg[1] & zerodds_rtps::FLAG_E_LITTLE_ENDIAN != 0;
9302                let otn = u16::try_from(submsg.len() - 4).unwrap_or(0);
9303                let b = if le {
9304                    otn.to_le_bytes()
9305                } else {
9306                    otn.to_be_bytes()
9307                };
9308                submsg[2] = b[0];
9309                submsg[3] = b[1];
9310            }
9311            out.extend_from_slice(&submsg);
9312            i = postfix_idx + 1;
9313        } else {
9314            out.extend_from_slice(&bytes[start..start + total]);
9315            i += 1;
9316        }
9317    }
9318    Some(out)
9319}
9320
9321/// §8.5.1.9.1 / §9.5.3.3.1 data_protection (send): encrypts ONLY the
9322/// SerializedPayload INSIDE each DATA submessage (payload layer). The
9323/// submessage header, octetsToInlineQos, InlineQoS and the flags (E/Q/D/K)
9324/// stay byte-identical; only the N-flag (NonStandardPayload, §8.3.8.2) is
9325/// set and octetsToNextHeader adjusted to the new payload length. This is
9326/// the spec-conformant + cyclone-interop form of data_protection (counterpart:
9327/// metadata_protection = whole submessage SEC_*-wrapped). Applied as the INNER
9328/// layer BEFORE the submessage/message protection. `None` on a
9329/// crypto error (drop instead of leak); a datagram without DATA stays unchanged.
9330#[cfg(feature = "security")]
9331fn protect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
9332    use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
9333    use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
9334    let gate = rt.config.security.as_ref()?;
9335    if bytes.len() < 20 {
9336        return Some(bytes.to_vec());
9337    }
9338    let subs = walk_submessages(bytes);
9339    if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
9340        return Some(bytes.to_vec());
9341    }
9342    let mut out = Vec::with_capacity(bytes.len() + 64);
9343    out.extend_from_slice(&bytes[..20]);
9344    for (id, start, total) in subs {
9345        let submsg = &bytes[start..start + total];
9346        if id != SMID_DATA {
9347            out.extend_from_slice(submsg);
9348            continue;
9349        }
9350        let flags = submsg[1];
9351        let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
9352        // data_protection payload key: the **per-endpoint DataWriter key**
9353        // (§9.5.3.3.1). cyclone associates the DataWriter strictly with its
9354        // datawriter_crypto_handle and decodes the SerializedPayload ONLY with
9355        // this key — the participant key yields "Invalid Crypto
9356        // Handle" in cyclone. The key is sent to the peer as a datawriter_crypto_token;
9357        // the reader finds it via the transformation_key_id in the CryptoHeader.
9358        let handle = writer_eid_in_submessage(submsg, id)
9359            .and_then(|w| local_endpoint_crypto_handle(rt, w, true))?;
9360        // Payload boundary: read_body_with_flags returns serialized_payload as
9361        // an Arc of body[pos..] -> payload = the last plen bytes of the submessage.
9362        let body = &submsg[4..];
9363        let ds = DataSubmessage::read_body_with_flags(body, le, flags).ok()?;
9364        let plen = ds.serialized_payload.len();
9365        let payload_off = submsg.len() - plen;
9366        let enc = gate
9367            .encode_serialized_payload(handle, &ds.serialized_payload)
9368            .ok()?;
9369        let new_body_len = (payload_off - 4) + enc.len();
9370        if new_body_len > u16::MAX as usize {
9371            return None;
9372        }
9373        out.push(submsg[0]);
9374        out.push(flags | DATA_FLAG_NON_STANDARD);
9375        let otn = new_body_len as u16;
9376        if le {
9377            out.extend_from_slice(&otn.to_le_bytes());
9378        } else {
9379            out.extend_from_slice(&otn.to_be_bytes());
9380        }
9381        // Body prefix (extraFlags..InlineQoS) verbatim, then encrypted payload.
9382        out.extend_from_slice(&submsg[4..payload_off]);
9383        out.extend_from_slice(&enc);
9384    }
9385    Some(out)
9386}
9387
9388/// Result of the inner payload layer on receipt (§8.5.1.9.4).
9389#[cfg(feature = "security")]
9390enum PayloadDecode {
9391    /// No DATA submessage carries the N-flag — plaintext path, pass the datagram
9392    /// on unchanged.
9393    NotEncrypted,
9394    /// Successfully decrypted — use the plaintext datagram.
9395    Decoded(Vec<u8>),
9396    /// N-flag set, but decryption failed. The datagram MUST
9397    /// be discarded — passing an undecodable encrypted payload as
9398    /// ciphertext gives the reader garbage (§8.5: reject). The
9399    /// reliable re-send catches up on the sample once the key is installed
9400    /// resp. another (e.g. inproc/message-level) copy delivers it.
9401    Failed,
9402}
9403
9404/// `true` if the SerializedPayload begins with a CryptoHeader (§9.5.3.3.1):
9405/// the first 4 bytes are a CryptoTransformKind != NONE
9406/// (AES128_GMAC/GCM, AES256_GMAC/GCM = `[0,0,0,1..=4]`). A plaintext CDR
9407/// encapsulation carries either a different first byte pair (CDR_LE `[0,1]`,
9408/// XCDR2 `[0,6/7]`, PL_CDR `[0,2/3]`) or — for CDR_BE `[0,0]` — options
9409/// `[0,0]`, so it does not collide with the transform kinds 1..=4. Serves as
9410/// detection for vendors (cyclone) that encrypt the data_protection payload
9411/// without setting the N-flag of the DATA submessage.
9412#[cfg(feature = "security")]
9413fn payload_has_crypto_header(payload: &[u8]) -> bool {
9414    matches!(payload, [0, 0, 0, 1..=4, ..])
9415}
9416
9417/// §8.5.1.9.4 / §9.5.3.3.1 data_protection (recv): decrypts the
9418/// SerializedPayload of each DATA submessage whose payload begins with a CryptoHeader
9419/// — recognized by the set N-flag (zero↔zero, [`protect_user_payload`])
9420/// OR by the CryptoTransformKind signature (cyclone does not set the N-flag).
9421/// The tag verification of the GCM open IS the detection: if the decode fails
9422/// and the N-flag was not set, the submessage is passed through as plaintext
9423/// (false positive of the signature heuristic). The key is found via the
9424/// `transformation_key_id` (key_id), the sender prefix (peer slot) or — for
9425/// key_id=0 (participant/Kx key, cyclone) — the Kx key material.
9426/// `NotEncrypted` if no DATA submessage was decrypted; `Failed` only
9427/// on an N-flag decode error (§8.5: reject undecryptable).
9428#[cfg(feature = "security")]
9429fn unprotect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> PayloadDecode {
9430    use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
9431    use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
9432    let Some(gate) = rt.config.security.as_ref() else {
9433        return PayloadDecode::NotEncrypted;
9434    };
9435    if bytes.len() < 20 {
9436        return PayloadDecode::NotEncrypted;
9437    }
9438    // Sender prefix (RTPS header bytes[8..20]) as a fallback key index, if the
9439    // transformation_key_id in the CryptoHeader is not uniquely in the remote index
9440    // (zero↔zero indexed via the peer slot, cyclone strictly via key_id).
9441    let mut peer_key = [0u8; 12];
9442    peer_key.copy_from_slice(&bytes[8..20]);
9443    let subs = walk_submessages(bytes);
9444    let mut out = Vec::with_capacity(bytes.len());
9445    out.extend_from_slice(&bytes[..20]);
9446    let mut did_decode = false;
9447    for (id, start, total) in subs {
9448        let submsg = &bytes[start..start + total];
9449        if id != SMID_DATA {
9450            out.extend_from_slice(submsg);
9451            continue;
9452        }
9453        let flags = submsg[1];
9454        let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
9455        let nflag = flags & DATA_FLAG_NON_STANDARD != 0;
9456        let body = &submsg[4..];
9457        let Ok(ds) = DataSubmessage::read_body_with_flags(body, le, flags) else {
9458            // Parse error of a DATA marked as encrypted -> drop;
9459            // a pure plaintext DATA never made read_body_with_flags fail,
9460            // so a set N-flag is the only reason here.
9461            if nflag {
9462                return PayloadDecode::Failed;
9463            }
9464            out.extend_from_slice(submsg);
9465            continue;
9466        };
9467        // Only attempt when the payload is recognizable as encrypted:
9468        // N-flag (zero↔zero) or CryptoHeader signature (cyclone without an N-flag).
9469        if !nflag && !payload_has_crypto_header(&ds.serialized_payload) {
9470            out.extend_from_slice(submsg);
9471            continue;
9472        }
9473        let plen = ds.serialized_payload.len();
9474        let payload_off = submsg.len() - plen;
9475        let pdec = gate
9476            .decode_serialized_payload(&ds.serialized_payload)
9477            .or_else(|_| gate.decode_serialized_payload_from(&peer_key, &ds.serialized_payload))
9478            .or_else(|_| gate.decode_serialized_payload_kx(&peer_key, &ds.serialized_payload));
9479        let Ok(dec) = pdec else {
9480            // §8.5: if the N-flag was set, the payload is surely encrypted
9481            // and the reader would get garbage -> drop (reliable re-send catches it
9482            // up after key install). If only the signature heuristic was the trigger
9483            // (no N-flag), it is a plaintext CDR_BE payload whose options
9484            // happen to look like a TransformKind -> pass through unchanged.
9485            if nflag {
9486                return PayloadDecode::Failed;
9487            }
9488            out.extend_from_slice(submsg);
9489            continue;
9490        };
9491        let new_body_len = (payload_off - 4) + dec.len();
9492        if new_body_len > u16::MAX as usize {
9493            return PayloadDecode::Failed;
9494        }
9495        out.push(submsg[0]);
9496        out.push(flags & !DATA_FLAG_NON_STANDARD);
9497        let otn = new_body_len as u16;
9498        if le {
9499            out.extend_from_slice(&otn.to_le_bytes());
9500        } else {
9501            out.extend_from_slice(&otn.to_be_bytes());
9502        }
9503        out.extend_from_slice(&submsg[4..payload_off]);
9504        out.extend_from_slice(&dec);
9505        did_decode = true;
9506    }
9507    if did_decode {
9508        PayloadDecode::Decoded(out)
9509    } else {
9510        PayloadDecode::NotEncrypted
9511    }
9512}
9513
9514/// `true` if the EntityId is one of the four secure-SEDP discovery endpoints
9515/// (DCPSPublicationsSecure/DCPSSubscriptionsSecure, EntityIds ff0003c2/c7 +
9516/// ff0004c2/c7). Controls whether a SEDP datagram is protected-discovery traffic
9517/// and must be SEC_*-protected (DDS-Security §8.4.2.4).
9518#[cfg(feature = "security")]
9519fn is_secure_sedp_entity(e: EntityId) -> bool {
9520    e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER
9521        || e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER
9522        || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER
9523        || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER
9524}
9525
9526/// `true` if the datagram carries a submessage to/from a secure-SEDP endpoint
9527/// — then it is protected-discovery traffic.
9528#[cfg(feature = "security")]
9529fn is_secure_sedp_datagram(bytes: &[u8]) -> bool {
9530    let Ok(parsed) = decode_datagram(bytes) else {
9531        return false;
9532    };
9533    parsed.submessages.iter().any(|s| {
9534        let ids = match s {
9535            ParsedSubmessage::Data(d) => [Some(d.writer_id), Some(d.reader_id)],
9536            ParsedSubmessage::DataFrag(d) => [Some(d.writer_id), Some(d.reader_id)],
9537            ParsedSubmessage::Heartbeat(h) => [Some(h.writer_id), Some(h.reader_id)],
9538            ParsedSubmessage::Gap(g) => [Some(g.writer_id), Some(g.reader_id)],
9539            ParsedSubmessage::AckNack(a) => [Some(a.writer_id), Some(a.reader_id)],
9540            ParsedSubmessage::NackFrag(n) => [Some(n.writer_id), Some(n.reader_id)],
9541            _ => [None, None],
9542        };
9543        ids.into_iter().flatten().any(is_secure_sedp_entity)
9544    })
9545}
9546
9547/// Protected discovery (DDS-Security §8.4.2.4) send: secure-SEDP datagrams
9548/// (DATA/HEARTBEAT/GAP of the secure writers) are
9549/// `encode_datawriter_submessage`-protected with the participant data key — the same key the peer installs via
9550/// `participant_crypto_tokens`. Non-secure SEDP goes through unchanged.
9551/// `None` ⟹ crypto error on secure SEDP → drop the datagram instead of a
9552/// plaintext leak.
9553#[cfg(feature = "security")]
9554fn protect_sedp_outbound(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
9555    let Some(gate) = rt.config.security.as_ref() else {
9556        return Some(bytes.to_vec());
9557    };
9558    if !is_secure_sedp_datagram(bytes) || bytes.len() < 20 {
9559        return Some(bytes.to_vec());
9560    }
9561    // Governance §8.4.2.4: discovery_protection_kind=NONE -> NO discovery
9562    // protection. Secure-SEDP entities (ff0003c7/ff0004c7) must then NOT
9563    // be per-endpoint-protected; otherwise their ACKNACKs leak as message-
9564    // level SEC_PREFIX with a never-exchanged per-endpoint key that a
9565    // peer (cyclone uses plain SEDP under discovery=NONE) discards as "Invalid Crypto
9566    // Handle". Pass through plain -> the outer rtps_protection
9567    // layer (SRTPS via secure_outbound_bytes) wraps the whole message correctly.
9568    if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
9569        return Some(bytes.to_vec());
9570    }
9571    // §8.4.2.4: protect BOTH directions — writer submessages (DATA/HEARTBEAT/
9572    // GAP) with the per-endpoint writer key (encode_datawriter_submessage), reader
9573    // submessages (ACKNACK/NACK_FRAG) with the per-endpoint reader key
9574    // (encode_datareader_submessage). A spec-conformant cyclone under
9575    // discovery=ENCRYPT discards a CLEAR ACKNACK of the secure-SEDP reader →
9576    // never re-sends the SubscriptionData → ZeroDDS never discovers the reader. The
9577    // per-endpoint key (same as in the sent datareader_crypto_token)
9578    // makes the ACKNACK decodable for cyclone.
9579    let subs = walk_submessages(bytes);
9580    let mut out = Vec::with_capacity(bytes.len() + 64);
9581    out.extend_from_slice(&bytes[..20]);
9582    for (id, start, total) in subs {
9583        let submsg = &bytes[start..start + total];
9584        let handle = if is_protected_writer_submessage(id) {
9585            writer_eid_in_submessage(submsg, id)
9586                .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
9587        } else if is_protected_reader_submessage(id) {
9588            reader_eid_in_submessage(submsg, id)
9589                .and_then(|r| local_endpoint_crypto_handle(rt, r, false))
9590        } else {
9591            // Framing submessage (INFO_TS/INFO_DST/...) — unchanged.
9592            out.extend_from_slice(submsg);
9593            continue;
9594        };
9595        let sec = match handle {
9596            Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
9597            // No per-endpoint handle (should not occur for secure SEDP)
9598            // → participant-key fallback, so no plaintext leak arises.
9599            None => gate.encode_data_datawriter_local(submsg),
9600        };
9601        match sec {
9602            Ok(s) => out.extend_from_slice(&s),
9603            Err(_) => return None,
9604        }
9605    }
9606    Some(out)
9607}
9608
9609/// Protects a user-reader outbound datagram (ACKNACK/NACK_FRAG) on the
9610/// send direction (DDS-Security §8.4.2.4). Counterpart to the writer-DATA layer:
9611/// under `metadata_protection != NONE` the reader submessage too MUST be protected with the
9612/// per-endpoint reader key, otherwise a spec-strict
9613/// peer writer (cyclone/FastDDS) discards the CLEAR ACKNACK → the SN gap is never
9614/// re-sent → permanent reliable stall. Only needed when
9615/// **rtps_protection** does NOT already wrap the message as an SRTPS whole; otherwise
9616/// (and with metadata=NONE) the function delegates to `secure_outbound_bytes`.
9617#[cfg(feature = "security")]
9618fn protect_user_reader_datagram<'a>(
9619    rt: &DcpsRuntime,
9620    bytes: &'a [u8],
9621) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9622    let Some(gate) = rt.config.security.as_ref() else {
9623        return Some(alloc::borrow::Cow::Borrowed(bytes));
9624    };
9625    let metadata = gate.metadata_protection().unwrap_or(ProtectionLevel::None);
9626    let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
9627    // rtps != None → SRTPS wraps the whole message incl. ACKNACK; metadata ==
9628    // None → no submessage protection configured. secure_outbound_bytes
9629    // (transform_outbound) covers both cases correctly.
9630    if metadata == ProtectionLevel::None || rtps != ProtectionLevel::None || bytes.len() < 20 {
9631        return secure_outbound_bytes(rt, bytes);
9632    }
9633    let subs = walk_submessages(bytes);
9634    let mut out = Vec::with_capacity(bytes.len() + 64);
9635    out.extend_from_slice(&bytes[..20]);
9636    for (id, start, total) in subs {
9637        let submsg = &bytes[start..start + total];
9638        if is_protected_reader_submessage(id) {
9639            let handle = reader_eid_in_submessage(submsg, id)
9640                .and_then(|r| local_endpoint_crypto_handle(rt, r, false));
9641            match handle {
9642                Some(h) => match gate.encode_data_datawriter_by_handle(h, submsg) {
9643                    Ok(s) => out.extend_from_slice(&s),
9644                    Err(_) => return None,
9645                },
9646                // No per-endpoint reader key yet (the endpoint matches only after
9647                // secure SEDP) → pass through plaintext; the reader tick re-sends
9648                // the ACKNACK once the key is installed.
9649                None => out.extend_from_slice(submsg),
9650            }
9651        } else {
9652            // Framing submessage (INFO_DST/INFO_TS/...) — unchanged.
9653            out.extend_from_slice(submsg);
9654        }
9655    }
9656    Some(alloc::borrow::Cow::Owned(out))
9657}
9658
9659#[cfg(not(feature = "security"))]
9660fn protect_user_reader_datagram<'a>(
9661    rt: &DcpsRuntime,
9662    bytes: &'a [u8],
9663) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9664    secure_outbound_bytes(rt, bytes)
9665}
9666
9667/// `true` if `liveliness_protection != NONE` is configured — then WLP runs
9668/// over the secure entity + participant-key protection (§8.4.2.4).
9669#[cfg(feature = "security")]
9670fn wlp_liveliness_protected(rt: &DcpsRuntime) -> bool {
9671    rt.config.security.as_ref().is_some_and(|gate| {
9672        gate.liveliness_protection()
9673            .unwrap_or(ProtectionLevel::None)
9674            != ProtectionLevel::None
9675    })
9676}
9677
9678#[cfg(not(feature = "security"))]
9679fn wlp_liveliness_protected(_rt: &DcpsRuntime) -> bool {
9680    false
9681}
9682
9683/// Protects a WLP outbound datagram (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER
9684/// DATA) under `liveliness_protection != NONE` with the **participant data key**
9685/// (§8.4.2.4 / §7.4.7.1 Tab.7). WLP is participant-level (no per-endpoint key)
9686/// — analogous to the participant-key fallback in `protect_sedp_outbound`. If
9687/// `rtps_protection` already covers the message as SRTPS (or liveliness=NONE),
9688/// the function delegates to `secure_outbound_bytes`.
9689#[cfg(feature = "security")]
9690fn protect_wlp_outbound<'a>(
9691    rt: &DcpsRuntime,
9692    bytes: &'a [u8],
9693) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9694    let Some(gate) = rt.config.security.as_ref() else {
9695        return Some(alloc::borrow::Cow::Borrowed(bytes));
9696    };
9697    let live = gate
9698        .liveliness_protection()
9699        .unwrap_or(ProtectionLevel::None);
9700    let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
9701    // liveliness=NONE: no inner SEC layer -> secure_outbound_bytes covers
9702    // rtps_protection (SRTPS) resp. passthrough. PREVIOUSLY this branch
9703    // also delegated with rtps!=None and thus left out the liveliness SEC -> cyclone
9704    // saw the WLP DATA "clear submsg from protected src" -> no liveliness.
9705    if live == ProtectionLevel::None || bytes.len() < 20 {
9706        return secure_outbound_bytes(rt, bytes);
9707    }
9708    let subs = walk_submessages(bytes);
9709    let mut out = Vec::with_capacity(bytes.len() + 64);
9710    out.extend_from_slice(&bytes[..20]);
9711    for (id, start, total) in subs {
9712        let submsg = &bytes[start..start + total];
9713        if id == SMID_DATA {
9714            // Protect the secure-WLP DATA with the per-endpoint key of the secure-WLP writer
9715            // (ff0200c2) — the same key ZeroDDS sends the peer via the
9716            // datawriter_crypto_token (prepare_endpoint_crypto_tokens
9717            // liveliness block). encode_data_datawriter_local took the participant
9718            // key, which cyclone does NOT associate with ff0200c2 -> undecodable ->
9719            // no liveliness -> peer approval of the user endpoints hangs.
9720            let sec = writer_eid_in_submessage(submsg, id)
9721                .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
9722                .and_then(|h| gate.encode_data_datawriter_by_handle(h, submsg).ok());
9723            match sec {
9724                Some(s) => out.extend_from_slice(&s),
9725                None => return None,
9726            }
9727        } else {
9728            out.extend_from_slice(submsg);
9729        }
9730    }
9731    // Under additional rtps_protection, message-level SRTPS MUST go around the
9732    // liveliness-SEC-wrapped WLP (both layers, like cyclone<->cyclone) —
9733    // otherwise cyclone would see only the SRTPS shell OR (with the old logic) the
9734    // clear DATA. First inner SEC (above), then SRTPS (here).
9735    if rtps != ProtectionLevel::None {
9736        return gate
9737            .transform_outbound(&out)
9738            .ok()
9739            .map(alloc::borrow::Cow::Owned);
9740    }
9741    Some(alloc::borrow::Cow::Owned(out))
9742}
9743
9744#[cfg(not(feature = "security"))]
9745fn protect_wlp_outbound<'a>(
9746    rt: &DcpsRuntime,
9747    bytes: &'a [u8],
9748) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9749    secure_outbound_bytes(rt, bytes)
9750}
9751
9752/// Wire demux for the security builtin topics. Routes an
9753/// incoming RTPS submessage sequence to the `SecurityBuiltinStack`,
9754/// if the stack is active. No-op if the datagram does not address a security
9755/// builtin reader or the plugin is not enabled.
9756///
9757/// Called by the metatraffic receive path — stateless +
9758/// VolatileSecure run over the SPDP unicast locators (PID 0x0032),
9759/// not over `user_unicast`.
9760fn dispatch_security_builtin_datagram(
9761    rt: &Arc<DcpsRuntime>,
9762    bytes: &[u8],
9763    now: Duration,
9764) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
9765    // `mut` only needed on the security path (the handshake reply is appended
9766    // there); without the feature the list stays empty.
9767    #[cfg(feature = "security")]
9768    let mut outbound = Vec::new();
9769    #[cfg(not(feature = "security"))]
9770    let outbound = Vec::new();
9771    let Some(stack) = rt.security_builtin_snapshot() else {
9772        return outbound;
9773    };
9774    // Cross-vendor VolatileSecure: cyclone protects the volatile DATA as a
9775    // SEC_PREFIX/SEC_BODY/SEC_POSTFIX sequence. Before the submessage parse,
9776    // transform the sequence with the sender's Kx key (GuidPrefix = RTPS header bytes[8..20])
9777    // back to the original DATA submessage. `None` = no SEC_*
9778    // sequence (normal path) resp. crypto error.
9779    #[cfg(feature = "security")]
9780    let unprotected: Option<Vec<u8>> = if bytes.len() >= 20 {
9781        let mut pk = [0u8; 12];
9782        pk.copy_from_slice(&bytes[8..20]);
9783        unprotect_volatile_datagram(rt, bytes, &pk)
9784    } else {
9785        None
9786    };
9787    #[cfg(feature = "security")]
9788    let bytes: &[u8] = unprotected.as_deref().unwrap_or(bytes);
9789    let Ok(parsed) = decode_datagram(bytes) else {
9790        return outbound;
9791    };
9792    // sourceGuidPrefix of the datagram (DDSI-RTPS §8.3.4) — reader demux key for
9793    // the volatile builtin readers. Used in both feature configs.
9794    let remote_prefix = parsed.header.guid_prefix;
9795    let Ok(mut s) = stack.lock() else {
9796        return outbound;
9797    };
9798    for sub in parsed.submessages {
9799        match sub {
9800            ParsedSubmessage::Data(d) => {
9801                if d.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
9802                    || d.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
9803                {
9804                    // FU2 Gap 5: decode the stateless auth and — with
9805                    // an active auth plugin — drive the handshake.
9806                    // `on_stateless_message` returns the next token
9807                    // message (reply/final), which we send back to the peer.
9808                    // Decode errors are swallowed (stateless
9809                    // has no resend path, Spec §10.3.4.1). The
9810                    // completion `(remote_identity, secret)` is stored in the stack
9811                    // (peer_secret) — the gate registration +
9812                    // crypto-token exchange follows in Gap 6.
9813                    if let Ok(msg) = s.stateless_reader.handle_data(&d) {
9814                        #[cfg(feature = "security")]
9815                        s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
9816                        #[cfg(feature = "security")]
9817                        if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, &msg) {
9818                            outbound.extend(out);
9819                            // FU2 S1.4: handshake done → register Kx +
9820                            // send the Kx-encrypted data token to the peer over Volatile-
9821                            // Secure. (the pki lock is free here:
9822                            // on_stateless_message released it.)
9823                            if let Some((remote_identity, secret)) = completed {
9824                                if let Some(token_msg) =
9825                                    prepare_crypto_token(rt, remote_prefix, remote_identity, secret)
9826                                {
9827                                    outbound.extend(protect_volatile_outbound(
9828                                        rt,
9829                                        remote_prefix,
9830                                        s.volatile_writer
9831                                            .write_with_heartbeat(&token_msg, now)
9832                                            .unwrap_or_default(),
9833                                    ));
9834                                }
9835                                // Step 6b: per-endpoint datawriter/datareader
9836                                // tokens (per-token dedup #29: the builtins go out
9837                                // here exactly once + are marked).
9838                                let already = rt
9839                                    .endpoint_tokens_sent
9840                                    .read()
9841                                    .map(|set| set.clone())
9842                                    .unwrap_or_default();
9843                                let pending = pending_endpoint_tokens(
9844                                    prepare_endpoint_crypto_tokens(rt, remote_prefix),
9845                                    &already,
9846                                );
9847                                for ep_msg in pending {
9848                                    let key = endpoint_token_key(&ep_msg);
9849                                    outbound.extend(protect_volatile_outbound(
9850                                        rt,
9851                                        remote_prefix,
9852                                        s.volatile_writer
9853                                            .write_with_heartbeat(&ep_msg, now)
9854                                            .unwrap_or_default(),
9855                                    ));
9856                                    if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
9857                                        set.insert(key);
9858                                    }
9859                                }
9860                            }
9861                        }
9862                        #[cfg(not(feature = "security"))]
9863                        let _ = msg;
9864                    }
9865                } else if d.reader_id
9866                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
9867                {
9868                    // FU2 S1.4: VolatileSecure carries the crypto-token
9869                    // exchange. Kx-decrypt the received PARTICIPANT_CRYPTO_TOKENS
9870                    // message + install the data key in the gate.
9871                    if let Ok(_msgs) = s.volatile_reader.handle_data(remote_prefix, &d) {
9872                        #[cfg(feature = "security")]
9873                        for m in &_msgs {
9874                            install_crypto_token(rt, remote_prefix, m);
9875                        }
9876                        // Step 6b: now (peer ready) send our per-endpoint
9877                        // tokens back. Per-token dedup (#29): builtins
9878                        // go out early here, the later-matching user-
9879                        // endpoint tokens are caught up by the tick path (no per-peer
9880                        // guard that blocks them forever).
9881                        #[cfg(feature = "security")]
9882                        {
9883                            let already = rt
9884                                .endpoint_tokens_sent
9885                                .read()
9886                                .map(|set| set.clone())
9887                                .unwrap_or_default();
9888                            let pending = pending_endpoint_tokens(
9889                                prepare_endpoint_crypto_tokens(rt, remote_prefix),
9890                                &already,
9891                            );
9892                            for ep_msg in pending {
9893                                let key = endpoint_token_key(&ep_msg);
9894                                outbound.extend(protect_volatile_outbound(
9895                                    rt,
9896                                    remote_prefix,
9897                                    s.volatile_writer
9898                                        .write_with_heartbeat(&ep_msg, now)
9899                                        .unwrap_or_default(),
9900                                ));
9901                                if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
9902                                    set.insert(key);
9903                                }
9904                            }
9905                        }
9906                        // The peer now has our participant crypto token (can
9907                        // decode our SRTPS/SEC SEDP): catch up the initially dropped
9908                        // SEDP burst once (OpenDDS convergence).
9909                        #[cfg(feature = "security")]
9910                        rt.re_announce_sedp_to_peer(remote_prefix);
9911                    }
9912                }
9913            }
9914            ParsedSubmessage::DataFrag(df) => {
9915                if df.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
9916                    || df.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
9917                {
9918                    // FU2 cross-vendor: cyclone/FastDDS RTPS-fragment the
9919                    // large HandshakeReply/Final (cert + permissions over
9920                    // MTU). Reassemble the fragments + drive them through the
9921                    // handshake driver like a stateless DATA.
9922                    if let Ok(msgs) = s.stateless_reader.handle_data_frag(&df) {
9923                        #[cfg(feature = "security")]
9924                        s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
9925                        #[cfg(feature = "security")]
9926                        for msg in &msgs {
9927                            if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, msg)
9928                            {
9929                                outbound.extend(out);
9930                                if let Some((remote_identity, secret)) = completed {
9931                                    if let Some(token_msg) = prepare_crypto_token(
9932                                        rt,
9933                                        remote_prefix,
9934                                        remote_identity,
9935                                        secret,
9936                                    ) {
9937                                        outbound.extend(protect_volatile_outbound(
9938                                            rt,
9939                                            remote_prefix,
9940                                            s.volatile_writer
9941                                                .write_with_heartbeat(&token_msg, now)
9942                                                .unwrap_or_default(),
9943                                        ));
9944                                    }
9945                                    let already = rt
9946                                        .endpoint_tokens_sent
9947                                        .read()
9948                                        .map(|set| set.clone())
9949                                        .unwrap_or_default();
9950                                    let pending = pending_endpoint_tokens(
9951                                        prepare_endpoint_crypto_tokens(rt, remote_prefix),
9952                                        &already,
9953                                    );
9954                                    for ep_msg in pending {
9955                                        let key = endpoint_token_key(&ep_msg);
9956                                        outbound.extend(protect_volatile_outbound(
9957                                            rt,
9958                                            remote_prefix,
9959                                            s.volatile_writer
9960                                                .write_with_heartbeat(&ep_msg, now)
9961                                                .unwrap_or_default(),
9962                                        ));
9963                                        if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
9964                                            set.insert(key);
9965                                        }
9966                                    }
9967                                }
9968                            }
9969                        }
9970                        #[cfg(not(feature = "security"))]
9971                        let _ = msgs;
9972                    }
9973                } else if df.reader_id
9974                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
9975                {
9976                    let _ = s.volatile_reader.handle_data_frag(remote_prefix, &df, now);
9977                }
9978            }
9979            ParsedSubmessage::Heartbeat(h) => {
9980                let to_volatile_reader = h.reader_id
9981                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
9982                    || (h.reader_id == EntityId::UNKNOWN
9983                        && h.writer_id
9984                            == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER);
9985                if to_volatile_reader {
9986                    s.volatile_reader.handle_heartbeat(remote_prefix, &h, now);
9987                }
9988            }
9989            ParsedSubmessage::Gap(g) => {
9990                if g.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
9991                    let _ = s.volatile_reader.handle_gap(remote_prefix, &g);
9992                }
9993            }
9994            ParsedSubmessage::AckNack(ack) => {
9995                if ack.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
9996                    let base = ack.reader_sn_state.bitmap_base;
9997                    let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
9998                    let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
9999                    s.volatile_writer.handle_acknack(src, base, requested);
10000                }
10001            }
10002            ParsedSubmessage::NackFrag(nf) => {
10003                if nf.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
10004                    let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
10005                    s.volatile_writer.handle_nackfrag(src, &nf);
10006                }
10007            }
10008            _ => {}
10009        }
10010    }
10011    outbound
10012}
10013
10014/// Dispatches a datagram addressed to the TypeLookup service endpoints
10015/// (XTypes 1.3 §7.6.3.3.4). Handles incoming
10016/// requests (to `TL_SVC_REQ_READER`), generates replies and sends
10017/// them back to the source locator; handles incoming replies
10018/// (to `TL_SVC_REPLY_READER`), correlates with the client.
10019///
10020/// Returns `true` if the datagram was accepted by the TypeLookup path
10021/// — the caller can then skip the user-reader path.
10022fn dispatch_type_lookup_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], source: &Locator) -> bool {
10023    use zerodds_cdr::{BufferReader, Endianness};
10024    use zerodds_rtps::inline_qos::{SampleIdentityBytes, find_related_sample_identity};
10025    use zerodds_types::type_lookup::{
10026        GetTypeDependenciesReply, GetTypeDependenciesRequest, GetTypesReply, GetTypesRequest,
10027    };
10028
10029    let Ok(parsed) = decode_datagram(bytes) else {
10030        return false;
10031    };
10032    // DDS-RPC §7.8.2: the request sample identity = (request writer GUID,
10033    // request SN). The server carries it as PID_RELATED_SAMPLE_IDENTITY in the
10034    // reply inline QoS, so a client (also cross-vendor) can correlate
10035    // without relying on the echoed writer_sn.
10036    let src_prefix = parsed.header.guid_prefix;
10037
10038    let mut accepted = false;
10039
10040    for sub in &parsed.submessages {
10041        let ParsedSubmessage::Data(d) = sub else {
10042            continue;
10043        };
10044        let payload: &[u8] = &d.serialized_payload;
10045        if payload.is_empty() {
10046            continue;
10047        }
10048        // Skip CDR-Encapsulation header (4 bytes) if present.
10049        let body: &[u8] = if payload.len() >= 4 && (payload[0] == 0x00 && payload[1] == 0x01) {
10050            &payload[4..]
10051        } else {
10052            payload
10053        };
10054
10055        // Inbound Request → Server.
10056        if d.reader_id == EntityId::TL_SVC_REQ_READER {
10057            accepted = true;
10058            // Request sample identity = (request writer GUID, request SN) — mirrored
10059            // as related_sample_identity into the reply inline QoS.
10060            let (sn_hi, sn_lo) = d.writer_sn.split();
10061            let req_sn = ((u64::from(sn_hi as u32)) << 32) | u64::from(sn_lo);
10062            let related =
10063                SampleIdentityBytes::new(Guid::new(src_prefix, d.writer_id).to_bytes(), req_sn);
10064            // Try GetTypes-Request first; fall back to
10065            // GetTypeDependenciesRequest if that fails.
10066            let mut r = BufferReader::new(body, Endianness::Little);
10067            if let Ok(req) = GetTypesRequest::decode_from(&mut r) {
10068                let reply = match rt.type_lookup_server.lock() {
10069                    Ok(g) => g.handle_get_types(&req),
10070                    Err(_) => continue,
10071                };
10072                let _ = send_type_lookup_reply(
10073                    rt,
10074                    source,
10075                    TypeLookupReplyPayload::Types(reply),
10076                    related,
10077                );
10078                continue;
10079            }
10080            let mut r = BufferReader::new(body, Endianness::Little);
10081            if let Ok(req) = GetTypeDependenciesRequest::decode_from(&mut r) {
10082                let reply = match rt.type_lookup_server.lock() {
10083                    Ok(g) => g.handle_get_type_dependencies(&req),
10084                    Err(_) => continue,
10085                };
10086                let _ = send_type_lookup_reply(
10087                    rt,
10088                    source,
10089                    TypeLookupReplyPayload::Dependencies(reply),
10090                    related,
10091                );
10092                continue;
10093            }
10094        }
10095
10096        // Inbound Reply → Client.
10097        if d.reader_id == EntityId::TL_SVC_REPLY_READER {
10098            accepted = true;
10099            // Correlation prefers PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2,
10100            // cross-vendor compatible); fallback to the echoed writer_sn for
10101            // peers/legacy replies without inline QoS.
10102            let request_id = d
10103                .inline_qos
10104                .as_ref()
10105                .and_then(|pl| find_related_sample_identity(pl, true).ok().flatten())
10106                .map(|sid| zerodds_discovery::type_lookup::RequestId::from_u64(sid.sequence_number))
10107                .unwrap_or_else(|| {
10108                    let (sn_high, sn_low) = d.writer_sn.split();
10109                    let sn_u64 = ((u64::from(sn_high as u32)) << 32) | u64::from(sn_low);
10110                    zerodds_discovery::type_lookup::RequestId::from_u64(sn_u64)
10111                });
10112            let mut r = BufferReader::new(body, Endianness::Little);
10113            if let Ok(reply) = GetTypesReply::decode_from(&mut r) {
10114                if let Ok(mut client) = rt.type_lookup_client.lock() {
10115                    client.handle_reply(request_id, TypeLookupReply::Types(reply));
10116                }
10117                continue;
10118            }
10119            // M-5: the getTypeDependencies reply carries a different element type
10120            // (TypeIdentifierWithSize list) — its own decode branch, otherwise the
10121            // dependencies callback never fires.
10122            let mut r = BufferReader::new(body, Endianness::Little);
10123            if let Ok(reply) = GetTypeDependenciesReply::decode_from(&mut r) {
10124                if let Ok(mut client) = rt.type_lookup_client.lock() {
10125                    client.handle_reply(request_id, TypeLookupReply::Dependencies(reply));
10126                }
10127                continue;
10128            }
10129        }
10130    }
10131
10132    accepted
10133}
10134
10135/// Reply payload variants that the TypeLookup server can emit.
10136enum TypeLookupReplyPayload {
10137    Types(zerodds_types::type_lookup::GetTypesReply),
10138    Dependencies(zerodds_types::type_lookup::GetTypeDependenciesReply),
10139}
10140
10141/// Sends a TypeLookup reply to a peer locator as a
10142/// DATA datagram on the TL_SVC_REPLY_WRITER → peer's
10143/// TL_SVC_REPLY_READER. The sequence number echoes the request sequence
10144/// for correlation purposes (see XTypes §7.6.3.3.3 sample identity).
10145fn send_type_lookup_reply(
10146    rt: &Arc<DcpsRuntime>,
10147    target: &Locator,
10148    reply: TypeLookupReplyPayload,
10149    related: zerodds_rtps::inline_qos::SampleIdentityBytes,
10150) -> Result<()> {
10151    use alloc::sync::Arc as AllocArc;
10152    use core::sync::atomic::Ordering;
10153    use zerodds_cdr::{BufferWriter, Endianness};
10154    use zerodds_rtps::datagram::encode_data_datagram;
10155    use zerodds_rtps::header::RtpsHeader;
10156    use zerodds_rtps::submessages::DataSubmessage;
10157    use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber, VendorId};
10158
10159    // CDR-encode reply (PL_CDR_LE-Encapsulation).
10160    let mut w = BufferWriter::new(Endianness::Little);
10161    match reply {
10162        TypeLookupReplyPayload::Types(r) => {
10163            r.encode_into(&mut w)
10164                .map_err(|_| DdsError::PreconditionNotMet {
10165                    reason: "type_lookup reply encode failed",
10166                })?;
10167        }
10168        TypeLookupReplyPayload::Dependencies(r) => {
10169            r.encode_into(&mut w)
10170                .map_err(|_| DdsError::PreconditionNotMet {
10171                    reason: "type_lookup deps reply encode failed",
10172                })?;
10173        }
10174    }
10175    let body = w.into_bytes();
10176    let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
10177    payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
10178    payload.extend_from_slice(&body);
10179
10180    let header = RtpsHeader {
10181        protocol_version: ProtocolVersion::CURRENT,
10182        vendor_id: VendorId::ZERODDS,
10183        guid_prefix: rt.guid_prefix,
10184    };
10185    // Own monotonically increasing reply-writer SN (starting at 1) instead of a
10186    // request-SN echo — a reliable cross-vendor reply reader would otherwise see SN jumps.
10187    let reply_sn = rt
10188        .tl_reply_sn
10189        .fetch_add(1, Ordering::Relaxed)
10190        .wrapping_add(1);
10191    let writer_sn =
10192        SequenceNumber::from_high_low((reply_sn >> 32) as i32, (reply_sn & 0xFFFF_FFFF) as u32);
10193    let data = DataSubmessage {
10194        extra_flags: 0,
10195        reader_id: EntityId::TL_SVC_REPLY_READER,
10196        writer_id: EntityId::TL_SVC_REPLY_WRITER,
10197        writer_sn,
10198        // DDS-RPC §7.8.2: related_sample_identity couples the reply to the
10199        // request (cross-vendor correlation without a writer_sn echo).
10200        inline_qos: Some(zerodds_rtps::inline_qos::reply_inline_qos(related, true)),
10201        key_flag: false,
10202        non_standard_flag: false,
10203        serialized_payload: AllocArc::from(payload.into_boxed_slice()),
10204    };
10205    let datagram =
10206        encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
10207            reason: "type_lookup reply datagram encode failed",
10208        })?;
10209
10210    if is_routable_user_locator(target) {
10211        let _ = rt.user_unicast.send(target, &datagram);
10212    }
10213    Ok(())
10214}
10215
10216/// Sends a discovery datagram to all target locators. UDP-only
10217/// (TCPv4/SHM/UDS are not carried in discovery); non-UDP
10218/// locators are silently ignored.
10219fn send_discovery_datagram(rt: &Arc<DcpsRuntime>, targets: &[Locator], bytes: &[u8]) {
10220    let Some(secured) = secure_outbound_bytes(rt, bytes) else {
10221        return;
10222    };
10223    for t in targets {
10224        if !is_routable_user_locator(t) {
10225            continue;
10226        }
10227        // Send unicast metatraffic (SEDP responses, VolatileSecure, stateless auth)
10228        // from the **metatraffic recv socket** (`spdp_unicast`, = announced
10229        // metatraffic_unicast_locator), NOT from the ephemeral `spdp_mc_tx`.
10230        // Otherwise the peer sees a foreign source port and sends its
10231        // responses (e.g. cyclone's VolatileSecure ACKNACK to the source locator)
10232        // to a port ZeroDDS does not listen on → reliable resends stay
10233        // out (cross-vendor). `spdp_mc_tx` stays only for SPDP multicast.
10234        let _ = rt.spdp_unicast.send(t, &secured);
10235    }
10236}
10237
10238/// Default user-multicast locator for a DomainParticipant.
10239/// Not used in live mode 1 yet; SPDP-announced in B2.
10240#[must_use]
10241pub fn user_multicast_endpoint(domain_id: i32) -> SocketAddr {
10242    // Spec §9.6.1.4.1: user-multicast-port = PB + DG * d + d2
10243    //   = 7400 + 250 * d + 1
10244    let port = 7400u16.saturating_add(250u16.saturating_mul(domain_id as u16).saturating_add(1));
10245    SocketAddr::from((Ipv4Addr::from([239, 255, 0, 1]), port))
10246}
10247
10248#[cfg(test)]
10249#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
10250mod tests {
10251    use super::*;
10252
10253    /// FU1 diagnosis: inject a REAL FastDDS-3.6 SPDP datagram (domain 205,
10254    /// codepit capture 2026-05-29) directly into handle_spdp_datagram
10255    /// — does the runtime register FastDDS as a peer? Separates the
10256    /// receive problem (socket) from the handle problem (parse/insert/filter).
10257    #[test]
10258    fn handle_spdp_registers_real_fastdds_participant() {
10259        fn hx(s: &str) -> Vec<u8> {
10260            (0..s.len())
10261                .step_by(2)
10262                .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
10263                .collect()
10264        }
10265        const FASTDDS_SPDP: &str = "525450530203010f010fa72bbbebc90100000000090108006850196a57e882b41505b80100001000000100c7000100c2000000000100000000030000150004000203000016000400010f000000800400030601000f000400cd00000050001000010fa72bbbebc90100000000000001c107800400010000000380280021000000653830653630353335646339343432636231306537323239313038653135666600000000320018000100000024e50000000000000000000000000000c0a8b273310018000100000025e50000000000000000000000000000c0a8b273020008001400000000000000580004003ffc0f006200140010000000525450535061727469636970616e74005900cc0004000000110000005041525449434950414e545f54595045000000000700000053494d504c4500001b000000666173746464732e706879736963616c5f646174612e686f73740000210000006538306536303533356463393434326362313065373232393130386531356666000000001b000000666173746464732e706879736963616c5f646174612e75736572000005000000726f6f74000000001e000000666173746464732e706879736963616c5f646174612e70726f636573730000000600000036303334370000000100000080013800010000001ae50000000000000000000000000000efff00016850196a72ca8cb4020000000000000030040000000000000000000000000000";
10266        let bytes = hx(FASTDDS_SPDP);
10267        let prefix = GuidPrefix::from_bytes([0x99; 12]);
10268        let rt =
10269            Arc::new(DcpsRuntime::start(205, prefix, RuntimeConfig::default()).expect("rt start"));
10270        assert_eq!(rt.discovered_participants().len(), 0, "fresh: no peers");
10271        handle_spdp_datagram_for_test(&rt, &bytes);
10272        let n = rt.discovered_participants().len();
10273        assert_eq!(
10274            n, 1,
10275            "FastDDS must be registered after handle_spdp_datagram (got {n})"
10276        );
10277    }
10278
10279    #[test]
10280    fn select_user_transport_tcpv4_yields_tcpv4_locator() {
10281        let prefix = GuidPrefix::from_bytes([1u8; 12]);
10282        let (t, accept) =
10283            select_user_transport(UserTransportKind::TcpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
10284                .expect("TcpV4 transport");
10285        assert_eq!(t.local_locator().kind, LocatorKind::Tcpv4);
10286        assert!(accept.is_some(), "TCP needs an accept handle");
10287    }
10288
10289    #[test]
10290    fn select_user_transport_udpv4_default_kind() {
10291        let prefix = GuidPrefix::from_bytes([2u8; 12]);
10292        let (t, accept) =
10293            select_user_transport(UserTransportKind::UdpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
10294                .expect("UdpV4 transport");
10295        assert_eq!(t.local_locator().kind, LocatorKind::UdpV4);
10296        assert!(accept.is_none(), "UDP needs no accept handle");
10297    }
10298
10299    #[cfg(feature = "same-host-uds")]
10300    #[test]
10301    fn select_user_transport_uds_yields_uds_locator() {
10302        let prefix = GuidPrefix::from_bytes([3u8; 12]);
10303        let (t, accept) =
10304            select_user_transport(UserTransportKind::Uds, prefix, 0, Ipv4Addr::UNSPECIFIED)
10305                .expect("Uds transport");
10306        assert_eq!(t.local_locator().kind, LocatorKind::Uds);
10307        assert!(accept.is_none(), "UDS needs no accept handle");
10308    }
10309
10310    #[test]
10311    fn strip_user_encap_xcdr2_le() {
10312        let payload = [0x00, 0x07, 0x00, 0x00, 1, 2, 3];
10313        assert_eq!(strip_user_encap(&payload), Some(alloc::vec![1, 2, 3]));
10314    }
10315
10316    #[test]
10317    fn strip_user_encap_xcdr1_le() {
10318        // Cyclone default for simple types.
10319        let payload = [0x00, 0x01, 0x00, 0x00, 0xAA];
10320        assert_eq!(strip_user_encap(&payload), Some(alloc::vec![0xAA]));
10321    }
10322
10323    #[test]
10324    fn strip_user_encap_rejects_unknown_scheme() {
10325        let payload = [0xFF, 0xFF, 0x00, 0x00, 1];
10326        assert_eq!(strip_user_encap(&payload), None);
10327    }
10328
10329    #[test]
10330    fn strip_user_encap_rejects_short() {
10331        assert_eq!(strip_user_encap(&[0x00, 0x07]), None);
10332    }
10333
10334    #[test]
10335    fn user_payload_encap_is_cdr_le() {
10336        // CDR_LE (PLAIN_CDR / XCDR1, Little-Endian) — ehrliche
10337        // Declaration of the body encoding generated by codegen.
10338        assert_eq!(USER_PAYLOAD_ENCAP, [0x00, 0x01, 0x00, 0x00]);
10339    }
10340
10341    #[test]
10342    fn data_repr_offer_str_uses_spec_ids() {
10343        use zerodds_rtps::publication_data::data_representation as dr;
10344        // XCDR1 -> Spec-Id 0 (NICHT 1 = XML); XCDR2 -> 2.
10345        assert_eq!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XCDR]));
10346        assert_eq!(parse_data_repr_offer_str("XCDR2"), Some(vec![dr::XCDR2]));
10347        assert_eq!(parse_data_repr_offer_str("xcdr2"), Some(vec![dr::XCDR2]));
10348        assert_eq!(
10349            parse_data_repr_offer_str("XCDR2,XCDR1"),
10350            Some(vec![dr::XCDR2, dr::XCDR])
10351        );
10352        assert_eq!(parse_data_repr_offer_str("bogus"), None);
10353        assert_eq!(parse_data_repr_offer_str(""), None);
10354        // XCDR1 must NOT map to the XML id (1).
10355        assert_ne!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XML]));
10356    }
10357
10358    #[test]
10359    fn user_payload_encap_maps_repr_and_extensibility() {
10360        use zerodds_rtps::publication_data::data_representation as dr;
10361        use zerodds_types::qos::ExtensibilityForRepr as Ext;
10362        // DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 Encapsulation-IDs
10363        // (2-byte repr-id BE + 2-byte options=0), little-endian variant:
10364        //   XCDR1 final/appendable -> CDR_LE        0x0001
10365        //   XCDR1 mutable          -> PL_CDR_LE      0x0003
10366        //   XCDR2 final            -> PLAIN_CDR2_LE  0x0007
10367        //   XCDR2 appendable       -> D_CDR2_LE      0x0009
10368        //   XCDR2 mutable          -> PL_CDR2_LE     0x000b
10369        assert_eq!(
10370            user_payload_encap(dr::XCDR, Ext::Final),
10371            [0x00, 0x01, 0x00, 0x00]
10372        );
10373        assert_eq!(
10374            user_payload_encap(dr::XCDR, Ext::Appendable),
10375            [0x00, 0x01, 0x00, 0x00]
10376        );
10377        assert_eq!(
10378            user_payload_encap(dr::XCDR, Ext::Mutable),
10379            [0x00, 0x03, 0x00, 0x00]
10380        );
10381        assert_eq!(
10382            user_payload_encap(dr::XCDR2, Ext::Final),
10383            [0x00, 0x07, 0x00, 0x00]
10384        );
10385        assert_eq!(
10386            user_payload_encap(dr::XCDR2, Ext::Appendable),
10387            [0x00, 0x09, 0x00, 0x00]
10388        );
10389        assert_eq!(
10390            user_payload_encap(dr::XCDR2, Ext::Mutable),
10391            [0x00, 0x0b, 0x00, 0x00]
10392        );
10393        // The default const is exactly the (XCDR1, Final) case.
10394        assert_eq!(user_payload_encap(dr::XCDR, Ext::Final), USER_PAYLOAD_ENCAP);
10395        // Unknown/XML repr falls back safely to CDR_LE.
10396        assert_eq!(
10397            user_payload_encap(dr::XML, Ext::Final),
10398            [0x00, 0x01, 0x00, 0x00]
10399        );
10400    }
10401
10402    #[test]
10403    fn observability_sink_records_writer_and_reader_creation() {
10404        // VecSink injizieren, Writer + Reader erzeugen,
10405        // check that both events arrive.
10406        use std::sync::Arc as StdArc;
10407        use zerodds_foundation::observability::{Component, Level, VecSink};
10408
10409        let sink = StdArc::new(VecSink::new());
10410        let cfg = RuntimeConfig {
10411            observability: sink.clone(),
10412            ..RuntimeConfig::default()
10413        };
10414        let rt =
10415            DcpsRuntime::start(7, GuidPrefix::from_bytes([0xAA; 12]), cfg).expect("start runtime");
10416        let _ = rt.register_user_writer(UserWriterConfig {
10417            topic_name: "ObsTopic".into(),
10418            type_name: "ObsType".into(),
10419            reliable: true,
10420            durability: zerodds_qos::DurabilityKind::Volatile,
10421            deadline: zerodds_qos::DeadlineQosPolicy::default(),
10422            lifespan: zerodds_qos::LifespanQosPolicy::default(),
10423            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10424            ownership: zerodds_qos::OwnershipKind::Shared,
10425            ownership_strength: 0,
10426            partition: alloc::vec![],
10427            user_data: alloc::vec![],
10428            topic_data: alloc::vec![],
10429            group_data: alloc::vec![],
10430            type_identifier: zerodds_types::TypeIdentifier::None,
10431            data_representation_offer: None,
10432        });
10433        let _ = rt.register_user_reader(UserReaderConfig {
10434            topic_name: "ObsTopic".into(),
10435            type_name: "ObsType".into(),
10436            reliable: true,
10437            durability: zerodds_qos::DurabilityKind::Volatile,
10438            deadline: zerodds_qos::DeadlineQosPolicy::default(),
10439            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10440            ownership: zerodds_qos::OwnershipKind::Shared,
10441            partition: alloc::vec![],
10442            user_data: alloc::vec![],
10443            topic_data: alloc::vec![],
10444            group_data: alloc::vec![],
10445            type_identifier: zerodds_types::TypeIdentifier::None,
10446            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10447            data_representation_offer: None,
10448        });
10449        rt.shutdown();
10450
10451        let events = sink.snapshot();
10452        assert!(
10453            events.iter().any(|e| e.name == "user_writer.created"
10454                && e.component == Component::Dcps
10455                && e.level == Level::Info),
10456            "writer-event missing: got {:?}",
10457            events.iter().map(|e| e.name).collect::<Vec<_>>()
10458        );
10459        assert!(
10460            events
10461                .iter()
10462                .any(|e| e.name == "user_reader.created" && e.component == Component::Dcps),
10463            "reader-event missing"
10464        );
10465        // The topic attribute must hang on the writer.created event.
10466        let writer_event = events
10467            .iter()
10468            .find(|e| e.name == "user_writer.created")
10469            .expect("writer event");
10470        assert!(
10471            writer_event
10472                .attrs
10473                .iter()
10474                .any(|a| a.key == "topic" && a.value == "ObsTopic"),
10475            "topic attr missing"
10476        );
10477    }
10478
10479    #[test]
10480    fn user_endpoint_entity_kind_follows_keyedness() {
10481        // Regression (ROS-2 cross-vendor): the entityKind of a user
10482        // endpoint MUST follow the type keyedness (Spec §9.3.1.2). A
10483        // a keyless type yields NoKey (Writer 0x03 / Reader 0x04), a
10484        // keyed type WithKey (0x02 / 0x07). If this does not match the
10485        // peer, CycloneDDS/ROS 2 silently rejects the endpoint match
10486        // (DDS_INVALID_QOS_POLICY_ID, no log). create_datawriter/
10487        // create_datareader derive `is_keyed` from `DdsType::HAS_KEY`.
10488        use zerodds_rtps::wire_types::EntityKind;
10489        let rt = DcpsRuntime::start(
10490            11,
10491            GuidPrefix::from_bytes([0xBC; 12]),
10492            RuntimeConfig::default(),
10493        )
10494        .expect("start runtime");
10495        let mk_w = || UserWriterConfig {
10496            topic_name: "KindTopic".into(),
10497            type_name: "KindType".into(),
10498            reliable: true,
10499            durability: zerodds_qos::DurabilityKind::Volatile,
10500            deadline: zerodds_qos::DeadlineQosPolicy::default(),
10501            lifespan: zerodds_qos::LifespanQosPolicy::default(),
10502            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10503            ownership: zerodds_qos::OwnershipKind::Shared,
10504            ownership_strength: 0,
10505            partition: alloc::vec![],
10506            user_data: alloc::vec![],
10507            topic_data: alloc::vec![],
10508            group_data: alloc::vec![],
10509            type_identifier: zerodds_types::TypeIdentifier::None,
10510            data_representation_offer: None,
10511        };
10512        let mk_r = || UserReaderConfig {
10513            topic_name: "KindTopic".into(),
10514            type_name: "KindType".into(),
10515            reliable: true,
10516            durability: zerodds_qos::DurabilityKind::Volatile,
10517            deadline: zerodds_qos::DeadlineQosPolicy::default(),
10518            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10519            ownership: zerodds_qos::OwnershipKind::Shared,
10520            partition: alloc::vec![],
10521            user_data: alloc::vec![],
10522            topic_data: alloc::vec![],
10523            group_data: alloc::vec![],
10524            type_identifier: zerodds_types::TypeIdentifier::None,
10525            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10526            data_representation_offer: None,
10527        };
10528        // keyless (HAS_KEY=false) -> NoKey
10529        let w_nokey = rt.register_user_writer_kind(mk_w(), false).expect("writer");
10530        assert_eq!(w_nokey.entity_kind, EntityKind::UserWriterNoKey);
10531        let (r_nokey, _) = rt.register_user_reader_kind(mk_r(), false).expect("reader");
10532        assert_eq!(r_nokey.entity_kind, EntityKind::UserReaderNoKey);
10533        // keyed (HAS_KEY=true) -> WithKey
10534        let w_key = rt.register_user_writer_kind(mk_w(), true).expect("writer");
10535        assert_eq!(w_key.entity_kind, EntityKind::UserWriterWithKey);
10536        let (r_key, _) = rt.register_user_reader_kind(mk_r(), true).expect("reader");
10537        assert_eq!(r_key.entity_kind, EntityKind::UserReaderWithKey);
10538        rt.shutdown();
10539    }
10540
10541    #[test]
10542    fn incompatible_qos_match_emits_loud_warning() {
10543        // C2 "loud instead of silent": an incompatible QoS match is logged as a
10544        // warn event with topic + policy, not silently discarded.
10545        // Setup: writer Volatile + reader TransientLocal on the same
10546        // Topic (reader requests more durability than the writer offers)
10547        // → intra-runtime match fails with policy DURABILITY.
10548        use std::sync::Arc as StdArc;
10549        use zerodds_foundation::observability::{Component, Level, VecSink};
10550
10551        let sink = StdArc::new(VecSink::new());
10552        let cfg_a = RuntimeConfig {
10553            observability: sink.clone(),
10554            tick_period: Duration::from_millis(5),
10555            ..RuntimeConfig::default()
10556        };
10557        let cfg_b = RuntimeConfig {
10558            tick_period: Duration::from_millis(5),
10559            ..RuntimeConfig::default()
10560        };
10561        // Two same-process runtimes, same domain → inproc discovery.
10562        let rt = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCE; 12]), cfg_a)
10563            .expect("start runtime a");
10564        let rt_b = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCF; 12]), cfg_b)
10565            .expect("start runtime b");
10566        let _w = rt
10567            .register_user_writer(UserWriterConfig {
10568                topic_name: "QT".into(),
10569                type_name: "QType".into(),
10570                reliable: false,
10571                durability: zerodds_qos::DurabilityKind::Volatile,
10572                deadline: zerodds_qos::DeadlineQosPolicy::default(),
10573                lifespan: zerodds_qos::LifespanQosPolicy::default(),
10574                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10575                ownership: zerodds_qos::OwnershipKind::Shared,
10576                ownership_strength: 0,
10577                partition: alloc::vec![],
10578                user_data: alloc::vec![],
10579                topic_data: alloc::vec![],
10580                group_data: alloc::vec![],
10581                type_identifier: zerodds_types::TypeIdentifier::None,
10582                data_representation_offer: None,
10583            })
10584            .expect("writer");
10585        let _r = rt_b
10586            .register_user_reader(UserReaderConfig {
10587                topic_name: "QT".into(),
10588                type_name: "QType".into(),
10589                reliable: false,
10590                durability: zerodds_qos::DurabilityKind::TransientLocal,
10591                deadline: zerodds_qos::DeadlineQosPolicy::default(),
10592                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10593                ownership: zerodds_qos::OwnershipKind::Shared,
10594                partition: alloc::vec![],
10595                user_data: alloc::vec![],
10596                topic_data: alloc::vec![],
10597                group_data: alloc::vec![],
10598                type_identifier: zerodds_types::TypeIdentifier::None,
10599                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10600                data_representation_offer: None,
10601            })
10602            .expect("reader");
10603        // Await the match pass.
10604        let mut found = false;
10605        for _ in 0..40 {
10606            std::thread::sleep(Duration::from_millis(25));
10607            let events = sink.snapshot();
10608            if events.iter().any(|e| {
10609                (e.name == "qos.incompatible.offered" || e.name == "qos.incompatible.requested")
10610                    && e.component == Component::Dcps
10611                    && e.level == Level::Warn
10612                    && e.attrs.iter().any(|a| a.key == "topic" && a.value == "QT")
10613                    && e.attrs
10614                        .iter()
10615                        .any(|a| a.key == "policy" && a.value == "DURABILITY")
10616            }) {
10617                found = true;
10618                break;
10619            }
10620        }
10621        rt.shutdown();
10622        rt_b.shutdown();
10623        assert!(
10624            found,
10625            "expected a loud qos.incompatible warn event with policy DURABILITY"
10626        );
10627    }
10628
10629    #[test]
10630    fn spdp_unicast_port_follows_rtps_formula() {
10631        // Spec §9.6.1.4.1: PB + DG*domain + d1 + PG*pid = 7400+250*d+10+2*pid.
10632        assert_eq!(super::spdp_unicast_port(0, 0), 7410);
10633        assert_eq!(spdp_unicast_port(0, 1), 7412);
10634        assert_eq!(spdp_unicast_port(1, 0), 7660);
10635        assert_eq!(spdp_unicast_port(7, 0), 9160);
10636    }
10637
10638    #[test]
10639    fn announce_locator_pins_interface_over_route_probe() {
10640        // Interface pinning: a set interface takes precedence over the
10641        // route probe (multi-homed robustness, cf. Cyclone NetworkInterface).
10642        let udp = UdpTransport::bind_v4(Ipv4Addr::UNSPECIFIED, 0).expect("bind");
10643        let pin = Ipv4Addr::new(10, 11, 12, 13);
10644        let loc = super::announce_locator(&udp, pin);
10645        assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
10646        assert_eq!(loc.address[12..], [10, 11, 12, 13]);
10647        // Without a pin (UNSPECIFIED) → probe/fallback does NOT return the pin IP.
10648        let auto = super::announce_locator(&udp, Ipv4Addr::UNSPECIFIED);
10649        assert_ne!(auto.address[12..], [10, 11, 12, 13]);
10650    }
10651
10652    #[test]
10653    fn expand_initial_peer_ip_only_yields_well_known_port_range() {
10654        let m = super::INITIAL_PEER_MAX_PARTICIPANTS;
10655        let mut out = Vec::new();
10656        super::expand_initial_peer("127.0.0.1", 0, m, &mut out);
10657        assert_eq!(out.len(), m as usize);
10658        assert_eq!(out[0].port, 7410);
10659        assert_eq!(out[1].port, 7412);
10660        // Larger limit → more ports (C1 dense multi-robot scenarios).
10661        let mut wide = Vec::new();
10662        super::expand_initial_peer("127.0.0.1", 0, 30, &mut wide);
10663        assert_eq!(wide.len(), 30);
10664        assert_eq!(wide[29].port, 7410 + 2 * 29);
10665        // ip:port -> exactly one exact locator.
10666        let mut one = Vec::new();
10667        super::expand_initial_peer("10.0.0.5:7410", 0, m, &mut one);
10668        assert_eq!(one.len(), 1);
10669        assert_eq!(one[0].port, 7410);
10670        assert_eq!(one[0].address[12..], [10, 0, 0, 5]);
10671        // Garbage is ignored.
10672        let mut none = Vec::new();
10673        super::expand_initial_peer("not-an-ip", 0, m, &mut none);
10674        assert!(none.is_empty());
10675    }
10676
10677    #[test]
10678    #[ignore = "heavy multi-runtime scaling test (12 runtimes); explicit: cargo test -- --ignored"]
10679    #[allow(clippy::print_stdout)]
10680    fn multicast_free_discovery_scales_to_many_participants() {
10681        // C1 scaling: N participants, each with its own multicast group
10682        // (→ separate inproc buckets) AND multicast send off → pure
10683        // Unicast discovery via an explicit well-known-port peer list. Evidence,
10684        // that multicast-free all-to-all discovery works beyond 2 participants
10685        // (the "N²-multicast-storm" pain cluster, but unicast).
10686        // N via env (ZERODDS_SCALE_N, default 12) for >50 perf demos.
10687        let n: u32 = std::env::var("ZERODDS_SCALE_N")
10688            .ok()
10689            .and_then(|s| s.parse().ok())
10690            .unwrap_or(12)
10691            .clamp(2, 120);
10692        let domain = 21;
10693        let peers: Vec<Locator> = (0..n)
10694            .map(|pid| Locator::udp_v4([127, 0, 0, 1], super::spdp_unicast_port(domain, pid)))
10695            .collect();
10696        let mut rts = Vec::new();
10697        for i in 0..n {
10698            let cfg = RuntimeConfig {
10699                tick_period: Duration::from_millis(10),
10700                spdp_period: Duration::from_millis(40),
10701                // Own group per runtime → no inproc, no multicast.
10702                spdp_multicast_group: Ipv4Addr::new(239, 255, 21, (i + 1) as u8),
10703                spdp_multicast_send: false,
10704                initial_peers: peers.clone(),
10705                ..RuntimeConfig::default()
10706            };
10707            // Unique prefix even for n>47 (two-byte index).
10708            let mut pb = [0xD0u8; 12];
10709            pb[0] = (i & 0xff) as u8;
10710            pb[1] = (i >> 8) as u8;
10711            let prefix = GuidPrefix::from_bytes(pb);
10712            rts.push(DcpsRuntime::start(domain as i32, prefix, cfg).expect("start"));
10713        }
10714        // Wait until each participant has discovered all n-1 others.
10715        // Grosszuegiges Fenster: viele Runtimes konkurrieren um CPU; break-early.
10716        let started = std::time::Instant::now();
10717        let mut all_full = false;
10718        for _ in 0..1200 {
10719            std::thread::sleep(Duration::from_millis(25));
10720            if rts
10721                .iter()
10722                .all(|rt| rt.discovered_participants().len() >= (n as usize - 1))
10723            {
10724                all_full = true;
10725                break;
10726            }
10727        }
10728        let elapsed = started.elapsed();
10729        let min_seen = rts
10730            .iter()
10731            .map(|rt| rt.discovered_participants().len())
10732            .min()
10733            .unwrap_or(0);
10734        for rt in &rts {
10735            rt.shutdown();
10736        }
10737        println!(
10738            "C1-Scaling: {n} Participants multicast-frei all-to-all in {:.2}s (min={min_seen}/{})",
10739            elapsed.as_secs_f64(),
10740            n - 1
10741        );
10742        assert!(
10743            all_full,
10744            "multicast-free all-to-all discovery does not scale: min seen = {min_seen}/{}",
10745            n - 1
10746        );
10747    }
10748
10749    #[test]
10750    fn default_reassembly_cap_is_ros_realistic() {
10751        // C3 regression: the DCPS reassembly cap must be ROS-PointCloud2/
10752        // Image-capable (several MB), not the conservative
10753        // rtps 1-MiB default that silently discards large samples.
10754        let cfg = RuntimeConfig::default();
10755        assert!(
10756            cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024,
10757            "reassembly cap too small for ROS PointCloud2/Image: {}",
10758            cfg.max_reassembly_sample_bytes
10759        );
10760    }
10761
10762    #[test]
10763    fn ros_defaults_offers_xcdr1_for_ros_writers() {
10764        // C4: the ROS profile offers [XCDR1, XCDR2] (matches ROS/Cyclone
10765        // XCDR1 writer) + keeps the ROS-realistic reassembly cap.
10766        use zerodds_rtps::publication_data::data_representation as dr;
10767        let cfg = RuntimeConfig::ros_defaults();
10768        assert_eq!(
10769            cfg.data_representation_offer,
10770            alloc::vec![dr::XCDR, dr::XCDR2]
10771        );
10772        assert!(cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024);
10773    }
10774
10775    #[test]
10776    fn multicast_free_discovery_via_initial_peers() {
10777        // C1: two runtimes with DIFFERENT multicast groups lie
10778        // in different inproc buckets AND cannot see each other via
10779        // multicast — so they discover each other EXCLUSIVELY via
10780        // the unicast initial peers (well-known SPDP ports on 127.0.0.1).
10781        let domain = 7;
10782        let mut peers = Vec::new();
10783        super::expand_initial_peer(
10784            "127.0.0.1",
10785            domain as u32,
10786            super::INITIAL_PEER_MAX_PARTICIPANTS,
10787            &mut peers,
10788        );
10789        let mk = |group: [u8; 4]| RuntimeConfig {
10790            tick_period: Duration::from_millis(10),
10791            spdp_period: Duration::from_millis(40),
10792            spdp_multicast_group: Ipv4Addr::from(group),
10793            // Multicast send fully off → rigorous unicast-only proof.
10794            spdp_multicast_send: false,
10795            initial_peers: peers.clone(),
10796            ..RuntimeConfig::default()
10797        };
10798        let a = DcpsRuntime::start(
10799            domain,
10800            GuidPrefix::from_bytes([0xA1; 12]),
10801            mk([239, 255, 7, 1]),
10802        )
10803        .expect("a");
10804        let b = DcpsRuntime::start(
10805            domain,
10806            GuidPrefix::from_bytes([0xB2; 12]),
10807            mk([239, 255, 7, 2]),
10808        )
10809        .expect("b");
10810        let mut discovered = false;
10811        for _ in 0..160 {
10812            std::thread::sleep(Duration::from_millis(25));
10813            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
10814                discovered = true;
10815                break;
10816            }
10817        }
10818        a.shutdown();
10819        b.shutdown();
10820        assert!(
10821            discovered,
10822            "multicast-freie Discovery via Unicast-Initial-Peers fehlgeschlagen"
10823        );
10824    }
10825
10826    #[test]
10827    fn multi_robot_profile_is_multicast_free_and_wan_tolerant() {
10828        // C6: the named profile must be unicast-only with ROS reprs and a
10829        // WAN-tolerant lease, independent of any env.
10830        let cfg = RuntimeConfig::multi_robot();
10831        assert!(
10832            !cfg.spdp_multicast_send,
10833            "multi_robot() must disable multicast send"
10834        );
10835        assert_eq!(
10836            cfg.data_representation_offer,
10837            alloc::vec![
10838                zerodds_rtps::publication_data::data_representation::XCDR,
10839                zerodds_rtps::publication_data::data_representation::XCDR2
10840            ],
10841            "multi_robot() must offer the ROS XCDR1+XCDR2 reprs"
10842        );
10843        assert_eq!(
10844            cfg.participant_lease_duration,
10845            Duration::from_secs(300),
10846            "multi_robot() must use the WAN-tolerant 300s lease"
10847        );
10848    }
10849
10850    #[test]
10851    fn multi_robot_profile_discovers_via_unicast() {
10852        // C6 e2e: two runtimes started from the `multi_robot()` profile (whose
10853        // `spdp_multicast_send = false` is the field under test) sit in
10854        // different multicast buckets and can ONLY find each other through the
10855        // unicast initial peers — proving the profile drives multicast-free
10856        // discovery end-to-end. Only test-timing + the peer list are
10857        // overridden; `spdp_multicast_send` comes from the profile.
10858        let domain = 9;
10859        let mut peers = Vec::new();
10860        super::expand_initial_peer(
10861            "127.0.0.1",
10862            domain as u32,
10863            super::INITIAL_PEER_MAX_PARTICIPANTS,
10864            &mut peers,
10865        );
10866        let mk = |group: [u8; 4]| RuntimeConfig {
10867            tick_period: Duration::from_millis(10),
10868            spdp_period: Duration::from_millis(40),
10869            spdp_multicast_group: Ipv4Addr::from(group),
10870            initial_peers: peers.clone(),
10871            ..RuntimeConfig::multi_robot()
10872        };
10873        let a = DcpsRuntime::start(
10874            domain,
10875            GuidPrefix::from_bytes([0xC6; 12]),
10876            mk([239, 255, 9, 1]),
10877        )
10878        .expect("a");
10879        let b = DcpsRuntime::start(
10880            domain,
10881            GuidPrefix::from_bytes([0xD7; 12]),
10882            mk([239, 255, 9, 2]),
10883        )
10884        .expect("b");
10885        let mut discovered = false;
10886        for _ in 0..160 {
10887            std::thread::sleep(Duration::from_millis(25));
10888            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
10889                discovered = true;
10890                break;
10891            }
10892        }
10893        a.shutdown();
10894        b.shutdown();
10895        assert!(
10896            discovered,
10897            "multi_robot() profile failed to discover via unicast initial peers"
10898        );
10899    }
10900
10901    #[test]
10902    fn intra_runtime_writer_to_reader_loopback_delivers_sample() {
10903        // Bridge daemon use case: writer and reader in the SAME
10904        // DcpsRuntime, same topic+type. Before the same-runtime loopback
10905        // hook, a write() produced NO sample at the local reader,
10906        // because `inproc_announce_*` explicitly skips self and UDP multicast
10907        // loopback is not guaranteed.
10908        let rt = DcpsRuntime::start(
10909            17,
10910            GuidPrefix::from_bytes([0x42; 12]),
10911            RuntimeConfig::default(),
10912        )
10913        .expect("start runtime");
10914        let writer_eid = rt
10915            .register_user_writer(UserWriterConfig {
10916                topic_name: "IntraTopic".into(),
10917                type_name: "IntraType".into(),
10918                reliable: true,
10919                durability: zerodds_qos::DurabilityKind::Volatile,
10920                deadline: zerodds_qos::DeadlineQosPolicy::default(),
10921                lifespan: zerodds_qos::LifespanQosPolicy::default(),
10922                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10923                ownership: zerodds_qos::OwnershipKind::Shared,
10924                ownership_strength: 0,
10925                partition: alloc::vec![],
10926                user_data: alloc::vec![],
10927                topic_data: alloc::vec![],
10928                group_data: alloc::vec![],
10929                type_identifier: zerodds_types::TypeIdentifier::None,
10930                data_representation_offer: None,
10931            })
10932            .expect("register writer");
10933        let (_reader_eid, rx) = rt
10934            .register_user_reader(UserReaderConfig {
10935                topic_name: "IntraTopic".into(),
10936                type_name: "IntraType".into(),
10937                reliable: true,
10938                durability: zerodds_qos::DurabilityKind::Volatile,
10939                deadline: zerodds_qos::DeadlineQosPolicy::default(),
10940                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10941                ownership: zerodds_qos::OwnershipKind::Shared,
10942                partition: alloc::vec![],
10943                user_data: alloc::vec![],
10944                topic_data: alloc::vec![],
10945                group_data: alloc::vec![],
10946                type_identifier: zerodds_types::TypeIdentifier::None,
10947                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10948                data_representation_offer: None,
10949            })
10950            .expect("register reader");
10951
10952        rt.write_user_sample(writer_eid, b"hello-intra-runtime".to_vec())
10953            .expect("write");
10954
10955        // Same-runtime loopback is synchronous in the write_user_sample_borrowed
10956        // path — `recv_timeout` needs only microseconds, not the
10957        // wire roundtrip.
10958        let sample = rx
10959            .recv_timeout(core::time::Duration::from_millis(100))
10960            .expect("intra-runtime reader should receive sample");
10961        match sample {
10962            UserSample::Alive { payload, .. } => {
10963                assert_eq!(payload.as_ref(), b"hello-intra-runtime");
10964            }
10965            other => panic!("expected Alive, got {other:?}"),
10966        }
10967        rt.shutdown();
10968    }
10969
10970    #[test]
10971    fn intra_runtime_loopback_not_matched_on_different_topic() {
10972        // Negative test: writer on TopicA, reader on TopicB — no
10973        // intra-runtime match, no sample. Prevents the
10974        // routing table from topic-blindly merging everything.
10975        let rt = DcpsRuntime::start(
10976            18,
10977            GuidPrefix::from_bytes([0x43; 12]),
10978            RuntimeConfig::default(),
10979        )
10980        .expect("start runtime");
10981        let writer_eid = rt
10982            .register_user_writer(UserWriterConfig {
10983                topic_name: "TopicA".into(),
10984                type_name: "TypeA".into(),
10985                reliable: true,
10986                durability: zerodds_qos::DurabilityKind::Volatile,
10987                deadline: zerodds_qos::DeadlineQosPolicy::default(),
10988                lifespan: zerodds_qos::LifespanQosPolicy::default(),
10989                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10990                ownership: zerodds_qos::OwnershipKind::Shared,
10991                ownership_strength: 0,
10992                partition: alloc::vec![],
10993                user_data: alloc::vec![],
10994                topic_data: alloc::vec![],
10995                group_data: alloc::vec![],
10996                type_identifier: zerodds_types::TypeIdentifier::None,
10997                data_representation_offer: None,
10998            })
10999            .expect("register writer");
11000        let (_reader_eid, rx) = rt
11001            .register_user_reader(UserReaderConfig {
11002                topic_name: "TopicB".into(),
11003                type_name: "TypeB".into(),
11004                reliable: true,
11005                durability: zerodds_qos::DurabilityKind::Volatile,
11006                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11007                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11008                ownership: zerodds_qos::OwnershipKind::Shared,
11009                partition: alloc::vec![],
11010                user_data: alloc::vec![],
11011                topic_data: alloc::vec![],
11012                group_data: alloc::vec![],
11013                type_identifier: zerodds_types::TypeIdentifier::None,
11014                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11015                data_representation_offer: None,
11016            })
11017            .expect("register reader");
11018
11019        rt.write_user_sample(writer_eid, b"should-not-arrive".to_vec())
11020            .expect("write");
11021
11022        match rx.recv_timeout(core::time::Duration::from_millis(50)) {
11023            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { /* expected */ }
11024            other => panic!("reader on different topic must not receive: got {other:?}"),
11025        }
11026        rt.shutdown();
11027    }
11028
11029    #[test]
11030    fn runtime_starts_and_shuts_down_cleanly() {
11031        let rt = DcpsRuntime::start(
11032            42,
11033            GuidPrefix::from_bytes([7; 12]),
11034            RuntimeConfig::default(),
11035        )
11036        .expect("start runtime");
11037        assert_eq!(rt.domain_id, 42);
11038        // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): the SameHostTracker
11039        // must be initially empty and a same-host match (manually
11040        // simulated, without SEDP setup) must produce a `Pending`
11041        // entry. The real SEDP hook trigger is the job of the E2E
11042        // test in wave 4c — here only a smoke test of the wiring point.
11043        assert!(rt.same_host.is_empty(), "fresh runtime: no same-host pairs");
11044        let local_writer = zerodds_rtps::wire_types::Guid::new(
11045            rt.guid_prefix,
11046            zerodds_rtps::wire_types::EntityId::user_writer_with_key([1, 2, 3]),
11047        );
11048        let same_host_reader = zerodds_rtps::wire_types::Guid::new(
11049            rt.guid_prefix,
11050            zerodds_rtps::wire_types::EntityId::user_reader_with_key([4, 5, 6]),
11051        );
11052        rt.same_host
11053            .register_pending(local_writer, same_host_reader);
11054        assert_eq!(rt.same_host.len(), 1);
11055        assert!(matches!(
11056            rt.same_host.lookup(local_writer, same_host_reader),
11057            Some(crate::same_host::SameHostState::Pending)
11058        ));
11059        // Shutdown is idempotent.
11060        rt.shutdown();
11061        rt.shutdown();
11062    }
11063
11064    #[test]
11065    fn spdp_announces_standard_bits_by_default() {
11066        // Default config (without security): standard bits + WLP bits 10/11
11067        // + TypeLookup bits 12/13 must be announced along;
11068        // secure bits 16..27 + SEDP-topics bits 28/29 must NOT
11069        // be set. Topics bits are optional per RTPS 2.5 §8.5.4.4
11070        // — ZeroDDS does not implement the native topic endpoints
11071        // (synthetic DCPSTopic derivation from pub/sub covers the
11072        // end-user need), so we do not announce the capability
11073        // either.
11074        let rt = DcpsRuntime::start(
11075            5,
11076            GuidPrefix::from_bytes([0xC; 12]),
11077            RuntimeConfig::default(),
11078        )
11079        .expect("start");
11080        let mask = rt.announced_builtin_endpoint_set();
11081        // Standard bits + WLP + TypeLookup.
11082        assert_ne!(mask & endpoint_flag::PARTICIPANT_ANNOUNCER, 0);
11083        assert_ne!(mask & endpoint_flag::PARTICIPANT_DETECTOR, 0);
11084        assert_ne!(mask & endpoint_flag::PUBLICATIONS_ANNOUNCER, 0);
11085        assert_ne!(mask & endpoint_flag::SUBSCRIPTIONS_DETECTOR, 0);
11086        assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_WRITER, 0);
11087        assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_READER, 0);
11088        assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REQUEST, 0);
11089        assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REPLY, 0);
11090        // Do NOT set the SEDP-topics bits — covered synthetically.
11091        assert_eq!(mask & endpoint_flag::TOPICS_ANNOUNCER, 0);
11092        assert_eq!(mask & endpoint_flag::TOPICS_DETECTOR, 0);
11093        // No secure bits without explicit announce_secure_endpoints.
11094        assert_eq!(mask & endpoint_flag::ALL_SECURE, 0);
11095    }
11096
11097    #[test]
11098    fn spdp_announces_secure_bits_when_configured() {
11099        // With announce_secure_endpoints=true all 12 secure
11100        // bits (16..27) must be set.
11101        let config = RuntimeConfig {
11102            announce_secure_endpoints: true,
11103            ..Default::default()
11104        };
11105        let rt = DcpsRuntime::start(6, GuidPrefix::from_bytes([0xD; 12]), config).expect("start");
11106        let mask = rt.announced_builtin_endpoint_set();
11107        for bit in 16u32..=27 {
11108            assert!(
11109                mask & (1u32 << bit) != 0,
11110                "secure bit {bit} missing in the SPDP announce"
11111            );
11112        }
11113        // Standard bits must still be set.
11114        assert_eq!(
11115            mask & endpoint_flag::ALL_STANDARD,
11116            endpoint_flag::ALL_STANDARD
11117        );
11118    }
11119
11120    #[test]
11121    fn spdp_lease_duration_is_configurable() {
11122        // Default 100 s (spec). The override of 17 s must arrive in the beacon.
11123        let config = RuntimeConfig {
11124            participant_lease_duration: Duration::from_secs(17),
11125            ..Default::default()
11126        };
11127        let rt = DcpsRuntime::start(7, GuidPrefix::from_bytes([0xE; 12]), config).expect("start");
11128        let secs = rt
11129            .spdp_beacon
11130            .lock()
11131            .map(|b| b.data.lease_duration.seconds)
11132            .unwrap_or(0);
11133        assert_eq!(secs, 17);
11134    }
11135
11136    #[test]
11137    fn user_locator_is_udp_v4_127_0_0_x() {
11138        let rt = DcpsRuntime::start(
11139            0,
11140            GuidPrefix::from_bytes([0xA; 12]),
11141            RuntimeConfig::default(),
11142        )
11143        .expect("start");
11144        let loc = rt.user_locator();
11145        assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
11146        // Port > 0 (ephemeral).
11147        assert!(loc.port > 0);
11148    }
11149
11150    #[test]
11151    fn two_runtimes_on_same_domain_can_coexist() {
11152        // The SPDP multicast port is SO_REUSE in our bind.
11153        let a = DcpsRuntime::start(
11154            3,
11155            GuidPrefix::from_bytes([0xA; 12]),
11156            RuntimeConfig::default(),
11157        )
11158        .expect("a");
11159        let b = DcpsRuntime::start(
11160            3,
11161            GuidPrefix::from_bytes([0xB; 12]),
11162            RuntimeConfig::default(),
11163        )
11164        .expect("b");
11165        assert_eq!(a.domain_id, b.domain_id);
11166    }
11167
11168    #[test]
11169    fn peer_capabilities_unknown_peer_returns_none() {
11170        let rt = DcpsRuntime::start(
11171            10,
11172            GuidPrefix::from_bytes([0x60; 12]),
11173            RuntimeConfig::default(),
11174        )
11175        .expect("start");
11176        // A fresh runtime has discovered no peer.
11177        let caps = rt.peer_capabilities(&GuidPrefix::from_bytes([0xEE; 12]));
11178        assert!(caps.is_none());
11179    }
11180
11181    #[test]
11182    fn assert_liveliness_enqueues_wlp_pulse_without_panic() {
11183        // Smoke test: assert_liveliness() must not poison the lock
11184        // and must return synchronously.
11185        let rt = DcpsRuntime::start(
11186            8,
11187            GuidPrefix::from_bytes([0xF; 12]),
11188            RuntimeConfig::default(),
11189        )
11190        .expect("start");
11191        rt.assert_liveliness();
11192        rt.assert_writer_liveliness(alloc::vec![0xDE, 0xAD]);
11193        // The lock must stay usable.
11194        let count = rt.wlp.lock().map(|w| w.peer_count()).unwrap_or(usize::MAX);
11195        assert_eq!(count, 0, "no peer announced itself → 0");
11196    }
11197
11198    #[test]
11199    fn wlp_period_default_is_lease_over_three() {
11200        // With the default lease of 100 s → wlp_period = 33.33 s.
11201        let rt = DcpsRuntime::start(
11202            9,
11203            GuidPrefix::from_bytes([0x10; 12]),
11204            RuntimeConfig::default(),
11205        )
11206        .expect("start");
11207        // We cannot read the value directly; but we
11208        // know: tick_period > 30 s means the default lease was
11209        // used. Enqueue a pulse and tick — it must fire,
11210        // the next AUTOMATIC comes only in 33 s.
11211        let mut wlp = rt.wlp.lock().unwrap();
11212        wlp.assert_participant();
11213        let now0 = Duration::from_secs(0);
11214        let dg = wlp.tick(now0).unwrap();
11215        assert!(dg.is_some(), "pulse is emitted immediately");
11216    }
11217
11218    // Multicast loopback is unreliable on macOS (no auto-
11219    // interface-join with bind_multicast_v4(0.0.0.0)). On Linux
11220    // it works out of the box; there the test will run in CI.
11221    #[cfg(target_os = "linux")]
11222    #[test]
11223    fn two_runtimes_exchange_wlp_heartbeat_via_multicast() {
11224        // .D-e: A sends periodic WLP heartbeats. B must
11225        // know its own WLP endpoint with A's prefix as a peer
11226        // within ~3 tick periods.
11227        let cfg = RuntimeConfig {
11228            tick_period: Duration::from_millis(20),
11229            spdp_period: Duration::from_millis(100),
11230            // Aggressive WLP period for fast tests.
11231            wlp_period: Duration::from_millis(80),
11232            participant_lease_duration: Duration::from_millis(240),
11233            ..RuntimeConfig::default()
11234        };
11235        let _a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x40; 12]), cfg.clone()).expect("a");
11236        let _b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x41; 12]), cfg).expect("b");
11237
11238        let a_prefix = GuidPrefix::from_bytes([0x40; 12]);
11239        for _ in 0..60 {
11240            thread::sleep(Duration::from_millis(50));
11241            if _b.peer_liveliness_last_seen(&a_prefix).is_some() {
11242                return;
11243            }
11244        }
11245        panic!("B did not see A's WLP heartbeat within 3 s");
11246    }
11247
11248    #[cfg(target_os = "linux")]
11249    #[test]
11250    fn two_runtimes_assert_liveliness_reaches_peer() {
11251        // The Manual-By-Participant pulse must arrive at the peer, the
11252        // last-seen timestamp must reset compared to purely Automatic
11253        // beats. Since the pulse goes out synchronously on the next
11254        // tick, a short wait suffices.
11255        let cfg = RuntimeConfig {
11256            tick_period: Duration::from_millis(20),
11257            spdp_period: Duration::from_millis(100),
11258            // WLP period large enough that no AUTOMATIC beat comes
11259            // in between within the test. The manual pulse queue
11260            // is processed before the AUTOMATIC slot.
11261            wlp_period: Duration::from_secs(3600),
11262            ..RuntimeConfig::default()
11263        };
11264        let a = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x50; 12]), cfg.clone()).expect("a");
11265        let b = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x51; 12]), cfg).expect("b");
11266
11267        a.assert_liveliness();
11268        let a_prefix = GuidPrefix::from_bytes([0x50; 12]);
11269        for _ in 0..60 {
11270            thread::sleep(Duration::from_millis(50));
11271            if b.peer_liveliness_last_seen(&a_prefix).is_some() {
11272                return;
11273            }
11274        }
11275        // In case of multicast-loopback problems, at least check A's
11276        // own pulse counter.
11277        panic!("B did not see A's manual liveliness assert within 3 s");
11278    }
11279
11280    #[cfg(target_os = "linux")]
11281    #[test]
11282    fn two_runtimes_exchange_sedp_publication_announce() {
11283        // E2E smoke: A announces a publication, B sees it
11284        // via SEDP. Assumes SPDP works (so that
11285        // the SEDP peer proxies get wired).
11286        use zerodds_qos::{DurabilityKind, ReliabilityKind};
11287        use zerodds_rtps::publication_data::PublicationBuiltinTopicData;
11288
11289        let cfg = RuntimeConfig {
11290            tick_period: Duration::from_millis(20),
11291            spdp_period: Duration::from_millis(100),
11292            ..RuntimeConfig::default()
11293        };
11294        // Own domain, so the test does not collide with the SPDP-only test
11295        // on domain 0 over the multicast port.
11296        let a = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xCC; 12]), cfg.clone()).expect("a");
11297        let b = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xDD; 12]), cfg).expect("b");
11298
11299        // Wait until both see each other via SPDP.
11300        for _ in 0..40 {
11301            thread::sleep(Duration::from_millis(50));
11302            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
11303                break;
11304            }
11305        }
11306        assert!(
11307            !a.discovered_participants().is_empty(),
11308            "no SPDP discovery a"
11309        );
11310
11311        // A announces a publication for topic "Chatter" with type "RawBytes".
11312        let pub_data = PublicationBuiltinTopicData {
11313            key: Guid::new(
11314                a.guid_prefix,
11315                EntityId::user_writer_with_key([0x01, 0x02, 0x03]),
11316            ),
11317            participant_key: Guid::new(a.guid_prefix, EntityId::PARTICIPANT),
11318            topic_name: "Chatter".into(),
11319            type_name: "zerodds::RawBytes".into(),
11320            durability: DurabilityKind::Volatile,
11321            reliability: zerodds_qos::ReliabilityQosPolicy {
11322                kind: ReliabilityKind::Reliable,
11323                max_blocking_time: QosDuration::from_millis(100_i32),
11324            },
11325            ownership: zerodds_qos::OwnershipKind::Shared,
11326            ownership_strength: 0,
11327            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11328            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11329            lifespan: zerodds_qos::LifespanQosPolicy::default(),
11330            partition: Vec::new(),
11331            user_data: Vec::new(),
11332            topic_data: Vec::new(),
11333            group_data: Vec::new(),
11334            type_information: None,
11335            data_representation: Vec::new(),
11336            security_info: None,
11337            service_instance_name: None,
11338            related_entity_guid: None,
11339            topic_aliases: None,
11340            type_identifier: zerodds_types::TypeIdentifier::None,
11341            unicast_locators: Vec::new(),
11342            multicast_locators: Vec::new(),
11343        };
11344        a.announce_publication(&pub_data).expect("announce");
11345
11346        // B should have the publication in the cache within ~3 s.
11347        // CI on shared runners has more jitter, 1 s was too tight.
11348        for _ in 0..60 {
11349            thread::sleep(Duration::from_millis(50));
11350            if b.discovered_publications_count() > 0 {
11351                return;
11352            }
11353        }
11354        panic!(
11355            "B did not receive SEDP publication within 3 s (pub_count={})",
11356            b.discovered_publications_count()
11357        );
11358    }
11359
11360    #[cfg(target_os = "linux")]
11361    #[test]
11362    fn two_runtimes_e2e_user_data_match_and_transfer() {
11363        // E2E smoke: kompletter Pfad
11364        //   Runtime-A register_user_writer(topic, type)
11365        //   Runtime-B register_user_reader(topic, type)
11366        //   SEDP match, writer add_reader_proxy, reader add_writer_proxy
11367        //   A.write_user_sample(payload) → UDP → B's mpsc::Receiver
11368        //
11369        // Eigene Domain (2) um Kollisionen zu vermeiden.
11370        let cfg = RuntimeConfig {
11371            tick_period: Duration::from_millis(20),
11372            spdp_period: Duration::from_millis(100),
11373            ..RuntimeConfig::default()
11374        };
11375        let a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xEE; 12]), cfg.clone()).expect("a");
11376        let b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xFF; 12]), cfg).expect("b");
11377
11378        // SPDP mutual — 3 s Budget.
11379        let mut spdp_ok = false;
11380        for _ in 0..60 {
11381            thread::sleep(Duration::from_millis(50));
11382            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
11383                spdp_ok = true;
11384                break;
11385            }
11386        }
11387        assert!(spdp_ok, "SPDP mutual discovery did not complete in 3 s");
11388
11389        // Register endpoints. A publish, B subscribe.
11390        let wid = a
11391            .register_user_writer(UserWriterConfig {
11392                topic_name: "Chatter".into(),
11393                type_name: "zerodds::RawBytes".into(),
11394                reliable: true,
11395                durability: zerodds_qos::DurabilityKind::Volatile,
11396                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11397                lifespan: zerodds_qos::LifespanQosPolicy::default(),
11398                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11399                ownership: zerodds_qos::OwnershipKind::Shared,
11400                ownership_strength: 0,
11401                partition: Vec::new(),
11402                user_data: Vec::new(),
11403                topic_data: Vec::new(),
11404                group_data: Vec::new(),
11405                type_identifier: zerodds_types::TypeIdentifier::None,
11406                data_representation_offer: None,
11407            })
11408            .expect("wid");
11409        let (_rid, rx) = b
11410            .register_user_reader(UserReaderConfig {
11411                topic_name: "Chatter".into(),
11412                type_name: "zerodds::RawBytes".into(),
11413                reliable: true,
11414                durability: zerodds_qos::DurabilityKind::Volatile,
11415                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11416                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11417                ownership: zerodds_qos::OwnershipKind::Shared,
11418                partition: Vec::new(),
11419                user_data: Vec::new(),
11420                topic_data: Vec::new(),
11421                group_data: Vec::new(),
11422                type_identifier: zerodds_types::TypeIdentifier::None,
11423                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11424                data_representation_offer: None,
11425            })
11426            .expect("rid");
11427
11428        // SEDP match + User-Data-Flow. `add_reader_proxy` triggert
11429        // a heartbeat immediately (RTPS §8.4.15.4), so ~tick_period
11430        // (20 ms) + response-delay (200 ms) + resend ≈ 300 ms in
11431        // idle state. A 4 s budget suffices even with CI jitter.
11432        let mut attempts = 0;
11433        loop {
11434            thread::sleep(Duration::from_millis(50));
11435            let _ = a.write_user_sample(wid, alloc::vec![0xAA, 0xBB, 0xCC]);
11436            if let Ok(sample) = rx.recv_timeout(Duration::from_millis(50)) {
11437                match sample {
11438                    UserSample::Alive { payload, .. } => {
11439                        assert_eq!(payload.as_slice(), &[0xAA, 0xBB, 0xCC][..]);
11440                        return;
11441                    }
11442                    other => panic!("expected Alive sample, got {other:?}"),
11443                }
11444            }
11445            attempts += 1;
11446            if attempts > 80 {
11447                panic!("no sample delivered within 4 s");
11448            }
11449        }
11450    }
11451
11452    #[cfg(target_os = "linux")]
11453    #[test]
11454    fn two_runtimes_discover_each_other_via_spdp() {
11455        // We use a tight SPDP period so the test does not wait 5 s.
11456        let cfg = RuntimeConfig {
11457            tick_period: Duration::from_millis(20),
11458            spdp_period: Duration::from_millis(100),
11459            ..RuntimeConfig::default()
11460        };
11461        // Eigene Domain 3 (SEDP=1, E2E=2) um Cross-Test-Kollision zu vermeiden.
11462        let a = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xAA; 12]), cfg.clone()).expect("a");
11463        let b = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xBB; 12]), cfg).expect("b");
11464
11465        // Give the loop time for 2-3 beacon rounds. Multicast on
11466        // loopback is somewhat timing-sensitive when parallel tests
11467        // share the multicast group — hence 60 iterations of 50 ms
11468        // = 3 s budget instead of 1 s.
11469        for _ in 0..60 {
11470            thread::sleep(Duration::from_millis(50));
11471            let a_sees_b = a
11472                .discovered_participants()
11473                .iter()
11474                .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xBB; 12]));
11475            let b_sees_a = b
11476                .discovered_participants()
11477                .iter()
11478                .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xAA; 12]));
11479            if a_sees_b && b_sees_a {
11480                return;
11481            }
11482        }
11483        panic!(
11484            "mutual SPDP discovery failed within 3 s (a={} b={})",
11485            a.discovered_participants().len(),
11486            b.discovered_participants().len()
11487        );
11488    }
11489
11490    // =======================================================================
11491    // Security: Writer-Side Per-Reader-Serializer
11492    // =======================================================================
11493
11494    #[cfg(feature = "security")]
11495    #[test]
11496    fn per_target_serializer_produces_different_wire_per_reader() {
11497        use zerodds_security_crypto::AesGcmCryptoPlugin;
11498        use zerodds_security_permissions::parse_governance_xml;
11499        use zerodds_security_runtime::{
11500            PeerCapabilities, ProtectionLevel as SecProtectionLevel, SharedSecurityGate,
11501        };
11502
11503        // The governance enforces ENCRYPT on domain 0 — the default
11504        // path (transform_outbound) wraps too. A per-reader override
11505        // can still deliver plaintext if the reader is legacy.
11506        const GOV: &str = r#"
11507<domain_access_rules>
11508  <domain_rule>
11509    <domains><id>0</id></domains>
11510    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11511    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11512  </domain_rule>
11513</domain_access_rules>
11514"#;
11515        let gate = SharedSecurityGate::new(
11516            0,
11517            parse_governance_xml(GOV).unwrap(),
11518            Box::new(AesGcmCryptoPlugin::new()),
11519        );
11520
11521        let cfg = RuntimeConfig {
11522            security: Some(std::sync::Arc::new(gate)),
11523            ..RuntimeConfig::default()
11524        };
11525        let rt =
11526            DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE4; 12]), cfg).expect("start runtime");
11527
11528        let wid = rt
11529            .register_user_writer(UserWriterConfig {
11530                topic_name: "HeteroTopic".into(),
11531                type_name: "zerodds::RawBytes".into(),
11532                reliable: true,
11533                durability: zerodds_qos::DurabilityKind::Volatile,
11534                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11535                lifespan: zerodds_qos::LifespanQosPolicy::default(),
11536                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11537                ownership: zerodds_qos::OwnershipKind::Shared,
11538                ownership_strength: 0,
11539                partition: Vec::new(),
11540                user_data: Vec::new(),
11541                topic_data: Vec::new(),
11542                group_data: Vec::new(),
11543                type_identifier: zerodds_types::TypeIdentifier::None,
11544                data_representation_offer: None,
11545            })
11546            .expect("register writer");
11547
11548        // Drei fiktive Reader-Targets — eines pro Protection-Klasse.
11549        let legacy_loc = Locator::udp_v4([127, 0, 0, 11], 40001);
11550        let fast_loc = Locator::udp_v4([127, 0, 0, 12], 40002);
11551        let secure_loc = Locator::udp_v4([127, 0, 0, 13], 40003);
11552        let legacy_peer: [u8; 12] = [0x11; 12];
11553        let fast_peer: [u8; 12] = [0x22; 12];
11554        let secure_peer: [u8; 12] = [0x33; 12];
11555
11556        // Simulates the SEDP match: populate the writer-slot maps.
11557        {
11558            let arc = rt.writer_slot(wid).unwrap();
11559            let mut slot = arc.lock().unwrap();
11560            slot.reader_protection
11561                .insert(legacy_peer, SecProtectionLevel::None);
11562            slot.reader_protection
11563                .insert(fast_peer, SecProtectionLevel::Sign);
11564            slot.reader_protection
11565                .insert(secure_peer, SecProtectionLevel::Encrypt);
11566            slot.locator_to_peer.insert(legacy_loc, legacy_peer);
11567            slot.locator_to_peer.insert(fast_loc, fast_peer);
11568            slot.locator_to_peer.insert(secure_loc, secure_peer);
11569        }
11570
11571        // Fiktive Writer-Datagram-Bytes (RTPS-Header + User-Payload).
11572        let mut msg = Vec::new();
11573        msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
11574        msg.extend_from_slice(&[0xE4; 12]); // GuidPrefix
11575        msg.extend_from_slice(b"HELLO-HETERO");
11576
11577        let wire_legacy =
11578            secure_outbound_for_target(&rt, wid, &msg, &legacy_loc).expect("legacy path");
11579        let wire_fast = secure_outbound_for_target(&rt, wid, &msg, &fast_loc).expect("fast path");
11580        let wire_secure =
11581            secure_outbound_for_target(&rt, wid, &msg, &secure_loc).expect("secure path");
11582
11583        // Spec §8.4.2.4: under rtps_protection_kind=ENCRYPT EVERY message MUST
11584        // be SRTPS-wrapped — even a legacy reader (data-level None) may
11585        // get NO plaintext, otherwise user DATA leaks on a protected
11586        // domain. The per-reader data level only controls the inner payload/
11587        // submessage layer, not the outer rtps_protection.
11588        assert_ne!(
11589            wire_legacy, msg,
11590            "legacy under rtps_protection=ENCRYPT MUST be SRTPS-wrapped (no plaintext leak)"
11591        );
11592        assert_ne!(wire_fast, msg, "fast reader must be protected");
11593        assert_ne!(wire_secure, msg, "secure reader must be protected");
11594
11595        // Heterogeneity proof: the three wires are pairwise
11596        // different (each with its own nonce/session counter in SRTPS).
11597        assert_ne!(wire_legacy, wire_fast);
11598        assert_ne!(wire_legacy, wire_secure);
11599        assert_ne!(wire_fast, wire_secure);
11600
11601        // Without a locator match the fallback must take the domain-rule path
11602        // — this governance requires ENCRYPT, so SRTPS-wrapped.
11603        let unknown_loc = Locator::udp_v4([127, 0, 0, 99], 40099);
11604        let wire_unknown =
11605            secure_outbound_for_target(&rt, wid, &msg, &unknown_loc).expect("fallback path");
11606        assert_ne!(
11607            wire_unknown, msg,
11608            "unknown target should be protected via the domain rule"
11609        );
11610
11611        // The absence of the PeerCapabilities type is a compile check:
11612        // the import shows that the entire per-reader structure
11613        // is available in the dcps integration.
11614        let _unused: PeerCapabilities = PeerCapabilities::default();
11615
11616        rt.shutdown();
11617    }
11618
11619    // =======================================================================
11620    // Security: Reader-Side Per-Writer-Validator + Logging
11621    // =======================================================================
11622
11623    #[cfg(feature = "security")]
11624    #[derive(Default, Clone)]
11625    struct CapturingLogger {
11626        inner: std::sync::Arc<
11627            std::sync::Mutex<Vec<(zerodds_security_runtime::LogLevel, String, String)>>,
11628        >,
11629    }
11630
11631    #[cfg(feature = "security")]
11632    impl CapturingLogger {
11633        fn events(&self) -> Vec<(zerodds_security_runtime::LogLevel, String, String)> {
11634            self.inner.lock().map(|g| g.clone()).unwrap_or_default()
11635        }
11636    }
11637
11638    #[cfg(feature = "security")]
11639    impl zerodds_security_runtime::LoggingPlugin for CapturingLogger {
11640        fn log(
11641            &self,
11642            level: zerodds_security_runtime::LogLevel,
11643            _participant: [u8; 16],
11644            category: &str,
11645            message: &str,
11646        ) {
11647            if let Ok(mut g) = self.inner.lock() {
11648                g.push((level, category.to_string(), message.to_string()));
11649            }
11650        }
11651        fn plugin_class_id(&self) -> &str {
11652            "zerodds.test.capturing_logger"
11653        }
11654    }
11655
11656    #[cfg(feature = "security")]
11657    fn build_runtime_with(
11658        gov_xml: &str,
11659        logger: std::sync::Arc<CapturingLogger>,
11660    ) -> std::sync::Arc<DcpsRuntime> {
11661        use zerodds_security_crypto::AesGcmCryptoPlugin;
11662        use zerodds_security_permissions::parse_governance_xml;
11663        use zerodds_security_runtime::{LoggingPlugin, SharedSecurityGate};
11664        let gate = SharedSecurityGate::new(
11665            0,
11666            parse_governance_xml(gov_xml).unwrap(),
11667            Box::new(AesGcmCryptoPlugin::new()),
11668        );
11669        let logger_dyn: std::sync::Arc<dyn LoggingPlugin> = logger;
11670        let cfg = RuntimeConfig {
11671            security: Some(std::sync::Arc::new(gate)),
11672            security_logger: Some(logger_dyn),
11673            ..RuntimeConfig::default()
11674        };
11675        DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE7; 12]), cfg).expect("start rt")
11676    }
11677
11678    #[cfg(feature = "security")]
11679    #[test]
11680    fn inbound_plain_on_encrypt_domain_drops_with_error_event() {
11681        // DoD plan §stage 5: writer sends plain, policy expects
11682        // ENCRYPT → Reader droppt. Ohne allow_unauthenticated ist
11683        // this a "LegacyBlocked" → error level (not warning) per
11684        // the plan spec "missing-caps = Error".
11685        const GOV_ENCRYPT: &str = r#"
11686<domain_access_rules>
11687  <domain_rule>
11688    <domains><id>0</id></domains>
11689    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11690    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11691  </domain_rule>
11692</domain_access_rules>
11693"#;
11694        let logger = std::sync::Arc::new(CapturingLogger::default());
11695        let rt = build_runtime_with(GOV_ENCRYPT, std::sync::Arc::clone(&logger));
11696
11697        // Plain-RTPS-Datagram (header + body).
11698        let mut plain = Vec::new();
11699        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
11700        plain.extend_from_slice(&[0x77; 12]); // attacker guid_prefix
11701        plain.extend_from_slice(b"plaintext-on-encrypted-domain");
11702
11703        let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan);
11704        assert!(out.is_none(), "tampering packet must be dropped");
11705
11706        let events = logger.events();
11707        assert_eq!(events.len(), 1, "exactly one log event expected");
11708        let (level, category, _msg) = &events[0];
11709        assert_eq!(
11710            *level,
11711            zerodds_security_runtime::LogLevel::Error,
11712            "plain-on-protected-domain without allow_unauth = Error (LegacyBlocked)"
11713        );
11714        assert_eq!(category, "inbound.legacy_blocked");
11715        rt.shutdown();
11716    }
11717
11718    #[cfg(feature = "security")]
11719    #[test]
11720    fn inbound_legacy_peer_accepted_when_governance_allows_unauth() {
11721        // DoD plan §stage 5: the legacy peer can keep talking to the reader,
11722        // when the governance sets allow_unauthenticated_participants=true.
11723        const GOV: &str = r#"
11724<domain_access_rules>
11725  <domain_rule>
11726    <domains><id>0</id></domains>
11727    <allow_unauthenticated_participants>TRUE</allow_unauthenticated_participants>
11728    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11729    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11730  </domain_rule>
11731</domain_access_rules>
11732"#;
11733        let logger = std::sync::Arc::new(CapturingLogger::default());
11734        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
11735
11736        let mut plain = Vec::new();
11737        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
11738        plain.extend_from_slice(&[0x88; 12]);
11739        plain.extend_from_slice(b"legacy-but-allowed");
11740
11741        let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan)
11742            .expect("legacy peer must be accepted");
11743        assert_eq!(out, plain, "output is byte-identical (no crypto unwrap)");
11744        assert!(
11745            logger.events().is_empty(),
11746            "no log event on the accept path"
11747        );
11748        rt.shutdown();
11749    }
11750
11751    #[cfg(feature = "security")]
11752    #[test]
11753    fn inbound_malformed_drops_and_logs_error() {
11754        const GOV: &str = r#"
11755<domain_access_rules>
11756  <domain_rule>
11757    <domains><id>0</id></domains>
11758    <rtps_protection_kind>NONE</rtps_protection_kind>
11759    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11760  </domain_rule>
11761</domain_access_rules>
11762"#;
11763        let logger = std::sync::Arc::new(CapturingLogger::default());
11764        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
11765
11766        let out = secure_inbound_bytes(&rt, &[1, 2, 3, 4], &NetInterface::Wan);
11767        assert!(out.is_none());
11768        let events = logger.events();
11769        assert_eq!(events.len(), 1);
11770        assert_eq!(events[0].0, zerodds_security_runtime::LogLevel::Error);
11771        assert_eq!(events[0].1, "inbound.malformed");
11772        rt.shutdown();
11773    }
11774
11775    #[cfg(feature = "security")]
11776    #[test]
11777    fn inbound_without_security_gate_bypasses_classify_and_logger() {
11778        // Without a security gate: passthrough, no log event.
11779        let logger = std::sync::Arc::new(CapturingLogger::default());
11780        let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
11781            std::sync::Arc::clone(&logger) as _;
11782        let cfg = RuntimeConfig {
11783            security_logger: Some(logger_dyn),
11784            ..RuntimeConfig::default()
11785        };
11786        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE8; 12]), cfg).unwrap();
11787        let msg = vec![0xAAu8; 40];
11788        let out = secure_inbound_bytes(&rt, &msg, &NetInterface::Wan).unwrap();
11789        assert_eq!(out, msg);
11790        assert!(
11791            logger.events().is_empty(),
11792            "the logger must NOT be called without a gate"
11793        );
11794        rt.shutdown();
11795    }
11796
11797    // =======================================================================
11798    // Security: Interface-Routing (Multi-Socket-Binding)
11799    // =======================================================================
11800
11801    #[cfg(feature = "security")]
11802    fn lo_range(third: u8) -> zerodds_security_runtime::IpRange {
11803        zerodds_security_runtime::IpRange {
11804            base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, third)),
11805            prefix_len: 32,
11806        }
11807    }
11808
11809    #[cfg(feature = "security")]
11810    #[test]
11811    fn outbound_pool_routes_target_to_matching_binding() {
11812        let specs = vec![
11813            InterfaceBindingSpec {
11814                name: "lo-a".into(),
11815                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11816                bind_port: 0,
11817                kind: zerodds_security_runtime::NetInterface::Loopback,
11818                subnet: lo_range(11),
11819                default: false,
11820            },
11821            InterfaceBindingSpec {
11822                name: "lo-b".into(),
11823                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11824                bind_port: 0,
11825                kind: zerodds_security_runtime::NetInterface::Wan,
11826                subnet: lo_range(22),
11827                default: true,
11828            },
11829        ];
11830        let pool = OutboundSocketPool::bind_all(&specs).expect("pool");
11831
11832        // Exact match on the first subnet -> lo-a.
11833        let t1 = Locator::udp_v4([127, 0, 0, 11], 40000);
11834        let (sock1, iface1) = pool.route(&t1).expect("route 1");
11835        assert_eq!(iface1, zerodds_security_runtime::NetInterface::Loopback);
11836
11837        // Exact match on the second subnet -> lo-b.
11838        let t2 = Locator::udp_v4([127, 0, 0, 22], 40000);
11839        let (sock2, iface2) = pool.route(&t2).expect("route 2");
11840        assert_eq!(iface2, zerodds_security_runtime::NetInterface::Wan);
11841
11842        // The two sockets must have different local ports.
11843        let p1 = sock1.local_locator().port;
11844        let p2 = sock2.local_locator().port;
11845        assert_ne!(p1, p2);
11846    }
11847
11848    #[cfg(feature = "security")]
11849    #[test]
11850    fn outbound_pool_falls_back_to_default_when_no_subnet_matches() {
11851        let specs = vec![
11852            InterfaceBindingSpec {
11853                name: "lo-specific".into(),
11854                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11855                bind_port: 0,
11856                kind: zerodds_security_runtime::NetInterface::Loopback,
11857                subnet: lo_range(33),
11858                default: false,
11859            },
11860            InterfaceBindingSpec {
11861                name: "wan-default".into(),
11862                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11863                bind_port: 0,
11864                kind: zerodds_security_runtime::NetInterface::Wan,
11865                subnet: zerodds_security_runtime::IpRange {
11866                    base: core::net::IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED),
11867                    prefix_len: 0,
11868                },
11869                default: true,
11870            },
11871        ];
11872        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
11873        let unknown = Locator::udp_v4([192, 168, 7, 7], 12345);
11874        let (_sock, iface) = pool.route(&unknown).expect("default fallback");
11875        assert_eq!(iface, zerodds_security_runtime::NetInterface::Wan);
11876    }
11877
11878    #[cfg(feature = "security")]
11879    #[test]
11880    fn outbound_pool_returns_none_when_no_match_and_no_default() {
11881        let specs = vec![InterfaceBindingSpec {
11882            name: "only-lo".into(),
11883            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11884            bind_port: 0,
11885            kind: zerodds_security_runtime::NetInterface::Loopback,
11886            subnet: lo_range(44),
11887            default: false,
11888        }];
11889        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
11890        assert!(pool.route(&Locator::udp_v4([8, 8, 8, 8], 53)).is_none());
11891    }
11892
11893    #[cfg(feature = "security")]
11894    #[test]
11895    fn outbound_pool_skips_non_v4_locators() {
11896        let specs = vec![InterfaceBindingSpec {
11897            name: "lo".into(),
11898            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11899            bind_port: 0,
11900            kind: zerodds_security_runtime::NetInterface::Loopback,
11901            subnet: lo_range(55),
11902            default: true,
11903        }];
11904        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
11905        // SHM locator (no IPv4) → no match; without a default it would be None,
11906        // here default=true and subnet-contains does not apply
11907        // because ipv4_from_locator returns None.
11908        let shm = Locator {
11909            kind: zerodds_rtps::wire_types::LocatorKind::Shm,
11910            port: 0,
11911            address: [0u8; 16],
11912        };
11913        assert!(pool.route(&shm).is_none());
11914    }
11915
11916    #[cfg(feature = "security")]
11917    #[test]
11918    fn dod_plaintext_lo_vs_srtps_wan_via_sniffer() {
11919        // Spec §8.4.2.4 (spec wins vs DoD loopback plaintext): under
11920        // rtps_protection_kind=ENCRYPT means bytes are SRTPS-wrapped on EVERY
11921        // interface — including loopback. The test proves that the
11922        // per-interface routing serves both targets AND both outputs
11923        // are spec-conformantly protected (no plaintext leak, regardless of which
11924        // binding).
11925        //
11926        // Setup:
11927        //  * 2 sniffer UDP sockets, one simulates a legacy
11928        //    loopback peer (expects plaintext), the other a
11929        //    WAN secure peer (expects SRTPS).
11930        //  * DcpsRuntime with a security gate (governance = ENCRYPT) and
11931        //    two interface bindings: lo-binding on 127.0.0.100,
11932        //    wan-binding auf 127.0.0.200.
11933        //  * 1 writer, 2 matched_readers with different protection
11934        //    (Legacy=None, Secure=Encrypt) and the respective sniffer
11935        //    Socket address as the locator_to_peer target.
11936        //  * `send_on_best_interface(rt, target, bytes)` is triggered
11937        //    manually; the sniffer per target receives and checks
11938        //    the wire format.
11939        use std::net::{SocketAddrV4, UdpSocket};
11940        use zerodds_security_crypto::AesGcmCryptoPlugin;
11941        use zerodds_security_permissions::parse_governance_xml;
11942        use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
11943
11944        const GOV: &str = r#"
11945<domain_access_rules>
11946  <domain_rule>
11947    <domains><id>0</id></domains>
11948    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11949    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11950  </domain_rule>
11951</domain_access_rules>
11952"#;
11953        // Two sniffer sockets on ephemeral loopback ports (independent
11954        // from our bindings; they act as "peer receivers").
11955        let lo_sniffer =
11956            UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).expect("lo sniffer");
11957        lo_sniffer
11958            .set_read_timeout(Some(Duration::from_millis(250)))
11959            .unwrap();
11960        let wan_sniffer = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0))
11961            .expect("wan sniffer");
11962        wan_sniffer
11963            .set_read_timeout(Some(Duration::from_millis(250)))
11964            .unwrap();
11965        let lo_port = lo_sniffer.local_addr().unwrap().port();
11966        let wan_port = wan_sniffer.local_addr().unwrap().port();
11967        let lo_target = Locator::udp_v4([127, 0, 0, 1], u32::from(lo_port));
11968        let wan_target = Locator::udp_v4([127, 0, 0, 1], u32::from(wan_port));
11969
11970        // Two bindings, subnet-matched to exactly these ports. Since
11971        // IpRange currently matches only on IP, we use two
11972        // different /32 host ranges as a trick:
11973        // we set both bindings to the same IP/32, but because
11974        // `route` takes the first subnet match, I list them such
11975        // that "lo-bind" comes first and then the default.
11976        //
11977        // Correct: both sniffers share 127.0.0.1/32 and the pool would
11978        // pick the first binding. To distinguish cleanly, we map
11979        // the binding decision by *target port* — that works
11980        // not today. So: we work around this subtlety by
11981        // calling `send_on_best_interface` directly for different targets
11982        // and assigning the binding by IP range —
11983        // the DoD checks the routing at the binding level, not the
11984        // socket layer.
11985        //
11986        // Pragmatically: we test end-to-end that the pool actually
11987        // picks the right interface socket for the target and
11988        // processes the bytes differently (plain vs SRTPS).
11989        // The target locators differ only in the port, but
11990        // `send_on_best_interface` gets them separately each. The
11991        // decisive point is: both bindings send **and** the
11992        // sniffer socket receives — proving the routing in combination
11993        // with the per-reader serializer from stage 4.
11994
11995        let bindings = vec![InterfaceBindingSpec {
11996            name: "lo-for-legacy".into(),
11997            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11998            bind_port: 0,
11999            kind: SecIf::Loopback,
12000            subnet: zerodds_security_runtime::IpRange {
12001                base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 1)),
12002                prefix_len: 32,
12003            },
12004            default: true,
12005        }];
12006        let gate = SharedSecurityGate::new(
12007            0,
12008            parse_governance_xml(GOV).unwrap(),
12009            Box::new(AesGcmCryptoPlugin::new()),
12010        );
12011        let cfg = RuntimeConfig {
12012            security: Some(std::sync::Arc::new(gate)),
12013            interface_bindings: bindings,
12014            ..RuntimeConfig::default()
12015        };
12016        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF0; 12]), cfg).expect("rt");
12017
12018        let wid = rt
12019            .register_user_writer(UserWriterConfig {
12020                topic_name: "HeteroRouting".into(),
12021                type_name: "zerodds::RawBytes".into(),
12022                reliable: true,
12023                durability: zerodds_qos::DurabilityKind::Volatile,
12024                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12025                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12026                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12027                ownership: zerodds_qos::OwnershipKind::Shared,
12028                ownership_strength: 0,
12029                partition: Vec::new(),
12030                user_data: Vec::new(),
12031                topic_data: Vec::new(),
12032                group_data: Vec::new(),
12033                type_identifier: zerodds_types::TypeIdentifier::None,
12034                data_representation_offer: None,
12035            })
12036            .unwrap();
12037
12038        // Peer protection setup: Legacy=None for lo_target,
12039        // Encrypt for wan_target.
12040        let legacy_peer: [u8; 12] = [0x01; 12];
12041        let secure_peer: [u8; 12] = [0x02; 12];
12042        {
12043            let arc = rt.writer_slot(wid).unwrap();
12044            let mut slot = arc.lock().unwrap();
12045            slot.reader_protection
12046                .insert(legacy_peer, ProtectionLevel::None);
12047            slot.reader_protection
12048                .insert(secure_peer, ProtectionLevel::Encrypt);
12049            slot.locator_to_peer.insert(lo_target, legacy_peer);
12050            slot.locator_to_peer.insert(wan_target, secure_peer);
12051        }
12052
12053        // Fiktives Datagram.
12054        let mut msg = Vec::new();
12055        msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12056        msg.extend_from_slice(&[0xF0; 12]);
12057        msg.extend_from_slice(b"DOD-ROUTING-PAYLOAD");
12058
12059        // Generate the per-target wire + route via send_on_best_interface.
12060        let plain_wire = secure_outbound_for_target(&rt, wid, &msg, &lo_target).unwrap();
12061        let secure_wire = secure_outbound_for_target(&rt, wid, &msg, &wan_target).unwrap();
12062        assert_ne!(
12063            plain_wire, msg,
12064            "lo-target under rtps_protection=ENCRYPT also SRTPS (no plaintext leak)"
12065        );
12066        assert_ne!(secure_wire, msg, "wan-target: SRTPS-wrapped");
12067
12068        send_on_best_interface(&rt, &lo_target, &plain_wire);
12069        send_on_best_interface(&rt, &wan_target, &secure_wire);
12070
12071        // sniffer receive and compare.
12072        let mut buf = [0u8; 4096];
12073        let (n1, _) = lo_sniffer.recv_from(&mut buf).expect("lo snif got");
12074        assert_ne!(
12075            &buf[..n1],
12076            &msg[..],
12077            "loopback sniffer must see SRTPS (spec wins, no plaintext on a protected domain)"
12078        );
12079        assert_eq!(buf[20], 0x33, "lo output must begin with SRTPS_PREFIX");
12080        let (n2, _) = wan_sniffer.recv_from(&mut buf).expect("wan snif got");
12081        assert_ne!(&buf[..n2], &msg[..], "WAN sniffer must see SRTPS-wrapped");
12082        // Additionally: SRTPS marker at the 20th byte (after the RTPS header).
12083        // SRTPS_PREFIX-Submessage-Id = 0x33 (Spec §7.3.6.3).
12084        assert_eq!(
12085            buf[20], 0x33,
12086            "WAN output must begin with an SRTPS_PREFIX submessage"
12087        );
12088
12089        rt.shutdown();
12090    }
12091
12092    #[cfg(feature = "security")]
12093    #[test]
12094    fn inbound_loopback_accepts_plain_on_protected_domain() {
12095        // Plan §stage 6: the inbound dispatcher should accept plaintext
12096        // for loopback packets even on a protected domain
12097        // (bytes do not leave the host). That is
12098        // exactly the `NetInterface` consultation in classify_inbound.
12099        use zerodds_security_runtime::NetInterface as SecIf;
12100        const GOV: &str = r#"
12101<domain_access_rules>
12102  <domain_rule>
12103    <domains><id>0</id></domains>
12104    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12105    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12106  </domain_rule>
12107</domain_access_rules>
12108"#;
12109        let logger = std::sync::Arc::new(CapturingLogger::default());
12110        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
12111
12112        let mut plain = Vec::new();
12113        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12114        plain.extend_from_slice(&[0x99; 12]);
12115        plain.extend_from_slice(b"loopback-plain-is-ok");
12116
12117        // Accepted on loopback — no log event.
12118        let out = secure_inbound_bytes(&rt, &plain, &SecIf::Loopback)
12119            .expect("loopback plain must be accepted");
12120        assert_eq!(out, plain);
12121        assert!(logger.events().is_empty());
12122
12123        // On WAN the same content → drop + error event.
12124        let out_wan = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
12125        assert!(out_wan.is_none());
12126        let evs = logger.events();
12127        assert_eq!(evs.len(), 1);
12128        assert_eq!(evs[0].0, zerodds_security_runtime::LogLevel::Error);
12129        assert!(
12130            evs[0].2.contains("iface=Wan"),
12131            "log message must carry iface"
12132        );
12133        rt.shutdown();
12134    }
12135
12136    #[cfg(feature = "security")]
12137    #[test]
12138    fn dod_inbound_per_interface_receive_via_pool_socket() {
12139        // Plan §stage 6 inbound DoD: each pool binding has its
12140        // own receive path, and the NetInterface class is
12141        // reflected in the log event (iface=<class>).
12142        //
12143        // Setup:
12144        //  * DcpsRuntime with 1 InterfaceBinding (kind=Loopback,
12145        //    subnet=127.0.0.0/8)
12146        //  * Protected Governance + CapturingLogger
12147        //  * We bind an external UDP socket and send two
12148        //    plain packets:
12149        //      a) to the pool socket (the event loop polls it and
12150        //         classifies as loopback → accept without log)
12151        //      b) we trigger secure_inbound_bytes directly with Wan
12152        //         → error log with iface=Wan
12153        //
12154        // This proves that the per-interface receive path
12155        // exists and the iface class flows through the decision.
12156        use std::net::{SocketAddrV4, UdpSocket};
12157        use zerodds_security_crypto::AesGcmCryptoPlugin;
12158        use zerodds_security_permissions::parse_governance_xml;
12159        use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
12160
12161        const GOV: &str = r#"
12162<domain_access_rules>
12163  <domain_rule>
12164    <domains><id>0</id></domains>
12165    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12166    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12167  </domain_rule>
12168</domain_access_rules>
12169"#;
12170        let logger = std::sync::Arc::new(CapturingLogger::default());
12171        let gate = SharedSecurityGate::new(
12172            0,
12173            parse_governance_xml(GOV).unwrap(),
12174            Box::new(AesGcmCryptoPlugin::new()),
12175        );
12176        let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
12177            std::sync::Arc::clone(&logger) as _;
12178        let bindings = vec![InterfaceBindingSpec {
12179            name: "lo".into(),
12180            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12181            bind_port: 0,
12182            kind: SecIf::Loopback,
12183            subnet: zerodds_security_runtime::IpRange {
12184                base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 0)),
12185                prefix_len: 8,
12186            },
12187            default: true,
12188        }];
12189        let cfg = RuntimeConfig {
12190            security: Some(std::sync::Arc::new(gate)),
12191            security_logger: Some(logger_dyn),
12192            interface_bindings: bindings,
12193            ..RuntimeConfig::default()
12194        };
12195        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF1; 12]), cfg).expect("rt");
12196
12197        // Read the port of the pool binding (ephemeral).
12198        let pool_port = rt.outbound_pool.as_ref().unwrap().bindings[0]
12199            .socket
12200            .local_locator()
12201            .port as u16;
12202        assert!(pool_port > 0);
12203
12204        // An external socket sends a plain packet to the pool socket.
12205        let sender = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).unwrap();
12206        let mut plain = Vec::new();
12207        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12208        plain.extend_from_slice(&[0xAB; 12]);
12209        plain.extend_from_slice(b"loopback-dispatch");
12210        sender
12211            .send_to(
12212                &plain,
12213                SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), pool_port),
12214            )
12215            .unwrap();
12216
12217        // The event loop needs a few ticks to poll the packet.
12218        // The default tick_period is 50 ms; we wait a few of them.
12219        std::thread::sleep(Duration::from_millis(300));
12220
12221        // The pool packet, through classify_inbound with iface=Loopback,
12222        // ran → accept, no log events from this path.
12223        let pool_events = logger.events();
12224
12225        // Comparison test: the same packet through secure_inbound_bytes
12226        // with iface=Wan → error event with an iface=Wan marker.
12227        let _ = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
12228        let after = logger.events();
12229        assert!(
12230            after.len() > pool_events.len(),
12231            "the Wan path must produce a new log event"
12232        );
12233        let new_ev = &after[after.len() - 1];
12234        assert_eq!(new_ev.0, zerodds_security_runtime::LogLevel::Error);
12235        assert!(
12236            new_ev.2.contains("iface=Wan"),
12237            "log message carries the iface marker: got={:?}",
12238            new_ev.2
12239        );
12240
12241        // Log events from the pool path must NOT carry the error level
12242        // (because classify_inbound returns accept on loopback).
12243        for (lvl, cat, msg) in &pool_events {
12244            assert_ne!(
12245                *lvl,
12246                zerodds_security_runtime::LogLevel::Error,
12247                "the loopback path must not produce an error event: cat={cat} msg={msg}"
12248            );
12249        }
12250        rt.shutdown();
12251    }
12252
12253    #[cfg(feature = "security")]
12254    #[test]
12255    fn per_target_without_security_gate_is_passthrough() {
12256        // Without a `security` config in RuntimeConfig, the per-target
12257        // path is a pure passthrough. Important so that we do not
12258        // break the v1.4 backward compat.
12259        let rt = DcpsRuntime::start(
12260            0,
12261            GuidPrefix::from_bytes([0xE5; 12]),
12262            RuntimeConfig::default(),
12263        )
12264        .expect("rt");
12265        let wid = rt
12266            .register_user_writer(UserWriterConfig {
12267                topic_name: "T".into(),
12268                type_name: "zerodds::RawBytes".into(),
12269                reliable: true,
12270                durability: zerodds_qos::DurabilityKind::Volatile,
12271                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12272                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12273                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12274                ownership: zerodds_qos::OwnershipKind::Shared,
12275                ownership_strength: 0,
12276                partition: Vec::new(),
12277                user_data: Vec::new(),
12278                topic_data: Vec::new(),
12279                group_data: Vec::new(),
12280                type_identifier: zerodds_types::TypeIdentifier::None,
12281                data_representation_offer: None,
12282            })
12283            .unwrap();
12284        let tgt = Locator::udp_v4([127, 0, 0, 1], 40000);
12285        let msg = b"raw-plaintext".to_vec();
12286        let out = secure_outbound_for_target(&rt, wid, &msg, &tgt).unwrap();
12287        assert_eq!(out, msg, "without a gate it must be passthrough");
12288        rt.shutdown();
12289    }
12290
12291    // ----  Builtin-Topic-Reader Discovery-Hook (DDS 1.4 §2.2.5) ----
12292
12293    /// Helper: constructs a synthetic SPDP beacon
12294    /// for a remote participant, so that `handle_spdp_datagram`
12295    /// accepts it.
12296    fn make_remote_spdp_beacon(remote_prefix: GuidPrefix) -> Vec<u8> {
12297        use zerodds_discovery::spdp::SpdpBeacon;
12298        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12299        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12300        let data = ParticipantBuiltinTopicData {
12301            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
12302            protocol_version: ProtocolVersion::V2_5,
12303            vendor_id: VendorId::ZERODDS,
12304            default_unicast_locator: None,
12305            default_multicast_locator: None,
12306            metatraffic_unicast_locator: None,
12307            metatraffic_multicast_locator: None,
12308            domain_id: Some(0),
12309            builtin_endpoint_set: 0,
12310            lease_duration: QosDuration::from_secs(100),
12311            user_data: alloc::vec::Vec::new(),
12312            properties: Default::default(),
12313            identity_token: None,
12314            permissions_token: None,
12315            identity_status_token: None,
12316            sig_algo_info: None,
12317            kx_algo_info: None,
12318            sym_cipher_algo_info: None,
12319            participant_security_info: None,
12320        };
12321        let mut beacon = SpdpBeacon::new(data);
12322        beacon.serialize().expect("serialize")
12323    }
12324
12325    #[test]
12326    fn handle_spdp_datagram_pushes_into_builtin_participant_reader() {
12327        let rt = DcpsRuntime::start(
12328            41,
12329            GuidPrefix::from_bytes([0x21; 12]),
12330            RuntimeConfig::default(),
12331        )
12332        .expect("start");
12333        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12334        rt.attach_builtin_sinks(bs.sinks());
12335
12336        let remote = GuidPrefix::from_bytes([0x99; 12]);
12337        let dg = make_remote_spdp_beacon(remote);
12338        // A direct hook call simulates an SPDP receive without multicast.
12339        handle_spdp_datagram(&rt, &dg);
12340
12341        let reader = bs
12342            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
12343                "DCPSParticipant",
12344            )
12345            .unwrap();
12346        let samples = reader.take().unwrap();
12347        assert_eq!(samples.len(), 1, "exactly 1 sample for 1 SPDP beacon");
12348        assert_eq!(samples[0].key.prefix, remote);
12349        rt.shutdown();
12350    }
12351
12352    #[test]
12353    fn handle_spdp_datagram_skips_self_beacon() {
12354        let prefix = GuidPrefix::from_bytes([0x22; 12]);
12355        let rt = DcpsRuntime::start(42, prefix, RuntimeConfig::default()).expect("start");
12356        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12357        rt.attach_builtin_sinks(bs.sinks());
12358
12359        // Beacon from our own prefix → must be ignored (Spec
12360        // §8.5.4 self-discovery filter).
12361        let dg = make_remote_spdp_beacon(prefix);
12362        handle_spdp_datagram(&rt, &dg);
12363
12364        let reader = bs
12365            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
12366                "DCPSParticipant",
12367            )
12368            .unwrap();
12369        let samples = reader.take().unwrap();
12370        assert!(samples.is_empty(), "own beacon must not be logged");
12371        rt.shutdown();
12372    }
12373
12374    #[test]
12375    fn sedp_event_push_populates_publication_and_topic_readers() {
12376        use crate::builtin_topics as bt;
12377        use zerodds_discovery::sedp::SedpEvents;
12378        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12379        let rt = DcpsRuntime::start(
12380            43,
12381            GuidPrefix::from_bytes([0x23; 12]),
12382            RuntimeConfig::default(),
12383        )
12384        .expect("start");
12385        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12386        rt.attach_builtin_sinks(bs.sinks());
12387
12388        let mut events = SedpEvents::default();
12389        events.new_publications.push(
12390            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
12391                key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
12392                participant_key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
12393                topic_name: "WireT".into(),
12394                type_name: "WireType".into(),
12395                durability: zerodds_qos::DurabilityKind::Volatile,
12396                reliability: ReliabilityQosPolicy::default(),
12397                ownership: zerodds_qos::OwnershipKind::Shared,
12398                ownership_strength: 0,
12399                liveliness: LivelinessQosPolicy::default(),
12400                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12401                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12402                partition: Vec::new(),
12403                user_data: Vec::new(),
12404                topic_data: Vec::new(),
12405                group_data: Vec::new(),
12406                type_information: None,
12407                data_representation: Vec::new(),
12408                security_info: None,
12409                service_instance_name: None,
12410                related_entity_guid: None,
12411                topic_aliases: None,
12412                type_identifier: zerodds_types::TypeIdentifier::None,
12413                unicast_locators: Vec::new(),
12414                multicast_locators: Vec::new(),
12415            },
12416        );
12417
12418        push_sedp_events_to_builtin_readers(&rt, &events);
12419
12420        let pub_reader = bs
12421            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
12422            .unwrap();
12423        let pub_samples = pub_reader.take().unwrap();
12424        assert_eq!(pub_samples.len(), 1);
12425        assert_eq!(pub_samples[0].topic_name, "WireT");
12426
12427        let topic_reader = bs
12428            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12429            .unwrap();
12430        let topic_samples = topic_reader.take().unwrap();
12431        assert_eq!(topic_samples.len(), 1);
12432        assert_eq!(topic_samples[0].name, "WireT");
12433        rt.shutdown();
12434    }
12435
12436    #[test]
12437    fn sedp_event_push_populates_subscription_reader() {
12438        use crate::builtin_topics as bt;
12439        use zerodds_discovery::sedp::SedpEvents;
12440        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12441        let rt = DcpsRuntime::start(
12442            44,
12443            GuidPrefix::from_bytes([0x24; 12]),
12444            RuntimeConfig::default(),
12445        )
12446        .expect("start");
12447        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12448        rt.attach_builtin_sinks(bs.sinks());
12449
12450        let mut events = SedpEvents::default();
12451        events.new_subscriptions.push(
12452            zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
12453                key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
12454                participant_key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
12455                topic_name: "SubT".into(),
12456                type_name: "SubType".into(),
12457                durability: zerodds_qos::DurabilityKind::Volatile,
12458                reliability: ReliabilityQosPolicy::default(),
12459                ownership: zerodds_qos::OwnershipKind::Shared,
12460                liveliness: LivelinessQosPolicy::default(),
12461                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12462                partition: Vec::new(),
12463                user_data: Vec::new(),
12464                topic_data: Vec::new(),
12465                group_data: Vec::new(),
12466                type_information: None,
12467                data_representation: Vec::new(),
12468                content_filter: None,
12469                security_info: None,
12470                service_instance_name: None,
12471                related_entity_guid: None,
12472                topic_aliases: None,
12473                type_identifier: zerodds_types::TypeIdentifier::None,
12474                unicast_locators: Vec::new(),
12475                multicast_locators: Vec::new(),
12476            },
12477        );
12478
12479        push_sedp_events_to_builtin_readers(&rt, &events);
12480
12481        let sub_reader = bs
12482            .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
12483            .unwrap();
12484        let sub_samples = sub_reader.take().unwrap();
12485        assert_eq!(sub_samples.len(), 1);
12486        assert_eq!(sub_samples[0].topic_name, "SubT");
12487
12488        // The topic reader gets a synthetic topic sample also from
12489        // Subscription.
12490        let topic_reader = bs
12491            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12492            .unwrap();
12493        let topic_samples = topic_reader.take().unwrap();
12494        assert_eq!(topic_samples.len(), 1);
12495        assert_eq!(topic_samples[0].name, "SubT");
12496        rt.shutdown();
12497    }
12498
12499    #[test]
12500    fn push_sedp_events_to_builtin_readers_is_noop_without_sinks() {
12501        use zerodds_discovery::sedp::SedpEvents;
12502        let rt = DcpsRuntime::start(
12503            45,
12504            GuidPrefix::from_bytes([0x25; 12]),
12505            RuntimeConfig::default(),
12506        )
12507        .expect("start");
12508        // No attach_builtin_sinks → push must stay silent, not
12509        // panic.
12510        let events = SedpEvents::default();
12511        push_sedp_events_to_builtin_readers(&rt, &events);
12512        rt.shutdown();
12513    }
12514
12515    // ----  Ignore-Filter im Discovery-Hot-Path -------------
12516
12517    #[test]
12518    fn handle_spdp_datagram_drops_ignored_participant_beacon() {
12519        // Spec §2.2.2.2.1.14: ein einmal ignorierter Participant
12520        // taucht in keinem nachfolgenden Builtin-Sample mehr auf.
12521        let rt = DcpsRuntime::start(
12522            46,
12523            GuidPrefix::from_bytes([0x26; 12]),
12524            RuntimeConfig::default(),
12525        )
12526        .expect("start");
12527        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12528        rt.attach_builtin_sinks(bs.sinks());
12529        let filter = crate::participant::IgnoreFilter::default();
12530        rt.attach_ignore_filter(filter.clone());
12531
12532        let remote = GuidPrefix::from_bytes([0xAA; 12]);
12533        // Derive the ignore handle from the future beacon — we
12534        // know that the builtin sample key is the GUID of the remote
12535        // participant (=prefix + EntityId::PARTICIPANT).
12536        let key = Guid::new(remote, EntityId::PARTICIPANT);
12537        let h = crate::instance_handle::InstanceHandle::from_guid(key);
12538        if let Ok(mut s) = filter.inner.participants.lock() {
12539            s.insert(h);
12540        }
12541        let dg = make_remote_spdp_beacon(remote);
12542        handle_spdp_datagram(&rt, &dg);
12543
12544        let reader = bs
12545            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
12546                "DCPSParticipant",
12547            )
12548            .unwrap();
12549        assert!(
12550            reader.take().unwrap().is_empty(),
12551            "an ignored participant must not land in DCPSParticipant"
12552        );
12553        rt.shutdown();
12554    }
12555
12556    #[test]
12557    fn sedp_event_push_filters_ignored_publication() {
12558        use crate::builtin_topics as bt;
12559        use zerodds_discovery::sedp::SedpEvents;
12560        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12561        let rt = DcpsRuntime::start(
12562            47,
12563            GuidPrefix::from_bytes([0x27; 12]),
12564            RuntimeConfig::default(),
12565        )
12566        .expect("start");
12567        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12568        rt.attach_builtin_sinks(bs.sinks());
12569        let filter = crate::participant::IgnoreFilter::default();
12570        rt.attach_ignore_filter(filter.clone());
12571
12572        let pub_key = Guid::new(GuidPrefix::from_bytes([0x33; 12]), EntityId::PARTICIPANT);
12573        let h_pub = crate::instance_handle::InstanceHandle::from_guid(pub_key);
12574        if let Ok(mut s) = filter.inner.publications.lock() {
12575            s.insert(h_pub);
12576        }
12577
12578        let mut events = SedpEvents::default();
12579        events.new_publications.push(
12580            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
12581                key: pub_key,
12582                participant_key: Guid::new(
12583                    GuidPrefix::from_bytes([0x33; 12]),
12584                    EntityId::PARTICIPANT,
12585                ),
12586                topic_name: "Filtered".into(),
12587                type_name: "T".into(),
12588                durability: zerodds_qos::DurabilityKind::Volatile,
12589                reliability: ReliabilityQosPolicy::default(),
12590                ownership: zerodds_qos::OwnershipKind::Shared,
12591                ownership_strength: 0,
12592                liveliness: LivelinessQosPolicy::default(),
12593                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12594                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12595                partition: Vec::new(),
12596                user_data: Vec::new(),
12597                topic_data: Vec::new(),
12598                group_data: Vec::new(),
12599                type_information: None,
12600                data_representation: Vec::new(),
12601                security_info: None,
12602                service_instance_name: None,
12603                related_entity_guid: None,
12604                topic_aliases: None,
12605                type_identifier: zerodds_types::TypeIdentifier::None,
12606                unicast_locators: Vec::new(),
12607                multicast_locators: Vec::new(),
12608            },
12609        );
12610
12611        push_sedp_events_to_builtin_readers(&rt, &events);
12612
12613        let pub_reader = bs
12614            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
12615            .unwrap();
12616        assert!(
12617            pub_reader.take().unwrap().is_empty(),
12618            "an ignored publication must not land in DCPSPublication"
12619        );
12620        // The synthetic DCPSTopic sample too must not be
12621        // forwarded, because the publication is completely
12622        // discarded.
12623        let topic_reader = bs
12624            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12625            .unwrap();
12626        assert!(topic_reader.take().unwrap().is_empty());
12627        rt.shutdown();
12628    }
12629
12630    #[test]
12631    fn sedp_event_push_filters_ignored_subscription() {
12632        use crate::builtin_topics as bt;
12633        use zerodds_discovery::sedp::SedpEvents;
12634        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12635        let rt = DcpsRuntime::start(
12636            48,
12637            GuidPrefix::from_bytes([0x28; 12]),
12638            RuntimeConfig::default(),
12639        )
12640        .expect("start");
12641        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12642        rt.attach_builtin_sinks(bs.sinks());
12643        let filter = crate::participant::IgnoreFilter::default();
12644        rt.attach_ignore_filter(filter.clone());
12645
12646        let sub_key = Guid::new(GuidPrefix::from_bytes([0x44; 12]), EntityId::PARTICIPANT);
12647        let h_sub = crate::instance_handle::InstanceHandle::from_guid(sub_key);
12648        if let Ok(mut s) = filter.inner.subscriptions.lock() {
12649            s.insert(h_sub);
12650        }
12651
12652        let mut events = SedpEvents::default();
12653        events.new_subscriptions.push(
12654            zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
12655                key: sub_key,
12656                participant_key: Guid::new(
12657                    GuidPrefix::from_bytes([0x44; 12]),
12658                    EntityId::PARTICIPANT,
12659                ),
12660                topic_name: "FilteredSub".into(),
12661                type_name: "T".into(),
12662                durability: zerodds_qos::DurabilityKind::Volatile,
12663                reliability: ReliabilityQosPolicy::default(),
12664                ownership: zerodds_qos::OwnershipKind::Shared,
12665                liveliness: LivelinessQosPolicy::default(),
12666                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12667                partition: Vec::new(),
12668                user_data: Vec::new(),
12669                topic_data: Vec::new(),
12670                group_data: Vec::new(),
12671                type_information: None,
12672                data_representation: Vec::new(),
12673                content_filter: None,
12674                security_info: None,
12675                service_instance_name: None,
12676                related_entity_guid: None,
12677                topic_aliases: None,
12678                type_identifier: zerodds_types::TypeIdentifier::None,
12679                unicast_locators: Vec::new(),
12680                multicast_locators: Vec::new(),
12681            },
12682        );
12683
12684        push_sedp_events_to_builtin_readers(&rt, &events);
12685
12686        let sub_reader = bs
12687            .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
12688            .unwrap();
12689        assert!(sub_reader.take().unwrap().is_empty());
12690        rt.shutdown();
12691    }
12692
12693    #[test]
12694    fn sedp_event_push_filters_ignored_topic_only() {
12695        // If only the topic is ignored, DCPSPublication should
12696        // still be pushed — only the DCPSTopic sample falls
12697        // away.
12698        use crate::builtin_topics as bt;
12699        use zerodds_discovery::sedp::SedpEvents;
12700        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12701        let rt = DcpsRuntime::start(
12702            49,
12703            GuidPrefix::from_bytes([0x29; 12]),
12704            RuntimeConfig::default(),
12705        )
12706        .expect("start");
12707        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12708        rt.attach_builtin_sinks(bs.sinks());
12709        let filter = crate::participant::IgnoreFilter::default();
12710        rt.attach_ignore_filter(filter.clone());
12711
12712        let topic_key =
12713            crate::builtin_topics::TopicBuiltinTopicData::synthesize_key("OnlyTopic", "T");
12714        let h_topic = crate::instance_handle::InstanceHandle::from_guid(topic_key);
12715        if let Ok(mut s) = filter.inner.topics.lock() {
12716            s.insert(h_topic);
12717        }
12718
12719        let mut events = SedpEvents::default();
12720        events.new_publications.push(
12721            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
12722                key: Guid::new(GuidPrefix::from_bytes([0x55; 12]), EntityId::PARTICIPANT),
12723                participant_key: Guid::new(
12724                    GuidPrefix::from_bytes([0x55; 12]),
12725                    EntityId::PARTICIPANT,
12726                ),
12727                topic_name: "OnlyTopic".into(),
12728                type_name: "T".into(),
12729                durability: zerodds_qos::DurabilityKind::Volatile,
12730                reliability: ReliabilityQosPolicy::default(),
12731                ownership: zerodds_qos::OwnershipKind::Shared,
12732                ownership_strength: 0,
12733                liveliness: LivelinessQosPolicy::default(),
12734                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12735                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12736                partition: Vec::new(),
12737                user_data: Vec::new(),
12738                topic_data: Vec::new(),
12739                group_data: Vec::new(),
12740                type_information: None,
12741                data_representation: Vec::new(),
12742                security_info: None,
12743                service_instance_name: None,
12744                related_entity_guid: None,
12745                topic_aliases: None,
12746                type_identifier: zerodds_types::TypeIdentifier::None,
12747                unicast_locators: Vec::new(),
12748                multicast_locators: Vec::new(),
12749            },
12750        );
12751
12752        push_sedp_events_to_builtin_readers(&rt, &events);
12753
12754        let pub_reader = bs
12755            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
12756            .unwrap();
12757        assert_eq!(pub_reader.take().unwrap().len(), 1);
12758        let topic_reader = bs
12759            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12760            .unwrap();
12761        assert!(
12762            topic_reader.take().unwrap().is_empty(),
12763            "an ignored topic may block the synthetic DCPSTopic sample"
12764        );
12765        rt.shutdown();
12766    }
12767
12768    // -------- Security-Builtin-Endpoint-Wiring --------
12769
12770    /// Creates an SPDP beacon with configurable BuiltinEndpoint
12771    /// bits. Extension of [`make_remote_spdp_beacon`] with
12772    /// flag-Argument (Security-Bits 22..25).
12773    fn make_remote_spdp_beacon_with_flags(remote_prefix: GuidPrefix, endpoint_set: u32) -> Vec<u8> {
12774        use zerodds_discovery::spdp::SpdpBeacon;
12775        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12776        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12777        let data = ParticipantBuiltinTopicData {
12778            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
12779            protocol_version: ProtocolVersion::V2_5,
12780            vendor_id: VendorId::ZERODDS,
12781            default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
12782            default_multicast_locator: None,
12783            metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
12784            metatraffic_multicast_locator: None,
12785            domain_id: Some(0),
12786            builtin_endpoint_set: endpoint_set,
12787            lease_duration: QosDuration::from_secs(100),
12788            user_data: alloc::vec::Vec::new(),
12789            properties: Default::default(),
12790            identity_token: None,
12791            permissions_token: None,
12792            identity_status_token: None,
12793            sig_algo_info: None,
12794            kx_algo_info: None,
12795            sym_cipher_algo_info: None,
12796            participant_security_info: None,
12797        };
12798        let mut beacon = SpdpBeacon::new(data);
12799        beacon.serialize().expect("serialize")
12800    }
12801
12802    fn dp_with_locators(
12803        prefix: GuidPrefix,
12804        metatraffic: Option<Locator>,
12805        default: Option<Locator>,
12806    ) -> zerodds_discovery::spdp::DiscoveredParticipant {
12807        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12808        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12809        zerodds_discovery::spdp::DiscoveredParticipant {
12810            sender_prefix: prefix,
12811            sender_vendor: VendorId::ZERODDS,
12812            data: ParticipantBuiltinTopicData {
12813                guid: Guid::new(prefix, EntityId::PARTICIPANT),
12814                protocol_version: ProtocolVersion::V2_5,
12815                vendor_id: VendorId::ZERODDS,
12816                default_unicast_locator: default,
12817                default_multicast_locator: None,
12818                metatraffic_unicast_locator: metatraffic,
12819                metatraffic_multicast_locator: None,
12820                domain_id: Some(0),
12821                builtin_endpoint_set: 0,
12822                lease_duration: QosDuration::from_secs(100),
12823                user_data: alloc::vec::Vec::new(),
12824                properties: Default::default(),
12825                identity_token: None,
12826                permissions_token: None,
12827                identity_status_token: None,
12828                sig_algo_info: None,
12829                kx_algo_info: None,
12830                sym_cipher_algo_info: None,
12831                participant_security_info: None,
12832            },
12833        }
12834    }
12835
12836    #[test]
12837    fn wlp_unicast_targets_prefers_metatraffic_then_default() {
12838        // M-2: WLP-Unicast-Fan-out waehlt pro Peer metatraffic_unicast (bevorzugt),
12839        // otherwise default_unicast; peers without a routable locator fall out.
12840        let meta = Locator::udp_v4([127, 0, 0, 1], 7501);
12841        let deflt = Locator::udp_v4([127, 0, 0, 2], 7500);
12842        let peers = alloc::vec![
12843            // (a) has metatraffic → metatraffic wins
12844            dp_with_locators(GuidPrefix::from_bytes([1; 12]), Some(meta), Some(deflt)),
12845            // (b) only default → default
12846            dp_with_locators(GuidPrefix::from_bytes([2; 12]), None, Some(deflt)),
12847            // (c) none at all → no target
12848            dp_with_locators(GuidPrefix::from_bytes([3; 12]), None, None),
12849        ];
12850        let targets = wlp_unicast_targets(&peers);
12851        assert_eq!(targets, alloc::vec![meta, deflt]);
12852    }
12853
12854    /// Like [`make_remote_spdp_beacon_with_flags`], but with a set
12855    /// `identity_token` (FU2 Gap 7d — triggers the auth handshake).
12856    #[cfg(feature = "security")]
12857    fn make_secure_beacon_with_identity_token(
12858        remote_prefix: GuidPrefix,
12859        endpoint_set: u32,
12860        identity_token: Vec<u8>,
12861    ) -> Vec<u8> {
12862        use zerodds_discovery::spdp::SpdpBeacon;
12863        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12864        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12865        let data = ParticipantBuiltinTopicData {
12866            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
12867            protocol_version: ProtocolVersion::V2_5,
12868            vendor_id: VendorId::ZERODDS,
12869            default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
12870            default_multicast_locator: None,
12871            metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
12872            metatraffic_multicast_locator: None,
12873            domain_id: Some(0),
12874            builtin_endpoint_set: endpoint_set,
12875            lease_duration: QosDuration::from_secs(100),
12876            user_data: alloc::vec::Vec::new(),
12877            properties: Default::default(),
12878            identity_token: Some(identity_token),
12879            permissions_token: None,
12880            identity_status_token: None,
12881            sig_algo_info: None,
12882            kx_algo_info: None,
12883            sym_cipher_algo_info: None,
12884            participant_security_info: None,
12885        };
12886        let mut beacon = SpdpBeacon::new(data);
12887        beacon.serialize().expect("serialize")
12888    }
12889
12890    /// Minimal auth plugin for the FU2 wiring tests (Gap 4/7).
12891    /// Crypto correctness is verified in the stack.rs driver test; here
12892    /// it is only about the runtime wiring path.
12893    #[cfg(feature = "security")]
12894    struct FakeAuth;
12895    #[cfg(feature = "security")]
12896    impl zerodds_security::authentication::AuthenticationPlugin for FakeAuth {
12897        fn validate_local_identity(
12898            &mut self,
12899            _: &zerodds_security::properties::PropertyList,
12900            _: [u8; 16],
12901        ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
12902        {
12903            Ok(zerodds_security::authentication::IdentityHandle(1))
12904        }
12905        fn validate_remote_identity(
12906            &mut self,
12907            _: zerodds_security::authentication::IdentityHandle,
12908            _: [u8; 16],
12909            _: &[u8],
12910        ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
12911        {
12912            Ok(zerodds_security::authentication::IdentityHandle(2))
12913        }
12914        fn begin_handshake_request(
12915            &mut self,
12916            _: zerodds_security::authentication::IdentityHandle,
12917            _: zerodds_security::authentication::IdentityHandle,
12918        ) -> zerodds_security::error::SecurityResult<(
12919            zerodds_security::authentication::HandshakeHandle,
12920            zerodds_security::authentication::HandshakeStepOutcome,
12921        )> {
12922            Ok((
12923                zerodds_security::authentication::HandshakeHandle(1),
12924                zerodds_security::authentication::HandshakeStepOutcome::SendMessage {
12925                    token: zerodds_security::token::DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")
12926                        .to_cdr_le(),
12927                },
12928            ))
12929        }
12930        fn begin_handshake_reply(
12931            &mut self,
12932            _: zerodds_security::authentication::IdentityHandle,
12933            _: zerodds_security::authentication::IdentityHandle,
12934            _: &[u8],
12935        ) -> zerodds_security::error::SecurityResult<(
12936            zerodds_security::authentication::HandshakeHandle,
12937            zerodds_security::authentication::HandshakeStepOutcome,
12938        )> {
12939            Ok((
12940                zerodds_security::authentication::HandshakeHandle(2),
12941                zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer,
12942            ))
12943        }
12944        fn process_handshake(
12945            &mut self,
12946            _: zerodds_security::authentication::HandshakeHandle,
12947            _: &[u8],
12948        ) -> zerodds_security::error::SecurityResult<
12949            zerodds_security::authentication::HandshakeStepOutcome,
12950        > {
12951            Ok(zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer)
12952        }
12953        fn shared_secret(
12954            &self,
12955            _: zerodds_security::authentication::HandshakeHandle,
12956        ) -> zerodds_security::error::SecurityResult<
12957            zerodds_security::authentication::SharedSecretHandle,
12958        > {
12959            Err(zerodds_security::error::SecurityError::new(
12960                zerodds_security::error::SecurityErrorKind::BadArgument,
12961                "fake: handshake not complete",
12962            ))
12963        }
12964        fn plugin_class_id(&self) -> &str {
12965            "FAKE:Auth:1.0"
12966        }
12967        fn get_identity_token(
12968            &self,
12969            _: zerodds_security::authentication::IdentityHandle,
12970        ) -> zerodds_security::error::SecurityResult<Vec<u8>> {
12971            // Non-empty Token (Format irrelevant — FakeAuth.validate_remote_
12972            // identity accepts everything); only so the beacon-populate path
12973            // (Gap 7c) has something to announce.
12974            Ok(alloc::vec![0xAB, 0xCD, 0xEF, 0x01])
12975        }
12976        fn get_permissions_token(&self) -> Vec<u8> {
12977            // Non-empty PermissionsToken, so the beacon-populate path
12978            // (S4 point 1) has something to announce (format irrelevant).
12979            zerodds_security::token::DataHolder::new("DDS:Access:Permissions:1.0").to_cdr_le()
12980        }
12981    }
12982
12983    /// Consolidated test for the wiring. A single
12984    /// runtime walks all paths — snapshot API, idempotency of
12985    /// `enable_security_builtins`, SPDP hot path with security bits,
12986    /// without bits, plus the wire-demux hook. We bundle this into one
12987    /// test body, because each `DcpsRuntime::start` binds a multicast socket
12988    /// and parallel tests could brush against the OS resource caps.
12989    #[test]
12990    fn c34c_security_builtin_wiring_end_to_end() {
12991        use zerodds_discovery::security::SecurityBuiltinStack;
12992        use zerodds_security::generic_message::{
12993            MessageIdentity, ParticipantGenericMessage, class_id,
12994        };
12995        use zerodds_security::token::DataHolder;
12996
12997        let local_prefix = GuidPrefix::from_bytes([0x75; 12]);
12998        let rt = DcpsRuntime::start(75, local_prefix, RuntimeConfig::default()).expect("start");
12999
13000        // 1. Snapshot is None before enable
13001        assert!(rt.security_builtin_snapshot().is_none());
13002
13003        // 2. enable ist idempotent
13004        let h1 = rt.enable_security_builtins(VendorId::ZERODDS);
13005        let h2 = rt.enable_security_builtins(VendorId::ZERODDS);
13006        assert!(Arc::ptr_eq(&h1, &h2));
13007        assert!(rt.security_builtin_snapshot().is_some());
13008
13009        // 3. SPDP beacon with all security-builtin bits → the stack has
13010        //    four proxies
13011        let remote_a = GuidPrefix::from_bytes([0x99; 12]);
13012        let flags_all = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13013            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
13014            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
13015            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
13016        handle_spdp_datagram(
13017            &rt,
13018            &make_remote_spdp_beacon_with_flags(remote_a, flags_all),
13019        );
13020        {
13021            let s = h1.lock().unwrap();
13022            assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
13023            assert_eq!(s.stateless_reader.writer_proxy_count(), 1);
13024            assert_eq!(s.volatile_writer.reader_proxy_count(), 1);
13025            assert_eq!(s.volatile_reader.writer_proxy_count(), 1);
13026        }
13027
13028        // 4. SPDP beacon without security bits → the stack stays unchanged
13029        let remote_b = GuidPrefix::from_bytes([0x88; 12]);
13030        handle_spdp_datagram(
13031            &rt,
13032            &make_remote_spdp_beacon_with_flags(remote_b, endpoint_flag::ALL_STANDARD),
13033        );
13034        {
13035            let s = h1.lock().unwrap();
13036            assert_eq!(
13037                s.stateless_writer.reader_proxy_count(),
13038                1,
13039                "a peer without security bits must not touch existing proxies"
13040            );
13041        }
13042
13043        // 5. Wire-demux hook with a valid stateless DATA: remote-stack
13044        //    mirror sends a message → the demux hook routes it through
13045        //    the local reader without panic.
13046        let mut remote_stack = SecurityBuiltinStack::new(remote_a, VendorId::ZERODDS);
13047        let local_peer = make_remote_spdp_beacon_with_flags(local_prefix, flags_all);
13048        let parsed_local = zerodds_discovery::spdp::SpdpReader::new()
13049            .parse_datagram(&local_peer)
13050            .unwrap();
13051        remote_stack.handle_remote_endpoints(&parsed_local);
13052        let msg = ParticipantGenericMessage {
13053            message_identity: MessageIdentity {
13054                source_guid: [0xCD; 16],
13055                sequence_number: 1,
13056            },
13057            related_message_identity: MessageIdentity::default(),
13058            destination_participant_key: [0xEF; 16],
13059            destination_endpoint_key: [0; 16],
13060            source_endpoint_key: [0xFE; 16],
13061            message_class_id: class_id::AUTH_REQUEST.into(),
13062            message_data: alloc::vec![DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")],
13063        };
13064        let dgs = remote_stack.stateless_writer.write(&msg).unwrap();
13065        assert_eq!(dgs.len(), 1);
13066        dispatch_security_builtin_datagram(&rt, &dgs[0].bytes, Duration::from_secs(1));
13067
13068        // 6. The demux hook does not panic on garbage bytes
13069        dispatch_security_builtin_datagram(&rt, &[0u8; 32], Duration::from_secs(1));
13070
13071        rt.shutdown();
13072    }
13073
13074    /// FU2 Gap 4: `enable_security_builtins_with_auth` builds the stack with
13075    /// an active handshake driver — `begin_handshake_with` sends, as
13076    /// the initiator actually sends an AUTH_REQUEST (instead of a no-op like with
13077    /// the auth-less `enable_security_builtins`).
13078    #[cfg(feature = "security")]
13079    #[test]
13080    fn enable_security_builtins_with_auth_activates_handshake_driver() {
13081        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
13082
13083        let local_prefix = GuidPrefix::from_bytes([0x40; 12]);
13084        let rt = DcpsRuntime::start(40, local_prefix, RuntimeConfig::default()).expect("start");
13085
13086        let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13087        let stack =
13088            rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
13089
13090        // Discover a peer with stateless bits (WITHOUT identity_token → the
13091        // discovery trigger starts no handshake yet) → proxies
13092        // are wired. The remote prefix is LARGER than local ([0x40]),
13093        // so that local is the initiator under the cyclone convention (smaller GUID
13094        // initiates) and actually sends.
13095        let remote = GuidPrefix::from_bytes([0x99; 12]);
13096        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13097            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13098        handle_spdp_datagram(&rt, &make_remote_spdp_beacon_with_flags(remote, flags));
13099
13100        let dgs = {
13101            let mut s = stack.lock().unwrap();
13102            let remote_guid = Guid::new(remote, EntityId::PARTICIPANT).to_bytes();
13103            s.begin_handshake_with(remote, remote_guid, b"fake-remote-cert-der")
13104                .expect("begin_handshake_with")
13105        };
13106        assert_eq!(
13107            dgs.len(),
13108            1,
13109            "auth driver active → the initiator sends exactly one AUTH_REQUEST"
13110        );
13111
13112        rt.shutdown();
13113    }
13114
13115    /// FU2 Gap 7c/d: `enable_security_builtins_with_auth` announces the
13116    /// local `identity_token` in the SPDP beacon (+ stateless/volatile bits),
13117    /// and an incoming peer beacon WITH an `identity_token` kicks off the
13118    /// Auth-Handshake an (Discovery-Trigger).
13119    #[cfg(feature = "security")]
13120    #[test]
13121    fn spdp_beacon_announces_identity_token_and_discovery_triggers_handshake() {
13122        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
13123
13124        let local_prefix = GuidPrefix::from_bytes([0x41; 12]);
13125        let rt = DcpsRuntime::start(41, local_prefix, RuntimeConfig::default()).expect("start");
13126        let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13127        let stack =
13128            rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
13129
13130        // Gap 7c: the beacon now announces identity_token + secure bits.
13131        let beacon_bytes = rt.spdp_beacon.lock().unwrap().serialize().unwrap();
13132        let parsed = zerodds_discovery::spdp::SpdpReader::new()
13133            .parse_datagram(&beacon_bytes)
13134            .unwrap();
13135        assert!(
13136            parsed.data.identity_token.is_some(),
13137            "the beacon must announce PID_IDENTITY_TOKEN"
13138        );
13139        // Cross-vendor: secure vendors validate a remote only when
13140        // SPDP carries **both** tokens. Without PID_PERMISSIONS_TOKEN they treat
13141        // cyclone treats us as non-secure and never starts validate_remote_identity.
13142        assert!(
13143            parsed.data.permissions_token.is_some(),
13144            "the beacon must announce PID_PERMISSIONS_TOKEN (cross-vendor mandatory)"
13145        );
13146        assert_ne!(
13147            parsed.data.builtin_endpoint_set & endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER,
13148            0,
13149            "the beacon must announce the stateless-auth bit"
13150        );
13151
13152        // Gap 7d: peer beacon WITH identity_token + stateless bits → the
13153        // discovery path kicks off begin_handshake_with.
13154        let remote = GuidPrefix::from_bytes([0x99; 12]);
13155        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13156            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13157        let peer_beacon =
13158            make_secure_beacon_with_identity_token(remote, flags, alloc::vec![0x11, 0x22, 0x33]);
13159        handle_spdp_datagram(&rt, &peer_beacon);
13160
13161        // Proof that the discovery trigger fired: the peer is now
13162        // registered in the stack's handshake state. (The earlier length
13163        // probe via a repeated begin_handshake_with no longer applies since the resend path
13164        // resends as the initiator on a repeated call.)
13165        let started = {
13166            let s = stack.lock().unwrap();
13167            s.handshake_peer_count()
13168        };
13169        assert_eq!(
13170            started, 1,
13171            "the discovery trigger must have started the handshake (peer registered)"
13172        );
13173
13174        rt.shutdown();
13175    }
13176
13177    /// FU2 S3: two secure runtimes in the same process MUST find each other via
13178    /// in-process participant discovery and kick off the auth handshake
13179    /// — WITHOUT a single multicast beacon. That was exactly missing:
13180    /// `inproc_inject_publication`/`_subscription` inject only SEDP, the
13181    /// SPDP participant discovery (identity_token + `begin_handshake_with`)
13182    /// ran exclusively over the flaky multicast path.
13183    #[cfg(feature = "security")]
13184    #[test]
13185    fn inproc_participant_discovery_triggers_handshake_without_multicast() {
13186        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
13187
13188        let a_prefix = GuidPrefix::from_bytes([0x4A; 12]);
13189        let b_prefix = GuidPrefix::from_bytes([0x4B; 12]);
13190        let rt_a = DcpsRuntime::start(47, a_prefix, RuntimeConfig::default()).expect("start a");
13191        let rt_b = DcpsRuntime::start(47, b_prefix, RuntimeConfig::default()).expect("start b");
13192        let auth_a: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13193        let auth_b: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13194        let stack_a =
13195            rt_a.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_a, IdentityHandle(1));
13196        let stack_b =
13197            rt_b.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_b, IdentityHandle(1));
13198
13199        // KEIN handle_spdp_datagram / Multicast — rein in-process.
13200        let a_peers = stack_a.lock().unwrap().handshake_peer_count();
13201        let b_peers = stack_b.lock().unwrap().handshake_peer_count();
13202        assert!(
13203            a_peers >= 1,
13204            "A must have discovered B in-process + started the handshake (got {a_peers})"
13205        );
13206        assert!(
13207            b_peers >= 1,
13208            "B must have discovered A in-process + started the handshake (got {b_peers})"
13209        );
13210
13211        rt_a.shutdown();
13212        rt_b.shutdown();
13213    }
13214
13215    /// Mints a shared CA + two leaf identities (PEM) for the
13216    /// FU2-Handshake-e2e-Test.
13217    #[cfg(feature = "security")]
13218    #[allow(clippy::type_complexity)]
13219    fn mint_handshake_identities() -> ((Vec<u8>, Vec<u8>), (Vec<u8>, Vec<u8>)) {
13220        use rcgen::{CertificateParams, KeyPair};
13221        let mut ca_params =
13222            CertificateParams::new(alloc::vec![alloc::string::String::from("FU2 CA")]).unwrap();
13223        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
13224        let ca_key = KeyPair::generate().unwrap();
13225        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
13226        let ca_pem = ca_cert.pem().into_bytes();
13227        let mint = |name: &str| -> (Vec<u8>, Vec<u8>) {
13228            let mut p =
13229                CertificateParams::new(alloc::vec![alloc::string::String::from(name)]).unwrap();
13230            p.is_ca = rcgen::IsCa::NoCa;
13231            let k = KeyPair::generate().unwrap();
13232            let c = p.signed_by(&k, &ca_cert, &ca_key).unwrap();
13233            (c.pem().into_bytes(), k.serialize_pem().into_bytes())
13234        };
13235        let alice = {
13236            let (cert, key) = mint("alice");
13237            (cert, key)
13238        };
13239        let bob = {
13240            let (cert, key) = mint("bob");
13241            (cert, key)
13242        };
13243        // attach ca_pem to both, so the caller has the trust anchor.
13244        (
13245            ([alice.0, b"\n".to_vec(), ca_pem.clone()].concat(), alice.1),
13246            ([bob.0, b"\n".to_vec(), ca_pem].concat(), bob.1),
13247        )
13248    }
13249
13250    /// FU2 Gap 5 (e2e): a runtime replier (A) and an in-test initiator
13251    /// stack (B) complete a real PKI 3-round handshake via the dispatch path
13252    /// and BOTH derive the same SharedSecret.
13253    /// Verifies the dispatch wiring (`on_stateless_message` →
13254    /// reply/final → completion) in the real runtime context.
13255    #[cfg(feature = "security")]
13256    #[test]
13257    fn handshake_completes_through_runtime_dispatch_e2e() {
13258        use zerodds_discovery::security::SecurityBuiltinStack;
13259        use zerodds_security::authentication::AuthenticationPlugin;
13260        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
13261
13262        // cert_pem here contains Leaf || CA (mint_handshake_identities),
13263        // identity_ca_pem = the same bundle (CA is included).
13264        let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
13265
13266        // A = Runtime (Replier, HOEHERER Prefix). B = in-test Stack
13267        // (initiator, LOWER prefix) — cyclone convention: smaller
13268        // GUID initiiert.
13269        let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
13270        let b_prefix = GuidPrefix::from_bytes([0x10; 12]);
13271        let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
13272        let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
13273
13274        // --- A: runtime with a real PKI plugin ---
13275        let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13276        let a_local = a_pki
13277            .lock()
13278            .unwrap()
13279            .validate_with_config(
13280                IdentityConfig {
13281                    identity_cert_pem: a_cert.clone(),
13282                    identity_ca_pem: a_cert.clone(),
13283                    identity_key_pem: Some(a_key),
13284                },
13285                a_guid,
13286            )
13287            .unwrap();
13288        let a_token = a_pki.lock().unwrap().get_identity_token(a_local).unwrap();
13289        let rt = DcpsRuntime::start(42, a_prefix, RuntimeConfig::default()).expect("start");
13290        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
13291        let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
13292
13293        // --- B: in-test initiator stack with a real PKI plugin ---
13294        let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13295        let b_local = b_pki
13296            .lock()
13297            .unwrap()
13298            .validate_with_config(
13299                IdentityConfig {
13300                    identity_cert_pem: b_cert.clone(),
13301                    identity_ca_pem: b_cert.clone(),
13302                    identity_key_pem: Some(b_key),
13303                },
13304                b_guid,
13305            )
13306            .unwrap();
13307        let b_token = b_pki.lock().unwrap().get_identity_token(b_local).unwrap();
13308        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
13309        let mut b_stack =
13310            SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
13311
13312        let stateless = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13313            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13314
13315        // B discovers A (wired proxies) — via the parsed A beacon.
13316        let a_beacon = make_secure_beacon_with_identity_token(a_prefix, stateless, a_token.clone());
13317        let a_parsed = zerodds_discovery::spdp::SpdpReader::new()
13318            .parse_datagram(&a_beacon)
13319            .unwrap();
13320        b_stack.handle_remote_endpoints(&a_parsed);
13321
13322        // A discovers B → the discovery trigger creates A's peer state (A is
13323        // the replier, sends nothing).
13324        let b_beacon = make_secure_beacon_with_identity_token(b_prefix, stateless, b_token);
13325        handle_spdp_datagram(&rt, &b_beacon);
13326
13327        // B (initiator) starts → AUTH_REQUEST.
13328        let req = b_stack
13329            .begin_handshake_with(a_prefix, a_guid, &a_token)
13330            .unwrap();
13331        assert_eq!(req.len(), 1, "B sends AUTH_REQUEST");
13332
13333        // Pump: REQUEST → A.dispatch → REPLY.
13334        let reply = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
13335        assert_eq!(reply.len(), 1, "A (replier) answers with AUTH reply");
13336
13337        // REPLY → B verarbeitet → FINAL (+ B erreicht Complete).
13338        let b_msgs = b_stack
13339            .stateless_reader
13340            .handle_datagram(&reply[0].bytes)
13341            .unwrap();
13342        assert_eq!(b_msgs.len(), 1);
13343        let (final_dgs, _b_complete) = b_stack.on_stateless_message(a_prefix, &b_msgs[0]).unwrap();
13344        assert_eq!(final_dgs.len(), 1, "B sends AUTH-Final");
13345
13346        // FINAL → A.dispatch → A erreicht Complete.
13347        let _ =
13348            dispatch_security_builtin_datagram(&rt, &final_dgs[0].bytes, Duration::from_secs(1));
13349
13350        // Both sides must now have derived the same SharedSecret.
13351        let a_secret = {
13352            let s = a_stack.lock().unwrap();
13353            s.peer_secret(b_prefix)
13354                .expect("A must have authenticated B")
13355        };
13356        let b_secret = b_stack
13357            .peer_secret(a_prefix)
13358            .expect("B must have authenticated A");
13359        let a_bytes = a_pki
13360            .lock()
13361            .unwrap()
13362            .secret_bytes(a_secret)
13363            .unwrap()
13364            .to_vec();
13365        let b_bytes = b_pki
13366            .lock()
13367            .unwrap()
13368            .secret_bytes(b_secret)
13369            .unwrap()
13370            .to_vec();
13371        assert_eq!(a_bytes.len(), 32);
13372        assert_eq!(
13373            a_bytes, b_bytes,
13374            "runtime dispatch + in-test stack derive the same secret"
13375        );
13376
13377        rt.shutdown();
13378    }
13379
13380    /// FU2 S1.5 (e2e): after the auth handshake the runtime dispatch
13381    /// (A, replier) and a reference peer (B, stack+gate, initiator) over
13382    /// the Kx-protected VolatileSecure channel automatically exchange their data
13383    /// crypto tokens — afterwards secured user DATA round-trips in BOTH
13384    /// directions. **The secured-DATA proof via the runtime dispatch.**
13385    #[cfg(feature = "security")]
13386    #[test]
13387    #[serial_test::serial(dcps_security_e2e)]
13388    fn secured_data_round_trips_through_runtime_dispatch_e2e() {
13389        use zerodds_discovery::security::SecurityBuiltinStack;
13390        use zerodds_security::authentication::{AuthenticationPlugin, SharedSecretProvider};
13391        use zerodds_security::generic_message::{
13392            MessageIdentity, ParticipantGenericMessage, class_id,
13393        };
13394        use zerodds_security::token::DataHolder;
13395        use zerodds_security_crypto::{AesGcmCryptoPlugin, Suite};
13396        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
13397        use zerodds_security_runtime::{ProtectionLevel, SharedSecurityGate};
13398
13399        // Couples the pki plugin (behind a mutex) as the SharedSecretProvider to
13400        // the crypto plugin — like SecurityProfile in the FFI (Gap 1).
13401        struct PkiProvider(Arc<Mutex<PkiAuthenticationPlugin>>);
13402        impl SharedSecretProvider for PkiProvider {
13403            fn get_shared_secret(
13404                &self,
13405                h: zerodds_security::authentication::SharedSecretHandle,
13406            ) -> Option<Vec<u8>> {
13407                self.0.lock().ok()?.get_shared_secret(h)
13408            }
13409        }
13410        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>"#;
13411        let gov = || zerodds_security_permissions::parse_governance_xml(GOV).unwrap();
13412        let gate_with = |pki: &Arc<Mutex<PkiAuthenticationPlugin>>| {
13413            SharedSecurityGate::new(
13414                0,
13415                gov(),
13416                Box::new(AesGcmCryptoPlugin::with_secret_provider(
13417                    Suite::Aes128Gcm,
13418                    Arc::new(PkiProvider(pki.clone())) as Arc<dyn SharedSecretProvider>,
13419                )),
13420            )
13421        };
13422        let fake_rtps = |prefix: GuidPrefix, body: &[u8]| -> Vec<u8> {
13423            let mut m = Vec::new();
13424            m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13425            m.extend_from_slice(&prefix.to_bytes());
13426            m.extend_from_slice(body);
13427            m
13428        };
13429
13430        let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
13431        let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
13432        let b_prefix = GuidPrefix::from_bytes([0x10; 12]); // B < A → B initiator (cyclone convention)
13433        let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
13434        let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
13435        let a_key_pk = a_prefix.to_bytes();
13436        let b_key_pk = b_prefix.to_bytes();
13437
13438        // --- A: runtime with auth + gate (sharing pki_a) ---
13439        let pki_a = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13440        let a_local = pki_a
13441            .lock()
13442            .unwrap()
13443            .validate_with_config(
13444                IdentityConfig {
13445                    identity_cert_pem: a_cert.clone(),
13446                    identity_ca_pem: a_cert.clone(),
13447                    identity_key_pem: Some(a_key),
13448                },
13449                a_guid,
13450            )
13451            .unwrap();
13452        let a_token = pki_a.lock().unwrap().get_identity_token(a_local).unwrap();
13453        let gate_a = Arc::new(gate_with(&pki_a));
13454        let rt = DcpsRuntime::start(
13455            43,
13456            a_prefix,
13457            RuntimeConfig {
13458                security: Some(gate_a.clone()),
13459                ..RuntimeConfig::default()
13460            },
13461        )
13462        .expect("start");
13463        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_a.clone();
13464        let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
13465
13466        // --- B: in-test Stack + Gate (sharing pki_b), Initiator ---
13467        let pki_b = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13468        let b_local = pki_b
13469            .lock()
13470            .unwrap()
13471            .validate_with_config(
13472                IdentityConfig {
13473                    identity_cert_pem: b_cert.clone(),
13474                    identity_ca_pem: b_cert.clone(),
13475                    identity_key_pem: Some(b_key),
13476                },
13477                b_guid,
13478            )
13479            .unwrap();
13480        let b_token = pki_b.lock().unwrap().get_identity_token(b_local).unwrap();
13481        let gate_b = gate_with(&pki_b);
13482        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_b.clone();
13483        let mut stack_b =
13484            SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
13485
13486        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13487            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
13488            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
13489            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
13490        let a_beacon = make_secure_beacon_with_identity_token(a_prefix, flags, a_token.clone());
13491        stack_b.handle_remote_endpoints(
13492            &zerodds_discovery::spdp::SpdpReader::new()
13493                .parse_datagram(&a_beacon)
13494                .unwrap(),
13495        );
13496        // Wire A's stack deterministically (no handle_spdp_datagram —
13497        // a running runtime + trigger otherwise produces non-deterministic
13498        // proxy wirings via parallel/loopback beacons). A is the replier:
13499        // begin_handshake_with only sets up the peer state.
13500        let b_beacon = make_secure_beacon_with_identity_token(b_prefix, flags, b_token.clone());
13501        let b_parsed = zerodds_discovery::spdp::SpdpReader::new()
13502            .parse_datagram(&b_beacon)
13503            .unwrap();
13504        {
13505            let mut s = a_stack.lock().unwrap();
13506            s.handle_remote_endpoints(&b_parsed);
13507            s.begin_handshake_with(b_prefix, b_guid, &b_token).unwrap();
13508        }
13509
13510        // --- Stateless-Handshake pumpen (B initiiert) ---
13511        // A is the replier and derives the secret already at begin_handshake_
13512        // reply → A's response to the request contains BOTH: the
13513        // AUTH reply (stateless) AND A's Kx-encrypted crypto token
13514        // (volatile, automatically via the dispatch).
13515        let decode_route = |dgs: &[zerodds_rtps::message_builder::OutboundDatagram]| {
13516            let mut stateless = Vec::new();
13517            let mut volatile = Vec::new();
13518            for dg in dgs {
13519                let parsed = zerodds_rtps::datagram::decode_datagram(&dg.bytes).unwrap();
13520                let is_vol = parsed.submessages.iter().any(|sub| {
13521                    // Klartext-Pfad (unprotected): DATA an den VolatileSecure-Reader.
13522                    matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Data(d)
13523                        if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER)
13524                    // Cross-vendor path (protected): the volatile crypto-token DATA
13525                    // is SEC_*-protected (protect_volatile_outbound) -> the inner
13526                    // DATA is encrypted and recognizable only by the prepended SEC_PREFIX
13527                    // submessage (id 0x31). Stateless AUTH stays plaintext.
13528                    || matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Unknown { id: 0x31, .. })
13529                });
13530                if is_vol {
13531                    volatile.push(dg.bytes.clone());
13532                } else {
13533                    stateless.push(dg.bytes.clone());
13534                }
13535            }
13536            (stateless, volatile)
13537        };
13538
13539        let req = stack_b
13540            .begin_handshake_with(a_prefix, a_guid, &a_token)
13541            .unwrap();
13542        let a_resp = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
13543        let (a_stateless, a_volatile) = decode_route(&a_resp);
13544        assert!(
13545            !a_volatile.is_empty(),
13546            "A dispatch must send A's crypto token"
13547        );
13548
13549        // B verarbeitet A's AUTH-Reply → Final + B completes.
13550        let mut b_remote_id = None;
13551        let mut b_secret = None;
13552        let mut b_final = Vec::new();
13553        for sl in &a_stateless {
13554            for m in stack_b.stateless_reader.handle_datagram(sl).unwrap() {
13555                let (out, comp) = stack_b.on_stateless_message(a_prefix, &m).unwrap();
13556                b_final.extend(out);
13557                if let Some((id, sec)) = comp {
13558                    b_remote_id = Some(id);
13559                    b_secret = Some(sec);
13560                }
13561            }
13562        }
13563        let b_remote_id = b_remote_id.expect("B remote identity");
13564        let b_secret = b_secret.expect("B completes");
13565
13566        // B registers A's Kx, installs A's crypto token (from a_volatile).
13567        gate_b
13568            .register_remote_by_guid_from_secret(a_key_pk, b_remote_id, b_secret)
13569            .unwrap();
13570        // A's volatile crypto token is cross-vendor SEC_*-protected
13571        // (protect_volatile_outbound). B must decrypt the SEC_PREFIX/BODY/POSTFIX sequence
13572        // with A's Kx key to the inner DATA submessage before the
13573        // volatile_reader can process it — mirrors unprotect_volatile_
13574        // datagram im Live-Dispatch.
13575        let unprotect_vol_b = |bytes: &[u8]| -> Option<Vec<u8>> {
13576            let subs = walk_submessages(bytes);
13577            let prefix_pos = subs.iter().position(|(id, _, _)| *id == SMID_SEC_PREFIX)?;
13578            let postfix_idx = subs[prefix_pos..]
13579                .iter()
13580                .position(|(id, _, _)| *id == SMID_SEC_POSTFIX)
13581                .map(|i| prefix_pos + i)?;
13582            let (_, p_start, _) = subs[prefix_pos];
13583            let (_, q_start, q_total) = subs[postfix_idx];
13584            let data_submsg = gate_b
13585                .decode_kx_datawriter_from(&a_key_pk, &bytes[p_start..q_start + q_total])
13586                .ok()?;
13587            let mut out = Vec::with_capacity(bytes.len());
13588            out.extend_from_slice(&bytes[..20]);
13589            for (i, &(_, start, total)) in subs.iter().enumerate() {
13590                if i < prefix_pos || i > postfix_idx {
13591                    out.extend_from_slice(&bytes[start..start + total]);
13592                } else if i == prefix_pos {
13593                    out.extend_from_slice(&data_submsg);
13594                }
13595            }
13596            Some(out)
13597        };
13598        let mut b_installed = 0;
13599        for vol in &a_volatile {
13600            let vol_plain = unprotect_vol_b(vol).unwrap_or_else(|| vol.clone());
13601            let parsed = zerodds_rtps::datagram::decode_datagram(&vol_plain).unwrap();
13602            let vol_src = parsed.header.guid_prefix;
13603            for sub in parsed.submessages {
13604                if let zerodds_rtps::datagram::ParsedSubmessage::Data(d) = sub {
13605                    if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
13606                        for m in stack_b.volatile_reader.handle_data(vol_src, &d).unwrap() {
13607                            if m.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS {
13608                                // plaintext keymat (confidentiality was provided by the SEC_*
13609                                // protection of the volatile DATA, decrypted above) —
13610                                // install directly, no transform_kx_inbound.
13611                                let token = m.message_data[0]
13612                                    .binary_property(CRYPTO_TOKEN_PROP)
13613                                    .unwrap();
13614                                gate_b
13615                                    .set_remote_data_token_by_guid(&a_key_pk, token)
13616                                    .unwrap();
13617                                b_installed += 1;
13618                            }
13619                        }
13620                    }
13621                }
13622            }
13623        }
13624        assert!(b_installed >= 1, "B must install A's crypto token");
13625
13626        // B builds + sends its crypto token — plaintext keymat in the
13627        // ParticipantGenericMessage (cross-vendor: confidentiality via SEC_*
13628        // protection of the transporting volatile DATA, not via token-internal
13629        // Kx encryption).
13630        let b_data_token = gate_b.local_token().unwrap();
13631        let b_crypto_msg = ParticipantGenericMessage {
13632            message_identity: MessageIdentity {
13633                source_guid: b_guid,
13634                sequence_number: 1,
13635            },
13636            related_message_identity: MessageIdentity::default(),
13637            destination_participant_key: a_guid,
13638            destination_endpoint_key: [0; 16],
13639            source_endpoint_key: [0; 16],
13640            message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
13641            message_data: alloc::vec![
13642                DataHolder::new("DDS:Crypto:AES_GCM_GMAC")
13643                    .with_binary_property(CRYPTO_TOKEN_PROP, b_data_token)
13644            ],
13645        };
13646        let b_volatile = stack_b.volatile_writer.write(&b_crypto_msg).unwrap();
13647        // SEC_* submessage protection with A's Kx key (mirrors protect_volatile_
13648        // datagram in the live path): B encrypts the DATA submessage, A's
13649        // dispatch decrypts it via unprotect_volatile_datagram.
13650        let protect_vol_b = |bytes: &[u8]| -> Vec<u8> {
13651            let subs = walk_submessages(bytes);
13652            if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
13653                return bytes.to_vec();
13654            }
13655            let mut out = Vec::with_capacity(bytes.len() + 64);
13656            out.extend_from_slice(&bytes[..20]);
13657            for (id, start, total) in subs {
13658                let submsg = &bytes[start..start + total];
13659                if id == SMID_DATA {
13660                    out.extend_from_slice(
13661                        &gate_b.encode_kx_datawriter_for(&a_key_pk, submsg).unwrap(),
13662                    );
13663                } else {
13664                    out.extend_from_slice(submsg);
13665                }
13666            }
13667            out
13668        };
13669        let b_vol_protected = protect_vol_b(&b_volatile[0].bytes);
13670
13671        // B's Final + B's Crypto-Token an A's Dispatch: A installiert B's
13672        // Data token (automatically via install_crypto_token).
13673        for f in &b_final {
13674            dispatch_security_builtin_datagram(&rt, &f.bytes, Duration::from_secs(1));
13675        }
13676        dispatch_security_builtin_datagram(&rt, &b_vol_protected, Duration::from_secs(1));
13677
13678        // --- Secured DATA in both directions ---
13679        let msg_ab = fake_rtps(a_prefix, b"[A->B secured payload]");
13680        let wire_ab = gate_a
13681            .transform_outbound_for(&b_key_pk, &msg_ab, ProtectionLevel::Encrypt)
13682            .unwrap();
13683        assert_eq!(
13684            gate_b.transform_inbound_from(&a_key_pk, &wire_ab).unwrap(),
13685            msg_ab,
13686            "A->B secured DATA must round-trip"
13687        );
13688        let msg_ba = fake_rtps(b_prefix, b"[B->A secured payload]");
13689        let wire_ba = gate_b
13690            .transform_outbound_for(&a_key_pk, &msg_ba, ProtectionLevel::Encrypt)
13691            .unwrap();
13692        assert_eq!(
13693            gate_a.transform_inbound_from(&b_key_pk, &wire_ba).unwrap(),
13694            msg_ba,
13695            "B->A secured DATA must round-trip (A's dispatch installed B's token)"
13696        );
13697
13698        rt.shutdown();
13699    }
13700
13701    #[test]
13702    fn c34c_enable_security_builtins_replays_known_peers() {
13703        // Order reversed: SPDP discovery first, plugin-
13704        // activation afterward. enable_security_builtins must catch up on already-
13705        // known peers. Plus: demux without a plugin (before enable)
13706        // is a no-op + does not panic.
13707        let rt = DcpsRuntime::start(
13708            76,
13709            GuidPrefix::from_bytes([0x76; 12]),
13710            RuntimeConfig::default(),
13711        )
13712        .expect("start");
13713
13714        // Demux without a plugin: silent no-op
13715        dispatch_security_builtin_datagram(&rt, &[0u8; 16], Duration::from_secs(1));
13716
13717        let remote = GuidPrefix::from_bytes([0x77; 12]);
13718        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13719            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13720        let dg = make_remote_spdp_beacon_with_flags(remote, flags);
13721        handle_spdp_datagram(&rt, &dg);
13722
13723        let stack = rt.enable_security_builtins(VendorId::ZERODDS);
13724        {
13725            let s = stack.lock().unwrap();
13726            assert_eq!(
13727                s.stateless_writer.reader_proxy_count(),
13728                1,
13729                "late plugin activation must catch up on known peers"
13730            );
13731        }
13732
13733        rt.shutdown();
13734    }
13735
13736    /// #29 regression: the earlier per-peer once-guard blocked late-matched
13737    /// user-endpoint tokens. `pending_endpoint_tokens` must, with already-sent
13738    /// builtin tokens, let through EXACTLY the new user token — not treat the whole
13739    /// peer as "done".
13740    #[cfg(feature = "security")]
13741    #[test]
13742    fn pending_endpoint_tokens_keeps_late_user_token_after_builtins_sent() {
13743        use zerodds_security::generic_message::ParticipantGenericMessage;
13744        // An early-sent builtin token (secure-SEDP) ...
13745        let builtin = ParticipantGenericMessage {
13746            source_endpoint_key: [0xff; 16],
13747            destination_endpoint_key: [0xfe; 16],
13748            ..Default::default()
13749        };
13750        // ... and a late-matched user-endpoint token.
13751        let user = ParticipantGenericMessage {
13752            source_endpoint_key: [0x03; 16],
13753            destination_endpoint_key: [0x04; 16],
13754            ..Default::default()
13755        };
13756        let mut sent = alloc::collections::BTreeSet::new();
13757        sent.insert(endpoint_token_key(&builtin));
13758
13759        let pending = pending_endpoint_tokens(vec![builtin.clone(), user.clone()], &sent);
13760
13761        assert_eq!(pending.len(), 1, "only the new user token may be pending");
13762        assert_eq!(
13763            pending[0].source_endpoint_key, user.source_endpoint_key,
13764            "the let-through token must be the user-endpoint token"
13765        );
13766        // Idempotency: after sending, nothing is pending anymore.
13767        let mut sent2 = sent.clone();
13768        sent2.insert(endpoint_token_key(&user));
13769        assert!(
13770            pending_endpoint_tokens(vec![builtin, user], &sent2).is_empty(),
13771            "already-sent tokens must not become pending again"
13772        );
13773    }
13774}