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. Ignored when
589    /// [`Self::user_transports`] is non-empty.
590    pub user_transport: Option<UserTransportKind>,
591
592    /// Preference-ordered set of transports for DCPS user traffic. When
593    /// non-empty, the runtime builds a [`LayeredUserTransport`](crate::layered_transport::LayeredUserTransport)
594    /// over all of them: each datagram is routed to the first transport whose
595    /// locator kind matches the destination (so list the fast/local transport
596    /// first — e.g. `[Shm, UdpV4]` — and the fallback last), and receives are
597    /// multiplexed from all of them. Empty (default) → single-transport via
598    /// [`Self::user_transport`].
599    pub user_transports: alloc::vec::Vec<UserTransportKind>,
600
601    /// Optional security gate. Active only with the `security` feature.
602    /// When set, UDP outbound messages are pulled through
603    /// [`SharedSecurityGate::transform_outbound`], and inbound messages
604    /// through [`SharedSecurityGate::transform_inbound_from`] (peer key
605    /// from RTPS header bytes 8..20).
606    #[cfg(feature = "security")]
607    pub security: Option<std::sync::Arc<zerodds_security_runtime::SharedSecurityGate>>,
608    /// Optional LoggingPlugin for security events. Called by the inbound
609    /// path when packets are dropped due to a policy violation, tampering
610    /// or a legacy block.
611    #[cfg(feature = "security")]
612    pub security_logger: Option<std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin>>,
613
614    /// Multi-interface bindings. Empty → `user_unicast` is the only
615    /// outbound socket (legacy behavior). Non-empty →
616    /// `DcpsRuntime::start` builds a dedicated UDP socket per spec and the
617    /// writer tick loop routes to the matching socket per destination
618    /// locator.
619    #[cfg(feature = "security")]
620    pub interface_bindings: Vec<InterfaceBindingSpec>,
621
622    /// `true` → the SPDP beacon additionally announces the 12 secure
623    /// discovery bits (16..27, DDS-Security 1.2 §7.4.7.1). Default
624    /// `false` — only standard bits are announced. Set by the DCPS
625    /// factory once a PolicyEngine is configured. This flag is available
626    /// even without the `security` feature, so that tests can check bit
627    /// presence without activating the whole crypto crate.
628    pub announce_secure_endpoints: bool,
629
630    /// FastDDS interop: run the reliable secure SPDP channel (0xff0101c2/c7,
631    /// `ENTITYID_SPDP_RELIABLE_BUILTIN_PARTICIPANT_SECURE_*`). FastDDS announces
632    /// its full secured participant data (identity_token/security_info) over
633    /// this channel and gates the crypto-token reciprocation/endpoint matching
634    /// on it; cyclone does NOT need it (cyclone↔zerodds runs without). Default off
635    /// — enable only for FastDDS cross-vendor.
636    pub enable_secure_spdp: bool,
637
638    /// WLP-Tick-Periode (Writer-Liveliness-Protocol, RTPS 2.5 §8.4.13).
639    /// `Duration::ZERO` → default `participant_lease_duration / 3`
640    /// (spec recommendation: three misses before the reader marks the
641    /// writer as not-alive). A direct override enables aggressive
642    /// tests.
643    pub wlp_period: Duration,
644
645    /// Lease duration announced in the SPDP beacon as
646    /// `PARTICIPANT_LEASE_DURATION` (spec default 100 s). Also used as the
647    /// basis for the AUTOMATIC WLP tick (`wlp_period =
648    /// participant_lease_duration / 3` if `wlp_period == Duration::ZERO`).
649    pub participant_lease_duration: Duration,
650
651    /// USER_DATA bytes of the participant (DDS 1.4 §2.2.3.1
652    /// `UserDataQosPolicy`). Announced in the SPDP beacon as PID_USER_DATA
653    /// (DDSI-RTPS §9.6.3.2) and exposed on the receiver side in
654    /// `ParticipantBuiltinTopicData.user_data`. Default empty.
655    pub user_data: Vec<u8>,
656
657    /// Observability sink. Default is `null_sink()` — each event emit is
658    /// then a direct return without allocation on the consumer side.
659    /// Consumers inject e.g.
660    /// [`zerodds_foundation::observability::StderrJsonSink`] (JSON lines
661    /// for Vector/fluentd/Datadog) or their own OTLP bridge.
662    pub observability: zerodds_foundation::observability::SharedSink,
663
664    /// Sprint D.5d lever C — RT pinning + priority. Linux-only; on
665    /// macOS/Windows the hooks are no-ops.
666    ///
667    /// SCHED_FIFO priority (1-99) for the three recv workers (SPDP MC,
668    /// metatraffic, user data). `None` = default scheduler (CFS).
669    /// `Some(80)` is the spec recommendation for real-time paths. Requires
670    /// `CAP_SYS_NICE` or an `RLIMIT_RTPRIO`-permitted user.
671    pub recv_thread_priority: Option<i32>,
672
673    /// Like [`Self::recv_thread_priority`], but for the tick worker.
674    pub tick_thread_priority: Option<i32>,
675
676    /// CPU affinity mask for the recv workers. `None` = no affinity (the
677    /// kernel schedules freely). A list of CPU indices, e.g.
678    /// `vec![2, 3]` for cores 2+3. Set via `sched_setaffinity`; all three
679    /// recv threads share the same mask.
680    pub recv_thread_cpus: Option<Vec<usize>>,
681
682    /// Like [`Self::recv_thread_cpus`], but for the tick worker.
683    pub tick_thread_cpus: Option<Vec<usize>>,
684
685    /// Opt-3 (Spec `zerodds-zero-copy-1.0` §9): number of additional
686    /// user-data recv workers that listen on the same port as
687    /// `user_unicast` via `SO_REUSEPORT`. `0` (default) = only the primary
688    /// `recv_user_data_loop` worker. Under high recv load the pool scales
689    /// linearly with cores (kernel flow hashing distributes incoming
690    /// datagrams). Recommended values: 1-3 additional workers per CPU
691    /// core.
692    pub extra_recv_threads: usize,
693
694    /// D.5g — default DataRepresentation list announced in SEDP
695    /// PublicationData and SEDP SubscriptionData, when not overridden
696    /// per-writer/reader (UserWriterConfig/UserReaderConfig).
697    ///
698    /// **Important**: per strict spec (XTypes 1.3 §7.6.3.1.2) the first
699    /// element is the writer's "offered" and must be in the reader's
700    /// "accepted" list for a match to happen. Default `[XCDR1, XCDR2]` =
701    /// legacy-first → max interop with the RTI Connext Shapes Demo
702    /// (XCDR1-only). Pure-XCDR2 deployments can switch this to `[XCDR2]`
703    /// or `[XCDR2, XCDR1]` for bandwidth efficiency and
704    /// @appendable/@mutable support.
705    ///
706    /// Empty (`vec![]`) is interpreted per spec as `[XCDR1]`.
707    pub data_representation_offer: Vec<i16>,
708
709    /// D.5g — default match mode for DataRepresentation negotiation.
710    ///
711    /// `Strict` (XTypes 1.3 §7.6.3.1.2 normative): writer.first ∈
712    /// reader.list = match. `Tolerant` (industry norm): any overlap =
713    /// match, picks the first overlap as the wire format.
714    ///
715    /// Default `Tolerant` because Cyclone DDS and FastDDS match this way —
716    /// maximizes interop. The strict setting is only meaningful for
717    /// formal spec-compliance tests.
718    pub data_rep_match_mode: zerodds_rtps::publication_data::data_representation::DataRepMatchMode,
719
720    /// zerodds-async-1.0 §4 — when `true`, `start()` does **not** spawn the
721    /// dedicated `zdds-tick` std::thread. The periodic tick (SPDP announce,
722    /// SEDP/WLP, deadline/lifespan/liveliness) must then be driven externally
723    /// via [`DcpsRuntime::tick_driver`]. Used by the async API's
724    /// `spawn_in_tokio`, which multiplexes many participants' tick loops onto
725    /// a tokio runtime instead of one thread each. Default `false` (internal
726    /// thread, unchanged behaviour). The recv worker threads are unaffected —
727    /// they block on socket recv and stay regardless.
728    pub external_tick: bool,
729
730    /// D.5e Phase 3 — when `true`, `start()` drives the periodic tick via the
731    /// event-driven deadline scheduler ([`crate::scheduler`]) instead of the
732    /// fixed-`tick_period` poll: the worker parks until the next due deadline
733    /// (SPDP announce, or a fine floor while user endpoints/QoS timers are
734    /// active) or until a write/recv `raise` wakes it — no busy-poll, lower idle
735    /// CPU, lower tail latency. The work done per wake is the **unchanged**
736    /// `run_tick_iteration` (identical wire output + cadence — cross-vendor
737    /// safe). **Default `true`** since D.5e Phase C (2026-06-14) — set
738    /// `ZERODDS_SCHEDULER_TICK=0` or this field to `false` for the classic
739    /// fixed-period `tick_loop`. Mutually exclusive with `external_tick`
740    /// (external wins).
741    pub scheduler_tick: bool,
742}
743
744/// Configuration entry for a physical or logical network interface.
745///
746/// A binding describes an outbound socket: which IP/port it binds to,
747/// which `NetInterface` class the interface represents, and which IP
748/// range counts as "associated peers" (routing match).
749#[cfg(feature = "security")]
750#[derive(Clone, Debug)]
751pub struct InterfaceBindingSpec {
752    /// Name for diagnostics + log attribution (e.g. `"eth0"`, `"tun0"`,
753    /// `"lo"`).
754    pub name: String,
755    /// Bind address. `0.0.0.0` leaves the interface to the kernel.
756    pub bind_addr: Ipv4Addr,
757    /// Bind port. `0` = ephemeral.
758    pub bind_port: u16,
759    /// Interface class — feeds into the PolicyEngine context.
760    pub kind: NetInterface,
761    /// Destination IP range this binding is responsible for. Example:
762    /// `127.0.0.0/8` for loopback. A target whose IP lies in this range is
763    /// routed to this binding.
764    pub subnet: IpRange,
765    /// If `true`: this binding is used when **no** other subnet match
766    /// applies. Exactly one entry should have `default = true` (usually
767    /// the WAN binding).
768    pub default: bool,
769}
770
771/// Fully bound interface with its UDP socket.
772#[cfg(feature = "security")]
773struct InterfaceBinding {
774    spec: InterfaceBindingSpec,
775    socket: Arc<UdpTransport>,
776}
777
778/// Pool of per-interface UDP sockets with target-based routing.
779///
780/// Decision:
781/// 1. Iterates over all bindings; the first whose subnet contains the
782///    target wins.
783/// 2. If no match and a default binding exists → default path.
784/// 3. No match + no default → `None`, the caller drops.
785#[cfg(feature = "security")]
786struct OutboundSocketPool {
787    bindings: Vec<InterfaceBinding>,
788    default_idx: Option<usize>,
789}
790
791#[cfg(feature = "security")]
792impl OutboundSocketPool {
793    fn bind_all(specs: &[InterfaceBindingSpec]) -> Result<Self> {
794        let mut bindings = Vec::with_capacity(specs.len());
795        for spec in specs {
796            let socket = UdpTransport::bind_v4(spec.bind_addr, spec.bind_port).map_err(|_| {
797                DdsError::TransportError {
798                    label: "interface-binding bind_v4 failed",
799                }
800            })?;
801            // Short read timeout so that the per-interface inbound poll in
802            // the event loop becomes non-blocking. 5 ms is small enough not
803            // to create latency elsewhere (the tick period defaults to
804            // 50 ms), but large enough to amortize context switches.
805            let socket = socket
806                .with_timeout(Some(Duration::from_millis(5)))
807                .map_err(|_| DdsError::TransportError {
808                    label: "interface-binding set_timeout failed",
809                })?;
810            bindings.push(InterfaceBinding {
811                spec: spec.clone(),
812                socket: Arc::new(socket),
813            });
814        }
815        let default_idx = bindings.iter().position(|b| b.spec.default);
816        Ok(Self {
817            bindings,
818            default_idx,
819        })
820    }
821
822    /// Returns `(socket, NetInterface class)` for a destination locator.
823    /// `None` if neither a subnet match nor a default binding exists.
824    fn route(&self, target: &Locator) -> Option<(&Arc<UdpTransport>, NetInterface)> {
825        let ip = ipv4_from_locator(target)?;
826        let addr = core::net::IpAddr::V4(core::net::Ipv4Addr::from(ip));
827        for b in &self.bindings {
828            if b.spec.subnet.contains(&addr) {
829                return Some((&b.socket, b.spec.kind.clone()));
830            }
831        }
832        let idx = self.default_idx?;
833        let b = self.bindings.get(idx)?;
834        Some((&b.socket, b.spec.kind.clone()))
835    }
836}
837
838/// True if the locator is routable over the user-data transport
839/// (trait object). Accepts UDPv4, UDPv6, TCPv4, Shm. The concrete
840/// transport (UdpTransport/TcpTransport/ShmUserTransport) then returns
841/// `UnsupportedLocator` for kinds it does not itself speak;
842/// the filter here only prevents sending to clearly non-IP/SHM
843/// locators like UDS (for which we have no transport plugin).
844fn is_routable_user_locator(loc: &Locator) -> bool {
845    matches!(
846        loc.kind,
847        LocatorKind::UdpV4
848            | LocatorKind::UdpV6
849            | LocatorKind::Tcpv4
850            | LocatorKind::Tcpv6
851            | LocatorKind::Shm
852            | LocatorKind::Uds
853            | LocatorKind::Tsn
854    )
855}
856
857/// Computes the user-endpoint `EndpointSecurityInfo` mask from the governance
858/// protection kinds (DDS-Security 1.2 §10.4.1.2.6 / §9.4.2.4). The wire mask
859/// MUST match cyclone/FastDDS/OpenDDS byte-exactly, otherwise the peer rejects
860/// the endpoint match with "security_attributes mismatch".
861///
862/// - metadata=SIGN/ENCRYPT → IS_SUBMESSAGE_PROTECTED (+ plugin SUBMESSAGE_ENCRYPTED on ENCRYPT)
863/// - data=SIGN    → IS_PAYLOAD_PROTECTED
864/// - data=ENCRYPT → IS_PAYLOAD_PROTECTED | **IS_KEY_PROTECTED** (+ plugin PAYLOAD_ENCRYPTED)
865/// - liveliness=SIGN/ENCRYPT → **IS_LIVELINESS_PROTECTED** (§9.4.1.3: per-endpoint!)
866/// - topic enable_discovery_protection → IS_DISCOVERY_PROTECTED
867///
868/// is_key_protected follows §10.4.1.2.6 exclusively from the **DATA** protection
869/// and only on ENCRYPT — NOT from the metadata protection. is_liveliness_protected
870/// in contrast MUST be on every user endpoint as soon as liveliness_protection is active;
871/// cyclone compares the mask at endpoint match and otherwise rejects with
872/// "security_attributes mismatch" (0x..30 vs 0x..70).
873#[cfg(feature = "security")]
874fn compute_user_endpoint_attrs(
875    meta: ProtectionLevel,
876    data: ProtectionLevel,
877    discovery_protected: bool,
878    liveliness_protected: bool,
879    read_protected: bool,
880    write_protected: bool,
881) -> zerodds_rtps::endpoint_security_info::EndpointSecurityInfo {
882    use zerodds_rtps::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
883    let mut a = attrs::IS_VALID;
884    let mut p = plugin_attrs::IS_VALID;
885    if read_protected {
886        a |= attrs::IS_READ_PROTECTED;
887    }
888    if write_protected {
889        a |= attrs::IS_WRITE_PROTECTED;
890    }
891    if meta != ProtectionLevel::None {
892        a |= attrs::IS_SUBMESSAGE_PROTECTED;
893    }
894    if meta == ProtectionLevel::Encrypt {
895        p |= plugin_attrs::IS_SUBMESSAGE_ENCRYPTED;
896    }
897    if data != ProtectionLevel::None {
898        a |= attrs::IS_PAYLOAD_PROTECTED;
899    }
900    if data == ProtectionLevel::Encrypt {
901        a |= attrs::IS_KEY_PROTECTED;
902        p |= plugin_attrs::IS_PAYLOAD_ENCRYPTED;
903    }
904    if discovery_protected {
905        a |= attrs::IS_DISCOVERY_PROTECTED;
906    }
907    if liveliness_protected {
908        a |= attrs::IS_LIVELINESS_PROTECTED;
909    }
910    EndpointSecurityInfo {
911        endpoint_security_attributes: a,
912        plugin_endpoint_security_attributes: p,
913    }
914}
915
916#[cfg(all(test, feature = "security"))]
917mod endpoint_attr_tests {
918    use super::compute_user_endpoint_attrs;
919    use zerodds_rtps::endpoint_security_info::attrs;
920    use zerodds_security_runtime::ProtectionLevel;
921
922    fn mask(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
923        compute_user_endpoint_attrs(meta, data, false, false, false, false)
924            .endpoint_security_attributes
925    }
926
927    fn mask_liv(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
928        compute_user_endpoint_attrs(meta, data, false, true, false, false)
929            .endpoint_security_attributes
930    }
931
932    #[test]
933    fn liveliness_protected_sets_0x40_per_spec_9_4_1_3() {
934        use ProtectionLevel::{Encrypt, None};
935        let v = attrs::IS_VALID;
936        let pay = attrs::IS_PAYLOAD_PROTECTED;
937        let key = attrs::IS_KEY_PROTECTED;
938        let liv = attrs::IS_LIVELINESS_PROTECTED;
939        // liveliness=ENCRYPT + data=ENCRYPT → 0x..70 (cyclone's value at match).
940        assert_eq!(mask_liv(None, Encrypt), v | pay | key | liv);
941        // without liveliness → 0x..30, NO 0x40.
942        assert_eq!(mask(None, Encrypt), v | pay | key);
943        assert_eq!(mask_liv(None, None), v | liv);
944    }
945
946    #[test]
947    fn key_protected_follows_data_encrypt_per_spec_10_4_1_2_6() {
948        use ProtectionLevel::{Encrypt, None, Sign};
949        let v = attrs::IS_VALID;
950        let sub = attrs::IS_SUBMESSAGE_PROTECTED;
951        let pay = attrs::IS_PAYLOAD_PROTECTED;
952        let key = attrs::IS_KEY_PROTECTED;
953        // §10.4.1.2.6: is_key_protected follows ONLY data=ENCRYPT.
954        // data=ENCRYPT → PAYLOAD|KEY (= cyclone's 0x30 in the common subset).
955        assert_eq!(mask(None, Encrypt), v | pay | key);
956        // data=SIGN → PAYLOAD, NO KEY.
957        assert_eq!(mask(None, Sign), v | pay);
958        // data=NONE → no payload/key bits.
959        assert_eq!(mask(None, None), v);
960        // KEY does NOT depend on metadata: meta=ENCRYPT/data=NONE → only SUBMESSAGE.
961        assert_eq!(mask(Encrypt, None), v | sub);
962        // meta=ENCRYPT + data=ENCRYPT → SUBMESSAGE|PAYLOAD|KEY (0x38).
963        assert_eq!(mask(Encrypt, Encrypt), v | sub | pay | key);
964    }
965}
966
967/// Unicast targets for the WLP heartbeat fan-out (M-2): per discovered peer the
968/// `metatraffic_unicast_locator` (fallback `default_unicast_locator`), filtered
969/// to routable kinds. WLP is metatraffic (DDSI-RTPS §8.4.13); in multicast-
970/// free environments (container/cloud) the pure multicast pulse never reaches the
971/// peer reader → the lease expires although the peer is alive. The additional
972/// unicast fan-out follows the SEDP locator model.
973fn wlp_unicast_targets(peers: &[zerodds_discovery::spdp::DiscoveredParticipant]) -> Vec<Locator> {
974    peers
975        .iter()
976        .filter_map(|dp| {
977            dp.data
978                .metatraffic_unicast_locator
979                .or(dp.data.default_unicast_locator)
980        })
981        .filter(is_routable_user_locator)
982        .collect()
983}
984
985/// Extracts the IPv4 address from a `Locator` (UDP-V4).
986/// `None` for SHM/UDS/IPv6.
987#[cfg(feature = "security")]
988fn ipv4_from_locator(loc: &Locator) -> Option<[u8; 4]> {
989    if loc.kind != LocatorKind::UdpV4 {
990        return None;
991    }
992    Some([
993        loc.address[12],
994        loc.address[13],
995        loc.address[14],
996        loc.address[15],
997    ])
998}
999
1000impl core::fmt::Debug for RuntimeConfig {
1001    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1002        let mut dbg = f.debug_struct("RuntimeConfig");
1003        dbg.field("tick_period", &self.tick_period)
1004            .field("spdp_period", &self.spdp_period)
1005            .field("spdp_multicast_group", &self.spdp_multicast_group)
1006            .field("multicast_interface", &self.multicast_interface);
1007        #[cfg(feature = "security")]
1008        {
1009            dbg.field("security", &self.security.as_ref().map(|_| "<gate>"));
1010            dbg.field(
1011                "security_logger",
1012                &self.security_logger.as_ref().map(|_| "<logger>"),
1013            );
1014        }
1015        dbg.finish()
1016    }
1017}
1018
1019impl Default for RuntimeConfig {
1020    fn default() -> Self {
1021        // Env hook for bench tuning: ZERODDS_TICK_PERIOD_MS=N → overrides
1022        // the 5ms default. High (e.g. 1000) relieves the write hot path of
1023        // the periodic HB/tick overhead and makes spread spikes from tick
1024        // preemption visible. Production: do not set; the default 5 ms is
1025        // spec-compliant.
1026        let tick = std::env::var("ZERODDS_TICK_PERIOD_MS")
1027            .ok()
1028            .and_then(|s| s.parse::<u64>().ok())
1029            .map(Duration::from_millis)
1030            .unwrap_or(DEFAULT_TICK_PERIOD);
1031        // C3 WiFi-robust discovery — initial-announcement burst. Env overrides:
1032        // `ZERODDS_INITIAL_ANNOUNCE_COUNT` (0 disables) +
1033        // `ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS`.
1034        let initial_announce_count = std::env::var("ZERODDS_INITIAL_ANNOUNCE_COUNT")
1035            .ok()
1036            .and_then(|s| s.parse::<u32>().ok())
1037            .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_COUNT);
1038        let initial_announce_period = std::env::var("ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS")
1039            .ok()
1040            .and_then(|s| s.parse::<u64>().ok())
1041            .map(Duration::from_millis)
1042            .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_PERIOD);
1043        Self {
1044            tick_period: tick,
1045            spdp_period: DEFAULT_SPDP_PERIOD,
1046            initial_announce_count,
1047            initial_announce_period,
1048            // Env override `ZERODDS_SPDP_MC_GROUP` (IPv4) of the SPDP
1049            // multicast group. Two processes with different groups do NOT
1050            // see each other via multicast → enables a multicast-free C1
1051            // e2e proof (discovery then only via ZERODDS_PEERS). Default is
1052            // the spec group.
1053            spdp_multicast_group: std::env::var("ZERODDS_SPDP_MC_GROUP")
1054                .ok()
1055                .and_then(|s| s.parse::<Ipv4Addr>().ok())
1056                .unwrap_or_else(|| Ipv4Addr::from(SPDP_DEFAULT_MULTICAST_ADDRESS)),
1057            // Interface pinning (Cyclone `NetworkInterface`/FastDDS
1058            // whitelist equivalent): `ZERODDS_INTERFACE=<ipv4>` forces
1059            // announce + bind on this interface. Default UNSPECIFIED = auto
1060            // (route probe). Critical on multi-homed hosts (VPN/Docker/
1061            // macOS bridge100), where the auto choice may announce the
1062            // wrong interface.
1063            multicast_interface: std::env::var("ZERODDS_INTERFACE")
1064                .ok()
1065                .and_then(|s| s.parse::<Ipv4Addr>().ok())
1066                .unwrap_or(Ipv4Addr::UNSPECIFIED),
1067            // Multicast send on by default; `ZERODDS_NO_MULTICAST` (any
1068            // non-empty value) turns it off → pure unicast discovery.
1069            spdp_multicast_send: std::env::var("ZERODDS_NO_MULTICAST")
1070                .map(|v| v.is_empty())
1071                .unwrap_or(true),
1072            // C3: 16 MiB default (suitable for ROS PointCloud2/Image),
1073            // env override `ZERODDS_MAX_SAMPLE_BYTES`.
1074            max_reassembly_sample_bytes: std::env::var("ZERODDS_MAX_SAMPLE_BYTES")
1075                .ok()
1076                .and_then(|s| s.parse::<usize>().ok())
1077                .unwrap_or(16 * 1024 * 1024),
1078            // Programmatic default empty. The env `ZERODDS_PEERS` is
1079            // expanded domain-aware only in `DcpsRuntime::start` and merged
1080            // with this field into the effective peer list.
1081            initial_peers: Vec::new(),
1082            user_transport: None,
1083            user_transports: alloc::vec::Vec::new(),
1084            #[cfg(feature = "security")]
1085            security: None,
1086            #[cfg(feature = "security")]
1087            security_logger: None,
1088            #[cfg(feature = "security")]
1089            interface_bindings: Vec::new(),
1090            announce_secure_endpoints: false,
1091            // Env hook for bench/FastDDS interop: ZERODDS_SECURE_SPDP=1 turns
1092            // on the reliable secure SPDP channel (0xff0101). Production sets this
1093            // explicitly via the SecurityProfile/config.
1094            enable_secure_spdp: std::env::var("ZERODDS_SECURE_SPDP").ok().as_deref() == Some("1"),
1095            wlp_period: Duration::ZERO,
1096            participant_lease_duration: Duration::from_secs(100),
1097            user_data: Vec::new(),
1098            observability: zerodds_foundation::observability::null_sink(),
1099            recv_thread_priority: None,
1100            tick_thread_priority: None,
1101            recv_thread_cpus: None,
1102            tick_thread_cpus: None,
1103            extra_recv_threads: 0,
1104            // D.5g — default `[XCDR1, XCDR2]` (legacy-first, max interop).
1105            // Env-var override `ZERODDS_DATA_REPR_OFFER` as a comma list
1106            // ("XCDR1", "XCDR2", "XCDR1,XCDR2", "XCDR2,XCDR1"). Cross-vendor
1107            // benches against strict-matching vendors (RTI) need XCDR2-only
1108            // so that every wire match happens.
1109            data_representation_offer: parse_data_repr_offer_env().unwrap_or_else(|| {
1110                zerodds_rtps::publication_data::data_representation::DEFAULT_OFFER.to_vec()
1111            }),
1112            data_rep_match_mode:
1113                zerodds_rtps::publication_data::data_representation::DataRepMatchMode::default(),
1114            external_tick: false,
1115            // D.5e Phase 3 — the event-driven deadline-heap scheduler is the
1116            // DEFAULT tick (Phase C, 2026-06-14): it parks until the next due
1117            // deadline / a write-recv raise instead of polling every 5 ms (~17×
1118            // fewer idle iterations, lower tail latency, identical wire output).
1119            // Verified cross-vendor secured (data-enc + rtps-enc all pairs) +
1120            // same_host_e2e + latency_assertions on codepit. Escape hatch:
1121            // `ZERODDS_SCHEDULER_TICK=0` restores the classic fixed-period
1122            // `tick_loop`.
1123            scheduler_tick: std::env::var("ZERODDS_SCHEDULER_TICK")
1124                .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
1125                .unwrap_or(true),
1126        }
1127    }
1128}
1129
1130impl RuntimeConfig {
1131    /// Apply a [`SecurityBundle`](zerodds_security_runtime::SecurityBundle):
1132    /// wires its security-event logger into [`Self::security_logger`] and, if
1133    /// the bundle carries a [`SecurityProfile`](zerodds_security_runtime::SecurityProfile),
1134    /// its gate into [`Self::security`]. Convenience for the common
1135    /// `SecurityBundle::builder()…build()` flow so callers don't have to set
1136    /// the two fields by hand.
1137    #[cfg(feature = "security")]
1138    #[must_use]
1139    pub fn with_security_bundle(
1140        mut self,
1141        bundle: &zerodds_security_runtime::SecurityBundle,
1142    ) -> Self {
1143        if let Some(logger) = bundle.logging_plugin() {
1144            self.security_logger = Some(logger);
1145        }
1146        if let Some(profile) = bundle.security_profile() {
1147            self.security = Some(profile.gate.clone());
1148        }
1149        self
1150    }
1151
1152    /// Materialize a security-event logger from `dds.sec.log.*` properties and
1153    /// wire it into [`Self::security_logger`]. This is the DDS-Security
1154    /// spec-style alternative to handing a logger object in directly (see
1155    /// [`Self::with_security_bundle`]): the participant carries
1156    /// `dds.sec.log.plugin = "stderr,jsonl"` (+ `dds.sec.log.*` parameters) on
1157    /// its [`PropertyQosPolicy`](zerodds_qos::PropertyQosPolicy), and the
1158    /// runtime builds the fan-out logger from them.
1159    ///
1160    /// No-op when `dds.sec.log.plugin` is absent. Errors if a selected sink is
1161    /// misconfigured (e.g. `jsonl` without `dds.sec.log.jsonl.path`).
1162    #[cfg(feature = "security")]
1163    pub fn with_security_log_properties(
1164        mut self,
1165        property: &zerodds_qos::PropertyQosPolicy,
1166    ) -> core::result::Result<Self, zerodds_security_logging::LogConfigError> {
1167        let pairs: alloc::vec::Vec<(&str, &str)> = property.iter().collect();
1168        if let Some(logger) = zerodds_security_logging::logging_plugin_from_properties(&pairs)? {
1169            self.security_logger = Some(alloc::sync::Arc::from(logger));
1170        }
1171        Ok(self)
1172    }
1173
1174    /// C4: robotics-capable defaults for **out-of-the-box ROS-2 interop**.
1175    /// Saves the manual env tuning otherwise needed for real ROS-2 nodes.
1176    /// Specifically, compared to [`RuntimeConfig::default`]:
1177    /// - **`data_representation_offer = [XCDR1, XCDR2]`**: `rmw_cyclonedds`/
1178    ///   `rmw_fastrtps` write XCDR1 for final/simple types (e.g.
1179    ///   `std_msgs/String`). An XCDR2-only reader does not match an XCDR1
1180    ///   writer — so the ROS reader here offers both legacy-first
1181    ///   (tolerant match is already the default). This is the clean,
1182    ///   ROS-specific variant of the `ZERODDS_DATA_REPR_OFFER` env
1183    ///   workaround, WITHOUT changing the global `DEFAULT_OFFER`
1184    ///   (XCDR2-only, deliberately for FastDDS/OpenDDS XCDR2 readers).
1185    ///
1186    /// The ROS-realistic reassembly cap (16 MiB, PointCloud2/Image) is
1187    /// already the global default and is carried over here.
1188    #[must_use]
1189    pub fn ros_defaults() -> Self {
1190        use zerodds_rtps::publication_data::data_representation as dr;
1191        Self {
1192            data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1193            ..Self::default()
1194        }
1195    }
1196
1197    /// C6 multi-robot / WAN / cross-subnet profile.
1198    ///
1199    /// A named profile for fleets that span subnets, the cloud, or WiFi —
1200    /// environments that drop IP multicast, so SPDP discovery cannot rely on
1201    /// the multicast beacon. It is the [`ros_defaults`](Self::ros_defaults)
1202    /// representation offer **plus**:
1203    ///
1204    /// - **Multicast-free discovery** (`spdp_multicast_send = false`):
1205    ///   participants find each other purely through unicast initial peers,
1206    ///   regardless of the `ZERODDS_NO_MULTICAST` env. Set the peers via
1207    ///   `ZERODDS_PEERS` (a comma list of `ip` or `ip:port`); a port-less
1208    ///   `ip` is expanded to the well-known SPDP unicast ports of the first
1209    ///   N participant indices (`ZERODDS_MAX_PEER_PARTICIPANTS`).
1210    /// - **WAN-tolerant liveliness**: a longer participant lease (300 s vs
1211    ///   the 100 s spec default) so transient cross-subnet RTT spikes or
1212    ///   brief link drops do not trigger a false liveliness loss.
1213    ///
1214    /// **Domain isolation** is the caller's lever: pass a fleet-dedicated
1215    /// `domain_id` to [`DcpsRuntime::start`] to keep robots off the default
1216    /// domain 0. The profile deliberately does not pick a domain for you.
1217    ///
1218    /// ```
1219    /// use zerodds_dcps::runtime::RuntimeConfig;
1220    /// let cfg = RuntimeConfig::multi_robot();
1221    /// assert!(!cfg.spdp_multicast_send); // unicast-only discovery
1222    /// ```
1223    pub fn multi_robot() -> Self {
1224        use zerodds_rtps::publication_data::data_representation as dr;
1225        Self {
1226            data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1227            spdp_multicast_send: false,
1228            participant_lease_duration: Duration::from_secs(300),
1229            ..Self::default()
1230        }
1231    }
1232}
1233
1234/// Parse the `ZERODDS_DATA_REPR_OFFER` env var. Values: "XCDR1", "XCDR2",
1235/// or a comma list. None if the env var is missing or invalid.
1236fn parse_data_repr_offer_env() -> Option<Vec<i16>> {
1237    let s = std::env::var("ZERODDS_DATA_REPR_OFFER").ok()?;
1238    parse_data_repr_offer_str(&s)
1239}
1240
1241/// Computes the **well-known** SPDP unicast discovery port for a
1242/// domain + participant index. Formula (DDSI-RTPS 2.5 §9.6.1.4.1):
1243///   port = PB + DG·domain + d1 + PG·pid = 7400 + 250·domain + 10 + 2·pid
1244///
1245/// This lets a configured unicast initial peer (multicast-free discovery)
1246/// reach a participant deterministically WITHOUT having found it via
1247/// multicast first. Defined locally in `dcps` to avoid touching
1248/// `crates/rtps` (spec constants as literals).
1249#[must_use]
1250fn spdp_unicast_port(domain_id: u32, participant_id: u32) -> u32 {
1251    7400 + 250 * domain_id + 10 + 2 * participant_id
1252}
1253
1254/// Default number of participant indices a port-less initial peer is
1255/// expanded to (Cyclone equivalent: `MaxAutoParticipantIndex`). The
1256/// beacon thereby reaches the first N participants of the peer host via
1257/// their well-known SPDP unicast ports. Overridable via the env
1258/// `ZERODDS_MAX_PEER_PARTICIPANTS` (e.g. for dense multi-robot / >10
1259/// participants-per-host scenarios). Cap 120 (= the well-known-port
1260/// allocation window).
1261const INITIAL_PEER_MAX_PARTICIPANTS: u32 = 10;
1262
1263/// Effective peer-expansion limit: env `ZERODDS_MAX_PEER_PARTICIPANTS`
1264/// or [`INITIAL_PEER_MAX_PARTICIPANTS`], clamped to 1..=120.
1265fn initial_peer_max_participants() -> u32 {
1266    std::env::var("ZERODDS_MAX_PEER_PARTICIPANTS")
1267        .ok()
1268        .and_then(|s| s.parse::<u32>().ok())
1269        .unwrap_or(INITIAL_PEER_MAX_PARTICIPANTS)
1270        .clamp(1, 120)
1271}
1272
1273/// C1 multicast-free discovery: parses the env `ZERODDS_PEERS` (comma
1274/// list of `ip` or `ip:port`) into SPDP unicast initial-peer locators for
1275/// `domain_id`. Empty/invalid → empty list.
1276fn parse_initial_peers_env(domain_id: u32) -> Vec<Locator> {
1277    let mut out = Vec::new();
1278    let max = initial_peer_max_participants();
1279    if let Ok(s) = std::env::var("ZERODDS_PEERS") {
1280        for entry in s.split(',') {
1281            expand_initial_peer(entry.trim(), domain_id, max, &mut out);
1282        }
1283    }
1284    out
1285}
1286
1287/// Expands a single peer spec into locator(s) and appends them to `out`.
1288/// `ip:port` → exact locator. Just `ip` → well-known SPDP unicast ports
1289/// of participant indices `0..max_participants` (Spec §9.6.1.4.1).
1290/// Invalid specs are ignored.
1291fn expand_initial_peer(spec: &str, domain_id: u32, max_participants: u32, out: &mut Vec<Locator>) {
1292    if spec.is_empty() {
1293        return;
1294    }
1295    if let Some((ip_s, port_s)) = spec.rsplit_once(':') {
1296        if let (Ok(ip), Ok(port)) = (ip_s.parse::<Ipv4Addr>(), port_s.parse::<u16>()) {
1297            out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1298            return;
1299        }
1300    }
1301    if let Ok(ip) = spec.parse::<Ipv4Addr>() {
1302        for pid in 0..max_participants {
1303            if let Ok(port) = u16::try_from(spdp_unicast_port(domain_id, pid)) {
1304                out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1305            }
1306        }
1307    }
1308}
1309
1310/// Pure parser for the `ZERODDS_DATA_REPR_OFFER` syntax (testable without
1311/// env). Returns the DataRepresentationId list with the **spec values**
1312/// `XCDR=0`, `XCDR2=2` (XTypes 1.3 §7.6.3.1.2) — NOT version numbers.
1313/// `None` on empty/invalid input.
1314fn parse_data_repr_offer_str(s: &str) -> Option<Vec<i16>> {
1315    use zerodds_rtps::publication_data::data_representation as dr;
1316    let mut out = Vec::new();
1317    for tok in s.split(',').map(str::trim) {
1318        let v = match tok.to_ascii_uppercase().as_str() {
1319            "XCDR1" | "XCDR" | "1" => dr::XCDR,
1320            "XCDR2" | "2" => dr::XCDR2,
1321            _ => return None,
1322        };
1323        out.push(v);
1324    }
1325    if out.is_empty() { None } else { Some(out) }
1326}
1327
1328// ---------------------------------------------------------------------------
1329// Security-gate helpers
1330// ---------------------------------------------------------------------------
1331
1332/// Pull outbound UDP bytes through the security gate (when configured).
1333/// Without the `security` feature or without a gate: pass-through (clone
1334/// as Vec).
1335///
1336/// Errors in the gate are logged silently and the packet is **not** sent —
1337/// better to drop than leak plaintext.
1338/// DDS-Security 8.4.2.4: the RTPS message protection (message-level SRTPS)
1339/// does NOT apply to bootstrap traffic that must flow BEFORE the participant
1340/// crypto-key exchange: SPDP (participant discovery, to everyone) and the
1341/// ParticipantStatelessMessage (auth handshake). Wrapping them in SRTPS would
1342/// mean a not-yet-authenticated peer could not decrypt them
1343/// (no key) -> discovery/auth breaks (match timeout pub=0 sub=0). Detection
1344/// via the writer EntityId of the DATA/DATA_FRAG submessages.
1345#[cfg(feature = "security")]
1346fn rtps_message_protection_exempt(
1347    bytes: &[u8],
1348    discovery_plain: bool,
1349    liveliness_plain: bool,
1350) -> bool {
1351    use zerodds_rtps::wire_types::EntityId;
1352    // Bootstrap endpoints (§8.4.2.4): SPDP/Stateless/Volatile ALWAYS flow
1353    // plain (before/during key exchange resp. their own submessage protection).
1354    // Discovery plane (SEDP pub/sub, TypeLookup) is plain when discovery_
1355    // protection_kind=NONE; WLP (ParticipantMessage) plain when liveliness_
1356    // protection_kind=NONE. cyclone<->cyclone reference capture: under rtps_
1357    // protection=ENCRYPT + discovery=NONE cyclone sends the ENTIRE discovery
1358    // plane (DATA+HEARTBEAT+ACKNACK) PLAINTEXT — only user DATA is SRTPS-
1359    // wrapped. ZeroDDS must mirror this, otherwise it drops cyclone's plain
1360    // SubscriptionData as legacy_blocked -> no user-endpoint match.
1361    let entity_exempt = |e: EntityId| -> bool {
1362        matches!(
1363            e,
1364            EntityId::SPDP_BUILTIN_PARTICIPANT_WRITER
1365                | EntityId::SPDP_BUILTIN_PARTICIPANT_READER
1366                | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
1367                | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
1368                | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
1369                | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
1370        ) || (discovery_plain
1371            && matches!(
1372                e,
1373                EntityId::SEDP_BUILTIN_PUBLICATIONS_WRITER
1374                    | EntityId::SEDP_BUILTIN_PUBLICATIONS_READER
1375                    | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_WRITER
1376                    | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_READER
1377                    | EntityId::TL_SVC_REQ_WRITER
1378                    | EntityId::TL_SVC_REQ_READER
1379                    | EntityId::TL_SVC_REPLY_WRITER
1380                    | EntityId::TL_SVC_REPLY_READER
1381            ))
1382            || (liveliness_plain
1383                && matches!(
1384                    e,
1385                    EntityId::BUILTIN_PARTICIPANT_MESSAGE_WRITER
1386                        | EntityId::BUILTIN_PARTICIPANT_MESSAGE_READER
1387                ))
1388    };
1389    let Ok(parsed) = decode_datagram(bytes) else {
1390        return false;
1391    };
1392    // Datagram exempt if it has at least one relevant submessage AND
1393    // ALL relevant ones are exempt (.all) — otherwise a bundled
1394    // exempt+non-exempt datagram leaks the protection-worthy submessage.
1395    let relevant: alloc::vec::Vec<bool> = parsed
1396        .submessages
1397        .iter()
1398        .filter_map(|sm| match sm {
1399            ParsedSubmessage::Data(d) => {
1400                Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1401            }
1402            ParsedSubmessage::DataFrag(d) => {
1403                Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1404            }
1405            ParsedSubmessage::Heartbeat(h) => {
1406                Some(entity_exempt(h.reader_id) || entity_exempt(h.writer_id))
1407            }
1408            ParsedSubmessage::AckNack(a) => {
1409                Some(entity_exempt(a.reader_id) || entity_exempt(a.writer_id))
1410            }
1411            ParsedSubmessage::Gap(g) => {
1412                Some(entity_exempt(g.reader_id) || entity_exempt(g.writer_id))
1413            }
1414            ParsedSubmessage::NackFrag(n) => {
1415                Some(entity_exempt(n.reader_id) || entity_exempt(n.writer_id))
1416            }
1417            // SEC_PREFIX (Kx-Volatile, inner writer-id encrypted) -> exempt.
1418            ParsedSubmessage::Unknown { id: 0x31, .. } => Some(true),
1419            // Framing (INFO_DST/INFO_TS/...) -> neutral.
1420            _ => None,
1421        })
1422        .collect();
1423    !relevant.is_empty() && relevant.iter().all(|&b| b)
1424}
1425
1426#[cfg(feature = "security")]
1427fn secure_outbound_bytes<'a>(
1428    rt: &DcpsRuntime,
1429    bytes: &'a [u8],
1430) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1431    match &rt.config.security {
1432        // OUTBOUND is spec-strict (DDS-Security 8.4.2.4 Table 27 is_rtps_protected):
1433        // under rtps_protection the ENTIRE RTPS message is SRTPS-wrapped; ONLY the
1434        // "separate messages" (SPDP/Stateless/Volatile) flow plain. SEDP/WLP/
1435        // TypeLookup are NOT among them and must be wrapped — independent
1436        // of discovery_/liveliness_protection (those are orthogonal submessage layers).
1437        // -> discovery_plain=false, liveliness_plain=false forces the wrap.
1438        // OpenDDS' RtpsUdpReceiveStrategy::check_encoded otherwise drops every plain SEDP
1439        // as "Full message requires protection". cyclone does take the shortcut
1440        // (sends SEDP plain), but accepts wrapped SEDP inbound without issue.
1441        // The INBOUND path (secure_inbound_bytes) deliberately stays lenient and still
1442        // accepts cyclone's plain SEDP — the asymmetry is intentional.
1443        Some(gate) if rtps_message_protection_exempt(bytes, false, false) => {
1444            let _ = gate;
1445            Some(alloc::borrow::Cow::Borrowed(bytes))
1446        }
1447        Some(gate) => gate
1448            .transform_outbound(bytes)
1449            .ok()
1450            .map(alloc::borrow::Cow::Owned),
1451        None => Some(alloc::borrow::Cow::Borrowed(bytes)),
1452    }
1453}
1454
1455// Security off: no clone — the caller borrows the datagram bytes
1456// directly (copy 6 of the zero-copy audit eliminated).
1457#[cfg(not(feature = "security"))]
1458fn secure_outbound_bytes<'a>(
1459    _rt: &DcpsRuntime,
1460    bytes: &'a [u8],
1461) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1462    Some(alloc::borrow::Cow::Borrowed(bytes))
1463}
1464
1465/// Pull inbound UDP bytes through the security gate.
1466///
1467/// Expects an RTPS header with the GuidPrefix at bytes 8..20.
1468/// `None` → drop the packet.
1469///
1470/// Security: drop reasons are forwarded, differentiated, to the
1471/// configured `LoggingPlugin`:
1472/// * `Malformed`       → `Error`
1473/// * `LegacyBlocked`   → `Error`
1474/// * `PolicyViolation` → `Warning` (possible tampering)
1475/// * `CryptoError`     → `Warning` (tag mismatch, replay etc.)
1476#[cfg(feature = "security")]
1477fn secure_inbound_bytes<'a>(
1478    rt: &DcpsRuntime,
1479    bytes: &'a [u8],
1480    iface: &NetInterface,
1481) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1482    use zerodds_security_runtime::{InboundVerdict, LogLevel};
1483    let Some(gate) = &rt.config.security else {
1484        return Some(alloc::borrow::Cow::Borrowed(bytes));
1485    };
1486    // DDS-Security 8.4.2.4 (symmetric to outbound): SPDP/Stateless are
1487    // message-protection-exempt and ALWAYS arrive plain (also from cyclone). Without
1488    // this exception classify_inbound discards plain SPDP on the WAN iface under
1489    // rtps_protection as LegacyBlocked -> no discovery (match timeout).
1490    {
1491        let looks_srtps = bytes.len() > 20usize && bytes[20usize] == 0x33;
1492        if !looks_srtps
1493            && rtps_message_protection_exempt(
1494                bytes,
1495                gate.discovery_protection().unwrap_or(ProtectionLevel::None)
1496                    == ProtectionLevel::None,
1497                gate.liveliness_protection()
1498                    .unwrap_or(ProtectionLevel::None)
1499                    == ProtectionLevel::None,
1500            )
1501        {
1502            // SRTPS-exempt. BUT metadata_protection user DATA carries per-submessage
1503            // SEC_PREFIX/BODY/POSTFIX (§9.5.3.3, NO SRTPS) — that must still be
1504            // decrypted per-endpoint here, otherwise the reader gets the
1505            // SEC wrapper instead of the DATA. Volatile-Kx-SEC fails with None
1506            // (key_id not in user-remote_by_key_id) -> unchanged for the
1507            // Volatile handler in the metatraffic loop.
1508            if walk_submessages(bytes)
1509                .iter()
1510                .any(|(id, _, _)| *id == SMID_SEC_PREFIX)
1511            {
1512                let mut pk = [0u8; 12];
1513                pk.copy_from_slice(&bytes[8..20]);
1514                if let Some(mut dg) = unprotect_user_datagram(rt, bytes, &pk) {
1515                    match unprotect_user_payload(rt, &dg) {
1516                        PayloadDecode::Decoded(clear) => dg = clear,
1517                        PayloadDecode::Failed => return None,
1518                        PayloadDecode::NotEncrypted => {}
1519                    }
1520                    return Some(alloc::borrow::Cow::Owned(dg));
1521                }
1522            }
1523            return Some(alloc::borrow::Cow::Borrowed(bytes));
1524        }
1525    }
1526    let verdict = gate.classify_inbound(bytes, iface);
1527    let category = verdict.category();
1528    let (level, message): (LogLevel, String) = match &verdict {
1529        InboundVerdict::Accept(out) => {
1530            // Cross-vendor user DATA: cyclone protects the DATA submessage as a
1531            // SEC_PREFIX/BODY/POSTFIX sequence (metadata_protection=ENCRYPT). Before
1532            // the submessage parse, transform it back with the sender's data key
1533            // (GuidPrefix = bytes[8..20]). `unprotect_user_datagram` returns
1534            // `None` when no SEC_* sequence is present → normal accept path.
1535            // OUTER layer first (metadata_protection, SEC_PREFIX/BODY/
1536            // POSTFIX), then the INNER one (data_protection, encrypted
1537            // SerializedPayload §9.5.3.3.1). Both can be active at once
1538            // (full secure profile); each returns `None` when its layer
1539            // is not present -> then the datagram stays unchanged.
1540            let mut dg: alloc::vec::Vec<u8> = out.clone();
1541            if dg.len() >= 20 {
1542                let mut pk = [0u8; 12];
1543                pk.copy_from_slice(&dg[8..20]);
1544                if let Some(clear) = unprotect_user_datagram(rt, &dg, &pk) {
1545                    dg = clear;
1546                }
1547            }
1548            match unprotect_user_payload(rt, &dg) {
1549                PayloadDecode::Decoded(clear) => dg = clear,
1550                // Undecodable encrypted payload -> discard the datagram
1551                // (no ciphertext garbage to the reader; reliable re-send resp. another
1552                // copy delivers the sample later).
1553                PayloadDecode::Failed => return None,
1554                PayloadDecode::NotEncrypted => {}
1555            }
1556            return Some(alloc::borrow::Cow::Owned(dg));
1557        }
1558        InboundVerdict::Malformed => (
1559            LogLevel::Error,
1560            alloc::format!(
1561                "inbound datagram too short ({} bytes, iface={:?})",
1562                bytes.len(),
1563                iface
1564            ),
1565        ),
1566        InboundVerdict::LegacyBlocked => (
1567            LogLevel::Error,
1568            alloc::format!(
1569                "legacy plaintext peer on protected domain \
1570                 (iface={iface:?}, allow_unauthenticated_participants=false)"
1571            ),
1572        ),
1573        InboundVerdict::PolicyViolation(msg) => {
1574            (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1575        }
1576        InboundVerdict::CryptoError(msg) => {
1577            (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1578        }
1579    };
1580    if let Some(logger) = &rt.config.security_logger {
1581        // Participant ident: GuidPrefix (or 0-padding for Malformed).
1582        let mut participant = [0u8; 16];
1583        if bytes.len() >= 20 {
1584            participant[..12].copy_from_slice(&bytes[8..20]);
1585        }
1586        logger.log(level, participant, category, &message);
1587    }
1588    None
1589}
1590
1591#[cfg(not(feature = "security"))]
1592fn secure_inbound_bytes<'a>(
1593    _rt: &DcpsRuntime,
1594    bytes: &'a [u8],
1595) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1596    Some(alloc::borrow::Cow::Borrowed(bytes))
1597}
1598
1599/// Default interface class for inbound dispatch when the socket does not
1600/// belong to the `outbound_pool`. In the v1.4 setup (without
1601/// `interface_bindings`), all packets run through `user_unicast` and are
1602/// classified as `Wan` — the most conservative assumption (protection
1603/// rules apply as in the single-interface case).
1604#[cfg(feature = "security")]
1605const DEFAULT_INBOUND_IFACE: NetInterface = NetInterface::Wan;
1606
1607/// Per-reader outbound transform.
1608///
1609/// Looks up in the writer slot which `ProtectionLevel` the matched reader
1610/// expects at the given `target` locator, then pulls the datagram through
1611/// the security gate individually. This way each reader gets a wire
1612/// payload matching its security profile (Legacy=plain, Fast=Sign,
1613/// Secure=Encrypt).
1614///
1615/// Fallback paths:
1616/// * No security gate configured → passthrough.
1617/// * No `locator_to_peer` entry (reader not yet matched via SEDP) →
1618///   `transform_outbound` with the domain rule — that is the homogeneous
1619///   v1.4 path.
1620/// * The gate returns an error → `None` (the caller drops — better no
1621///   plaintext leak).
1622#[cfg(feature = "security")]
1623fn secure_outbound_for_target(
1624    rt: &DcpsRuntime,
1625    writer_eid: EntityId,
1626    bytes: &[u8],
1627    target: &Locator,
1628) -> Option<Vec<u8>> {
1629    let Some(gate) = &rt.config.security else {
1630        return Some(bytes.to_vec());
1631    };
1632    // FU2 S3: fallback level from our own governance (data_protection_
1633    // kind), in case the matched reader did not announce an explicit SEDP
1634    // security_info level. This way user data to an authenticated peer is
1635    // encrypted per our own governance, while SPDP/SEDP metatraffic
1636    // bootstraps plaintext over rtps_protection_kind=NONE.
1637    // Governance `data_protection` is a FLOOR, not a mere fallback: a
1638    // per-reader level can only STRENGTHEN (e.g. legacy plaintext is only
1639    // allowed if the domain policy itself permits plaintext), never fall
1640    // below the domain policy. Otherwise a matched-but-not-authenticated
1641    // peer (foreign CA, SEDP match over plaintext discovery,
1642    // reader_protection=None) leaks plaintext user data.
1643    let gov_data_level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1644    // metadata_protection (§8.4.2.4 / §9.5.3.3): EVERY writer submessage (DATA,
1645    // HEARTBEAT, GAP) is SEC_PREFIX/BODY/POSTFIX-wrapped per-submessage —
1646    // TARGET-INDEPENDENT, since the per-endpoint writer key is local (the peer fetches
1647    // it via datawriter_crypto_token). Must take effect BEFORE the locator-based reader
1648    // resolution: otherwise tick HEARTBEATs/GAPs to not-yet-locator-
1649    // matched targets fall into the None branch -> with rtps=NONE PLAIN -> leak + no
1650    // reliable recovery (breaks already zero<->zero). data_protection (inner
1651    // payload layer) first, then the outer submessage layer.
1652    if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1653        let inner = if gov_data_level != ProtectionLevel::None {
1654            protect_user_payload(rt, bytes)?
1655        } else {
1656            bytes.to_vec()
1657        };
1658        let meta_sec = protect_user_datagram(rt, &inner)?;
1659        // Under rtps_protection message-level SRTPS MUST additionally go on top —
1660        // BOTH layers, like cyclone<->cyclone. Without it the peer would see the
1661        // metadata-SEC-DATA as "clear submsg from protected src" and discard it.
1662        if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1663            return gate.transform_outbound(&meta_sec).ok();
1664        }
1665        return Some(meta_sec);
1666    }
1667    let resolved = rt.writer_slot(writer_eid).and_then(|arc| {
1668        arc.lock().ok().and_then(|slot| {
1669            let pk = slot.locator_to_peer.get(target).copied()?;
1670            // An EXPLICITLY negotiated per-reader level is respected: a
1671            // legacy-v1.4 reader has reader_protection=None and MUST get plaintext,
1672            // otherwise it cannot decode (heterogeneous domain). Only
1673            // when NO entry exists (matched via plaintext discovery, but
1674            // no level negotiated -> potentially unauthenticated) does the
1675            // governance data_protection FLOOR apply as leak protection.
1676            // Governance data_protection is a FLOOR (§8.4.2.4, memory-documented):
1677            // a per-reader level can only STRENGTHEN, never fall below the domain
1678            // policy. A reader discovered via secure SEDP whose security_info parses
1679            // to `Some(None)` (no is_payload_protected bit detected, discovery=
1680            // ENCRYPT) would otherwise yield level=None -> Some(None) arm -> PLAINTEXT
1681            // leak, although the domain requires data_protection=ENCRYPT (disc-data-
1682            // enc: zerodds sent user DATA without the N-flag -> OpenDDS decode_serialized_
1683            // payload=0 -> no echo). `.max` enforces at least the governance FLOOR.
1684            // With gov=None legacy plaintext (reader_lv) stays allowed.
1685            let level = match slot.reader_protection.get(&pk).copied() {
1686                Some(reader_lv) => reader_lv.max(gov_data_level),
1687                None => gov_data_level,
1688            };
1689            Some((pk, level))
1690        })
1691    });
1692    match resolved {
1693        // Matched reader with Sign/Encrypt: cyclone-conformant SUBMESSAGE
1694        // protection (SEC_PREFIX/BODY/POSTFIX around the DATA submessage, local
1695        // data key) instead of message-level SRTPS — `metadata_protection_kind=
1696        // ENCRYPT`, §9.5.3.3. cyclone decodes with the key sent via datawriter_crypto_
1697        // tokens. `None` level = byte-identical passthrough.
1698        Some((peer_key, level)) if level != ProtectionLevel::None => {
1699            // Layer choice per governance (DDS-Security §8.4.2.4 vs §7.3.7):
1700            //  * metadata_protection_kind != NONE -> per-submessage protection
1701            //    (`encode_datawriter_submessage`, SEC_PREFIX/BODY/POSTFIX) for
1702            //    EVERY writer submessage (DATA, HEARTBEAT, GAP, ...). This is the
1703            //    cyclone interop path: cyclone expects HEARTBEAT/GAP SEC_*-
1704            //    wrapped too, otherwise its reader never NACKs (no reliable recovery).
1705            //  * otherwise (only rtps_protection_kind != NONE) -> message-level SRTPS
1706            //    via `transform_outbound_for` (whole message, §7.3.7).
1707            // INNER layer (§9.5.3.3.1): data_protection encrypts ONLY the
1708            // SerializedPayload of each DATA submessage. Applied BEFORE the outer
1709            // submessage/message layer — cyclone-conformant
1710            // nesting (§9.5.3.3): data_protection (inner) + metadata_
1711            // protection (outer). With pure data_protection this is the
1712            // only + complete protection.
1713            let inner: Vec<u8> = if gov_data_level != ProtectionLevel::None {
1714                // Crypto error -> drop instead of leak (None propagated via `?`).
1715                protect_user_payload(rt, bytes)?
1716            } else {
1717                bytes.to_vec()
1718            };
1719            // OUTER layer choice (DDS-Security §8.4.2.4 / §7.3.7):
1720            if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None
1721            {
1722                // metadata_protection -> per-submessage protection (DATA, HEARTBEAT,
1723                // GAP, ...) with the per-endpoint writer key (cyclone interop path).
1724                // Under additional rtps_protection message-level SRTPS MUST go on top
1725                // (both layers) — otherwise "clear submsg from protected src".
1726                match protect_user_datagram(rt, &inner) {
1727                    Some(ms)
1728                        if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1729                            != ProtectionLevel::None =>
1730                    {
1731                        gate.transform_outbound(&ms).ok()
1732                    }
1733                    other => other,
1734                }
1735            } else if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1736                != ProtectionLevel::None
1737            {
1738                // rtps_protection -> message-level SRTPS (whole message, §7.3.7),
1739                // per-reader key.
1740                gate.transform_outbound_for(&peer_key, &inner, level).ok()
1741            } else {
1742                // only data_protection -> the payload layer is already the
1743                // complete protection (§9.5.3.3.1). Header/InlineQoS stay
1744                // plaintext, the encrypted payload carries the N-flag.
1745                Some(inner)
1746            }
1747        }
1748        // Matched reader with level None: a legacy-v1.4 reader (explicit
1749        // SEDP legacy or NONE governance) gets byte-identical plaintext —
1750        // message-level SRTPS would make it undecryptable.
1751        Some(_) => {
1752            // Matched reader with data level None: under rtps_protection the
1753            // message MUST still be message-level-SRTPS-wrapped (§8.4.2.4) —
1754            // the data_protection level only controls the payload/submessage layer.
1755            // Without it user DATA/HEARTBEAT leaks plain, although the domain
1756            // requires rtps_protection=ENCRYPT (the peer discards it as legacy).
1757            if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1758                gate.transform_outbound(bytes).ok()
1759            } else {
1760                Some(bytes.to_vec())
1761            }
1762        }
1763        // No locator-resolved reader: multicast/meta bootstrap OR a
1764        // user reader whose locator is (not yet) in `locator_to_peer`
1765        // (e.g. discovered via secure SEDP, discovery_protection=ENCRYPT). The
1766        // data_protection (inner payload layer §9.5.3.3.1) is TARGET-INDEPENDENT
1767        // (local writer key) and MUST still apply for a user writer —
1768        // otherwise under data_protection=ENCRYPT the user DATA leaks PLAINTEXT (N-flag
1769        // missing -> a spec-conformant remote reader never calls `decode_serialized_payload`
1770        // -> no sample, no echo; disc-data-enc stall, source-documented: OpenDDS
1771        // decode_serialized_payload=0). ONLY for user writers — SPDP/SEDP builtin DATA
1772        // must bootstrap plaintext (otherwise undecodable before key exchange).
1773        None => {
1774            use zerodds_rtps::wire_types::EntityKind;
1775            let is_user_writer = matches!(
1776                writer_eid.entity_kind,
1777                EntityKind::UserWriterWithKey | EntityKind::UserWriterNoKey
1778            );
1779            if is_user_writer && gov_data_level != ProtectionLevel::None {
1780                let inner = protect_user_payload(rt, bytes)?;
1781                gate.transform_outbound(&inner).ok()
1782            } else {
1783                gate.transform_outbound(bytes).ok()
1784            }
1785        }
1786    }
1787}
1788
1789#[cfg(not(feature = "security"))]
1790fn secure_outbound_for_target(
1791    _rt: &DcpsRuntime,
1792    _writer_eid: EntityId,
1793    bytes: &[u8],
1794    _target: &Locator,
1795) -> Option<Vec<u8>> {
1796    Some(bytes.to_vec())
1797}
1798
1799/// FU2 S3: data_protection-aware user DATA outbound. Encrypts the
1800/// datagram with the governance `data_protection` level. `transform_outbound_
1801/// for` ignores the `peer_key` and uses the local key — the ciphertext
1802/// is decryptable for EVERY authenticated peer (with our token),
1803/// non-authenticated peers cannot read it. A `None` level falls
1804/// back to message-level (`rtps_protection` resp. passthrough). Used for
1805/// UDP + in-process fastpath + SHM UNIFORMLY, so the
1806/// inproc path is secured too.
1807#[cfg(feature = "security")]
1808fn secure_user_outbound<'a>(
1809    rt: &DcpsRuntime,
1810    bytes: &'a [u8],
1811) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1812    let Some(gate) = &rt.config.security else {
1813        return Some(alloc::borrow::Cow::Borrowed(bytes));
1814    };
1815    let level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1816    if matches!(level, ProtectionLevel::None) {
1817        gate.transform_outbound(bytes)
1818            .ok()
1819            .map(alloc::borrow::Cow::Owned)
1820    } else {
1821        gate.transform_outbound_for(&[0u8; 12], bytes, level)
1822            .ok()
1823            .map(alloc::borrow::Cow::Owned)
1824    }
1825}
1826
1827#[cfg(not(feature = "security"))]
1828fn secure_user_outbound<'a>(
1829    _rt: &DcpsRuntime,
1830    bytes: &'a [u8],
1831) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1832    Some(alloc::borrow::Cow::Borrowed(bytes))
1833}
1834
1835/// Sends `bytes` to `target` on the matching interface socket.
1836/// Falls back to `rt.user_unicast` if no
1837/// pool is configured or no binding matches the target range
1838/// and no default binding is set either.
1839#[cfg(feature = "security")]
1840fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1841    if let Some(pool) = &rt.outbound_pool {
1842        if let Some((socket, _iface)) = pool.route(target) {
1843            let _ = socket.send(target, bytes);
1844            return;
1845        }
1846    }
1847    let _ = rt.user_unicast.send(target, bytes);
1848}
1849
1850#[cfg(not(feature = "security"))]
1851fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1852    let _ = rt.user_unicast.send(target, bytes);
1853}
1854
1855/// User-writer slot in the runtime. Carries ReliableWriter + topic meta +
1856/// fragment size (from QoS).
1857struct UserWriterSlot {
1858    writer: ReliableWriter,
1859    topic_name: String,
1860    type_name: String,
1861    reliable: bool,
1862    durability: zerodds_qos::DurabilityKind,
1863    /// Deadline period in nanoseconds (0 == INFINITE, no monitoring).
1864    deadline_nanos: u64,
1865    /// Last successful `write` relative to `DcpsRuntime::start_instant`.
1866    last_write: Option<Duration>,
1867    /// Counter for missed deadlines (Spec §2.2.4.2.9).
1868    offered_deadline_missed_count: u64,
1869    /// Counter for LivelinessLost detections from the writer's view
1870    /// (Spec §2.2.4.2.10). Incremented in `check_writer_liveliness` on
1871    /// manual-lease overrun. 0 == not monitored.
1872    liveliness_lost_count: u64,
1873    /// Last assert time (manual liveliness). `None` == never.
1874    last_liveliness_assert: Option<Duration>,
1875    /// Per-policy counter for offered_incompatible_qos. Spec
1876    /// §2.2.4.2.4.2 — writer side. Incremented on
1877    /// `wire_writer_to_remote_reader` reject.
1878    offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus,
1879    /// Lifespan duration in nanoseconds (0 == INFINITE, no expiry).
1880    lifespan_nanos: u64,
1881    /// Per sample SN the insert time (relative to start_instant).
1882    /// Removed from front on expiry — SNs are monotonic, lifespan
1883    /// is constant, so the expiry prefix is always front.
1884    sample_insert_times:
1885        alloc::collections::VecDeque<(zerodds_rtps::wire_types::SequenceNumber, Duration)>,
1886    /// Liveliness kind (Automatic / ManualByParticipant / ManualByTopic).
1887    liveliness_kind: zerodds_qos::LivelinessKind,
1888    /// Lease duration in nanoseconds (0 == INFINITE).
1889    liveliness_lease_nanos: u64,
1890    /// Ownership mode.
1891    ownership: zerodds_qos::OwnershipKind,
1892    /// Ownership strength (Spec §2.2.3.2). Mirrored in the same-runtime
1893    /// dispatch into `UserSample::Alive.writer_strength`, so that
1894    /// EXCLUSIVE ownership logic in the reader also works for intra-process
1895    /// loopback.
1896    ownership_strength: i32,
1897    /// Partition list.
1898    partition: Vec<String>,
1899    /// Per-matched-reader ProtectionLevel. Derived at the
1900    /// SEDP match from `sub.security_info`. `None` entries
1901    /// for legacy readers. Empty for writers without matched
1902    /// security peers — then the hot path is unchanged.
1903    #[cfg(feature = "security")]
1904    reader_protection: BTreeMap<[u8; 12], ProtectionLevel>,
1905    /// Mapping Locator → GuidPrefix for the writer tick loop, so that
1906    /// `secure_outbound_for_target` can look up the protection per target
1907    /// without breaking the writer-tick API (`dg.targets` are
1908    /// locator lists today).
1909    #[cfg(feature = "security")]
1910    locator_to_peer: BTreeMap<Locator, [u8; 12]>,
1911    /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the writer type
1912    /// (from `T::TYPE_IDENTIFIER` in `UserWriterConfig`).
1913    type_identifier: zerodds_types::TypeIdentifier,
1914    /// D.5g — per-writer override for the DataRepresentation offer.
1915    /// `None` = runtime default. `Some(vec)` = hardcoded per writer.
1916    data_rep_offer_override: Option<Vec<i16>>,
1917    /// Type extensibility of the writer type (FINAL/APPENDABLE/MUTABLE).
1918    /// Together with the offer `first` element it determines the
1919    /// encapsulation header of the user payload (see
1920    /// [`user_payload_encap`]). Default `Final`; set by codegen/FFI via
1921    /// `set_user_writer_wire_extensibility` when the type
1922    /// is appendable/mutable (relevant for XCDR2 wire: D_CDR2/PL_CDR2).
1923    wire_extensibility: zerodds_types::qos::ExtensibilityForRepr,
1924    /// Spec §2.2.3.5 DurabilityService — with Durability=Transient/
1925    /// Persistent the backend holds in addition to the writer's own
1926    /// HistoryCache. On the first late-joiner match in
1927    /// `wire_writer_to_remote_reader` the backend samples are
1928    /// (re-)injected into the HistoryCache, so that the RTPS reliable
1929    /// path delivers them to the reader. `None` for Volatile/
1930    /// TransientLocal (the cache suffices).
1931    durability_backend: Option<alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>>,
1932    /// `true` as soon as the backend has been replayed once into the
1933    /// HistoryCache. Prevents repeated re-injection on further matches.
1934    backend_primed: bool,
1935}
1936
1937/// The listener dispatch carries, alongside the `UserSample`, a
1938/// zero-copy view on the original `Arc<[u8]>` with an encap offset
1939/// (lever-E zero-copy path).
1940pub type UserSampleWithEncap = (UserSample, Option<(Arc<[u8]>, usize)>);
1941
1942/// Sample channel item: either data payload or lifecycle marker.
1943/// Lifecycle is reconstructed by the wire path as `key_hash + ChangeKind` from
1944/// the PID_STATUS_INFO header; the DataReader DCPS layer
1945/// translates that into `__push_lifecycle`.
1946#[derive(Debug, Clone)]
1947pub enum UserSample {
1948    /// Normal sample with payload (CDR-encoded application type).
1949    /// `writer_guid` is the 16-byte GUID of the emitting writer
1950    /// — needed by the subscriber for exclusive-ownership resolution
1951    /// (DDS 1.4 §2.2.3.23 / §2.2.2.5.5).
1952    Alive {
1953        /// CDR payload (without encapsulation header). Zero-copy container:
1954        /// typically holds an `Arc<[u8]>` slice into the RTPS wire datagram
1955        /// without a heap re-alloc. See `docs/specs/zerodds-zero-copy-1.0.md`.
1956        payload: crate::sample_bytes::SampleBytes,
1957        /// Writer GUID — for strongest-writer selection.
1958        writer_guid: [u8; 16],
1959        /// Writer `ownership_strength` at the time of receipt.
1960        /// `0` if the writer is not yet known via discovery
1961        /// (the reader treats this as default strength = spec-conformant
1962        /// for shared-ownership topics; for exclusive the
1963        /// reader filters the real strength against the current owner).
1964        writer_strength: i32,
1965        /// XCDR version of the `payload` — extracted from the encapsulation
1966        /// header of the wire sample (RTPS 2.5 §10.5) BEFORE the
1967        /// header was stripped: `0` = XCDR1 (CDR/PL_CDR), `1` =
1968        /// XCDR2 (CDR2/D_CDR2/PL_CDR2). The typed consumer
1969        /// needs this to decode the body with the correct alignment rule
1970        /// (XTypes 1.3 §7.4.3.4.2).
1971        representation: u8,
1972    },
1973    /// Lifecycle marker (dispose / unregister) — the reader sets
1974    /// InstanceState accordingly.
1975    Lifecycle {
1976        /// Key hash of the affected instance (16 byte).
1977        key_hash: [u8; 16],
1978        /// `NotAliveDisposed` / `NotAliveUnregistered` /
1979        /// `NotAliveDisposedUnregistered`.
1980        kind: zerodds_rtps::history_cache::ChangeKind,
1981    },
1982}
1983
1984/// User-reader slot. ReliableReader + topic meta + channel to the
1985/// DataReader (DCPS API side).
1986/// Listener callback for sample arrival.
1987///
1988/// Fired synchronously by `recv_user_data_loop` in the recv-thread
1989/// context as soon as an alive sample lands in the reader HistoryCache.
1990/// Eliminates the polling latency of `zerodds_reader_take()` —
1991/// the listener path typically saves 50-100 µs per side.
1992///
1993/// **Contract** (analogous to DDS spec §2.2.4.4 listener semantics):
1994/// * The callback runs on the recv thread, NOT the user thread.
1995/// * Short and non-blocking. No I/O, no locks, no
1996///   ZeroDDS API calls inside.
1997/// * `bytes` points to the CDR payload of the alive sample (without
1998///   encapsulation header). Lifetime only for the duration of the
1999///   callback; copy if needed beyond the call.
2000/// * Disposed/unregistered lifecycle events do NOT fire the listener
2001///   (only `Alive` samples) — for lifecycle tracking
2002///   keep using `zerodds_reader_take()` or add a
2003///   lifecycle-listener API.
2004///
2005/// Data-available listener. Arguments: CDR body (without encapsulation
2006/// header) and the XCDR version of the sample (`0` = XCDR1, `1` = XCDR2)
2007/// — the typed consumer needs the latter for the alignment
2008/// rule on decode (XTypes 1.3 §7.4.3.4.2).
2009pub type UserReaderListener = alloc::boxed::Box<dyn Fn(&[u8], u8) + Send + Sync + 'static>;
2010
2011struct UserReaderSlot {
2012    reader: ReliableReader,
2013    topic_name: String,
2014    type_name: String,
2015    sample_tx: mpsc::Sender<UserSample>,
2016    /// Spec §3 zerodds-async-1.0: async waker slot. Registered by the
2017    /// async reader; on `sample_tx.send` we call
2018    /// `waker.wake()`. `None` if no async reader is active.
2019    async_waker: alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>,
2020    /// Listener callback for alive samples.
2021    /// Fired synchronously by `recv_user_data_loop`. `None` =
2022    /// no listener registered (the user polls via
2023    /// `zerodds_reader_take()`). Arc, so the recv thread can
2024    /// execute the callback cloned without another lock (minimize lock
2025    /// hold time).
2026    listener: Option<alloc::sync::Arc<UserReaderListener>>,
2027    durability: zerodds_qos::DurabilityKind,
2028    /// Deadline period in nanoseconds (0 == INFINITE).
2029    deadline_nanos: u64,
2030    /// Time of the last received sample relative to runtime start.
2031    last_sample_received: Option<Duration>,
2032    /// Counter for missed deadline expectations (Spec §2.2.4.2.11).
2033    requested_deadline_missed_count: u64,
2034    /// Per-policy counter for requested_incompatible_qos. Spec
2035    /// §2.2.4.2.6.5 — reader side. Incremented on
2036    /// `wire_reader_to_remote_writer` reject.
2037    requested_incompatible_qos: crate::status::RequestedIncompatibleQosStatus,
2038    /// Sample-lost counter (Spec §2.2.4.2.6.2). Incremented
2039    /// by `record_sample_lost`.
2040    sample_lost_count: u64,
2041    /// Sample-rejected counter (Spec §2.2.4.2.6.3). Incremented
2042    /// by `record_sample_rejected`.
2043    sample_rejected: crate::status::SampleRejectedStatus,
2044    /// Monotonically increasing count of alive samples delivered to the
2045    /// user. Serves as a non-consuming data-availability detector for
2046    /// `on_data_available` (DDS 1.4 §2.2.4.2.6.1) — unlike
2047    /// `last_sample_received`, this counter is only bumped on real sample
2048    /// delivery, never by the deadline path. Read via
2049    /// [`DcpsRuntime::user_reader_samples_delivered`].
2050    samples_delivered_count: u64,
2051    /// Reader-side requested liveliness lease (0 == INFINITE).
2052    liveliness_lease_nanos: u64,
2053    /// Reader-side requested liveliness kind.
2054    liveliness_kind: zerodds_qos::LivelinessKind,
2055    /// Counter: how often the writer was marked "alive"
2056    /// (Spec §2.2.4.2.14 alive_count).
2057    liveliness_alive_count: u64,
2058    /// Counter: how often it was marked "not_alive" (lease expired).
2059    liveliness_not_alive_count: u64,
2060    /// Current "alive/not-alive" state from the reader's view.
2061    liveliness_alive: bool,
2062    /// Ownership.
2063    ownership: zerodds_qos::OwnershipKind,
2064    /// Partition.
2065    partition: Vec<String>,
2066    /// Per-writer strength cache for exclusive-ownership resolution
2067    /// (DDS 1.4 §2.2.3.23). Filled by `wire_reader_to_remote_writer`
2068    /// from each `PublicationBuiltinTopicData.ownership_strength`;
2069    /// `delivered_to_user_sample` looks it up here to pack the
2070    /// strength into `UserSample::Alive`.
2071    writer_strengths: alloc::collections::BTreeMap<[u8; 16], i32>,
2072    /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the reader type
2073    /// (from `T::TYPE_IDENTIFIER` in `UserReaderConfig`). Default
2074    /// `TypeIdentifier::None` signals "no TypeIdentifier" —
2075    /// the match falls back to a pure `type_name` comparison
2076    /// (DDS 1.4 §2.2.3 default path).
2077    type_identifier: zerodds_types::TypeIdentifier,
2078    /// XTypes 1.3 §7.6.3.7 — TCE policy controlling the strictness
2079    /// of the XTypes match path.
2080    type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2081}
2082
2083/// Helper struct for announcing a local publication/subscription
2084/// as SEDP BuiltinTopicData. The caller creates it once per
2085/// writer/reader registration and passes it to SedpStack.
2086/// QoS config for registering a user writer with the runtime.
2087/// Bundles all policies that go on the wire via SEDP plus the local
2088/// Per-endpoint discovery info for ROS 2 endpoint-info-by-topic introspection
2089/// (`rmw_get_publishers_info_by_topic` / `rmw_get_subscriptions_info_by_topic`,
2090/// the data behind `ros2 topic info -v`). Covers local user endpoints plus
2091/// remote SEDP-discovered ones. QoS is best-effort from what discovery carries
2092/// (history/depth are not on the wire, so the consumer fills rmw defaults).
2093#[derive(Debug, Clone)]
2094pub struct DiscoveredEndpointInfo {
2095    /// DDS topic name (raw, un-demangled).
2096    pub topic_name: String,
2097    /// IDL type name (raw).
2098    pub type_name: String,
2099    /// 16-byte endpoint GUID: 12-byte participant prefix + 4-byte entity id.
2100    /// Bytes 0..12 identify the owning participant (for node-name lookup).
2101    pub endpoint_guid: [u8; 16],
2102    /// RELIABLE (`true`) vs BEST_EFFORT (`false`).
2103    pub reliable: bool,
2104    /// TRANSIENT_LOCAL or stronger (`true`) vs VOLATILE (`false`).
2105    pub transient_local: bool,
2106    /// Deadline period in whole seconds (0 == INFINITE).
2107    pub deadline_seconds: i32,
2108    /// Lifespan in whole seconds (0 == INFINITE; always 0 for subscriptions).
2109    pub lifespan_seconds: i32,
2110    /// Liveliness lease in whole seconds (0 == INFINITE).
2111    pub liveliness_lease_seconds: i32,
2112}
2113
2114/// Packs an RTPS [`Guid`] into the 16-byte wire form (prefix ++ entity id).
2115fn guid_to_16(g: Guid) -> [u8; 16] {
2116    let mut b = [0u8; 16];
2117    b[..12].copy_from_slice(&g.prefix.to_bytes());
2118    b[12..].copy_from_slice(&g.entity_id.to_bytes());
2119    b
2120}
2121
2122/// monitoring. Avoids 10+-argument functions.
2123#[derive(Debug, Clone)]
2124pub struct UserWriterConfig {
2125    /// Topic name (DDS topic).
2126    pub topic_name: String,
2127    /// IDL type name.
2128    pub type_name: String,
2129    /// `true` = RELIABLE, `false` = BEST_EFFORT.
2130    pub reliable: bool,
2131    /// Durability.
2132    pub durability: zerodds_qos::DurabilityKind,
2133    /// Deadline period (offered).
2134    pub deadline: zerodds_qos::DeadlineQosPolicy,
2135    /// Lifespan duration (writer-only).
2136    pub lifespan: zerodds_qos::LifespanQosPolicy,
2137    /// Liveliness (offered).
2138    pub liveliness: zerodds_qos::LivelinessQosPolicy,
2139    /// Ownership mode (Shared / Exclusive).
2140    pub ownership: zerodds_qos::OwnershipKind,
2141    /// Strength for Exclusive (ignored for Shared).
2142    pub ownership_strength: i32,
2143    /// Partition list. Empty == default partition (`""`).
2144    pub partition: Vec<String>,
2145    /// UserData QoS (Spec §2.2.3.1) — opaque `sequence<octet>`, propagated
2146    /// via discovery.
2147    pub user_data: Vec<u8>,
2148    /// TopicData QoS (Spec §2.2.3.3).
2149    pub topic_data: Vec<u8>,
2150    /// GroupData QoS (Spec §2.2.3.2).
2151    pub group_data: Vec<u8>,
2152    /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up). Default
2153    /// `TypeIdentifier::None` for the `T::TYPE_IDENTIFIER` default.
2154    pub type_identifier: zerodds_types::TypeIdentifier,
2155
2156    /// D.5g — per-writer override of the DataRepresentation offer list.
2157    /// `None` = use `RuntimeConfig::data_representation_offer`.
2158    /// `Some(vec)` = overridden per writer (e.g. `[XCDR2]` for
2159    /// a modern-only pub).
2160    pub data_representation_offer: Option<Vec<i16>>,
2161}
2162
2163/// QoS config for registering a user reader.
2164#[derive(Debug, Clone)]
2165pub struct UserReaderConfig {
2166    /// Topic name.
2167    pub topic_name: String,
2168    /// IDL type name.
2169    pub type_name: String,
2170    /// `true` = RELIABLE, `false` = BEST_EFFORT.
2171    pub reliable: bool,
2172    /// Durability (requested).
2173    pub durability: zerodds_qos::DurabilityKind,
2174    /// Deadline (requested).
2175    pub deadline: zerodds_qos::DeadlineQosPolicy,
2176    /// Liveliness (requested).
2177    pub liveliness: zerodds_qos::LivelinessQosPolicy,
2178    /// Ownership.
2179    pub ownership: zerodds_qos::OwnershipKind,
2180    /// Partition.
2181    pub partition: Vec<String>,
2182    /// UserData QoS (Spec §2.2.3.1).
2183    pub user_data: Vec<u8>,
2184    /// TopicData QoS (Spec §2.2.3.3).
2185    pub topic_data: Vec<u8>,
2186    /// GroupData QoS (Spec §2.2.3.2).
2187    pub group_data: Vec<u8>,
2188    /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up).
2189    pub type_identifier: zerodds_types::TypeIdentifier,
2190    /// TypeConsistencyEnforcement (XTypes §7.6.3.7) — controls how strictly
2191    /// the reader match checks XTypes compatibility.
2192    pub type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2193
2194    /// D.5g — per-reader override of the DataRepresentation accept list.
2195    /// `None` = use `RuntimeConfig::data_representation_offer`.
2196    /// `Some(vec)` = overridden per reader (e.g. `[XCDR1]` for
2197    /// a reader that accepts only legacy XCDR1 wire).
2198    pub data_representation_offer: Option<Vec<i16>>,
2199}
2200
2201fn build_publication_data(
2202    owner_prefix: GuidPrefix,
2203    writer_eid: EntityId,
2204    cfg: &UserWriterConfig,
2205    runtime_offer: &[i16],
2206    user_locator: Locator,
2207) -> zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2208    use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2209    zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2210        key: Guid::new(owner_prefix, writer_eid),
2211        participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2212        topic_name: cfg.topic_name.clone(),
2213        type_name: cfg.type_name.clone(),
2214        durability: cfg.durability,
2215        reliability: ReliabilityQosPolicy {
2216            kind: if cfg.reliable {
2217                ReliabilityKind::Reliable
2218            } else {
2219                ReliabilityKind::BestEffort
2220            },
2221            max_blocking_time: QosDuration::from_millis(100_i32),
2222        },
2223        ownership: cfg.ownership,
2224        ownership_strength: cfg.ownership_strength,
2225        liveliness: cfg.liveliness,
2226        deadline: cfg.deadline,
2227        lifespan: cfg.lifespan,
2228        partition: cfg.partition.clone(),
2229        user_data: cfg.user_data.clone(),
2230        topic_data: cfg.topic_data.clone(),
2231        group_data: cfg.group_data.clone(),
2232        type_information: None,
2233        // D.5g — PID_DATA_REPRESENTATION (XTypes 1.3 §7.6.3.1.1, RTPS 2.5
2234        // PID 0x0073). Per-Writer-Override (cfg.data_representation_offer)
2235        // overrides the RuntimeConfig default.
2236        data_representation: cfg
2237            .data_representation_offer
2238            .clone()
2239            .unwrap_or_else(|| runtime_offer.to_vec()),
2240        // Security: the PolicyEngine fills this later. Default
2241        // None = legacy behavior (no EndpointSecurityInfo PID).
2242        security_info: None,
2243        // .B — RPC discovery PIDs. Default None: no RPC endpoint;
2244        // the RpcEndpoint builder fills these fields.
2245        service_instance_name: None,
2246        related_entity_guid: None,
2247        topic_aliases: None,
2248        // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2249        type_identifier: cfg.type_identifier.clone(),
2250        // DDSI-RTPS 2.5 §8.5.3.3: endpoint locator. All user endpoints
2251        // share the one `user_unicast` socket — hence the
2252        // endpoint locator equals the resolved participant locator.
2253        unicast_locators: alloc::vec![user_locator],
2254        multicast_locators: Vec::new(),
2255    }
2256}
2257
2258fn build_subscription_data(
2259    owner_prefix: GuidPrefix,
2260    reader_eid: EntityId,
2261    cfg: &UserReaderConfig,
2262    runtime_offer: &[i16],
2263    user_locator: Locator,
2264) -> zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2265    use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2266    zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2267        key: Guid::new(owner_prefix, reader_eid),
2268        participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2269        topic_name: cfg.topic_name.clone(),
2270        type_name: cfg.type_name.clone(),
2271        durability: cfg.durability,
2272        reliability: ReliabilityQosPolicy {
2273            kind: if cfg.reliable {
2274                ReliabilityKind::Reliable
2275            } else {
2276                ReliabilityKind::BestEffort
2277            },
2278            max_blocking_time: QosDuration::from_millis(100_i32),
2279        },
2280        ownership: cfg.ownership,
2281        liveliness: cfg.liveliness,
2282        deadline: cfg.deadline,
2283        partition: cfg.partition.clone(),
2284        user_data: cfg.user_data.clone(),
2285        topic_data: cfg.topic_data.clone(),
2286        group_data: cfg.group_data.clone(),
2287        type_information: None,
2288        // D.5g — PID_DATA_REPRESENTATION (see build_publication_data).
2289        // A per-reader override overrides the RuntimeConfig default.
2290        data_representation: cfg
2291            .data_representation_offer
2292            .clone()
2293            .unwrap_or_else(|| runtime_offer.to_vec()),
2294        content_filter: None,
2295        security_info: None,
2296        service_instance_name: None,
2297        related_entity_guid: None,
2298        topic_aliases: None,
2299        // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2300        type_identifier: cfg.type_identifier.clone(),
2301        // DDSI-RTPS 2.5 §8.5.3.2: endpoint locator (see
2302        // build_publication_data).
2303        unicast_locators: alloc::vec![user_locator],
2304        multicast_locators: Vec::new(),
2305    }
2306}
2307
2308/// The runtime of a `DomainParticipant`. Hosts all background
2309/// threads and UDP sockets.
2310pub struct DcpsRuntime {
2311    /// Participant GUID prefix (12-byte identifier, random per instance).
2312    pub guid_prefix: GuidPrefix,
2313    /// Domain id.
2314    pub domain_id: i32,
2315    /// SPDP multicast receiver socket.
2316    pub spdp_multicast_rx: Arc<UdpTransport>,
2317    /// SPDP unicast socket (for bidirectional SPDP, B2).
2318    pub spdp_unicast: Arc<UdpTransport>,
2319    /// User-data unicast transport (default user unicast, where peers
2320    /// send matched samples). Trait object: can be UDP/v4 or /v6,
2321    /// and in phase C additionally TCP or SHM (env var
2322    /// `ZERODDS_USER_TRANSPORT`). Discovery (SPDP/SEDP) stays UDP-only.
2323    pub user_unicast: Arc<dyn Transport + Send + Sync>,
2324    /// Resolved user-unicast locator (routable interface address,
2325    /// not `0.0.0.0`). Written as `PID_UNICAST_LOCATOR` into EVERY
2326    /// SEDP pub/sub announce (DDSI-RTPS 2.5 §8.5.3.2/3)
2327    /// and as the participant `DEFAULT_UNICAST_LOCATOR` in SPDP. Precomputed
2328    /// via `announce_locator`, so the endpoint and participant locators
2329    /// are guaranteed identical.
2330    pub user_announce_locator: Locator,
2331    /// Sender socket for the SPDP multicast announce (separate UdpSocket
2332    /// without SO_REUSE/SO_BIND_IP_MULTICAST, so send_to routes cleanly).
2333    spdp_mc_tx: Arc<UdpTransport>,
2334    /// SPDP beacon (sends periodic announces).
2335    spdp_beacon: Mutex<SpdpBeacon>,
2336    /// Own participant data (SPDP self-view). Handed by the in-process
2337    /// discovery fastpath as a `DiscoveredParticipant` to same-process
2338    /// peers (see [`crate::inproc`]).
2339    participant_data: ParticipantBuiltinTopicData,
2340    /// Stash of all locally announced publications/subscriptions —
2341    /// so a peer runtime starting later in the same process
2342    /// can pull our endpoints via `inproc_snapshot`
2343    /// (pull-on-creation of the in-process discovery fastpath).
2344    /// Append-only; a future patch for endpoint deletion would
2345    /// remove by GUID here.
2346    announced_pubs: Mutex<Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>>,
2347    announced_subs: Mutex<Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>>,
2348    /// SPDP reader (parses incoming beacons).
2349    spdp_reader: SpdpReader,
2350    /// Discovered remote participants (prefix → data).
2351    discovered: Arc<Mutex<DiscoveredParticipantsCache>>,
2352    /// SEDP stack for publication/subscription announce + discovery.
2353    pub sedp: Arc<Mutex<SedpStack>>,
2354    /// TypeLookup-Service Builtin-Endpoint-GUIDs (XTypes 1.3 §7.6.3.3.4).
2355    pub type_lookup_endpoints: TypeLookupEndpoints,
2356    /// TypeLookup server (server-side handler over the local
2357    /// TypeRegistry).
2358    pub type_lookup_server: Arc<Mutex<TypeLookupServer>>,
2359    /// TypeLookup client (client-side correlation table for outstanding
2360    /// requests).
2361    pub type_lookup_client: Arc<Mutex<TypeLookupClient>>,
2362    /// Monotonically increasing sequence number of the TL_SVC_REPLY_WRITER. Reply DATA
2363    /// carry their OWN writer_sn (instead of echoing the request SN) — the
2364    /// correlation runs via PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2),
2365    /// so a reliable cross-vendor reply reader sees no SN jumps.
2366    tl_reply_sn: core::sync::atomic::AtomicU64,
2367    /// Security builtin endpoint stack
2368    /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
2369    /// MessageSecure`). `None` as long as no security plugin is active
2370    /// — the hot path then skips any security-builtin
2371    /// demux. `Some` is set via [`DcpsRuntime::enable_security_builtins`]
2372    /// as soon as the factory has registered a plugin.
2373    pub security_builtin: Mutex<Option<Arc<Mutex<SecurityBuiltinStack>>>>,
2374    /// Monotonic "start time" — for SEDP tick clocks.
2375    start_instant: Instant,
2376    /// Local user-writer registry (EntityId → writer state).
2377    user_writers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserWriterSlot>>>>>,
2378    /// ADR-0006 side map: per user writer an optional ShmLocator bytes
2379    /// value (PID_SHM_LOCATOR in the SEDP sample). `None` = no
2380    /// same-host backend attached. The wire encoder consults
2381    /// this map on the SEDP push.
2382    shm_locators: Arc<RwLock<BTreeMap<EntityId, Vec<u8>>>>,
2383    /// Wave 4 (Spec `zerodds-zero-copy-1.0` §6): tracker for
2384    /// same-host (writer, reader) pairs. The SEDP match hook registers
2385    /// here every pair whose remote prefix carries the same host-id prefix
2386    /// as the local participant. The hot-path send consults
2387    /// the tracker and routes over SHM instead of UDP in the `Bound` state.
2388    pub same_host: Arc<crate::same_host::SameHostTracker>,
2389    /// Local user-reader registry (EntityId → reader state).
2390    user_readers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserReaderSlot>>>>>,
2391    /// Cross-vendor step 6b: peers to whom we have already sent per-endpoint
2392    /// crypto tokens (datawriter/datareader). Prevents spam on the
2393    /// repeated receipt of cyclone's tokens; sending happens only once the
2394    /// user endpoints exist (the bench creates them after handshake start).
2395    #[cfg(feature = "security")]
2396    /// Already-sent per-endpoint crypto tokens, per dedup key
2397    /// (source_endpoint ++ destination_endpoint, see `endpoint_token_key`).
2398    /// Per-token instead of per-peer, so late-matched user endpoints still
2399    /// get their tokens (#29).
2400    endpoint_tokens_sent: Arc<RwLock<alloc::collections::BTreeSet<[u8; 32]>>>,
2401    /// Peers (prefix) to whom our SEDP endpoint records have already been
2402    /// re-announced after a completed crypto-token exchange. Under rtps_/discovery_
2403    /// protection the initial SEDP burst is discarded by the peer (no key), until
2404    /// the participant crypto token arrives via Volatile; a one-time
2405    /// re-announce from that moment (the peer can now decode) brings the
2406    /// dropped SEDP up (OpenDDS flow; cyclone/FastDDS converge anyway).
2407    #[cfg(feature = "security")]
2408    sedp_reannounced: Arc<RwLock<alloc::collections::BTreeSet<[u8; 12]>>>,
2409    /// Per-endpoint crypto (DDS-Security §9.5.3.3): per local writer/reader
2410    /// EntityId its own crypto slot handle (its own key material, not the
2411    /// participant key). Used for the per-endpoint token (prepare_endpoint_
2412    /// crypto_tokens) AND the per-endpoint encode (protect_user_datagram)
2413    /// — the same key on both sides. Get-or-register lazily via
2414    /// `local_endpoint_crypto_handle`.
2415    #[cfg(feature = "security")]
2416    endpoint_crypto:
2417        Arc<RwLock<alloc::collections::BTreeMap<EntityId, zerodds_security::crypto::CryptoHandle>>>,
2418    /// Same-runtime writer→reader routes: per local writer the list
2419    /// of local readers subscribed to the same topic+type.
2420    /// Rebuilt in `recompute_intra_runtime_routes` on every
2421    /// register/unregister. Looked up in the write hot path,
2422    /// to push samples directly into the reader slot's `sample_tx`
2423    /// (intra-process loopback without an RTPS roundtrip, in parallel to the
2424    /// inproc peer path that only serves cross-runtime peers).
2425    intra_runtime_routes: Arc<RwLock<BTreeMap<EntityId, Vec<EntityId>>>>,
2426    /// Entity key counter (3 byte, incrementing). User writers use
2427    /// `0xC2` (with-key, user), user readers `0xC7`.
2428    entity_counter: AtomicU32,
2429    /// Configuration (cloned from RuntimeConfig).
2430    pub config: RuntimeConfig,
2431    /// Per-interface outbound socket pool. `None`
2432    /// when `config.interface_bindings` is empty — then
2433    /// `user_unicast` stays the only outbound socket (v1.4 path).
2434    #[cfg(feature = "security")]
2435    outbound_pool: Option<Arc<OutboundSocketPool>>,
2436    /// Writer-Liveliness-Protocol endpoint (RTPS 2.5 §8.4.13).
2437    /// Sends periodic `ParticipantMessageData` heartbeats and
2438    /// tracks last-seen per remote participant.
2439    pub wlp: Arc<Mutex<crate::wlp::WlpEndpoint>>,
2440    /// Builtin-topic reader sinks (DDS 1.4 §2.2.5). Set by the
2441    /// `DomainParticipant` constructor via `attach_builtin_sinks`;
2442    /// before that this is `None` and the discovery hot path
2443    /// drops samples silently (e.g. when the runtime is
2444    /// started directly for internal tests, without a participant).
2445    builtin_sinks: Mutex<Option<crate::builtin_subscriber::BuiltinSinks>>,
2446    /// Ignore filter (DDS 1.4 §2.2.2.2.1.14-17). Set by the
2447    /// `DomainParticipant` constructor via `attach_ignore_filter`.
2448    /// `None` means: no participant hook → no
2449    /// filtering.
2450    ignore_filter: Mutex<Option<crate::participant::IgnoreFilter>>,
2451    /// Stop flag for all worker threads (recv loops + tick loop).
2452    stop: Arc<AtomicBool>,
2453    /// Monotonic count of completed tick iterations. Incremented once per
2454    /// [`run_tick_iteration`], regardless of whether the tick is driven by the
2455    /// internal `zdds-tick` thread or an external executor (zerodds-async-1.0
2456    /// §4 `spawn_in_tokio`). Diagnostic: a stalled count means the tick loop
2457    /// stopped advancing. Read via [`DcpsRuntime::tick_count`].
2458    tick_seq: AtomicU64,
2459    /// Total SPDP announces emitted (multicast + unicast fan-out count as one).
2460    /// Diagnostic for the C3 initial-announcement burst — a fresh, peer-less
2461    /// participant should advance this fast initially. Read via
2462    /// [`DcpsRuntime::spdp_announce_count`].
2463    spdp_announce_seq: AtomicU64,
2464    /// Inconsistent-topic counter (DDS 1.4 §2.2.4.2.4). Incremented when
2465    /// matching discovers a remote endpoint carrying the same `topic_name`
2466    /// but a differing `type_name` in the SEDP cache. Read via
2467    /// [`DcpsRuntime::inconsistent_topic_count`].
2468    inconsistent_topic_seq: AtomicU64,
2469    /// D.5e Phase 3 — wake handle for the event-driven scheduler tick. `Some`
2470    /// only when started with `scheduler_tick`. Recv loops + the write path call
2471    /// [`DcpsRuntime::raise_tick_wake`] to wake the worker immediately on new
2472    /// work (so HEARTBEAT/ACKNACK/HB processing does not wait for a deadline).
2473    tick_wake: Mutex<Option<crate::scheduler::SchedulerHandle<TickEvent>>>,
2474    /// Coalesces wake raises: many incoming datagrams collapse into one wake.
2475    tick_wake_pending: AtomicBool,
2476    /// Worker thread JoinHandles. Per-socket recv threads + tick thread,
2477    /// all terminated together via `stop` (Sprint D.5b — previously
2478    /// a single single-threaded `event_loop`).
2479    handles: Mutex<Vec<JoinHandle<()>>>,
2480    /// Match-event notifier (D.5e Phase-1 quick win). Notified by the
2481    /// SEDP match path after `add_reader_proxy` / `add_writer_proxy`;
2482    /// `wait_for_matched_*` parks on it instead of polling every 20 ms.
2483    /// The mutex content is only a lock anchor for the Condvar API; there is
2484    /// no state protected by it (the count is read independently
2485    /// via `user_*_matched_count`).
2486    match_event: Arc<(Mutex<()>, Condvar)>,
2487    /// Acknowledgments event notifier. Notified when a writer
2488    /// receives an ACKNACK that advances its acked-base.
2489    /// `wait_for_acknowledgments` parks on it instead of polling every 50 ms.
2490    ack_event: Arc<(Mutex<()>, Condvar)>,
2491}
2492
2493impl core::fmt::Debug for DcpsRuntime {
2494    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2495        f.debug_struct("DcpsRuntime")
2496            .field("domain_id", &self.domain_id)
2497            .field("guid_prefix", &self.guid_prefix)
2498            .field("spdp_group", &self.config.spdp_multicast_group)
2499            .finish_non_exhaustive()
2500    }
2501}
2502
2503/// Type alias: Arc-shared slot handles from the per-slot mutex
2504/// architecture.
2505type WriterSlotArc = Arc<Mutex<UserWriterSlot>>;
2506type ReaderSlotArc = Arc<Mutex<UserReaderSlot>>;
2507
2508impl DcpsRuntime {
2509    // ========================================================================
2510    // --- Per-Slot-Mutex-Helpers
2511    //
2512    // The `user_writers`/`user_readers` registry is `RwLock<BTreeMap<EntityId,
2513    // Arc<Mutex<Slot>>>>`. Hot-path accesses take the read lock briefly, clone
2514    // the slot Arc and release the read lock before taking the per-slot mutex.
2515    // Parallel writes to **different** slots thereby run
2516    // without global contention.
2517    //
2518    // Slot creation/deletion takes the write lock; that is rare and
2519    // amortizes out.
2520    // ========================================================================
2521
2522    /// Returns the slot Arc for a user writer, if present.
2523    /// Hot-path form: a single read lock + Arc clone, no
2524    /// per-slot mutex. The caller takes the mutex itself.
2525    fn writer_slot(&self, eid: EntityId) -> Option<WriterSlotArc> {
2526        self.user_writers
2527            .read()
2528            .ok()
2529            .and_then(|w| w.get(&eid).cloned())
2530    }
2531
2532    /// Returns the slot Arc for a user reader, if present.
2533    fn reader_slot(&self, eid: EntityId) -> Option<ReaderSlotArc> {
2534        self.user_readers
2535            .read()
2536            .ok()
2537            .and_then(|r| r.get(&eid).cloned())
2538    }
2539
2540    /// Snapshot of all writer slots as `Vec<(EntityId, Arc)>`. Allows
2541    /// iteration without holding the registry read lock — e.g. for
2542    /// the heartbeat tick or liveliness sweep, where we potentially take every
2543    /// slot's mutex.
2544    fn writer_slots_snapshot(&self) -> Vec<(EntityId, WriterSlotArc)> {
2545        match self.user_writers.read() {
2546            Ok(w) => w.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2547            Err(_) => Vec::new(),
2548        }
2549    }
2550
2551    /// Snapshot of all reader slots — symmetric to writer_slots_snapshot.
2552    fn reader_slots_snapshot(&self) -> Vec<(EntityId, ReaderSlotArc)> {
2553        match self.user_readers.read() {
2554            Ok(r) => r.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2555            Err(_) => Vec::new(),
2556        }
2557    }
2558
2559    /// Returns the list of EntityIds of all registered writers.
2560    /// Very lightweight — no slot-Arc clone, just EntityIds.
2561    fn writer_eids(&self) -> Vec<EntityId> {
2562        match self.user_writers.read() {
2563            Ok(w) => w.keys().copied().collect(),
2564            Err(_) => Vec::new(),
2565        }
2566    }
2567
2568    /// Returns the list of EntityIds of all registered readers.
2569    fn reader_eids(&self) -> Vec<EntityId> {
2570        match self.user_readers.read() {
2571            Ok(r) => r.keys().copied().collect(),
2572            Err(_) => Vec::new(),
2573        }
2574    }
2575
2576    /// Starts a new runtime for a participant.
2577    ///
2578    /// # Errors
2579    /// `TransportError` if one of the 3 UDP sockets fails to bind
2580    /// (e.g. a port collision on the SPDP multicast port in another
2581    /// SO_REUSE-less DDS instance).
2582    pub fn start(
2583        domain_id: i32,
2584        guid_prefix: GuidPrefix,
2585        mut config: RuntimeConfig,
2586    ) -> Result<Arc<Self>> {
2587        // C1 multicast-free discovery: merge the domain-aware env `ZERODDS_PEERS`
2588        // into the (programmatic) `config.initial_peers`. Default
2589        // is both empty → pure multicast behavior.
2590        config
2591            .initial_peers
2592            .extend(parse_initial_peers_env(domain_id as u32));
2593        // SPDP multicast receiver on the spec port.
2594        // u32 → u16 enforcing, the spec port is always < 65536.
2595        let spdp_port = u16::try_from(spdp_multicast_port(domain_id as u32)).map_err(|_| {
2596            DdsError::BadParameter {
2597                what: "domain_id too large for SPDP port mapping",
2598            }
2599        })?;
2600        let spdp_mc = UdpTransport::bind_multicast_v4(
2601            config.spdp_multicast_group,
2602            spdp_port,
2603            config.multicast_interface,
2604        )
2605        .map_err(|_| DdsError::TransportError {
2606            label: "spdp multicast bind",
2607        })?
2608        // Sprint D.5b: recv sockets have their own thread that
2609        // blocks waiting for data. Timeout 1 s = stop-flag polling
2610        // granularity at shutdown, NOT the tick rhythm.
2611        .with_timeout(Some(Duration::from_secs(1)))
2612        .map_err(|_| DdsError::TransportError {
2613            label: "spdp multicast set_timeout",
2614        })?;
2615
2616        // SPDP unicast: bind to the **well-known** RTPS port
2617        // (7400+250*domain+10+2*pid, Spec §9.6.1.4.1), so a
2618        // configured unicast initial peer can reach this participant
2619        // WITHOUT prior multicast (C1 multicast-free
2620        // discovery). Participant index 0,1,2,… until a free port
2621        // is found (multiple participants per host, also alongside
2622        // Cyclone/FastDDS). Fallback ephemeral if all well-known
2623        // ports are taken (then multicast discovery only).
2624        // Interface pinning (ZERODDS_INTERFACE): UNSPECIFIED = auto. If
2625        // set, ALL IP sockets bind (SPDP-uc, SPDP-mc-tx, user UDP/TCP)
2626        // to this IP → announce + egress + receive on exactly this
2627        // interface (multi-homed robustness, cf. Cyclone `NetworkInterface`).
2628        let pinned = config.multicast_interface;
2629        let (spdp_uc_raw, _spdp_participant_id) = {
2630            let mut bound = None;
2631            for pid in 0u32..120 {
2632                let Ok(port) = u16::try_from(spdp_unicast_port(domain_id as u32, pid)) else {
2633                    break;
2634                };
2635                if let Ok(sock) = UdpTransport::bind_v4(pinned, port) {
2636                    bound = Some((sock, pid));
2637                    break;
2638                }
2639            }
2640            match bound {
2641                Some(b) => b,
2642                None => (
2643                    UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2644                        label: "spdp unicast bind",
2645                    })?,
2646                    u32::MAX,
2647                ),
2648            }
2649        };
2650        let spdp_uc = spdp_uc_raw
2651            .with_timeout(Some(Duration::from_secs(1)))
2652            .map_err(|_| DdsError::TransportError {
2653                label: "spdp unicast set_timeout",
2654            })?;
2655
2656        // User-data unicast (ephemeral port). Transport choice primarily via
2657        // `RuntimeConfig::user_transport`, fallback to the env var
2658        // `ZERODDS_USER_TRANSPORT` (bench binaries), otherwise UDPv4.
2659        // SPDP multicast stays UDPv4 — the DDSI-RTPS spec mandates
2660        // 239.255.0.1 for cross-vendor discovery; v6-only hosts
2661        // cannot discover cross-vendor (its own sprint).
2662        let (user_uc, tcp_accept_handle): (Arc<dyn Transport + Send + Sync>, _) =
2663            if !config.user_transports.is_empty() {
2664                // Multi-transport: build each kind and layer them. Preference =
2665                // config order (first match by destination locator kind wins).
2666                let mut legs: alloc::vec::Vec<Arc<dyn Transport + Send + Sync>> =
2667                    alloc::vec::Vec::new();
2668                let mut tcp_handle = None;
2669                for kind in &config.user_transports {
2670                    let (leg, tcp) = select_user_transport(*kind, guid_prefix, domain_id, pinned)?;
2671                    legs.push(leg);
2672                    if tcp.is_some() {
2673                        tcp_handle = tcp;
2674                    }
2675                }
2676                let layered = Arc::new(crate::layered_transport::LayeredUserTransport::new(legs));
2677                (layered, tcp_handle)
2678            } else {
2679                let user_transport_kind = config
2680                    .user_transport
2681                    .or_else(parse_user_transport_env)
2682                    .unwrap_or(UserTransportKind::UdpV4);
2683                select_user_transport(user_transport_kind, guid_prefix, domain_id, pinned)?
2684            };
2685
2686        // Separate sender socket for the SPDP announce. Ephemeral port; with
2687        // interface pinning it binds to the pinned IP (egress source), otherwise
2688        // `0.0.0.0` (the kernel picks the outgoing interface per route).
2689        let spdp_mc_tx =
2690            UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2691                label: "spdp mc-tx bind",
2692            })?;
2693
2694        let stop = Arc::new(AtomicBool::new(false));
2695
2696        // Materialize beacon locators for cross-host interop:
2697        // with a `0.0.0.0` bind address (UNSPECIFIED) the peer would
2698        // otherwise learn a non-routable address. We resolve UNSPECIFIED
2699        // via a UDP connect probe to a non-routable IP
2700        // (no traffic, just the routing table) and announce the
2701        // resulting local interface address — cross-host-capable
2702        // without an external crate dependency.
2703        let user_locator = announce_locator(&*user_uc, config.multicast_interface);
2704        let spdp_uc_locator = announce_locator(&spdp_uc, config.multicast_interface);
2705        let participant_data = ParticipantBuiltinTopicData {
2706            guid: Guid::new(guid_prefix, EntityId::PARTICIPANT),
2707            protocol_version: ProtocolVersion::V2_5,
2708            vendor_id: VendorId::ZERODDS,
2709            default_unicast_locator: Some(user_locator),
2710            default_multicast_locator: None,
2711            metatraffic_unicast_locator: Some(spdp_uc_locator),
2712            metatraffic_multicast_locator: Some(Locator {
2713                kind: LocatorKind::UdpV4,
2714                port: u32::from(spdp_port),
2715                address: {
2716                    let mut a = [0u8; 16];
2717                    a[12..].copy_from_slice(&config.spdp_multicast_group.octets());
2718                    a
2719                },
2720            }),
2721            domain_id: Some(domain_id as u32),
2722            // We announce the endpoints we actually
2723            // implement: SPDP (participant ann/det) + SEDP
2724            // (publications/subscriptions ann+det) + WLP (10/11) +
2725            // TypeLookup service (12/13). Cyclone/Fast-DDS filter
2726            // their proxy setup by these flags — without them
2727            // we get no SEDP/WLP peers. SEDP topic
2728            // endpoints (bits 28/29) are optional per RTPS 2.5 §8.5.4.4
2729            // and covered in ZeroDDS via synthetic DCPSTopic
2730            // derivation from pub/sub — we do not announce them,
2731            // otherwise we promise peers a non-existent
2732            // endpoint pairing. When the caller sets
2733            // `announce_secure_endpoints = true` (security
2734            // factory path), we additionally mix in the 12 secure
2735            // discovery bits (16..27, DDS-Security 1.2 §7.4.7.1).
2736            builtin_endpoint_set: {
2737                let mut mask = endpoint_flag::ALL_STANDARD;
2738                if config.announce_secure_endpoints {
2739                    mask |= endpoint_flag::ALL_SECURE;
2740                }
2741                mask
2742            },
2743            // Spec default lease = 100 s; configurable via
2744            // `RuntimeConfig::participant_lease_duration`.
2745            lease_duration: qos_duration_from_std(config.participant_lease_duration),
2746            // UserData on the participant — filled from
2747            // `DomainParticipantQos::user_data` via RuntimeConfig.
2748            user_data: config.user_data.clone(),
2749            // PROPERTY_LIST: security fills this with security caps
2750            // once a PolicyEngine is configured. Default-empty
2751            // stays backward-compatible with legacy peers.
2752            properties: Default::default(),
2753            // IdentityToken/PermissionsToken are filled by the security
2754            // layer once authentication + access control are
2755            // initialized. Default `None` = legacy announce.
2756            identity_token: None,
2757            permissions_token: None,
2758            identity_status_token: None,
2759            sig_algo_info: None,
2760            kx_algo_info: None,
2761            sym_cipher_algo_info: None,
2762            // Filled by the security layer (enable_security_builtins*) —
2763            // without PID_PARTICIPANT_SECURITY_INFO foreign vendors classify
2764            // us as non-secure. Default None = legacy/plain.
2765            participant_security_info: None,
2766        };
2767        let beacon = SpdpBeacon::new(participant_data.clone());
2768        let sedp = SedpStack::new(guid_prefix, VendorId::ZERODDS);
2769        // In-process discovery fastpath: remember the multicast group before
2770        // `config` is moved into the struct literal.
2771        let inproc_group = config.spdp_multicast_group;
2772
2773        #[cfg(feature = "security")]
2774        let outbound_pool = if config.interface_bindings.is_empty() {
2775            None
2776        } else {
2777            Some(Arc::new(OutboundSocketPool::bind_all(
2778                &config.interface_bindings,
2779            )?))
2780        };
2781
2782        // WLP endpoint (RTPS 2.5 §8.4.13). The tick period is explicit
2783        // `wlp_period`, or `lease/3` when `wlp_period == ZERO`
2784        // (spec recommendation: three misses before the reader marks the
2785        // writer as not-alive).
2786        let wlp_tick_period = if config.wlp_period.is_zero() {
2787            config.participant_lease_duration / 3
2788        } else {
2789            config.wlp_period
2790        };
2791        let wlp = crate::wlp::WlpEndpoint::new(guid_prefix, VendorId::ZERODDS, wlp_tick_period);
2792
2793        let rt = Arc::new(Self {
2794            guid_prefix,
2795            domain_id,
2796            spdp_multicast_rx: Arc::new(spdp_mc),
2797            spdp_unicast: Arc::new(spdp_uc),
2798            user_unicast: user_uc,
2799            user_announce_locator: user_locator,
2800            spdp_mc_tx: Arc::new(spdp_mc_tx),
2801            spdp_beacon: Mutex::new(beacon),
2802            participant_data,
2803            announced_pubs: Mutex::new(Vec::new()),
2804            announced_subs: Mutex::new(Vec::new()),
2805            spdp_reader: SpdpReader::new(),
2806            discovered: Arc::new(Mutex::new(DiscoveredParticipantsCache::new())),
2807            sedp: Arc::new(Mutex::new(sedp)),
2808            type_lookup_endpoints: TypeLookupEndpoints::new(guid_prefix),
2809            type_lookup_server: Arc::new(Mutex::new(TypeLookupServer::new())),
2810            type_lookup_client: Arc::new(Mutex::new(TypeLookupClient::new())),
2811            tl_reply_sn: core::sync::atomic::AtomicU64::new(0),
2812            security_builtin: Mutex::new(None),
2813            start_instant: Instant::now(),
2814            user_writers: Arc::new(RwLock::new(BTreeMap::new())),
2815            shm_locators: Arc::new(RwLock::new(BTreeMap::new())),
2816            same_host: Arc::new(crate::same_host::SameHostTracker::new()),
2817            user_readers: Arc::new(RwLock::new(BTreeMap::new())),
2818            #[cfg(feature = "security")]
2819            endpoint_tokens_sent: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
2820            #[cfg(feature = "security")]
2821            sedp_reannounced: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
2822            #[cfg(feature = "security")]
2823            endpoint_crypto: Arc::new(RwLock::new(alloc::collections::BTreeMap::new())),
2824            intra_runtime_routes: Arc::new(RwLock::new(BTreeMap::new())),
2825            entity_counter: AtomicU32::new(1),
2826            config,
2827            stop: stop.clone(),
2828            tick_seq: AtomicU64::new(0),
2829            spdp_announce_seq: AtomicU64::new(0),
2830            inconsistent_topic_seq: AtomicU64::new(0),
2831            tick_wake: Mutex::new(None),
2832            tick_wake_pending: AtomicBool::new(false),
2833            handles: Mutex::new(Vec::new()),
2834            match_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
2835            ack_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
2836            #[cfg(feature = "security")]
2837            outbound_pool,
2838            wlp: Arc::new(Mutex::new(wlp)),
2839            builtin_sinks: Mutex::new(None),
2840            ignore_filter: Mutex::new(None),
2841        });
2842
2843        // In-process discovery fastpath: register the runtime in the process
2844        // registry so same-process+domain peers find each other
2845        // deterministically (see `crate::inproc`). Right
2846        // after, `pull-on-creation`: pull all already-announced endpoints
2847        // of existing peers into our SEDP cache — otherwise
2848        // we see peers that announced endpoints BEFORE us
2849        // only via the (lossy) UDP SEDP path.
2850        crate::inproc::register(&rt, domain_id, inproc_group);
2851        rt.inproc_pull_from_peers();
2852
2853        // Per-socket recv threads + one tick thread (Sprint D.5b).
2854        //
2855        // Previously the entire stack ran in a single event loop
2856        // that went through three blocking `recv()`s with a `tick_period`
2857        // timeout (50 ms) sequentially per iteration. On a
2858        // roundtrip each stage waited up to 50 ms for timeouts of the
2859        // front sockets before its own datagram got its turn —
2860        // yielded 5-14 ms p50.
2861        //
2862        // Refit: every relevant recv path has its own thread
2863        // that sits directly blocking on its socket and dispatches
2864        // immediately when data arrives. The tick thread does the
2865        // periodic outbound work (HEARTBEAT/resend/ACKNACK/
2866        // SPDP announce/deadline/lifespan/liveliness) and sleeps
2867        // `tick_period` between iterations.
2868        //
2869        // Lock order (deadlock avoidance): the tick thread and
2870        // recv threads contend for `rt.sedp.lock()` / `rt.wlp.lock()`.
2871        // Convention: keep lock-hold times short (handle_datagram /
2872        // tick are both fast), do not take a sub-lock under the `sedp`
2873        // or `wlp` lock.
2874        let mut handles_init: Vec<JoinHandle<()>> = Vec::with_capacity(4);
2875
2876        let rt_recv_spdp_mc = Arc::clone(&rt);
2877        let stop_recv_spdp_mc = stop.clone();
2878        handles_init.push(
2879            thread::Builder::new()
2880                .name(String::from("zdds-recv-spdp-mc"))
2881                .spawn(move || recv_spdp_multicast_loop(rt_recv_spdp_mc, stop_recv_spdp_mc))
2882                .map_err(|_| DdsError::PreconditionNotMet {
2883                    reason: "spawn zdds-recv-spdp-mc thread",
2884                })?,
2885        );
2886
2887        let rt_recv_meta = Arc::clone(&rt);
2888        let stop_recv_meta = stop.clone();
2889        handles_init.push(
2890            thread::Builder::new()
2891                .name(String::from("zdds-recv-meta"))
2892                .spawn(move || recv_metatraffic_loop(rt_recv_meta, stop_recv_meta))
2893                .map_err(|_| DdsError::PreconditionNotMet {
2894                    reason: "spawn zdds-recv-meta thread",
2895                })?,
2896        );
2897
2898        let rt_recv_user = Arc::clone(&rt);
2899        let stop_recv_user = stop.clone();
2900        let primary_socket = Arc::clone(&rt.user_unicast);
2901        handles_init.push(
2902            thread::Builder::new()
2903                .name(String::from("zdds-recv-user"))
2904                .spawn(move || recv_user_data_loop(rt_recv_user, primary_socket, stop_recv_user))
2905                .map_err(|_| DdsError::PreconditionNotMet {
2906                    reason: "spawn zdds-recv-user thread",
2907                })?,
2908        );
2909
2910        // TCPv4 variant: a separate accept worker (TcpTransport has
2911        // no implicit accept thread in the constructor — accept_one()
2912        // must be called explicitly).
2913        if let Some(tcp_arc) = tcp_accept_handle {
2914            let stop_accept = stop.clone();
2915            handles_init.push(
2916                thread::Builder::new()
2917                    .name(String::from("zdds-tcp-accept"))
2918                    .spawn(move || {
2919                        while !stop_accept.load(Ordering::Relaxed) {
2920                            // accept_one() blocks until connection +
2921                            // handshake; on EOF it returns Ok(()) and
2922                            // we accept the next peer.
2923                            let _ = tcp_arc.accept_one();
2924                        }
2925                    })
2926                    .map_err(|_| DdsError::PreconditionNotMet {
2927                        reason: "spawn zdds-tcp-accept thread",
2928                    })?,
2929            );
2930        }
2931
2932        // Opt-3 (Spec `zerodds-zero-copy-1.0` §9): additional
2933        // SO_REUSEPORT recv workers. Each binds to the same
2934        // user_unicast port; the kernel distributes incoming datagrams via
2935        // flow hash. On a bind error (e.g. a platform without
2936        // SO_REUSEPORT support) the worker is skipped and the
2937        // runtime continues with the available workers.
2938        if rt.config.extra_recv_threads > 0 {
2939            let user_port = u16::try_from(rt.user_unicast.local_locator().port).unwrap_or(0);
2940            // With an active security config, share the first interface bind address;
2941            // otherwise INADDR_ANY (the kernel chooses).
2942            #[cfg(feature = "security")]
2943            let bind_addr = rt
2944                .config
2945                .interface_bindings
2946                .first()
2947                .map(|spec| spec.bind_addr)
2948                .unwrap_or(Ipv4Addr::UNSPECIFIED);
2949            #[cfg(not(feature = "security"))]
2950            let bind_addr = Ipv4Addr::UNSPECIFIED;
2951            for i in 0..rt.config.extra_recv_threads {
2952                let extra_socket =
2953                    match UdpTransport::bind_v4_reuse(bind_addr, user_port) {
2954                        Ok(t) => Arc::new(t.with_timeout(Some(Duration::from_secs(1))).map_err(
2955                            |_| DdsError::TransportError {
2956                                label: "extra-recv set_timeout failed",
2957                            },
2958                        )?),
2959                        Err(_) => break, // SO_REUSEPORT not available — skip.
2960                    };
2961                let rt_extra = Arc::clone(&rt);
2962                let stop_extra = stop.clone();
2963                let name = format!("zdds-recv-user-{}", i + 1);
2964                handles_init.push(
2965                    thread::Builder::new()
2966                        .name(name)
2967                        .spawn(move || recv_user_data_loop(rt_extra, extra_socket, stop_extra))
2968                        .map_err(|_| DdsError::PreconditionNotMet {
2969                            reason: "spawn zdds-recv-user-N thread",
2970                        })?,
2971                );
2972            }
2973        }
2974
2975        // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6): per-owner
2976        // SHM recv loop. Polls all bound consumer entries of the
2977        // SameHostTracker round-robin and dispatches incoming
2978        // frames analogous to the UDP path. Only compiled when
2979        // the `same-host-shm` feature is on.
2980        #[cfg(feature = "same-host-shm")]
2981        {
2982            let rt_recv_shm = Arc::clone(&rt);
2983            let stop_recv_shm = stop.clone();
2984            handles_init.push(
2985                thread::Builder::new()
2986                    .name(String::from("zdds-recv-shm"))
2987                    .spawn(move || recv_user_shm_loop(rt_recv_shm, stop_recv_shm))
2988                    .map_err(|_| DdsError::PreconditionNotMet {
2989                        reason: "spawn zdds-recv-shm thread",
2990                    })?,
2991            );
2992        }
2993
2994        // zerodds-async-1.0 §4: with `external_tick`, the tick loop is driven
2995        // by an external executor (tokio via `spawn_in_tokio`) rather than a
2996        // dedicated thread — so we skip the spawn here. `stop` is dropped; the
2997        // driver observes shutdown via `rt.stop` (set in `Drop`/`stop()`).
2998        if rt.config.external_tick {
2999            drop(stop);
3000        } else if rt.config.scheduler_tick {
3001            // D.5e Phase 3 — event-driven scheduler tick. Create the scheduler
3002            // up front, publish its wake handle so recv loops + the write path
3003            // can `raise_tick_wake`, then drive the (unchanged) tick from the
3004            // deadline-heap worker.
3005            let (scheduler, handle) =
3006                crate::scheduler::Scheduler::<TickEvent>::new(SCHEDULER_IDLE_FLOOR);
3007            if let Ok(mut g) = rt.tick_wake.lock() {
3008                *g = Some(handle.clone());
3009            }
3010            let rt_tick = Arc::clone(&rt);
3011            let stop_tick = stop;
3012            handles_init.push(
3013                thread::Builder::new()
3014                    .name(String::from("zdds-tick-sched"))
3015                    .spawn(move || scheduler_tick_loop(rt_tick, stop_tick, scheduler, handle))
3016                    .map_err(|_| DdsError::PreconditionNotMet {
3017                        reason: "spawn zdds-tick-sched thread",
3018                    })?,
3019            );
3020        } else {
3021            let rt_tick = Arc::clone(&rt);
3022            let stop_tick = stop;
3023            handles_init.push(
3024                thread::Builder::new()
3025                    .name(String::from("zdds-tick"))
3026                    .spawn(move || tick_loop(rt_tick, stop_tick))
3027                    .map_err(|_| DdsError::PreconditionNotMet {
3028                        reason: "spawn zdds-tick thread",
3029                    })?,
3030            );
3031        }
3032
3033        let mut guard = rt
3034            .handles
3035            .lock()
3036            .map_err(|_| DdsError::PreconditionNotMet {
3037                reason: "runtime handles mutex poisoned",
3038            })?;
3039        *guard = handles_init;
3040        drop(guard);
3041
3042        Ok(rt)
3043    }
3044
3045    /// Local unicast locator for user data (announced in SPDP).
3046    #[must_use]
3047    pub fn user_locator(&self) -> zerodds_rtps::wire_types::Locator {
3048        self.user_unicast.local_locator()
3049    }
3050
3051    /// Local unicast locator for SPDP metatraffic.
3052    #[must_use]
3053    pub fn spdp_unicast_locator(&self) -> zerodds_rtps::wire_types::Locator {
3054        self.spdp_unicast.local_locator()
3055    }
3056
3057    /// Returns the `BuiltinEndpointSet` bitmask that the runtime
3058    /// currently announces in the SPDP beacon. Used for tests + diagnostics;
3059    /// production consumers should decode the SPDP beacon
3060    /// themselves.
3061    #[must_use]
3062    pub fn announced_builtin_endpoint_set(&self) -> u32 {
3063        self.spdp_beacon
3064            .lock()
3065            .map(|b| b.data.builtin_endpoint_set)
3066            .unwrap_or(0)
3067    }
3068
3069    /// Registers a `TypeObject` in the local TypeLookup server
3070    /// registry. Other participants can then query this type via
3071    /// a `getTypes` request (XTypes 1.3 §7.6.3.3.4).
3072    ///
3073    /// Returns the `EquivalenceHash` of the registered type
3074    /// (the caller can embed it e.g. in `PublicationBuiltinTopicData` as a
3075    /// PID_TYPE_INFORMATION hint).
3076    ///
3077    /// # Errors
3078    /// `DdsError::PreconditionNotMet` on lock poisoning or a hash
3079    /// computation error.
3080    pub fn register_type_object(
3081        &self,
3082        obj: zerodds_types::type_object::TypeObject,
3083    ) -> Result<zerodds_types::EquivalenceHash> {
3084        let hash = zerodds_types::compute_hash(&obj).map_err(|_| DdsError::PreconditionNotMet {
3085            reason: "type hash computation failed",
3086        })?;
3087        let mut server =
3088            self.type_lookup_server
3089                .lock()
3090                .map_err(|_| DdsError::PreconditionNotMet {
3091                    reason: "type_lookup_server mutex poisoned",
3092                })?;
3093        match obj {
3094            zerodds_types::type_object::TypeObject::Minimal(m) => {
3095                server.registry.insert_minimal(hash, m);
3096            }
3097            zerodds_types::type_object::TypeObject::Complete(c) => {
3098                server.registry.insert_complete(hash, c);
3099            }
3100            _ => {
3101                return Err(DdsError::PreconditionNotMet {
3102                    reason: "unknown TypeObject variant",
3103                });
3104            }
3105        }
3106        Ok(hash)
3107    }
3108
3109    /// Sends a `getTypes` request to a discovered peer and
3110    /// returns a `RequestId` with which the caller can correlate the
3111    /// asynchronous reply later (XTypes 1.3
3112    /// §7.6.3.3.4 + `TypeLookupClient::handle_reply`).
3113    ///
3114    /// `peer` must be in `discovered_participants()` — otherwise
3115    /// `None` is returned (no known peer locator). On a
3116    /// successful send the request sample-identity sequence
3117    /// is returned as the `RequestId`; an incoming reply is correlated on
3118    /// this sequence ID.
3119    ///
3120    /// # Errors
3121    /// `DdsError::PreconditionNotMet` on encode errors or lock
3122    /// poisoning.
3123    pub fn send_type_lookup_request(
3124        &self,
3125        peer: zerodds_rtps::wire_types::GuidPrefix,
3126        type_hashes: &[zerodds_types::EquivalenceHash],
3127    ) -> Result<Option<zerodds_discovery::type_lookup::RequestId>> {
3128        use alloc::sync::Arc as AllocArc;
3129        use zerodds_discovery::type_lookup::request_types_payload;
3130        use zerodds_rtps::datagram::encode_data_datagram;
3131        use zerodds_rtps::header::RtpsHeader;
3132        use zerodds_rtps::submessages::DataSubmessage;
3133        use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber};
3134
3135        // Find peer's user-unicast locator (default-unicast first;
3136        // fallback metatraffic-unicast). TypeLookup datagrams go via
3137        // the user-unicast path — the peer DCPS runtime has a
3138        // shared receive loop there for SEDP/user data/TypeLookup.
3139        let target = {
3140            let discovered = self
3141                .discovered
3142                .lock()
3143                .map_err(|_| DdsError::PreconditionNotMet {
3144                    reason: "discovered mutex poisoned",
3145                })?;
3146            let Some(dp) = discovered.get(&peer) else {
3147                return Ok(None);
3148            };
3149            dp.data
3150                .default_unicast_locator
3151                .or(dp.data.metatraffic_unicast_locator)
3152        };
3153        let Some(target) = target else {
3154            return Ok(None);
3155        };
3156
3157        // Allocate RequestId (client-side incrementing sequence). Reply
3158        // correlation runs via the `handle_reply` callback. We
3159        // register a callback that feeds the returned
3160        // TypeObjects into the local `TypeLookupServer.registry`
3161        // (XTypes 1.3 §7.6.3.3.4): hash-by-hash, separately
3162        // for Minimal and Complete variants. This way a hash that
3163        // was resolved once is recognized for future `has_type_for_hash`
3164        // checks (= no re-requests).
3165        let mut client =
3166            self.type_lookup_client
3167                .lock()
3168                .map_err(|_| DdsError::PreconditionNotMet {
3169                    reason: "type_lookup_client mutex poisoned",
3170                })?;
3171        let type_ids: alloc::vec::Vec<zerodds_types::TypeIdentifier> = type_hashes
3172            .iter()
3173            .map(|h| zerodds_types::TypeIdentifier::EquivalenceHashMinimal(*h))
3174            .collect();
3175        let server_for_cb = Arc::clone(&self.type_lookup_server);
3176        let cb = Box::new(
3177            move |reply: zerodds_discovery::type_lookup::TypeLookupReply| {
3178                let zerodds_discovery::type_lookup::TypeLookupReply::Types(types_reply) = reply
3179                else {
3180                    return;
3181                };
3182                let Ok(mut server) = server_for_cb.lock() else {
3183                    return;
3184                };
3185                for t in &types_reply.types {
3186                    match t {
3187                        zerodds_types::type_lookup::ReplyTypeObject::Minimal(m) => {
3188                            let to = zerodds_types::type_object::TypeObject::Minimal(m.clone());
3189                            if let Ok(h) = zerodds_types::compute_hash(&to) {
3190                                server.registry.insert_minimal(h, m.clone());
3191                            }
3192                        }
3193                        zerodds_types::type_lookup::ReplyTypeObject::Complete(c) => {
3194                            let to = zerodds_types::type_object::TypeObject::Complete(c.clone());
3195                            if let Ok(h) = zerodds_types::compute_hash(&to) {
3196                                server.registry.insert_complete(h, c.clone());
3197                            }
3198                        }
3199                    }
3200                }
3201            },
3202        );
3203        let request_id = client.request_types(type_ids.clone(), cb);
3204        drop(client);
3205
3206        // Encode the wire request payload (PL_CDR_LE-Encapsulation).
3207        let body = request_types_payload(&type_ids).map_err(|_| DdsError::PreconditionNotMet {
3208            reason: "type_lookup request payload encode failed",
3209        })?;
3210        let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
3211        payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
3212        payload.extend_from_slice(&body);
3213
3214        // Use the RequestId as the writer_sn so the peer-side reply can
3215        // echo it for correlation (XTypes §7.6.3.3.3 Sample-Identity).
3216        let id_u64 = request_id.0;
3217        let sn =
3218            SequenceNumber::from_high_low((id_u64 >> 32) as i32, (id_u64 & 0xFFFF_FFFF) as u32);
3219        let header = RtpsHeader {
3220            protocol_version: ProtocolVersion::CURRENT,
3221            vendor_id: VendorId::ZERODDS,
3222            guid_prefix: self.guid_prefix,
3223        };
3224        let data = DataSubmessage {
3225            extra_flags: 0,
3226            reader_id: EntityId::TL_SVC_REQ_READER,
3227            writer_id: EntityId::TL_SVC_REQ_WRITER,
3228            writer_sn: sn,
3229            inline_qos: None,
3230            key_flag: false,
3231            non_standard_flag: false,
3232            serialized_payload: AllocArc::from(payload.into_boxed_slice()),
3233        };
3234        let datagram =
3235            encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
3236                reason: "type_lookup request datagram encode failed",
3237            })?;
3238
3239        if is_routable_user_locator(&target) {
3240            let _ = self.user_unicast.send(&target, &datagram);
3241        }
3242        Ok(Some(request_id))
3243    }
3244
3245    /// Activates the security builtin endpoint stack
3246    /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
3247    /// MessageSecure`). Typically called by the factory
3248    /// once a security plugin is registered on the participant.
3249    /// Idempotent: a second call has no effect. Returns the (possibly
3250    /// freshly created) stack handle.
3251    pub fn enable_security_builtins(
3252        &self,
3253        vendor_id: VendorId,
3254    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3255        self.install_security_stack(SecurityBuiltinStack::new(self.guid_prefix, vendor_id))
3256    }
3257
3258    /// Like [`enable_security_builtins`](Self::enable_security_builtins),
3259    /// but with an active auth-handshake driver (FU2 Gap 4). The stack
3260    /// is built via [`SecurityBuiltinStack::with_auth`]: the shared
3261    /// `auth` plugin (= the same instance that hangs on the crypto gate as
3262    /// the `SharedSecretProvider`) drives the PKI handshake as soon as
3263    /// a peer with stateless bits + identity token is discovered.
3264    ///
3265    /// `local_identity` comes from `validate_local_identity`; the local
3266    /// 16-byte participant GUID is derived from the `guid_prefix`.
3267    ///
3268    /// Idempotent (first-wins): if a stack is already active — even one
3269    /// built without auth — that one is returned and the freshly
3270    /// built one discarded.
3271    #[cfg(feature = "security")]
3272    pub fn enable_security_builtins_with_auth(
3273        self: &Arc<Self>,
3274        vendor_id: VendorId,
3275        auth: Arc<Mutex<dyn zerodds_security::authentication::AuthenticationPlugin>>,
3276        local_identity: zerodds_security::authentication::IdentityHandle,
3277    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3278        let local_guid = Guid::new(self.guid_prefix, EntityId::PARTICIPANT).to_bytes();
3279        // Announce the local IdentityToken in the SPDP beacon (PID_IDENTITY_TOKEN,
3280        // FU2 Gap 7c) + set the stateless/volatile-secure bits, so peers
3281        // initiate the auth handshake. Before moving `auth` into the stack.
3282        if let Ok(mut plugin) = auth.lock() {
3283            if let Ok(token) = plugin.get_identity_token(local_identity) {
3284                // PID_PERMISSIONS_TOKEN (§7.4.1.5, S4 point 1): secure
3285                // vendors (cyclone/FastDDS) start validate_remote_identity
3286                // only when SPDP carries identity_token AND permissions_token;
3287                // otherwise we stay non-secure and all endpoints are "not
3288                // allowed". Empty if no permissions are configured.
3289                let perm_token = plugin.get_permissions_token();
3290                let pdata = if let Ok(mut beacon) = self.spdp_beacon.lock() {
3291                    if !token.is_empty() {
3292                        beacon.data.identity_token = Some(token);
3293                    }
3294                    if !perm_token.is_empty() {
3295                        beacon.data.permissions_token = Some(perm_token);
3296                    }
3297                    // Full secure builtin endpoint set (§7.4.7.1): stateless +
3298                    // VolatileSecure (22-25) PLUS secure SEDP (16-19),
3299                    // secure ParticipantMessage (20-21) and DCPSParticipantsSecure
3300                    // (26-27). cyclone starts validate_remote_identity + creates the
3301                    // secure builtin proxies ONLY when the remote announces the full
3302                    // secure set (cyclone-trace-verified) — only
3303                    // 22-25 → "Non secure remote ... not allowed", no handshake.
3304                    beacon.data.builtin_endpoint_set |= endpoint_flag::PUBLICATIONS_SECURE_WRITER
3305                        | endpoint_flag::PUBLICATIONS_SECURE_READER
3306                        | endpoint_flag::SUBSCRIPTIONS_SECURE_WRITER
3307                        | endpoint_flag::SUBSCRIPTIONS_SECURE_READER
3308                        | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_WRITER
3309                        | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_READER
3310                        | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
3311                        | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
3312                        | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
3313                        | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
3314                        | endpoint_flag::PARTICIPANT_SECURE_WRITER
3315                        | endpoint_flag::PARTICIPANT_SECURE_READER;
3316                    // PID_PARTICIPANT_SECURITY_INFO (§7.4.1.6): marks us as a
3317                    // secure participant — mandatory, otherwise cyclone/
3318                    // FastDDS treat us as non-secure and reject all endpoints.
3319                    // IS_VALID on both masks; derive the participant-level
3320                    // ParticipantSecurityAttributes (§9.4.2.4) from the governance:
3321                    // is_{rtps,discovery,liveliness}_protected in the
3322                    // attr mask, is_*_encrypted in the plugin mask. cyclone
3323                    // matches the announced bits against its own governance —
3324                    // a null mask with e.g. discovery=ENCRYPT is a policy
3325                    // mismatch and cyclone then establishes NO secured
3326                    // participant crypto handshake (bug source protected discovery).
3327                    use zerodds_rtps::participant_security_info::{
3328                        ParticipantSecurityInfo, attrs, plugin_attrs,
3329                    };
3330                    let (mut a, mut p) = (attrs::IS_VALID, plugin_attrs::IS_VALID);
3331                    if let Some(gate) = self.config.security.as_ref() {
3332                        let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
3333                        let disc = gate.discovery_protection().unwrap_or(ProtectionLevel::None);
3334                        let live = gate
3335                            .liveliness_protection()
3336                            .unwrap_or(ProtectionLevel::None);
3337                        if rtps != ProtectionLevel::None {
3338                            a |= attrs::IS_RTPS_PROTECTED;
3339                        }
3340                        if disc != ProtectionLevel::None {
3341                            a |= attrs::IS_DISCOVERY_PROTECTED;
3342                        }
3343                        if live != ProtectionLevel::None {
3344                            a |= attrs::IS_LIVELINESS_PROTECTED;
3345                        }
3346                        if rtps == ProtectionLevel::Encrypt {
3347                            p |= plugin_attrs::IS_RTPS_ENCRYPTED;
3348                        }
3349                        if disc == ProtectionLevel::Encrypt {
3350                            p |= plugin_attrs::IS_DISCOVERY_ENCRYPTED;
3351                        }
3352                        if live == ProtectionLevel::Encrypt {
3353                            p |= plugin_attrs::IS_LIVELINESS_ENCRYPTED;
3354                        }
3355                    }
3356                    beacon.data.participant_security_info = Some(ParticipantSecurityInfo {
3357                        participant_security_attributes: a,
3358                        plugin_participant_security_attributes: p,
3359                    });
3360                    // c.pdata (§9.3.2.5.2, S4 root 6+7): our own
3361                    // ParticipantBuiltinTopicData as PL_CDR_**BE** — the replier
3362                    // (cyclone) deserializes c.pdata strictly as a big-endian
3363                    // ParameterList and binds the participant_guid to the
3364                    // authenticated identity. LE → "payload too long".
3365                    Some(beacon.data.to_pl_cdr_be())
3366                } else {
3367                    None
3368                };
3369                if let Some(pd) = pdata {
3370                    plugin.set_local_participant_data(pd);
3371                }
3372            }
3373        }
3374        let stack = self.install_security_stack(SecurityBuiltinStack::with_auth(
3375            self.guid_prefix,
3376            vendor_id,
3377            auth,
3378            local_identity,
3379            local_guid,
3380        ));
3381        // FU2 S3: kick off in-process participant discovery + handshake trigger
3382        // deterministically — decouples the secured discovery from the
3383        // flaky multicast path (codepit LXC). Bidirectional, idempotent.
3384        self.inproc_announce_participant();
3385        // FU2 S3: immediate token-carrying SPDP re-announce (event-driven).
3386        self.announce_spdp_now();
3387        stack
3388    }
3389
3390    /// Installs a freshly built `SecurityBuiltinStack` into the
3391    /// runtime slot (idempotent) and catches up on peers already
3392    /// discovered via SPDP. Shared core of
3393    /// [`enable_security_builtins`](Self::enable_security_builtins) and
3394    /// [`enable_security_builtins_with_auth`](Self::enable_security_builtins_with_auth).
3395    fn install_security_stack(
3396        &self,
3397        fresh: SecurityBuiltinStack,
3398    ) -> Arc<Mutex<SecurityBuiltinStack>> {
3399        // Lock poisoning is a bug indicator here (an earlier panic in the
3400        // hot path). In that case we return a fresh, isolated
3401        // stack — the caller gets at least a
3402        // functional slot, but the hot path writes its mutations
3403        // into the unlocked original. In production code this does not happen;
3404        // in tests (where poisoning can occur) this is a
3405        // best-effort recovery.
3406        let mut slot = match self.security_builtin.lock() {
3407            Ok(g) => g,
3408            Err(_) => {
3409                return Arc::new(Mutex::new(fresh));
3410            }
3411        };
3412        if let Some(existing) = slot.as_ref() {
3413            return Arc::clone(existing);
3414        }
3415        let stack = Arc::new(Mutex::new(fresh));
3416        // Catch up on already-discovered peers (discovery may have already
3417        // seen SPDP beacons before the plugin was activated).
3418        if let Ok(cache) = self.discovered.lock() {
3419            if let Ok(mut s) = stack.lock() {
3420                for peer in cache.iter() {
3421                    s.handle_remote_endpoints(peer);
3422                }
3423            }
3424        }
3425        *slot = Some(Arc::clone(&stack));
3426        // Protected discovery (DDS-Security §8.4.2.4): if the governance demands
3427        // `discovery_protection_kind != NONE`, the SedpStack routes secured
3428        // endpoints via the secure SEDP (DCPSPublicationsSecure/Subscriptions
3429        // Secure) instead of plaintext — the runtime send path protects their DATA/
3430        // HEARTBEAT/GAP with the participant data key. Set before the first
3431        // announce_* (endpoint creation follows the security activation).
3432        #[cfg(feature = "security")]
3433        if let Some(gate) = self.config.security.as_ref() {
3434            let protected = gate
3435                .discovery_protection()
3436                .map(|l| l != ProtectionLevel::None)
3437                .unwrap_or(false);
3438            if protected {
3439                if let Ok(mut sedp) = self.sedp.lock() {
3440                    sedp.set_discovery_protected(true);
3441                }
3442            }
3443        }
3444        stack
3445    }
3446
3447    /// Snapshot handle on the security builtin stack. `None` if
3448    /// [`enable_security_builtins`](Self::enable_security_builtins)
3449    /// has not been called yet.
3450    #[must_use]
3451    pub fn security_builtin_snapshot(&self) -> Option<Arc<Mutex<SecurityBuiltinStack>>> {
3452        self.security_builtin.lock().ok()?.as_ref().map(Arc::clone)
3453    }
3454
3455    /// `assert_liveliness()` on the `DomainParticipant` (DCPS 1.4
3456    /// §2.2.3.11 MANUAL_BY_PARTICIPANT). Sends exactly one WLP heartbeat
3457    /// with `kind = MANUAL_BY_PARTICIPANT` on the next tick;
3458    /// all readers matching this participant refresh their
3459    /// last-seen timestamp. Idempotent — multiple calls within
3460    /// one tick period result in multiple wire sends up to the
3461    /// cap (`MAX_QUEUED_PULSES = 32`).
3462    pub fn assert_liveliness(&self) {
3463        if let Ok(mut wlp) = self.wlp.lock() {
3464            wlp.assert_participant();
3465        }
3466    }
3467
3468    /// `assert_liveliness()` on a `DataWriter` (DCPS 1.4 §2.2.3.11
3469    /// MANUAL_BY_TOPIC). `topic_token` is an opaque token that
3470    /// matching readers can use to associate the pulse with a concrete
3471    /// topic. We use the ZeroDDS vendor kind (Cyclone /
3472    /// Fast-DDS ignore the vendor kind, which is spec-conformant —
3473    /// MSB-set in `kind` requests "ignore unknown" behavior).
3474    pub fn assert_writer_liveliness(&self, topic_token: Vec<u8>) {
3475        if let Ok(mut wlp) = self.wlp.lock() {
3476            wlp.assert_topic(topic_token);
3477        }
3478    }
3479
3480    /// Current WLP last-seen timestamp of a remote peer (relative
3481    /// to runtime start). `None` if the peer has not sent a WLP
3482    /// heartbeat yet.
3483    #[must_use]
3484    pub fn peer_liveliness_last_seen(&self, prefix: &GuidPrefix) -> Option<Duration> {
3485        self.wlp
3486            .lock()
3487            .ok()
3488            .and_then(|w| w.peer_state(prefix).map(|s| s.last_seen))
3489    }
3490
3491    /// Returns the [`zerodds_discovery::PeerCapabilities`] of a remote
3492    /// peer, based on its most recently received SPDP beacon.
3493    /// `None` if the peer has not been discovered via SPDP yet.
3494    #[must_use]
3495    pub fn peer_capabilities(
3496        &self,
3497        prefix: &GuidPrefix,
3498    ) -> Option<zerodds_discovery::PeerCapabilities> {
3499        self.discovered
3500            .lock()
3501            .ok()
3502            .and_then(|d| d.get(prefix).map(|p| p.data.builtin_endpoint_set))
3503            .map(zerodds_discovery::PeerCapabilities::from_bits)
3504    }
3505
3506    /// Snapshot of the currently discovered remote participants.
3507    /// Key = GUID prefix, value = last seen beacon content.
3508    #[must_use]
3509    pub fn discovered_participants(&self) -> Vec<DiscoveredParticipant> {
3510        self.discovered
3511            .lock()
3512            .map(|cache| cache.iter().cloned().collect())
3513            .unwrap_or_default()
3514    }
3515
3516    /// Wires the `BuiltinSinks` of the `DomainParticipant` into the
3517    /// discovery hot path. From this
3518    /// call on, all SPDP/SEDP receive events land as samples in
3519    /// the 4 builtin-topic readers.
3520    ///
3521    /// Called by the `DomainParticipant` constructor exactly once during
3522    /// setup.
3523    pub fn attach_builtin_sinks(&self, sinks: crate::builtin_subscriber::BuiltinSinks) {
3524        if let Ok(mut guard) = self.builtin_sinks.lock() {
3525            *guard = Some(sinks);
3526        }
3527    }
3528
3529    /// Snapshot of the currently wired BuiltinSinks (internal, for the
3530    /// hot path).
3531    pub(crate) fn builtin_sinks_snapshot(&self) -> Option<crate::builtin_subscriber::BuiltinSinks> {
3532        self.builtin_sinks.lock().ok().and_then(|g| g.clone())
3533    }
3534
3535    /// Wires the `IgnoreFilter` of the `DomainParticipant` into the
3536    /// discovery hot path. From
3537    /// this call on, SPDP/SEDP receive events are checked against the
3538    /// filter before being pushed as a builtin sample or used as an
3539    /// SEDP match source.
3540    ///
3541    /// Called by the `DomainParticipant` constructor exactly once during
3542    /// setup.
3543    pub fn attach_ignore_filter(&self, filter: crate::participant::IgnoreFilter) {
3544        if let Ok(mut guard) = self.ignore_filter.lock() {
3545            *guard = Some(filter);
3546        }
3547    }
3548
3549    /// Snapshot of the currently wired IgnoreFilter (internal, for
3550    /// the hot path).
3551    pub(crate) fn ignore_filter_snapshot(&self) -> Option<crate::participant::IgnoreFilter> {
3552        self.ignore_filter.lock().ok().and_then(|g| g.clone())
3553    }
3554
3555    /// Synchronizes the protected-discovery flag of the `SedpStack` with the
3556    /// governance (`discovery_protection_kind`). Idempotent, called before every
3557    /// `announce_*` — so the flag is set correctly regardless of the
3558    /// order in which security activation and endpoint creation ran.
3559    #[cfg(feature = "security")]
3560    fn sync_sedp_discovery_protected(&self, sedp: &mut SedpStack) {
3561        if let Some(gate) = self.config.security.as_ref() {
3562            let protected = gate
3563                .discovery_protection()
3564                .map(|l| l != ProtectionLevel::None)
3565                .unwrap_or(false);
3566            sedp.set_discovery_protected(protected);
3567        }
3568    }
3569
3570    /// Announces a local publication via SEDP. The runtime
3571    /// sends the generated datagrams immediately to all already-
3572    /// discovered remote participants.
3573    ///
3574    /// # Errors
3575    /// `WireError` if encoding fails.
3576    pub fn announce_publication(
3577        &self,
3578        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3579    ) -> Result<()> {
3580        // In-process discovery fastpath: put it in the stash so a
3581        // peer runtime starting later in the same process can pull us
3582        // via `inproc_snapshot`.
3583        if let Ok(mut v) = self.announced_pubs.lock() {
3584            v.push(data.clone());
3585        }
3586        // ADR-0006: side-map lookup. If the local user writer has a
3587        // same-host backend attached (set_shm_locator was
3588        // called), we inject PID_SHM_LOCATOR into the SEDP
3589        // sample. Otherwise pure 1:1 spec wire.
3590        let shm = self.shm_locator(data.key.entity_id);
3591        let datagrams = {
3592            let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3593                reason: "sedp poisoned",
3594            })?;
3595            // Protected discovery (§8.4.2.4): set robustly before the announce —
3596            // independent of the order of enable_security_builtins vs.
3597            // endpoint creation. `discovery_protection_kind != NONE` routes
3598            // the announce into the secure SEDP writer.
3599            #[cfg(feature = "security")]
3600            self.sync_sedp_discovery_protected(&mut sedp);
3601            let res = if let Some(ref bytes) = shm {
3602                sedp.announce_publication_with_shm_locator(data, bytes)
3603            } else {
3604                sedp.announce_publication(data)
3605            };
3606            res.map_err(|_| DdsError::WireError {
3607                message: alloc::string::String::from("sedp announce_publication"),
3608            })?
3609        };
3610        // Send outside the lock (Rc<Vec<Locator>> is !Send,
3611        // but we are on the same thread as `self` — no
3612        // problem).
3613        for dg in datagrams {
3614            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3615                for t in dg.targets.iter() {
3616                    if is_routable_user_locator(t) {
3617                        // §8.3.7: unicast metatraffic (SEDP DATA to the remote
3618                        // metatraffic_unicast_locator) MUST go out from the metatraffic
3619                        // recv socket `spdp_unicast`, NOT from the ephemeral
3620                        // `spdp_mc_tx` — otherwise the peer sees a foreign
3621                        // source port and sends its reliable ACKNACK/resends
3622                        // to a dead port (cross-vendor SEDP stall). Identical
3623                        // to `send_discovery_datagram`.
3624                        let _ = self.spdp_unicast.send(t, &secured);
3625                    }
3626                }
3627            }
3628        }
3629        // In-process discovery fastpath: serve same-process+domain peers
3630        // synchronously + losslessly with this publication.
3631        self.inproc_announce_publication(data);
3632        Ok(())
3633    }
3634
3635    /// Announces a local subscription via SEDP. Analogous to
3636    /// `announce_publication`.
3637    ///
3638    /// # Errors
3639    /// `WireError` if encoding fails.
3640    pub fn announce_subscription(
3641        &self,
3642        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3643    ) -> Result<()> {
3644        if let Ok(mut v) = self.announced_subs.lock() {
3645            v.push(data.clone());
3646        }
3647        let datagrams = {
3648            let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3649                reason: "sedp poisoned",
3650            })?;
3651            #[cfg(feature = "security")]
3652            self.sync_sedp_discovery_protected(&mut sedp);
3653            sedp.announce_subscription(data)
3654                .map_err(|_| DdsError::WireError {
3655                    message: alloc::string::String::from("sedp announce_subscription"),
3656                })?
3657        };
3658        for dg in datagrams {
3659            if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3660                for t in dg.targets.iter() {
3661                    if is_routable_user_locator(t) {
3662                        // Source port: metatraffic recv socket, not spdp_mc_tx
3663                        // (see announce_publication / send_discovery_datagram).
3664                        let _ = self.spdp_unicast.send(t, &secured);
3665                    }
3666                }
3667            }
3668        }
3669        // In-process discovery fastpath: see `announce_publication`.
3670        self.inproc_announce_subscription(data);
3671        Ok(())
3672    }
3673
3674    /// Re-announces the local SEDP endpoint records (publications +
3675    /// subscriptions) to a peer whose crypto-token exchange has just
3676    /// completed. Background: under `rtps_protection`/`discovery_
3677    /// protection` ZeroDDS wraps the SEDP message-/submessage-protected; the
3678    /// peer discards the initial SEDP burst UNTIL it has our participant crypto
3679    /// token (via Volatile). From that moment it can decode — a
3680    /// one-time re-announce brings the previously dropped SEDP up (mints fresh
3681    /// SNs; the reliable SEDP writer delivers them, HEARTBEAT/NACK retry covers a
3682    /// not-quite-ready peer timing). Once per peer (dedup).
3683    ///
3684    /// No-op without active rtps_/discovery_protection (then the announce
3685    /// went through plaintext anyway) and for already re-announced peers. Emits
3686    /// the RETAINED records directly (NO additional `announced_pubs` push).
3687    #[cfg(feature = "security")]
3688    fn re_announce_sedp_to_peer(&self, peer_prefix: GuidPrefix) {
3689        let Some(gate) = &self.config.security else {
3690            return;
3691        };
3692        let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3693        let disc =
3694            gate.discovery_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3695        if !rtps && !disc {
3696            return;
3697        }
3698        // First check whether we have any local endpoints at all — the token
3699        // exchange can complete BEFORE the user endpoint creation.
3700        // Without records do NOT mark as "re-announced" (the periodic tick
3701        // retriggers as soon as the user writer/reader is announced).
3702        let pubs = self
3703            .announced_pubs
3704            .lock()
3705            .map(|v| v.clone())
3706            .unwrap_or_default();
3707        let subs = self
3708            .announced_subs
3709            .lock()
3710            .map(|v| v.clone())
3711            .unwrap_or_default();
3712        if pubs.is_empty() && subs.is_empty() {
3713            return;
3714        }
3715        {
3716            let mut set = match self.sedp_reannounced.write() {
3717                Ok(s) => s,
3718                Err(_) => return,
3719            };
3720            if !set.insert(peer_prefix.0) {
3721                return; // already re-announced
3722            }
3723        }
3724        let send_dgs = |dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>| {
3725            for dg in dgs {
3726                if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3727                    for t in dg.targets.iter() {
3728                        if is_routable_user_locator(t) {
3729                            let _ = self.spdp_unicast.send(t, &secured);
3730                        }
3731                    }
3732                }
3733            }
3734        };
3735        for data in &pubs {
3736            let shm = self.shm_locator(data.key.entity_id);
3737            let dgs = {
3738                let Ok(mut sedp) = self.sedp.lock() else {
3739                    continue;
3740                };
3741                self.sync_sedp_discovery_protected(&mut sedp);
3742                let res = if let Some(ref bytes) = shm {
3743                    sedp.announce_publication_with_shm_locator(data, bytes)
3744                } else {
3745                    sedp.announce_publication(data)
3746                };
3747                match res {
3748                    Ok(d) => d,
3749                    Err(_) => continue,
3750                }
3751            };
3752            send_dgs(dgs);
3753        }
3754        for data in &subs {
3755            let dgs = {
3756                let Ok(mut sedp) = self.sedp.lock() else {
3757                    continue;
3758                };
3759                self.sync_sedp_discovery_protected(&mut sedp);
3760                match sedp.announce_subscription(data) {
3761                    Ok(d) => d,
3762                    Err(_) => continue,
3763                }
3764            };
3765            send_dgs(dgs);
3766        }
3767    }
3768
3769    /// Own participant data as a `DiscoveredParticipant` — the
3770    /// self-view that the in-process fastpath hands to peers.
3771    fn self_as_discovered_participant(&self) -> zerodds_discovery::spdp::DiscoveredParticipant {
3772        // From the LIVE SPDP beacon: after `enable_security_builtins_with_auth`
3773        // it carries the `identity_token` + the secure endpoint bits that the
3774        // `participant_data` construction snapshot does NOT have. Without these the
3775        // in-process injected DP is worthless for the security handshake trigger
3776        // (`handle_remote_endpoints`/`begin_handshake_with` need
3777        // the token). Fallback to `participant_data` on lock poisoning.
3778        let data = self
3779            .spdp_beacon
3780            .lock()
3781            .map(|b| b.data.clone())
3782            .unwrap_or_else(|_| self.participant_data.clone());
3783        zerodds_discovery::spdp::DiscoveredParticipant {
3784            sender_prefix: self.guid_prefix,
3785            sender_vendor: VendorId::ZERODDS,
3786            data,
3787        }
3788    }
3789
3790    /// In-process discovery: injects the just-announced publication
3791    /// synchronously into all same-process+domain peer runtimes.
3792    fn inproc_announce_publication(
3793        &self,
3794        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3795    ) {
3796        let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
3797        let mut dp = None;
3798        for peer in peers {
3799            if peer.guid_prefix == self.guid_prefix {
3800                continue;
3801            }
3802            let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
3803            peer.inproc_inject_publication(dp, data);
3804        }
3805    }
3806
3807    /// In-process discovery: injects the just-announced subscription
3808    /// synchronously into all same-process+domain peer runtimes.
3809    fn inproc_announce_subscription(
3810        &self,
3811        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3812    ) {
3813        let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
3814        let mut dp = None;
3815        for peer in peers {
3816            if peer.guid_prefix == self.guid_prefix {
3817                continue;
3818            }
3819            let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
3820            peer.inproc_inject_subscription(dp, data);
3821        }
3822    }
3823
3824    /// In-process discovery (receive side): wires the remote
3825    /// participant + injects the publication into the SEDP cache and
3826    /// matches the local readers. Idempotent — an announcement arriving
3827    /// later via UDP is thereby a no-op.
3828    fn inproc_inject_publication(
3829        self: &Arc<Self>,
3830        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
3831        data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3832    ) {
3833        // §2.2.2.2.1.17: an ignored publication/participant must not be matched.
3834        // The in-process fastpath bypasses the wire match path, so the ignore
3835        // filter must be honored here too — otherwise the Durability-Service's
3836        // own two participants (ingest + replay, same process) would match and
3837        // echo-loop despite mutually ignoring each other.
3838        if let Some(filter) = self.ignore_filter_snapshot() {
3839            let pub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
3840            let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
3841            if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
3842                return;
3843            }
3844        }
3845        let now = self.start_instant.elapsed();
3846        let is_new = self
3847            .discovered
3848            .lock()
3849            .map(|mut c| c.insert(dp.clone()))
3850            .unwrap_or(false);
3851        if let Ok(mut sedp) = self.sedp.lock() {
3852            if is_new {
3853                sedp.on_participant_discovered(dp);
3854            }
3855            sedp.cache_mut().insert_publication(data.clone(), now);
3856        }
3857        run_matching_pass(self);
3858    }
3859
3860    /// In-process discovery (receive side): like `inproc_inject_publication`
3861    /// for a subscription.
3862    fn inproc_inject_subscription(
3863        self: &Arc<Self>,
3864        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
3865        data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3866    ) {
3867        // See `inproc_inject_publication`: honor the ignore filter on the
3868        // in-process fastpath (symmetric, subscription side).
3869        if let Some(filter) = self.ignore_filter_snapshot() {
3870            let sub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
3871            let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
3872            if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
3873                return;
3874            }
3875        }
3876        let now = self.start_instant.elapsed();
3877        let is_new = self
3878            .discovered
3879            .lock()
3880            .map(|mut c| c.insert(dp.clone()))
3881            .unwrap_or(false);
3882        if let Ok(mut sedp) = self.sedp.lock() {
3883            if is_new {
3884                sedp.on_participant_discovered(dp);
3885            }
3886            sedp.cache_mut().insert_subscription(data.clone(), now);
3887        }
3888        run_matching_pass(self);
3889    }
3890
3891    /// Snapshot of our own endpoints for the `pull-on-creation` path
3892    /// of a peer runtime starting later in the same process.
3893    fn inproc_snapshot(
3894        &self,
3895    ) -> (
3896        zerodds_discovery::spdp::DiscoveredParticipant,
3897        Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>,
3898        Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>,
3899    ) {
3900        let dp = self.self_as_discovered_participant();
3901        let pubs = self
3902            .announced_pubs
3903            .lock()
3904            .map(|v| v.clone())
3905            .unwrap_or_default();
3906        let subs = self
3907            .announced_subs
3908            .lock()
3909            .map(|v| v.clone())
3910            .unwrap_or_default();
3911        (dp, pubs, subs)
3912    }
3913
3914    /// At runtime creation: ask existing same-process+domain peers
3915    /// for their already-announced endpoints and inject these into
3916    /// our SEDP cache. Symmetric counterpart to the
3917    /// announce hook (which distributes live endpoints to peers).
3918    fn inproc_pull_from_peers(self: &Arc<Self>) {
3919        let peers: Vec<Arc<DcpsRuntime>> =
3920            crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
3921                .into_iter()
3922                .filter(|rt| rt.guid_prefix != self.guid_prefix)
3923                .collect();
3924        for peer in peers {
3925            let (dp, pubs, subs) = peer.inproc_snapshot();
3926            for p in &pubs {
3927                self.inproc_inject_publication(&dp, p);
3928            }
3929            for s in &subs {
3930                self.inproc_inject_subscription(&dp, s);
3931            }
3932        }
3933    }
3934
3935    /// FU2 S3: in-process counterpart to the security part of
3936    /// [`handle_spdp_datagram`]. Wires the secure builtin endpoints of the
3937    /// discovered peer and kicks off — if it announces an `identity_token`
3938    /// — the auth handshake; the resulting AUTH datagrams
3939    /// go to the peer via UDP **unicast** (reliable loopback).
3940    /// No-op without a local security stack or without a peer `identity_token`.
3941    #[cfg(feature = "security")]
3942    fn inproc_drive_security_handshake(
3943        self: &Arc<Self>,
3944        dp: &zerodds_discovery::spdp::DiscoveredParticipant,
3945    ) {
3946        if dp.sender_prefix == self.guid_prefix {
3947            return;
3948        }
3949        let Some(sec) = self.security_builtin_snapshot() else {
3950            return;
3951        };
3952        let dgs = if let Ok(mut s) = sec.lock() {
3953            s.note_remote_vendor(dp.sender_prefix, dp.sender_vendor);
3954            s.handle_remote_endpoints(dp);
3955            match dp.data.identity_token.as_ref() {
3956                Some(token) => s
3957                    .begin_handshake_with(dp.sender_prefix, dp.data.guid.to_bytes(), token)
3958                    .unwrap_or_default(),
3959                None => Vec::new(),
3960            }
3961        } else {
3962            Vec::new()
3963        };
3964        for dg in dgs {
3965            send_discovery_datagram(self, &dg.targets, &dg.bytes);
3966        }
3967    }
3968
3969    /// FU2 S3: in-process SPDP **participant** discovery. This was the real
3970    /// gap — `inproc_inject_publication`/`_subscription` only inject
3971    /// SEDP endpoints, the SPDP participant level (identity_token +
3972    /// `begin_handshake_with`) ran EXCLUSIVELY over the multicast path
3973    /// that is flaky on the codepit LXC. This hook, on activation of the
3974    /// security builtins, exchanges the participant DPs (with token) **bidirectionally** with
3975    /// all same-process+domain peers and kicks off the auth handshakes
3976    /// — deterministically, without a single multicast beacon.
3977    #[cfg(feature = "security")]
3978    fn inproc_announce_participant(self: &Arc<Self>) {
3979        let self_dp = self.self_as_discovered_participant();
3980        let peers: Vec<Arc<DcpsRuntime>> =
3981            crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
3982                .into_iter()
3983                .filter(|rt| rt.guid_prefix != self.guid_prefix)
3984                .collect();
3985        for peer in peers {
3986            // self → peer: the peer discovers US + triggers its handshake.
3987            let _ = peer
3988                .discovered
3989                .lock()
3990                .map(|mut c| c.insert(self_dp.clone()));
3991            peer.inproc_drive_security_handshake(&self_dp);
3992            // peer → self: WE discover the peer + trigger our handshake.
3993            let peer_dp = peer.self_as_discovered_participant();
3994            let _ = self
3995                .discovered
3996                .lock()
3997                .map(|mut c| c.insert(peer_dp.clone()));
3998            self.inproc_drive_security_handshake(&peer_dp);
3999        }
4000    }
4001
4002    /// C1 multicast-free discovery: sends a (possibly already security-
4003    /// transformed) SPDP beacon additionally to all configured
4004    /// unicast initial peers. No-op without peers → pure multicast behavior,
4005    /// no additional syscalls by default.
4006    fn send_spdp_to_initial_peers(&self, bytes: &[u8]) {
4007        for peer in &self.config.initial_peers {
4008            let _ = self.spdp_mc_tx.send(peer, bytes);
4009        }
4010    }
4011
4012    /// FU2 S3: sends an SPDP beacon IMMEDIATELY via multicast, instead of waiting
4013    /// for the next periodic `spdp_period` tick. Critical for the
4014    /// cross-process secured handshake: `DcpsRuntime::start` starts the
4015    /// beacon sender, whose first beacon (token-LESS) goes out BEFORE
4016    /// `enable_security_builtins_with_auth` sets the `identity_token` on the beacon.
4017    /// If a peer latches this token-less first beacon, it calls
4018    /// `begin_handshake_with` with `token=None` → no-op → the handshake NEVER
4019    /// starts. An immediate re-announce after setting the token ensures
4020    /// that the first token-carrying beacon goes out promptly.
4021    #[cfg(feature = "security")]
4022    fn announce_spdp_now(&self) {
4023        let mc_target = Locator {
4024            kind: LocatorKind::UdpV4,
4025            port: u32::from(
4026                u16::try_from(spdp_multicast_port(self.domain_id as u32)).unwrap_or(7400),
4027            ),
4028            address: {
4029                let mut a = [0u8; 16];
4030                a[12..].copy_from_slice(&self.config.spdp_multicast_group.octets());
4031                a
4032            },
4033        };
4034        if let Ok(mut beacon) = self.spdp_beacon.lock() {
4035            if let Ok(datagram) = beacon.serialize() {
4036                if let Some(secured) = secure_outbound_bytes(self, &datagram) {
4037                    let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4038                    // C1 multicast-free discovery: on the immediate announce too, to
4039                    // the configured initial peers (ZERODDS_PEERS).
4040                    self.send_spdp_to_initial_peers(&secured);
4041                    // Directed unicast fan-out to already-discovered peers:
4042                    // covers the order in which we discover a peer
4043                    // BEFORE our security builtins (token) are active — then the
4044                    // directed response in handle_spdp_datagram skipped tokenless;
4045                    // announce_spdp_now() (called by enable() after the token set)
4046                    // catches up with the tokened beacon promptly + LXC-multicast-
4047                    // independently. Otherwise the peer waits until spdp_period.
4048                    for loc in wlp_unicast_targets(&self.discovered_participants()) {
4049                        let _ = self.spdp_unicast.send(&loc, &secured);
4050                    }
4051                }
4052            }
4053            // FastDDS interop: additionally announce on the reliable secure SPDP
4054            // writer (0xff0101c2), so FastDDS sees our full secured
4055            // participant data over its expected channel.
4056            if self.config.enable_secure_spdp {
4057                if let Ok(datagram) = beacon.serialize_secure() {
4058                    let protected = protect_secure_spdp(self, &datagram).unwrap_or(datagram);
4059                    if let Some(secured) = secure_outbound_bytes(self, &protected) {
4060                        let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4061                    }
4062                }
4063            }
4064        }
4065    }
4066
4067    /// FU2 cross-vendor: `EndpointSecurityInfo` (PID_ENDPOINT_SECURITY_INFO,
4068    /// 0x1004) for user endpoints, derived from the governance
4069    /// `data_protection`. Foreign vendors (cyclone/FastDDS) reject, with
4070    /// `data_protection=ENCRYPT`, a user endpoint WITHOUT this PID as
4071    /// non-secure ("Non secure remote ... not allowed by security").
4072    /// `None` without an active security gate (plain).
4073    #[cfg(feature = "security")]
4074    fn user_endpoint_security_info(
4075        &self,
4076    ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4077        let gate = self.config.security.as_ref()?;
4078        let meta = gate.metadata_protection().ok()?;
4079        let data = gate.data_protection().ok()?;
4080        let disc = gate.topic_discovery_protected().unwrap_or(false);
4081        let liv = gate
4082            .liveliness_protection()
4083            .map(|l| l != ProtectionLevel::None)
4084            .unwrap_or(false);
4085        let rdp = gate.topic_read_protected().unwrap_or(false);
4086        let wrp = gate.topic_write_protected().unwrap_or(false);
4087        Some(compute_user_endpoint_attrs(meta, data, disc, liv, rdp, wrp))
4088    }
4089
4090    #[cfg(not(feature = "security"))]
4091    fn user_endpoint_security_info(
4092        &self,
4093    ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4094        None
4095    }
4096
4097    /// Registers a local user writer. The caller gets the
4098    /// writer `EntityId`; for sends via `write_user_sample(eid, ...)`.
4099    ///
4100    /// In the runtime there is **still no** automatic SEDP announce +
4101    /// matching — that comes in B4b. Currently `register_user_writer`
4102    /// is just the wiring.
4103    ///
4104    /// # Errors
4105    /// `PreconditionNotMet` if the registry mutex is poisoned.
4106    pub fn register_user_writer(&self, cfg: UserWriterConfig) -> Result<EntityId> {
4107        // Default: WithKey. Backward-compat for all test callers.
4108        self.register_user_writer_kind(cfg, true)
4109    }
4110
4111    /// Like [`register_user_writer`] but with an explicit NoKey/WithKey
4112    /// flag. Cross-vendor interop needs it: if the IDL type has no
4113    /// `@key`, the writer MUST set `is_keyed=false`, otherwise
4114    /// a remote reader rejects the DATA submessage due to an
4115    /// entityKind mismatch (Spec §9.3.1.2 table 9.1: 0x02=WithKey
4116    /// vs 0x03=NoKey).
4117    pub fn register_user_writer_kind(
4118        &self,
4119        cfg: UserWriterConfig,
4120        is_keyed: bool,
4121    ) -> Result<EntityId> {
4122        let now = self.start_instant.elapsed();
4123        let key = self.next_entity_key();
4124        let eid = if is_keyed {
4125            EntityId::user_writer_with_key(key)
4126        } else {
4127            EntityId::user_writer_no_key(key)
4128        };
4129        let writer = ReliableWriter::new(ReliableWriterConfig {
4130            guid: Guid::new(self.guid_prefix, eid),
4131            vendor_id: VendorId::ZERODDS,
4132            reader_proxies: Vec::new(),
4133            max_samples: 1024,
4134            history_kind: HistoryKind::KeepLast { depth: 32 },
4135            heartbeat_period: DEFAULT_HEARTBEAT_PERIOD,
4136            // Ethernet-safe default; the value is raised at the reader match
4137            // if all readers are same-host (see the
4138            // set_fragmentation call after add_reader_proxy).
4139            fragment_size: DEFAULT_FRAGMENT_SIZE,
4140            mtu: DEFAULT_MTU,
4141        });
4142        let mut pub_data = build_publication_data(
4143            self.guid_prefix,
4144            eid,
4145            &cfg,
4146            &self.config.data_representation_offer,
4147            self.user_announce_locator,
4148        );
4149        // FU2 cross-vendor: EndpointSecurityInfo from the governance
4150        // data_protection — otherwise cyclone/FastDDS reject the user endpoint
4151        // with data_protection=ENCRYPT as non-secure.
4152        pub_data.security_info = self.user_endpoint_security_info();
4153        self.user_writers
4154            .write()
4155            .map_err(|_| DdsError::PreconditionNotMet {
4156                reason: "user_writers poisoned",
4157            })?
4158            .insert(
4159                eid,
4160                Arc::new(Mutex::new(UserWriterSlot {
4161                    writer,
4162                    topic_name: cfg.topic_name.clone(),
4163                    type_name: cfg.type_name.clone(),
4164                    reliable: cfg.reliable,
4165                    durability: cfg.durability,
4166                    deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4167                    // Initial `None`: the deadline window starts only on the
4168                    // first real write. Prevents false misses due to
4169                    // slow entity setup (e.g. Linux CI container)
4170                    // before the app does its first write(). On the
4171                    // first write() `last_write = Some(now)` is set,
4172                    // and from then the deadline counter ticks.
4173                    last_write: None,
4174                    offered_deadline_missed_count: 0,
4175                    liveliness_lost_count: 0,
4176                    last_liveliness_assert: Some(now),
4177                    offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus::default(
4178                    ),
4179                    lifespan_nanos: qos_duration_to_nanos(cfg.lifespan.duration),
4180                    sample_insert_times: alloc::collections::VecDeque::new(),
4181                    liveliness_kind: cfg.liveliness.kind,
4182                    liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4183                    ownership: cfg.ownership,
4184                    ownership_strength: cfg.ownership_strength,
4185                    partition: cfg.partition.clone(),
4186                    #[cfg(feature = "security")]
4187                    reader_protection: BTreeMap::new(),
4188                    #[cfg(feature = "security")]
4189                    locator_to_peer: BTreeMap::new(),
4190                    type_identifier: cfg.type_identifier.clone(),
4191                    data_rep_offer_override: cfg.data_representation_offer.clone(),
4192                    // Default FINAL: irrelevant for XCDR1 (default offer)
4193                    // (final==appendable==CDR_LE), correct for XCDR2 for
4194                    // @final types. Appendable/mutable types set this later via
4195                    // set_user_writer_wire_extensibility.
4196                    wire_extensibility: zerodds_types::qos::ExtensibilityForRepr::Final,
4197                    durability_backend: None,
4198                    backend_primed: false,
4199                })),
4200            );
4201        // FIRST match locally, THEN announce — symmetric to
4202        // register_user_reader_kind. Avoids a peer-side match
4203        // triggered by our announce_publication
4204        // starting a data flow to us before we have wired the
4205        // ReaderProxies.
4206        self.match_local_writer_against_cache(eid);
4207        let _ = self.announce_publication(&pub_data);
4208        // Intra-runtime routing: scan local readers for a match on
4209        // (topic, type). Applies to bridge daemons with writer+reader in
4210        // the same runtime (WS/MQTT/CoAP/AMQP bridges). Without this
4211        // route the local reader gets no samples from the local
4212        // writer — the `inproc` fastpath explicitly skips self, UDP loopback
4213        // is not guaranteed, and SEDP match paths go via
4214        // the discovered cache, which does not contain self.
4215        self.recompute_intra_runtime_routes();
4216        // FU2 F-ECHO-WRITE: a user writer created AFTER handshake completion
4217        // (e.g. the event-driven echo writer in the responder/pong) must send its
4218        // per-endpoint datawriter_crypto_tokens IMMEDIATELY to the already-
4219        // authenticated peers — not only on the next tick. Otherwise
4220        // cyclone's reader stays in "waiting for approval by security" beyond
4221        // its match deadline (the event-driven pong may not tick
4222        // in time) → flaky sub=0. Idempotent via endpoint_tokens_sent dedup.
4223        #[cfg(feature = "security")]
4224        self.flush_late_endpoint_tokens();
4225        // Observability event.
4226        self.config.observability.record(
4227            &zerodds_foundation::observability::Event::new(
4228                zerodds_foundation::observability::Level::Info,
4229                zerodds_foundation::observability::Component::Dcps,
4230                "user_writer.created",
4231            )
4232            .with_attr("topic", cfg.topic_name.as_str())
4233            .with_attr("type", cfg.type_name.as_str())
4234            .with_attr("reliable", if cfg.reliable { "true" } else { "false" }),
4235        );
4236        Ok(eid)
4237    }
4238
4239    /// FU2 F-ECHO-WRITE: sends pending per-endpoint crypto tokens IMMEDIATELY to all
4240    /// already-authenticated peers. For user endpoints created AFTER handshake
4241    /// completion (event-driven echo writer in the responder): their token
4242    /// must go out before cyclone's reader match deadline expires — the periodic
4243    /// tick (or a VolatileSecure recv) is otherwise possibly too late. Idempotent
4244    /// via `endpoint_tokens_sent` dedup (double-send with the tick excluded).
4245    #[cfg(feature = "security")]
4246    fn flush_late_endpoint_tokens(&self) {
4247        let Some(stack) = self.security_builtin_snapshot() else {
4248            return;
4249        };
4250        let Ok(mut s) = stack.lock() else {
4251            return;
4252        };
4253        let now = self.start_instant.elapsed();
4254        let peers: alloc::vec::Vec<GuidPrefix> = self
4255            .config
4256            .security
4257            .as_ref()
4258            .map(|g| {
4259                g.authenticated_peer_prefixes()
4260                    .into_iter()
4261                    .map(GuidPrefix::from_bytes)
4262                    .collect()
4263            })
4264            .unwrap_or_default();
4265        for prefix in peers {
4266            let already = self
4267                .endpoint_tokens_sent
4268                .read()
4269                .map(|set| set.clone())
4270                .unwrap_or_default();
4271            let pending =
4272                pending_endpoint_tokens(prepare_endpoint_crypto_tokens(self, prefix), &already);
4273            for ep_msg in pending {
4274                let key = endpoint_token_key(&ep_msg);
4275                let dgs = protect_volatile_outbound(
4276                    self,
4277                    prefix,
4278                    s.volatile_writer
4279                        .write_with_heartbeat(&ep_msg, now)
4280                        .unwrap_or_default(),
4281                );
4282                for dg in dgs {
4283                    for t in dg.targets.iter() {
4284                        let _ = self.spdp_unicast.send(t, &dg.bytes);
4285                    }
4286                }
4287                if let Ok(mut set) = self.endpoint_tokens_sent.write() {
4288                    set.insert(key);
4289                }
4290            }
4291            // Periodic re-announce retrigger: as soon as the user writer/reader
4292            // is announced (announced_pubs/subs not empty), this catches up the
4293            // SEDP initially dropped under rtps_/discovery_protection to this
4294            // (now tokened) peer. Once per peer (dedup in the method).
4295            self.re_announce_sedp_to_peer(prefix);
4296        }
4297    }
4298
4299    /// Spec §2.2.3.5 — registers a durability-service backend on
4300    /// a writer already registered via [`register_user_writer`].
4301    /// With Durability=Transient/Persistent the backend is replayed into the
4302    /// HistoryCache on the first late-joiner match in
4303    /// `wire_writer_to_remote_reader`, so the reader gets all samples —
4304    /// including those no longer in the writer cache due to history eviction
4305    /// or those that have survived a writer restart.
4306    pub fn attach_durability_backend(
4307        &self,
4308        eid: EntityId,
4309        backend: alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>,
4310    ) -> Result<()> {
4311        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4312            what: "attach_durability_backend: unknown writer entity id",
4313        })?;
4314        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4315            reason: "user_writer slot poisoned",
4316        })?;
4317        slot.durability_backend = Some(backend);
4318        slot.backend_primed = false;
4319        Ok(())
4320    }
4321
4322    /// Sets the type extensibility of a writer (FINAL/APPENDABLE/
4323    /// MUTABLE). Affects exclusively the encapsulation header
4324    /// of the user payload (see [`user_payload_encap`]) — relevant for
4325    /// XCDR2 wire, where @appendable requires a `D_CDR2_LE` and @mutable a
4326    /// `PL_CDR2_LE` header. The codegen/FFI calls this after
4327    /// `register_user_writer*` when the type is not @final.
4328    /// Does NOT change the SEDP announce offer list.
4329    ///
4330    /// # Errors
4331    /// `BadParameter` on an unknown EntityId, `PreconditionNotMet` on a
4332    /// poisoned slot mutex.
4333    pub fn set_user_writer_wire_extensibility(
4334        &self,
4335        eid: EntityId,
4336        ext: zerodds_types::qos::ExtensibilityForRepr,
4337    ) -> Result<()> {
4338        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4339            what: "set_user_writer_wire_extensibility: unknown writer entity id",
4340        })?;
4341        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4342            reason: "user_writer slot poisoned",
4343        })?;
4344        slot.wire_extensibility = ext;
4345        Ok(())
4346    }
4347
4348    /// Registers a local user reader. Returns the reader EntityId
4349    /// and an `mpsc::Receiver` through which DataReader handles
4350    /// consume incoming samples.
4351    ///
4352    /// # Errors
4353    /// `PreconditionNotMet` if the registry mutex is poisoned.
4354    /// Registers a user reader. Returns the EntityId and an
4355    /// `mpsc::Receiver<UserSample>` — alive samples deliver payload,
4356    /// lifecycle markers carry key hash + ChangeKind.
4357    pub fn register_user_reader(
4358        &self,
4359        cfg: UserReaderConfig,
4360    ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4361        // Default: WithKey. Backward-compat for all test callers.
4362        self.register_user_reader_kind(cfg, true)
4363    }
4364
4365    /// Like [`register_user_reader`] but with an explicit NoKey/WithKey
4366    /// flag. Symmetric to [`register_user_writer_kind`] — the reader kind
4367    /// must match the writer kind.
4368    pub fn register_user_reader_kind(
4369        &self,
4370        cfg: UserReaderConfig,
4371        is_keyed: bool,
4372    ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4373        let now = self.start_instant.elapsed();
4374        let key = self.next_entity_key();
4375        let eid = if is_keyed {
4376            EntityId::user_reader_with_key(key)
4377        } else {
4378            EntityId::user_reader_no_key(key)
4379        };
4380        let reader = ReliableReader::new(ReliableReaderConfig {
4381            guid: Guid::new(self.guid_prefix, eid),
4382            vendor_id: VendorId::ZERODDS,
4383            writer_proxies: Vec::new(),
4384            max_samples_per_proxy: 256,
4385            // D.5e: 0ms = synchronous ACK response (Cyclone parity).
4386            // Previously 200ms = pre-1.0 default without spec justification.
4387            heartbeat_response_delay:
4388                zerodds_rtps::reliable_reader::DEFAULT_HEARTBEAT_RESPONSE_DELAY,
4389            // C3: ROS-realistic reassembly cap (PointCloud2/Image),
4390            // instead of the conservative rtps 1-MiB default.
4391            assembler_caps: AssemblerCaps {
4392                max_sample_bytes: self.config.max_reassembly_sample_bytes,
4393                ..AssemblerCaps::default()
4394            },
4395        });
4396        let (tx, rx) = mpsc::channel();
4397        let mut sub_data = build_subscription_data(
4398            self.guid_prefix,
4399            eid,
4400            &cfg,
4401            &self.config.data_representation_offer,
4402            self.user_announce_locator,
4403        );
4404        // FU2 cross-vendor: EndpointSecurityInfo from the governance (see writer).
4405        sub_data.security_info = self.user_endpoint_security_info();
4406        self.user_readers
4407            .write()
4408            .map_err(|_| DdsError::PreconditionNotMet {
4409                reason: "user_readers poisoned",
4410            })?
4411            .insert(
4412                eid,
4413                Arc::new(Mutex::new(UserReaderSlot {
4414                    reader,
4415                    topic_name: cfg.topic_name.clone(),
4416                    type_name: cfg.type_name.clone(),
4417                    sample_tx: tx,
4418                    async_waker: Arc::new(std::sync::Mutex::new(None)),
4419                    listener: None,
4420                    durability: cfg.durability,
4421                    deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4422                    // Start time as reference (see register_user_writer).
4423                    last_sample_received: Some(now),
4424                    requested_deadline_missed_count: 0,
4425                    requested_incompatible_qos:
4426                        crate::status::RequestedIncompatibleQosStatus::default(),
4427                    sample_lost_count: 0,
4428                    sample_rejected: crate::status::SampleRejectedStatus::default(),
4429                    samples_delivered_count: 0,
4430                    liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4431                    liveliness_kind: cfg.liveliness.kind,
4432                    liveliness_alive_count: 0,
4433                    liveliness_not_alive_count: 0,
4434                    // Optimistic init: we see the writer via SEDP,
4435                    // until the lease expires it counts as alive.
4436                    liveliness_alive: true,
4437                    ownership: cfg.ownership,
4438                    partition: cfg.partition.clone(),
4439                    writer_strengths: alloc::collections::BTreeMap::new(),
4440                    type_identifier: cfg.type_identifier.clone(),
4441                    type_consistency: cfg.type_consistency,
4442                })),
4443            );
4444        // FIRST match locally (create the writer proxy on the reader),
4445        // THEN announce. Otherwise our announce_subscription triggers a
4446        // backend replay at the peer via the in-process fastpath
4447        // (Spec §2.2.3.5), which injects DATA into *our* reader
4448        // before we have wired the matching WriterProxies — the
4449        // samples are then discarded as unknown-source
4450        // (tests `{transient,persistent}_late_joiner_receives_backend_replay`).
4451        self.match_local_reader_against_cache(eid);
4452        let _ = self.announce_subscription(&sub_data);
4453        // Intra-runtime routing: see `register_user_writer_kind`.
4454        self.recompute_intra_runtime_routes();
4455        // Observability event.
4456        self.config.observability.record(
4457            &zerodds_foundation::observability::Event::new(
4458                zerodds_foundation::observability::Level::Info,
4459                zerodds_foundation::observability::Component::Dcps,
4460                "user_reader.created",
4461            )
4462            .with_attr("topic", cfg.topic_name.as_str())
4463            .with_attr("type", cfg.type_name.as_str()),
4464        );
4465        Ok((eid, rx))
4466    }
4467
4468    /// Rebuilds the same-runtime writer→reader routing table.
4469    /// Called in `register_user_writer_kind` and `register_user_reader_kind`
4470    /// after every endpoint create. Per local writer it collects
4471    /// all local readers that have exactly the same `topic_name`
4472    /// and `type_name`. The lookup in the write hot path
4473    /// (`write_user_sample_borrowed`) is read-locked and cheap
4474    /// (BTreeMap lookup → Vec clone). On endpoint removal (TODO: not
4475    /// yet hooked everywhere) this would be called too.
4476    fn recompute_intra_runtime_routes(&self) {
4477        let writer_snap = self.writer_slots_snapshot();
4478        let reader_snap = self.reader_slots_snapshot();
4479        let mut new_map: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
4480        for (writer_eid, w_arc) in writer_snap {
4481            let (w_topic, w_type) = match w_arc.lock() {
4482                Ok(s) => (s.topic_name.clone(), s.type_name.clone()),
4483                Err(_) => continue,
4484            };
4485            let mut readers: Vec<EntityId> = Vec::new();
4486            for (reader_eid, r_arc) in &reader_snap {
4487                let matches = match r_arc.lock() {
4488                    Ok(s) => s.topic_name == w_topic && s.type_name == w_type,
4489                    Err(_) => false,
4490                };
4491                if matches {
4492                    readers.push(*reader_eid);
4493                }
4494            }
4495            if !readers.is_empty() {
4496                new_map.insert(writer_eid, readers);
4497            }
4498        }
4499        let changed = match self.intra_runtime_routes.write() {
4500            Ok(mut g) => {
4501                let changed = *g != new_map;
4502                *g = new_map;
4503                changed
4504            }
4505            Err(_) => false,
4506        };
4507        // A new/changed intra-runtime route is a same-participant
4508        // match → wake the `wait_for_matched_{subscription,publication}` waiter
4509        // (the matched count now includes these routes).
4510        if changed {
4511            self.match_event.1.notify_all();
4512        }
4513    }
4514
4515    /// Same-runtime direct dispatch: pushes the just-written
4516    /// sample directly into the `sample_tx` channel of all local readers
4517    /// on the same topic+type. Avoids an RTPS wire roundtrip + UDP
4518    /// loopback for the bridge-daemon case (writer+reader in the same
4519    /// `DcpsRuntime`). Called by the write hot path after the normal
4520    /// wire dispatch.
4521    fn intra_runtime_dispatch_alive(
4522        &self,
4523        writer_eid: EntityId,
4524        payload: &[u8],
4525        writer_strength: i32,
4526    ) {
4527        let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
4528            Ok(g) => match g.get(&writer_eid) {
4529                Some(v) => v.clone(),
4530                None => return,
4531            },
4532            Err(_) => return,
4533        };
4534        if routes.is_empty() {
4535            return;
4536        }
4537        let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
4538        for reader_eid in routes {
4539            let Some(slot_arc) = self.reader_slot(reader_eid) else {
4540                continue;
4541            };
4542            // Hold the slot lock only for the listener/sender clone, dispatch
4543            // outside (symmetric to the data-receive path above, which
4544            // preserves exactly the same order in the DATA arm).
4545            let listener;
4546            let waker;
4547            let sender;
4548            {
4549                let Ok(slot) = slot_arc.lock() else {
4550                    continue;
4551                };
4552                listener = slot.listener.clone();
4553                waker = Arc::clone(&slot.async_waker);
4554                sender = slot.sample_tx.clone();
4555            }
4556            // Listener and MPSC are exclusive (see the data-arm comment):
4557            // if a listener is set, the sample only goes to it;
4558            // otherwise to the MPSC receiver.
4559            if let Some(l) = listener {
4560                // The listener signature is `(payload: &[u8], representation: u8)`.
4561                // Intra-runtime: no encap header, `0` = native.
4562                l(payload, 0);
4563            } else {
4564                let sample = UserSample::Alive {
4565                    payload: crate::sample_bytes::SampleBytes::from_vec(payload.to_vec()),
4566                    writer_guid,
4567                    writer_strength,
4568                    representation: 0,
4569                };
4570                let _ = sender.send(sample);
4571                wake_async_waker(&waker);
4572            }
4573        }
4574    }
4575
4576    /// On registration / SEDP event: for a local writer `eid`
4577    /// go through all subscriptions known in the cache; on a topic+type
4578    /// match add a `ReaderProxy` to the local ReliableWriter.
4579    fn match_local_writer_against_cache(&self, eid: EntityId) {
4580        let (topic, type_name) = {
4581            let Some(arc) = self.writer_slot(eid) else {
4582                return;
4583            };
4584            let Ok(s) = arc.lock() else {
4585                return;
4586            };
4587            (s.topic_name.clone(), s.type_name.clone())
4588        };
4589        let (matches, conflict): (Vec<_>, bool) = {
4590            let sedp = match self.sedp.lock() {
4591                Ok(s) => s,
4592                Err(_) => return,
4593            };
4594            let matches = sedp
4595                .cache()
4596                .match_subscriptions(&topic, &type_name)
4597                .map(|s| s.data.clone())
4598                .collect();
4599            let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
4600            (matches, conflict)
4601        };
4602        if conflict {
4603            self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
4604        }
4605        for sub in matches {
4606            self.wire_writer_to_remote_reader(eid, &sub);
4607        }
4608    }
4609
4610    fn match_local_reader_against_cache(&self, eid: EntityId) {
4611        let (topic, type_name) = {
4612            let Some(arc) = self.reader_slot(eid) else {
4613                return;
4614            };
4615            let Ok(s) = arc.lock() else {
4616                return;
4617            };
4618            (s.topic_name.clone(), s.type_name.clone())
4619        };
4620        let (matches, conflict): (Vec<_>, bool) = {
4621            let sedp = match self.sedp.lock() {
4622                Ok(s) => s,
4623                Err(_) => return,
4624            };
4625            let matches = sedp
4626                .cache()
4627                .match_publications(&topic, &type_name)
4628                .map(|p| p.data.clone())
4629                .collect();
4630            let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
4631            (matches, conflict)
4632        };
4633        if conflict {
4634            self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
4635        }
4636        for pubd in matches {
4637            self.wire_reader_to_remote_writer(eid, &pubd);
4638        }
4639    }
4640
4641    fn wire_writer_to_remote_reader(
4642        &self,
4643        writer_eid: EntityId,
4644        sub: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4645    ) {
4646        // §2.2.2.2.1.16: an ignored subscription must not be MATCHED (symmetric
4647        // to the publication gate in `wire_reader_to_remote_writer`). The
4648        // Durability-Service ignores its own ingest reader here so the replay
4649        // writer never delivers back to it (echo loop).
4650        if let Some(filter) = self.ignore_filter_snapshot() {
4651            let sub_h = crate::instance_handle::InstanceHandle::from_guid(sub.key);
4652            let part_h = crate::instance_handle::InstanceHandle::from_guid(sub.participant_key);
4653            if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
4654                return;
4655            }
4656        }
4657        let locators =
4658            endpoint_or_default_locators(&sub.unicast_locators, sub.key.prefix, &self.discovered);
4659        if locators.is_empty() {
4660            return;
4661        }
4662        // Backend replay datagrams (Spec §2.2.3.5). Sent after
4663        // the slot-lock release, so the send path does not run under
4664        // the slot mutex.
4665        let mut replay_dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram> = Vec::new();
4666        if let Some(slot_arc) = self.writer_slot(writer_eid) {
4667            if let Ok(mut slot) = slot_arc.lock() {
4668                let slot = &mut *slot;
4669                // Idempotency gate: if a ReaderProxy already exists for this
4670                // remote reader, the match has already run
4671                // once. A re-wire (e.g. when the SEDP announcement
4672                // arrives at the writer both via the in-process fastpath and via UDP)
4673                // would REPLACE the proxy via
4674                // `add_reader_proxy` — and thereby reset
4675                // `highest_acked_sn`/`highest_sent_sn`.
4676                // The next tick then emits an invalid HEARTBEAT
4677                // with `first_sn > last_sn` (cache_min=N, highest_acked+1=N+1),
4678                // the reader interprets this as "everything before first_sn is
4679                // lost" and advances `delivered_up_to` past not-yet-
4680                // delivered backend replay samples (tests
4681                // `{transient,persistent}_late_joiner_receives_backend_replay`
4682                // — 3% flake without the gate).
4683                if slot
4684                    .writer
4685                    .reader_proxies()
4686                    .iter()
4687                    .any(|p| p.remote_reader_guid == sub.key)
4688                {
4689                    return;
4690                }
4691                // --- QoS-Compatibility ---
4692                // Spec OMG DDS 1.4 §2.2.3.6: Writer offered >= Reader requested.
4693                //
4694                // Per reject, bump the responsible policy ID in
4695                // `offered_incompatible_qos.policies`, so the
4696                // DataWriter listener is triggered via `dispatch_offered_incompatible_qos`.
4697                // We track the *first* faulty
4698                // policy as `last_policy_id` (Spec §2.2.4.1: most-recent).
4699                use crate::psm_constants::qos_policy_id as qid;
4700                use crate::status::bump_policy_count;
4701                // C2 "loud instead of silent": an incompatible QoS match is
4702                // not only kept as a pollable status (Spec §2.2.4.1),
4703                // but logged loudly IMMEDIATELY. The central ROS-DDS
4704                // pain point is that QoS mismatches are silently discarded
4705                // (e.g. Cyclone's `DDS_INVALID_QOS_POLICY_ID` without a
4706                // log) — exactly that made the ROS-2 entityKind diagnosis so
4707                // expensive. The reject names the topic, remote reader and
4708                // the exact policy.
4709                let obs = self.config.observability.clone();
4710                let topic_for_log = slot.topic_name.clone();
4711                let remote_for_log = alloc::format!("{:?}", sub.key);
4712                let bump = |slot: &mut UserWriterSlot, pid: u32| {
4713                    slot.offered_incompatible_qos.total_count =
4714                        slot.offered_incompatible_qos.total_count.saturating_add(1);
4715                    slot.offered_incompatible_qos.last_policy_id = pid;
4716                    bump_policy_count(&mut slot.offered_incompatible_qos.policies, pid);
4717                    obs.record(
4718                        &zerodds_foundation::observability::Event::new(
4719                            zerodds_foundation::observability::Level::Warn,
4720                            zerodds_foundation::observability::Component::Dcps,
4721                            "qos.incompatible.offered",
4722                        )
4723                        .with_attr("topic", topic_for_log.as_str())
4724                        .with_attr("remote_reader", remote_for_log.as_str())
4725                        .with_attr("policy", qos_policy_id_name(pid)),
4726                    );
4727                };
4728
4729                // Durability rank: Volatile < TransientLocal < Transient <
4730                // Persistent. The writer may offer more than the reader requests.
4731                if (slot.durability as u8) < (sub.durability as u8) {
4732                    bump(slot, qid::DURABILITY);
4733                    return;
4734                }
4735                // Deadline: writer period <= reader period (the writer promises
4736                // to write faster than the reader expects).
4737                if !deadline_compat(
4738                    slot.deadline_nanos,
4739                    qos_duration_to_nanos(sub.deadline.period),
4740                ) {
4741                    bump(slot, qid::DEADLINE);
4742                    return;
4743                }
4744                // Liveliness-Kind: Automatic < ManualByParticipant < ManualByTopic.
4745                // Writer-Kind >= Reader-Kind. Lease: writer.lease <= reader.lease.
4746                if (slot.liveliness_kind as u8) < (sub.liveliness.kind as u8) {
4747                    bump(slot, qid::LIVELINESS);
4748                    return;
4749                }
4750                if !deadline_compat(
4751                    slot.liveliness_lease_nanos,
4752                    qos_duration_to_nanos(sub.liveliness.lease_duration),
4753                ) {
4754                    bump(slot, qid::LIVELINESS);
4755                    return;
4756                }
4757                // Ownership: both must be equal (Spec §2.2.3.6 Table:
4758                // no "compatible" case except exactly equal).
4759                if slot.ownership != sub.ownership {
4760                    bump(slot, qid::OWNERSHIP);
4761                    return;
4762                }
4763                // Partition: at least one common partition — or
4764                // both empty (default partition "").
4765                if !partitions_overlap(&slot.partition, &sub.partition) {
4766                    bump(slot, qid::PARTITION);
4767                    return;
4768                }
4769                // F-TYPES-3 XTypes-1.3 §7.6.3.7 symmetric writer-side check.
4770                // If both sides carry a TypeIdentifier (≠ None),
4771                // we check compatibility. The reader's TCE policy is not
4772                // directly available here; we take the default TCE
4773                // (AllowTypeCoercion without PreventWidening) — the reader-
4774                // side check in `wire_reader_to_remote_writer` validates
4775                // with the real reader TCE.
4776                if slot.type_identifier != zerodds_types::TypeIdentifier::None
4777                    && sub.type_identifier != zerodds_types::TypeIdentifier::None
4778                {
4779                    let registry = zerodds_types::resolve::TypeRegistry::new();
4780                    let tce = zerodds_types::qos::TypeConsistencyEnforcement::default();
4781                    let matcher = zerodds_types::type_matcher::TypeMatcher::new(&tce);
4782                    if !matcher
4783                        .match_types(&slot.type_identifier, &sub.type_identifier, &registry)
4784                        .is_match()
4785                    {
4786                        bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
4787                        return;
4788                    }
4789                }
4790
4791                let mut proxy = zerodds_rtps::reader_proxy::ReaderProxy::new(
4792                    sub.key,
4793                    locators.clone(),
4794                    Vec::new(),
4795                    slot.reliable,
4796                );
4797                // D.5g — Per-Peer DataRepresentation negotiation
4798                // (XTypes 1.3 §7.6.3.1.2). Writer-offered = Per-Writer-
4799                // Override (slot.data_rep_offer_override) ODER Runtime-
4800                // Default. Reader-accepted = sub.data_representation
4801                // (spec default `[XCDR1]` if empty). Match mode from
4802                // RuntimeConfig.
4803                {
4804                    use zerodds_rtps::publication_data::data_representation as dr;
4805                    let writer_offered: Vec<i16> = slot
4806                        .data_rep_offer_override
4807                        .clone()
4808                        .unwrap_or_else(|| self.config.data_representation_offer.clone());
4809                    let mode = self.config.data_rep_match_mode;
4810                    if let Some(negotiated) =
4811                        dr::negotiate(&writer_offered, &sub.data_representation, mode)
4812                    {
4813                        proxy.set_negotiated_data_representation(negotiated);
4814                    } else {
4815                        // No overlap → SEDP match spec violation.
4816                        // We add the proxy anyway for best-effort
4817                        // compat; the wire-format default stays XCDR2.
4818                        // A spec-strict caller should reject the match.
4819                    }
4820                }
4821                // Spec §2.2.3.4 Tab. 16: cache replay suppression. For
4822                // Volatile the reader must not see any late-joiner history
4823                // → skip up to `cache.max_sn`. For Transient/Persistent
4824                // the backend is authoritative — we deliver the history
4825                // via the backend replay path with NEW SNs; the
4826                // writer's own cache (especially gappy under KeepLast
4827                // eviction) must not serve the reader twice.
4828                // TransientLocal is the only tier where the
4829                // writer cache is the real history anchor.
4830                if !matches!(slot.durability, zerodds_qos::DurabilityKind::TransientLocal) {
4831                    if let Some(max) = slot.writer.cache().max_sn() {
4832                        proxy.skip_samples_up_to(max);
4833                    }
4834                }
4835                // Spec §2.2.3.5 — Durability=Transient/Persistent:
4836                // on the first late-joiner match, re-inject the backend samples
4837                // into the HistoryCache. The existing
4838                // reliable-reader path then delivers them via DATA +
4839                // heartbeat/AckNack. Idempotent via the
4840                // `backend_primed` flag.
4841                let backend_writes: Vec<Vec<u8>> = if !slot.backend_primed
4842                    && (slot.durability == zerodds_qos::DurabilityKind::Transient
4843                        || slot.durability == zerodds_qos::DurabilityKind::Persistent)
4844                {
4845                    slot.durability_backend
4846                        .as_ref()
4847                        .and_then(|b| b.replay_for_topic(&slot.topic_name).ok())
4848                        .unwrap_or_default()
4849                        .into_iter()
4850                        .map(|s| s.payload)
4851                        .collect()
4852                } else {
4853                    Vec::new()
4854                };
4855                slot.writer.add_reader_proxy(proxy);
4856                // Path-MTU-aware fragmentation: if ALL matched
4857                // readers run on the same host, traffic goes via
4858                // loopback (MTU 65536) — then one datagram per sample
4859                // instead of N 1344-B fragments (halves the 8-kB roundtrip
4860                // latency). As soon as a reader is remote, it stays
4861                // Ethernet-safe at DEFAULT_FRAGMENT_SIZE, so no
4862                // oversized datagram gets IP-fragmented on the 1500-byte
4863                // path.
4864                let all_same_host = slot
4865                    .writer
4866                    .reader_proxies()
4867                    .iter()
4868                    .all(|p| self.guid_prefix.is_same_host(p.remote_reader_guid.prefix));
4869                if all_same_host {
4870                    slot.writer
4871                        .set_fragmentation(LOOPBACK_FRAGMENT_SIZE, LOOPBACK_MTU);
4872                } else {
4873                    slot.writer
4874                        .set_fragmentation(DEFAULT_FRAGMENT_SIZE, DEFAULT_MTU);
4875                }
4876                // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): if the
4877                // remote reader runs on the same host (matching
4878                // GuidPrefix host-id, wave 4a), register the pair in the
4879                // SameHostTracker. Wave 4b.3 (feature `same-host-shm`):
4880                // additionally try to set up a PosixShmTransport owner
4881                // segment — on success `mark_bound(Owner)`,
4882                // otherwise `mark_failed` and UDP fallback.
4883                if self.guid_prefix.is_same_host(sub.key.prefix) {
4884                    let local_writer_guid =
4885                        zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
4886                    self.same_host.register_pending(local_writer_guid, sub.key);
4887                    #[cfg(feature = "same-host-shm")]
4888                    {
4889                        match crate::same_host_shm::open_owner_segment(
4890                            self.guid_prefix,
4891                            local_writer_guid,
4892                            sub.key,
4893                        ) {
4894                            Ok(t) => self.same_host.mark_bound(
4895                                local_writer_guid,
4896                                sub.key,
4897                                t,
4898                                crate::same_host::Role::Owner,
4899                            ),
4900                            Err(reason) => {
4901                                self.same_host
4902                                    .mark_failed(local_writer_guid, sub.key, reason)
4903                            }
4904                        }
4905                    }
4906                }
4907                // Inject the backend replay into the HistoryCache (within
4908                // the slot lock). Important: with `KeepLast(N)` and a small N
4909                // the cache would immediately evict every replay sample
4910                // again — the subsequent writer tick then sees
4911                // SN=4,5 as "not in cache" and sends GAPs to the
4912                // reader, which marks our replay samples as irrelevant.
4913                // Solution: temporarily expand the cache to `KeepAll` with
4914                // a sufficient cap, for the duration of the
4915                // burst, then restore the user QoS.
4916                // Backend samples are in **raw** format (that is how
4917                // `DataWriter::write` in publisher.rs stores them) — before the
4918                // writer.write we must prepend the USER_PAYLOAD_ENCAP framing,
4919                // so the reader recognizes the stream value spec-conformantly
4920                // (see `validate_user_encap_offset`).
4921                let now_replay = self.start_instant.elapsed();
4922                if !backend_writes.is_empty() {
4923                    // Same encap header as in the live-write path
4924                    // (offer `first` + extensibility), so replay samples
4925                    // declare the same wire encoding.
4926                    let replay_encap = {
4927                        let offer_first = slot
4928                            .data_rep_offer_override
4929                            .as_ref()
4930                            .and_then(|v| v.first().copied())
4931                            .or_else(|| self.config.data_representation_offer.first().copied())
4932                            .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
4933                        user_payload_encap(offer_first, slot.wire_extensibility)
4934                    };
4935                    let original_kind = slot.writer.cache().kind();
4936                    let original_max = slot.writer.cache().max_samples();
4937                    let burst_max = original_max
4938                        .saturating_add(backend_writes.len())
4939                        .max(backend_writes.len() + 16);
4940                    slot.writer.set_cache_kind_and_max(
4941                        zerodds_rtps::history_cache::HistoryKind::KeepAll,
4942                        burst_max,
4943                    );
4944                    for raw_payload in &backend_writes {
4945                        let mut framed = Vec::with_capacity(replay_encap.len() + raw_payload.len());
4946                        framed.extend_from_slice(&replay_encap);
4947                        framed.extend_from_slice(raw_payload);
4948                        if let Ok(out) = slot.writer.write_with_heartbeat(&framed, now_replay) {
4949                            replay_dgs.extend(out);
4950                        }
4951                    }
4952                    slot.writer
4953                        .set_cache_kind_and_max(original_kind, original_max);
4954                    slot.backend_primed = true;
4955                }
4956                // D.5e Phase-1: wake `wait_for_matched_subscription`-waiters.
4957                self.match_event.1.notify_all();
4958
4959                // Security: derive the per-reader protection level from
4960                // security_info and build the locator lookup map,
4961                // so the writer tick can serialize per target
4962                // individually.
4963                #[cfg(feature = "security")]
4964                {
4965                    let peer_key = sub.key.prefix.0;
4966                    // Set the per-reader level ONLY for an EXPLICITLY announced
4967                    // `PID_ENDPOINT_SECURITY_INFO`. If it is missing (OpenDDS does not
4968                    // send it — it relies on the domain governance), NO
4969                    // None override: then the governance `data_protection` FLOOR
4970                    // applies in `secure_outbound_for_target`. An authenticated peer
4971                    // in a data_protection=ENCRYPT domain expects the encrypted
4972                    // payload; a None override would leak plaintext (cyclone/
4973                    // FastDDS announce security_info → unchanged).
4974                    if let Some(info) = sub.security_info.as_ref() {
4975                        let level = EndpointProtection::from_info(Some(info)).level;
4976                        slot.reader_protection.insert(peer_key, level);
4977                    }
4978                    for loc in &locators {
4979                        slot.locator_to_peer.insert(*loc, peer_key);
4980                    }
4981                }
4982            }
4983        }
4984        // Send the backend replay datagrams (Spec §2.2.3.5). The slot mutex
4985        // is released here; the send path mirrors the pattern from
4986        // `write_user_sample` — including the in-process fastpath for
4987        // same-process peers (otherwise UDP loopback loss under load can
4988        // swallow the Transient/Persistent replay samples).
4989        let inproc_peers: Vec<Arc<DcpsRuntime>> = {
4990            let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4991            all.into_iter()
4992                .filter(|rt| rt.guid_prefix != self.guid_prefix)
4993                .collect()
4994        };
4995        let now_send = self.start_instant.elapsed();
4996        for dg in &replay_dgs {
4997            for t in dg.targets.iter() {
4998                if is_routable_user_locator(t) {
4999                    let _ = self.user_unicast.send(t, &dg.bytes);
5000                }
5001            }
5002            for peer in &inproc_peers {
5003                handle_user_datagram(peer, &dg.bytes, now_send);
5004            }
5005        }
5006        // Emit the match event outside the slot mutex.
5007        self.config.observability.record(
5008            &zerodds_foundation::observability::Event::new(
5009                zerodds_foundation::observability::Level::Info,
5010                zerodds_foundation::observability::Component::Discovery,
5011                "writer.matched_remote_reader",
5012            )
5013            .with_attr("writer_eid", alloc::format!("{writer_eid:?}")),
5014        );
5015    }
5016
5017    fn wire_reader_to_remote_writer(
5018        &self,
5019        reader_eid: EntityId,
5020        pubd: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
5021    ) {
5022        // §2.2.2.2.1.17: an ignored publication must not be MATCHED, not merely
5023        // hidden from the DCPSPublication builtin reader. The Durability-Service
5024        // relies on this to avoid ingesting its own replay writer (echo loop).
5025        if let Some(filter) = self.ignore_filter_snapshot() {
5026            let pub_h = crate::instance_handle::InstanceHandle::from_guid(pubd.key);
5027            let part_h = crate::instance_handle::InstanceHandle::from_guid(pubd.participant_key);
5028            if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
5029                return;
5030            }
5031        }
5032        let locators =
5033            endpoint_or_default_locators(&pubd.unicast_locators, pubd.key.prefix, &self.discovered);
5034        if locators.is_empty() {
5035            return;
5036        }
5037        if let Some(slot_arc) = self.reader_slot(reader_eid) {
5038            if let Ok(mut slot) = slot_arc.lock() {
5039                let slot = &mut *slot;
5040                // Idempotency gate (symmetric to
5041                // `wire_writer_to_remote_reader`): if a WriterProxy already
5042                // exists for this remote writer, the
5043                // match has already run. A re-wire via UDP SEDP after
5044                // an in-process pull would REPLACE via `add_writer_proxy` —
5045                // resetting `delivered_up_to`/`received` and
5046                // losing already-buffered/delivered samples.
5047                if slot
5048                    .reader
5049                    .writer_proxies()
5050                    .iter()
5051                    .any(|s| s.proxy.remote_writer_guid == pubd.key)
5052                {
5053                    return;
5054                }
5055                // Per-policy bump for requested_incompatible_qos.
5056                use crate::psm_constants::qos_policy_id as qid;
5057                use crate::status::bump_policy_count;
5058                // C2 "loud instead of silent" (symmetric to the writer side):
5059                // an incompatible QoS match is logged loudly immediately.
5060                let obs = self.config.observability.clone();
5061                let topic_for_log = slot.topic_name.clone();
5062                let remote_for_log = alloc::format!("{:?}", pubd.key);
5063                let bump = |slot: &mut UserReaderSlot, pid: u32| {
5064                    slot.requested_incompatible_qos.total_count = slot
5065                        .requested_incompatible_qos
5066                        .total_count
5067                        .saturating_add(1);
5068                    slot.requested_incompatible_qos.last_policy_id = pid;
5069                    bump_policy_count(&mut slot.requested_incompatible_qos.policies, pid);
5070                    obs.record(
5071                        &zerodds_foundation::observability::Event::new(
5072                            zerodds_foundation::observability::Level::Warn,
5073                            zerodds_foundation::observability::Component::Dcps,
5074                            "qos.incompatible.requested",
5075                        )
5076                        .with_attr("topic", topic_for_log.as_str())
5077                        .with_attr("remote_writer", remote_for_log.as_str())
5078                        .with_attr("policy", qos_policy_id_name(pid)),
5079                    );
5080                };
5081
5082                // See wire_writer... — symmetric, the writer is now remote.
5083                if (pubd.durability as u8) < (slot.durability as u8) {
5084                    bump(slot, qid::DURABILITY);
5085                    return;
5086                }
5087                if !deadline_compat(
5088                    qos_duration_to_nanos(pubd.deadline.period),
5089                    slot.deadline_nanos,
5090                ) {
5091                    bump(slot, qid::DEADLINE);
5092                    return;
5093                }
5094                if (pubd.liveliness.kind as u8) < (slot.liveliness_kind as u8) {
5095                    bump(slot, qid::LIVELINESS);
5096                    return;
5097                }
5098                if !deadline_compat(
5099                    qos_duration_to_nanos(pubd.liveliness.lease_duration),
5100                    slot.liveliness_lease_nanos,
5101                ) {
5102                    bump(slot, qid::LIVELINESS);
5103                    return;
5104                }
5105                if pubd.ownership != slot.ownership {
5106                    bump(slot, qid::OWNERSHIP);
5107                    return;
5108                }
5109                if !partitions_overlap(&pubd.partition, &slot.partition) {
5110                    bump(slot, qid::PARTITION);
5111                    return;
5112                }
5113
5114                // F-TYPES-3 XTypes-1.3 §7.6.3.7 TypeConsistencyEnforcement.
5115                // If both sides carry a TypeIdentifier (≠ None),
5116                // we check compatibility via the TypeMatcher. Otherwise
5117                // the match falls back to a pure type_name comparison (default path).
5118                if slot.type_identifier != zerodds_types::TypeIdentifier::None
5119                    && pubd.type_identifier != zerodds_types::TypeIdentifier::None
5120                {
5121                    let registry = zerodds_types::resolve::TypeRegistry::new();
5122                    let matcher =
5123                        zerodds_types::type_matcher::TypeMatcher::new(&slot.type_consistency);
5124                    if !matcher
5125                        .match_types(&pubd.type_identifier, &slot.type_identifier, &registry)
5126                        .is_match()
5127                    {
5128                        bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5129                        return;
5130                    }
5131                }
5132
5133                slot.reader
5134                    .add_writer_proxy(zerodds_rtps::writer_proxy::WriterProxy::new(
5135                        pubd.key,
5136                        locators,
5137                        Vec::new(),
5138                        true,
5139                    ));
5140                // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): reader
5141                // side of the same-host match. If the remote writer runs on
5142                // the same host, register the pair AND
5143                // attach synchronously to the SHM segment.
5144                //
5145                // Idempotent: thanks to the `PosixShmTransport::open` refactor
5146                // (transport-shm bug fix 2026-05-19) it does not matter whether the
5147                // writer hook (open_owner) or the reader hook
5148                // (open_consumer) runs first — whoever comes first
5149                // creates the segment, whoever later attaches. Real-life
5150                // DDS has no guaranteed SEDP match order.
5151                if self.guid_prefix.is_same_host(pubd.key.prefix) {
5152                    let local_reader_guid =
5153                        zerodds_rtps::wire_types::Guid::new(self.guid_prefix, reader_eid);
5154                    self.same_host.register_pending(pubd.key, local_reader_guid);
5155                    #[cfg(feature = "same-host-shm")]
5156                    {
5157                        match crate::same_host_shm::open_consumer_segment(
5158                            self.guid_prefix,
5159                            pubd.key,
5160                            local_reader_guid,
5161                        ) {
5162                            Ok(t) => self.same_host.mark_bound(
5163                                pubd.key,
5164                                local_reader_guid,
5165                                t,
5166                                crate::same_host::Role::Consumer,
5167                            ),
5168                            Err(reason) => {
5169                                self.same_host
5170                                    .mark_failed(pubd.key, local_reader_guid, reason)
5171                            }
5172                        }
5173                    }
5174                }
5175                // D.5e Phase-1: wake `wait_for_matched_publication`-waiters.
5176                self.match_event.1.notify_all();
5177
5178                // §2.2.3.23 exclusive-ownership resolver cache:
5179                // remember the writer `ownership_strength` from discovery, so
5180                // `delivered_to_user_sample` can pack the value into every
5181                // sample.
5182                slot.writer_strengths
5183                    .insert(pubd.key.to_bytes(), pubd.ownership_strength);
5184            }
5185        }
5186    }
5187
5188    /// Writes a sample to a registered user writer and
5189    /// sends the generated datagrams.
5190    ///
5191    /// The payload is prefixed with the RTPS serialized-payload header
5192    /// (encapsulation scheme) before it goes into the DATA
5193    /// submessage. OMG RTPS 2.5 §9.4.2.13 requires exactly these
5194    /// 4 bytes at the start of every serialized user payload —
5195    /// see [`USER_PAYLOAD_ENCAP`] (`CDR_LE` / XCDR1).
5196    /// Without this header Cyclone/Fast-DDS readers refuse to
5197    /// deliver the sample (they parse the first 4 bytes as
5198    /// encapsulation kind + options and drop unknown-scheme).
5199    ///
5200    /// # Errors
5201    /// - `BadParameter` if the EntityId has no registered writer.
5202    /// - `WireError` on an encoding error.
5203    pub fn write_user_sample(&self, eid: EntityId, payload: Vec<u8>) -> Result<()> {
5204        // Vec-ownership API. The spec contract is unchanged. We delegate to
5205        // the borrowed variant; this saves a heap-allocation hop when
5206        // the caller already has a `&[u8]` (e.g. the C-FFI loan API).
5207        self.write_user_sample_borrowed(eid, &payload)
5208    }
5209
5210    /// Sets the per-writer data-representation override for a user writer. The
5211    /// next `write_user_sample*` derives its encapsulation header from this
5212    /// override's first element instead of the runtime default — so a
5213    /// representation-faithful re-publisher (e.g. the durability service
5214    /// replaying foreign-vendor XCDR1 bytes) can declare the encap that matches
5215    /// the body it holds. `None` clears the override (back to the runtime
5216    /// default). Idempotent + cheap; safe to call before every write.
5217    ///
5218    /// # Errors
5219    /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5220    /// poisoned slot lock.
5221    pub fn set_user_writer_data_rep_override(
5222        &self,
5223        eid: EntityId,
5224        offer: Option<Vec<i16>>,
5225    ) -> Result<()> {
5226        let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5227            what: "unknown writer entity id",
5228        })?;
5229        let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5230            reason: "user_writer slot poisoned",
5231        })?;
5232        slot.data_rep_offer_override = offer;
5233        Ok(())
5234    }
5235
5236    /// Writes a user sample from a borrowed byte slice.
5237    /// **Zero-copy path** for the loan API and SHM backend: avoids
5238    /// the Vec materialization when the caller holds a slot/stack buffer.
5239    ///
5240    /// Identical semantics to `write_user_sample`; it just takes no
5241    /// ownership of the buffer.
5242    ///
5243    /// # Errors
5244    /// As `write_user_sample`.
5245    pub fn write_user_sample_borrowed(&self, eid: EntityId, payload: &[u8]) -> Result<()> {
5246        let _phase_guard = if phase_timing_enabled() {
5247            Some(PhaseTimer {
5248                start: std::time::Instant::now(),
5249                ns_acc: &PHASE_WRITE_USER_NS,
5250                calls_acc: &PHASE_WRITE_USER_CALLS,
5251            })
5252        } else {
5253            None
5254        };
5255        let pt_on = phase_timing_enabled();
5256        let pt_t0 = if pt_on {
5257            Some(std::time::Instant::now())
5258        } else {
5259            None
5260        };
5261        // Hot path: for small samples (<= 1.5 kB payload)
5262        // the encap framing is copied into a stack PoolBuffer —
5263        // zero heap touch in the framing step. Large samples fall
5264        // back to Vec.
5265        let now = self.start_instant.elapsed();
5266        let total = USER_PAYLOAD_ENCAP.len() + payload.len();
5267        let pt_t2_out: Option<std::time::Instant>;
5268        let out_datagrams = {
5269            let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5270                what: "unknown writer entity id",
5271            })?;
5272            let pt_t1 = if pt_on {
5273                Some(std::time::Instant::now())
5274            } else {
5275                None
5276            };
5277            if let (Some(t0), Some(t1)) = (pt_t0, pt_t1) {
5278                PHASE_WRITE_SUB_NS[0].fetch_add(
5279                    (t1 - t0).as_nanos() as u64,
5280                    core::sync::atomic::Ordering::Relaxed,
5281                );
5282            }
5283            let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5284                reason: "user_writer slot poisoned",
5285            })?;
5286            let pt_t2 = if pt_on {
5287                Some(std::time::Instant::now())
5288            } else {
5289                None
5290            };
5291            pt_t2_out = pt_t2;
5292            if let (Some(t1), Some(t2)) = (pt_t1, pt_t2) {
5293                PHASE_WRITE_SUB_NS[1].fetch_add(
5294                    (t2 - t1).as_nanos() as u64,
5295                    core::sync::atomic::Ordering::Relaxed,
5296                );
5297            }
5298            // Deadline timer: remember the last write for offered_deadline_missed.
5299            slot.last_write = Some(now);
5300            // Encap header from the effective offer `first` (per-writer
5301            // override else runtime default) + type extensibility. The app
5302            // encoder serializes exactly this wire format; the header must
5303            // declare it honestly (otherwise an XCDR2-only vendor
5304            // reader misparses). See `user_payload_encap`.
5305            let encap = {
5306                let offer_first = slot
5307                    .data_rep_offer_override
5308                    .as_ref()
5309                    .and_then(|v| v.first().copied())
5310                    .or_else(|| self.config.data_representation_offer.first().copied())
5311                    .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
5312                user_payload_encap(offer_first, slot.wire_extensibility)
5313            };
5314            // Spec §2.2.3.5 backend filling happens in
5315            // `DataWriter::write` (publisher.rs) with the **raw** payload —
5316            // here only the HistoryCache filling + wire send.
5317            let dgs = if total <= SMALL_FRAME_CAP {
5318                write_user_sample_pooled(&mut slot.writer, payload, now, &encap)?
5319            } else {
5320                let mut framed = Vec::with_capacity(total);
5321                framed.extend_from_slice(&encap);
5322                framed.extend_from_slice(payload);
5323                // See write_user_sample_pooled: HB rate-limited via the
5324                // tick loop instead of per-write.
5325                let _ = now;
5326                slot.writer
5327                    .write(&framed)
5328                    .map_err(|_| DdsError::WireError {
5329                        message: String::from("user writer encode"),
5330                    })?
5331            };
5332            // Lifespan: remember the insert time of the just-written SN.
5333            if slot.lifespan_nanos != 0 {
5334                if let Some(sn) = slot.writer.cache().max_sn() {
5335                    slot.sample_insert_times.push_back((sn, now));
5336                }
5337            }
5338            dgs
5339        };
5340        let pt_t3 = if pt_on {
5341            Some(std::time::Instant::now())
5342        } else {
5343            None
5344        };
5345        if let (Some(t2), Some(t3)) = (pt_t2_out, pt_t3) {
5346            PHASE_WRITE_SUB_NS[2].fetch_add(
5347                (t3 - t2).as_nanos() as u64,
5348                core::sync::atomic::Ordering::Relaxed,
5349            );
5350        }
5351        // Opt-4 (Spec `zerodds-zero-copy-1.0` §9): precompute the skip set
5352        // of UDP locators occupied by a bound same-host reader.
5353        // Readers on these locators get the sample via
5354        // SHM (`same_host_send_pass` below); a UDP send would be a duplicate.
5355        #[cfg(feature = "same-host-shm")]
5356        let same_host_skip_locators: Vec<Locator> = self.same_host_udp_skip_set(eid);
5357        // In-process fastpath (same-process+domain peers): snapshot the
5358        // peer runtimes ONCE per write, then feed each datagram directly into
5359        // their recv path — no UDP loopback, no reliable
5360        // recovery race. The receiver deduplicates by SequenceNumber,
5361        // a copy arriving additionally via UDP later is a
5362        // no-op. The wire path stays untouched for cross-process.
5363        //
5364        // Hot-path fast path: lock-free registry hint. In the typical
5365        // cross-process bench (ping in process A, pong in process B)
5366        // A's registry has only A — the `peers()` lock+Vec alloc would be
5367        // pure overhead per write. Skip when count ≤ 1.
5368        let inproc_peers: Vec<Arc<DcpsRuntime>> = if crate::inproc::registry_count_hint() <= 1 {
5369            Vec::new()
5370        } else {
5371            let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
5372            all.into_iter()
5373                .filter(|rt| rt.guid_prefix != self.guid_prefix)
5374                .collect()
5375        };
5376        for dg in out_datagrams {
5377            // FU2 S3: UDP per target with per-reader data_protection
5378            // (`secure_outbound_for_target` — heterogeneously correct: legacy readers
5379            // get plaintext, secure readers SRTPS; the governance
5380            // data_protection fallback applies for readers without explicit
5381            // SEDP security_info).
5382            for t in dg.targets.iter() {
5383                if is_routable_user_locator(t) {
5384                    #[cfg(feature = "same-host-shm")]
5385                    if same_host_skip_locators.iter().any(|s| s == t) {
5386                        continue;
5387                    }
5388                    if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
5389                        #[allow(clippy::print_stderr)]
5390                        if let Err(e) = self.user_unicast.send(t, &secured) {
5391                            if std::env::var("ZERODDS_TRACE_SEND_ERR")
5392                                .map(|s| s == "1")
5393                                .unwrap_or(false)
5394                            {
5395                                eprintln!("[TRACE] user_unicast.send({t:?}) failed: {e:?}");
5396                            }
5397                        }
5398                    }
5399                }
5400            }
5401            // SHM + in-process fastpath: `secure_user_outbound` (uniform
5402            // governance data_protection level). The inproc peer runs through
5403            // its secured inbound path (decrypt or drop),
5404            // symmetric to the UDP recv — otherwise a non-
5405            // authenticated same-process peer could see encrypted data
5406            // unencrypted.
5407            if let Some(secured) = secure_user_outbound(self, &dg.bytes) {
5408                // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6):
5409                // parallel send via SHM to all bound-owner entries
5410                // for this writer. Opt-4 above filters their UDP
5411                // locators out beforehand, so nothing is sent twice.
5412                #[cfg(feature = "same-host-shm")]
5413                self.same_host_send_pass(eid, &secured);
5414                for peer in &inproc_peers {
5415                    #[cfg(feature = "security")]
5416                    {
5417                        if let Some(clear) =
5418                            secure_inbound_bytes(peer, &secured, &DEFAULT_INBOUND_IFACE)
5419                        {
5420                            handle_user_datagram(peer, &clear, now);
5421                        }
5422                    }
5423                    #[cfg(not(feature = "security"))]
5424                    handle_user_datagram(peer, &secured, now);
5425                }
5426            }
5427        }
5428        let pt_t4 = if pt_on {
5429            Some(std::time::Instant::now())
5430        } else {
5431            None
5432        };
5433        if let (Some(t3), Some(t4)) = (pt_t3, pt_t4) {
5434            PHASE_WRITE_SUB_NS[3].fetch_add(
5435                (t4 - t3).as_nanos() as u64,
5436                core::sync::atomic::Ordering::Relaxed,
5437            );
5438        }
5439        // Same-runtime writer→reader loopback: in parallel to the wire path
5440        // push directly into the `sample_tx` of all local readers on the same
5441        // topic+type. Bridge-daemon use case (writer+reader
5442        // in the same DcpsRuntime); without this hook intra-process
5443        // loopback would be completely dead, because `inproc_announce_*` skips self
5444        // and UDP multicast loopback is not guaranteed. Strength from
5445        // the writer slot.
5446        let writer_strength = self
5447            .writer_slot(eid)
5448            .and_then(|arc| arc.lock().ok().map(|s| s.ownership_strength))
5449            .unwrap_or(0);
5450        self.intra_runtime_dispatch_alive(eid, payload, writer_strength);
5451        // Embargo inspect tap at the DCPS layer (path-separated from the
5452        // production path). Only compiled when the `inspect` feature is
5453        // on. The topic name is fetched via a separate lookup, outside
5454        // the lock region so hooks do not run under the lock.
5455        #[cfg(feature = "inspect")]
5456        {
5457            self.dispatch_inspect_dcps_tap(eid, payload);
5458        }
5459        // D.5e Phase 3 — a freshly written sample makes a HEARTBEAT due: wake the
5460        // scheduler tick so it goes out immediately (no 5 ms tail), speeding the
5461        // reliable HB→ACKNACK handshake.
5462        self.raise_tick_wake();
5463        Ok(())
5464    }
5465
5466    /// Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) helper:
5467    /// sends `bytes` to all bound-owner entries of the [`SameHostTracker`]
5468    /// for this local writer (owner role).
5469    ///
5470    /// Called by the [`Self::write_user_sample`] hot path after the UDP send.
5471    /// Same-host readers thereby receive the sample frame
5472    /// via SHM **in addition** to the UDP path — the reader HistoryCache
5473    /// deduplicates by SequenceNumber.
5474    #[cfg(feature = "same-host-shm")]
5475    /// Opt-4 (Spec `zerodds-zero-copy-1.0` §9): locator skip set for
5476    /// the UDP send path. Returns all UDP default-unicast locators
5477    /// of the readers that have a bound same-host SHM pair with this
5478    /// writer — the hot-path caller filters these targets out of
5479    /// `dg.targets`, so the same readers are not served twice
5480    /// (UDP + SHM).
5481    #[cfg(feature = "same-host-shm")]
5482    fn same_host_udp_skip_set(&self, writer_eid: EntityId) -> Vec<Locator> {
5483        use crate::same_host::{Role, SameHostState};
5484        let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5485        let mut skip: Vec<Locator> = Vec::new();
5486        let snapshot = self.same_host.snapshot();
5487        let discovered = self.discovered.clone();
5488        for (w, reader, state) in snapshot {
5489            if w != writer_guid {
5490                continue;
5491            }
5492            if !matches!(
5493                state,
5494                SameHostState::Bound {
5495                    role: Role::Owner,
5496                    ..
5497                }
5498            ) {
5499                continue;
5500            }
5501            // Reader prefix → default_unicast_locator from discovery.
5502            if let Ok(cache) = discovered.lock() {
5503                if let Some(p) = cache.get(&reader.prefix) {
5504                    if let Some(loc) = p.data.default_unicast_locator {
5505                        skip.push(loc);
5506                    }
5507                }
5508            }
5509        }
5510        skip
5511    }
5512
5513    #[cfg(feature = "same-host-shm")]
5514    fn same_host_send_pass(&self, writer_eid: EntityId, bytes: &[u8]) {
5515        use crate::same_host::{Role, SameHostState};
5516        use zerodds_transport::Transport;
5517        use zerodds_transport_shm::PosixShmTransport;
5518
5519        let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5520        let snapshot = self.same_host.snapshot();
5521        let total = snapshot.len();
5522        let mut matched = 0u32;
5523        let mut owners = 0u32;
5524        let mut sent = 0u32;
5525        for (w, _reader, state) in snapshot {
5526            if w != writer_guid {
5527                continue;
5528            }
5529            matched += 1;
5530            let SameHostState::Bound { transport, role } = state else {
5531                continue;
5532            };
5533            if !matches!(role, Role::Owner) {
5534                continue;
5535            }
5536            owners += 1;
5537            let Ok(t) = transport.downcast::<PosixShmTransport>() else {
5538                continue;
5539            };
5540            // ShmTransport is 1:1: send() validates `dest ==
5541            // peer_locator`. Owner.peer_locator points to the
5542            // consumer endpoint → that is our target.
5543            let target = t.peer_locator();
5544            if t.send(&target, bytes).is_ok() {
5545                sent += 1;
5546            }
5547        }
5548        let _ = (total, matched, owners, sent); // diag counter removed after the Bug-3 fix
5549    }
5550
5551    /// Inspect-endpoint tap dispatch for DCPS publish.
5552    /// Reads the topic name separately from the WriterSlot and passes
5553    /// a frame to the zerodds-inspect-endpoint tap registry.
5554    /// **Not** the production hot path: only when the `inspect` feature is on.
5555    #[cfg(feature = "inspect")]
5556    fn dispatch_inspect_dcps_tap(&self, eid: EntityId, payload: &[u8]) {
5557        let Some(slot_arc) = self.writer_slot(eid) else {
5558            return;
5559        };
5560        let topic = match slot_arc.lock() {
5561            Ok(slot) => slot.topic_name.clone(),
5562            Err(_) => return,
5563        };
5564        let ts_ns = std::time::SystemTime::now()
5565            .duration_since(std::time::UNIX_EPOCH)
5566            .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
5567            .unwrap_or(0);
5568        let mut corr: u64 = 0;
5569        for (i, byte) in eid.entity_key.iter().enumerate() {
5570            corr |= u64::from(*byte) << (i * 8);
5571        }
5572        corr |= u64::from(eid.entity_kind as u8) << 24;
5573        let frame = zerodds_inspect_endpoint::Frame::dcps(topic, ts_ns, corr, payload.to_vec());
5574        zerodds_inspect_endpoint::tap::dispatch(&frame);
5575    }
5576
5577    /// Sends a lifecycle marker (`dispose`/`unregister_instance`) to
5578    /// all matched readers. Spec §2.2.2.4.2.10/.7 + §9.6.3.9 PID_STATUS_INFO.
5579    /// `status_bits` is the OR combination of
5580    /// `zerodds_rtps::inline_qos::status_info::DISPOSED` and/or `UNREGISTERED`.
5581    ///
5582    /// # Errors
5583    /// - `BadParameter` if the EntityId has no registered writer.
5584    /// - `WireError` on an encode error.
5585    pub fn write_user_lifecycle(
5586        &self,
5587        eid: EntityId,
5588        key_hash: [u8; 16],
5589        status_bits: u32,
5590    ) -> Result<()> {
5591        let out_datagrams = {
5592            let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5593                what: "unknown writer entity id",
5594            })?;
5595            let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5596                reason: "user_writer slot poisoned",
5597            })?;
5598            slot.writer
5599                .write_lifecycle(key_hash, status_bits)
5600                .map_err(|_| DdsError::WireError {
5601                    message: String::from("user writer lifecycle encode"),
5602                })?
5603        };
5604        for dg in out_datagrams {
5605            // FU2 S3: lifecycle DATA (dispose/unregister) per-target
5606            // data_protection-aware (heterogeneously correct like the immediate send).
5607            for t in dg.targets.iter() {
5608                if is_routable_user_locator(t) {
5609                    if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
5610                        let _ = self.user_unicast.send(t, &secured);
5611                    }
5612                }
5613            }
5614        }
5615        Ok(())
5616    }
5617
5618    /// Generates a 3-byte entity key for new user endpoints.
5619    fn next_entity_key(&self) -> [u8; 3] {
5620        let n = self.entity_counter.fetch_add(1, Ordering::Relaxed);
5621        [(n >> 16) as u8, (n >> 8) as u8, n as u8]
5622    }
5623
5624    /// Snapshot of all currently known remote publications (topic
5625    /// name + type name + writer GUID).
5626    #[must_use]
5627    pub fn discovered_publications_count(&self) -> usize {
5628        self.sedp
5629            .lock()
5630            .map(|s| s.cache().publications_len())
5631            .unwrap_or(0)
5632    }
5633
5634    /// Snapshot of every publication on this domain as `(topic_name,
5635    /// type_name)` — raw DDS topic/type strings — for graph introspection
5636    /// (`rmw_get_topic_names_and_types`, `rmw_count_publishers`). Includes BOTH
5637    /// this participant's LOCAL user writers AND the remote publications from
5638    /// SEDP, so a node sees its own topics as well as its peers'.
5639    #[must_use]
5640    pub fn discovered_publication_topics(&self) -> Vec<(String, String)> {
5641        let mut out: Vec<(String, String)> = Vec::new();
5642        if let Ok(map) = self.user_writers.read() {
5643            for slot in map.values() {
5644                if let Ok(s) = slot.lock() {
5645                    out.push((s.topic_name.clone(), s.type_name.clone()));
5646                }
5647            }
5648        }
5649        if let Ok(s) = self.sedp.lock() {
5650            out.extend(
5651                s.cache()
5652                    .publications()
5653                    .map(|p| (p.data.topic_name.clone(), p.data.type_name.clone())),
5654            );
5655        }
5656        out
5657    }
5658
5659    /// Snapshot of every subscription on this domain as `(topic_name,
5660    /// type_name)` (local user readers + remote SEDP). Counterpart to
5661    /// [`Self::discovered_publication_topics`].
5662    #[must_use]
5663    pub fn discovered_subscription_topics(&self) -> Vec<(String, String)> {
5664        let mut out: Vec<(String, String)> = Vec::new();
5665        if let Ok(map) = self.user_readers.read() {
5666            for slot in map.values() {
5667                if let Ok(s) = slot.lock() {
5668                    out.push((s.topic_name.clone(), s.type_name.clone()));
5669                }
5670            }
5671        }
5672        if let Ok(s) = self.sedp.lock() {
5673            out.extend(
5674                s.cache()
5675                    .subscriptions()
5676                    .map(|s| (s.data.topic_name.clone(), s.data.type_name.clone())),
5677            );
5678        }
5679        out
5680    }
5681
5682    /// Snapshot of all currently known remote subscriptions.
5683    #[must_use]
5684    pub fn discovered_subscriptions_count(&self) -> usize {
5685        self.sedp
5686            .lock()
5687            .map(|s| s.cache().subscriptions_len())
5688            .unwrap_or(0)
5689    }
5690
5691    /// Per-endpoint snapshot of every publication on this domain (local user
5692    /// writers + remote SEDP), for ROS 2 `rmw_get_publishers_info_by_topic`.
5693    #[must_use]
5694    pub fn discovered_publication_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
5695        let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
5696        let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
5697        if let Ok(map) = self.user_writers.read() {
5698            for slot in map.values() {
5699                if let Ok(s) = slot.lock() {
5700                    out.push(DiscoveredEndpointInfo {
5701                        topic_name: s.topic_name.clone(),
5702                        type_name: s.type_name.clone(),
5703                        endpoint_guid: guid_to_16(s.writer.guid()),
5704                        reliable: s.reliable,
5705                        transient_local: !matches!(
5706                            s.durability,
5707                            zerodds_qos::DurabilityKind::Volatile
5708                        ),
5709                        deadline_seconds: secs(s.deadline_nanos),
5710                        lifespan_seconds: secs(s.lifespan_nanos),
5711                        liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
5712                    });
5713                }
5714            }
5715        }
5716        if let Ok(s) = self.sedp.lock() {
5717            for p in s.cache().publications() {
5718                out.push(DiscoveredEndpointInfo {
5719                    topic_name: p.data.topic_name.clone(),
5720                    type_name: p.data.type_name.clone(),
5721                    endpoint_guid: guid_to_16(p.data.key),
5722                    reliable: matches!(
5723                        p.data.reliability.kind,
5724                        zerodds_qos::ReliabilityKind::Reliable
5725                    ),
5726                    transient_local: !matches!(
5727                        p.data.durability,
5728                        zerodds_qos::DurabilityKind::Volatile
5729                    ),
5730                    deadline_seconds: p.data.deadline.period.seconds,
5731                    lifespan_seconds: p.data.lifespan.duration.seconds,
5732                    liveliness_lease_seconds: p.data.liveliness.lease_duration.seconds,
5733                });
5734            }
5735        }
5736        out
5737    }
5738
5739    /// Counterpart to [`Self::discovered_publication_endpoints`] for
5740    /// subscriptions (`rmw_get_subscriptions_info_by_topic`).
5741    #[must_use]
5742    pub fn discovered_subscription_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
5743        let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
5744        let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
5745        if let Ok(map) = self.user_readers.read() {
5746            for slot in map.values() {
5747                if let Ok(s) = slot.lock() {
5748                    out.push(DiscoveredEndpointInfo {
5749                        topic_name: s.topic_name.clone(),
5750                        type_name: s.type_name.clone(),
5751                        endpoint_guid: guid_to_16(s.reader.guid()),
5752                        // Reader requested-reliability is not retained in the
5753                        // slot; RELIABLE is the rmw default (best-effort field).
5754                        reliable: true,
5755                        transient_local: !matches!(
5756                            s.durability,
5757                            zerodds_qos::DurabilityKind::Volatile
5758                        ),
5759                        deadline_seconds: secs(s.deadline_nanos),
5760                        lifespan_seconds: 0,
5761                        liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
5762                    });
5763                }
5764            }
5765        }
5766        if let Ok(s) = self.sedp.lock() {
5767            for sub in s.cache().subscriptions() {
5768                out.push(DiscoveredEndpointInfo {
5769                    topic_name: sub.data.topic_name.clone(),
5770                    type_name: sub.data.type_name.clone(),
5771                    endpoint_guid: guid_to_16(sub.data.key),
5772                    reliable: matches!(
5773                        sub.data.reliability.kind,
5774                        zerodds_qos::ReliabilityKind::Reliable
5775                    ),
5776                    transient_local: !matches!(
5777                        sub.data.durability,
5778                        zerodds_qos::DurabilityKind::Volatile
5779                    ),
5780                    deadline_seconds: sub.data.deadline.period.seconds,
5781                    lifespan_seconds: 0,
5782                    liveliness_lease_seconds: sub.data.liveliness.lease_duration.seconds,
5783                });
5784            }
5785        }
5786        out
5787    }
5788
5789    /// Number of matched remote readers for a local user writer.
5790    /// Polled by `DataWriter::wait_for_matched_subscription`.
5791    #[must_use]
5792    pub fn user_writer_matched_count(&self, eid: EntityId) -> usize {
5793        // Distinct matched subscriptions = remote/cross-participant reader
5794        // proxies UNION same-participant (intra-runtime) local readers. The
5795        // intra-runtime self-match path delivers samples without adding a wire
5796        // reader-proxy (avoids UDP-to-self double-delivery), so its matches
5797        // would otherwise be invisible to `wait_for_matched_subscription`.
5798        self.user_writer_matched_subscription_handles(eid).len()
5799    }
5800
5801    /// List of `InstanceHandle`s of all matched readers for a local
5802    /// user writer (Spec §2.2.2.4.2.x `get_matched_subscriptions`): remote/
5803    /// cross-participant readers (reader proxies) plus the same-participant
5804    /// readers from the intra-runtime routes, deduplicated by GUID.
5805    #[must_use]
5806    pub fn user_writer_matched_subscription_handles(
5807        &self,
5808        eid: EntityId,
5809    ) -> Vec<crate::instance_handle::InstanceHandle> {
5810        let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
5811            .writer_slot(eid)
5812            .and_then(|arc| {
5813                arc.lock().ok().map(|s| {
5814                    s.writer
5815                        .reader_proxies()
5816                        .iter()
5817                        .map(|p| {
5818                            crate::instance_handle::InstanceHandle::from_guid(p.remote_reader_guid)
5819                        })
5820                        .collect::<Vec<_>>()
5821                })
5822            })
5823            .unwrap_or_default();
5824        for h in self.intra_runtime_writer_matched_readers(eid) {
5825            if !handles.contains(&h) {
5826                handles.push(h);
5827            }
5828        }
5829        handles
5830    }
5831
5832    /// Same-participant readers that the local writer `eid` delivers to via
5833    /// an intra-runtime route (as matched-subscription handles).
5834    fn intra_runtime_writer_matched_readers(
5835        &self,
5836        writer_eid: EntityId,
5837    ) -> Vec<crate::instance_handle::InstanceHandle> {
5838        match self.intra_runtime_routes.read() {
5839            Ok(g) => g
5840                .get(&writer_eid)
5841                .map(|readers| {
5842                    readers
5843                        .iter()
5844                        .map(|reid| {
5845                            crate::instance_handle::InstanceHandle::from_guid(Guid::new(
5846                                self.guid_prefix,
5847                                *reid,
5848                            ))
5849                        })
5850                        .collect()
5851                })
5852                .unwrap_or_default(),
5853            Err(_) => Vec::new(),
5854        }
5855    }
5856
5857    /// Same-participant writers that deliver to the local
5858    /// reader `reader_eid` via an intra-runtime route (as matched-publication handles).
5859    fn intra_runtime_reader_matched_writers(
5860        &self,
5861        reader_eid: EntityId,
5862    ) -> Vec<crate::instance_handle::InstanceHandle> {
5863        match self.intra_runtime_routes.read() {
5864            Ok(g) => g
5865                .iter()
5866                .filter(|(_, readers)| readers.contains(&reader_eid))
5867                .map(|(weid, _)| {
5868                    crate::instance_handle::InstanceHandle::from_guid(Guid::new(
5869                        self.guid_prefix,
5870                        *weid,
5871                    ))
5872                })
5873                .collect(),
5874            Err(_) => Vec::new(),
5875        }
5876    }
5877
5878    /// List of `InstanceHandle`s of all matched remote writers for a
5879    /// local user reader (Spec §2.2.2.5.x `get_matched_publications`).
5880    #[must_use]
5881    pub fn user_reader_matched_publication_handles(
5882        &self,
5883        eid: EntityId,
5884    ) -> Vec<crate::instance_handle::InstanceHandle> {
5885        let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
5886            .reader_slot(eid)
5887            .and_then(|arc| {
5888                arc.lock().ok().map(|s| {
5889                    s.reader
5890                        .writer_proxies()
5891                        .iter()
5892                        .map(|p| {
5893                            crate::instance_handle::InstanceHandle::from_guid(
5894                                p.proxy.remote_writer_guid,
5895                            )
5896                        })
5897                        .collect::<Vec<_>>()
5898                })
5899            })
5900            .unwrap_or_default();
5901        for h in self.intra_runtime_reader_matched_writers(eid) {
5902            if !handles.contains(&h) {
5903                handles.push(h);
5904            }
5905        }
5906        handles
5907    }
5908
5909    /// Counter for missed offered deadlines on the user writer.
5910    /// Spec OMG DDS 1.4 §2.2.4.2.9 `OFFERED_DEADLINE_MISSED_STATUS`.
5911    #[must_use]
5912    pub fn user_writer_offered_deadline_missed(&self, eid: EntityId) -> u64 {
5913        self.writer_slot(eid)
5914            .and_then(|arc| arc.lock().ok().map(|s| s.offered_deadline_missed_count))
5915            .unwrap_or(0)
5916    }
5917
5918    /// Counter for missed requested deadlines on the user reader.
5919    /// Spec §2.2.4.2.11 `REQUESTED_DEADLINE_MISSED_STATUS`.
5920    #[must_use]
5921    pub fn user_reader_requested_deadline_missed(&self, eid: EntityId) -> u64 {
5922        self.reader_slot(eid)
5923            .and_then(|arc| arc.lock().ok().map(|s| s.requested_deadline_missed_count))
5924            .unwrap_or(0)
5925    }
5926
5927    /// Current liveliness status of a local user reader.
5928    /// Spec §2.2.4.2.14 `LIVELINESS_CHANGED_STATUS`:
5929    /// `(alive, alive_count, not_alive_count)`.
5930    #[must_use]
5931    pub fn user_reader_liveliness_status(&self, eid: EntityId) -> (bool, u64, u64) {
5932        self.reader_slot(eid)
5933            .and_then(|arc| {
5934                arc.lock().ok().map(|s| {
5935                    (
5936                        s.liveliness_alive,
5937                        s.liveliness_alive_count,
5938                        s.liveliness_not_alive_count,
5939                    )
5940                })
5941            })
5942            .unwrap_or((false, 0, 0))
5943    }
5944
5945    /// LivelinessLost counter on the user writer (Spec §2.2.4.2.10).
5946    /// Incremented by `check_writer_liveliness`.
5947    #[must_use]
5948    pub fn user_writer_liveliness_lost(&self, eid: EntityId) -> u64 {
5949        self.writer_slot(eid)
5950            .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_lost_count))
5951            .unwrap_or(0)
5952    }
5953
5954    /// Snapshot of OfferedIncompatibleQosStatus on the writer.
5955    #[must_use]
5956    pub fn user_writer_offered_incompatible_qos(
5957        &self,
5958        eid: EntityId,
5959    ) -> crate::status::OfferedIncompatibleQosStatus {
5960        self.writer_slot(eid)
5961            .and_then(|arc| arc.lock().ok().map(|s| s.offered_incompatible_qos.clone()))
5962            .unwrap_or_default()
5963    }
5964
5965    /// Snapshot of RequestedIncompatibleQosStatus on the reader.
5966    #[must_use]
5967    pub fn user_reader_requested_incompatible_qos(
5968        &self,
5969        eid: EntityId,
5970    ) -> crate::status::RequestedIncompatibleQosStatus {
5971        self.reader_slot(eid)
5972            .and_then(|arc| {
5973                arc.lock()
5974                    .ok()
5975                    .map(|s| s.requested_incompatible_qos.clone())
5976            })
5977            .unwrap_or_default()
5978    }
5979
5980    /// Sample-lost counter (reader side). Spec §2.2.4.2.6.2.
5981    #[must_use]
5982    pub fn user_reader_sample_lost(&self, eid: EntityId) -> u64 {
5983        self.reader_slot(eid)
5984            .and_then(|arc| arc.lock().ok().map(|s| s.sample_lost_count))
5985            .unwrap_or(0)
5986    }
5987
5988    /// Monotonically increasing count of alive samples delivered to the
5989    /// user (Spec §2.2.4.2.6.1 `on_data_available` detector). A delta
5990    /// against the last poll snapshot means "new data available".
5991    #[must_use]
5992    pub fn user_reader_samples_delivered(&self, eid: EntityId) -> u64 {
5993        self.reader_slot(eid)
5994            .and_then(|arc| arc.lock().ok().map(|s| s.samples_delivered_count))
5995            .unwrap_or(0)
5996    }
5997
5998    /// Bug-2 diagnosis (2026-05-19): number of submessages dropped
5999    /// because of an unknown writer_id. If this value is incremented
6000    /// after a write, it indicates an SEDP match
6001    /// race (writer_proxy not yet added when DATA is received).
6002    #[must_use]
6003    pub fn user_reader_unknown_src_count(&self, eid: EntityId) -> u64 {
6004        self.reader_slot(eid)
6005            .and_then(|arc| arc.lock().ok().map(|s| s.reader.unknown_src_count()))
6006            .unwrap_or(0)
6007    }
6008
6009    /// Sample-rejected status (reader side). Spec §2.2.4.2.6.3.
6010    #[must_use]
6011    pub fn user_reader_sample_rejected(
6012        &self,
6013        eid: EntityId,
6014    ) -> crate::status::SampleRejectedStatus {
6015        self.reader_slot(eid)
6016            .and_then(|arc| arc.lock().ok().map(|s| s.sample_rejected))
6017            .unwrap_or_default()
6018    }
6019
6020    /// Records a lost sample on the user reader. Called
6021    /// by resource-limit or decode-failure paths — the
6022    /// detector is application-external, because sample-lost depending on the
6023    /// implementation comes from several sources (cache drop, decode
6024    /// fail, sequence-number gap drop).
6025    pub fn record_sample_lost(&self, eid: EntityId, count: u32) {
6026        if count == 0 {
6027            return;
6028        }
6029        if let Some(arc) = self.reader_slot(eid) {
6030            if let Ok(mut slot) = arc.lock() {
6031                slot.sample_lost_count = slot.sample_lost_count.saturating_add(u64::from(count));
6032            }
6033        }
6034    }
6035
6036    /// Records a rejected sample on the user reader.
6037    pub fn record_sample_rejected(
6038        &self,
6039        eid: EntityId,
6040        kind: crate::status::SampleRejectedStatusKind,
6041        instance: crate::instance_handle::InstanceHandle,
6042    ) {
6043        if let Some(arc) = self.reader_slot(eid) {
6044            if let Ok(mut slot) = arc.lock() {
6045                slot.sample_rejected.total_count =
6046                    slot.sample_rejected.total_count.saturating_add(1);
6047                slot.sample_rejected.last_reason = kind;
6048                slot.sample_rejected.last_instance_handle = instance;
6049            }
6050        }
6051    }
6052
6053    /// Manual liveliness assert on the user writer. Sets the
6054    /// `last_liveliness_assert` timestamp. For `LivelinessKind::Automatic`
6055    /// `last_write` is also set — the liveliness path
6056    /// otherwise never falls through the `assert` trigger, because every successful
6057    /// `write` already takes over the liveliness tick.
6058    pub fn assert_writer_liveliness_eid(&self, eid: EntityId) {
6059        let now = self.start_instant.elapsed();
6060        if let Some(arc) = self.writer_slot(eid) {
6061            if let Ok(mut slot) = arc.lock() {
6062                slot.last_liveliness_assert = Some(now);
6063                if slot.liveliness_kind == zerodds_qos::LivelinessKind::Automatic {
6064                    slot.last_write = Some(now);
6065                }
6066            }
6067        }
6068    }
6069
6070    /// True if all matched readers have acknowledged all samples written
6071    /// so far. Empty cache or no proxies → true.
6072    #[must_use]
6073    pub fn user_writer_all_acknowledged(&self, eid: EntityId) -> bool {
6074        self.writer_slot(eid)
6075            .and_then(|arc| arc.lock().ok().map(|s| s.writer.all_samples_acknowledged()))
6076            .unwrap_or(true)
6077    }
6078
6079    /// Test helper — pushes a synthetic `UserSample::Alive`
6080    /// directly into the `mpsc::Sender` of the given reader, without
6081    /// going through the wire/discovery path. Enables end-to-end tests of
6082    /// downstream consumers (e.g. bridge-daemon pumps) that otherwise
6083    /// become flaky in CI containers due to multicast-loopback limits.
6084    /// **Not** for production code.
6085    ///
6086    /// `writer_guid` and `writer_strength` are set to default values
6087    /// (shared-ownership assumption).
6088    ///
6089    /// Returns `true` if the reader slot exists and the push
6090    /// succeeded, `false` if the EID is unknown or the channel is
6091    /// closed.
6092    #[doc(hidden)]
6093    pub fn test_inject_user_alive(&self, eid: EntityId, payload: Vec<u8>) -> bool {
6094        let Some(arc) = self.reader_slot(eid) else {
6095            return false;
6096        };
6097        let Ok(mut slot) = arc.lock() else {
6098            return false;
6099        };
6100        let sent = slot
6101            .sample_tx
6102            .send(UserSample::Alive {
6103                payload: crate::sample_bytes::SampleBytes::from_vec(payload),
6104                writer_guid: [0u8; 16],
6105                writer_strength: 0,
6106                representation: 0,
6107            })
6108            .is_ok();
6109        if sent {
6110            slot.samples_delivered_count = slot.samples_delivered_count.saturating_add(1);
6111        }
6112        sent
6113    }
6114
6115    /// Test helper — bumps the inconsistent-topic counter as if matching had
6116    /// discovered a remote endpoint with the same `topic_name` but a
6117    /// different `type_name`. Lets listener-FFI tests exercise the
6118    /// `on_inconsistent_topic` poll path without standing up two
6119    /// participants with a real SEDP type mismatch. **Not** for production.
6120    #[doc(hidden)]
6121    pub fn test_bump_inconsistent_topic(&self) {
6122        self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
6123    }
6124
6125    /// Spec §3.1 zerodds-async-1.0: registers the waker of an
6126    /// async reader in the UserReaderSlot. On `sample_tx.send`
6127    /// the waker is woken. `None` as the argument clears the waker
6128    /// (e.g. after the async reader is dropped).
6129    pub fn register_user_reader_waker(&self, eid: EntityId, waker: Option<core::task::Waker>) {
6130        if let Some(arc) = self.reader_slot(eid) {
6131            if let Ok(slot) = arc.lock() {
6132                if let Ok(mut g) = slot.async_waker.lock() {
6133                    *g = waker;
6134                }
6135            }
6136        }
6137    }
6138
6139    /// Register a listener callback for alive-sample
6140    /// arrival on the user reader. `None` clears an
6141    /// existing listener.
6142    ///
6143    /// The listener fires synchronously on the recv thread of
6144    /// `recv_user_data_loop` — see the contract doc on the
6145    /// [`UserReaderListener`] type. Eliminates the user-polling
6146    /// latency (~50-100 µs) compared to `sample_tx.recv()`.
6147    ///
6148    /// Returns `true` if the reader slot exists and the listener
6149    /// was set, `false` if the EID is not a known user reader.
6150    pub fn set_user_reader_listener(
6151        &self,
6152        eid: EntityId,
6153        listener: Option<UserReaderListener>,
6154    ) -> bool {
6155        let Some(arc) = self.reader_slot(eid) else {
6156            return false;
6157        };
6158        let Ok(mut slot) = arc.lock() else {
6159            return false;
6160        };
6161        slot.listener = listener.map(alloc::sync::Arc::new);
6162        true
6163    }
6164
6165    /// Number of matched writers for a local user reader: remote/cross-
6166    /// participant writers (writer proxies) plus same-participant writers from the
6167    /// intra-runtime routes, deduplicated by GUID (symmetric to the writer).
6168    #[must_use]
6169    pub fn user_reader_matched_count(&self, eid: EntityId) -> usize {
6170        self.user_reader_matched_publication_handles(eid).len()
6171    }
6172
6173    /// D.5e Phase-1 — waits until a match event occurs or the timeout
6174    /// is reached. Replaces 20-ms polling in `DataReader::wait_for_matched_*`
6175    /// and `DataWriter::wait_for_matched_*`.
6176    ///
6177    /// The caller checks the match count itself (via `user_*_matched_count`)
6178    /// before and after the wait — this function is only the block mechanics.
6179    /// Returns `false` if the timeout is reached, `true` if a notify came.
6180    #[cfg(feature = "std")]
6181    pub fn wait_match_event(&self, timeout: core::time::Duration) -> bool {
6182        let (lock, cvar) = &*self.match_event;
6183        let Ok(guard) = lock.lock() else { return false };
6184        match cvar.wait_timeout(guard, timeout) {
6185            Ok((_, t)) => !t.timed_out(),
6186            Err(_) => false,
6187        }
6188    }
6189
6190    /// D.5e Phase-1 — waits until an ACK event occurs or a timeout.
6191    /// Replaces 50-ms polling in `DataWriter::wait_for_acknowledgments`.
6192    #[cfg(feature = "std")]
6193    pub fn wait_ack_event(&self, timeout: core::time::Duration) -> bool {
6194        let (lock, cvar) = &*self.ack_event;
6195        let Ok(guard) = lock.lock() else { return false };
6196        match cvar.wait_timeout(guard, timeout) {
6197            Ok((_, t)) => !t.timed_out(),
6198            Err(_) => false,
6199        }
6200    }
6201
6202    /// D.5e Phase-1 — notify helper for the ACK event. Called by the reliable
6203    /// writer path when an ACKNACK advances the acked-base.
6204    #[cfg(feature = "std")]
6205    pub(crate) fn notify_ack_event(&self) {
6206        self.ack_event.1.notify_all();
6207    }
6208
6209    /// ADR-0006 — sets the PID_SHM_LOCATOR bytes for a local
6210    /// user writer in the side map. Called by the DataWriter
6211    /// once `set_flat_backend` has attached a same-host backend (POSIX shm /
6212    /// Iceoryx2). On the next SEDP push the wire encoder
6213    /// injects PID 0x8001 into the `PublicationData`.
6214    pub fn set_shm_locator(&self, eid: EntityId, bytes: Vec<u8>) {
6215        if let Ok(mut g) = self.shm_locators.write() {
6216            g.insert(eid, bytes);
6217        }
6218    }
6219
6220    /// ADR-0006 — reads the PID_SHM_LOCATOR bytes for a local
6221    /// user writer from the side map. Returns `None` if no
6222    /// same-host backend is set.
6223    #[must_use]
6224    pub fn shm_locator(&self, eid: EntityId) -> Option<Vec<u8>> {
6225        self.shm_locators.read().ok()?.get(&eid).cloned()
6226    }
6227
6228    /// ADR-0006 — removes the PID_SHM_LOCATOR entry (e.g. when the
6229    /// user writer is reconfigured without a backend).
6230    pub fn clear_shm_locator(&self, eid: EntityId) {
6231        if let Ok(mut g) = self.shm_locators.write() {
6232            g.remove(&eid);
6233        }
6234    }
6235
6236    /// Stops all worker threads (recv loops + tick loop) and joins
6237    /// them. Idempotent — repeated calls are no-ops.
6238    ///
6239    /// Shutdown delay: up to ~1 s, because the recv threads sit in
6240    /// `recv()` with a 1 s read timeout. After the
6241    /// current recv() call finishes they check the stop flag and
6242    /// terminate.
6243    pub fn shutdown(&self) {
6244        self.stop.store(true, Ordering::Relaxed);
6245        // D.5e Phase 3 — wake the scheduler tick worker so it observes `stop`
6246        // immediately instead of parking up to the idle floor.
6247        if let Ok(guard) = self.tick_wake.lock() {
6248            if let Some(h) = guard.as_ref() {
6249                h.stop();
6250            }
6251        }
6252        if let Ok(mut guard) = self.handles.lock() {
6253            for h in guard.drain(..) {
6254                let _ = h.join();
6255            }
6256        }
6257    }
6258}
6259
6260impl Drop for DcpsRuntime {
6261    // ZERODDS_PHASE_DUMP=1 is on-demand debug telemetry for
6262    // phase-latency profiling. eprintln is semantically correct here
6263    // (stderr diagnostics), no log-crate dependency wanted.
6264    #[allow(clippy::print_stderr)]
6265    fn drop(&mut self) {
6266        if std::env::var("ZERODDS_PHASE_DUMP")
6267            .map(|s| s == "1")
6268            .unwrap_or(false)
6269        {
6270            let hu_ns = PHASE_HANDLE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6271            let hu_n = PHASE_HANDLE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6272            let wu_ns = PHASE_WRITE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6273            let wu_n = PHASE_WRITE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6274            let hu_us = if hu_n > 0 {
6275                hu_ns as f64 / hu_n as f64 / 1000.0
6276            } else {
6277                0.0
6278            };
6279            let wu_us = if wu_n > 0 {
6280                wu_ns as f64 / wu_n as f64 / 1000.0
6281            } else {
6282                0.0
6283            };
6284            eprintln!(
6285                "[ZERODDS_PHASE] handle_user_datagram:  N={}  avg={:.3}us  total={:.1}ms",
6286                hu_n,
6287                hu_us,
6288                hu_ns as f64 / 1_000_000.0
6289            );
6290            eprintln!(
6291                "[ZERODDS_PHASE] write_user_sample:      N={}  avg={:.3}us  total={:.1}ms",
6292                wu_n,
6293                wu_us,
6294                wu_ns as f64 / 1_000_000.0
6295            );
6296            // Sub-phases of write_user_sample_borrowed.
6297            // [0] slot_lookup, [1] slot_lock_acquire,
6298            // [2] writer.write + framing, [3] dispatch (UDP + inproc).
6299            const SUB_LABELS: [&str; 4] = [
6300                "  ├─ slot_lookup       ",
6301                "  ├─ slot_lock_acquire ",
6302                "  ├─ writer.write+frame",
6303                "  └─ dispatch (UDP+...)",
6304            ];
6305            for (i, label) in SUB_LABELS.iter().enumerate() {
6306                let s_ns = PHASE_WRITE_SUB_NS[i].load(core::sync::atomic::Ordering::Relaxed);
6307                if s_ns > 0 && wu_n > 0 {
6308                    let s_us = s_ns as f64 / wu_n as f64 / 1000.0;
6309                    eprintln!(
6310                        "[ZERODDS_PHASE] {} avg={:.3}us  total={:.1}ms",
6311                        label,
6312                        s_us,
6313                        s_ns as f64 / 1_000_000.0
6314                    );
6315                }
6316            }
6317        }
6318        self.shutdown();
6319    }
6320}
6321
6322// ---------------------------------------------------------------------
6323// Worker threads (Sprint D.5b — per-socket recv + central tick).
6324//
6325// Before: a single `event_loop` that went through three sequential
6326// blocking `recv()`s with a `tick_period` timeout (50 ms) per iteration.
6327// Roundtrip latency: 5-14 ms p50 (CFS drift + sequential wait stages).
6328//
6329// Now: four dedicated threads.
6330//   * recv_spdp_multicast_loop  — blocks on the SPDP multicast socket
6331//   * recv_metatraffic_loop     — blocks on SPDP unicast (= metatraffic)
6332//   * recv_user_data_loop       — blocks on user-data unicast
6333//   * tick_loop                 — periodic outbound tasks +
6334//                                 per-interface inbound (non-blocking) +
6335//                                 deadline/lifespan/liveliness
6336//
6337// Lock discipline: the recv threads and the tick thread contend for
6338// `rt.sedp.lock()` / `rt.wlp.lock()` / per-slot `slot.lock()`.
6339// Convention: keep lock-hold times short (handle_datagram + tick each
6340// have only single-pass logic), no sub-lock under sedp/wlp.
6341// ---------------------------------------------------------------------
6342
6343/// Sprint D.5d lever C — applies SCHED_FIFO + CPU affinity to the
6344/// calling thread. Linux-only; no-op on macOS/Windows.
6345///
6346/// Called by every worker loop right at the start, so
6347/// the syscalls run on the actual worker thread
6348/// (`pthread_self()` must come from the thread itself).
6349///
6350/// Failures are logged to stderr but are not fatal — if
6351/// the process has no `CAP_SYS_NICE`, the runtime continues with
6352/// the CFS default scheduler.
6353#[allow(unused_variables)]
6354fn apply_thread_tuning(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
6355    #[cfg(target_os = "linux")]
6356    rt_pinning::apply(label, priority, cpus);
6357}
6358
6359/// Linux-only `pthread_setschedparam` + `sched_setaffinity` wrapper.
6360/// A dedicated module encapsulates the `unsafe` locally with safety notes; the
6361/// crate-level `#![deny(unsafe_code)]` stays active for the rest of the dcps
6362/// codebase.
6363#[cfg(target_os = "linux")]
6364#[allow(unsafe_code, clippy::print_stderr)]
6365mod rt_pinning {
6366    pub(super) fn apply(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
6367        if let Some(prio) = priority {
6368            // SAFETY: libc FFI with an owned `param` struct. The self-thread via
6369            // `pthread_self()` is always valid.
6370            // musl libc has additional `sched_ss_*` fields (POSIX
6371            // sporadic-server) that we do not set — `mem::zeroed`
6372            // initializes them cleanly to 0.
6373            unsafe {
6374                let mut param: libc::sched_param = core::mem::zeroed();
6375                param.sched_priority = prio;
6376                let rc = libc::pthread_setschedparam(
6377                    libc::pthread_self(),
6378                    libc::SCHED_FIFO,
6379                    &raw const param,
6380                );
6381                if rc != 0 {
6382                    eprintln!(
6383                        "zdds[{label}]: pthread_setschedparam SCHED_FIFO {prio} \
6384                         failed (rc={rc}). Need CAP_SYS_NICE or RLIMIT_RTPRIO."
6385                    );
6386                }
6387            }
6388        }
6389        if let Some(cpu_list) = cpus {
6390            // SAFETY: cpu_set_t is POD; CPU_ZERO/SET are libc inline
6391            // functions without lifetime requirements.
6392            unsafe {
6393                let mut set: libc::cpu_set_t = core::mem::zeroed();
6394                libc::CPU_ZERO(&mut set);
6395                for &cpu in cpu_list {
6396                    if cpu < libc::CPU_SETSIZE as usize {
6397                        libc::CPU_SET(cpu, &mut set);
6398                    }
6399                }
6400                let rc = libc::sched_setaffinity(
6401                    0,
6402                    core::mem::size_of::<libc::cpu_set_t>(),
6403                    &raw const set,
6404                );
6405                if rc != 0 {
6406                    eprintln!("zdds[{label}]: sched_setaffinity({cpu_list:?}) failed.");
6407                }
6408            }
6409        }
6410    }
6411}
6412
6413/// FastDDS interop (phase 2): acknowledges FastDDS' reliable secure SPDP writer
6414/// (0xff0101c2). FastDDS heartbeats its secure SPDP reliably and sends the
6415/// `participant_crypto_tokens` only once our 0xff0101c7 reader has acked its writer
6416/// (fast<->fast reference pcap: ACKNACK on 0xff0101c7). We respond to
6417/// every incoming secure-SPDP HEARTBEAT with an ACKNACK (base = last+1,
6418/// final), addressed via INFO_DST to the sender prefix. Gated on
6419/// `enable_secure_spdp`.
6420#[cfg(feature = "security")]
6421fn secure_spdp_reader_acks(rt: &DcpsRuntime, clear: &[u8]) -> Vec<Vec<u8>> {
6422    use zerodds_rtps::header::RtpsHeader;
6423    use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
6424    use zerodds_rtps::submessages::{AckNackSubmessage, HeartbeatSubmessage, SequenceNumberSet};
6425    use zerodds_rtps::wire_types::SequenceNumber;
6426    if !rt.config.enable_secure_spdp {
6427        return Vec::new();
6428    }
6429    let Ok(parsed) = decode_datagram(clear) else {
6430        return Vec::new();
6431    };
6432    let peer_prefix = parsed.header.guid_prefix;
6433    let mut out = Vec::new();
6434    let mut count = 0i32;
6435    let secure_writer = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER;
6436    let secure_reader = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER;
6437    // Header + INFO_DST(peer) + submessage. INFO_DST is mandatory, otherwise the
6438    // dest prefix is UNKNOWN -> FastDDS discards it as "not a connection".
6439    let wrap = |id: SubmessageId, body: &[u8], flags: u8| -> Option<Vec<u8>> {
6440        let blen = u16::try_from(body.len()).ok()?;
6441        let header = RtpsHeader::new(VendorId::ZERODDS, rt.guid_prefix);
6442        let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
6443        dg.extend_from_slice(&header.to_bytes());
6444        let info = SubmessageHeader {
6445            submessage_id: SubmessageId::InfoDst,
6446            flags: FLAG_E_LITTLE_ENDIAN,
6447            octets_to_next_header: 12,
6448        };
6449        dg.extend_from_slice(&info.to_bytes());
6450        dg.extend_from_slice(&peer_prefix.to_bytes());
6451        let sh = SubmessageHeader {
6452            submessage_id: id,
6453            flags: flags | FLAG_E_LITTLE_ENDIAN,
6454            octets_to_next_header: blen,
6455        };
6456        dg.extend_from_slice(&sh.to_bytes());
6457        dg.extend_from_slice(body);
6458        Some(dg)
6459    };
6460    for sub in &parsed.submessages {
6461        match sub {
6462            // FastDDS' secure-SPDP writer HEARTBEAT -> we ack (reader 0xff0101c7).
6463            ParsedSubmessage::Heartbeat(hb) if hb.writer_id == secure_writer => {
6464                count = count.wrapping_add(1);
6465                let ack = AckNackSubmessage {
6466                    reader_id: secure_reader,
6467                    writer_id: secure_writer,
6468                    reader_sn_state: SequenceNumberSet {
6469                        bitmap_base: SequenceNumber(hb.last_sn.0 + 1),
6470                        num_bits: 0,
6471                        bitmap: Vec::new(),
6472                    },
6473                    count,
6474                    final_flag: true,
6475                };
6476                let (body, flags) = ack.write_body(true);
6477                if let Some(dg) = wrap(SubmessageId::AckNack, &body, flags) {
6478                    out.push(dg);
6479                }
6480            }
6481            // FastDDS' reader requests (preemptive ACKNACK to our 0xff0101c2
6482            // writer) our secure-SPDP data reliably -> deliver DATA(SN=1) +
6483            // HEARTBEAT(1,1), otherwise FastDDS' reader never matches and
6484            // sends no crypto_tokens.
6485            ParsedSubmessage::AckNack(a) if a.writer_id == secure_writer => {
6486                if let Ok(mut beacon) = rt.spdp_beacon.lock() {
6487                    if let Ok(data_dg) = beacon.serialize_secure() {
6488                        out.push(protect_secure_spdp(rt, &data_dg).unwrap_or(data_dg));
6489                    }
6490                }
6491                count = count.wrapping_add(1);
6492                let hbsm = HeartbeatSubmessage {
6493                    reader_id: secure_reader,
6494                    writer_id: secure_writer,
6495                    first_sn: SequenceNumber(1),
6496                    last_sn: SequenceNumber(1),
6497                    count,
6498                    final_flag: false,
6499                    liveliness_flag: false,
6500                    group_info: None,
6501                };
6502                let (body, flags) = hbsm.write_body(true);
6503                if let Some(dg) = wrap(SubmessageId::Heartbeat, &body, flags) {
6504                    out.push(dg);
6505                }
6506            }
6507            _ => {}
6508        }
6509    }
6510    out
6511}
6512
6513/// FastDDS interop (phase 2b): builds a secure-SPDP HEARTBEAT (writer
6514/// 0xff0101c2, first=1/last=1) with INFO_DST to `peer_prefix`. Sent periodically per
6515/// discovered peer, so FastDDS' reliable secure-SPDP reader is solicited to a
6516/// (preemptive) ACKNACK and matches our writer.
6517#[cfg(feature = "security")]
6518fn build_secure_spdp_heartbeat(
6519    local_prefix: GuidPrefix,
6520    peer_prefix: GuidPrefix,
6521    count: i32,
6522) -> Option<Vec<u8>> {
6523    use zerodds_rtps::header::RtpsHeader;
6524    use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
6525    use zerodds_rtps::submessages::HeartbeatSubmessage;
6526    use zerodds_rtps::wire_types::SequenceNumber;
6527    let hb = HeartbeatSubmessage {
6528        reader_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
6529        writer_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
6530        first_sn: SequenceNumber(1),
6531        last_sn: SequenceNumber(1),
6532        count,
6533        final_flag: false,
6534        liveliness_flag: false,
6535        group_info: None,
6536    };
6537    let (body, flags) = hb.write_body(true);
6538    let blen = u16::try_from(body.len()).ok()?;
6539    let header = RtpsHeader::new(VendorId::ZERODDS, local_prefix);
6540    let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
6541    dg.extend_from_slice(&header.to_bytes());
6542    let info = SubmessageHeader {
6543        submessage_id: SubmessageId::InfoDst,
6544        flags: FLAG_E_LITTLE_ENDIAN,
6545        octets_to_next_header: 12,
6546    };
6547    dg.extend_from_slice(&info.to_bytes());
6548    dg.extend_from_slice(&peer_prefix.to_bytes());
6549    let sh = SubmessageHeader {
6550        submessage_id: SubmessageId::Heartbeat,
6551        flags: flags | FLAG_E_LITTLE_ENDIAN,
6552        octets_to_next_header: blen,
6553    };
6554    dg.extend_from_slice(&sh.to_bytes());
6555    dg.extend_from_slice(&body);
6556    Some(dg)
6557}
6558
6559/// FastDDS interop: SEC-protects the secure-SPDP DATA (0xff0101c2) under
6560/// `discovery_protection != NONE` — FastDDS then encrypts the secure-SPDP DATA
6561/// (like the secure SEDP), and a PLAIN secure SPDP is discarded. Wraps
6562/// the DATA submessage with the per-endpoint writer key (0xff0101c2) as
6563/// SEC_PREFIX/BODY/POSTFIX; framing submessages (INFO_*) stay. Without
6564/// discovery_protection (common subset) passthrough. `None` on a crypto error.
6565#[cfg(feature = "security")]
6566fn protect_secure_spdp(rt: &DcpsRuntime, datagram: &[u8]) -> Option<Vec<u8>> {
6567    let gate = rt.config.security.as_ref()?;
6568    if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None
6569        || datagram.len() < 20
6570    {
6571        return Some(datagram.to_vec());
6572    }
6573    let h = local_endpoint_crypto_handle(
6574        rt,
6575        EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
6576        true,
6577    )?;
6578    let mut out = datagram[..20].to_vec();
6579    for (id, start, total) in walk_submessages(datagram) {
6580        let submsg = &datagram[start..start + total];
6581        if id == SMID_DATA {
6582            match gate.encode_data_datawriter_by_handle(h, submsg) {
6583                Ok(s) => out.extend_from_slice(&s),
6584                Err(_) => return None,
6585            }
6586        } else {
6587            out.extend_from_slice(submsg);
6588        }
6589    }
6590    Some(out)
6591}
6592
6593/// Worker: blocks on the SPDP multicast socket, dispatches SPDP beacons +
6594/// WLP heartbeats that come in over multicast.
6595fn recv_spdp_multicast_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6596    apply_thread_tuning(
6597        "recv-spdp-mc",
6598        rt.config.recv_thread_priority,
6599        rt.config.recv_thread_cpus.as_deref(),
6600    );
6601    while !stop.load(Ordering::Relaxed) {
6602        let elapsed = rt.start_instant.elapsed();
6603        let sedp_now = Duration::from_secs(elapsed.as_secs())
6604            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6605        let Ok(dg) = rt.spdp_multicast_rx.recv() else {
6606            continue;
6607        };
6608        #[cfg(feature = "security")]
6609        let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6610        #[cfg(not(feature = "security"))]
6611        let clear = secure_inbound_bytes(&rt, &dg.data);
6612        if let Some(clear) = clear {
6613            handle_spdp_datagram(&rt, &clear);
6614            // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
6615            // reliably, otherwise FastDDS sends no crypto_tokens.
6616            #[cfg(feature = "security")]
6617            for ack in secure_spdp_reader_acks(&rt, &clear) {
6618                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
6619                    let _ = rt.spdp_unicast.send(&loc, &ack);
6620                }
6621            }
6622            // WLP heartbeats arrive on the SPDP multicast socket
6623            // (the sender sends them to the SPDP multicast group).
6624            // handle_spdp_datagram ignores them, so we also feed
6625            // the same buffer into the WLP endpoint. A
6626            // secure-WLP DATA is participant-key SEC-protected → decode
6627            // it first (like secure SEDP in the metatraffic loop), otherwise
6628            // wlp.handle_datagram would only see the SEC block.
6629            #[cfg(feature = "security")]
6630            let wlp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
6631                let mut pk = [0u8; 12];
6632                pk.copy_from_slice(&clear[8..20]);
6633                unprotect_user_datagram(&rt, &clear, &pk)
6634            } else {
6635                None
6636            };
6637            #[cfg(feature = "security")]
6638            let wlp_input: &[u8] = wlp_decoded.as_deref().unwrap_or(&clear);
6639            #[cfg(not(feature = "security"))]
6640            let wlp_input: &[u8] = &clear;
6641            if let Ok(mut wlp) = rt.wlp.lock() {
6642                let _ = wlp.handle_datagram(wlp_input, sedp_now);
6643            }
6644        }
6645    }
6646}
6647
6648/// Worker: blocks on SPDP unicast (= metatraffic socket), dispatches
6649/// SPDP reverse beacons + SEDP + WLP + security builtin.
6650fn recv_metatraffic_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6651    apply_thread_tuning(
6652        "recv-meta",
6653        rt.config.recv_thread_priority,
6654        rt.config.recv_thread_cpus.as_deref(),
6655    );
6656    while !stop.load(Ordering::Relaxed) {
6657        let elapsed = rt.start_instant.elapsed();
6658        let sedp_now = Duration::from_secs(elapsed.as_secs())
6659            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6660        let Ok(dg) = rt.spdp_unicast.recv() else {
6661            continue;
6662        };
6663        #[cfg(feature = "security")]
6664        let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6665        #[cfg(not(feature = "security"))]
6666        let clear = secure_inbound_bytes(&rt, &dg.data);
6667        if let Some(clear) = clear {
6668            // A single recv call, both handlers on the same
6669            // datagram. SPDP first (Cyclone reverse beacons), then
6670            // SEDP, then WLP, then security builtin.
6671            handle_spdp_datagram(&rt, &clear);
6672            // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
6673            // reliably (they arrive unicast over the metatraffic socket).
6674            #[cfg(feature = "security")]
6675            for ack in secure_spdp_reader_acks(&rt, &clear) {
6676                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
6677                    let _ = rt.spdp_unicast.send(&loc, &ack);
6678                }
6679            }
6680            // Protected discovery: secure-SEDP DATA is SEC_* submessage-
6681            // protected (the sender's participant data key). Before the SEDP parse
6682            // decode it with the sender prefix (RTPS header bytes[8..20]); for
6683            // plaintext SEDP (no SEC_*) unprotect_user_datagram returns None
6684            // and we use `clear` unchanged.
6685            #[cfg(feature = "security")]
6686            let sedp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
6687                let mut pk = [0u8; 12];
6688                pk.copy_from_slice(&clear[8..20]);
6689                unprotect_user_datagram(&rt, &clear, &pk)
6690            } else {
6691                None
6692            };
6693            // OPEN (phase 3, docs/security/per-endpoint-crypto-followup.md):
6694            // if `unprotect_user_datagram` fails for a secure-SEDP DATA
6695            // (cyclone's per-endpoint token not yet installed — race),
6696            // `sedp_input` falls back to the SEC_* bytes and the DATA is discarded.
6697            // Cross-vendor (discovery=ENCRYPT) must make this deterministic:
6698            // treat the reliable secure-SEDP DATA as not-received (NACK,
6699            // no SN advance), so the re-send after token install decodes.
6700            #[cfg(feature = "security")]
6701            let sedp_input: &[u8] = sedp_decoded.as_deref().unwrap_or(&clear);
6702            #[cfg(not(feature = "security"))]
6703            let sedp_input: &[u8] = &clear;
6704            let events = {
6705                if let Ok(mut sedp) = rt.sedp.lock() {
6706                    sedp.handle_datagram(sedp_input, sedp_now).ok()
6707                } else {
6708                    None
6709                }
6710            };
6711            if let Some(ev) = events {
6712                if !ev.is_empty() {
6713                    run_matching_pass(&rt);
6714                    push_sedp_events_to_builtin_readers(&rt, &ev);
6715                }
6716            }
6717
6718            // Secure WLP (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER) is, like
6719            // secure SEDP, participant-key SEC-protected → feed the decoded variant
6720            // (sedp_input), not the still SEC-wrapped `clear`. For
6721            // plaintext WLP, sedp_input == clear.
6722            let wlp_resends = if let Ok(mut wlp) = rt.wlp.lock() {
6723                let _ = wlp.handle_datagram(sedp_input, sedp_now);
6724                // Reliable resend: if the peer NACKs our (secure-)WLP writer,
6725                // we re-emit the missing beats (cyclone treats WLP as
6726                // reliable; without a resend it would never get the liveliness assertion).
6727                wlp.wlp_acknack_resends(sedp_input)
6728            } else {
6729                Vec::new()
6730            };
6731            for beat in wlp_resends {
6732                if let Some(secured) = protect_wlp_outbound(&rt, &beat) {
6733                    for loc in wlp_unicast_targets(&rt.discovered_participants()) {
6734                        let _ = rt.spdp_unicast.send(&loc, &secured);
6735                    }
6736                }
6737            }
6738            for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
6739                send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
6740            }
6741        }
6742    }
6743}
6744
6745/// Worker: wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) — per-owner
6746/// SHM recv loop. Iterates round-robin over all bound-consumer
6747/// entries of the [`SameHostTracker`](crate::same_host::SameHostTracker)
6748/// and calls `recv()` with the configured per-transport timeout
6749/// (50 ms default). On data, dispatches via [`handle_user_datagram`]
6750/// analogous to the UDP path.
6751///
6752/// Latency tradeoff: with N consumers the worst-case latency
6753/// for a sample is (N-1) × recv_timeout. Acceptable for small
6754/// N (typically <10 same-host peers); for larger topologies
6755/// this would have to be switched to multiple threads or epoll-style
6756/// multiplexing (wave 4b.4 follow-up).
6757#[cfg(feature = "same-host-shm")]
6758fn recv_user_shm_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6759    use crate::same_host::{Role, SameHostState};
6760    use zerodds_transport::Transport;
6761    use zerodds_transport_shm::PosixShmTransport;
6762
6763    apply_thread_tuning(
6764        "recv-shm",
6765        rt.config.recv_thread_priority,
6766        rt.config.recv_thread_cpus.as_deref(),
6767    );
6768    let idle_sleep = Duration::from_millis(100);
6769    while !stop.load(Ordering::Relaxed) {
6770        // SHM bind now happens synchronously in the SEDP hook (transport-shm
6771        // 2026-05-19 idempotent open_or_create). Here only the bound-
6772        // consumer drain — no lazy retry needed anymore.
6773        let consumers: Vec<Arc<PosixShmTransport>> = rt
6774            .same_host
6775            .snapshot()
6776            .into_iter()
6777            .filter_map(|(_, _, state)| match state {
6778                SameHostState::Bound { transport, role } => {
6779                    if !matches!(role, Role::Consumer) {
6780                        return None;
6781                    }
6782                    transport.downcast::<PosixShmTransport>().ok()
6783                }
6784                _ => None,
6785            })
6786            .collect();
6787        if consumers.is_empty() {
6788            thread::sleep(idle_sleep);
6789            continue;
6790        }
6791        let elapsed = rt.start_instant.elapsed();
6792        let sedp_now = Duration::from_secs(elapsed.as_secs())
6793            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6794        for consumer in &consumers {
6795            if stop.load(Ordering::Relaxed) {
6796                break;
6797            }
6798            match consumer.recv() {
6799                Ok(dg) => {
6800                    // Security gate (analogous to the UDP path). SHM is
6801                    // same-host-only — if the policy allows plaintext,
6802                    // the datagram comes through unchanged.
6803                    #[cfg(feature = "security")]
6804                    let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6805                    #[cfg(not(feature = "security"))]
6806                    let clear = secure_inbound_bytes(&rt, &dg.data);
6807                    if let Some(clear) = clear {
6808                        handle_user_datagram(&rt, &clear, sedp_now);
6809                    }
6810                }
6811                // A timeout is normal — recv has the configured
6812                // 50 ms limit, an empty segment is not an error.
6813                Err(zerodds_transport::RecvError::Timeout) => {}
6814                Err(_) => {
6815                    // Hard error (broken segment, peer crashed).
6816                    // We could set the tracker entry to
6817                    // Failed here — for the first cut we leave
6818                    // it at silence + the UDP fallback
6819                    // stays active.
6820                }
6821            }
6822        }
6823    }
6824}
6825
6826/// Worker: blocks on the user-data unicast socket, dispatches
6827/// TypeLookup service replies + user-sample datagrams.
6828///
6829/// Int-1 (Spec `zerodds-zero-copy-1.0` §9): with the feature
6830/// `recvmmsg-batch` on Linux the loop uses `recv_batch_linux` and
6831/// fetches up to 32 datagrams per syscall — a 7-8x throughput boost.
6832/// On an empty batch the path falls back to single-recv() so
6833/// the recv thread does not spin in a busy loop at low traffic.
6834fn recv_user_data_loop(
6835    rt: Arc<DcpsRuntime>,
6836    socket: Arc<dyn Transport + Send + Sync>,
6837    stop: Arc<AtomicBool>,
6838) {
6839    apply_thread_tuning(
6840        "recv-user",
6841        rt.config.recv_thread_priority,
6842        rt.config.recv_thread_cpus.as_deref(),
6843    );
6844    // recvmmsg-batch (Linux + feature) needs the concrete UdpSocket
6845    // under the trait. With a trait-object transport this is not directly
6846    // accessible — we fall back to single-recv(). recvmmsg is
6847    // a UDP optimization; once TCP/SHM transports are to be mixed,
6848    // it is no longer worth it. For a pure UDPv4 user transport
6849    // this costs ~5-10% throughput in Linux batch mode (measured 2026-05).
6850    while !stop.load(Ordering::Relaxed) {
6851        let elapsed = rt.start_instant.elapsed();
6852        let sedp_now = Duration::from_secs(elapsed.as_secs())
6853            + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6854        let Ok(dg) = socket.recv() else {
6855            continue;
6856        };
6857        dispatch_user_datagram(&rt, &dg, sedp_now);
6858        // D.5e Phase 3 — incoming user data may solicit an ACKNACK or advance a
6859        // reliable reader: wake the scheduler tick immediately (no 5 ms tail).
6860        rt.raise_tick_wake();
6861    }
6862}
6863
6864/// Helper: dispatches a single user datagram through the security gate +
6865/// TypeLookup + handle_user_datagram. Shared by the single-recv and the
6866/// recvmmsg batch path.
6867fn dispatch_user_datagram(
6868    rt: &Arc<DcpsRuntime>,
6869    dg: &zerodds_transport::ReceivedDatagram,
6870    sedp_now: Duration,
6871) {
6872    #[cfg(feature = "security")]
6873    let clear = secure_inbound_bytes(rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6874    #[cfg(not(feature = "security"))]
6875    let clear = secure_inbound_bytes(rt, &dg.data);
6876    if let Some(clear) = clear {
6877        // TypeLookup service first — if the frame is addressed to
6878        // TL_SVC_*_READER, it does not go to a
6879        // user reader. Other frames fall through.
6880        if !dispatch_type_lookup_datagram(rt, &clear, &dg.source) {
6881            handle_user_datagram(rt, &clear, sedp_now);
6882        }
6883    }
6884}
6885
6886/// Worker: periodic outbound tasks + per-interface inbound
6887/// (non-blocking) + housekeeping. Sleeps `tick_period` between
6888/// iterations.
6889fn tick_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6890    apply_thread_tuning(
6891        "tick",
6892        rt.config.tick_thread_priority,
6893        rt.config.tick_thread_cpus.as_deref(),
6894    );
6895    let mut st = TickState::new(&rt);
6896    while !stop.load(Ordering::Relaxed) {
6897        run_tick_iteration(Arc::clone(&rt), &mut st);
6898        // Housekeeping runs inline here in the classic fixed-period path,
6899        // exactly as before (every `tick_period`, same cadence).
6900        tick_housekeep(&rt, rt.start_instant.elapsed());
6901        std::thread::sleep(rt.config.tick_period);
6902    }
6903}
6904
6905/// D.5e Phase 3 — idle park cap for a discovery-only participant (no user
6906/// endpoints): how long the scheduler tick worker may sleep when nothing but
6907/// SPDP/WLP is pending. SPDP/WLP fire on their own (longer) periods, so this is
6908/// just a safety heartbeat — well above the 5 ms poll it replaces.
6909const SCHEDULER_IDLE_FLOOR: Duration = Duration::from_millis(250);
6910
6911/// Earliest instant the scheduler tick worker must next run `run_tick_iteration`
6912/// so no periodic work is delayed: never past the next SPDP announce, and —
6913/// while user endpoints exist — capped at `tick_period` so HEARTBEAT/ACKNACK/
6914/// deadline/lifespan/liveliness keep their current cadence (identical wire
6915/// behaviour). With no user endpoints, parks up to [`SCHEDULER_IDLE_FLOOR`].
6916/// Active traffic is handled out-of-band by `raise_tick_wake` (immediate).
6917fn next_tick_deadline(rt: &Arc<DcpsRuntime>, st: &TickState) -> Instant {
6918    let now = Instant::now();
6919    let fine_cap = if rt.has_user_endpoints() {
6920        rt.config.tick_period
6921    } else {
6922        SCHEDULER_IDLE_FLOOR
6923    };
6924    st.next_announce.min(now + fine_cap).max(now)
6925}
6926
6927/// D.5e Phase 3 B-2 — the kinds of work the deadline-heap scheduler fires as
6928/// distinct heap events, each re-armed at its own next deadline.
6929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6930enum TickEvent {
6931    /// Periodic SPDP announce + reliable outbound (SEDP / WLP / user HEARTBEAT /
6932    /// ACKNACK) + secondary inbound poll — the wire-producing tick
6933    /// ([`run_tick_iteration`]), re-armed at [`next_tick_deadline`].
6934    Tick,
6935    /// Deadline / lifespan / liveliness housekeeping ([`tick_housekeep`]),
6936    /// re-armed at the **exact** next QoS due-instant (no fixed quantum).
6937    Housekeep,
6938}
6939
6940/// D.5e Phase 3 — event-driven scheduler tick worker. Replaces the fixed-period
6941/// `tick_loop` sleep with a deadline-heap park. Two independent event streams:
6942/// [`TickEvent::Tick`] drives the **unchanged** `run_tick_iteration` (wire
6943/// output byte-identical to `tick_loop`), re-armed at [`next_tick_deadline`];
6944/// [`TickEvent::Housekeep`] runs the QoS checks, re-armed at their exact next
6945/// due-instant so a deadline/lifespan/liveliness fires on time instead of up to
6946/// one `tick_period` late, and an idle participant parks long. A write/recv
6947/// `raise_tick_wake` wakes **both** immediately, so freshly-armed QoS windows
6948/// are picked up without delay.
6949fn scheduler_tick_loop(
6950    rt: Arc<DcpsRuntime>,
6951    stop: Arc<AtomicBool>,
6952    mut scheduler: crate::scheduler::Scheduler<TickEvent>,
6953    handle: crate::scheduler::SchedulerHandle<TickEvent>,
6954) {
6955    apply_thread_tuning(
6956        "tick",
6957        rt.config.tick_thread_priority,
6958        rt.config.tick_thread_cpus.as_deref(),
6959    );
6960    let mut st = TickState::new(&rt);
6961    // Prime both event streams immediately.
6962    handle.raise_now(TickEvent::Tick);
6963    handle.raise_now(TickEvent::Housekeep);
6964    loop {
6965        let (due, stopped) = scheduler.park_due_batch();
6966        if stopped || stop.load(Ordering::Relaxed) {
6967            break;
6968        }
6969        if due.is_empty() {
6970            continue; // woken with nothing due yet — re-evaluate.
6971        }
6972        // Coalesce: a batch of wakes maps to at most ONE run of each kind.
6973        let mut do_tick = false;
6974        let mut do_housekeep = false;
6975        for ev in due {
6976            match ev {
6977                TickEvent::Tick => do_tick = true,
6978                TickEvent::Housekeep => do_housekeep = true,
6979            }
6980        }
6981        if do_tick {
6982            rt.tick_wake_pending.store(false, Ordering::Release);
6983            run_tick_iteration(Arc::clone(&rt), &mut st);
6984            if stop.load(Ordering::Relaxed) {
6985                break;
6986            }
6987            handle.raise_at(next_tick_deadline(&rt, &st), TickEvent::Tick);
6988        }
6989        if do_housekeep {
6990            let next = tick_housekeep(&rt, rt.start_instant.elapsed());
6991            if stop.load(Ordering::Relaxed) {
6992                break;
6993            }
6994            // Park exactly until the next QoS due-instant; nothing pending →
6995            // idle floor (a later write re-arms via `raise_tick_wake`).
6996            let deadline = match next {
6997                Some(due_nanos) => rt.start_instant + Duration::from_nanos(due_nanos),
6998                None => Instant::now() + SCHEDULER_IDLE_FLOOR,
6999            };
7000            handle.raise_at(deadline, TickEvent::Housekeep);
7001        }
7002    }
7003}
7004
7005/// Per-iteration mutable state of the runtime tick. Held across iterations so
7006/// the same body ([`run_tick_iteration`]) can be driven from either the
7007/// dedicated `zdds-tick` thread (default) or an external executor — tokio via
7008/// [`DcpsRuntime::tick_driver`] / async `spawn_in_tokio`
7009/// (zerodds-async-1.0 §4).
7010struct TickState {
7011    /// Multicast target locator to which we send SPDP beacons.
7012    mc_target: Locator,
7013    /// Next instant at which a periodic SPDP announce is due.
7014    next_announce: Instant,
7015    /// Number of SPDP announces already sent. Drives the C3 initial
7016    /// announcement burst: as long as `< initial_announce_count` **and** no
7017    /// peer discovered yet, announces happen at `initial_announce_period` cadence
7018    /// instead of the full `spdp_period` — so discovery over lossy/power-save WiFi
7019    /// does not fail on lost first beacons.
7020    announces_done: u32,
7021    /// FastDDS interop: count for the periodic secure-SPDP HEARTBEATs
7022    /// (0xff0101c2). Must increase, otherwise FastDDS' reader ignores follow-up HBs.
7023    #[cfg(feature = "security")]
7024    secure_hb_count: i32,
7025}
7026
7027impl TickState {
7028    fn new(rt: &Arc<DcpsRuntime>) -> Self {
7029        let mc_target = Locator {
7030            kind: LocatorKind::UdpV4,
7031            port: u32::from(
7032                u16::try_from(spdp_multicast_port(rt.domain_id as u32)).unwrap_or(7400),
7033            ),
7034            address: {
7035                let mut a = [0u8; 16];
7036                a[12..].copy_from_slice(&rt.config.spdp_multicast_group.octets());
7037                a
7038            },
7039        };
7040        Self {
7041            mc_target,
7042            next_announce: Instant::now(), // immediately at start
7043            announces_done: 0,
7044            #[cfg(feature = "security")]
7045            secure_hb_count: 0,
7046        }
7047    }
7048}
7049
7050/// One iteration of the runtime's **wire** tick: periodic SPDP announce,
7051/// SEDP/WLP ticks, per-user-writer + per-user-reader ticks, secondary inbound
7052/// poll. QoS housekeeping (deadline/lifespan/liveliness) is **not** part of this
7053/// — each driver calls [`tick_housekeep`] separately (D.5e Phase 3 B-2), so the
7054/// event-driven scheduler can fire it on its own exact-deadline schedule.
7055/// Mutable per-iteration state lives in `st`; the caller waits `tick_period`
7056/// between calls. Factored out of [`tick_loop`] so an external executor can
7057/// drive the tick without the dedicated thread (zerodds-async-1.0 §4).
7058fn run_tick_iteration(rt: Arc<DcpsRuntime>, st: &mut TickState) {
7059    // Monotonic clock relative to runtime start. Used by the SEDP,
7060    // WLP and user tick alike.
7061    let elapsed_since_start = rt.start_instant.elapsed();
7062    let sedp_now = Duration::from_secs(elapsed_since_start.as_secs())
7063        + Duration::from_nanos(u64::from(elapsed_since_start.subsec_nanos()));
7064
7065    // --- Periodic SPDP announce ---
7066    // FU2 cross-vendor (cyclone-trace-documented): a secured participant MUST
7067    // NOT announce before its security builtins are enabled — otherwise
7068    // a token-less/non-secure first beacon goes out, which foreign vendors
7069    // (cyclone: "Non secure remote ... not allowed by security") latch as
7070    // non-secure and, on the later token beacon, treat ONLY as a QoS update
7071    // (no security re-evaluation) → the handshake never starts.
7072    // `config.security.is_some()` = secured runtime; until
7073    // `enable_security_builtins*` installs the stack (snapshot Some) +
7074    // sets the token/security-info on the beacon, we hold the beacon
7075    // back. enable() triggers the first token-carrying beacon via
7076    // `announce_spdp_now()`. Plain runtimes (security None) announce
7077    // immediately as before.
7078    #[cfg(feature = "security")]
7079    let security_pending = rt.config.security.is_some() && rt.security_builtin_snapshot().is_none();
7080    #[cfg(not(feature = "security"))]
7081    let security_pending = false;
7082    if Instant::now() >= st.next_announce && !security_pending {
7083        let secured_beacon: Option<Vec<u8>> = {
7084            if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7085                beacon
7086                    .serialize()
7087                    .ok()
7088                    .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7089            } else {
7090                None
7091            }
7092        };
7093        if let Some(secured) = secured_beacon {
7094            let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7095            // C1 multicast-free discovery: additionally to all configured
7096            // initial peers (ZERODDS_PEERS) — bootstrap without multicast.
7097            rt.send_spdp_to_initial_peers(&secured);
7098            // SPDP unicast fan-out to discovered peers (analogous to WLP-M-2/H-3-H-4):
7099            // codepit-LXC multicast is flaky; if it loses the tokened
7100            // secure beacon, the peer never discovers ZeroDDS as secure and
7101            // NEVER starts the auth handshake (cyclone→ZeroDDS responder hung
7102            // exactly here: HS_DISPATCH=0). From the metatraffic recv socket
7103            // (spdp_unicast), so the source port is correct.
7104            // Periodic directed unicast fan-out to discovered peers:
7105            // codepit-LXC multicast is flaky; if it loses the tokened
7106            // beacon, the peer never discovers ZeroDDS as secure and never starts
7107            // the auth handshake. The unicast refresh (every spdp_period) robustly
7108            // covers lost multicasts + late joiners. (Previously disabled for a
7109            // flaky-diag experiment — reactivated as a regular path,
7110            // complements the event-driven directed response in handle_spdp_datagram.)
7111            for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7112                let _ = rt.spdp_unicast.send(&loc, &secured);
7113            }
7114        }
7115        // FastDDS interop: announce in parallel on the reliable secure-SPDP writer
7116        // (0xff0101c2). FastDDS announces its full secured
7117        // participant data over this channel and gates the crypto-token
7118        // reciprocation on it; without our secure SPDP it never sees ZeroDDS there
7119        // and reciprocates no datawriter/datareader tokens.
7120        #[cfg(feature = "security")]
7121        if rt.config.enable_secure_spdp {
7122            let secure_beacon: Option<Vec<u8>> = {
7123                if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7124                    beacon
7125                        .serialize_secure()
7126                        .ok()
7127                        .and_then(|d| protect_secure_spdp(&rt, &d))
7128                        .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7129                } else {
7130                    None
7131                }
7132            };
7133            if let Some(secured) = secure_beacon {
7134                let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7135                for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7136                    let _ = rt.spdp_unicast.send(&loc, &secured);
7137                }
7138            }
7139            // Secure-SPDP HEARTBEAT per peer (INFO_DST), so FastDDS' reader
7140            // — even as a late joiner — is solicited to a (preemptive) ACKNACK
7141            // and matches our 0xff0101c2 writer. Without a HEARTBEAT
7142            // FastDDS does not engage our writer (fastdds->zerodds: 0 ACKNACK).
7143            st.secure_hb_count = st.secure_hb_count.wrapping_add(1);
7144            for p in rt.discovered_participants() {
7145                let peer_prefix = p.data.guid.prefix;
7146                if let Some(hb) =
7147                    build_secure_spdp_heartbeat(rt.guid_prefix, peer_prefix, st.secure_hb_count)
7148                {
7149                    for loc in wlp_unicast_targets(core::slice::from_ref(&p)) {
7150                        let _ = rt.spdp_unicast.send(&loc, &hb);
7151                    }
7152                }
7153            }
7154        }
7155        // C3 WiFi robustness — initial announcement burst: as long as we have
7156        // not discovered a peer yet and the burst count is not exhausted,
7157        // announce at the fast `initial_announce_period` cadence. Over
7158        // lossy/power-save WiFi the first beacons often get lost in the cold-start
7159        // or sleep window; a single announce + 5s period
7160        // then leads to `participants=0`. The burst keeps the NIC awake through
7161        // frequent TX, keeps the stateful-firewall pinhole open and
7162        // elicits directed SPDP responses that arrive in the wake windows
7163        // — analogous to FastDDS `initial_announcements`. As soon as a peer
7164        // is discovered, the cadence falls back to the full `spdp_period`.
7165        st.announces_done = st.announces_done.saturating_add(1);
7166        rt.spdp_announce_seq.fetch_add(1, Ordering::Relaxed);
7167        let still_searching = st.announces_done < rt.config.initial_announce_count
7168            && rt.discovered_participants().is_empty();
7169        let period = if still_searching {
7170            rt.config.initial_announce_period
7171        } else {
7172            rt.config.spdp_period
7173        };
7174        st.next_announce = Instant::now() + period;
7175    }
7176
7177    // (SPDP multicast recv: now in `recv_spdp_multicast_loop`.)
7178
7179    // --- SEDP-Tick (outbound HEARTBEAT/Resend/ACKNACK) ---
7180    let sedp_outbound = {
7181        if let Ok(mut sedp) = rt.sedp.lock() {
7182            sedp.tick(sedp_now).unwrap_or_default()
7183        } else {
7184            Vec::new()
7185        }
7186    };
7187    for dg in sedp_outbound {
7188        // Protected discovery: SEC_*-protect secure-SEDP DATA/HEARTBEAT/GAP
7189        // (participant data key). Non-secure SEDP goes unchanged; on a
7190        // crypto error on secure SEDP it is dropped (no plaintext leak).
7191        #[cfg(feature = "security")]
7192        {
7193            if let Some(inner) = protect_sedp_outbound(&rt, &dg.bytes) {
7194                // discovery_protection has SEC-wrapped the secure SEDP per-submessage
7195                // (SEC_PREFIX/BODY/POSTFIX, per-endpoint key). Under
7196                // rtps_protection SRTPS MUST additionally go on top — BOTH layers,
7197                // like cyclone<->cyclone (reference pcap: 0x "clear submsg from
7198                // protected src"). send_discovery_datagram -> secure_outbound_bytes
7199                // would classify the SEC_PREFIX datagram as volatile-Kx (which is
7200                // RIGHTLY SRTPS-exempt, because its key only comes over the volatile
7201                // itself) and skip SRTPS -> cyclone would see the
7202                // secure SEDP clear, discard ACKNACK/HEARTBEAT as "clear submsg
7203                // from protected src" and never re-send the SubscriptionData ->
7204                // ZeroDDS' writer never matches cyclone's reader (wait_for_matched
7205                // timeout). Hence wrap SRTPS EXPLICITLY here instead of via the
7206                // generic exempt heuristic.
7207                let final_bytes: Option<Vec<u8>> = match &rt.config.security {
7208                    Some(gate)
7209                        if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
7210                            != ProtectionLevel::None =>
7211                    {
7212                        gate.transform_outbound(&inner).ok()
7213                    }
7214                    _ => Some(inner),
7215                };
7216                if let Some(fb) = final_bytes {
7217                    for t in dg.targets.iter() {
7218                        if is_routable_user_locator(t) {
7219                            let _ = rt.spdp_unicast.send(t, &fb);
7220                        }
7221                    }
7222                }
7223            }
7224        }
7225        #[cfg(not(feature = "security"))]
7226        send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7227    }
7228
7229    // --- Security-Builtin-Tick ---
7230    // Volatile-Secure-Writer heartbeats + Volatile-Secure-Reader
7231    // ACKNACK/NACK_FRAG. Stateless hat keinen Tick (BestEffort).
7232    if let Some(stack) = rt.security_builtin_snapshot() {
7233        let outbound = {
7234            if let Ok(mut s) = stack.lock() {
7235                // `out` is only mutated under feature="security" (reassign +
7236                // extend in the cfg block below); otherwise unused_mut in the no-security build.
7237                #[allow(unused_mut)]
7238                let mut out = s.poll(sedp_now).unwrap_or_default();
7239                #[cfg(feature = "security")]
7240                if rt.config.security.is_some() {
7241                    // STABLE peer list: `completed_peer_prefixes()` reads
7242                    // `self.handshakes`, which is GC'd after handshake completion
7243                    // → the LATE volatile RESENDS/HEARTBEATs (tick, long after
7244                    // completion) would then find NO peer anymore (`peers.len()!=1`)
7245                    // and go out CLEAR → cyclone discards them as "clear
7246                    // submsg from protected src". The stabler `authenticated_peer_
7247                    // prefixes()` (the installed Kx key stays) — identical to the
7248                    // token-send tick further below.
7249                    let peers: Vec<GuidPrefix> = rt
7250                        .config
7251                        .security
7252                        .as_ref()
7253                        .map(|g| {
7254                            g.authenticated_peer_prefixes()
7255                                .into_iter()
7256                                .map(GuidPrefix::from_bytes)
7257                                .collect()
7258                        })
7259                        .unwrap_or_default();
7260                    // The reliable volatile submessages from poll() (DATA RESENDS
7261                    // + HEARTBEAT + GAP) must — like the first send — be SEC_*-
7262                    // protected (§8.4.2.4, all writer submessages incl.
7263                    // HEARTBEAT). protect_volatile_datagram now protects all
7264                    // is_protected_writer_submessage. With exactly one peer
7265                    // (bench) with its Kx key.
7266                    if peers.len() == 1 {
7267                        let pk = peers[0].to_bytes();
7268                        out = out
7269                            .into_iter()
7270                            .filter_map(|dg| {
7271                                protect_volatile_datagram(&rt, &dg.bytes, &pk).map(|bytes| {
7272                                    zerodds_rtps::message_builder::OutboundDatagram {
7273                                        bytes,
7274                                        targets: dg.targets,
7275                                    }
7276                                })
7277                            })
7278                            .collect();
7279                    }
7280                    // FU2 step 6b: send per-endpoint datawriter/datareader crypto
7281                    // tokens to every authenticated peer as soon as the
7282                    // local user endpoints exist.
7283                    //
7284                    // STABLE peer list instead of `completed_peer_prefixes()`: the
7285                    // handshake entry is GC'd after completion, so a
7286                    // late-matching user writer/reader (user endpoints match
7287                    // AFTER the secure SEDP) would find no tick window in which
7288                    // its per-endpoint token would go out — the peer could then never
7289                    // decode ZeroDDS' user DATA (#29). `authenticated_peer_
7290                    // prefixes()` (the installed data key) stays.
7291                    let token_peers: Vec<GuidPrefix> = rt
7292                        .config
7293                        .security
7294                        .as_ref()
7295                        .map(|g| {
7296                            g.authenticated_peer_prefixes()
7297                                .into_iter()
7298                                .map(GuidPrefix::from_bytes)
7299                                .collect()
7300                        })
7301                        .unwrap_or_default();
7302                    for prefix in token_peers {
7303                        // Per-token dedup (#29): each per-endpoint token
7304                        // exactly once — builtins early, user endpoints
7305                        // as soon as they match. A per-peer guard would
7306                        // block late-matched user endpoints forever.
7307                        let already = rt
7308                            .endpoint_tokens_sent
7309                            .read()
7310                            .map(|set| set.clone())
7311                            .unwrap_or_default();
7312                        let pending = pending_endpoint_tokens(
7313                            prepare_endpoint_crypto_tokens(&rt, prefix),
7314                            &already,
7315                        );
7316                        for ep_msg in pending {
7317                            let key = endpoint_token_key(&ep_msg);
7318                            out.extend(protect_volatile_outbound(
7319                                &rt,
7320                                prefix,
7321                                s.volatile_writer
7322                                    .write_with_heartbeat(&ep_msg, sedp_now)
7323                                    .unwrap_or_default(),
7324                            ));
7325                            if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
7326                                set.insert(key);
7327                            }
7328                        }
7329                    }
7330                }
7331                out
7332            } else {
7333                Vec::new()
7334            }
7335        };
7336        for dg in outbound {
7337            send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7338        }
7339    }
7340
7341    // --- WLP-Tick (Writer-Liveliness-Protocol Heartbeats) ---
7342    //
7343    // RTPS 2.5 §8.4.13: WLP heartbeats are metatraffic.
7344    // Spec recommendation: multicast to all known peers, one
7345    // heartbeat per `lease_duration / 3`. We send via the
7346    // SPDP multicast sender — that is the same socket that
7347    // sends out the SPDP beacons, and it ensures that all
7348    // peers see the WLP pulses without the runtime having to
7349    // look up a unicast locator per peer.
7350    let wlp_outbound = {
7351        if let Ok(mut wlp) = rt.wlp.lock() {
7352            // Use the secure-WLP entity when liveliness_protection != NONE
7353            // (set idempotently per tick — follows the current governance).
7354            wlp.set_secure(wlp_liveliness_protected(&rt));
7355            wlp.tick(sedp_now).unwrap_or(None)
7356        } else {
7357            None
7358        }
7359    };
7360    if let Some(bytes) = wlp_outbound {
7361        // Under liveliness_protection != NONE the secure-WLP DATA is protected
7362        // with the participant key (§8.4.2.4); otherwise rtps-level/plaintext.
7363        if let Some(secured) = protect_wlp_outbound(&rt, &bytes) {
7364            // Multicast to all peers (spec recommendation §8.4.13)...
7365            let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7366            // ...plus unicast to every discovered peer (M-2), so WLP also
7367            // arrives without multicast (container/cloud). From the metatraffic recv
7368            // socket (spdp_unicast), so the source port is correct (cf. H-3/H-4).
7369            for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7370                let _ = rt.spdp_unicast.send(&loc, &secured);
7371            }
7372        }
7373    }
7374
7375    // (Metatraffic unicast recv: now in `recv_metatraffic_loop`.)
7376
7377    // --- User-Writer-Tick (HEARTBEAT + Resends) ---
7378    //
7379    // Security: per-target serializer. A datagram can go to
7380    // multiple reader locators. Per target we pull it
7381    // individually through `secure_outbound_for_target`, so the
7382    // wire payload matches the protection class of the respective reader.
7383    let user_writer_outbound: Vec<(EntityId, _)> = {
7384        let mut all = Vec::new();
7385        for (eid, arc) in rt.writer_slots_snapshot() {
7386            if let Ok(mut slot) = arc.lock() {
7387                if let Ok(dgs) = slot.writer.tick(sedp_now) {
7388                    for dg in dgs {
7389                        all.push((eid, dg));
7390                    }
7391                }
7392            }
7393        }
7394        all
7395    };
7396    for (writer_eid, dg) in user_writer_outbound {
7397        for t in dg.targets.iter() {
7398            if !is_routable_user_locator(t) {
7399                continue;
7400            }
7401            if let Some(secured) = secure_outbound_for_target(&rt, writer_eid, &dg.bytes, t) {
7402                send_on_best_interface(&rt, t, &secured);
7403            }
7404        }
7405    }
7406
7407    // --- User-Reader-Tick-Outbound (ACKNACK / NACK_FRAG) ---
7408    let user_reader_outbound: Vec<_> = {
7409        let mut all = Vec::new();
7410        for (_eid, arc) in rt.reader_slots_snapshot() {
7411            if let Ok(mut slot) = arc.lock() {
7412                if let Ok(dgs) = slot.reader.tick_outbound(sedp_now) {
7413                    all.extend(dgs);
7414                }
7415            }
7416        }
7417        all
7418    };
7419    for dg in user_reader_outbound {
7420        if let Some(secured) = protect_user_reader_datagram(&rt, &dg.bytes) {
7421            for t in dg.targets.iter() {
7422                if is_routable_user_locator(t) {
7423                    let _ = rt.user_unicast.send(t, &secured);
7424                }
7425            }
7426        }
7427    }
7428
7429    // (User-data unicast recv: now in `recv_user_data_loop`.)
7430
7431    // --- Per-interface inbound ---
7432    //
7433    // Each pool binding is polled non-blocking; the
7434    // received datagram goes through `secure_inbound_bytes` with
7435    // the matching NetInterface class. This lets the
7436    // PolicyEngine make interface-specific decisions
7437    // (e.g. accept loopback-plain on a protected domain).
7438    //
7439    // The non-blocking semantics are achieved by each socket
7440    // in `bind_all` holding a short read timeout — see
7441    // `OutboundSocketPool::bind_all`. Without a timeout the
7442    // event loop would hang on an empty binding per tick.
7443    #[cfg(feature = "security")]
7444    if let Some(pool) = &rt.outbound_pool {
7445        for binding in &pool.bindings {
7446            while let Ok(dg) = binding.socket.recv() {
7447                let iface = binding.spec.kind.clone();
7448                if let Some(clear) = secure_inbound_bytes(&rt, &dg.data, &iface) {
7449                    // Try SPDP first (reverse beacons), then
7450                    // SEDP, then user data — same dispatch as
7451                    // for the legacy sockets.
7452                    handle_spdp_datagram(&rt, &clear);
7453                    let events = rt
7454                        .sedp
7455                        .lock()
7456                        .ok()
7457                        .and_then(|mut s| s.handle_datagram(&clear, sedp_now).ok());
7458                    if let Some(ev) = events {
7459                        if !ev.is_empty() {
7460                            run_matching_pass(&rt);
7461                            push_sedp_events_to_builtin_readers(&rt, &ev);
7462                        }
7463                    }
7464                    if !dispatch_type_lookup_datagram(&rt, &clear, &dg.source) {
7465                        handle_user_datagram(&rt, &clear, sedp_now);
7466                    }
7467                    // DDS-Security 1.2 §7.4.2 Builtin-Endpoints
7468                    for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
7469                        send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7470                    }
7471                }
7472            }
7473        }
7474    }
7475
7476    // Housekeeping (deadline/lifespan/liveliness) runs as a separate
7477    // `tick_housekeep` call of the respective driver (tick_loop /
7478    // tick_driver / scheduler_tick_loop) — see `tick_housekeep`.
7479
7480    // Diagnostic: mark this iteration complete so `tick_count()` advances
7481    // whether driven by the internal thread or an external executor.
7482    rt.tick_seq.fetch_add(1, Ordering::Relaxed);
7483}
7484
7485/// Min tracker for the earliest "next-due" instant (nanos in the runtime
7486/// `elapsed` time base) across multiple housekeeping sources.
7487struct NextDue(Option<u64>);
7488
7489impl NextDue {
7490    fn new() -> Self {
7491        Self(None)
7492    }
7493    fn note(&mut self, due_nanos: u64) {
7494        self.0 = Some(self.0.map_or(due_nanos, |e| e.min(due_nanos)));
7495    }
7496    fn into_inner(self) -> Option<u64> {
7497        self.0
7498    }
7499}
7500
7501/// D.5e Phase 3 B-2 — the time-driven housekeeping checks, factored out of
7502/// [`run_tick_iteration`], so the event-driven scheduler can fire them
7503/// as its own [`TickEvent::Housekeep`] heap event exactly at the next
7504/// due-instant (and `tick_loop`/`tick_driver` call them inline).
7505/// Pure reader/writer-side bookkeeping — **no** cross-vendor wire
7506/// output, the cadence is purely internal.
7507///
7508/// Return value: the earliest instant (nanos in the `elapsed` time base) at which
7509/// a check is due again, or `None` if nothing is currently pending
7510/// (no active deadline/lifespan/liveliness slot) — then the
7511/// scheduler parks until the idle floor resp. until a `raise_tick_wake` signals new
7512/// work.
7513fn tick_housekeep(rt: &Arc<DcpsRuntime>, elapsed: Duration) -> Option<u64> {
7514    let mut next_due = NextDue::new();
7515    // --- Deadline-Monitoring ---
7516    if let Some(d) = check_deadlines(rt, elapsed) {
7517        next_due.note(d);
7518    }
7519    // --- Lifespan-Expire ---
7520    if let Some(d) = expire_by_lifespan(rt, elapsed) {
7521        next_due.note(d);
7522    }
7523    // --- Liveliness lease check (reader side) ---
7524    if let Some(d) = check_liveliness(rt, elapsed) {
7525        next_due.note(d);
7526    }
7527    // --- Writer-side liveliness-lost check ---
7528    if let Some(d) = check_writer_liveliness(rt, elapsed) {
7529        next_due.note(d);
7530    }
7531    next_due.into_inner()
7532}
7533
7534impl DcpsRuntime {
7535    /// Number of completed tick iterations since `start()`. Advances once per
7536    /// tick regardless of whether the internal `zdds-tick` thread or an
7537    /// external executor ([`DcpsRuntime::tick_driver`]) drives it — a stalled
7538    /// value means the periodic tick stopped. Diagnostic only.
7539    #[must_use]
7540    pub fn tick_count(&self) -> u64 {
7541        self.tick_seq.load(Ordering::Relaxed)
7542    }
7543
7544    /// Number of SPDP announces emitted since `start()`. Diagnostic for the C3
7545    /// initial-announcement burst: a fresh participant with no discovered peer
7546    /// advances this at [`RuntimeConfig::initial_announce_period`] for the first
7547    /// [`RuntimeConfig::initial_announce_count`] announces, then slows to
7548    /// `spdp_period`.
7549    #[must_use]
7550    pub fn spdp_announce_count(&self) -> u64 {
7551        self.spdp_announce_seq.load(Ordering::Relaxed)
7552    }
7553
7554    /// Number of discovered topic inconsistencies (DDS 1.4 §2.2.4.2.4).
7555    /// Bumped during matching against the SEDP cache whenever a remote
7556    /// endpoint carries the same `topic_name` but a differing `type_name`
7557    /// than a local endpoint. A delta against the last poll snapshot
7558    /// triggers `on_inconsistent_topic`.
7559    #[must_use]
7560    pub fn inconsistent_topic_count(&self) -> u64 {
7561        self.inconsistent_topic_seq.load(Ordering::Relaxed)
7562    }
7563
7564    /// External tick driver (zerodds-async-1.0 §4). Only meaningful when the
7565    /// runtime was started with [`RuntimeConfig::external_tick`] = `true`,
7566    /// which suppresses the dedicated `zdds-tick` thread. Each
7567    /// [`DcpsTickDriver::tick`] call runs exactly one tick iteration; the
7568    /// caller schedules the next after [`DcpsTickDriver::tick_period`]. The
7569    /// async API's `spawn_in_tokio` uses this to multiplex many participants'
7570    /// tick loops onto a tokio runtime instead of one std::thread each.
7571    #[must_use]
7572    pub fn tick_driver(self: &Arc<Self>) -> DcpsTickDriver {
7573        DcpsTickDriver {
7574            st: TickState::new(self),
7575            rt: Arc::clone(self),
7576        }
7577    }
7578
7579    /// D.5e Phase 3 — wake the scheduler tick worker immediately (new work:
7580    /// a sample written, a HEARTBEAT/DATA/ACKNACK received). Coalesced: many
7581    /// raises between two worker passes collapse into a single wake, so a
7582    /// datagram storm does not flood the channel. No-op unless started with
7583    /// `scheduler_tick`.
7584    pub fn raise_tick_wake(&self) {
7585        // Only the first raiser since the last pass actually sends.
7586        if self.tick_wake_pending.swap(true, Ordering::AcqRel) {
7587            return;
7588        }
7589        if let Ok(guard) = self.tick_wake.lock() {
7590            if let Some(h) = guard.as_ref() {
7591                // Active traffic wakes the reliable tick AND re-evaluates
7592                // housekeeping, so a freshly-armed deadline/lifespan/liveliness
7593                // window is scheduled at once instead of waiting out the park.
7594                h.raise_now(TickEvent::Tick);
7595                h.raise_now(TickEvent::Housekeep);
7596            }
7597        }
7598    }
7599
7600    /// `true` if this participant has any user DataWriter or DataReader — i.e.
7601    /// the fine-grained periodic work (HEARTBEAT / ACKNACK / deadline / lifespan
7602    /// / liveliness) may be due and the scheduler keeps a fine cadence. A pure
7603    /// discovery-only participant parks long.
7604    fn has_user_endpoints(&self) -> bool {
7605        self.user_writers
7606            .read()
7607            .map(|m| !m.is_empty())
7608            .unwrap_or(true)
7609            || self
7610                .user_readers
7611                .read()
7612                .map(|m| !m.is_empty())
7613                .unwrap_or(true)
7614    }
7615}
7616
7617/// Drives a runtime's periodic tick from an external executor (tokio, an
7618/// embedded scheduler, a manual test loop). Obtained via
7619/// [`DcpsRuntime::tick_driver`]; only does useful work when the runtime was
7620/// started with [`RuntimeConfig::external_tick`] = `true`.
7621///
7622/// Typical loop (the async crate's `spawn_in_tokio` shape):
7623///
7624/// ```ignore
7625/// let mut driver = runtime.tick_driver();
7626/// let period = driver.tick_period();
7627/// while !driver.is_stopped() {
7628///     driver.tick();
7629///     tokio::time::sleep(period).await;
7630/// }
7631/// ```
7632pub struct DcpsTickDriver {
7633    rt: Arc<DcpsRuntime>,
7634    st: TickState,
7635}
7636
7637impl DcpsTickDriver {
7638    /// Period the caller should wait between consecutive [`Self::tick`] calls
7639    /// (mirrors the internal `zdds-tick` thread's `tick_period`).
7640    #[must_use]
7641    pub fn tick_period(&self) -> Duration {
7642        self.rt.config.tick_period
7643    }
7644
7645    /// `true` once the runtime is shutting down (set by `Drop`/`stop()`). The
7646    /// driving task must then stop calling [`Self::tick`] and return so the
7647    /// runtime can be dropped cleanly.
7648    #[must_use]
7649    pub fn is_stopped(&self) -> bool {
7650        self.rt.stop.load(Ordering::Relaxed)
7651    }
7652
7653    /// Run one tick iteration: periodic SPDP announce, SEDP/WLP ticks,
7654    /// per-user-writer ticks, deadline/lifespan/liveliness checks. Equivalent
7655    /// to one pass of the internal `zdds-tick` loop body.
7656    pub fn tick(&mut self) {
7657        run_tick_iteration(Arc::clone(&self.rt), &mut self.st);
7658        tick_housekeep(&self.rt, self.rt.start_instant.elapsed());
7659    }
7660}
7661
7662/// Writer-side liveliness-lost detection. Spec §2.2.4.2.10.
7663///
7664/// For all user writers: if a lease duration is set and more time
7665/// has elapsed since the last assert (Automatic = `last_write`, Manual =
7666/// `last_liveliness_assert`) than the
7667/// lease duration allows, the writer counts as
7668/// "not-alive" from the DDS view — `liveliness_lost_count++` and reset the window.
7669///
7670/// Note: with pure best-effort tests + `Automatic` the
7671/// counter typically does not advance — Automatic asserts with every
7672/// `write_user_sample`. Manual mode requires an explicit
7673/// `assert_liveliness` (comes with .4b — until then we already provide
7674/// the detection here, the hot-path trigger triggers it).
7675fn check_writer_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7676    let now_nanos = now.as_nanos() as u64;
7677    let mut next_due = NextDue::new();
7678    for (_eid, arc) in rt.writer_slots_snapshot() {
7679        let Ok(mut slot) = arc.lock() else { continue };
7680        if slot.liveliness_lease_nanos == 0 {
7681            continue;
7682        }
7683        let last = match slot.liveliness_kind {
7684            zerodds_qos::LivelinessKind::Automatic => slot.last_write,
7685            _ => slot.last_liveliness_assert,
7686        };
7687        let last_nanos = match last {
7688            Some(t) => t.as_nanos() as u64,
7689            None => continue,
7690        };
7691        if now_nanos.saturating_sub(last_nanos) >= slot.liveliness_lease_nanos {
7692            slot.liveliness_lost_count = slot.liveliness_lost_count.saturating_add(1);
7693            // Reset the window, so the same lease-window
7694            // overrun does not count in an infinite loop.
7695            // Spec §2.2.3.11: "lease has elapsed" — `>=` is boundary-
7696            // stable and avoids flakiness when tick_period == lease.
7697            slot.last_liveliness_assert = Some(now);
7698            slot.last_write = Some(now);
7699            next_due.note(now_nanos.saturating_add(slot.liveliness_lease_nanos));
7700        } else {
7701            next_due.note(last_nanos.saturating_add(slot.liveliness_lease_nanos));
7702        }
7703    }
7704    next_due.into_inner()
7705}
7706
7707/// Checks for all user readers whether the writer has delivered no sample
7708/// for longer than `lease_duration`. If so: transition
7709/// alive → not_alive, `not_alive_count++`.
7710///
7711/// Automatic liveliness (§2.2.3.11): every write is an implicit assert.
7712/// So we check the reader-side `last_sample_received`.
7713/// Manual kinds come with .4b (explicit assert messages).
7714fn check_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7715    let now_nanos = now.as_nanos() as u64;
7716    let mut next_due = NextDue::new();
7717    for (_eid, arc) in rt.reader_slots_snapshot() {
7718        let Ok(mut slot) = arc.lock() else { continue };
7719        if slot.liveliness_lease_nanos == 0 {
7720            continue;
7721        }
7722        // Until the first sample: consider it alive (optimistic).
7723        let last = match slot.last_sample_received {
7724            Some(t) => t.as_nanos() as u64,
7725            None => continue,
7726        };
7727        // Only a still-alive reader can transition; one already
7728        // not_alive stays so until a new sample arrives (event-driven
7729        // via the recv path) — so no re-schedule needed.
7730        if !slot.liveliness_alive {
7731            continue;
7732        }
7733        if now_nanos.saturating_sub(last) >= slot.liveliness_lease_nanos {
7734            slot.liveliness_alive = false;
7735            slot.liveliness_not_alive_count = slot.liveliness_not_alive_count.saturating_add(1);
7736        } else {
7737            next_due.note(last.saturating_add(slot.liveliness_lease_nanos));
7738        }
7739    }
7740    next_due.into_inner()
7741}
7742
7743/// For all user writers: remove samples from the HistoryCache whose
7744/// insert time + lifespan has elapsed. OMG DDS 1.4 §2.2.3.16:
7745/// "If the duration...elapses and the sample is still in the cache...
7746/// the sample is no longer available to any future DataReaders".
7747///
7748/// Implementation: `sample_insert_times` is a VecDeque, sorted
7749/// by insert time (= SN, because monotonic). Front-pop while expired;
7750/// the highest expired SN runs through via `cache.remove_up_to(sn + 1)`.
7751fn expire_by_lifespan(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7752    let now_nanos = now.as_nanos() as u64;
7753    let mut next_due = NextDue::new();
7754    for (_eid, arc) in rt.writer_slots_snapshot() {
7755        let Ok(mut slot) = arc.lock() else { continue };
7756        if slot.lifespan_nanos == 0 {
7757            continue;
7758        }
7759        let mut highest_expired = None;
7760        while let Some(&(sn, inserted)) = slot.sample_insert_times.front() {
7761            let inserted_nanos = inserted.as_nanos() as u64;
7762            if now_nanos.saturating_sub(inserted_nanos) >= slot.lifespan_nanos {
7763                highest_expired = Some(sn);
7764                slot.sample_insert_times.pop_front();
7765            } else {
7766                break;
7767            }
7768        }
7769        if let Some(sn) = highest_expired {
7770            let _removed = slot
7771                .writer
7772                .remove_samples_up_to(zerodds_rtps::wire_types::SequenceNumber(sn.0 + 1));
7773        }
7774        // Next lifespan due = expiry of the now-oldest sample still
7775        // remaining in the cache. Empty deque → nothing due,
7776        // until a new sample is written (raise_tick_wake covers that).
7777        if let Some(&(_sn, inserted)) = slot.sample_insert_times.front() {
7778            next_due.note((inserted.as_nanos() as u64).saturating_add(slot.lifespan_nanos));
7779        }
7780    }
7781    next_due.into_inner()
7782}
7783
7784/// Checks for all user writers + user readers whether the deadline period
7785/// has been exceeded since the last sample. Every exceedance
7786/// increments the corresponding missed counter by exactly 1
7787/// — regardless of how often `check_deadlines` is called within an
7788/// elapsed window, because we keep setting `last_*`
7789/// to "now" after we have counted.
7790///
7791/// **Init-state semantics:** as long as `last_write`/`last_sample_received`
7792/// is `None` (no real write/sample yet), the deadline
7793/// check does not count. Only after the first real data point does the
7794/// deadline window start. This prevents false misses due to slow
7795/// entity setup (Linux CI/container) before the app even issues a
7796/// write.
7797fn check_deadlines(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7798    let now_nanos = now.as_nanos() as u64;
7799    let mut next_due = NextDue::new();
7800    for (_eid, arc) in rt.writer_slots_snapshot() {
7801        let Ok(mut slot) = arc.lock() else { continue };
7802        if slot.deadline_nanos == 0 {
7803            continue;
7804        }
7805        let Some(last) = slot.last_write.map(|d| d.as_nanos() as u64) else {
7806            // Never written yet → deadline window not active.
7807            continue;
7808        };
7809        if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
7810            slot.offered_deadline_missed_count =
7811                slot.offered_deadline_missed_count.saturating_add(1);
7812            // Reset the window: the next deadline is counted relative
7813            // to the current tick. `>=` is boundary-stable
7814            // (Spec §2.2.3.7: "deadline has elapsed").
7815            slot.last_write = Some(now);
7816            next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
7817        } else {
7818            next_due.note(last.saturating_add(slot.deadline_nanos));
7819        }
7820    }
7821    for (_eid, arc) in rt.reader_slots_snapshot() {
7822        let Ok(mut slot) = arc.lock() else { continue };
7823        if slot.deadline_nanos == 0 {
7824            continue;
7825        }
7826        let Some(last) = slot.last_sample_received.map(|d| d.as_nanos() as u64) else {
7827            continue;
7828        };
7829        if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
7830            slot.requested_deadline_missed_count =
7831                slot.requested_deadline_missed_count.saturating_add(1);
7832            slot.last_sample_received = Some(now);
7833            next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
7834        } else {
7835            next_due.note(last.saturating_add(slot.deadline_nanos));
7836        }
7837    }
7838    next_due.into_inner()
7839}
7840
7841/// For all local writers + readers: matching against the current
7842/// SEDP cache. A cheap re-run when SEDP events came in — idempotent,
7843/// because ReliableWriter/Reader add_*_proxy are idempotent (same
7844/// GUID → replaced).
7845fn run_matching_pass(rt: &Arc<DcpsRuntime>) {
7846    let writer_ids: Vec<EntityId> = rt.writer_eids();
7847    for eid in writer_ids {
7848        rt.match_local_writer_against_cache(eid);
7849    }
7850    let reader_ids: Vec<EntityId> = rt.reader_eids();
7851    for eid in reader_ids {
7852        rt.match_local_reader_against_cache(eid);
7853    }
7854}
7855
7856/// Returns the default-unicast locator of a discovered remote
7857/// participant.
7858fn remote_user_locators(
7859    prefix: GuidPrefix,
7860    discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
7861) -> Vec<Locator> {
7862    match discovered.lock() {
7863        Ok(cache) => cache
7864            .get(&prefix)
7865            .and_then(|p| p.data.default_unicast_locator)
7866            .into_iter()
7867            .collect(),
7868        Err(_) => Vec::new(),
7869    }
7870}
7871
7872/// Determine the destination for user traffic to a remote endpoint.
7873///
7874/// DDSI-RTPS 2.5 §8.5.3.2/§8.5.3.3: the per-endpoint `unicastLocatorList`
7875/// from the SEDP announce is authoritative. §8.5.5: only when it is empty
7876/// does the sender fall back to the participant `DEFAULT_UNICAST_LOCATOR` from
7877/// SPDP.
7878///
7879/// Before this fix ZeroDDS *always* used the participant default — which
7880/// broke OpenDDS interop: OpenDDS stores only the
7881/// placeholder 127.0.0.1:12345 as the participant default and announces the real user locator
7882/// exclusively per-endpoint.
7883fn endpoint_or_default_locators(
7884    endpoint: &[Locator],
7885    prefix: GuidPrefix,
7886    discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
7887) -> Vec<Locator> {
7888    if !endpoint.is_empty() {
7889        return endpoint.to_vec();
7890    }
7891    remote_user_locators(prefix, discovered)
7892}
7893
7894/// Dispatches a received RTPS datagram to matching user readers.
7895/// Decides, based on the `reader_id` in DATA/DATA_FRAG/HEARTBEAT/GAP,
7896/// which local reader is responsible.
7897/// Strip the 4-byte encapsulation header off the received sample payload.
7898/// Returns `None` if the payload is < 4 bytes or carries an unknown
7899/// scheme (PL_CDR variants would not get here; they go via
7900/// SEDP — if we see such a thing on user endpoints, it is garbage).
7901/// Spec §3.2 zerodds-async-1.0: wakes a registered waker
7902/// after every `sample_tx.send`. `take` consumes the waker, to
7903/// avoid double wakeups — the caller registers a new one after
7904/// every `Pending` result.
7905fn wake_async_waker(slot: &alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>) {
7906    if let Ok(mut g) = slot.lock() {
7907        if let Some(w) = g.take() {
7908            w.wake();
7909        }
7910    }
7911}
7912
7913/// Converts a sample delivered by the ReliableReader into a
7914/// `UserSample` channel entry. For `ChangeKind::Alive` the
7915/// CDR encapsulation header is stripped; for lifecycle markers
7916/// the key hash is reconstructed from the bytes.
7917/// Inspect-endpoint tap dispatch for the DCPS receive path.
7918///
7919/// Called in `handle_user_datagram` when a sample is delivered to
7920/// a user reader. Only when the `inspect` feature is
7921/// on; without the feature no code, no branch.
7922#[cfg(feature = "inspect")]
7923fn dispatch_inspect_dcps_receive_tap(topic: &str, reader_id: EntityId, item: &UserSample) {
7924    let payload: Vec<u8> = match item {
7925        UserSample::Alive { payload, .. } => payload.to_vec(),
7926        UserSample::Lifecycle { key_hash, .. } => key_hash.to_vec(),
7927    };
7928    let ts_ns = std::time::SystemTime::now()
7929        .duration_since(std::time::UNIX_EPOCH)
7930        .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
7931        .unwrap_or(0);
7932    let mut corr: u64 = 0;
7933    for (i, byte) in reader_id.entity_key.iter().enumerate() {
7934        corr |= u64::from(*byte) << (i * 8);
7935    }
7936    corr |= u64::from(reader_id.entity_kind as u8) << 24;
7937    let frame = zerodds_inspect_endpoint::Frame::dcps(topic.to_owned(), ts_ns, corr, payload);
7938    zerodds_inspect_endpoint::tap::dispatch(&frame);
7939}
7940
7941fn delivered_to_user_sample(
7942    sample: &zerodds_rtps::reliable_reader::DeliveredSample,
7943    writer_strengths: &alloc::collections::BTreeMap<[u8; 16], i32>,
7944) -> Option<UserSample> {
7945    use zerodds_rtps::history_cache::ChangeKind;
7946    match sample.kind {
7947        ChangeKind::Alive | ChangeKind::AliveFiltered => {
7948            let writer_guid = sample.writer_guid.to_bytes();
7949            let writer_strength = writer_strengths.get(&writer_guid).copied().unwrap_or(0);
7950            // Encapsulation representation from byte[1] of the header
7951            // (RTPS 2.5 §10.5) — BEFORE stripping. 0x00–0x03 = XCDR1
7952            // (CDR/PL_CDR), 0x06–0x0b = XCDR2 (CDR2/D_CDR2/PL_CDR2).
7953            let representation = encap_representation(&sample.payload);
7954            strip_user_encap_arc(&sample.payload).map(|payload| UserSample::Alive {
7955                payload,
7956                writer_guid,
7957                writer_strength,
7958                representation,
7959            })
7960        }
7961        ChangeKind::NotAliveDisposed
7962        | ChangeKind::NotAliveUnregistered
7963        | ChangeKind::NotAliveDisposedUnregistered => {
7964            // Lifecycle marker: Spec §9.6.4.8 + §9.6.3.9 requires
7965            // `PID_KEY_HASH` in the inline QoS — the reader reads it
7966            // and propagates it via `DeliveredSample.key_hash`.
7967            // Fallback: with non-spec-conformant writers the
7968            // hash falls back to the first 16 bytes of the key-only payload
7969            // (PLAIN_CDR2-BE key holder).
7970            let kh = sample.key_hash.unwrap_or_else(|| {
7971                let mut h = [0u8; 16];
7972                let n = sample.payload.len().min(16);
7973                h[..n].copy_from_slice(&sample.payload[..n]);
7974                h
7975            });
7976            Some(UserSample::Lifecycle {
7977                key_hash: kh,
7978                kind: sample.kind,
7979            })
7980        }
7981    }
7982}
7983
7984/// Returns the XCDR version from the 4-byte encapsulation header
7985/// (RTPS 2.5 §10.5): `0` = XCDR1 (CDR/PL_CDR, encap byte 0x00–0x05),
7986/// `1` = XCDR2 (CDR2/DELIMITED_CDR2/PL_CDR2, encap byte 0x06–0x0b).
7987/// Default `0` for a too-short payload — XCDR1 is the spec baseline.
7988fn encap_representation(payload: &[u8]) -> u8 {
7989    if payload.len() >= 2 && payload[1] >= 0x06 {
7990        1
7991    } else {
7992        0
7993    }
7994}
7995
7996/// Checks whether `payload` has a known 4-byte encapsulation header.
7997/// Returns `Some(4)` if so (= offset behind the header), `None` if
7998/// no known scheme. Separated in use from [`strip_user_encap`]:
7999/// here only validation without allocation, for the listener zero-copy
8000/// path (lever E / Sprint D.5d).
8001fn validate_user_encap_offset(payload: &[u8]) -> Option<usize> {
8002    if payload.len() < 4 {
8003        return None;
8004    }
8005    // Accept all data-representation schemes (RTPS 2.5 §10.5,
8006    // table 10.3): byte0 = 0x00, byte1 in:
8007    //   0x00/0x01 CDR_BE/LE        (XCDR1 PLAIN_CDR)
8008    //   0x02/0x03 PL_CDR_BE/LE     (XCDR1 parameter list, key serial.)
8009    //   0x06/0x07 CDR2_BE/LE       (XCDR2 PLAIN_CDR2)
8010    //   0x08/0x09 D_CDR2_BE/LE     (XCDR2 DELIMITED_CDR2, @appendable)
8011    //   0x0a/0x0b PL_CDR2_BE/LE    (XCDR2 PL_CDR2, @mutable)
8012    // Cyclone often sends XCDR1, OpenDDS/FastDDS XCDR2. We pass
8013    // all through; the typed decoder picks the correct alignment rule
8014    // based on the `representation` (see `encap_representation`).
8015    if payload[0] != 0x00 {
8016        return None;
8017    }
8018    match payload[1] {
8019        0x00..=0x03 | 0x06..=0x0b => Some(4),
8020        _ => None,
8021    }
8022}
8023
8024/// Zero-copy variant: strips the encap header via range slicing
8025/// on the refcounted `Arc<[u8]>` backing store. No heap alloc.
8026/// Spec: `docs/specs/zerodds-zero-copy-1.0.md` §6 wave 2.
8027fn strip_user_encap_arc(
8028    payload: &alloc::sync::Arc<[u8]>,
8029) -> Option<crate::sample_bytes::SampleBytes> {
8030    validate_user_encap_offset(payload).map(|off| {
8031        crate::sample_bytes::SampleBytes::from_arc_slice(
8032            alloc::sync::Arc::clone(payload),
8033            off..payload.len(),
8034        )
8035    })
8036}
8037
8038#[cfg(test)]
8039fn strip_user_encap(payload: &[u8]) -> Option<alloc::vec::Vec<u8>> {
8040    validate_user_encap_offset(payload).map(|off| payload[off..].to_vec())
8041}
8042
8043/// Bench-only phase-timing accumulators. Active with env
8044/// `ZERODDS_PHASE_TIMING=1`. With `ZERODDS_PHASE_DUMP=1` the
8045/// atexit hook prints the totals on drop of the first runtime.
8046#[doc(hidden)]
8047pub static PHASE_HANDLE_USER_NS: core::sync::atomic::AtomicU64 =
8048    core::sync::atomic::AtomicU64::new(0);
8049#[doc(hidden)]
8050pub static PHASE_HANDLE_USER_CALLS: core::sync::atomic::AtomicU64 =
8051    core::sync::atomic::AtomicU64::new(0);
8052#[doc(hidden)]
8053pub static PHASE_WRITE_USER_NS: core::sync::atomic::AtomicU64 =
8054    core::sync::atomic::AtomicU64::new(0);
8055#[doc(hidden)]
8056pub static PHASE_WRITE_USER_CALLS: core::sync::atomic::AtomicU64 =
8057    core::sync::atomic::AtomicU64::new(0);
8058
8059/// Sub-phases in the `handle_user_datagram` receive hot path:
8060/// 0=decode_datagram, 1=slot-lookup+lock, 2=reader.handle_data,
8061/// 3=delivered_to_user_sample, 4=listener+sender-dispatch.
8062/// Active under `ZERODDS_PHASE_TIMING=1`. Each `Instant::now()` bracket
8063/// costs ~50 ns; at a ~3 µs handle that is ~1.6% per sub-phase.
8064#[doc(hidden)]
8065pub static PHASE_HANDLE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8066    core::sync::atomic::AtomicU64::new(0),
8067    core::sync::atomic::AtomicU64::new(0),
8068    core::sync::atomic::AtomicU64::new(0),
8069    core::sync::atomic::AtomicU64::new(0),
8070    core::sync::atomic::AtomicU64::new(0),
8071];
8072
8073/// Sub-phases in `write_user_sample_borrowed` (sender hot path):
8074/// 0=lookup, 1=lock, 2=write_with_heartbeat, 3=send-loop, 4=reserved.
8075/// The detail drilldown into socket.send_to vs. inproc-peer dispatch was
8076/// done once for the connected-UDP lever (showed send_to as
8077/// 97% of the dispatch path); not permanent in the code, because per-phase
8078/// `Instant::now()` itself costs ~50 ns — at a 6 µs send that
8079/// would be 1% overhead and skews the calibrated measurement.
8080#[doc(hidden)]
8081pub static PHASE_WRITE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8082    core::sync::atomic::AtomicU64::new(0),
8083    core::sync::atomic::AtomicU64::new(0),
8084    core::sync::atomic::AtomicU64::new(0),
8085    core::sync::atomic::AtomicU64::new(0),
8086    core::sync::atomic::AtomicU64::new(0),
8087];
8088
8089fn phase_timing_enabled() -> bool {
8090    static CACHE: core::sync::atomic::AtomicI8 = core::sync::atomic::AtomicI8::new(-1);
8091    let v = CACHE.load(core::sync::atomic::Ordering::Relaxed);
8092    if v >= 0 {
8093        return v == 1;
8094    }
8095    let on = std::env::var("ZERODDS_PHASE_TIMING")
8096        .map(|s| s == "1")
8097        .unwrap_or(false);
8098    CACHE.store(
8099        if on { 1 } else { 0 },
8100        core::sync::atomic::Ordering::Relaxed,
8101    );
8102    on
8103}
8104
8105struct PhaseTimer {
8106    start: std::time::Instant,
8107    ns_acc: &'static core::sync::atomic::AtomicU64,
8108    calls_acc: &'static core::sync::atomic::AtomicU64,
8109}
8110
8111impl Drop for PhaseTimer {
8112    fn drop(&mut self) {
8113        let ns = self.start.elapsed().as_nanos() as u64;
8114        self.ns_acc
8115            .fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8116        self.calls_acc
8117            .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
8118    }
8119}
8120
8121fn handle_user_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], now: Duration) {
8122    let _phase_guard = if phase_timing_enabled() {
8123        Some(PhaseTimer {
8124            start: std::time::Instant::now(),
8125            ns_acc: &PHASE_HANDLE_USER_NS,
8126            calls_acc: &PHASE_HANDLE_USER_CALLS,
8127        })
8128    } else {
8129        None
8130    };
8131    let pt_on = phase_timing_enabled();
8132    let pt_t0 = if pt_on {
8133        Some(std::time::Instant::now())
8134    } else {
8135        None
8136    };
8137    let parsed = match decode_datagram(bytes) {
8138        Ok(p) => p,
8139        Err(_) => return,
8140    };
8141    // DDSI-RTPS §8.3.4: the effective source of each writer submessage is the
8142    // sourceGuidPrefix from the RTPS header. The reader demux needs it to
8143    // distinguish writer proxies with the same EntityId but a different participant
8144    // (fan-in / multiple publishers on the same topic).
8145    let src_prefix = parsed.header.guid_prefix;
8146    if let (Some(t0), true) = (pt_t0, pt_on) {
8147        let ns = t0.elapsed().as_nanos() as u64;
8148        PHASE_HANDLE_SUB_NS[0].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8149    }
8150    // Per-submessage: take the matching slot mutex individually per
8151    // submessage — no global user_writers/user_readers lock anymore.
8152    // With per-submessage granularity, reader datagrams can be processed in parallel
8153    // to writer AckNacks.
8154    for sub in parsed.submessages {
8155        match sub {
8156            ParsedSubmessage::Data(d) => {
8157                // Sprint D.5d lever B — collect-then-dispatch:
8158                // sample conversion + liveliness update inside slot.lock,
8159                // then listener fire + channel send + waker wake
8160                // OUTSIDE the lock.
8161                //
8162                // Cross-vendor fix 2026-05-19: when reader_id ==
8163                // ENTITYID_UNKNOWN (RTPS spec §8.3.7.2: "deliver to all
8164                // matched readers on this topic"), we iterate over
8165                // ALL reader slots and let `handle_data` filter by
8166                // writer_proxies. Cyclone DDS/FastDDS/RTI send
8167                // user DATA with reader_id=UNKNOWN; without this fan-out
8168                // ZeroDDS would drop every such DATA.
8169                let pt_t1 = if pt_on {
8170                    Some(std::time::Instant::now())
8171                } else {
8172                    None
8173                };
8174                let target_slots: Vec<ReaderSlotArc> = if d.reader_id == EntityId::UNKNOWN {
8175                    let snap = rt.reader_slots_snapshot();
8176                    let mut v = Vec::with_capacity(snap.len());
8177                    v.extend(snap.into_iter().map(|(_, arc)| arc));
8178                    v
8179                } else {
8180                    let mut v = Vec::with_capacity(1);
8181                    if let Some(arc) = rt.reader_slot(d.reader_id) {
8182                        v.push(arc);
8183                    }
8184                    v
8185                };
8186                if let (Some(t1), true) = (pt_t1, pt_on) {
8187                    let ns = t1.elapsed().as_nanos() as u64;
8188                    PHASE_HANDLE_SUB_NS[1].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8189                }
8190                for arc in target_slots {
8191                    // Lever E: alongside the UserSample we carry a
8192                    // zero-copy view on the original `Arc<[u8]>` with
8193                    // the encap offset — the listener can thereby read into
8194                    // the payload without allocation.
8195                    let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
8196                    let listener;
8197                    let waker;
8198                    let sender;
8199                    #[cfg(feature = "inspect")]
8200                    let topic_name;
8201                    let pt_t2 = if pt_on {
8202                        Some(std::time::Instant::now())
8203                    } else {
8204                        None
8205                    };
8206                    {
8207                        let Ok(mut slot) = arc.lock() else { continue };
8208                        let hd_samples: Vec<_> = slot
8209                            .reader
8210                            .handle_data(src_prefix, &d)
8211                            .into_iter()
8212                            .collect();
8213                        for sample in hd_samples {
8214                            // Listener zero-copy view only for alive samples
8215                            // with a valid encap header. Arc::clone is
8216                            // an atomic refcount inc, no data copy.
8217                            let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
8218                                zerodds_rtps::history_cache::ChangeKind::Alive
8219                                | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
8220                                    validate_user_encap_offset(&sample.payload)
8221                                        .map(|off| (Arc::clone(&sample.payload), off))
8222                                }
8223                                _ => None,
8224                            };
8225                            if let Some(item) =
8226                                delivered_to_user_sample(&sample, &slot.writer_strengths)
8227                            {
8228                                items.push((item, listener_view));
8229                            }
8230                        }
8231                        if !items.is_empty() {
8232                            slot.last_sample_received = Some(now);
8233                            slot.samples_delivered_count = slot
8234                                .samples_delivered_count
8235                                .saturating_add(items.len() as u64);
8236                            if !slot.liveliness_alive {
8237                                slot.liveliness_alive = true;
8238                                slot.liveliness_alive_count =
8239                                    slot.liveliness_alive_count.saturating_add(1);
8240                            }
8241                        }
8242                        listener = slot.listener.clone();
8243                        waker = Arc::clone(&slot.async_waker);
8244                        sender = slot.sample_tx.clone();
8245                        #[cfg(feature = "inspect")]
8246                        {
8247                            topic_name = slot.topic_name.clone();
8248                        }
8249                    }
8250                    if let (Some(t2), true) = (pt_t2, pt_on) {
8251                        let ns = t2.elapsed().as_nanos() as u64;
8252                        PHASE_HANDLE_SUB_NS[2].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8253                    }
8254                    let pt_t3 = if pt_on {
8255                        Some(std::time::Instant::now())
8256                    } else {
8257                        None
8258                    };
8259                    // --- Outside slot.lock: dispatch ---
8260                    //
8261                    // Listener and MPSC are exclusive: if a listener
8262                    // (callback) is set, the consumer is on the
8263                    // callback path — the additional `sender.send` +
8264                    // `wake_async_waker` would be pure overhead AND
8265                    // would grow the channel buffer unboundedly
8266                    // (memory leak in callback-only apps). We
8267                    // dispatch either the callback OR the MPSC, not
8268                    // both. A caller (Rust API) that wants take()+listener
8269                    // at the same time simply sets NO listener
8270                    // and polls via take().
8271                    for (item, listener_view) in items {
8272                        let item_repr = if let UserSample::Alive { representation, .. } = &item {
8273                            *representation
8274                        } else {
8275                            0
8276                        };
8277                        #[cfg(feature = "inspect")]
8278                        dispatch_inspect_dcps_receive_tap(&topic_name, d.reader_id, &item);
8279                        if let Some(ref l) = listener {
8280                            if let Some((arc_payload, off)) = listener_view {
8281                                // Zero-copy: slice view into the original Arc.
8282                                l(&arc_payload[off..], item_repr);
8283                            }
8284                        } else {
8285                            let _ = sender.send(item);
8286                            wake_async_waker(&waker);
8287                        }
8288                    }
8289                    if let (Some(t3), true) = (pt_t3, pt_on) {
8290                        let ns = t3.elapsed().as_nanos() as u64;
8291                        PHASE_HANDLE_SUB_NS[4].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8292                    }
8293                } // for arc in target_slots
8294            }
8295            ParsedSubmessage::DataFrag(df) => {
8296                // Lever B+E — see the Data arm above.
8297                // Cross-vendor: same UNKNOWN fan-out as for Data.
8298                let target_slots: Vec<ReaderSlotArc> = if df.reader_id == EntityId::UNKNOWN {
8299                    rt.reader_slots_snapshot()
8300                        .into_iter()
8301                        .map(|(_, arc)| arc)
8302                        .collect()
8303                } else {
8304                    rt.reader_slot(df.reader_id).into_iter().collect()
8305                };
8306                for arc in target_slots {
8307                    let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
8308                    let listener;
8309                    let waker;
8310                    let sender;
8311                    #[cfg(feature = "inspect")]
8312                    let topic_name;
8313                    {
8314                        let Ok(mut slot) = arc.lock() else { continue };
8315                        for sample in slot.reader.handle_data_frag(src_prefix, &df, now) {
8316                            let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
8317                                zerodds_rtps::history_cache::ChangeKind::Alive
8318                                | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
8319                                    validate_user_encap_offset(&sample.payload)
8320                                        .map(|off| (Arc::clone(&sample.payload), off))
8321                                }
8322                                _ => None,
8323                            };
8324                            if let Some(item) =
8325                                delivered_to_user_sample(&sample, &slot.writer_strengths)
8326                            {
8327                                items.push((item, listener_view));
8328                            }
8329                        }
8330                        if !items.is_empty() {
8331                            slot.last_sample_received = Some(now);
8332                            slot.samples_delivered_count = slot
8333                                .samples_delivered_count
8334                                .saturating_add(items.len() as u64);
8335                            if !slot.liveliness_alive {
8336                                slot.liveliness_alive = true;
8337                                slot.liveliness_alive_count =
8338                                    slot.liveliness_alive_count.saturating_add(1);
8339                            }
8340                        }
8341                        listener = slot.listener.clone();
8342                        waker = Arc::clone(&slot.async_waker);
8343                        sender = slot.sample_tx.clone();
8344                        #[cfg(feature = "inspect")]
8345                        {
8346                            topic_name = slot.topic_name.clone();
8347                        }
8348                    }
8349                    for (item, listener_view) in items {
8350                        let item_repr = if let UserSample::Alive { representation, .. } = &item {
8351                            *representation
8352                        } else {
8353                            0
8354                        };
8355                        #[cfg(feature = "inspect")]
8356                        dispatch_inspect_dcps_receive_tap(&topic_name, df.reader_id, &item);
8357                        // See the Data arm: listener and MPSC are exclusive.
8358                        if let Some(ref l) = listener {
8359                            if let Some((arc_payload, off)) = listener_view {
8360                                l(&arc_payload[off..], item_repr);
8361                            }
8362                        } else {
8363                            let _ = sender.send(item);
8364                            wake_async_waker(&waker);
8365                        }
8366                    }
8367                } // for arc in target_slots (DataFrag)
8368            }
8369            ParsedSubmessage::Heartbeat(h) => {
8370                // Lever B — collect-then-dispatch like the Data arm. An HB can
8371                // unlock samples that were waiting on a hole fill
8372                // (volatile skip, historic eviction).
8373                //
8374                // D.5e Phase-2: synchronous ACKNACK emit on HB receipt
8375                // instead of deferred-via-tick. With `heartbeat_response_delay=0`
8376                // (D.5e default) `tick_outbound(now)` flushes the
8377                // ACKNACK directly for all pending writer_proxies — the tick loop
8378                // no longer has to wait 5 ms.
8379                // Cross-vendor: a HEARTBEAT with reader_id=UNKNOWN is
8380                // "to all matched readers". Cyclone often packs this into
8381                // DATA+HB submessage bundles.
8382                let target_slots: Vec<ReaderSlotArc> = if h.reader_id == EntityId::UNKNOWN {
8383                    rt.reader_slots_snapshot()
8384                        .into_iter()
8385                        .map(|(_, arc)| arc)
8386                        .collect()
8387                } else {
8388                    rt.reader_slot(h.reader_id).into_iter().collect()
8389                };
8390                for arc in target_slots {
8391                    let mut items: Vec<UserSample> = Vec::new();
8392                    let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
8393                        Vec::new();
8394                    let waker;
8395                    let sender;
8396                    {
8397                        let Ok(mut slot) = arc.lock() else { continue };
8398                        for sample in slot.reader.handle_heartbeat(src_prefix, &h, now) {
8399                            if let Some(item) =
8400                                delivered_to_user_sample(&sample, &slot.writer_strengths)
8401                            {
8402                                items.push(item);
8403                            }
8404                        }
8405                        if !items.is_empty() {
8406                            slot.last_sample_received = Some(now);
8407                            slot.samples_delivered_count = slot
8408                                .samples_delivered_count
8409                                .saturating_add(items.len() as u64);
8410                            if !slot.liveliness_alive {
8411                                slot.liveliness_alive = true;
8412                                slot.liveliness_alive_count =
8413                                    slot.liveliness_alive_count.saturating_add(1);
8414                            }
8415                        }
8416                        // D.5e Phase-2: synchronous ACKNACK directly in the recv thread.
8417                        if let Ok(dgs) = slot.reader.tick_outbound(now) {
8418                            sync_outbound = dgs;
8419                        }
8420                        waker = Arc::clone(&slot.async_waker);
8421                        sender = slot.sample_tx.clone();
8422                    }
8423                    for item in items {
8424                        let _ = sender.send(item);
8425                        wake_async_waker(&waker);
8426                    }
8427                    // Send ACKNACK datagrams synchronously — no tick-quantization tax.
8428                    for dg in sync_outbound {
8429                        if let Some(secured) = protect_user_reader_datagram(rt, &dg.bytes) {
8430                            for t in dg.targets.iter() {
8431                                if is_routable_user_locator(t) {
8432                                    let _ = rt.user_unicast.send(t, &secured);
8433                                }
8434                            }
8435                        }
8436                    }
8437                } // for arc in target_slots (Heartbeat)
8438            }
8439            ParsedSubmessage::Gap(g) => {
8440                // Cross-vendor: Gap with UNKNOWN reader → fan-out.
8441                let target_slots: Vec<ReaderSlotArc> = if g.reader_id == EntityId::UNKNOWN {
8442                    rt.reader_slots_snapshot()
8443                        .into_iter()
8444                        .map(|(_, arc)| arc)
8445                        .collect()
8446                } else {
8447                    rt.reader_slot(g.reader_id).into_iter().collect()
8448                };
8449                for arc in target_slots {
8450                    if let Ok(mut slot) = arc.lock() {
8451                        for sample in slot.reader.handle_gap(src_prefix, &g) {
8452                            if let Some(item) =
8453                                delivered_to_user_sample(&sample, &slot.writer_strengths)
8454                            {
8455                                let _ = slot.sample_tx.send(item);
8456                                wake_async_waker(&slot.async_waker);
8457                            }
8458                        }
8459                    }
8460                }
8461            }
8462            ParsedSubmessage::AckNack(ack) => {
8463                if let Some(arc) = rt.writer_slot(ack.writer_id) {
8464                    let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
8465                        Vec::new();
8466                    if let Ok(mut slot) = arc.lock() {
8467                        let base = ack.reader_sn_state.bitmap_base;
8468                        let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
8469                        let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
8470                        slot.writer.handle_acknack(src, base, requested);
8471                        // D.5e Phase-2: synchronous resend on NACK receipt.
8472                        // An ACKNACK may have listed requested SNs for resend;
8473                        // tick delivers the resend datagrams directly in the recv thread.
8474                        if let Ok(dgs) = slot.writer.tick(now) {
8475                            sync_outbound = dgs;
8476                        }
8477                    }
8478                    // ACK-Event-Cvar: wake `wait_for_acknowledgments`-waiters.
8479                    rt.notify_ack_event();
8480                    // Send sync resends (no more tick wait). FU2 S3:
8481                    // per-target data_protection (a reliable resend of user DATA
8482                    // must be encrypted just like the immediate send).
8483                    for dg in sync_outbound {
8484                        for t in dg.targets.iter() {
8485                            if is_routable_user_locator(t) {
8486                                if let Some(secured) =
8487                                    secure_outbound_for_target(rt, ack.writer_id, &dg.bytes, t)
8488                                {
8489                                    let _ = rt.user_unicast.send(t, &secured);
8490                                }
8491                            }
8492                        }
8493                    }
8494                }
8495            }
8496            ParsedSubmessage::NackFrag(nf) => {
8497                if let Some(arc) = rt.writer_slot(nf.writer_id) {
8498                    if let Ok(mut slot) = arc.lock() {
8499                        let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
8500                        slot.writer.handle_nackfrag(src, &nf);
8501                    }
8502                }
8503            }
8504            _ => {}
8505        }
8506    }
8507}
8508
8509/// Test hook: allows a direct call of `handle_spdp_datagram` from
8510/// other modules without spinning up the whole event loop.
8511/// For internal tests only.
8512#[cfg(test)]
8513pub(crate) fn handle_spdp_datagram_for_test(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
8514    handle_spdp_datagram(rt, bytes);
8515}
8516
8517fn handle_spdp_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
8518    let parsed = match rt.spdp_reader.parse_datagram(bytes) {
8519        Ok(p) => p,
8520        Err(_) => return, // not SPDP or wire error — swallow
8521    };
8522    // Self-discovery filter: ignore our own beacons.
8523    if parsed.sender_prefix == rt.guid_prefix {
8524        return;
8525    }
8526    let is_new = {
8527        if let Ok(mut cache) = rt.discovered.lock() {
8528            cache.insert(parsed.clone())
8529        } else {
8530            false
8531        }
8532    };
8533    // On first discovery: wire the SEDP stack + send out initial
8534    // announcements.
8535    if is_new {
8536        if let Ok(mut sedp) = rt.sedp.lock() {
8537            sedp.on_participant_discovered(&parsed);
8538        }
8539        // Event-driven directed SPDP response (§8.5.3): send OUR own
8540        // SPDP IMMEDIATELY unicast to the newly discovered peer, instead of letting it
8541        // wait for our next periodic multicast beacon (spdp_period=5s, codepit-LXC
8542        // multicast flaky). A spec-conformant peer (OpenDDS)
8543        // processes our auth request ONLY once it has our identity_token from
8544        // our SPDP — without this directed response it waits up to
8545        // spdp_period (seconds latency → cross-vendor ping wait_for_matched
8546        // timeout). NO timeout band-aid: the seconds latency was the missing
8547        // discovery event. Token-less first beacons (security not yet enabled)
8548        // are NOT sent (see security_pending in the announce loop) — the
8549        // periodic/announce_spdp_now path catches up.
8550        #[cfg(feature = "security")]
8551        let beacon_ready =
8552            !(rt.config.security.is_some() && rt.security_builtin_snapshot().is_none());
8553        #[cfg(not(feature = "security"))]
8554        let beacon_ready = true;
8555        if beacon_ready {
8556            let targets = wlp_unicast_targets(core::slice::from_ref(&parsed));
8557            if !targets.is_empty() {
8558                if let Some(secured) = rt
8559                    .spdp_beacon
8560                    .lock()
8561                    .ok()
8562                    .and_then(|mut b| b.serialize().ok())
8563                    .and_then(|d| secure_outbound_bytes(rt, &d).map(|c| c.to_vec()))
8564                {
8565                    for loc in &targets {
8566                        let _ = rt.spdp_unicast.send(loc, &secured);
8567                    }
8568                }
8569            }
8570        }
8571    }
8572    // FU2: wire the security builtin stack + kick off the auth handshake.
8573    // On EVERY beacon (not only is_new): `handle_remote_endpoints` and
8574    // `begin_handshake_with` are idempotent. This also covers the case
8575    // that the peer was discovered before the auth plugin was active via
8576    // `enable_security_builtins_with_auth` — the next
8577    // beacon refresh then kicks off the handshake. No-op without a plugin,
8578    // without security bits or without an announced identity_token.
8579    if let Some(sec) = rt.security_builtin_snapshot() {
8580        let handshake_dgs = if let Ok(mut s) = sec.lock() {
8581            s.note_remote_vendor(parsed.sender_prefix, parsed.sender_vendor);
8582            s.handle_remote_endpoints(&parsed);
8583            match parsed.data.identity_token.as_ref() {
8584                Some(token) => s
8585                    .begin_handshake_with(parsed.sender_prefix, parsed.data.guid.to_bytes(), token)
8586                    .unwrap_or_default(),
8587                None => Vec::new(),
8588            }
8589        } else {
8590            Vec::new()
8591        };
8592        for dg in handshake_dgs {
8593            send_discovery_datagram(rt, &dg.targets, &dg.bytes);
8594        }
8595    }
8596    //  Mirror the SPDP receive into the builtin DCPSParticipant reader.
8597    // We send on every beacon (also refresh) — Spec §2.2.5.1
8598    // allows it, take() returns the respective current
8599    // data to the user. A reader with KEEP_LAST(1) receives only the newest.
8600    if let Some(sinks) = rt.builtin_sinks_snapshot() {
8601        let dcps_sample =
8602            crate::builtin_topics::ParticipantBuiltinTopicData::from_wire(&parsed.data);
8603        // .7 §2.2.2.2.1.14: drop ignored participants before
8604        // they fall into the builtin reader.
8605        if let Some(filter) = rt.ignore_filter_snapshot() {
8606            let h = crate::instance_handle::InstanceHandle::from_guid(dcps_sample.key);
8607            if filter.is_participant_ignored(h) {
8608                return;
8609            }
8610        }
8611        let _ = sinks.push_participant(&dcps_sample);
8612    }
8613}
8614
8615/// Pushes SEDP events (new pubs/subs) into the 4 builtin-topic
8616/// readers. A new pub/sub produces **two** samples:
8617///
8618/// 1. a `DCPSPublication`/`DCPSSubscription` sample,
8619/// 2. a `DCPSTopic` sample (synthetic from topic name + type name).
8620///
8621/// The native SEDP-topics endpoints (RTPS 2.5 §9.3.2.12 bits 28/29)
8622/// are optional per Spec §8.5.4.4 and covered in ZeroDDS via this
8623/// synthetic derivation — see also
8624/// `endpoint_flag::ALL_STANDARD`, which deliberately omits the
8625/// topics bits. Cyclone/Fast-DDS peers that send their own topic
8626/// announces are ignored (no reader endpoint).
8627fn push_sedp_events_to_builtin_readers(
8628    rt: &Arc<DcpsRuntime>,
8629    events: &zerodds_discovery::sedp::SedpEvents,
8630) {
8631    let Some(sinks) = rt.builtin_sinks_snapshot() else {
8632        return;
8633    };
8634    let filter = rt.ignore_filter_snapshot();
8635    for w in &events.new_publications {
8636        let pub_sample = crate::builtin_topics::PublicationBuiltinTopicData::from_wire(w);
8637        let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_publication(w);
8638        // .7 §2.2.2.2.1.14/.16: consult the participant + publication +
8639        // topic ignore filters.
8640        if let Some(f) = &filter {
8641            let part_h = crate::instance_handle::InstanceHandle::from_guid(w.participant_key);
8642            let pub_h = crate::instance_handle::InstanceHandle::from_guid(w.key);
8643            let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
8644            if f.is_participant_ignored(part_h) || f.is_publication_ignored(pub_h) {
8645                continue;
8646            }
8647            let _ = sinks.push_publication(&pub_sample);
8648            if !f.is_topic_ignored(topic_h) {
8649                let _ = sinks.push_topic(&topic_sample);
8650            }
8651        } else {
8652            let _ = sinks.push_publication(&pub_sample);
8653            let _ = sinks.push_topic(&topic_sample);
8654        }
8655    }
8656    for r in &events.new_subscriptions {
8657        let sub_sample = crate::builtin_topics::SubscriptionBuiltinTopicData::from_wire(r);
8658        let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_subscription(r);
8659        if let Some(f) = &filter {
8660            let part_h = crate::instance_handle::InstanceHandle::from_guid(r.participant_key);
8661            let sub_h = crate::instance_handle::InstanceHandle::from_guid(r.key);
8662            let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
8663            if f.is_participant_ignored(part_h) || f.is_subscription_ignored(sub_h) {
8664                continue;
8665            }
8666            let _ = sinks.push_subscription(&sub_sample);
8667            if !f.is_topic_ignored(topic_h) {
8668                let _ = sinks.push_topic(&topic_sample);
8669            }
8670        } else {
8671            let _ = sinks.push_subscription(&sub_sample);
8672            let _ = sinks.push_topic(&topic_sample);
8673        }
8674    }
8675}
8676
8677/// Binary-property name of the crypto key material in the CryptoToken DataHolder
8678/// (DDS-Security §9.5.2.1.1, cyclone-verified: `dds.cryp.keymat`).
8679#[cfg(feature = "security")]
8680const CRYPTO_TOKEN_PROP: &str = "dds.cryp.keymat";
8681
8682/// CryptoToken `class_id` (§9.5.2.1: `DDS:Crypto:AES_GCM_GMAC` — underscores,
8683/// **not** the plugin-class string with hyphens).
8684#[cfg(feature = "security")]
8685const CRYPTO_TOKEN_CLASS_ID: &str = "DDS:Crypto:AES_GCM_GMAC";
8686
8687/// Builds the `PARTICIPANT_CRYPTO_TOKENS` VolatileSecure message with the
8688/// Kx-encrypted token as a binary property (FU2 S1.4).
8689#[cfg(feature = "security")]
8690fn build_crypto_token_message(
8691    rt: &DcpsRuntime,
8692    remote_prefix: GuidPrefix,
8693    kx_token: Vec<u8>,
8694) -> zerodds_security::generic_message::ParticipantGenericMessage {
8695    use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
8696    use zerodds_security::token::DataHolder;
8697    ParticipantGenericMessage {
8698        message_identity: MessageIdentity {
8699            source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
8700            sequence_number: 1,
8701        },
8702        related_message_identity: MessageIdentity::default(),
8703        destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
8704        destination_endpoint_key: [0; 16],
8705        source_endpoint_key: [0; 16],
8706        message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
8707        message_data: alloc::vec![
8708            DataHolder::new(CRYPTO_TOKEN_CLASS_ID)
8709                .with_binary_property(CRYPTO_TOKEN_PROP, kx_token)
8710        ],
8711    }
8712}
8713
8714/// FU2 S1.4 (send): after handshake completion Kx-encrypt the local data token
8715/// (`gate.local_token`) and send it as
8716/// `PARTICIPANT_CRYPTO_TOKENS` over VolatileSecure.
8717/// Registers the peer's Kx key in the gate beforehand. `None` without a gate
8718/// or on error (drop instead of leak).
8719#[cfg(feature = "security")]
8720fn prepare_crypto_token(
8721    rt: &DcpsRuntime,
8722    remote_prefix: GuidPrefix,
8723    remote_identity: zerodds_security::authentication::IdentityHandle,
8724    secret: zerodds_security::authentication::SharedSecretHandle,
8725) -> Option<zerodds_security::generic_message::ParticipantGenericMessage> {
8726    let gate = rt.config.security.as_ref()?;
8727    let peer_key = remote_prefix.to_bytes();
8728    // ALWAYS register the peer's Kx key — even with rtps=NONE: the per-endpoint
8729    // tokens (discovery_/data_protection) travel Kx-protected over the volatile,
8730    // protect_volatile_datagram needs this key.
8731    gate.register_remote_by_guid_from_secret(peer_key, remote_identity, secret)
8732        .ok()?;
8733    // BUT: send the ParticipantCryptoToken (= SRTPS keymat) ONLY when
8734    // rtps_protection != NONE. With rtps=NONE there is no SRTPS; OpenDDS rejects the
8735    // token (Spdp.cpp:1966 `crypto_handle_==NIL` -> "not configured for RTPS
8736    // Protection", logs `handle_participant_crypto_tokens failed`) and OpenDDS-self
8737    // also does NOT exchange it with rtps=NONE. None here = no participant
8738    // token send; the per-endpoint tokens continue over the separate path.
8739    if gate.rtps_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
8740        return None;
8741    }
8742    // Cross-vendor: the data token travels in PLAINTEXT in the
8743    // ParticipantGenericMessage — it becomes confidential only through the
8744    // SEC_PREFIX/BODY/POSTFIX submessage protection of the whole volatile
8745    // DATA (see protect_volatile_datagram). The `register_*` line above
8746    // created the peer's Kx key in the gate that this protection uses.
8747    let token = gate.local_token().ok()?;
8748    Some(build_crypto_token_message(rt, remote_prefix, token))
8749}
8750
8751/// Per-endpoint crypto handle for a local writer/reader (get-or-register).
8752/// DDS-Security §9.5.3.3: each endpoint has its OWN key material. Registration
8753/// under the write lock (race-free). `None` without an active gate.
8754#[cfg(feature = "security")]
8755fn local_endpoint_crypto_handle(
8756    rt: &DcpsRuntime,
8757    eid: EntityId,
8758    is_writer: bool,
8759) -> Option<zerodds_security::crypto::CryptoHandle> {
8760    let gate = rt.config.security.as_ref()?;
8761    {
8762        let map = rt.endpoint_crypto.read().ok()?;
8763        if let Some(h) = map.get(&eid) {
8764            return Some(*h);
8765        }
8766    }
8767    let mut map = rt.endpoint_crypto.write().ok()?;
8768    if let Some(h) = map.get(&eid) {
8769        return Some(*h);
8770    }
8771    let h = gate.register_local_endpoint(is_writer).ok()?;
8772    map.insert(eid, h);
8773    Some(h)
8774}
8775
8776/// Cross-vendor step 6b (send): per-endpoint `datawriter_crypto_tokens` (for
8777/// every local user writer) + `datareader_crypto_tokens` (for every local
8778/// user reader) to the peer. cyclone needs these to approve the user-endpoint
8779/// match and decode ZeroDDS' user DATA. `source_endpoint_key` = the
8780/// local endpoint GUID; the keymat is the local data key (one key per
8781/// participant in the bench). Empty list without a gate / without user endpoints.
8782#[cfg(feature = "security")]
8783fn prepare_endpoint_crypto_tokens(
8784    rt: &DcpsRuntime,
8785    remote_prefix: GuidPrefix,
8786) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
8787    use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
8788    use zerodds_security::token::DataHolder;
8789    let Some(gate) = rt.config.security.as_ref() else {
8790        return Vec::new();
8791    };
8792    let mut out = Vec::new();
8793    // cyclone associates a datawriter/datareader token via the pair
8794    // (source_endpoint, destination_endpoint). Hence per local endpoint ONE
8795    // token PER matched remote endpoint of **this** peer, with the concrete
8796    // remote GUID as destination_endpoint_key (dst=0 would make cyclone discard it).
8797    //
8798    // §9.5.3.3: the token carries the **per-endpoint** key material of the
8799    // `source_eid` (not the participant key) — the same key with which
8800    // ZeroDDS encodes this endpoint's submessages (protect_user_datagram).
8801    let build = |class: &str,
8802                 source_eid: EntityId,
8803                 dst: [u8; 16]|
8804     -> Option<ParticipantGenericMessage> {
8805        let is_writer = class == class_id::DATAWRITER_CRYPTO_TOKENS;
8806        let handle = local_endpoint_crypto_handle(rt, source_eid, is_writer)?;
8807        let token = gate.create_endpoint_token(handle).ok()?;
8808        // Dual key (metadata != data, meta-sign-data): cyclone expects
8809        // num_key_mat=2 — submessage keymat (metadata kind) + payload keymat
8810        // (data kind) as TWO DataHolders in this order. Single key
8811        // (all other profiles): only the submessage/endpoint keymat.
8812        let mut dhs = alloc::vec![
8813            DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, token)
8814        ];
8815        if let Some(pay) = gate.endpoint_payload_token(handle) {
8816            dhs.push(
8817                DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, pay),
8818            );
8819        }
8820        Some(ParticipantGenericMessage {
8821            message_identity: MessageIdentity {
8822                source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
8823                sequence_number: 1,
8824            },
8825            related_message_identity: MessageIdentity::default(),
8826            destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
8827            destination_endpoint_key: dst,
8828            source_endpoint_key: Guid::new(rt.guid_prefix, source_eid).to_bytes(),
8829            message_class_id: class.into(),
8830            message_data: dhs,
8831        })
8832    };
8833    // datawriter tokens: per local writer for every matched remote reader
8834    // of this peer (dst = reader GUID).
8835    for (weid, warc) in rt.writer_slots_snapshot() {
8836        if let Ok(slot) = warc.lock() {
8837            for proxy in slot.writer.reader_proxies() {
8838                if proxy.remote_reader_guid.prefix == remote_prefix {
8839                    out.extend(build(
8840                        class_id::DATAWRITER_CRYPTO_TOKENS,
8841                        weid,
8842                        proxy.remote_reader_guid.to_bytes(),
8843                    ));
8844                }
8845            }
8846        }
8847    }
8848    // datareader tokens: per local reader for every matched remote writer
8849    // of this peer (dst = writer GUID).
8850    for (reid, rarc) in rt.reader_slots_snapshot() {
8851        if let Ok(slot) = rarc.lock() {
8852            for ws in slot.reader.writer_proxies() {
8853                if ws.proxy.remote_writer_guid.prefix == remote_prefix {
8854                    out.extend(build(
8855                        class_id::DATAREADER_CRYPTO_TOKENS,
8856                        reid,
8857                        ws.proxy.remote_writer_guid.to_bytes(),
8858                    ));
8859                }
8860            }
8861        }
8862    }
8863    // Protected discovery (§8.4.2.4): the secure builtin SEDP endpoints
8864    // (DCPSPublications/SubscriptionsSecure) also need crypto tokens,
8865    // so the peer associates ZeroDDS' data key with them + decodes the secure-SEDP
8866    // submessages. cyclone exchanges these builtin-endpoint tokens
8867    // the same way over the volatile (ff0003c2/c7 + ff0004c2/c7).
8868    if gate
8869        .discovery_protection()
8870        .map(|l| l != ProtectionLevel::None)
8871        .unwrap_or(false)
8872    {
8873        let builtin_pairs = [
8874            (
8875                class_id::DATAWRITER_CRYPTO_TOKENS,
8876                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
8877                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
8878            ),
8879            (
8880                class_id::DATAREADER_CRYPTO_TOKENS,
8881                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
8882                EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
8883            ),
8884            (
8885                class_id::DATAWRITER_CRYPTO_TOKENS,
8886                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
8887                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
8888            ),
8889            (
8890                class_id::DATAREADER_CRYPTO_TOKENS,
8891                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
8892                EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
8893            ),
8894        ];
8895        for (class, src_eid, dst_eid) in builtin_pairs {
8896            out.extend(build(
8897                class,
8898                src_eid,
8899                Guid::new(remote_prefix, dst_eid).to_bytes(),
8900            ));
8901        }
8902    }
8903    // FastDDS interop: the reliable secure-SPDP builtin (DCPSParticipantsSecure,
8904    // ff0101c2/c7) needs per-endpoint crypto tokens when FastDDS SEC-encrypts the secure-
8905    // SPDP DATA under discovery_protection — otherwise the peer cannot
8906    // decode our secure SPDP -> no secure participant discovery ->
8907    // no token reciprocation. Gated on enable_secure_spdp.
8908    if rt.config.enable_secure_spdp {
8909        let spdp_pairs = [
8910            (
8911                class_id::DATAWRITER_CRYPTO_TOKENS,
8912                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
8913                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
8914            ),
8915            (
8916                class_id::DATAREADER_CRYPTO_TOKENS,
8917                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
8918                EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
8919            ),
8920        ];
8921        for (class, src_eid, dst_eid) in spdp_pairs {
8922            out.extend(build(
8923                class,
8924                src_eid,
8925                Guid::new(remote_prefix, dst_eid).to_bytes(),
8926            ));
8927        }
8928    }
8929    // Liveliness protection (§8.4.2.4): the secure-WLP builtin endpoints
8930    // (BuiltinParticipantMessageSecure, ff0200c2/c7) also need per-
8931    // endpoint crypto tokens. cyclone gates the participant security approval
8932    // (and thus the user-endpoint connection) on it — without these tokens
8933    // "connect ... waiting for approval by security" stays hung.
8934    if gate
8935        .liveliness_protection()
8936        .map(|l| l != ProtectionLevel::None)
8937        .unwrap_or(false)
8938    {
8939        let wlp_pairs = [
8940            (
8941                class_id::DATAWRITER_CRYPTO_TOKENS,
8942                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
8943                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
8944            ),
8945            (
8946                class_id::DATAREADER_CRYPTO_TOKENS,
8947                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
8948                EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
8949            ),
8950        ];
8951        for (class, src_eid, dst_eid) in wlp_pairs {
8952            out.extend(build(
8953                class,
8954                src_eid,
8955                Guid::new(remote_prefix, dst_eid).to_bytes(),
8956            ));
8957        }
8958    }
8959    out
8960}
8961
8962/// Dedup key of a per-endpoint crypto token: the pair
8963/// (source_endpoint, destination_endpoint). cyclone associates a
8964/// datawriter/datareader token via exactly this pair (§9.5.3.3), so it is
8965/// also the right granularity to remember which tokens have gone out.
8966#[cfg(feature = "security")]
8967fn endpoint_token_key(
8968    m: &zerodds_security::generic_message::ParticipantGenericMessage,
8969) -> [u8; 32] {
8970    let mut k = [0u8; 32];
8971    k[..16].copy_from_slice(&m.source_endpoint_key);
8972    k[16..].copy_from_slice(&m.destination_endpoint_key);
8973    k
8974}
8975
8976/// Filters out the per-endpoint tokens not yet sent. The previously
8977/// used **per-peer** once-guard was too coarse: it snapped shut as soon as the
8978/// participant/secure-SEDP builtin tokens were out — but user endpoints match
8979/// only later (after the secure SEDP). Their tokens then never went out,
8980/// and the peer could never decode ZeroDDS' user DATA. Per-token dedup
8981/// (peer+source+dest) sends each token exactly once — builtins early,
8982/// user endpoints as soon as they match.
8983#[cfg(feature = "security")]
8984fn pending_endpoint_tokens(
8985    msgs: Vec<zerodds_security::generic_message::ParticipantGenericMessage>,
8986    already_sent: &alloc::collections::BTreeSet<[u8; 32]>,
8987) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
8988    msgs.into_iter()
8989        .filter(|m| !already_sent.contains(&endpoint_token_key(m)))
8990        .collect()
8991}
8992
8993/// FU2 S1.4 (recv): Kx-decrypt an incoming `PARTICIPANT_CRYPTO_TOKENS` message
8994/// and install the peer's data key in the gate.
8995/// Afterwards secured user DATA round-trips with this peer.
8996#[cfg(feature = "security")]
8997fn install_crypto_token(
8998    rt: &DcpsRuntime,
8999    remote_prefix: GuidPrefix,
9000    msg: &zerodds_security::generic_message::ParticipantGenericMessage,
9001) {
9002    use zerodds_security::generic_message::class_id;
9003    // Cross-vendor: cyclone sends the data key both as
9004    // participant_crypto_tokens and per-endpoint as datawriter/
9005    // datareader_crypto_tokens. We install the keymat from all three
9006    // under the sender's participant slot (one user endpoint per participant
9007    // in the bench) — so decode_data_datawriter_from decodes the user DATA.
9008    if msg.message_class_id != class_id::PARTICIPANT_CRYPTO_TOKENS
9009        && msg.message_class_id != class_id::DATAWRITER_CRYPTO_TOKENS
9010        && msg.message_class_id != class_id::DATAREADER_CRYPTO_TOKENS
9011    {
9012        return;
9013    }
9014    let Some(gate) = rt.config.security.as_ref() else {
9015        return;
9016    };
9017    let peer_key = remote_prefix.to_bytes();
9018    // `message_data` is a sequence<DataHolder> (DDS-Security §7.4.4.3
9019    // ParticipantGenericMessage): cyclone packs MULTIPLE CryptoTokens (its own
9020    // key material per endpoint, different transformation_key_id) into ONE
9021    // message. Install ALL — taking only `.first()` lost the
9022    // endpoint keys (key_id 2..N) and the secure SEDP stayed undecodable.
9023    // Plaintext token (confidentiality was provided by the submessage protection of
9024    // the transporting volatile DATA, see unprotect_volatile_datagram).
9025    // DDS-Security §9.5.2 vs §9.5.3: the PARTICIPANT crypto token carries the
9026    // message-level key (SRTPS, decode_secured_rtps_message -> slots[peer]); the
9027    // datawriter/datareader tokens carry per-endpoint data keys that belong ONLY in
9028    // the key_id path (remote_by_key_id, decode_data_by_key_id). Putting both
9029    // into slots[peer] let the last-installed (datareader) overwrite the
9030    // participant key -> message-level SRTPS tag mismatch.
9031    let is_participant = msg.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS;
9032    for dh in &msg.message_data {
9033        if let Some(token) = dh.binary_property(CRYPTO_TOKEN_PROP) {
9034            let _ = if is_participant {
9035                gate.set_remote_data_token_by_guid(&peer_key, token)
9036            } else {
9037                gate.install_remote_endpoint_token(token)
9038            };
9039        }
9040    }
9041}
9042
9043// RTPS submessage IDs for the VolatileSecure submessage-protection surgery.
9044#[cfg(feature = "security")]
9045const SMID_DATA: u8 = 0x15;
9046#[cfg(feature = "security")]
9047const SMID_SEC_PREFIX: u8 = 0x31;
9048#[cfg(feature = "security")]
9049const SMID_SEC_POSTFIX: u8 = 0x32;
9050// Further writer submessage IDs (DDSI-RTPS 2.5 §8.3.7). Per DDS-Security
9051// §8.4.2.4 (is_submessage_protected=TRUE, DataWriter) ALL submessages sent by the
9052// writer — not only DATA — MUST be protected via encode_datawriter_submessage.
9053// HEARTBEAT is the critical one: without it the remote
9054// reader cannot NACK a missing sequence number (= no reliable recovery).
9055#[cfg(feature = "security")]
9056const SMID_HEARTBEAT: u8 = 0x07;
9057#[cfg(feature = "security")]
9058const SMID_GAP: u8 = 0x08;
9059#[cfg(feature = "security")]
9060const SMID_DATA_FRAG: u8 = 0x16;
9061#[cfg(feature = "security")]
9062const SMID_HEARTBEAT_FRAG: u8 = 0x13;
9063// Reader submessages (DDSI-RTPS 2.5 §8.3.7): under `metadata_protection_kind
9064// != NONE` to be protected via `encode_datareader_submessage` (§8.4.2.4) with the per-endpoint
9065// reader key — otherwise a spec-conformant remote writer
9066// (cyclone under discovery=ENCRYPT) discards the clear ACKNACK and never re-sends.
9067#[cfg(feature = "security")]
9068const SMID_ACKNACK: u8 = 0x06;
9069#[cfg(feature = "security")]
9070const SMID_NACK_FRAG: u8 = 0x12;
9071
9072/// `true` if the submessage ID is a submessage sent by the DataReader
9073/// (ACKNACK/NACK_FRAG) — datareader protection path.
9074#[cfg(feature = "security")]
9075fn is_protected_reader_submessage(id: u8) -> bool {
9076    matches!(id, SMID_ACKNACK | SMID_NACK_FRAG)
9077}
9078
9079/// Extracts the `reader_id` (sender) from an ACKNACK/NACK_FRAG submessage:
9080/// offset 4 (after header(4)), directly before the writer_id (offset 8).
9081#[cfg(feature = "security")]
9082fn reader_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
9083    if !is_protected_reader_submessage(id) {
9084        return None;
9085    }
9086    let raw: [u8; 4] = submsg.get(4..8)?.try_into().ok()?;
9087    Some(EntityId::from_bytes(raw))
9088}
9089
9090/// `true` if the submessage ID is a submessage sent by the DataWriter that,
9091/// under `metadata_protection_kind != NONE`, must be protected via `encode_datawriter_submessage`
9092/// (DDS-Security §8.4.2.4). ACKNACK/NACK_FRAG are
9093/// reader submessages (datareader path) and are excluded here.
9094#[cfg(feature = "security")]
9095fn is_protected_writer_submessage(id: u8) -> bool {
9096    matches!(
9097        id,
9098        SMID_DATA | SMID_DATA_FRAG | SMID_HEARTBEAT | SMID_HEARTBEAT_FRAG | SMID_GAP
9099    )
9100}
9101
9102/// Walks the submessages of an RTPS datagram from `offset` and returns
9103/// `(submessage_id, start, total_len)`. `octetsToNextHeader == 0` means
9104/// "to the end of the datagram" (RTPS §8.3.3.2.3).
9105#[cfg(feature = "security")]
9106fn walk_submessages(bytes: &[u8]) -> Vec<(u8, usize, usize)> {
9107    let mut out = Vec::new();
9108    let mut o = 20; // RTPS header
9109    while o + 4 <= bytes.len() {
9110        let id = bytes[o];
9111        let le = bytes[o + 1] & 0x01 != 0;
9112        let raw = if le {
9113            u16::from_le_bytes([bytes[o + 2], bytes[o + 3]])
9114        } else {
9115            u16::from_be_bytes([bytes[o + 2], bytes[o + 3]])
9116        } as usize;
9117        let body = if raw == 0 { bytes.len() - (o + 4) } else { raw };
9118        let total = 4 + body;
9119        if o + total > bytes.len() {
9120            break;
9121        }
9122        out.push((id, o, total));
9123        o += total;
9124    }
9125    out
9126}
9127
9128/// Cross-vendor VolatileSecure (send): replaces every DATA submessage in the
9129/// datagram with the cyclone-conformant `SEC_PREFIX`/`SEC_BODY`/`SEC_POSTFIX`
9130/// sequence (encrypted with the peer's Kx key). Other submessages
9131/// (INFO_DST/INFO_TS/HEARTBEAT) stay unchanged. Returns the datagram
9132/// unchanged if no DATA submessage is present (e.g. a pure
9133/// HEARTBEAT tick). `None` only on a crypto error (drop instead of leak).
9134#[cfg(feature = "security")]
9135fn protect_volatile_datagram(
9136    rt: &DcpsRuntime,
9137    bytes: &[u8],
9138    peer_key: &[u8; 12],
9139) -> Option<Vec<u8>> {
9140    let gate = rt.config.security.as_ref()?;
9141    if bytes.len() < 20 {
9142        return Some(bytes.to_vec());
9143    }
9144    let subs = walk_submessages(bytes);
9145    // DDS-Security §8.4.2.4: ParticipantVolatileMessageSecure is submessage-
9146    // protected — ALL submessages sent by the endpoint MUST be protected with the Kx key,
9147    // not only DATA. This holds for BOTH directions:
9148    //  * writer submessages (DATA, DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP)
9149    //  * reader submessages (ACKNACK, NACK_FRAG)
9150    // cyclone/FastDDS otherwise discard the WHOLE volatile sample with "clear
9151    // submsg from protected src" → the crypto-token exchange over the volatile
9152    // stalls. write_with_heartbeat bundles DATA+HEARTBEAT into ONE datagram; if
9153    // the HEARTBEAT stayed clear, the whole token sample was lost (cross-vendor
9154    // cyclone→ZeroDDS responder).
9155    // The reader ACKNACK: OpenDDS' RtpsUdpReceiveStrategy::check_encoded requires
9156    // protection for the volatile reader (ff0202c4, is_submessage_protected=TRUE) and
9157    // otherwise drops the clear ACKNACK ("Submessage requires protection") → its
9158    // volatile WRITER never gets an ACK → considers the token delivery
9159    // unacknowledged → zerodds NEVER sends the SRTPS-protected secure SEDP → no
9160    // user-endpoint match. The volatile channel uses ONE shared Kx session key
9161    // (KDF from the shared secret, §9.5.3.3.4.4), symmetric for both directions
9162    // → protect the ACKNACK with the same Kx key as the DATA.
9163    if !subs.iter().any(|(id, _, _)| {
9164        is_protected_writer_submessage(*id) || is_protected_reader_submessage(*id)
9165    }) {
9166        return Some(bytes.to_vec()); // no protection-worthy submessage -> unchanged
9167    }
9168    let mut out = Vec::with_capacity(bytes.len() + 64);
9169    out.extend_from_slice(&bytes[..20]);
9170    for (id, start, total) in subs {
9171        let submsg = &bytes[start..start + total];
9172        if is_protected_writer_submessage(id) || is_protected_reader_submessage(id) {
9173            match gate.encode_kx_datawriter_for(peer_key, submsg) {
9174                Ok(sec) => out.extend_from_slice(&sec),
9175                Err(_) => return None, // drop instead of plaintext leak
9176            }
9177        } else {
9178            out.extend_from_slice(submsg);
9179        }
9180    }
9181    Some(out)
9182}
9183
9184/// Cross-vendor VolatileSecure (recv): recognizes a `SEC_PREFIX`/`SEC_BODY`/
9185/// `SEC_POSTFIX` sequence, decodes it with the peer's Kx key to the
9186/// original DATA submessage and builds a plain RTPS datagram for the
9187/// `volatile_reader`. `None` if no SEC_* sequence is present (then the normal
9188/// path) or on a crypto error.
9189#[cfg(feature = "security")]
9190fn unprotect_volatile_datagram(
9191    rt: &DcpsRuntime,
9192    bytes: &[u8],
9193    peer_key: &[u8; 12],
9194) -> Option<Vec<u8>> {
9195    let gate = rt.config.security.as_ref()?;
9196    if bytes.len() < 20 {
9197        return None;
9198    }
9199    let subs = walk_submessages(bytes);
9200    // Cyclone/FastDDS bundle, via xpack, MULTIPLE SEC_*-protected volatile
9201    // submessages (all with the Kx key) into ONE datagram. So there can be
9202    // multiple SEC_PREFIX/BODY/POSTFIX triples — transform ALL back
9203    // (like unprotect_user_datagram). Decoding only the first block (an earlier
9204    // bug) left every bundled token sample after the first encrypted;
9205    // the VOLATILE writer does not retransmit them → deterministic
9206    // token loss (no "flaky" transport, all same-host). `None` if there is no
9207    // SEC_PREFIX at all (plaintext) or the Kx decode fails (= not a volatile datagram,
9208    // e.g. secure SEDP with a per-endpoint key).
9209    if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
9210        return None;
9211    }
9212    let mut out = Vec::with_capacity(bytes.len());
9213    out.extend_from_slice(&bytes[..20]);
9214    let mut i = 0;
9215    while i < subs.len() {
9216        let (id, start, total) = subs[i];
9217        if id == SMID_SEC_PREFIX {
9218            let postfix_idx = subs[i..]
9219                .iter()
9220                .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
9221                .map(|off| i + off)?;
9222            let (_, q_start, q_total) = subs[postfix_idx];
9223            let sec_wire = &bytes[start..q_start + q_total];
9224            let submsg = gate.decode_kx_datawriter_from(peer_key, sec_wire).ok()?;
9225            out.extend_from_slice(&submsg);
9226            i = postfix_idx + 1;
9227        } else {
9228            out.extend_from_slice(&bytes[start..start + total]);
9229            i += 1;
9230        }
9231    }
9232    Some(out)
9233}
9234
9235/// Protects a peer's volatile outbound datagrams (DATA -> SEC_*).
9236/// HEARTBEAT/ACKNACK datagrams (without DATA) stay unchanged; datagrams
9237/// with a crypto error are dropped.
9238#[cfg(feature = "security")]
9239fn protect_volatile_outbound(
9240    rt: &DcpsRuntime,
9241    remote_prefix: GuidPrefix,
9242    dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>,
9243) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
9244    let peer_key = remote_prefix.to_bytes();
9245    dgs.into_iter()
9246        .filter_map(|dg| {
9247            protect_volatile_datagram(rt, &dg.bytes, &peer_key).map(|bytes| {
9248                zerodds_rtps::message_builder::OutboundDatagram {
9249                    bytes,
9250                    targets: dg.targets,
9251                }
9252            })
9253        })
9254        .collect()
9255}
9256
9257/// Cross-vendor (send): replaces EVERY submessage sent by the DataWriter (DATA,
9258/// DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP) with the cyclone-conformant
9259/// SEC_PREFIX/BODY/POSTFIX sequence, encrypted with the **local data key**.
9260/// DDS-Security §8.4.2.4 (`is_submessage_protected=TRUE`, DataWriter): ALL
9261/// writer submessages MUST be protected via `encode_datawriter_submessage`
9262/// — in particular the HEARTBEAT, otherwise the remote reader cannot NACK missing
9263/// sequence numbers (no reliable recovery). Framing submessages
9264/// (INFO_TS/INFO_DST/...) stay unchanged; `None` on a crypto error.
9265#[cfg(feature = "security")]
9266fn protect_user_datagram(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
9267    let gate = rt.config.security.as_ref()?;
9268    if bytes.len() < 20 {
9269        return Some(bytes.to_vec());
9270    }
9271    let subs = walk_submessages(bytes);
9272    if !subs
9273        .iter()
9274        .any(|(id, _, _)| is_protected_writer_submessage(*id))
9275    {
9276        return Some(bytes.to_vec());
9277    }
9278    // §9.5.3.3 per-endpoint key: all writer submessages of a datagram
9279    // come from the same writer. Take the writer_id from the first protected
9280    // submessage + look up the per-endpoint handle. No handle
9281    // (unregistered endpoint) → participant-key fallback.
9282    let endpoint_handle = subs
9283        .iter()
9284        .find(|(id, _, _)| is_protected_writer_submessage(*id))
9285        .and_then(|&(id, start, total)| writer_eid_in_submessage(&bytes[start..start + total], id))
9286        .and_then(|weid| local_endpoint_crypto_handle(rt, weid, true));
9287    let mut out = Vec::with_capacity(bytes.len() + 64);
9288    out.extend_from_slice(&bytes[..20]);
9289    for (id, start, total) in subs {
9290        let submsg = &bytes[start..start + total];
9291        if is_protected_writer_submessage(id) {
9292            let sec = match endpoint_handle {
9293                Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
9294                None => gate.encode_data_datawriter_local(submsg),
9295            };
9296            match sec {
9297                Ok(s) => out.extend_from_slice(&s),
9298                Err(_) => return None,
9299            }
9300        } else {
9301            out.extend_from_slice(submsg);
9302        }
9303    }
9304    Some(out)
9305}
9306
9307/// Extracts the `writer_id` from an RTPS writer submessage. DATA/DATA_FRAG:
9308/// offset 12 (header(4)+extraFlags(2)+octetsToInlineQos(2)+readerId(4));
9309/// HEARTBEAT/GAP/HEARTBEAT_FRAG: offset 8 (header(4)+readerId(4)).
9310#[cfg(feature = "security")]
9311fn writer_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
9312    let off = match id {
9313        SMID_DATA | SMID_DATA_FRAG => 12,
9314        SMID_HEARTBEAT | SMID_GAP | SMID_HEARTBEAT_FRAG => 8,
9315        _ => return None,
9316    };
9317    let raw: [u8; 4] = submsg.get(off..off + 4)?.try_into().ok()?;
9318    Some(EntityId::from_bytes(raw))
9319}
9320
9321/// Cross-vendor user DATA (recv): decodes the SEC_* sequence with the sender's
9322/// data key (`peer_key` = sender GuidPrefix) back to the DATA submessage.
9323/// `None` if no SEC_* sequence is present (normal path) or on a crypto error.
9324#[cfg(feature = "security")]
9325fn unprotect_user_datagram(rt: &DcpsRuntime, bytes: &[u8], peer_key: &[u8; 12]) -> Option<Vec<u8>> {
9326    let gate = rt.config.security.as_ref()?;
9327    if bytes.len() < 20 {
9328        return None;
9329    }
9330    let subs = walk_submessages(bytes);
9331    // §8.4.2.4: the peer SEC_*-wrapped EVERY writer submessage individually
9332    // (DATA, HEARTBEAT, GAP, ...). So there can be MULTIPLE SEC_PREFIX/BODY/
9333    // POSTFIX triples in the same datagram — transform them all back. `None`
9334    // only if there is no SEC_* sequence at all (normal/plaintext path).
9335    if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
9336        return None;
9337    }
9338    let mut out = Vec::with_capacity(bytes.len());
9339    out.extend_from_slice(&bytes[..20]);
9340    let mut i = 0;
9341    while i < subs.len() {
9342        let (id, start, total) = subs[i];
9343        if id == SMID_SEC_PREFIX {
9344            // Find the matching SEC_POSTFIX from i; the block is [prefix..postfix].
9345            let postfix_idx = subs[i..]
9346                .iter()
9347                .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
9348                .map(|off| i + off)?;
9349            let (_, q_start, q_total) = subs[postfix_idx];
9350            let sec_wire = &bytes[start..q_start + q_total];
9351            // key_id-based decode: the peer has, per endpoint (user +
9352            // secure-builtin discovery), its own key material; the correct
9353            // key is found via the transformation_key_id in the CryptoHeader.
9354            // Fallback for transformation_key_id=0: this is NOT a per-
9355            // endpoint token key, but the participant-level key derived from the
9356            // SharedSecret (DDS-Security Tab.73, AES256-GCM, sender_key_id
9357            // =0) — cyclone protects with it under rtps_protection. That one is decoded by the
9358            // Kx path (peer-prefix-indexed SharedSecret key).
9359            let mut submsg = gate
9360                .decode_data_by_key_id(sec_wire)
9361                .or_else(|_| gate.decode_data_datawriter_from(peer_key, sec_wire))
9362                .or_else(|_| gate.decode_kx_datawriter_from(peer_key, sec_wire))
9363                .ok()?;
9364            // Correct octetsToNextHeader to the real body length: cyclone
9365            // wraps every writer submessage INDIVIDUALLY; within its SEC_BODY
9366            // it is the last one -> octetsToNextHeader=0 ("to the end of the message").
9367            // When concatenating multiple decoded blocks (e.g. DATA + piggybacked
9368            // HEARTBEAT), otn=0 would make the strict decode_datagram swallow the following
9369            // submessage as payload -> the reader would never see the
9370            // HEARTBEAT and would block as a late joiner on the SN gap.
9371            if submsg.len() >= 4 {
9372                let le = submsg[1] & zerodds_rtps::FLAG_E_LITTLE_ENDIAN != 0;
9373                let otn = u16::try_from(submsg.len() - 4).unwrap_or(0);
9374                let b = if le {
9375                    otn.to_le_bytes()
9376                } else {
9377                    otn.to_be_bytes()
9378                };
9379                submsg[2] = b[0];
9380                submsg[3] = b[1];
9381            }
9382            out.extend_from_slice(&submsg);
9383            i = postfix_idx + 1;
9384        } else {
9385            out.extend_from_slice(&bytes[start..start + total]);
9386            i += 1;
9387        }
9388    }
9389    Some(out)
9390}
9391
9392/// §8.5.1.9.1 / §9.5.3.3.1 data_protection (send): encrypts ONLY the
9393/// SerializedPayload INSIDE each DATA submessage (payload layer). The
9394/// submessage header, octetsToInlineQos, InlineQoS and the flags (E/Q/D/K)
9395/// stay byte-identical; only the N-flag (NonStandardPayload, §8.3.8.2) is
9396/// set and octetsToNextHeader adjusted to the new payload length. This is
9397/// the spec-conformant + cyclone-interop form of data_protection (counterpart:
9398/// metadata_protection = whole submessage SEC_*-wrapped). Applied as the INNER
9399/// layer BEFORE the submessage/message protection. `None` on a
9400/// crypto error (drop instead of leak); a datagram without DATA stays unchanged.
9401#[cfg(feature = "security")]
9402fn protect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
9403    use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
9404    use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
9405    let gate = rt.config.security.as_ref()?;
9406    if bytes.len() < 20 {
9407        return Some(bytes.to_vec());
9408    }
9409    let subs = walk_submessages(bytes);
9410    if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
9411        return Some(bytes.to_vec());
9412    }
9413    let mut out = Vec::with_capacity(bytes.len() + 64);
9414    out.extend_from_slice(&bytes[..20]);
9415    for (id, start, total) in subs {
9416        let submsg = &bytes[start..start + total];
9417        if id != SMID_DATA {
9418            out.extend_from_slice(submsg);
9419            continue;
9420        }
9421        let flags = submsg[1];
9422        let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
9423        // data_protection payload key: the **per-endpoint DataWriter key**
9424        // (§9.5.3.3.1). cyclone associates the DataWriter strictly with its
9425        // datawriter_crypto_handle and decodes the SerializedPayload ONLY with
9426        // this key — the participant key yields "Invalid Crypto
9427        // Handle" in cyclone. The key is sent to the peer as a datawriter_crypto_token;
9428        // the reader finds it via the transformation_key_id in the CryptoHeader.
9429        let handle = writer_eid_in_submessage(submsg, id)
9430            .and_then(|w| local_endpoint_crypto_handle(rt, w, true))?;
9431        // Payload boundary: read_body_with_flags returns serialized_payload as
9432        // an Arc of body[pos..] -> payload = the last plen bytes of the submessage.
9433        let body = &submsg[4..];
9434        let ds = DataSubmessage::read_body_with_flags(body, le, flags).ok()?;
9435        let plen = ds.serialized_payload.len();
9436        let payload_off = submsg.len() - plen;
9437        let enc = gate
9438            .encode_serialized_payload(handle, &ds.serialized_payload)
9439            .ok()?;
9440        let new_body_len = (payload_off - 4) + enc.len();
9441        if new_body_len > u16::MAX as usize {
9442            return None;
9443        }
9444        out.push(submsg[0]);
9445        out.push(flags | DATA_FLAG_NON_STANDARD);
9446        let otn = new_body_len as u16;
9447        if le {
9448            out.extend_from_slice(&otn.to_le_bytes());
9449        } else {
9450            out.extend_from_slice(&otn.to_be_bytes());
9451        }
9452        // Body prefix (extraFlags..InlineQoS) verbatim, then encrypted payload.
9453        out.extend_from_slice(&submsg[4..payload_off]);
9454        out.extend_from_slice(&enc);
9455    }
9456    Some(out)
9457}
9458
9459/// Result of the inner payload layer on receipt (§8.5.1.9.4).
9460#[cfg(feature = "security")]
9461enum PayloadDecode {
9462    /// No DATA submessage carries the N-flag — plaintext path, pass the datagram
9463    /// on unchanged.
9464    NotEncrypted,
9465    /// Successfully decrypted — use the plaintext datagram.
9466    Decoded(Vec<u8>),
9467    /// N-flag set, but decryption failed. The datagram MUST
9468    /// be discarded — passing an undecodable encrypted payload as
9469    /// ciphertext gives the reader garbage (§8.5: reject). The
9470    /// reliable re-send catches up on the sample once the key is installed
9471    /// resp. another (e.g. inproc/message-level) copy delivers it.
9472    Failed,
9473}
9474
9475/// `true` if the SerializedPayload begins with a CryptoHeader (§9.5.3.3.1):
9476/// the first 4 bytes are a CryptoTransformKind != NONE
9477/// (AES128_GMAC/GCM, AES256_GMAC/GCM = `[0,0,0,1..=4]`). A plaintext CDR
9478/// encapsulation carries either a different first byte pair (CDR_LE `[0,1]`,
9479/// XCDR2 `[0,6/7]`, PL_CDR `[0,2/3]`) or — for CDR_BE `[0,0]` — options
9480/// `[0,0]`, so it does not collide with the transform kinds 1..=4. Serves as
9481/// detection for vendors (cyclone) that encrypt the data_protection payload
9482/// without setting the N-flag of the DATA submessage.
9483#[cfg(feature = "security")]
9484fn payload_has_crypto_header(payload: &[u8]) -> bool {
9485    matches!(payload, [0, 0, 0, 1..=4, ..])
9486}
9487
9488/// §8.5.1.9.4 / §9.5.3.3.1 data_protection (recv): decrypts the
9489/// SerializedPayload of each DATA submessage whose payload begins with a CryptoHeader
9490/// — recognized by the set N-flag (zero↔zero, [`protect_user_payload`])
9491/// OR by the CryptoTransformKind signature (cyclone does not set the N-flag).
9492/// The tag verification of the GCM open IS the detection: if the decode fails
9493/// and the N-flag was not set, the submessage is passed through as plaintext
9494/// (false positive of the signature heuristic). The key is found via the
9495/// `transformation_key_id` (key_id), the sender prefix (peer slot) or — for
9496/// key_id=0 (participant/Kx key, cyclone) — the Kx key material.
9497/// `NotEncrypted` if no DATA submessage was decrypted; `Failed` only
9498/// on an N-flag decode error (§8.5: reject undecryptable).
9499#[cfg(feature = "security")]
9500fn unprotect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> PayloadDecode {
9501    use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
9502    use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
9503    let Some(gate) = rt.config.security.as_ref() else {
9504        return PayloadDecode::NotEncrypted;
9505    };
9506    if bytes.len() < 20 {
9507        return PayloadDecode::NotEncrypted;
9508    }
9509    // Sender prefix (RTPS header bytes[8..20]) as a fallback key index, if the
9510    // transformation_key_id in the CryptoHeader is not uniquely in the remote index
9511    // (zero↔zero indexed via the peer slot, cyclone strictly via key_id).
9512    let mut peer_key = [0u8; 12];
9513    peer_key.copy_from_slice(&bytes[8..20]);
9514    let subs = walk_submessages(bytes);
9515    let mut out = Vec::with_capacity(bytes.len());
9516    out.extend_from_slice(&bytes[..20]);
9517    let mut did_decode = false;
9518    for (id, start, total) in subs {
9519        let submsg = &bytes[start..start + total];
9520        if id != SMID_DATA {
9521            out.extend_from_slice(submsg);
9522            continue;
9523        }
9524        let flags = submsg[1];
9525        let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
9526        let nflag = flags & DATA_FLAG_NON_STANDARD != 0;
9527        let body = &submsg[4..];
9528        let Ok(ds) = DataSubmessage::read_body_with_flags(body, le, flags) else {
9529            // Parse error of a DATA marked as encrypted -> drop;
9530            // a pure plaintext DATA never made read_body_with_flags fail,
9531            // so a set N-flag is the only reason here.
9532            if nflag {
9533                return PayloadDecode::Failed;
9534            }
9535            out.extend_from_slice(submsg);
9536            continue;
9537        };
9538        // Only attempt when the payload is recognizable as encrypted:
9539        // N-flag (zero↔zero) or CryptoHeader signature (cyclone without an N-flag).
9540        if !nflag && !payload_has_crypto_header(&ds.serialized_payload) {
9541            out.extend_from_slice(submsg);
9542            continue;
9543        }
9544        let plen = ds.serialized_payload.len();
9545        let payload_off = submsg.len() - plen;
9546        let pdec = gate
9547            .decode_serialized_payload(&ds.serialized_payload)
9548            .or_else(|_| gate.decode_serialized_payload_from(&peer_key, &ds.serialized_payload))
9549            .or_else(|_| gate.decode_serialized_payload_kx(&peer_key, &ds.serialized_payload));
9550        let Ok(dec) = pdec else {
9551            // §8.5: if the N-flag was set, the payload is surely encrypted
9552            // and the reader would get garbage -> drop (reliable re-send catches it
9553            // up after key install). If only the signature heuristic was the trigger
9554            // (no N-flag), it is a plaintext CDR_BE payload whose options
9555            // happen to look like a TransformKind -> pass through unchanged.
9556            if nflag {
9557                return PayloadDecode::Failed;
9558            }
9559            out.extend_from_slice(submsg);
9560            continue;
9561        };
9562        let new_body_len = (payload_off - 4) + dec.len();
9563        if new_body_len > u16::MAX as usize {
9564            return PayloadDecode::Failed;
9565        }
9566        out.push(submsg[0]);
9567        out.push(flags & !DATA_FLAG_NON_STANDARD);
9568        let otn = new_body_len as u16;
9569        if le {
9570            out.extend_from_slice(&otn.to_le_bytes());
9571        } else {
9572            out.extend_from_slice(&otn.to_be_bytes());
9573        }
9574        out.extend_from_slice(&submsg[4..payload_off]);
9575        out.extend_from_slice(&dec);
9576        did_decode = true;
9577    }
9578    if did_decode {
9579        PayloadDecode::Decoded(out)
9580    } else {
9581        PayloadDecode::NotEncrypted
9582    }
9583}
9584
9585/// `true` if the EntityId is one of the four secure-SEDP discovery endpoints
9586/// (DCPSPublicationsSecure/DCPSSubscriptionsSecure, EntityIds ff0003c2/c7 +
9587/// ff0004c2/c7). Controls whether a SEDP datagram is protected-discovery traffic
9588/// and must be SEC_*-protected (DDS-Security §8.4.2.4).
9589#[cfg(feature = "security")]
9590fn is_secure_sedp_entity(e: EntityId) -> bool {
9591    e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER
9592        || e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER
9593        || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER
9594        || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER
9595}
9596
9597/// `true` if the datagram carries a submessage to/from a secure-SEDP endpoint
9598/// — then it is protected-discovery traffic.
9599#[cfg(feature = "security")]
9600fn is_secure_sedp_datagram(bytes: &[u8]) -> bool {
9601    let Ok(parsed) = decode_datagram(bytes) else {
9602        return false;
9603    };
9604    parsed.submessages.iter().any(|s| {
9605        let ids = match s {
9606            ParsedSubmessage::Data(d) => [Some(d.writer_id), Some(d.reader_id)],
9607            ParsedSubmessage::DataFrag(d) => [Some(d.writer_id), Some(d.reader_id)],
9608            ParsedSubmessage::Heartbeat(h) => [Some(h.writer_id), Some(h.reader_id)],
9609            ParsedSubmessage::Gap(g) => [Some(g.writer_id), Some(g.reader_id)],
9610            ParsedSubmessage::AckNack(a) => [Some(a.writer_id), Some(a.reader_id)],
9611            ParsedSubmessage::NackFrag(n) => [Some(n.writer_id), Some(n.reader_id)],
9612            _ => [None, None],
9613        };
9614        ids.into_iter().flatten().any(is_secure_sedp_entity)
9615    })
9616}
9617
9618/// Protected discovery (DDS-Security §8.4.2.4) send: secure-SEDP datagrams
9619/// (DATA/HEARTBEAT/GAP of the secure writers) are
9620/// `encode_datawriter_submessage`-protected with the participant data key — the same key the peer installs via
9621/// `participant_crypto_tokens`. Non-secure SEDP goes through unchanged.
9622/// `None` ⟹ crypto error on secure SEDP → drop the datagram instead of a
9623/// plaintext leak.
9624#[cfg(feature = "security")]
9625fn protect_sedp_outbound(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
9626    let Some(gate) = rt.config.security.as_ref() else {
9627        return Some(bytes.to_vec());
9628    };
9629    if !is_secure_sedp_datagram(bytes) || bytes.len() < 20 {
9630        return Some(bytes.to_vec());
9631    }
9632    // Governance §8.4.2.4: discovery_protection_kind=NONE -> NO discovery
9633    // protection. Secure-SEDP entities (ff0003c7/ff0004c7) must then NOT
9634    // be per-endpoint-protected; otherwise their ACKNACKs leak as message-
9635    // level SEC_PREFIX with a never-exchanged per-endpoint key that a
9636    // peer (cyclone uses plain SEDP under discovery=NONE) discards as "Invalid Crypto
9637    // Handle". Pass through plain -> the outer rtps_protection
9638    // layer (SRTPS via secure_outbound_bytes) wraps the whole message correctly.
9639    if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
9640        return Some(bytes.to_vec());
9641    }
9642    // §8.4.2.4: protect BOTH directions — writer submessages (DATA/HEARTBEAT/
9643    // GAP) with the per-endpoint writer key (encode_datawriter_submessage), reader
9644    // submessages (ACKNACK/NACK_FRAG) with the per-endpoint reader key
9645    // (encode_datareader_submessage). A spec-conformant cyclone under
9646    // discovery=ENCRYPT discards a CLEAR ACKNACK of the secure-SEDP reader →
9647    // never re-sends the SubscriptionData → ZeroDDS never discovers the reader. The
9648    // per-endpoint key (same as in the sent datareader_crypto_token)
9649    // makes the ACKNACK decodable for cyclone.
9650    let subs = walk_submessages(bytes);
9651    let mut out = Vec::with_capacity(bytes.len() + 64);
9652    out.extend_from_slice(&bytes[..20]);
9653    for (id, start, total) in subs {
9654        let submsg = &bytes[start..start + total];
9655        let handle = if is_protected_writer_submessage(id) {
9656            writer_eid_in_submessage(submsg, id)
9657                .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
9658        } else if is_protected_reader_submessage(id) {
9659            reader_eid_in_submessage(submsg, id)
9660                .and_then(|r| local_endpoint_crypto_handle(rt, r, false))
9661        } else {
9662            // Framing submessage (INFO_TS/INFO_DST/...) — unchanged.
9663            out.extend_from_slice(submsg);
9664            continue;
9665        };
9666        let sec = match handle {
9667            Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
9668            // No per-endpoint handle (should not occur for secure SEDP)
9669            // → participant-key fallback, so no plaintext leak arises.
9670            None => gate.encode_data_datawriter_local(submsg),
9671        };
9672        match sec {
9673            Ok(s) => out.extend_from_slice(&s),
9674            Err(_) => return None,
9675        }
9676    }
9677    Some(out)
9678}
9679
9680/// Protects a user-reader outbound datagram (ACKNACK/NACK_FRAG) on the
9681/// send direction (DDS-Security §8.4.2.4). Counterpart to the writer-DATA layer:
9682/// under `metadata_protection != NONE` the reader submessage too MUST be protected with the
9683/// per-endpoint reader key, otherwise a spec-strict
9684/// peer writer (cyclone/FastDDS) discards the CLEAR ACKNACK → the SN gap is never
9685/// re-sent → permanent reliable stall. Only needed when
9686/// **rtps_protection** does NOT already wrap the message as an SRTPS whole; otherwise
9687/// (and with metadata=NONE) the function delegates to `secure_outbound_bytes`.
9688#[cfg(feature = "security")]
9689fn protect_user_reader_datagram<'a>(
9690    rt: &DcpsRuntime,
9691    bytes: &'a [u8],
9692) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9693    let Some(gate) = rt.config.security.as_ref() else {
9694        return Some(alloc::borrow::Cow::Borrowed(bytes));
9695    };
9696    let metadata = gate.metadata_protection().unwrap_or(ProtectionLevel::None);
9697    let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
9698    // rtps != None → SRTPS wraps the whole message incl. ACKNACK; metadata ==
9699    // None → no submessage protection configured. secure_outbound_bytes
9700    // (transform_outbound) covers both cases correctly.
9701    if metadata == ProtectionLevel::None || rtps != ProtectionLevel::None || bytes.len() < 20 {
9702        return secure_outbound_bytes(rt, bytes);
9703    }
9704    let subs = walk_submessages(bytes);
9705    let mut out = Vec::with_capacity(bytes.len() + 64);
9706    out.extend_from_slice(&bytes[..20]);
9707    for (id, start, total) in subs {
9708        let submsg = &bytes[start..start + total];
9709        if is_protected_reader_submessage(id) {
9710            let handle = reader_eid_in_submessage(submsg, id)
9711                .and_then(|r| local_endpoint_crypto_handle(rt, r, false));
9712            match handle {
9713                Some(h) => match gate.encode_data_datawriter_by_handle(h, submsg) {
9714                    Ok(s) => out.extend_from_slice(&s),
9715                    Err(_) => return None,
9716                },
9717                // No per-endpoint reader key yet (the endpoint matches only after
9718                // secure SEDP) → pass through plaintext; the reader tick re-sends
9719                // the ACKNACK once the key is installed.
9720                None => out.extend_from_slice(submsg),
9721            }
9722        } else {
9723            // Framing submessage (INFO_DST/INFO_TS/...) — unchanged.
9724            out.extend_from_slice(submsg);
9725        }
9726    }
9727    Some(alloc::borrow::Cow::Owned(out))
9728}
9729
9730#[cfg(not(feature = "security"))]
9731fn protect_user_reader_datagram<'a>(
9732    rt: &DcpsRuntime,
9733    bytes: &'a [u8],
9734) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9735    secure_outbound_bytes(rt, bytes)
9736}
9737
9738/// `true` if `liveliness_protection != NONE` is configured — then WLP runs
9739/// over the secure entity + participant-key protection (§8.4.2.4).
9740#[cfg(feature = "security")]
9741fn wlp_liveliness_protected(rt: &DcpsRuntime) -> bool {
9742    rt.config.security.as_ref().is_some_and(|gate| {
9743        gate.liveliness_protection()
9744            .unwrap_or(ProtectionLevel::None)
9745            != ProtectionLevel::None
9746    })
9747}
9748
9749#[cfg(not(feature = "security"))]
9750fn wlp_liveliness_protected(_rt: &DcpsRuntime) -> bool {
9751    false
9752}
9753
9754/// Protects a WLP outbound datagram (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER
9755/// DATA) under `liveliness_protection != NONE` with the **participant data key**
9756/// (§8.4.2.4 / §7.4.7.1 Tab.7). WLP is participant-level (no per-endpoint key)
9757/// — analogous to the participant-key fallback in `protect_sedp_outbound`. If
9758/// `rtps_protection` already covers the message as SRTPS (or liveliness=NONE),
9759/// the function delegates to `secure_outbound_bytes`.
9760#[cfg(feature = "security")]
9761fn protect_wlp_outbound<'a>(
9762    rt: &DcpsRuntime,
9763    bytes: &'a [u8],
9764) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9765    let Some(gate) = rt.config.security.as_ref() else {
9766        return Some(alloc::borrow::Cow::Borrowed(bytes));
9767    };
9768    let live = gate
9769        .liveliness_protection()
9770        .unwrap_or(ProtectionLevel::None);
9771    let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
9772    // liveliness=NONE: no inner SEC layer -> secure_outbound_bytes covers
9773    // rtps_protection (SRTPS) resp. passthrough. PREVIOUSLY this branch
9774    // also delegated with rtps!=None and thus left out the liveliness SEC -> cyclone
9775    // saw the WLP DATA "clear submsg from protected src" -> no liveliness.
9776    if live == ProtectionLevel::None || bytes.len() < 20 {
9777        return secure_outbound_bytes(rt, bytes);
9778    }
9779    let subs = walk_submessages(bytes);
9780    let mut out = Vec::with_capacity(bytes.len() + 64);
9781    out.extend_from_slice(&bytes[..20]);
9782    for (id, start, total) in subs {
9783        let submsg = &bytes[start..start + total];
9784        if id == SMID_DATA {
9785            // Protect the secure-WLP DATA with the per-endpoint key of the secure-WLP writer
9786            // (ff0200c2) — the same key ZeroDDS sends the peer via the
9787            // datawriter_crypto_token (prepare_endpoint_crypto_tokens
9788            // liveliness block). encode_data_datawriter_local took the participant
9789            // key, which cyclone does NOT associate with ff0200c2 -> undecodable ->
9790            // no liveliness -> peer approval of the user endpoints hangs.
9791            let sec = writer_eid_in_submessage(submsg, id)
9792                .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
9793                .and_then(|h| gate.encode_data_datawriter_by_handle(h, submsg).ok());
9794            match sec {
9795                Some(s) => out.extend_from_slice(&s),
9796                None => return None,
9797            }
9798        } else {
9799            out.extend_from_slice(submsg);
9800        }
9801    }
9802    // Under additional rtps_protection, message-level SRTPS MUST go around the
9803    // liveliness-SEC-wrapped WLP (both layers, like cyclone<->cyclone) —
9804    // otherwise cyclone would see only the SRTPS shell OR (with the old logic) the
9805    // clear DATA. First inner SEC (above), then SRTPS (here).
9806    if rtps != ProtectionLevel::None {
9807        return gate
9808            .transform_outbound(&out)
9809            .ok()
9810            .map(alloc::borrow::Cow::Owned);
9811    }
9812    Some(alloc::borrow::Cow::Owned(out))
9813}
9814
9815#[cfg(not(feature = "security"))]
9816fn protect_wlp_outbound<'a>(
9817    rt: &DcpsRuntime,
9818    bytes: &'a [u8],
9819) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9820    secure_outbound_bytes(rt, bytes)
9821}
9822
9823/// Wire demux for the security builtin topics. Routes an
9824/// incoming RTPS submessage sequence to the `SecurityBuiltinStack`,
9825/// if the stack is active. No-op if the datagram does not address a security
9826/// builtin reader or the plugin is not enabled.
9827///
9828/// Called by the metatraffic receive path — stateless +
9829/// VolatileSecure run over the SPDP unicast locators (PID 0x0032),
9830/// not over `user_unicast`.
9831fn dispatch_security_builtin_datagram(
9832    rt: &Arc<DcpsRuntime>,
9833    bytes: &[u8],
9834    now: Duration,
9835) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
9836    // `mut` only needed on the security path (the handshake reply is appended
9837    // there); without the feature the list stays empty.
9838    #[cfg(feature = "security")]
9839    let mut outbound = Vec::new();
9840    #[cfg(not(feature = "security"))]
9841    let outbound = Vec::new();
9842    let Some(stack) = rt.security_builtin_snapshot() else {
9843        return outbound;
9844    };
9845    // Cross-vendor VolatileSecure: cyclone protects the volatile DATA as a
9846    // SEC_PREFIX/SEC_BODY/SEC_POSTFIX sequence. Before the submessage parse,
9847    // transform the sequence with the sender's Kx key (GuidPrefix = RTPS header bytes[8..20])
9848    // back to the original DATA submessage. `None` = no SEC_*
9849    // sequence (normal path) resp. crypto error.
9850    #[cfg(feature = "security")]
9851    let unprotected: Option<Vec<u8>> = if bytes.len() >= 20 {
9852        let mut pk = [0u8; 12];
9853        pk.copy_from_slice(&bytes[8..20]);
9854        unprotect_volatile_datagram(rt, bytes, &pk)
9855    } else {
9856        None
9857    };
9858    #[cfg(feature = "security")]
9859    let bytes: &[u8] = unprotected.as_deref().unwrap_or(bytes);
9860    let Ok(parsed) = decode_datagram(bytes) else {
9861        return outbound;
9862    };
9863    // sourceGuidPrefix of the datagram (DDSI-RTPS §8.3.4) — reader demux key for
9864    // the volatile builtin readers. Used in both feature configs.
9865    let remote_prefix = parsed.header.guid_prefix;
9866    let Ok(mut s) = stack.lock() else {
9867        return outbound;
9868    };
9869    for sub in parsed.submessages {
9870        match sub {
9871            ParsedSubmessage::Data(d) => {
9872                if d.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
9873                    || d.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
9874                {
9875                    // FU2 Gap 5: decode the stateless auth and — with
9876                    // an active auth plugin — drive the handshake.
9877                    // `on_stateless_message` returns the next token
9878                    // message (reply/final), which we send back to the peer.
9879                    // Decode errors are swallowed (stateless
9880                    // has no resend path, Spec §10.3.4.1). The
9881                    // completion `(remote_identity, secret)` is stored in the stack
9882                    // (peer_secret) — the gate registration +
9883                    // crypto-token exchange follows in Gap 6.
9884                    if let Ok(msg) = s.stateless_reader.handle_data(&d) {
9885                        #[cfg(feature = "security")]
9886                        s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
9887                        #[cfg(feature = "security")]
9888                        if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, &msg) {
9889                            outbound.extend(out);
9890                            // FU2 S1.4: handshake done → register Kx +
9891                            // send the Kx-encrypted data token to the peer over Volatile-
9892                            // Secure. (the pki lock is free here:
9893                            // on_stateless_message released it.)
9894                            if let Some((remote_identity, secret)) = completed {
9895                                if let Some(token_msg) =
9896                                    prepare_crypto_token(rt, remote_prefix, remote_identity, secret)
9897                                {
9898                                    outbound.extend(protect_volatile_outbound(
9899                                        rt,
9900                                        remote_prefix,
9901                                        s.volatile_writer
9902                                            .write_with_heartbeat(&token_msg, now)
9903                                            .unwrap_or_default(),
9904                                    ));
9905                                }
9906                                // Step 6b: per-endpoint datawriter/datareader
9907                                // tokens (per-token dedup #29: the builtins go out
9908                                // here exactly once + are marked).
9909                                let already = rt
9910                                    .endpoint_tokens_sent
9911                                    .read()
9912                                    .map(|set| set.clone())
9913                                    .unwrap_or_default();
9914                                let pending = pending_endpoint_tokens(
9915                                    prepare_endpoint_crypto_tokens(rt, remote_prefix),
9916                                    &already,
9917                                );
9918                                for ep_msg in pending {
9919                                    let key = endpoint_token_key(&ep_msg);
9920                                    outbound.extend(protect_volatile_outbound(
9921                                        rt,
9922                                        remote_prefix,
9923                                        s.volatile_writer
9924                                            .write_with_heartbeat(&ep_msg, now)
9925                                            .unwrap_or_default(),
9926                                    ));
9927                                    if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
9928                                        set.insert(key);
9929                                    }
9930                                }
9931                            }
9932                        }
9933                        #[cfg(not(feature = "security"))]
9934                        let _ = msg;
9935                    }
9936                } else if d.reader_id
9937                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
9938                {
9939                    // FU2 S1.4: VolatileSecure carries the crypto-token
9940                    // exchange. Kx-decrypt the received PARTICIPANT_CRYPTO_TOKENS
9941                    // message + install the data key in the gate.
9942                    if let Ok(_msgs) = s.volatile_reader.handle_data(remote_prefix, &d) {
9943                        #[cfg(feature = "security")]
9944                        for m in &_msgs {
9945                            install_crypto_token(rt, remote_prefix, m);
9946                        }
9947                        // Step 6b: now (peer ready) send our per-endpoint
9948                        // tokens back. Per-token dedup (#29): builtins
9949                        // go out early here, the later-matching user-
9950                        // endpoint tokens are caught up by the tick path (no per-peer
9951                        // guard that blocks them forever).
9952                        #[cfg(feature = "security")]
9953                        {
9954                            let already = rt
9955                                .endpoint_tokens_sent
9956                                .read()
9957                                .map(|set| set.clone())
9958                                .unwrap_or_default();
9959                            let pending = pending_endpoint_tokens(
9960                                prepare_endpoint_crypto_tokens(rt, remote_prefix),
9961                                &already,
9962                            );
9963                            for ep_msg in pending {
9964                                let key = endpoint_token_key(&ep_msg);
9965                                outbound.extend(protect_volatile_outbound(
9966                                    rt,
9967                                    remote_prefix,
9968                                    s.volatile_writer
9969                                        .write_with_heartbeat(&ep_msg, now)
9970                                        .unwrap_or_default(),
9971                                ));
9972                                if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
9973                                    set.insert(key);
9974                                }
9975                            }
9976                        }
9977                        // The peer now has our participant crypto token (can
9978                        // decode our SRTPS/SEC SEDP): catch up the initially dropped
9979                        // SEDP burst once (OpenDDS convergence).
9980                        #[cfg(feature = "security")]
9981                        rt.re_announce_sedp_to_peer(remote_prefix);
9982                    }
9983                }
9984            }
9985            ParsedSubmessage::DataFrag(df) => {
9986                if df.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
9987                    || df.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
9988                {
9989                    // FU2 cross-vendor: cyclone/FastDDS RTPS-fragment the
9990                    // large HandshakeReply/Final (cert + permissions over
9991                    // MTU). Reassemble the fragments + drive them through the
9992                    // handshake driver like a stateless DATA.
9993                    if let Ok(msgs) = s.stateless_reader.handle_data_frag(&df) {
9994                        #[cfg(feature = "security")]
9995                        s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
9996                        #[cfg(feature = "security")]
9997                        for msg in &msgs {
9998                            if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, msg)
9999                            {
10000                                outbound.extend(out);
10001                                if let Some((remote_identity, secret)) = completed {
10002                                    if let Some(token_msg) = prepare_crypto_token(
10003                                        rt,
10004                                        remote_prefix,
10005                                        remote_identity,
10006                                        secret,
10007                                    ) {
10008                                        outbound.extend(protect_volatile_outbound(
10009                                            rt,
10010                                            remote_prefix,
10011                                            s.volatile_writer
10012                                                .write_with_heartbeat(&token_msg, now)
10013                                                .unwrap_or_default(),
10014                                        ));
10015                                    }
10016                                    let already = rt
10017                                        .endpoint_tokens_sent
10018                                        .read()
10019                                        .map(|set| set.clone())
10020                                        .unwrap_or_default();
10021                                    let pending = pending_endpoint_tokens(
10022                                        prepare_endpoint_crypto_tokens(rt, remote_prefix),
10023                                        &already,
10024                                    );
10025                                    for ep_msg in pending {
10026                                        let key = endpoint_token_key(&ep_msg);
10027                                        outbound.extend(protect_volatile_outbound(
10028                                            rt,
10029                                            remote_prefix,
10030                                            s.volatile_writer
10031                                                .write_with_heartbeat(&ep_msg, now)
10032                                                .unwrap_or_default(),
10033                                        ));
10034                                        if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10035                                            set.insert(key);
10036                                        }
10037                                    }
10038                                }
10039                            }
10040                        }
10041                        #[cfg(not(feature = "security"))]
10042                        let _ = msgs;
10043                    }
10044                } else if df.reader_id
10045                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10046                {
10047                    let _ = s.volatile_reader.handle_data_frag(remote_prefix, &df, now);
10048                }
10049            }
10050            ParsedSubmessage::Heartbeat(h) => {
10051                let to_volatile_reader = h.reader_id
10052                    == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10053                    || (h.reader_id == EntityId::UNKNOWN
10054                        && h.writer_id
10055                            == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER);
10056                if to_volatile_reader {
10057                    s.volatile_reader.handle_heartbeat(remote_prefix, &h, now);
10058                }
10059            }
10060            ParsedSubmessage::Gap(g) => {
10061                if g.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
10062                    let _ = s.volatile_reader.handle_gap(remote_prefix, &g);
10063                }
10064            }
10065            ParsedSubmessage::AckNack(ack) => {
10066                if ack.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
10067                    let base = ack.reader_sn_state.bitmap_base;
10068                    let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
10069                    let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
10070                    s.volatile_writer.handle_acknack(src, base, requested);
10071                }
10072            }
10073            ParsedSubmessage::NackFrag(nf) => {
10074                if nf.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
10075                    let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
10076                    s.volatile_writer.handle_nackfrag(src, &nf);
10077                }
10078            }
10079            _ => {}
10080        }
10081    }
10082    outbound
10083}
10084
10085/// Dispatches a datagram addressed to the TypeLookup service endpoints
10086/// (XTypes 1.3 §7.6.3.3.4). Handles incoming
10087/// requests (to `TL_SVC_REQ_READER`), generates replies and sends
10088/// them back to the source locator; handles incoming replies
10089/// (to `TL_SVC_REPLY_READER`), correlates with the client.
10090///
10091/// Returns `true` if the datagram was accepted by the TypeLookup path
10092/// — the caller can then skip the user-reader path.
10093fn dispatch_type_lookup_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], source: &Locator) -> bool {
10094    use zerodds_cdr::{BufferReader, Endianness};
10095    use zerodds_rtps::inline_qos::{SampleIdentityBytes, find_related_sample_identity};
10096    use zerodds_types::type_lookup::{
10097        GetTypeDependenciesReply, GetTypeDependenciesRequest, GetTypesReply, GetTypesRequest,
10098    };
10099
10100    let Ok(parsed) = decode_datagram(bytes) else {
10101        return false;
10102    };
10103    // DDS-RPC §7.8.2: the request sample identity = (request writer GUID,
10104    // request SN). The server carries it as PID_RELATED_SAMPLE_IDENTITY in the
10105    // reply inline QoS, so a client (also cross-vendor) can correlate
10106    // without relying on the echoed writer_sn.
10107    let src_prefix = parsed.header.guid_prefix;
10108
10109    let mut accepted = false;
10110
10111    for sub in &parsed.submessages {
10112        let ParsedSubmessage::Data(d) = sub else {
10113            continue;
10114        };
10115        let payload: &[u8] = &d.serialized_payload;
10116        if payload.is_empty() {
10117            continue;
10118        }
10119        // Skip CDR-Encapsulation header (4 bytes) if present.
10120        let body: &[u8] = if payload.len() >= 4 && (payload[0] == 0x00 && payload[1] == 0x01) {
10121            &payload[4..]
10122        } else {
10123            payload
10124        };
10125
10126        // Inbound Request → Server.
10127        if d.reader_id == EntityId::TL_SVC_REQ_READER {
10128            accepted = true;
10129            // Request sample identity = (request writer GUID, request SN) — mirrored
10130            // as related_sample_identity into the reply inline QoS.
10131            let (sn_hi, sn_lo) = d.writer_sn.split();
10132            let req_sn = ((u64::from(sn_hi as u32)) << 32) | u64::from(sn_lo);
10133            let related =
10134                SampleIdentityBytes::new(Guid::new(src_prefix, d.writer_id).to_bytes(), req_sn);
10135            // Try GetTypes-Request first; fall back to
10136            // GetTypeDependenciesRequest if that fails.
10137            let mut r = BufferReader::new(body, Endianness::Little);
10138            if let Ok(req) = GetTypesRequest::decode_from(&mut r) {
10139                let reply = match rt.type_lookup_server.lock() {
10140                    Ok(g) => g.handle_get_types(&req),
10141                    Err(_) => continue,
10142                };
10143                let _ = send_type_lookup_reply(
10144                    rt,
10145                    source,
10146                    TypeLookupReplyPayload::Types(reply),
10147                    related,
10148                );
10149                continue;
10150            }
10151            let mut r = BufferReader::new(body, Endianness::Little);
10152            if let Ok(req) = GetTypeDependenciesRequest::decode_from(&mut r) {
10153                let reply = match rt.type_lookup_server.lock() {
10154                    Ok(g) => g.handle_get_type_dependencies(&req),
10155                    Err(_) => continue,
10156                };
10157                let _ = send_type_lookup_reply(
10158                    rt,
10159                    source,
10160                    TypeLookupReplyPayload::Dependencies(reply),
10161                    related,
10162                );
10163                continue;
10164            }
10165        }
10166
10167        // Inbound Reply → Client.
10168        if d.reader_id == EntityId::TL_SVC_REPLY_READER {
10169            accepted = true;
10170            // Correlation prefers PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2,
10171            // cross-vendor compatible); fallback to the echoed writer_sn for
10172            // peers/legacy replies without inline QoS.
10173            let request_id = d
10174                .inline_qos
10175                .as_ref()
10176                .and_then(|pl| find_related_sample_identity(pl, true).ok().flatten())
10177                .map(|sid| zerodds_discovery::type_lookup::RequestId::from_u64(sid.sequence_number))
10178                .unwrap_or_else(|| {
10179                    let (sn_high, sn_low) = d.writer_sn.split();
10180                    let sn_u64 = ((u64::from(sn_high as u32)) << 32) | u64::from(sn_low);
10181                    zerodds_discovery::type_lookup::RequestId::from_u64(sn_u64)
10182                });
10183            let mut r = BufferReader::new(body, Endianness::Little);
10184            if let Ok(reply) = GetTypesReply::decode_from(&mut r) {
10185                if let Ok(mut client) = rt.type_lookup_client.lock() {
10186                    client.handle_reply(request_id, TypeLookupReply::Types(reply));
10187                }
10188                continue;
10189            }
10190            // M-5: the getTypeDependencies reply carries a different element type
10191            // (TypeIdentifierWithSize list) — its own decode branch, otherwise the
10192            // dependencies callback never fires.
10193            let mut r = BufferReader::new(body, Endianness::Little);
10194            if let Ok(reply) = GetTypeDependenciesReply::decode_from(&mut r) {
10195                if let Ok(mut client) = rt.type_lookup_client.lock() {
10196                    client.handle_reply(request_id, TypeLookupReply::Dependencies(reply));
10197                }
10198                continue;
10199            }
10200        }
10201    }
10202
10203    accepted
10204}
10205
10206/// Reply payload variants that the TypeLookup server can emit.
10207enum TypeLookupReplyPayload {
10208    Types(zerodds_types::type_lookup::GetTypesReply),
10209    Dependencies(zerodds_types::type_lookup::GetTypeDependenciesReply),
10210}
10211
10212/// Sends a TypeLookup reply to a peer locator as a
10213/// DATA datagram on the TL_SVC_REPLY_WRITER → peer's
10214/// TL_SVC_REPLY_READER. The sequence number echoes the request sequence
10215/// for correlation purposes (see XTypes §7.6.3.3.3 sample identity).
10216fn send_type_lookup_reply(
10217    rt: &Arc<DcpsRuntime>,
10218    target: &Locator,
10219    reply: TypeLookupReplyPayload,
10220    related: zerodds_rtps::inline_qos::SampleIdentityBytes,
10221) -> Result<()> {
10222    use alloc::sync::Arc as AllocArc;
10223    use core::sync::atomic::Ordering;
10224    use zerodds_cdr::{BufferWriter, Endianness};
10225    use zerodds_rtps::datagram::encode_data_datagram;
10226    use zerodds_rtps::header::RtpsHeader;
10227    use zerodds_rtps::submessages::DataSubmessage;
10228    use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber, VendorId};
10229
10230    // CDR-encode reply (PL_CDR_LE-Encapsulation).
10231    let mut w = BufferWriter::new(Endianness::Little);
10232    match reply {
10233        TypeLookupReplyPayload::Types(r) => {
10234            r.encode_into(&mut w)
10235                .map_err(|_| DdsError::PreconditionNotMet {
10236                    reason: "type_lookup reply encode failed",
10237                })?;
10238        }
10239        TypeLookupReplyPayload::Dependencies(r) => {
10240            r.encode_into(&mut w)
10241                .map_err(|_| DdsError::PreconditionNotMet {
10242                    reason: "type_lookup deps reply encode failed",
10243                })?;
10244        }
10245    }
10246    let body = w.into_bytes();
10247    let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
10248    payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
10249    payload.extend_from_slice(&body);
10250
10251    let header = RtpsHeader {
10252        protocol_version: ProtocolVersion::CURRENT,
10253        vendor_id: VendorId::ZERODDS,
10254        guid_prefix: rt.guid_prefix,
10255    };
10256    // Own monotonically increasing reply-writer SN (starting at 1) instead of a
10257    // request-SN echo — a reliable cross-vendor reply reader would otherwise see SN jumps.
10258    let reply_sn = rt
10259        .tl_reply_sn
10260        .fetch_add(1, Ordering::Relaxed)
10261        .wrapping_add(1);
10262    let writer_sn =
10263        SequenceNumber::from_high_low((reply_sn >> 32) as i32, (reply_sn & 0xFFFF_FFFF) as u32);
10264    let data = DataSubmessage {
10265        extra_flags: 0,
10266        reader_id: EntityId::TL_SVC_REPLY_READER,
10267        writer_id: EntityId::TL_SVC_REPLY_WRITER,
10268        writer_sn,
10269        // DDS-RPC §7.8.2: related_sample_identity couples the reply to the
10270        // request (cross-vendor correlation without a writer_sn echo).
10271        inline_qos: Some(zerodds_rtps::inline_qos::reply_inline_qos(related, true)),
10272        key_flag: false,
10273        non_standard_flag: false,
10274        serialized_payload: AllocArc::from(payload.into_boxed_slice()),
10275    };
10276    let datagram =
10277        encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
10278            reason: "type_lookup reply datagram encode failed",
10279        })?;
10280
10281    if is_routable_user_locator(target) {
10282        let _ = rt.user_unicast.send(target, &datagram);
10283    }
10284    Ok(())
10285}
10286
10287/// Sends a discovery datagram to all target locators. UDP-only
10288/// (TCPv4/SHM/UDS are not carried in discovery); non-UDP
10289/// locators are silently ignored.
10290fn send_discovery_datagram(rt: &Arc<DcpsRuntime>, targets: &[Locator], bytes: &[u8]) {
10291    let Some(secured) = secure_outbound_bytes(rt, bytes) else {
10292        return;
10293    };
10294    for t in targets {
10295        if !is_routable_user_locator(t) {
10296            continue;
10297        }
10298        // Send unicast metatraffic (SEDP responses, VolatileSecure, stateless auth)
10299        // from the **metatraffic recv socket** (`spdp_unicast`, = announced
10300        // metatraffic_unicast_locator), NOT from the ephemeral `spdp_mc_tx`.
10301        // Otherwise the peer sees a foreign source port and sends its
10302        // responses (e.g. cyclone's VolatileSecure ACKNACK to the source locator)
10303        // to a port ZeroDDS does not listen on → reliable resends stay
10304        // out (cross-vendor). `spdp_mc_tx` stays only for SPDP multicast.
10305        let _ = rt.spdp_unicast.send(t, &secured);
10306    }
10307}
10308
10309/// Default user-multicast locator for a DomainParticipant.
10310/// Not used in live mode 1 yet; SPDP-announced in B2.
10311#[must_use]
10312pub fn user_multicast_endpoint(domain_id: i32) -> SocketAddr {
10313    // Spec §9.6.1.4.1: user-multicast-port = PB + DG * d + d2
10314    //   = 7400 + 250 * d + 1
10315    let port = 7400u16.saturating_add(250u16.saturating_mul(domain_id as u16).saturating_add(1));
10316    SocketAddr::from((Ipv4Addr::from([239, 255, 0, 1]), port))
10317}
10318
10319#[cfg(test)]
10320#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
10321mod tests {
10322    use super::*;
10323
10324    /// FU1 diagnosis: inject a REAL FastDDS-3.6 SPDP datagram (domain 205,
10325    /// codepit capture 2026-05-29) directly into handle_spdp_datagram
10326    /// — does the runtime register FastDDS as a peer? Separates the
10327    /// receive problem (socket) from the handle problem (parse/insert/filter).
10328    #[test]
10329    fn handle_spdp_registers_real_fastdds_participant() {
10330        fn hx(s: &str) -> Vec<u8> {
10331            (0..s.len())
10332                .step_by(2)
10333                .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
10334                .collect()
10335        }
10336        const FASTDDS_SPDP: &str = "525450530203010f010fa72bbbebc90100000000090108006850196a57e882b41505b80100001000000100c7000100c2000000000100000000030000150004000203000016000400010f000000800400030601000f000400cd00000050001000010fa72bbbebc90100000000000001c107800400010000000380280021000000653830653630353335646339343432636231306537323239313038653135666600000000320018000100000024e50000000000000000000000000000c0a8b273310018000100000025e50000000000000000000000000000c0a8b273020008001400000000000000580004003ffc0f006200140010000000525450535061727469636970616e74005900cc0004000000110000005041525449434950414e545f54595045000000000700000053494d504c4500001b000000666173746464732e706879736963616c5f646174612e686f73740000210000006538306536303533356463393434326362313065373232393130386531356666000000001b000000666173746464732e706879736963616c5f646174612e75736572000005000000726f6f74000000001e000000666173746464732e706879736963616c5f646174612e70726f636573730000000600000036303334370000000100000080013800010000001ae50000000000000000000000000000efff00016850196a72ca8cb4020000000000000030040000000000000000000000000000";
10337        let bytes = hx(FASTDDS_SPDP);
10338        let prefix = GuidPrefix::from_bytes([0x99; 12]);
10339        let rt =
10340            Arc::new(DcpsRuntime::start(205, prefix, RuntimeConfig::default()).expect("rt start"));
10341        assert_eq!(rt.discovered_participants().len(), 0, "fresh: no peers");
10342        handle_spdp_datagram_for_test(&rt, &bytes);
10343        let n = rt.discovered_participants().len();
10344        assert_eq!(
10345            n, 1,
10346            "FastDDS must be registered after handle_spdp_datagram (got {n})"
10347        );
10348    }
10349
10350    #[test]
10351    fn select_user_transport_tcpv4_yields_tcpv4_locator() {
10352        let prefix = GuidPrefix::from_bytes([1u8; 12]);
10353        let (t, accept) =
10354            select_user_transport(UserTransportKind::TcpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
10355                .expect("TcpV4 transport");
10356        assert_eq!(t.local_locator().kind, LocatorKind::Tcpv4);
10357        assert!(accept.is_some(), "TCP needs an accept handle");
10358    }
10359
10360    #[test]
10361    fn select_user_transport_udpv4_default_kind() {
10362        let prefix = GuidPrefix::from_bytes([2u8; 12]);
10363        let (t, accept) =
10364            select_user_transport(UserTransportKind::UdpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
10365                .expect("UdpV4 transport");
10366        assert_eq!(t.local_locator().kind, LocatorKind::UdpV4);
10367        assert!(accept.is_none(), "UDP needs no accept handle");
10368    }
10369
10370    #[cfg(feature = "same-host-uds")]
10371    #[test]
10372    fn select_user_transport_uds_yields_uds_locator() {
10373        let prefix = GuidPrefix::from_bytes([3u8; 12]);
10374        let (t, accept) =
10375            select_user_transport(UserTransportKind::Uds, prefix, 0, Ipv4Addr::UNSPECIFIED)
10376                .expect("Uds transport");
10377        assert_eq!(t.local_locator().kind, LocatorKind::Uds);
10378        assert!(accept.is_none(), "UDS needs no accept handle");
10379    }
10380
10381    #[test]
10382    fn strip_user_encap_xcdr2_le() {
10383        let payload = [0x00, 0x07, 0x00, 0x00, 1, 2, 3];
10384        assert_eq!(strip_user_encap(&payload), Some(alloc::vec![1, 2, 3]));
10385    }
10386
10387    #[test]
10388    fn strip_user_encap_xcdr1_le() {
10389        // Cyclone default for simple types.
10390        let payload = [0x00, 0x01, 0x00, 0x00, 0xAA];
10391        assert_eq!(strip_user_encap(&payload), Some(alloc::vec![0xAA]));
10392    }
10393
10394    #[test]
10395    fn strip_user_encap_rejects_unknown_scheme() {
10396        let payload = [0xFF, 0xFF, 0x00, 0x00, 1];
10397        assert_eq!(strip_user_encap(&payload), None);
10398    }
10399
10400    #[test]
10401    fn strip_user_encap_rejects_short() {
10402        assert_eq!(strip_user_encap(&[0x00, 0x07]), None);
10403    }
10404
10405    #[test]
10406    fn user_payload_encap_is_cdr_le() {
10407        // CDR_LE (PLAIN_CDR / XCDR1, Little-Endian) — ehrliche
10408        // Declaration of the body encoding generated by codegen.
10409        assert_eq!(USER_PAYLOAD_ENCAP, [0x00, 0x01, 0x00, 0x00]);
10410    }
10411
10412    #[test]
10413    fn data_repr_offer_str_uses_spec_ids() {
10414        use zerodds_rtps::publication_data::data_representation as dr;
10415        // XCDR1 -> Spec-Id 0 (NICHT 1 = XML); XCDR2 -> 2.
10416        assert_eq!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XCDR]));
10417        assert_eq!(parse_data_repr_offer_str("XCDR2"), Some(vec![dr::XCDR2]));
10418        assert_eq!(parse_data_repr_offer_str("xcdr2"), Some(vec![dr::XCDR2]));
10419        assert_eq!(
10420            parse_data_repr_offer_str("XCDR2,XCDR1"),
10421            Some(vec![dr::XCDR2, dr::XCDR])
10422        );
10423        assert_eq!(parse_data_repr_offer_str("bogus"), None);
10424        assert_eq!(parse_data_repr_offer_str(""), None);
10425        // XCDR1 must NOT map to the XML id (1).
10426        assert_ne!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XML]));
10427    }
10428
10429    #[test]
10430    fn user_payload_encap_maps_repr_and_extensibility() {
10431        use zerodds_rtps::publication_data::data_representation as dr;
10432        use zerodds_types::qos::ExtensibilityForRepr as Ext;
10433        // DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 Encapsulation-IDs
10434        // (2-byte repr-id BE + 2-byte options=0), little-endian variant:
10435        //   XCDR1 final/appendable -> CDR_LE        0x0001
10436        //   XCDR1 mutable          -> PL_CDR_LE      0x0003
10437        //   XCDR2 final            -> PLAIN_CDR2_LE  0x0007
10438        //   XCDR2 appendable       -> D_CDR2_LE      0x0009
10439        //   XCDR2 mutable          -> PL_CDR2_LE     0x000b
10440        assert_eq!(
10441            user_payload_encap(dr::XCDR, Ext::Final),
10442            [0x00, 0x01, 0x00, 0x00]
10443        );
10444        assert_eq!(
10445            user_payload_encap(dr::XCDR, Ext::Appendable),
10446            [0x00, 0x01, 0x00, 0x00]
10447        );
10448        assert_eq!(
10449            user_payload_encap(dr::XCDR, Ext::Mutable),
10450            [0x00, 0x03, 0x00, 0x00]
10451        );
10452        assert_eq!(
10453            user_payload_encap(dr::XCDR2, Ext::Final),
10454            [0x00, 0x07, 0x00, 0x00]
10455        );
10456        assert_eq!(
10457            user_payload_encap(dr::XCDR2, Ext::Appendable),
10458            [0x00, 0x09, 0x00, 0x00]
10459        );
10460        assert_eq!(
10461            user_payload_encap(dr::XCDR2, Ext::Mutable),
10462            [0x00, 0x0b, 0x00, 0x00]
10463        );
10464        // The default const is exactly the (XCDR1, Final) case.
10465        assert_eq!(user_payload_encap(dr::XCDR, Ext::Final), USER_PAYLOAD_ENCAP);
10466        // Unknown/XML repr falls back safely to CDR_LE.
10467        assert_eq!(
10468            user_payload_encap(dr::XML, Ext::Final),
10469            [0x00, 0x01, 0x00, 0x00]
10470        );
10471    }
10472
10473    #[test]
10474    fn observability_sink_records_writer_and_reader_creation() {
10475        // VecSink injizieren, Writer + Reader erzeugen,
10476        // check that both events arrive.
10477        use std::sync::Arc as StdArc;
10478        use zerodds_foundation::observability::{Component, Level, VecSink};
10479
10480        let sink = StdArc::new(VecSink::new());
10481        let cfg = RuntimeConfig {
10482            observability: sink.clone(),
10483            ..RuntimeConfig::default()
10484        };
10485        let rt =
10486            DcpsRuntime::start(7, GuidPrefix::from_bytes([0xAA; 12]), cfg).expect("start runtime");
10487        let _ = rt.register_user_writer(UserWriterConfig {
10488            topic_name: "ObsTopic".into(),
10489            type_name: "ObsType".into(),
10490            reliable: true,
10491            durability: zerodds_qos::DurabilityKind::Volatile,
10492            deadline: zerodds_qos::DeadlineQosPolicy::default(),
10493            lifespan: zerodds_qos::LifespanQosPolicy::default(),
10494            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10495            ownership: zerodds_qos::OwnershipKind::Shared,
10496            ownership_strength: 0,
10497            partition: alloc::vec![],
10498            user_data: alloc::vec![],
10499            topic_data: alloc::vec![],
10500            group_data: alloc::vec![],
10501            type_identifier: zerodds_types::TypeIdentifier::None,
10502            data_representation_offer: None,
10503        });
10504        let _ = rt.register_user_reader(UserReaderConfig {
10505            topic_name: "ObsTopic".into(),
10506            type_name: "ObsType".into(),
10507            reliable: true,
10508            durability: zerodds_qos::DurabilityKind::Volatile,
10509            deadline: zerodds_qos::DeadlineQosPolicy::default(),
10510            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10511            ownership: zerodds_qos::OwnershipKind::Shared,
10512            partition: alloc::vec![],
10513            user_data: alloc::vec![],
10514            topic_data: alloc::vec![],
10515            group_data: alloc::vec![],
10516            type_identifier: zerodds_types::TypeIdentifier::None,
10517            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10518            data_representation_offer: None,
10519        });
10520        rt.shutdown();
10521
10522        let events = sink.snapshot();
10523        assert!(
10524            events.iter().any(|e| e.name == "user_writer.created"
10525                && e.component == Component::Dcps
10526                && e.level == Level::Info),
10527            "writer-event missing: got {:?}",
10528            events.iter().map(|e| e.name).collect::<Vec<_>>()
10529        );
10530        assert!(
10531            events
10532                .iter()
10533                .any(|e| e.name == "user_reader.created" && e.component == Component::Dcps),
10534            "reader-event missing"
10535        );
10536        // The topic attribute must hang on the writer.created event.
10537        let writer_event = events
10538            .iter()
10539            .find(|e| e.name == "user_writer.created")
10540            .expect("writer event");
10541        assert!(
10542            writer_event
10543                .attrs
10544                .iter()
10545                .any(|a| a.key == "topic" && a.value == "ObsTopic"),
10546            "topic attr missing"
10547        );
10548    }
10549
10550    #[test]
10551    fn user_endpoint_entity_kind_follows_keyedness() {
10552        // Regression (ROS-2 cross-vendor): the entityKind of a user
10553        // endpoint MUST follow the type keyedness (Spec §9.3.1.2). A
10554        // a keyless type yields NoKey (Writer 0x03 / Reader 0x04), a
10555        // keyed type WithKey (0x02 / 0x07). If this does not match the
10556        // peer, CycloneDDS/ROS 2 silently rejects the endpoint match
10557        // (DDS_INVALID_QOS_POLICY_ID, no log). create_datawriter/
10558        // create_datareader derive `is_keyed` from `DdsType::HAS_KEY`.
10559        use zerodds_rtps::wire_types::EntityKind;
10560        let rt = DcpsRuntime::start(
10561            11,
10562            GuidPrefix::from_bytes([0xBC; 12]),
10563            RuntimeConfig::default(),
10564        )
10565        .expect("start runtime");
10566        let mk_w = || UserWriterConfig {
10567            topic_name: "KindTopic".into(),
10568            type_name: "KindType".into(),
10569            reliable: true,
10570            durability: zerodds_qos::DurabilityKind::Volatile,
10571            deadline: zerodds_qos::DeadlineQosPolicy::default(),
10572            lifespan: zerodds_qos::LifespanQosPolicy::default(),
10573            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10574            ownership: zerodds_qos::OwnershipKind::Shared,
10575            ownership_strength: 0,
10576            partition: alloc::vec![],
10577            user_data: alloc::vec![],
10578            topic_data: alloc::vec![],
10579            group_data: alloc::vec![],
10580            type_identifier: zerodds_types::TypeIdentifier::None,
10581            data_representation_offer: None,
10582        };
10583        let mk_r = || UserReaderConfig {
10584            topic_name: "KindTopic".into(),
10585            type_name: "KindType".into(),
10586            reliable: true,
10587            durability: zerodds_qos::DurabilityKind::Volatile,
10588            deadline: zerodds_qos::DeadlineQosPolicy::default(),
10589            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10590            ownership: zerodds_qos::OwnershipKind::Shared,
10591            partition: alloc::vec![],
10592            user_data: alloc::vec![],
10593            topic_data: alloc::vec![],
10594            group_data: alloc::vec![],
10595            type_identifier: zerodds_types::TypeIdentifier::None,
10596            type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10597            data_representation_offer: None,
10598        };
10599        // keyless (HAS_KEY=false) -> NoKey
10600        let w_nokey = rt.register_user_writer_kind(mk_w(), false).expect("writer");
10601        assert_eq!(w_nokey.entity_kind, EntityKind::UserWriterNoKey);
10602        let (r_nokey, _) = rt.register_user_reader_kind(mk_r(), false).expect("reader");
10603        assert_eq!(r_nokey.entity_kind, EntityKind::UserReaderNoKey);
10604        // keyed (HAS_KEY=true) -> WithKey
10605        let w_key = rt.register_user_writer_kind(mk_w(), true).expect("writer");
10606        assert_eq!(w_key.entity_kind, EntityKind::UserWriterWithKey);
10607        let (r_key, _) = rt.register_user_reader_kind(mk_r(), true).expect("reader");
10608        assert_eq!(r_key.entity_kind, EntityKind::UserReaderWithKey);
10609        rt.shutdown();
10610    }
10611
10612    #[test]
10613    fn incompatible_qos_match_emits_loud_warning() {
10614        // C2 "loud instead of silent": an incompatible QoS match is logged as a
10615        // warn event with topic + policy, not silently discarded.
10616        // Setup: writer Volatile + reader TransientLocal on the same
10617        // Topic (reader requests more durability than the writer offers)
10618        // → intra-runtime match fails with policy DURABILITY.
10619        use std::sync::Arc as StdArc;
10620        use zerodds_foundation::observability::{Component, Level, VecSink};
10621
10622        let sink = StdArc::new(VecSink::new());
10623        let cfg_a = RuntimeConfig {
10624            observability: sink.clone(),
10625            tick_period: Duration::from_millis(5),
10626            ..RuntimeConfig::default()
10627        };
10628        let cfg_b = RuntimeConfig {
10629            tick_period: Duration::from_millis(5),
10630            ..RuntimeConfig::default()
10631        };
10632        // Two same-process runtimes, same domain → inproc discovery.
10633        let rt = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCE; 12]), cfg_a)
10634            .expect("start runtime a");
10635        let rt_b = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCF; 12]), cfg_b)
10636            .expect("start runtime b");
10637        let _w = rt
10638            .register_user_writer(UserWriterConfig {
10639                topic_name: "QT".into(),
10640                type_name: "QType".into(),
10641                reliable: false,
10642                durability: zerodds_qos::DurabilityKind::Volatile,
10643                deadline: zerodds_qos::DeadlineQosPolicy::default(),
10644                lifespan: zerodds_qos::LifespanQosPolicy::default(),
10645                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10646                ownership: zerodds_qos::OwnershipKind::Shared,
10647                ownership_strength: 0,
10648                partition: alloc::vec![],
10649                user_data: alloc::vec![],
10650                topic_data: alloc::vec![],
10651                group_data: alloc::vec![],
10652                type_identifier: zerodds_types::TypeIdentifier::None,
10653                data_representation_offer: None,
10654            })
10655            .expect("writer");
10656        let _r = rt_b
10657            .register_user_reader(UserReaderConfig {
10658                topic_name: "QT".into(),
10659                type_name: "QType".into(),
10660                reliable: false,
10661                durability: zerodds_qos::DurabilityKind::TransientLocal,
10662                deadline: zerodds_qos::DeadlineQosPolicy::default(),
10663                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10664                ownership: zerodds_qos::OwnershipKind::Shared,
10665                partition: alloc::vec![],
10666                user_data: alloc::vec![],
10667                topic_data: alloc::vec![],
10668                group_data: alloc::vec![],
10669                type_identifier: zerodds_types::TypeIdentifier::None,
10670                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10671                data_representation_offer: None,
10672            })
10673            .expect("reader");
10674        // Await the match pass.
10675        let mut found = false;
10676        for _ in 0..40 {
10677            std::thread::sleep(Duration::from_millis(25));
10678            let events = sink.snapshot();
10679            if events.iter().any(|e| {
10680                (e.name == "qos.incompatible.offered" || e.name == "qos.incompatible.requested")
10681                    && e.component == Component::Dcps
10682                    && e.level == Level::Warn
10683                    && e.attrs.iter().any(|a| a.key == "topic" && a.value == "QT")
10684                    && e.attrs
10685                        .iter()
10686                        .any(|a| a.key == "policy" && a.value == "DURABILITY")
10687            }) {
10688                found = true;
10689                break;
10690            }
10691        }
10692        rt.shutdown();
10693        rt_b.shutdown();
10694        assert!(
10695            found,
10696            "expected a loud qos.incompatible warn event with policy DURABILITY"
10697        );
10698    }
10699
10700    #[test]
10701    fn spdp_unicast_port_follows_rtps_formula() {
10702        // Spec §9.6.1.4.1: PB + DG*domain + d1 + PG*pid = 7400+250*d+10+2*pid.
10703        assert_eq!(super::spdp_unicast_port(0, 0), 7410);
10704        assert_eq!(spdp_unicast_port(0, 1), 7412);
10705        assert_eq!(spdp_unicast_port(1, 0), 7660);
10706        assert_eq!(spdp_unicast_port(7, 0), 9160);
10707    }
10708
10709    #[test]
10710    fn announce_locator_pins_interface_over_route_probe() {
10711        // Interface pinning: a set interface takes precedence over the
10712        // route probe (multi-homed robustness, cf. Cyclone NetworkInterface).
10713        let udp = UdpTransport::bind_v4(Ipv4Addr::UNSPECIFIED, 0).expect("bind");
10714        let pin = Ipv4Addr::new(10, 11, 12, 13);
10715        let loc = super::announce_locator(&udp, pin);
10716        assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
10717        assert_eq!(loc.address[12..], [10, 11, 12, 13]);
10718        // Without a pin (UNSPECIFIED) → probe/fallback does NOT return the pin IP.
10719        let auto = super::announce_locator(&udp, Ipv4Addr::UNSPECIFIED);
10720        assert_ne!(auto.address[12..], [10, 11, 12, 13]);
10721    }
10722
10723    #[test]
10724    fn expand_initial_peer_ip_only_yields_well_known_port_range() {
10725        let m = super::INITIAL_PEER_MAX_PARTICIPANTS;
10726        let mut out = Vec::new();
10727        super::expand_initial_peer("127.0.0.1", 0, m, &mut out);
10728        assert_eq!(out.len(), m as usize);
10729        assert_eq!(out[0].port, 7410);
10730        assert_eq!(out[1].port, 7412);
10731        // Larger limit → more ports (C1 dense multi-robot scenarios).
10732        let mut wide = Vec::new();
10733        super::expand_initial_peer("127.0.0.1", 0, 30, &mut wide);
10734        assert_eq!(wide.len(), 30);
10735        assert_eq!(wide[29].port, 7410 + 2 * 29);
10736        // ip:port -> exactly one exact locator.
10737        let mut one = Vec::new();
10738        super::expand_initial_peer("10.0.0.5:7410", 0, m, &mut one);
10739        assert_eq!(one.len(), 1);
10740        assert_eq!(one[0].port, 7410);
10741        assert_eq!(one[0].address[12..], [10, 0, 0, 5]);
10742        // Garbage is ignored.
10743        let mut none = Vec::new();
10744        super::expand_initial_peer("not-an-ip", 0, m, &mut none);
10745        assert!(none.is_empty());
10746    }
10747
10748    #[test]
10749    #[ignore = "heavy multi-runtime scaling test (12 runtimes); explicit: cargo test -- --ignored"]
10750    #[allow(clippy::print_stdout)]
10751    fn multicast_free_discovery_scales_to_many_participants() {
10752        // C1 scaling: N participants, each with its own multicast group
10753        // (→ separate inproc buckets) AND multicast send off → pure
10754        // Unicast discovery via an explicit well-known-port peer list. Evidence,
10755        // that multicast-free all-to-all discovery works beyond 2 participants
10756        // (the "N²-multicast-storm" pain cluster, but unicast).
10757        // N via env (ZERODDS_SCALE_N, default 12) for >50 perf demos.
10758        let n: u32 = std::env::var("ZERODDS_SCALE_N")
10759            .ok()
10760            .and_then(|s| s.parse().ok())
10761            .unwrap_or(12)
10762            .clamp(2, 120);
10763        let domain = 21;
10764        let peers: Vec<Locator> = (0..n)
10765            .map(|pid| Locator::udp_v4([127, 0, 0, 1], super::spdp_unicast_port(domain, pid)))
10766            .collect();
10767        let mut rts = Vec::new();
10768        for i in 0..n {
10769            let cfg = RuntimeConfig {
10770                tick_period: Duration::from_millis(10),
10771                spdp_period: Duration::from_millis(40),
10772                // Own group per runtime → no inproc, no multicast.
10773                spdp_multicast_group: Ipv4Addr::new(239, 255, 21, (i + 1) as u8),
10774                spdp_multicast_send: false,
10775                initial_peers: peers.clone(),
10776                ..RuntimeConfig::default()
10777            };
10778            // Unique prefix even for n>47 (two-byte index).
10779            let mut pb = [0xD0u8; 12];
10780            pb[0] = (i & 0xff) as u8;
10781            pb[1] = (i >> 8) as u8;
10782            let prefix = GuidPrefix::from_bytes(pb);
10783            rts.push(DcpsRuntime::start(domain as i32, prefix, cfg).expect("start"));
10784        }
10785        // Wait until each participant has discovered all n-1 others.
10786        // Grosszuegiges Fenster: viele Runtimes konkurrieren um CPU; break-early.
10787        let started = std::time::Instant::now();
10788        let mut all_full = false;
10789        for _ in 0..1200 {
10790            std::thread::sleep(Duration::from_millis(25));
10791            if rts
10792                .iter()
10793                .all(|rt| rt.discovered_participants().len() >= (n as usize - 1))
10794            {
10795                all_full = true;
10796                break;
10797            }
10798        }
10799        let elapsed = started.elapsed();
10800        let min_seen = rts
10801            .iter()
10802            .map(|rt| rt.discovered_participants().len())
10803            .min()
10804            .unwrap_or(0);
10805        for rt in &rts {
10806            rt.shutdown();
10807        }
10808        println!(
10809            "C1-Scaling: {n} Participants multicast-frei all-to-all in {:.2}s (min={min_seen}/{})",
10810            elapsed.as_secs_f64(),
10811            n - 1
10812        );
10813        assert!(
10814            all_full,
10815            "multicast-free all-to-all discovery does not scale: min seen = {min_seen}/{}",
10816            n - 1
10817        );
10818    }
10819
10820    #[test]
10821    fn default_reassembly_cap_is_ros_realistic() {
10822        // C3 regression: the DCPS reassembly cap must be ROS-PointCloud2/
10823        // Image-capable (several MB), not the conservative
10824        // rtps 1-MiB default that silently discards large samples.
10825        let cfg = RuntimeConfig::default();
10826        assert!(
10827            cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024,
10828            "reassembly cap too small for ROS PointCloud2/Image: {}",
10829            cfg.max_reassembly_sample_bytes
10830        );
10831    }
10832
10833    #[test]
10834    fn ros_defaults_offers_xcdr1_for_ros_writers() {
10835        // C4: the ROS profile offers [XCDR1, XCDR2] (matches ROS/Cyclone
10836        // XCDR1 writer) + keeps the ROS-realistic reassembly cap.
10837        use zerodds_rtps::publication_data::data_representation as dr;
10838        let cfg = RuntimeConfig::ros_defaults();
10839        assert_eq!(
10840            cfg.data_representation_offer,
10841            alloc::vec![dr::XCDR, dr::XCDR2]
10842        );
10843        assert!(cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024);
10844    }
10845
10846    #[test]
10847    fn multicast_free_discovery_via_initial_peers() {
10848        // C1: two runtimes with DIFFERENT multicast groups lie
10849        // in different inproc buckets AND cannot see each other via
10850        // multicast — so they discover each other EXCLUSIVELY via
10851        // the unicast initial peers (well-known SPDP ports on 127.0.0.1).
10852        let domain = 7;
10853        let mut peers = Vec::new();
10854        super::expand_initial_peer(
10855            "127.0.0.1",
10856            domain as u32,
10857            super::INITIAL_PEER_MAX_PARTICIPANTS,
10858            &mut peers,
10859        );
10860        let mk = |group: [u8; 4]| RuntimeConfig {
10861            tick_period: Duration::from_millis(10),
10862            spdp_period: Duration::from_millis(40),
10863            spdp_multicast_group: Ipv4Addr::from(group),
10864            // Multicast send fully off → rigorous unicast-only proof.
10865            spdp_multicast_send: false,
10866            initial_peers: peers.clone(),
10867            ..RuntimeConfig::default()
10868        };
10869        let a = DcpsRuntime::start(
10870            domain,
10871            GuidPrefix::from_bytes([0xA1; 12]),
10872            mk([239, 255, 7, 1]),
10873        )
10874        .expect("a");
10875        let b = DcpsRuntime::start(
10876            domain,
10877            GuidPrefix::from_bytes([0xB2; 12]),
10878            mk([239, 255, 7, 2]),
10879        )
10880        .expect("b");
10881        let mut discovered = false;
10882        for _ in 0..160 {
10883            std::thread::sleep(Duration::from_millis(25));
10884            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
10885                discovered = true;
10886                break;
10887            }
10888        }
10889        a.shutdown();
10890        b.shutdown();
10891        assert!(
10892            discovered,
10893            "multicast-freie Discovery via Unicast-Initial-Peers fehlgeschlagen"
10894        );
10895    }
10896
10897    #[test]
10898    fn multi_robot_profile_is_multicast_free_and_wan_tolerant() {
10899        // C6: the named profile must be unicast-only with ROS reprs and a
10900        // WAN-tolerant lease, independent of any env.
10901        let cfg = RuntimeConfig::multi_robot();
10902        assert!(
10903            !cfg.spdp_multicast_send,
10904            "multi_robot() must disable multicast send"
10905        );
10906        assert_eq!(
10907            cfg.data_representation_offer,
10908            alloc::vec![
10909                zerodds_rtps::publication_data::data_representation::XCDR,
10910                zerodds_rtps::publication_data::data_representation::XCDR2
10911            ],
10912            "multi_robot() must offer the ROS XCDR1+XCDR2 reprs"
10913        );
10914        assert_eq!(
10915            cfg.participant_lease_duration,
10916            Duration::from_secs(300),
10917            "multi_robot() must use the WAN-tolerant 300s lease"
10918        );
10919    }
10920
10921    #[test]
10922    fn multi_robot_profile_discovers_via_unicast() {
10923        // C6 e2e: two runtimes started from the `multi_robot()` profile (whose
10924        // `spdp_multicast_send = false` is the field under test) sit in
10925        // different multicast buckets and can ONLY find each other through the
10926        // unicast initial peers — proving the profile drives multicast-free
10927        // discovery end-to-end. Only test-timing + the peer list are
10928        // overridden; `spdp_multicast_send` comes from the profile.
10929        let domain = 9;
10930        let mut peers = Vec::new();
10931        super::expand_initial_peer(
10932            "127.0.0.1",
10933            domain as u32,
10934            super::INITIAL_PEER_MAX_PARTICIPANTS,
10935            &mut peers,
10936        );
10937        let mk = |group: [u8; 4]| RuntimeConfig {
10938            tick_period: Duration::from_millis(10),
10939            spdp_period: Duration::from_millis(40),
10940            spdp_multicast_group: Ipv4Addr::from(group),
10941            initial_peers: peers.clone(),
10942            ..RuntimeConfig::multi_robot()
10943        };
10944        let a = DcpsRuntime::start(
10945            domain,
10946            GuidPrefix::from_bytes([0xC6; 12]),
10947            mk([239, 255, 9, 1]),
10948        )
10949        .expect("a");
10950        let b = DcpsRuntime::start(
10951            domain,
10952            GuidPrefix::from_bytes([0xD7; 12]),
10953            mk([239, 255, 9, 2]),
10954        )
10955        .expect("b");
10956        let mut discovered = false;
10957        for _ in 0..160 {
10958            std::thread::sleep(Duration::from_millis(25));
10959            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
10960                discovered = true;
10961                break;
10962            }
10963        }
10964        a.shutdown();
10965        b.shutdown();
10966        assert!(
10967            discovered,
10968            "multi_robot() profile failed to discover via unicast initial peers"
10969        );
10970    }
10971
10972    #[test]
10973    fn intra_runtime_writer_to_reader_loopback_delivers_sample() {
10974        // Bridge daemon use case: writer and reader in the SAME
10975        // DcpsRuntime, same topic+type. Before the same-runtime loopback
10976        // hook, a write() produced NO sample at the local reader,
10977        // because `inproc_announce_*` explicitly skips self and UDP multicast
10978        // loopback is not guaranteed.
10979        let rt = DcpsRuntime::start(
10980            17,
10981            GuidPrefix::from_bytes([0x42; 12]),
10982            RuntimeConfig::default(),
10983        )
10984        .expect("start runtime");
10985        let writer_eid = rt
10986            .register_user_writer(UserWriterConfig {
10987                topic_name: "IntraTopic".into(),
10988                type_name: "IntraType".into(),
10989                reliable: true,
10990                durability: zerodds_qos::DurabilityKind::Volatile,
10991                deadline: zerodds_qos::DeadlineQosPolicy::default(),
10992                lifespan: zerodds_qos::LifespanQosPolicy::default(),
10993                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10994                ownership: zerodds_qos::OwnershipKind::Shared,
10995                ownership_strength: 0,
10996                partition: alloc::vec![],
10997                user_data: alloc::vec![],
10998                topic_data: alloc::vec![],
10999                group_data: alloc::vec![],
11000                type_identifier: zerodds_types::TypeIdentifier::None,
11001                data_representation_offer: None,
11002            })
11003            .expect("register writer");
11004        let (_reader_eid, rx) = rt
11005            .register_user_reader(UserReaderConfig {
11006                topic_name: "IntraTopic".into(),
11007                type_name: "IntraType".into(),
11008                reliable: true,
11009                durability: zerodds_qos::DurabilityKind::Volatile,
11010                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11011                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11012                ownership: zerodds_qos::OwnershipKind::Shared,
11013                partition: alloc::vec![],
11014                user_data: alloc::vec![],
11015                topic_data: alloc::vec![],
11016                group_data: alloc::vec![],
11017                type_identifier: zerodds_types::TypeIdentifier::None,
11018                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11019                data_representation_offer: None,
11020            })
11021            .expect("register reader");
11022
11023        rt.write_user_sample(writer_eid, b"hello-intra-runtime".to_vec())
11024            .expect("write");
11025
11026        // Same-runtime loopback is synchronous in the write_user_sample_borrowed
11027        // path — `recv_timeout` needs only microseconds, not the
11028        // wire roundtrip.
11029        let sample = rx
11030            .recv_timeout(core::time::Duration::from_millis(100))
11031            .expect("intra-runtime reader should receive sample");
11032        match sample {
11033            UserSample::Alive { payload, .. } => {
11034                assert_eq!(payload.as_ref(), b"hello-intra-runtime");
11035            }
11036            other => panic!("expected Alive, got {other:?}"),
11037        }
11038        rt.shutdown();
11039    }
11040
11041    #[test]
11042    fn intra_runtime_loopback_not_matched_on_different_topic() {
11043        // Negative test: writer on TopicA, reader on TopicB — no
11044        // intra-runtime match, no sample. Prevents the
11045        // routing table from topic-blindly merging everything.
11046        let rt = DcpsRuntime::start(
11047            18,
11048            GuidPrefix::from_bytes([0x43; 12]),
11049            RuntimeConfig::default(),
11050        )
11051        .expect("start runtime");
11052        let writer_eid = rt
11053            .register_user_writer(UserWriterConfig {
11054                topic_name: "TopicA".into(),
11055                type_name: "TypeA".into(),
11056                reliable: true,
11057                durability: zerodds_qos::DurabilityKind::Volatile,
11058                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11059                lifespan: zerodds_qos::LifespanQosPolicy::default(),
11060                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11061                ownership: zerodds_qos::OwnershipKind::Shared,
11062                ownership_strength: 0,
11063                partition: alloc::vec![],
11064                user_data: alloc::vec![],
11065                topic_data: alloc::vec![],
11066                group_data: alloc::vec![],
11067                type_identifier: zerodds_types::TypeIdentifier::None,
11068                data_representation_offer: None,
11069            })
11070            .expect("register writer");
11071        let (_reader_eid, rx) = rt
11072            .register_user_reader(UserReaderConfig {
11073                topic_name: "TopicB".into(),
11074                type_name: "TypeB".into(),
11075                reliable: true,
11076                durability: zerodds_qos::DurabilityKind::Volatile,
11077                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11078                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11079                ownership: zerodds_qos::OwnershipKind::Shared,
11080                partition: alloc::vec![],
11081                user_data: alloc::vec![],
11082                topic_data: alloc::vec![],
11083                group_data: alloc::vec![],
11084                type_identifier: zerodds_types::TypeIdentifier::None,
11085                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11086                data_representation_offer: None,
11087            })
11088            .expect("register reader");
11089
11090        rt.write_user_sample(writer_eid, b"should-not-arrive".to_vec())
11091            .expect("write");
11092
11093        match rx.recv_timeout(core::time::Duration::from_millis(50)) {
11094            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { /* expected */ }
11095            other => panic!("reader on different topic must not receive: got {other:?}"),
11096        }
11097        rt.shutdown();
11098    }
11099
11100    #[test]
11101    fn runtime_starts_and_shuts_down_cleanly() {
11102        let rt = DcpsRuntime::start(
11103            42,
11104            GuidPrefix::from_bytes([7; 12]),
11105            RuntimeConfig::default(),
11106        )
11107        .expect("start runtime");
11108        assert_eq!(rt.domain_id, 42);
11109        // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): the SameHostTracker
11110        // must be initially empty and a same-host match (manually
11111        // simulated, without SEDP setup) must produce a `Pending`
11112        // entry. The real SEDP hook trigger is the job of the E2E
11113        // test in wave 4c — here only a smoke test of the wiring point.
11114        assert!(rt.same_host.is_empty(), "fresh runtime: no same-host pairs");
11115        let local_writer = zerodds_rtps::wire_types::Guid::new(
11116            rt.guid_prefix,
11117            zerodds_rtps::wire_types::EntityId::user_writer_with_key([1, 2, 3]),
11118        );
11119        let same_host_reader = zerodds_rtps::wire_types::Guid::new(
11120            rt.guid_prefix,
11121            zerodds_rtps::wire_types::EntityId::user_reader_with_key([4, 5, 6]),
11122        );
11123        rt.same_host
11124            .register_pending(local_writer, same_host_reader);
11125        assert_eq!(rt.same_host.len(), 1);
11126        assert!(matches!(
11127            rt.same_host.lookup(local_writer, same_host_reader),
11128            Some(crate::same_host::SameHostState::Pending)
11129        ));
11130        // Shutdown is idempotent.
11131        rt.shutdown();
11132        rt.shutdown();
11133    }
11134
11135    #[test]
11136    fn spdp_announces_standard_bits_by_default() {
11137        // Default config (without security): standard bits + WLP bits 10/11
11138        // + TypeLookup bits 12/13 must be announced along;
11139        // secure bits 16..27 + SEDP-topics bits 28/29 must NOT
11140        // be set. Topics bits are optional per RTPS 2.5 §8.5.4.4
11141        // — ZeroDDS does not implement the native topic endpoints
11142        // (synthetic DCPSTopic derivation from pub/sub covers the
11143        // end-user need), so we do not announce the capability
11144        // either.
11145        let rt = DcpsRuntime::start(
11146            5,
11147            GuidPrefix::from_bytes([0xC; 12]),
11148            RuntimeConfig::default(),
11149        )
11150        .expect("start");
11151        let mask = rt.announced_builtin_endpoint_set();
11152        // Standard bits + WLP + TypeLookup.
11153        assert_ne!(mask & endpoint_flag::PARTICIPANT_ANNOUNCER, 0);
11154        assert_ne!(mask & endpoint_flag::PARTICIPANT_DETECTOR, 0);
11155        assert_ne!(mask & endpoint_flag::PUBLICATIONS_ANNOUNCER, 0);
11156        assert_ne!(mask & endpoint_flag::SUBSCRIPTIONS_DETECTOR, 0);
11157        assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_WRITER, 0);
11158        assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_READER, 0);
11159        assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REQUEST, 0);
11160        assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REPLY, 0);
11161        // Do NOT set the SEDP-topics bits — covered synthetically.
11162        assert_eq!(mask & endpoint_flag::TOPICS_ANNOUNCER, 0);
11163        assert_eq!(mask & endpoint_flag::TOPICS_DETECTOR, 0);
11164        // No secure bits without explicit announce_secure_endpoints.
11165        assert_eq!(mask & endpoint_flag::ALL_SECURE, 0);
11166    }
11167
11168    #[test]
11169    fn spdp_announces_secure_bits_when_configured() {
11170        // With announce_secure_endpoints=true all 12 secure
11171        // bits (16..27) must be set.
11172        let config = RuntimeConfig {
11173            announce_secure_endpoints: true,
11174            ..Default::default()
11175        };
11176        let rt = DcpsRuntime::start(6, GuidPrefix::from_bytes([0xD; 12]), config).expect("start");
11177        let mask = rt.announced_builtin_endpoint_set();
11178        for bit in 16u32..=27 {
11179            assert!(
11180                mask & (1u32 << bit) != 0,
11181                "secure bit {bit} missing in the SPDP announce"
11182            );
11183        }
11184        // Standard bits must still be set.
11185        assert_eq!(
11186            mask & endpoint_flag::ALL_STANDARD,
11187            endpoint_flag::ALL_STANDARD
11188        );
11189    }
11190
11191    #[test]
11192    fn spdp_lease_duration_is_configurable() {
11193        // Default 100 s (spec). The override of 17 s must arrive in the beacon.
11194        let config = RuntimeConfig {
11195            participant_lease_duration: Duration::from_secs(17),
11196            ..Default::default()
11197        };
11198        let rt = DcpsRuntime::start(7, GuidPrefix::from_bytes([0xE; 12]), config).expect("start");
11199        let secs = rt
11200            .spdp_beacon
11201            .lock()
11202            .map(|b| b.data.lease_duration.seconds)
11203            .unwrap_or(0);
11204        assert_eq!(secs, 17);
11205    }
11206
11207    #[test]
11208    fn user_locator_is_udp_v4_127_0_0_x() {
11209        let rt = DcpsRuntime::start(
11210            0,
11211            GuidPrefix::from_bytes([0xA; 12]),
11212            RuntimeConfig::default(),
11213        )
11214        .expect("start");
11215        let loc = rt.user_locator();
11216        assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
11217        // Port > 0 (ephemeral).
11218        assert!(loc.port > 0);
11219    }
11220
11221    #[test]
11222    fn two_runtimes_on_same_domain_can_coexist() {
11223        // The SPDP multicast port is SO_REUSE in our bind.
11224        let a = DcpsRuntime::start(
11225            3,
11226            GuidPrefix::from_bytes([0xA; 12]),
11227            RuntimeConfig::default(),
11228        )
11229        .expect("a");
11230        let b = DcpsRuntime::start(
11231            3,
11232            GuidPrefix::from_bytes([0xB; 12]),
11233            RuntimeConfig::default(),
11234        )
11235        .expect("b");
11236        assert_eq!(a.domain_id, b.domain_id);
11237    }
11238
11239    #[test]
11240    fn peer_capabilities_unknown_peer_returns_none() {
11241        let rt = DcpsRuntime::start(
11242            10,
11243            GuidPrefix::from_bytes([0x60; 12]),
11244            RuntimeConfig::default(),
11245        )
11246        .expect("start");
11247        // A fresh runtime has discovered no peer.
11248        let caps = rt.peer_capabilities(&GuidPrefix::from_bytes([0xEE; 12]));
11249        assert!(caps.is_none());
11250    }
11251
11252    #[test]
11253    fn assert_liveliness_enqueues_wlp_pulse_without_panic() {
11254        // Smoke test: assert_liveliness() must not poison the lock
11255        // and must return synchronously.
11256        let rt = DcpsRuntime::start(
11257            8,
11258            GuidPrefix::from_bytes([0xF; 12]),
11259            RuntimeConfig::default(),
11260        )
11261        .expect("start");
11262        rt.assert_liveliness();
11263        rt.assert_writer_liveliness(alloc::vec![0xDE, 0xAD]);
11264        // The lock must stay usable.
11265        let count = rt.wlp.lock().map(|w| w.peer_count()).unwrap_or(usize::MAX);
11266        assert_eq!(count, 0, "no peer announced itself → 0");
11267    }
11268
11269    #[test]
11270    fn wlp_period_default_is_lease_over_three() {
11271        // With the default lease of 100 s → wlp_period = 33.33 s.
11272        let rt = DcpsRuntime::start(
11273            9,
11274            GuidPrefix::from_bytes([0x10; 12]),
11275            RuntimeConfig::default(),
11276        )
11277        .expect("start");
11278        // We cannot read the value directly; but we
11279        // know: tick_period > 30 s means the default lease was
11280        // used. Enqueue a pulse and tick — it must fire,
11281        // the next AUTOMATIC comes only in 33 s.
11282        let mut wlp = rt.wlp.lock().unwrap();
11283        wlp.assert_participant();
11284        let now0 = Duration::from_secs(0);
11285        let dg = wlp.tick(now0).unwrap();
11286        assert!(dg.is_some(), "pulse is emitted immediately");
11287    }
11288
11289    // Multicast loopback is unreliable on macOS (no auto-
11290    // interface-join with bind_multicast_v4(0.0.0.0)). On Linux
11291    // it works out of the box; there the test will run in CI.
11292    #[cfg(target_os = "linux")]
11293    #[test]
11294    fn two_runtimes_exchange_wlp_heartbeat_via_multicast() {
11295        // .D-e: A sends periodic WLP heartbeats. B must
11296        // know its own WLP endpoint with A's prefix as a peer
11297        // within ~3 tick periods.
11298        let cfg = RuntimeConfig {
11299            tick_period: Duration::from_millis(20),
11300            spdp_period: Duration::from_millis(100),
11301            // Aggressive WLP period for fast tests.
11302            wlp_period: Duration::from_millis(80),
11303            participant_lease_duration: Duration::from_millis(240),
11304            ..RuntimeConfig::default()
11305        };
11306        let _a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x40; 12]), cfg.clone()).expect("a");
11307        let _b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x41; 12]), cfg).expect("b");
11308
11309        let a_prefix = GuidPrefix::from_bytes([0x40; 12]);
11310        for _ in 0..60 {
11311            thread::sleep(Duration::from_millis(50));
11312            if _b.peer_liveliness_last_seen(&a_prefix).is_some() {
11313                return;
11314            }
11315        }
11316        panic!("B did not see A's WLP heartbeat within 3 s");
11317    }
11318
11319    #[cfg(target_os = "linux")]
11320    #[test]
11321    fn two_runtimes_assert_liveliness_reaches_peer() {
11322        // The Manual-By-Participant pulse must arrive at the peer, the
11323        // last-seen timestamp must reset compared to purely Automatic
11324        // beats. Since the pulse goes out synchronously on the next
11325        // tick, a short wait suffices.
11326        let cfg = RuntimeConfig {
11327            tick_period: Duration::from_millis(20),
11328            spdp_period: Duration::from_millis(100),
11329            // WLP period large enough that no AUTOMATIC beat comes
11330            // in between within the test. The manual pulse queue
11331            // is processed before the AUTOMATIC slot.
11332            wlp_period: Duration::from_secs(3600),
11333            ..RuntimeConfig::default()
11334        };
11335        let a = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x50; 12]), cfg.clone()).expect("a");
11336        let b = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x51; 12]), cfg).expect("b");
11337
11338        a.assert_liveliness();
11339        let a_prefix = GuidPrefix::from_bytes([0x50; 12]);
11340        for _ in 0..60 {
11341            thread::sleep(Duration::from_millis(50));
11342            if b.peer_liveliness_last_seen(&a_prefix).is_some() {
11343                return;
11344            }
11345        }
11346        // In case of multicast-loopback problems, at least check A's
11347        // own pulse counter.
11348        panic!("B did not see A's manual liveliness assert within 3 s");
11349    }
11350
11351    #[cfg(target_os = "linux")]
11352    #[test]
11353    fn two_runtimes_exchange_sedp_publication_announce() {
11354        // E2E smoke: A announces a publication, B sees it
11355        // via SEDP. Assumes SPDP works (so that
11356        // the SEDP peer proxies get wired).
11357        use zerodds_qos::{DurabilityKind, ReliabilityKind};
11358        use zerodds_rtps::publication_data::PublicationBuiltinTopicData;
11359
11360        let cfg = RuntimeConfig {
11361            tick_period: Duration::from_millis(20),
11362            spdp_period: Duration::from_millis(100),
11363            ..RuntimeConfig::default()
11364        };
11365        // Own domain, so the test does not collide with the SPDP-only test
11366        // on domain 0 over the multicast port.
11367        let a = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xCC; 12]), cfg.clone()).expect("a");
11368        let b = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xDD; 12]), cfg).expect("b");
11369
11370        // Wait until both see each other via SPDP.
11371        for _ in 0..40 {
11372            thread::sleep(Duration::from_millis(50));
11373            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
11374                break;
11375            }
11376        }
11377        assert!(
11378            !a.discovered_participants().is_empty(),
11379            "no SPDP discovery a"
11380        );
11381
11382        // A announces a publication for topic "Chatter" with type "RawBytes".
11383        let pub_data = PublicationBuiltinTopicData {
11384            key: Guid::new(
11385                a.guid_prefix,
11386                EntityId::user_writer_with_key([0x01, 0x02, 0x03]),
11387            ),
11388            participant_key: Guid::new(a.guid_prefix, EntityId::PARTICIPANT),
11389            topic_name: "Chatter".into(),
11390            type_name: "zerodds::RawBytes".into(),
11391            durability: DurabilityKind::Volatile,
11392            reliability: zerodds_qos::ReliabilityQosPolicy {
11393                kind: ReliabilityKind::Reliable,
11394                max_blocking_time: QosDuration::from_millis(100_i32),
11395            },
11396            ownership: zerodds_qos::OwnershipKind::Shared,
11397            ownership_strength: 0,
11398            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11399            deadline: zerodds_qos::DeadlineQosPolicy::default(),
11400            lifespan: zerodds_qos::LifespanQosPolicy::default(),
11401            partition: Vec::new(),
11402            user_data: Vec::new(),
11403            topic_data: Vec::new(),
11404            group_data: Vec::new(),
11405            type_information: None,
11406            data_representation: Vec::new(),
11407            security_info: None,
11408            service_instance_name: None,
11409            related_entity_guid: None,
11410            topic_aliases: None,
11411            type_identifier: zerodds_types::TypeIdentifier::None,
11412            unicast_locators: Vec::new(),
11413            multicast_locators: Vec::new(),
11414        };
11415        a.announce_publication(&pub_data).expect("announce");
11416
11417        // B should have the publication in the cache within ~3 s.
11418        // CI on shared runners has more jitter, 1 s was too tight.
11419        for _ in 0..60 {
11420            thread::sleep(Duration::from_millis(50));
11421            if b.discovered_publications_count() > 0 {
11422                return;
11423            }
11424        }
11425        panic!(
11426            "B did not receive SEDP publication within 3 s (pub_count={})",
11427            b.discovered_publications_count()
11428        );
11429    }
11430
11431    #[cfg(target_os = "linux")]
11432    #[test]
11433    fn two_runtimes_e2e_user_data_match_and_transfer() {
11434        // E2E smoke: kompletter Pfad
11435        //   Runtime-A register_user_writer(topic, type)
11436        //   Runtime-B register_user_reader(topic, type)
11437        //   SEDP match, writer add_reader_proxy, reader add_writer_proxy
11438        //   A.write_user_sample(payload) → UDP → B's mpsc::Receiver
11439        //
11440        // Eigene Domain (2) um Kollisionen zu vermeiden.
11441        let cfg = RuntimeConfig {
11442            tick_period: Duration::from_millis(20),
11443            spdp_period: Duration::from_millis(100),
11444            ..RuntimeConfig::default()
11445        };
11446        let a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xEE; 12]), cfg.clone()).expect("a");
11447        let b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xFF; 12]), cfg).expect("b");
11448
11449        // SPDP mutual — 3 s Budget.
11450        let mut spdp_ok = false;
11451        for _ in 0..60 {
11452            thread::sleep(Duration::from_millis(50));
11453            if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
11454                spdp_ok = true;
11455                break;
11456            }
11457        }
11458        assert!(spdp_ok, "SPDP mutual discovery did not complete in 3 s");
11459
11460        // Register endpoints. A publish, B subscribe.
11461        let wid = a
11462            .register_user_writer(UserWriterConfig {
11463                topic_name: "Chatter".into(),
11464                type_name: "zerodds::RawBytes".into(),
11465                reliable: true,
11466                durability: zerodds_qos::DurabilityKind::Volatile,
11467                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11468                lifespan: zerodds_qos::LifespanQosPolicy::default(),
11469                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11470                ownership: zerodds_qos::OwnershipKind::Shared,
11471                ownership_strength: 0,
11472                partition: Vec::new(),
11473                user_data: Vec::new(),
11474                topic_data: Vec::new(),
11475                group_data: Vec::new(),
11476                type_identifier: zerodds_types::TypeIdentifier::None,
11477                data_representation_offer: None,
11478            })
11479            .expect("wid");
11480        let (_rid, rx) = b
11481            .register_user_reader(UserReaderConfig {
11482                topic_name: "Chatter".into(),
11483                type_name: "zerodds::RawBytes".into(),
11484                reliable: true,
11485                durability: zerodds_qos::DurabilityKind::Volatile,
11486                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11487                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11488                ownership: zerodds_qos::OwnershipKind::Shared,
11489                partition: Vec::new(),
11490                user_data: Vec::new(),
11491                topic_data: Vec::new(),
11492                group_data: Vec::new(),
11493                type_identifier: zerodds_types::TypeIdentifier::None,
11494                type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11495                data_representation_offer: None,
11496            })
11497            .expect("rid");
11498
11499        // SEDP match + User-Data-Flow. `add_reader_proxy` triggert
11500        // a heartbeat immediately (RTPS §8.4.15.4), so ~tick_period
11501        // (20 ms) + response-delay (200 ms) + resend ≈ 300 ms in
11502        // idle state. A 4 s budget suffices even with CI jitter.
11503        let mut attempts = 0;
11504        loop {
11505            thread::sleep(Duration::from_millis(50));
11506            let _ = a.write_user_sample(wid, alloc::vec![0xAA, 0xBB, 0xCC]);
11507            if let Ok(sample) = rx.recv_timeout(Duration::from_millis(50)) {
11508                match sample {
11509                    UserSample::Alive { payload, .. } => {
11510                        assert_eq!(payload.as_slice(), &[0xAA, 0xBB, 0xCC][..]);
11511                        return;
11512                    }
11513                    other => panic!("expected Alive sample, got {other:?}"),
11514                }
11515            }
11516            attempts += 1;
11517            if attempts > 80 {
11518                panic!("no sample delivered within 4 s");
11519            }
11520        }
11521    }
11522
11523    #[cfg(target_os = "linux")]
11524    #[test]
11525    fn two_runtimes_discover_each_other_via_spdp() {
11526        // We use a tight SPDP period so the test does not wait 5 s.
11527        let cfg = RuntimeConfig {
11528            tick_period: Duration::from_millis(20),
11529            spdp_period: Duration::from_millis(100),
11530            ..RuntimeConfig::default()
11531        };
11532        // Eigene Domain 3 (SEDP=1, E2E=2) um Cross-Test-Kollision zu vermeiden.
11533        let a = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xAA; 12]), cfg.clone()).expect("a");
11534        let b = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xBB; 12]), cfg).expect("b");
11535
11536        // Give the loop time for 2-3 beacon rounds. Multicast on
11537        // loopback is somewhat timing-sensitive when parallel tests
11538        // share the multicast group — hence 60 iterations of 50 ms
11539        // = 3 s budget instead of 1 s.
11540        for _ in 0..60 {
11541            thread::sleep(Duration::from_millis(50));
11542            let a_sees_b = a
11543                .discovered_participants()
11544                .iter()
11545                .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xBB; 12]));
11546            let b_sees_a = b
11547                .discovered_participants()
11548                .iter()
11549                .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xAA; 12]));
11550            if a_sees_b && b_sees_a {
11551                return;
11552            }
11553        }
11554        panic!(
11555            "mutual SPDP discovery failed within 3 s (a={} b={})",
11556            a.discovered_participants().len(),
11557            b.discovered_participants().len()
11558        );
11559    }
11560
11561    // =======================================================================
11562    // Security: Writer-Side Per-Reader-Serializer
11563    // =======================================================================
11564
11565    #[cfg(feature = "security")]
11566    #[test]
11567    fn per_target_serializer_produces_different_wire_per_reader() {
11568        use zerodds_security_crypto::AesGcmCryptoPlugin;
11569        use zerodds_security_permissions::parse_governance_xml;
11570        use zerodds_security_runtime::{
11571            PeerCapabilities, ProtectionLevel as SecProtectionLevel, SharedSecurityGate,
11572        };
11573
11574        // The governance enforces ENCRYPT on domain 0 — the default
11575        // path (transform_outbound) wraps too. A per-reader override
11576        // can still deliver plaintext if the reader is legacy.
11577        const GOV: &str = r#"
11578<domain_access_rules>
11579  <domain_rule>
11580    <domains><id>0</id></domains>
11581    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11582    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11583  </domain_rule>
11584</domain_access_rules>
11585"#;
11586        let gate = SharedSecurityGate::new(
11587            0,
11588            parse_governance_xml(GOV).unwrap(),
11589            Box::new(AesGcmCryptoPlugin::new()),
11590        );
11591
11592        let cfg = RuntimeConfig {
11593            security: Some(std::sync::Arc::new(gate)),
11594            ..RuntimeConfig::default()
11595        };
11596        let rt =
11597            DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE4; 12]), cfg).expect("start runtime");
11598
11599        let wid = rt
11600            .register_user_writer(UserWriterConfig {
11601                topic_name: "HeteroTopic".into(),
11602                type_name: "zerodds::RawBytes".into(),
11603                reliable: true,
11604                durability: zerodds_qos::DurabilityKind::Volatile,
11605                deadline: zerodds_qos::DeadlineQosPolicy::default(),
11606                lifespan: zerodds_qos::LifespanQosPolicy::default(),
11607                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11608                ownership: zerodds_qos::OwnershipKind::Shared,
11609                ownership_strength: 0,
11610                partition: Vec::new(),
11611                user_data: Vec::new(),
11612                topic_data: Vec::new(),
11613                group_data: Vec::new(),
11614                type_identifier: zerodds_types::TypeIdentifier::None,
11615                data_representation_offer: None,
11616            })
11617            .expect("register writer");
11618
11619        // Drei fiktive Reader-Targets — eines pro Protection-Klasse.
11620        let legacy_loc = Locator::udp_v4([127, 0, 0, 11], 40001);
11621        let fast_loc = Locator::udp_v4([127, 0, 0, 12], 40002);
11622        let secure_loc = Locator::udp_v4([127, 0, 0, 13], 40003);
11623        let legacy_peer: [u8; 12] = [0x11; 12];
11624        let fast_peer: [u8; 12] = [0x22; 12];
11625        let secure_peer: [u8; 12] = [0x33; 12];
11626
11627        // Simulates the SEDP match: populate the writer-slot maps.
11628        {
11629            let arc = rt.writer_slot(wid).unwrap();
11630            let mut slot = arc.lock().unwrap();
11631            slot.reader_protection
11632                .insert(legacy_peer, SecProtectionLevel::None);
11633            slot.reader_protection
11634                .insert(fast_peer, SecProtectionLevel::Sign);
11635            slot.reader_protection
11636                .insert(secure_peer, SecProtectionLevel::Encrypt);
11637            slot.locator_to_peer.insert(legacy_loc, legacy_peer);
11638            slot.locator_to_peer.insert(fast_loc, fast_peer);
11639            slot.locator_to_peer.insert(secure_loc, secure_peer);
11640        }
11641
11642        // Fiktive Writer-Datagram-Bytes (RTPS-Header + User-Payload).
11643        let mut msg = Vec::new();
11644        msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
11645        msg.extend_from_slice(&[0xE4; 12]); // GuidPrefix
11646        msg.extend_from_slice(b"HELLO-HETERO");
11647
11648        let wire_legacy =
11649            secure_outbound_for_target(&rt, wid, &msg, &legacy_loc).expect("legacy path");
11650        let wire_fast = secure_outbound_for_target(&rt, wid, &msg, &fast_loc).expect("fast path");
11651        let wire_secure =
11652            secure_outbound_for_target(&rt, wid, &msg, &secure_loc).expect("secure path");
11653
11654        // Spec §8.4.2.4: under rtps_protection_kind=ENCRYPT EVERY message MUST
11655        // be SRTPS-wrapped — even a legacy reader (data-level None) may
11656        // get NO plaintext, otherwise user DATA leaks on a protected
11657        // domain. The per-reader data level only controls the inner payload/
11658        // submessage layer, not the outer rtps_protection.
11659        assert_ne!(
11660            wire_legacy, msg,
11661            "legacy under rtps_protection=ENCRYPT MUST be SRTPS-wrapped (no plaintext leak)"
11662        );
11663        assert_ne!(wire_fast, msg, "fast reader must be protected");
11664        assert_ne!(wire_secure, msg, "secure reader must be protected");
11665
11666        // Heterogeneity proof: the three wires are pairwise
11667        // different (each with its own nonce/session counter in SRTPS).
11668        assert_ne!(wire_legacy, wire_fast);
11669        assert_ne!(wire_legacy, wire_secure);
11670        assert_ne!(wire_fast, wire_secure);
11671
11672        // Without a locator match the fallback must take the domain-rule path
11673        // — this governance requires ENCRYPT, so SRTPS-wrapped.
11674        let unknown_loc = Locator::udp_v4([127, 0, 0, 99], 40099);
11675        let wire_unknown =
11676            secure_outbound_for_target(&rt, wid, &msg, &unknown_loc).expect("fallback path");
11677        assert_ne!(
11678            wire_unknown, msg,
11679            "unknown target should be protected via the domain rule"
11680        );
11681
11682        // The absence of the PeerCapabilities type is a compile check:
11683        // the import shows that the entire per-reader structure
11684        // is available in the dcps integration.
11685        let _unused: PeerCapabilities = PeerCapabilities::default();
11686
11687        rt.shutdown();
11688    }
11689
11690    // =======================================================================
11691    // Security: Reader-Side Per-Writer-Validator + Logging
11692    // =======================================================================
11693
11694    #[cfg(feature = "security")]
11695    #[derive(Default, Clone)]
11696    struct CapturingLogger {
11697        inner: std::sync::Arc<
11698            std::sync::Mutex<Vec<(zerodds_security_runtime::LogLevel, String, String)>>,
11699        >,
11700    }
11701
11702    #[cfg(feature = "security")]
11703    impl CapturingLogger {
11704        fn events(&self) -> Vec<(zerodds_security_runtime::LogLevel, String, String)> {
11705            self.inner.lock().map(|g| g.clone()).unwrap_or_default()
11706        }
11707    }
11708
11709    #[cfg(feature = "security")]
11710    impl zerodds_security_runtime::LoggingPlugin for CapturingLogger {
11711        fn log(
11712            &self,
11713            level: zerodds_security_runtime::LogLevel,
11714            _participant: [u8; 16],
11715            category: &str,
11716            message: &str,
11717        ) {
11718            if let Ok(mut g) = self.inner.lock() {
11719                g.push((level, category.to_string(), message.to_string()));
11720            }
11721        }
11722        fn plugin_class_id(&self) -> &str {
11723            "zerodds.test.capturing_logger"
11724        }
11725    }
11726
11727    #[cfg(feature = "security")]
11728    fn build_runtime_with(
11729        gov_xml: &str,
11730        logger: std::sync::Arc<CapturingLogger>,
11731    ) -> std::sync::Arc<DcpsRuntime> {
11732        use zerodds_security_crypto::AesGcmCryptoPlugin;
11733        use zerodds_security_permissions::parse_governance_xml;
11734        use zerodds_security_runtime::{LoggingPlugin, SharedSecurityGate};
11735        let gate = SharedSecurityGate::new(
11736            0,
11737            parse_governance_xml(gov_xml).unwrap(),
11738            Box::new(AesGcmCryptoPlugin::new()),
11739        );
11740        let logger_dyn: std::sync::Arc<dyn LoggingPlugin> = logger;
11741        let cfg = RuntimeConfig {
11742            security: Some(std::sync::Arc::new(gate)),
11743            security_logger: Some(logger_dyn),
11744            ..RuntimeConfig::default()
11745        };
11746        DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE7; 12]), cfg).expect("start rt")
11747    }
11748
11749    #[cfg(feature = "security")]
11750    #[test]
11751    fn inbound_plain_on_encrypt_domain_drops_with_error_event() {
11752        // DoD plan §stage 5: writer sends plain, policy expects
11753        // ENCRYPT → Reader droppt. Ohne allow_unauthenticated ist
11754        // this a "LegacyBlocked" → error level (not warning) per
11755        // the plan spec "missing-caps = Error".
11756        const GOV_ENCRYPT: &str = r#"
11757<domain_access_rules>
11758  <domain_rule>
11759    <domains><id>0</id></domains>
11760    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11761    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11762  </domain_rule>
11763</domain_access_rules>
11764"#;
11765        let logger = std::sync::Arc::new(CapturingLogger::default());
11766        let rt = build_runtime_with(GOV_ENCRYPT, std::sync::Arc::clone(&logger));
11767
11768        // Plain-RTPS-Datagram (header + body).
11769        let mut plain = Vec::new();
11770        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
11771        plain.extend_from_slice(&[0x77; 12]); // attacker guid_prefix
11772        plain.extend_from_slice(b"plaintext-on-encrypted-domain");
11773
11774        let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan);
11775        assert!(out.is_none(), "tampering packet must be dropped");
11776
11777        let events = logger.events();
11778        assert_eq!(events.len(), 1, "exactly one log event expected");
11779        let (level, category, _msg) = &events[0];
11780        assert_eq!(
11781            *level,
11782            zerodds_security_runtime::LogLevel::Error,
11783            "plain-on-protected-domain without allow_unauth = Error (LegacyBlocked)"
11784        );
11785        assert_eq!(category, "inbound.legacy_blocked");
11786        rt.shutdown();
11787    }
11788
11789    #[cfg(feature = "security")]
11790    #[test]
11791    fn inbound_legacy_peer_accepted_when_governance_allows_unauth() {
11792        // DoD plan §stage 5: the legacy peer can keep talking to the reader,
11793        // when the governance sets allow_unauthenticated_participants=true.
11794        const GOV: &str = r#"
11795<domain_access_rules>
11796  <domain_rule>
11797    <domains><id>0</id></domains>
11798    <allow_unauthenticated_participants>TRUE</allow_unauthenticated_participants>
11799    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11800    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11801  </domain_rule>
11802</domain_access_rules>
11803"#;
11804        let logger = std::sync::Arc::new(CapturingLogger::default());
11805        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
11806
11807        let mut plain = Vec::new();
11808        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
11809        plain.extend_from_slice(&[0x88; 12]);
11810        plain.extend_from_slice(b"legacy-but-allowed");
11811
11812        let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan)
11813            .expect("legacy peer must be accepted");
11814        assert_eq!(out, plain, "output is byte-identical (no crypto unwrap)");
11815        assert!(
11816            logger.events().is_empty(),
11817            "no log event on the accept path"
11818        );
11819        rt.shutdown();
11820    }
11821
11822    #[cfg(feature = "security")]
11823    #[test]
11824    fn inbound_malformed_drops_and_logs_error() {
11825        const GOV: &str = r#"
11826<domain_access_rules>
11827  <domain_rule>
11828    <domains><id>0</id></domains>
11829    <rtps_protection_kind>NONE</rtps_protection_kind>
11830    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11831  </domain_rule>
11832</domain_access_rules>
11833"#;
11834        let logger = std::sync::Arc::new(CapturingLogger::default());
11835        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
11836
11837        let out = secure_inbound_bytes(&rt, &[1, 2, 3, 4], &NetInterface::Wan);
11838        assert!(out.is_none());
11839        let events = logger.events();
11840        assert_eq!(events.len(), 1);
11841        assert_eq!(events[0].0, zerodds_security_runtime::LogLevel::Error);
11842        assert_eq!(events[0].1, "inbound.malformed");
11843        rt.shutdown();
11844    }
11845
11846    #[cfg(feature = "security")]
11847    #[test]
11848    fn inbound_without_security_gate_bypasses_classify_and_logger() {
11849        // Without a security gate: passthrough, no log event.
11850        let logger = std::sync::Arc::new(CapturingLogger::default());
11851        let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
11852            std::sync::Arc::clone(&logger) as _;
11853        let cfg = RuntimeConfig {
11854            security_logger: Some(logger_dyn),
11855            ..RuntimeConfig::default()
11856        };
11857        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE8; 12]), cfg).unwrap();
11858        let msg = vec![0xAAu8; 40];
11859        let out = secure_inbound_bytes(&rt, &msg, &NetInterface::Wan).unwrap();
11860        assert_eq!(out, msg);
11861        assert!(
11862            logger.events().is_empty(),
11863            "the logger must NOT be called without a gate"
11864        );
11865        rt.shutdown();
11866    }
11867
11868    // =======================================================================
11869    // Security: Interface-Routing (Multi-Socket-Binding)
11870    // =======================================================================
11871
11872    #[cfg(feature = "security")]
11873    fn lo_range(third: u8) -> zerodds_security_runtime::IpRange {
11874        zerodds_security_runtime::IpRange {
11875            base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, third)),
11876            prefix_len: 32,
11877        }
11878    }
11879
11880    #[cfg(feature = "security")]
11881    #[test]
11882    fn outbound_pool_routes_target_to_matching_binding() {
11883        let specs = vec![
11884            InterfaceBindingSpec {
11885                name: "lo-a".into(),
11886                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11887                bind_port: 0,
11888                kind: zerodds_security_runtime::NetInterface::Loopback,
11889                subnet: lo_range(11),
11890                default: false,
11891            },
11892            InterfaceBindingSpec {
11893                name: "lo-b".into(),
11894                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11895                bind_port: 0,
11896                kind: zerodds_security_runtime::NetInterface::Wan,
11897                subnet: lo_range(22),
11898                default: true,
11899            },
11900        ];
11901        let pool = OutboundSocketPool::bind_all(&specs).expect("pool");
11902
11903        // Exact match on the first subnet -> lo-a.
11904        let t1 = Locator::udp_v4([127, 0, 0, 11], 40000);
11905        let (sock1, iface1) = pool.route(&t1).expect("route 1");
11906        assert_eq!(iface1, zerodds_security_runtime::NetInterface::Loopback);
11907
11908        // Exact match on the second subnet -> lo-b.
11909        let t2 = Locator::udp_v4([127, 0, 0, 22], 40000);
11910        let (sock2, iface2) = pool.route(&t2).expect("route 2");
11911        assert_eq!(iface2, zerodds_security_runtime::NetInterface::Wan);
11912
11913        // The two sockets must have different local ports.
11914        let p1 = sock1.local_locator().port;
11915        let p2 = sock2.local_locator().port;
11916        assert_ne!(p1, p2);
11917    }
11918
11919    #[cfg(feature = "security")]
11920    #[test]
11921    fn outbound_pool_falls_back_to_default_when_no_subnet_matches() {
11922        let specs = vec![
11923            InterfaceBindingSpec {
11924                name: "lo-specific".into(),
11925                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11926                bind_port: 0,
11927                kind: zerodds_security_runtime::NetInterface::Loopback,
11928                subnet: lo_range(33),
11929                default: false,
11930            },
11931            InterfaceBindingSpec {
11932                name: "wan-default".into(),
11933                bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11934                bind_port: 0,
11935                kind: zerodds_security_runtime::NetInterface::Wan,
11936                subnet: zerodds_security_runtime::IpRange {
11937                    base: core::net::IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED),
11938                    prefix_len: 0,
11939                },
11940                default: true,
11941            },
11942        ];
11943        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
11944        let unknown = Locator::udp_v4([192, 168, 7, 7], 12345);
11945        let (_sock, iface) = pool.route(&unknown).expect("default fallback");
11946        assert_eq!(iface, zerodds_security_runtime::NetInterface::Wan);
11947    }
11948
11949    #[cfg(feature = "security")]
11950    #[test]
11951    fn outbound_pool_returns_none_when_no_match_and_no_default() {
11952        let specs = vec![InterfaceBindingSpec {
11953            name: "only-lo".into(),
11954            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11955            bind_port: 0,
11956            kind: zerodds_security_runtime::NetInterface::Loopback,
11957            subnet: lo_range(44),
11958            default: false,
11959        }];
11960        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
11961        assert!(pool.route(&Locator::udp_v4([8, 8, 8, 8], 53)).is_none());
11962    }
11963
11964    #[cfg(feature = "security")]
11965    #[test]
11966    fn outbound_pool_skips_non_v4_locators() {
11967        let specs = vec![InterfaceBindingSpec {
11968            name: "lo".into(),
11969            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11970            bind_port: 0,
11971            kind: zerodds_security_runtime::NetInterface::Loopback,
11972            subnet: lo_range(55),
11973            default: true,
11974        }];
11975        let pool = OutboundSocketPool::bind_all(&specs).unwrap();
11976        // SHM locator (no IPv4) → no match; without a default it would be None,
11977        // here default=true and subnet-contains does not apply
11978        // because ipv4_from_locator returns None.
11979        let shm = Locator {
11980            kind: zerodds_rtps::wire_types::LocatorKind::Shm,
11981            port: 0,
11982            address: [0u8; 16],
11983        };
11984        assert!(pool.route(&shm).is_none());
11985    }
11986
11987    #[cfg(feature = "security")]
11988    #[test]
11989    fn dod_plaintext_lo_vs_srtps_wan_via_sniffer() {
11990        // Spec §8.4.2.4 (spec wins vs DoD loopback plaintext): under
11991        // rtps_protection_kind=ENCRYPT means bytes are SRTPS-wrapped on EVERY
11992        // interface — including loopback. The test proves that the
11993        // per-interface routing serves both targets AND both outputs
11994        // are spec-conformantly protected (no plaintext leak, regardless of which
11995        // binding).
11996        //
11997        // Setup:
11998        //  * 2 sniffer UDP sockets, one simulates a legacy
11999        //    loopback peer (expects plaintext), the other a
12000        //    WAN secure peer (expects SRTPS).
12001        //  * DcpsRuntime with a security gate (governance = ENCRYPT) and
12002        //    two interface bindings: lo-binding on 127.0.0.100,
12003        //    wan-binding auf 127.0.0.200.
12004        //  * 1 writer, 2 matched_readers with different protection
12005        //    (Legacy=None, Secure=Encrypt) and the respective sniffer
12006        //    Socket address as the locator_to_peer target.
12007        //  * `send_on_best_interface(rt, target, bytes)` is triggered
12008        //    manually; the sniffer per target receives and checks
12009        //    the wire format.
12010        use std::net::{SocketAddrV4, UdpSocket};
12011        use zerodds_security_crypto::AesGcmCryptoPlugin;
12012        use zerodds_security_permissions::parse_governance_xml;
12013        use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
12014
12015        const GOV: &str = r#"
12016<domain_access_rules>
12017  <domain_rule>
12018    <domains><id>0</id></domains>
12019    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12020    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12021  </domain_rule>
12022</domain_access_rules>
12023"#;
12024        // Two sniffer sockets on ephemeral loopback ports (independent
12025        // from our bindings; they act as "peer receivers").
12026        let lo_sniffer =
12027            UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).expect("lo sniffer");
12028        lo_sniffer
12029            .set_read_timeout(Some(Duration::from_millis(250)))
12030            .unwrap();
12031        let wan_sniffer = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0))
12032            .expect("wan sniffer");
12033        wan_sniffer
12034            .set_read_timeout(Some(Duration::from_millis(250)))
12035            .unwrap();
12036        let lo_port = lo_sniffer.local_addr().unwrap().port();
12037        let wan_port = wan_sniffer.local_addr().unwrap().port();
12038        let lo_target = Locator::udp_v4([127, 0, 0, 1], u32::from(lo_port));
12039        let wan_target = Locator::udp_v4([127, 0, 0, 1], u32::from(wan_port));
12040
12041        // Two bindings, subnet-matched to exactly these ports. Since
12042        // IpRange currently matches only on IP, we use two
12043        // different /32 host ranges as a trick:
12044        // we set both bindings to the same IP/32, but because
12045        // `route` takes the first subnet match, I list them such
12046        // that "lo-bind" comes first and then the default.
12047        //
12048        // Correct: both sniffers share 127.0.0.1/32 and the pool would
12049        // pick the first binding. To distinguish cleanly, we map
12050        // the binding decision by *target port* — that works
12051        // not today. So: we work around this subtlety by
12052        // calling `send_on_best_interface` directly for different targets
12053        // and assigning the binding by IP range —
12054        // the DoD checks the routing at the binding level, not the
12055        // socket layer.
12056        //
12057        // Pragmatically: we test end-to-end that the pool actually
12058        // picks the right interface socket for the target and
12059        // processes the bytes differently (plain vs SRTPS).
12060        // The target locators differ only in the port, but
12061        // `send_on_best_interface` gets them separately each. The
12062        // decisive point is: both bindings send **and** the
12063        // sniffer socket receives — proving the routing in combination
12064        // with the per-reader serializer from stage 4.
12065
12066        let bindings = vec![InterfaceBindingSpec {
12067            name: "lo-for-legacy".into(),
12068            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12069            bind_port: 0,
12070            kind: SecIf::Loopback,
12071            subnet: zerodds_security_runtime::IpRange {
12072                base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 1)),
12073                prefix_len: 32,
12074            },
12075            default: true,
12076        }];
12077        let gate = SharedSecurityGate::new(
12078            0,
12079            parse_governance_xml(GOV).unwrap(),
12080            Box::new(AesGcmCryptoPlugin::new()),
12081        );
12082        let cfg = RuntimeConfig {
12083            security: Some(std::sync::Arc::new(gate)),
12084            interface_bindings: bindings,
12085            ..RuntimeConfig::default()
12086        };
12087        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF0; 12]), cfg).expect("rt");
12088
12089        let wid = rt
12090            .register_user_writer(UserWriterConfig {
12091                topic_name: "HeteroRouting".into(),
12092                type_name: "zerodds::RawBytes".into(),
12093                reliable: true,
12094                durability: zerodds_qos::DurabilityKind::Volatile,
12095                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12096                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12097                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12098                ownership: zerodds_qos::OwnershipKind::Shared,
12099                ownership_strength: 0,
12100                partition: Vec::new(),
12101                user_data: Vec::new(),
12102                topic_data: Vec::new(),
12103                group_data: Vec::new(),
12104                type_identifier: zerodds_types::TypeIdentifier::None,
12105                data_representation_offer: None,
12106            })
12107            .unwrap();
12108
12109        // Peer protection setup: Legacy=None for lo_target,
12110        // Encrypt for wan_target.
12111        let legacy_peer: [u8; 12] = [0x01; 12];
12112        let secure_peer: [u8; 12] = [0x02; 12];
12113        {
12114            let arc = rt.writer_slot(wid).unwrap();
12115            let mut slot = arc.lock().unwrap();
12116            slot.reader_protection
12117                .insert(legacy_peer, ProtectionLevel::None);
12118            slot.reader_protection
12119                .insert(secure_peer, ProtectionLevel::Encrypt);
12120            slot.locator_to_peer.insert(lo_target, legacy_peer);
12121            slot.locator_to_peer.insert(wan_target, secure_peer);
12122        }
12123
12124        // Fiktives Datagram.
12125        let mut msg = Vec::new();
12126        msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12127        msg.extend_from_slice(&[0xF0; 12]);
12128        msg.extend_from_slice(b"DOD-ROUTING-PAYLOAD");
12129
12130        // Generate the per-target wire + route via send_on_best_interface.
12131        let plain_wire = secure_outbound_for_target(&rt, wid, &msg, &lo_target).unwrap();
12132        let secure_wire = secure_outbound_for_target(&rt, wid, &msg, &wan_target).unwrap();
12133        assert_ne!(
12134            plain_wire, msg,
12135            "lo-target under rtps_protection=ENCRYPT also SRTPS (no plaintext leak)"
12136        );
12137        assert_ne!(secure_wire, msg, "wan-target: SRTPS-wrapped");
12138
12139        send_on_best_interface(&rt, &lo_target, &plain_wire);
12140        send_on_best_interface(&rt, &wan_target, &secure_wire);
12141
12142        // sniffer receive and compare.
12143        let mut buf = [0u8; 4096];
12144        let (n1, _) = lo_sniffer.recv_from(&mut buf).expect("lo snif got");
12145        assert_ne!(
12146            &buf[..n1],
12147            &msg[..],
12148            "loopback sniffer must see SRTPS (spec wins, no plaintext on a protected domain)"
12149        );
12150        assert_eq!(buf[20], 0x33, "lo output must begin with SRTPS_PREFIX");
12151        let (n2, _) = wan_sniffer.recv_from(&mut buf).expect("wan snif got");
12152        assert_ne!(&buf[..n2], &msg[..], "WAN sniffer must see SRTPS-wrapped");
12153        // Additionally: SRTPS marker at the 20th byte (after the RTPS header).
12154        // SRTPS_PREFIX-Submessage-Id = 0x33 (Spec §7.3.6.3).
12155        assert_eq!(
12156            buf[20], 0x33,
12157            "WAN output must begin with an SRTPS_PREFIX submessage"
12158        );
12159
12160        rt.shutdown();
12161    }
12162
12163    #[cfg(feature = "security")]
12164    #[test]
12165    fn inbound_loopback_accepts_plain_on_protected_domain() {
12166        // Plan §stage 6: the inbound dispatcher should accept plaintext
12167        // for loopback packets even on a protected domain
12168        // (bytes do not leave the host). That is
12169        // exactly the `NetInterface` consultation in classify_inbound.
12170        use zerodds_security_runtime::NetInterface as SecIf;
12171        const GOV: &str = r#"
12172<domain_access_rules>
12173  <domain_rule>
12174    <domains><id>0</id></domains>
12175    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12176    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12177  </domain_rule>
12178</domain_access_rules>
12179"#;
12180        let logger = std::sync::Arc::new(CapturingLogger::default());
12181        let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
12182
12183        let mut plain = Vec::new();
12184        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12185        plain.extend_from_slice(&[0x99; 12]);
12186        plain.extend_from_slice(b"loopback-plain-is-ok");
12187
12188        // Accepted on loopback — no log event.
12189        let out = secure_inbound_bytes(&rt, &plain, &SecIf::Loopback)
12190            .expect("loopback plain must be accepted");
12191        assert_eq!(out, plain);
12192        assert!(logger.events().is_empty());
12193
12194        // On WAN the same content → drop + error event.
12195        let out_wan = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
12196        assert!(out_wan.is_none());
12197        let evs = logger.events();
12198        assert_eq!(evs.len(), 1);
12199        assert_eq!(evs[0].0, zerodds_security_runtime::LogLevel::Error);
12200        assert!(
12201            evs[0].2.contains("iface=Wan"),
12202            "log message must carry iface"
12203        );
12204        rt.shutdown();
12205    }
12206
12207    #[cfg(feature = "security")]
12208    #[test]
12209    fn dod_inbound_per_interface_receive_via_pool_socket() {
12210        // Plan §stage 6 inbound DoD: each pool binding has its
12211        // own receive path, and the NetInterface class is
12212        // reflected in the log event (iface=<class>).
12213        //
12214        // Setup:
12215        //  * DcpsRuntime with 1 InterfaceBinding (kind=Loopback,
12216        //    subnet=127.0.0.0/8)
12217        //  * Protected Governance + CapturingLogger
12218        //  * We bind an external UDP socket and send two
12219        //    plain packets:
12220        //      a) to the pool socket (the event loop polls it and
12221        //         classifies as loopback → accept without log)
12222        //      b) we trigger secure_inbound_bytes directly with Wan
12223        //         → error log with iface=Wan
12224        //
12225        // This proves that the per-interface receive path
12226        // exists and the iface class flows through the decision.
12227        use std::net::{SocketAddrV4, UdpSocket};
12228        use zerodds_security_crypto::AesGcmCryptoPlugin;
12229        use zerodds_security_permissions::parse_governance_xml;
12230        use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
12231
12232        const GOV: &str = r#"
12233<domain_access_rules>
12234  <domain_rule>
12235    <domains><id>0</id></domains>
12236    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12237    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12238  </domain_rule>
12239</domain_access_rules>
12240"#;
12241        let logger = std::sync::Arc::new(CapturingLogger::default());
12242        let gate = SharedSecurityGate::new(
12243            0,
12244            parse_governance_xml(GOV).unwrap(),
12245            Box::new(AesGcmCryptoPlugin::new()),
12246        );
12247        let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
12248            std::sync::Arc::clone(&logger) as _;
12249        let bindings = vec![InterfaceBindingSpec {
12250            name: "lo".into(),
12251            bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12252            bind_port: 0,
12253            kind: SecIf::Loopback,
12254            subnet: zerodds_security_runtime::IpRange {
12255                base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 0)),
12256                prefix_len: 8,
12257            },
12258            default: true,
12259        }];
12260        let cfg = RuntimeConfig {
12261            security: Some(std::sync::Arc::new(gate)),
12262            security_logger: Some(logger_dyn),
12263            interface_bindings: bindings,
12264            ..RuntimeConfig::default()
12265        };
12266        let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF1; 12]), cfg).expect("rt");
12267
12268        // Read the port of the pool binding (ephemeral).
12269        let pool_port = rt.outbound_pool.as_ref().unwrap().bindings[0]
12270            .socket
12271            .local_locator()
12272            .port as u16;
12273        assert!(pool_port > 0);
12274
12275        // An external socket sends a plain packet to the pool socket.
12276        let sender = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).unwrap();
12277        let mut plain = Vec::new();
12278        plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12279        plain.extend_from_slice(&[0xAB; 12]);
12280        plain.extend_from_slice(b"loopback-dispatch");
12281        sender
12282            .send_to(
12283                &plain,
12284                SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), pool_port),
12285            )
12286            .unwrap();
12287
12288        // The event loop needs a few ticks to poll the packet.
12289        // The default tick_period is 50 ms; we wait a few of them.
12290        std::thread::sleep(Duration::from_millis(300));
12291
12292        // The pool packet, through classify_inbound with iface=Loopback,
12293        // ran → accept, no log events from this path.
12294        let pool_events = logger.events();
12295
12296        // Comparison test: the same packet through secure_inbound_bytes
12297        // with iface=Wan → error event with an iface=Wan marker.
12298        let _ = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
12299        let after = logger.events();
12300        assert!(
12301            after.len() > pool_events.len(),
12302            "the Wan path must produce a new log event"
12303        );
12304        let new_ev = &after[after.len() - 1];
12305        assert_eq!(new_ev.0, zerodds_security_runtime::LogLevel::Error);
12306        assert!(
12307            new_ev.2.contains("iface=Wan"),
12308            "log message carries the iface marker: got={:?}",
12309            new_ev.2
12310        );
12311
12312        // Log events from the pool path must NOT carry the error level
12313        // (because classify_inbound returns accept on loopback).
12314        for (lvl, cat, msg) in &pool_events {
12315            assert_ne!(
12316                *lvl,
12317                zerodds_security_runtime::LogLevel::Error,
12318                "the loopback path must not produce an error event: cat={cat} msg={msg}"
12319            );
12320        }
12321        rt.shutdown();
12322    }
12323
12324    #[cfg(feature = "security")]
12325    #[test]
12326    fn per_target_without_security_gate_is_passthrough() {
12327        // Without a `security` config in RuntimeConfig, the per-target
12328        // path is a pure passthrough. Important so that we do not
12329        // break the v1.4 backward compat.
12330        let rt = DcpsRuntime::start(
12331            0,
12332            GuidPrefix::from_bytes([0xE5; 12]),
12333            RuntimeConfig::default(),
12334        )
12335        .expect("rt");
12336        let wid = rt
12337            .register_user_writer(UserWriterConfig {
12338                topic_name: "T".into(),
12339                type_name: "zerodds::RawBytes".into(),
12340                reliable: true,
12341                durability: zerodds_qos::DurabilityKind::Volatile,
12342                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12343                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12344                liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12345                ownership: zerodds_qos::OwnershipKind::Shared,
12346                ownership_strength: 0,
12347                partition: Vec::new(),
12348                user_data: Vec::new(),
12349                topic_data: Vec::new(),
12350                group_data: Vec::new(),
12351                type_identifier: zerodds_types::TypeIdentifier::None,
12352                data_representation_offer: None,
12353            })
12354            .unwrap();
12355        let tgt = Locator::udp_v4([127, 0, 0, 1], 40000);
12356        let msg = b"raw-plaintext".to_vec();
12357        let out = secure_outbound_for_target(&rt, wid, &msg, &tgt).unwrap();
12358        assert_eq!(out, msg, "without a gate it must be passthrough");
12359        rt.shutdown();
12360    }
12361
12362    // ----  Builtin-Topic-Reader Discovery-Hook (DDS 1.4 §2.2.5) ----
12363
12364    /// Helper: constructs a synthetic SPDP beacon
12365    /// for a remote participant, so that `handle_spdp_datagram`
12366    /// accepts it.
12367    fn make_remote_spdp_beacon(remote_prefix: GuidPrefix) -> Vec<u8> {
12368        use zerodds_discovery::spdp::SpdpBeacon;
12369        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12370        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12371        let data = ParticipantBuiltinTopicData {
12372            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
12373            protocol_version: ProtocolVersion::V2_5,
12374            vendor_id: VendorId::ZERODDS,
12375            default_unicast_locator: None,
12376            default_multicast_locator: None,
12377            metatraffic_unicast_locator: None,
12378            metatraffic_multicast_locator: None,
12379            domain_id: Some(0),
12380            builtin_endpoint_set: 0,
12381            lease_duration: QosDuration::from_secs(100),
12382            user_data: alloc::vec::Vec::new(),
12383            properties: Default::default(),
12384            identity_token: None,
12385            permissions_token: None,
12386            identity_status_token: None,
12387            sig_algo_info: None,
12388            kx_algo_info: None,
12389            sym_cipher_algo_info: None,
12390            participant_security_info: None,
12391        };
12392        let mut beacon = SpdpBeacon::new(data);
12393        beacon.serialize().expect("serialize")
12394    }
12395
12396    #[test]
12397    fn handle_spdp_datagram_pushes_into_builtin_participant_reader() {
12398        let rt = DcpsRuntime::start(
12399            41,
12400            GuidPrefix::from_bytes([0x21; 12]),
12401            RuntimeConfig::default(),
12402        )
12403        .expect("start");
12404        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12405        rt.attach_builtin_sinks(bs.sinks());
12406
12407        let remote = GuidPrefix::from_bytes([0x99; 12]);
12408        let dg = make_remote_spdp_beacon(remote);
12409        // A direct hook call simulates an SPDP receive without multicast.
12410        handle_spdp_datagram(&rt, &dg);
12411
12412        let reader = bs
12413            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
12414                "DCPSParticipant",
12415            )
12416            .unwrap();
12417        let samples = reader.take().unwrap();
12418        assert_eq!(samples.len(), 1, "exactly 1 sample for 1 SPDP beacon");
12419        assert_eq!(samples[0].key.prefix, remote);
12420        rt.shutdown();
12421    }
12422
12423    #[test]
12424    fn handle_spdp_datagram_skips_self_beacon() {
12425        let prefix = GuidPrefix::from_bytes([0x22; 12]);
12426        let rt = DcpsRuntime::start(42, prefix, RuntimeConfig::default()).expect("start");
12427        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12428        rt.attach_builtin_sinks(bs.sinks());
12429
12430        // Beacon from our own prefix → must be ignored (Spec
12431        // §8.5.4 self-discovery filter).
12432        let dg = make_remote_spdp_beacon(prefix);
12433        handle_spdp_datagram(&rt, &dg);
12434
12435        let reader = bs
12436            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
12437                "DCPSParticipant",
12438            )
12439            .unwrap();
12440        let samples = reader.take().unwrap();
12441        assert!(samples.is_empty(), "own beacon must not be logged");
12442        rt.shutdown();
12443    }
12444
12445    #[test]
12446    fn sedp_event_push_populates_publication_and_topic_readers() {
12447        use crate::builtin_topics as bt;
12448        use zerodds_discovery::sedp::SedpEvents;
12449        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12450        let rt = DcpsRuntime::start(
12451            43,
12452            GuidPrefix::from_bytes([0x23; 12]),
12453            RuntimeConfig::default(),
12454        )
12455        .expect("start");
12456        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12457        rt.attach_builtin_sinks(bs.sinks());
12458
12459        let mut events = SedpEvents::default();
12460        events.new_publications.push(
12461            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
12462                key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
12463                participant_key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
12464                topic_name: "WireT".into(),
12465                type_name: "WireType".into(),
12466                durability: zerodds_qos::DurabilityKind::Volatile,
12467                reliability: ReliabilityQosPolicy::default(),
12468                ownership: zerodds_qos::OwnershipKind::Shared,
12469                ownership_strength: 0,
12470                liveliness: LivelinessQosPolicy::default(),
12471                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12472                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12473                partition: Vec::new(),
12474                user_data: Vec::new(),
12475                topic_data: Vec::new(),
12476                group_data: Vec::new(),
12477                type_information: None,
12478                data_representation: Vec::new(),
12479                security_info: None,
12480                service_instance_name: None,
12481                related_entity_guid: None,
12482                topic_aliases: None,
12483                type_identifier: zerodds_types::TypeIdentifier::None,
12484                unicast_locators: Vec::new(),
12485                multicast_locators: Vec::new(),
12486            },
12487        );
12488
12489        push_sedp_events_to_builtin_readers(&rt, &events);
12490
12491        let pub_reader = bs
12492            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
12493            .unwrap();
12494        let pub_samples = pub_reader.take().unwrap();
12495        assert_eq!(pub_samples.len(), 1);
12496        assert_eq!(pub_samples[0].topic_name, "WireT");
12497
12498        let topic_reader = bs
12499            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12500            .unwrap();
12501        let topic_samples = topic_reader.take().unwrap();
12502        assert_eq!(topic_samples.len(), 1);
12503        assert_eq!(topic_samples[0].name, "WireT");
12504        rt.shutdown();
12505    }
12506
12507    #[test]
12508    fn sedp_event_push_populates_subscription_reader() {
12509        use crate::builtin_topics as bt;
12510        use zerodds_discovery::sedp::SedpEvents;
12511        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12512        let rt = DcpsRuntime::start(
12513            44,
12514            GuidPrefix::from_bytes([0x24; 12]),
12515            RuntimeConfig::default(),
12516        )
12517        .expect("start");
12518        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12519        rt.attach_builtin_sinks(bs.sinks());
12520
12521        let mut events = SedpEvents::default();
12522        events.new_subscriptions.push(
12523            zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
12524                key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
12525                participant_key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
12526                topic_name: "SubT".into(),
12527                type_name: "SubType".into(),
12528                durability: zerodds_qos::DurabilityKind::Volatile,
12529                reliability: ReliabilityQosPolicy::default(),
12530                ownership: zerodds_qos::OwnershipKind::Shared,
12531                liveliness: LivelinessQosPolicy::default(),
12532                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12533                partition: Vec::new(),
12534                user_data: Vec::new(),
12535                topic_data: Vec::new(),
12536                group_data: Vec::new(),
12537                type_information: None,
12538                data_representation: Vec::new(),
12539                content_filter: None,
12540                security_info: None,
12541                service_instance_name: None,
12542                related_entity_guid: None,
12543                topic_aliases: None,
12544                type_identifier: zerodds_types::TypeIdentifier::None,
12545                unicast_locators: Vec::new(),
12546                multicast_locators: Vec::new(),
12547            },
12548        );
12549
12550        push_sedp_events_to_builtin_readers(&rt, &events);
12551
12552        let sub_reader = bs
12553            .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
12554            .unwrap();
12555        let sub_samples = sub_reader.take().unwrap();
12556        assert_eq!(sub_samples.len(), 1);
12557        assert_eq!(sub_samples[0].topic_name, "SubT");
12558
12559        // The topic reader gets a synthetic topic sample also from
12560        // Subscription.
12561        let topic_reader = bs
12562            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12563            .unwrap();
12564        let topic_samples = topic_reader.take().unwrap();
12565        assert_eq!(topic_samples.len(), 1);
12566        assert_eq!(topic_samples[0].name, "SubT");
12567        rt.shutdown();
12568    }
12569
12570    #[test]
12571    fn push_sedp_events_to_builtin_readers_is_noop_without_sinks() {
12572        use zerodds_discovery::sedp::SedpEvents;
12573        let rt = DcpsRuntime::start(
12574            45,
12575            GuidPrefix::from_bytes([0x25; 12]),
12576            RuntimeConfig::default(),
12577        )
12578        .expect("start");
12579        // No attach_builtin_sinks → push must stay silent, not
12580        // panic.
12581        let events = SedpEvents::default();
12582        push_sedp_events_to_builtin_readers(&rt, &events);
12583        rt.shutdown();
12584    }
12585
12586    // ----  Ignore-Filter im Discovery-Hot-Path -------------
12587
12588    #[test]
12589    fn handle_spdp_datagram_drops_ignored_participant_beacon() {
12590        // Spec §2.2.2.2.1.14: ein einmal ignorierter Participant
12591        // taucht in keinem nachfolgenden Builtin-Sample mehr auf.
12592        let rt = DcpsRuntime::start(
12593            46,
12594            GuidPrefix::from_bytes([0x26; 12]),
12595            RuntimeConfig::default(),
12596        )
12597        .expect("start");
12598        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12599        rt.attach_builtin_sinks(bs.sinks());
12600        let filter = crate::participant::IgnoreFilter::default();
12601        rt.attach_ignore_filter(filter.clone());
12602
12603        let remote = GuidPrefix::from_bytes([0xAA; 12]);
12604        // Derive the ignore handle from the future beacon — we
12605        // know that the builtin sample key is the GUID of the remote
12606        // participant (=prefix + EntityId::PARTICIPANT).
12607        let key = Guid::new(remote, EntityId::PARTICIPANT);
12608        let h = crate::instance_handle::InstanceHandle::from_guid(key);
12609        if let Ok(mut s) = filter.inner.participants.lock() {
12610            s.insert(h);
12611        }
12612        let dg = make_remote_spdp_beacon(remote);
12613        handle_spdp_datagram(&rt, &dg);
12614
12615        let reader = bs
12616            .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
12617                "DCPSParticipant",
12618            )
12619            .unwrap();
12620        assert!(
12621            reader.take().unwrap().is_empty(),
12622            "an ignored participant must not land in DCPSParticipant"
12623        );
12624        rt.shutdown();
12625    }
12626
12627    #[test]
12628    fn sedp_event_push_filters_ignored_publication() {
12629        use crate::builtin_topics as bt;
12630        use zerodds_discovery::sedp::SedpEvents;
12631        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12632        let rt = DcpsRuntime::start(
12633            47,
12634            GuidPrefix::from_bytes([0x27; 12]),
12635            RuntimeConfig::default(),
12636        )
12637        .expect("start");
12638        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12639        rt.attach_builtin_sinks(bs.sinks());
12640        let filter = crate::participant::IgnoreFilter::default();
12641        rt.attach_ignore_filter(filter.clone());
12642
12643        let pub_key = Guid::new(GuidPrefix::from_bytes([0x33; 12]), EntityId::PARTICIPANT);
12644        let h_pub = crate::instance_handle::InstanceHandle::from_guid(pub_key);
12645        if let Ok(mut s) = filter.inner.publications.lock() {
12646            s.insert(h_pub);
12647        }
12648
12649        let mut events = SedpEvents::default();
12650        events.new_publications.push(
12651            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
12652                key: pub_key,
12653                participant_key: Guid::new(
12654                    GuidPrefix::from_bytes([0x33; 12]),
12655                    EntityId::PARTICIPANT,
12656                ),
12657                topic_name: "Filtered".into(),
12658                type_name: "T".into(),
12659                durability: zerodds_qos::DurabilityKind::Volatile,
12660                reliability: ReliabilityQosPolicy::default(),
12661                ownership: zerodds_qos::OwnershipKind::Shared,
12662                ownership_strength: 0,
12663                liveliness: LivelinessQosPolicy::default(),
12664                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12665                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12666                partition: Vec::new(),
12667                user_data: Vec::new(),
12668                topic_data: Vec::new(),
12669                group_data: Vec::new(),
12670                type_information: None,
12671                data_representation: Vec::new(),
12672                security_info: None,
12673                service_instance_name: None,
12674                related_entity_guid: None,
12675                topic_aliases: None,
12676                type_identifier: zerodds_types::TypeIdentifier::None,
12677                unicast_locators: Vec::new(),
12678                multicast_locators: Vec::new(),
12679            },
12680        );
12681
12682        push_sedp_events_to_builtin_readers(&rt, &events);
12683
12684        let pub_reader = bs
12685            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
12686            .unwrap();
12687        assert!(
12688            pub_reader.take().unwrap().is_empty(),
12689            "an ignored publication must not land in DCPSPublication"
12690        );
12691        // The synthetic DCPSTopic sample too must not be
12692        // forwarded, because the publication is completely
12693        // discarded.
12694        let topic_reader = bs
12695            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12696            .unwrap();
12697        assert!(topic_reader.take().unwrap().is_empty());
12698        rt.shutdown();
12699    }
12700
12701    #[test]
12702    fn sedp_event_push_filters_ignored_subscription() {
12703        use crate::builtin_topics as bt;
12704        use zerodds_discovery::sedp::SedpEvents;
12705        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12706        let rt = DcpsRuntime::start(
12707            48,
12708            GuidPrefix::from_bytes([0x28; 12]),
12709            RuntimeConfig::default(),
12710        )
12711        .expect("start");
12712        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12713        rt.attach_builtin_sinks(bs.sinks());
12714        let filter = crate::participant::IgnoreFilter::default();
12715        rt.attach_ignore_filter(filter.clone());
12716
12717        let sub_key = Guid::new(GuidPrefix::from_bytes([0x44; 12]), EntityId::PARTICIPANT);
12718        let h_sub = crate::instance_handle::InstanceHandle::from_guid(sub_key);
12719        if let Ok(mut s) = filter.inner.subscriptions.lock() {
12720            s.insert(h_sub);
12721        }
12722
12723        let mut events = SedpEvents::default();
12724        events.new_subscriptions.push(
12725            zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
12726                key: sub_key,
12727                participant_key: Guid::new(
12728                    GuidPrefix::from_bytes([0x44; 12]),
12729                    EntityId::PARTICIPANT,
12730                ),
12731                topic_name: "FilteredSub".into(),
12732                type_name: "T".into(),
12733                durability: zerodds_qos::DurabilityKind::Volatile,
12734                reliability: ReliabilityQosPolicy::default(),
12735                ownership: zerodds_qos::OwnershipKind::Shared,
12736                liveliness: LivelinessQosPolicy::default(),
12737                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12738                partition: Vec::new(),
12739                user_data: Vec::new(),
12740                topic_data: Vec::new(),
12741                group_data: Vec::new(),
12742                type_information: None,
12743                data_representation: Vec::new(),
12744                content_filter: None,
12745                security_info: None,
12746                service_instance_name: None,
12747                related_entity_guid: None,
12748                topic_aliases: None,
12749                type_identifier: zerodds_types::TypeIdentifier::None,
12750                unicast_locators: Vec::new(),
12751                multicast_locators: Vec::new(),
12752            },
12753        );
12754
12755        push_sedp_events_to_builtin_readers(&rt, &events);
12756
12757        let sub_reader = bs
12758            .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
12759            .unwrap();
12760        assert!(sub_reader.take().unwrap().is_empty());
12761        rt.shutdown();
12762    }
12763
12764    #[test]
12765    fn sedp_event_push_filters_ignored_topic_only() {
12766        // If only the topic is ignored, DCPSPublication should
12767        // still be pushed — only the DCPSTopic sample falls
12768        // away.
12769        use crate::builtin_topics as bt;
12770        use zerodds_discovery::sedp::SedpEvents;
12771        use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12772        let rt = DcpsRuntime::start(
12773            49,
12774            GuidPrefix::from_bytes([0x29; 12]),
12775            RuntimeConfig::default(),
12776        )
12777        .expect("start");
12778        let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12779        rt.attach_builtin_sinks(bs.sinks());
12780        let filter = crate::participant::IgnoreFilter::default();
12781        rt.attach_ignore_filter(filter.clone());
12782
12783        let topic_key =
12784            crate::builtin_topics::TopicBuiltinTopicData::synthesize_key("OnlyTopic", "T");
12785        let h_topic = crate::instance_handle::InstanceHandle::from_guid(topic_key);
12786        if let Ok(mut s) = filter.inner.topics.lock() {
12787            s.insert(h_topic);
12788        }
12789
12790        let mut events = SedpEvents::default();
12791        events.new_publications.push(
12792            zerodds_rtps::publication_data::PublicationBuiltinTopicData {
12793                key: Guid::new(GuidPrefix::from_bytes([0x55; 12]), EntityId::PARTICIPANT),
12794                participant_key: Guid::new(
12795                    GuidPrefix::from_bytes([0x55; 12]),
12796                    EntityId::PARTICIPANT,
12797                ),
12798                topic_name: "OnlyTopic".into(),
12799                type_name: "T".into(),
12800                durability: zerodds_qos::DurabilityKind::Volatile,
12801                reliability: ReliabilityQosPolicy::default(),
12802                ownership: zerodds_qos::OwnershipKind::Shared,
12803                ownership_strength: 0,
12804                liveliness: LivelinessQosPolicy::default(),
12805                deadline: zerodds_qos::DeadlineQosPolicy::default(),
12806                lifespan: zerodds_qos::LifespanQosPolicy::default(),
12807                partition: Vec::new(),
12808                user_data: Vec::new(),
12809                topic_data: Vec::new(),
12810                group_data: Vec::new(),
12811                type_information: None,
12812                data_representation: Vec::new(),
12813                security_info: None,
12814                service_instance_name: None,
12815                related_entity_guid: None,
12816                topic_aliases: None,
12817                type_identifier: zerodds_types::TypeIdentifier::None,
12818                unicast_locators: Vec::new(),
12819                multicast_locators: Vec::new(),
12820            },
12821        );
12822
12823        push_sedp_events_to_builtin_readers(&rt, &events);
12824
12825        let pub_reader = bs
12826            .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
12827            .unwrap();
12828        assert_eq!(pub_reader.take().unwrap().len(), 1);
12829        let topic_reader = bs
12830            .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12831            .unwrap();
12832        assert!(
12833            topic_reader.take().unwrap().is_empty(),
12834            "an ignored topic may block the synthetic DCPSTopic sample"
12835        );
12836        rt.shutdown();
12837    }
12838
12839    // -------- Security-Builtin-Endpoint-Wiring --------
12840
12841    /// Creates an SPDP beacon with configurable BuiltinEndpoint
12842    /// bits. Extension of [`make_remote_spdp_beacon`] with
12843    /// flag-Argument (Security-Bits 22..25).
12844    fn make_remote_spdp_beacon_with_flags(remote_prefix: GuidPrefix, endpoint_set: u32) -> Vec<u8> {
12845        use zerodds_discovery::spdp::SpdpBeacon;
12846        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12847        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12848        let data = ParticipantBuiltinTopicData {
12849            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
12850            protocol_version: ProtocolVersion::V2_5,
12851            vendor_id: VendorId::ZERODDS,
12852            default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
12853            default_multicast_locator: None,
12854            metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
12855            metatraffic_multicast_locator: None,
12856            domain_id: Some(0),
12857            builtin_endpoint_set: endpoint_set,
12858            lease_duration: QosDuration::from_secs(100),
12859            user_data: alloc::vec::Vec::new(),
12860            properties: Default::default(),
12861            identity_token: None,
12862            permissions_token: None,
12863            identity_status_token: None,
12864            sig_algo_info: None,
12865            kx_algo_info: None,
12866            sym_cipher_algo_info: None,
12867            participant_security_info: None,
12868        };
12869        let mut beacon = SpdpBeacon::new(data);
12870        beacon.serialize().expect("serialize")
12871    }
12872
12873    fn dp_with_locators(
12874        prefix: GuidPrefix,
12875        metatraffic: Option<Locator>,
12876        default: Option<Locator>,
12877    ) -> zerodds_discovery::spdp::DiscoveredParticipant {
12878        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12879        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12880        zerodds_discovery::spdp::DiscoveredParticipant {
12881            sender_prefix: prefix,
12882            sender_vendor: VendorId::ZERODDS,
12883            data: ParticipantBuiltinTopicData {
12884                guid: Guid::new(prefix, EntityId::PARTICIPANT),
12885                protocol_version: ProtocolVersion::V2_5,
12886                vendor_id: VendorId::ZERODDS,
12887                default_unicast_locator: default,
12888                default_multicast_locator: None,
12889                metatraffic_unicast_locator: metatraffic,
12890                metatraffic_multicast_locator: None,
12891                domain_id: Some(0),
12892                builtin_endpoint_set: 0,
12893                lease_duration: QosDuration::from_secs(100),
12894                user_data: alloc::vec::Vec::new(),
12895                properties: Default::default(),
12896                identity_token: None,
12897                permissions_token: None,
12898                identity_status_token: None,
12899                sig_algo_info: None,
12900                kx_algo_info: None,
12901                sym_cipher_algo_info: None,
12902                participant_security_info: None,
12903            },
12904        }
12905    }
12906
12907    #[test]
12908    fn wlp_unicast_targets_prefers_metatraffic_then_default() {
12909        // M-2: WLP-Unicast-Fan-out waehlt pro Peer metatraffic_unicast (bevorzugt),
12910        // otherwise default_unicast; peers without a routable locator fall out.
12911        let meta = Locator::udp_v4([127, 0, 0, 1], 7501);
12912        let deflt = Locator::udp_v4([127, 0, 0, 2], 7500);
12913        let peers = alloc::vec![
12914            // (a) has metatraffic → metatraffic wins
12915            dp_with_locators(GuidPrefix::from_bytes([1; 12]), Some(meta), Some(deflt)),
12916            // (b) only default → default
12917            dp_with_locators(GuidPrefix::from_bytes([2; 12]), None, Some(deflt)),
12918            // (c) none at all → no target
12919            dp_with_locators(GuidPrefix::from_bytes([3; 12]), None, None),
12920        ];
12921        let targets = wlp_unicast_targets(&peers);
12922        assert_eq!(targets, alloc::vec![meta, deflt]);
12923    }
12924
12925    /// Like [`make_remote_spdp_beacon_with_flags`], but with a set
12926    /// `identity_token` (FU2 Gap 7d — triggers the auth handshake).
12927    #[cfg(feature = "security")]
12928    fn make_secure_beacon_with_identity_token(
12929        remote_prefix: GuidPrefix,
12930        endpoint_set: u32,
12931        identity_token: Vec<u8>,
12932    ) -> Vec<u8> {
12933        use zerodds_discovery::spdp::SpdpBeacon;
12934        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12935        use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12936        let data = ParticipantBuiltinTopicData {
12937            guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
12938            protocol_version: ProtocolVersion::V2_5,
12939            vendor_id: VendorId::ZERODDS,
12940            default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
12941            default_multicast_locator: None,
12942            metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
12943            metatraffic_multicast_locator: None,
12944            domain_id: Some(0),
12945            builtin_endpoint_set: endpoint_set,
12946            lease_duration: QosDuration::from_secs(100),
12947            user_data: alloc::vec::Vec::new(),
12948            properties: Default::default(),
12949            identity_token: Some(identity_token),
12950            permissions_token: None,
12951            identity_status_token: None,
12952            sig_algo_info: None,
12953            kx_algo_info: None,
12954            sym_cipher_algo_info: None,
12955            participant_security_info: None,
12956        };
12957        let mut beacon = SpdpBeacon::new(data);
12958        beacon.serialize().expect("serialize")
12959    }
12960
12961    /// Minimal auth plugin for the FU2 wiring tests (Gap 4/7).
12962    /// Crypto correctness is verified in the stack.rs driver test; here
12963    /// it is only about the runtime wiring path.
12964    #[cfg(feature = "security")]
12965    struct FakeAuth;
12966    #[cfg(feature = "security")]
12967    impl zerodds_security::authentication::AuthenticationPlugin for FakeAuth {
12968        fn validate_local_identity(
12969            &mut self,
12970            _: &zerodds_security::properties::PropertyList,
12971            _: [u8; 16],
12972        ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
12973        {
12974            Ok(zerodds_security::authentication::IdentityHandle(1))
12975        }
12976        fn validate_remote_identity(
12977            &mut self,
12978            _: zerodds_security::authentication::IdentityHandle,
12979            _: [u8; 16],
12980            _: &[u8],
12981        ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
12982        {
12983            Ok(zerodds_security::authentication::IdentityHandle(2))
12984        }
12985        fn begin_handshake_request(
12986            &mut self,
12987            _: zerodds_security::authentication::IdentityHandle,
12988            _: zerodds_security::authentication::IdentityHandle,
12989        ) -> zerodds_security::error::SecurityResult<(
12990            zerodds_security::authentication::HandshakeHandle,
12991            zerodds_security::authentication::HandshakeStepOutcome,
12992        )> {
12993            Ok((
12994                zerodds_security::authentication::HandshakeHandle(1),
12995                zerodds_security::authentication::HandshakeStepOutcome::SendMessage {
12996                    token: zerodds_security::token::DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")
12997                        .to_cdr_le(),
12998                },
12999            ))
13000        }
13001        fn begin_handshake_reply(
13002            &mut self,
13003            _: zerodds_security::authentication::IdentityHandle,
13004            _: zerodds_security::authentication::IdentityHandle,
13005            _: &[u8],
13006        ) -> zerodds_security::error::SecurityResult<(
13007            zerodds_security::authentication::HandshakeHandle,
13008            zerodds_security::authentication::HandshakeStepOutcome,
13009        )> {
13010            Ok((
13011                zerodds_security::authentication::HandshakeHandle(2),
13012                zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer,
13013            ))
13014        }
13015        fn process_handshake(
13016            &mut self,
13017            _: zerodds_security::authentication::HandshakeHandle,
13018            _: &[u8],
13019        ) -> zerodds_security::error::SecurityResult<
13020            zerodds_security::authentication::HandshakeStepOutcome,
13021        > {
13022            Ok(zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer)
13023        }
13024        fn shared_secret(
13025            &self,
13026            _: zerodds_security::authentication::HandshakeHandle,
13027        ) -> zerodds_security::error::SecurityResult<
13028            zerodds_security::authentication::SharedSecretHandle,
13029        > {
13030            Err(zerodds_security::error::SecurityError::new(
13031                zerodds_security::error::SecurityErrorKind::BadArgument,
13032                "fake: handshake not complete",
13033            ))
13034        }
13035        fn plugin_class_id(&self) -> &str {
13036            "FAKE:Auth:1.0"
13037        }
13038        fn get_identity_token(
13039            &self,
13040            _: zerodds_security::authentication::IdentityHandle,
13041        ) -> zerodds_security::error::SecurityResult<Vec<u8>> {
13042            // Non-empty Token (Format irrelevant — FakeAuth.validate_remote_
13043            // identity accepts everything); only so the beacon-populate path
13044            // (Gap 7c) has something to announce.
13045            Ok(alloc::vec![0xAB, 0xCD, 0xEF, 0x01])
13046        }
13047        fn get_permissions_token(&self) -> Vec<u8> {
13048            // Non-empty PermissionsToken, so the beacon-populate path
13049            // (S4 point 1) has something to announce (format irrelevant).
13050            zerodds_security::token::DataHolder::new("DDS:Access:Permissions:1.0").to_cdr_le()
13051        }
13052    }
13053
13054    /// Consolidated test for the wiring. A single
13055    /// runtime walks all paths — snapshot API, idempotency of
13056    /// `enable_security_builtins`, SPDP hot path with security bits,
13057    /// without bits, plus the wire-demux hook. We bundle this into one
13058    /// test body, because each `DcpsRuntime::start` binds a multicast socket
13059    /// and parallel tests could brush against the OS resource caps.
13060    #[test]
13061    fn c34c_security_builtin_wiring_end_to_end() {
13062        use zerodds_discovery::security::SecurityBuiltinStack;
13063        use zerodds_security::generic_message::{
13064            MessageIdentity, ParticipantGenericMessage, class_id,
13065        };
13066        use zerodds_security::token::DataHolder;
13067
13068        let local_prefix = GuidPrefix::from_bytes([0x75; 12]);
13069        let rt = DcpsRuntime::start(75, local_prefix, RuntimeConfig::default()).expect("start");
13070
13071        // 1. Snapshot is None before enable
13072        assert!(rt.security_builtin_snapshot().is_none());
13073
13074        // 2. enable ist idempotent
13075        let h1 = rt.enable_security_builtins(VendorId::ZERODDS);
13076        let h2 = rt.enable_security_builtins(VendorId::ZERODDS);
13077        assert!(Arc::ptr_eq(&h1, &h2));
13078        assert!(rt.security_builtin_snapshot().is_some());
13079
13080        // 3. SPDP beacon with all security-builtin bits → the stack has
13081        //    four proxies
13082        let remote_a = GuidPrefix::from_bytes([0x99; 12]);
13083        let flags_all = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13084            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
13085            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
13086            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
13087        handle_spdp_datagram(
13088            &rt,
13089            &make_remote_spdp_beacon_with_flags(remote_a, flags_all),
13090        );
13091        {
13092            let s = h1.lock().unwrap();
13093            assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
13094            assert_eq!(s.stateless_reader.writer_proxy_count(), 1);
13095            assert_eq!(s.volatile_writer.reader_proxy_count(), 1);
13096            assert_eq!(s.volatile_reader.writer_proxy_count(), 1);
13097        }
13098
13099        // 4. SPDP beacon without security bits → the stack stays unchanged
13100        let remote_b = GuidPrefix::from_bytes([0x88; 12]);
13101        handle_spdp_datagram(
13102            &rt,
13103            &make_remote_spdp_beacon_with_flags(remote_b, endpoint_flag::ALL_STANDARD),
13104        );
13105        {
13106            let s = h1.lock().unwrap();
13107            assert_eq!(
13108                s.stateless_writer.reader_proxy_count(),
13109                1,
13110                "a peer without security bits must not touch existing proxies"
13111            );
13112        }
13113
13114        // 5. Wire-demux hook with a valid stateless DATA: remote-stack
13115        //    mirror sends a message → the demux hook routes it through
13116        //    the local reader without panic.
13117        let mut remote_stack = SecurityBuiltinStack::new(remote_a, VendorId::ZERODDS);
13118        let local_peer = make_remote_spdp_beacon_with_flags(local_prefix, flags_all);
13119        let parsed_local = zerodds_discovery::spdp::SpdpReader::new()
13120            .parse_datagram(&local_peer)
13121            .unwrap();
13122        remote_stack.handle_remote_endpoints(&parsed_local);
13123        let msg = ParticipantGenericMessage {
13124            message_identity: MessageIdentity {
13125                source_guid: [0xCD; 16],
13126                sequence_number: 1,
13127            },
13128            related_message_identity: MessageIdentity::default(),
13129            destination_participant_key: [0xEF; 16],
13130            destination_endpoint_key: [0; 16],
13131            source_endpoint_key: [0xFE; 16],
13132            message_class_id: class_id::AUTH_REQUEST.into(),
13133            message_data: alloc::vec![DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")],
13134        };
13135        let dgs = remote_stack.stateless_writer.write(&msg).unwrap();
13136        assert_eq!(dgs.len(), 1);
13137        dispatch_security_builtin_datagram(&rt, &dgs[0].bytes, Duration::from_secs(1));
13138
13139        // 6. The demux hook does not panic on garbage bytes
13140        dispatch_security_builtin_datagram(&rt, &[0u8; 32], Duration::from_secs(1));
13141
13142        rt.shutdown();
13143    }
13144
13145    /// FU2 Gap 4: `enable_security_builtins_with_auth` builds the stack with
13146    /// an active handshake driver — `begin_handshake_with` sends, as
13147    /// the initiator actually sends an AUTH_REQUEST (instead of a no-op like with
13148    /// the auth-less `enable_security_builtins`).
13149    #[cfg(feature = "security")]
13150    #[test]
13151    fn enable_security_builtins_with_auth_activates_handshake_driver() {
13152        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
13153
13154        let local_prefix = GuidPrefix::from_bytes([0x40; 12]);
13155        let rt = DcpsRuntime::start(40, local_prefix, RuntimeConfig::default()).expect("start");
13156
13157        let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13158        let stack =
13159            rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
13160
13161        // Discover a peer with stateless bits (WITHOUT identity_token → the
13162        // discovery trigger starts no handshake yet) → proxies
13163        // are wired. The remote prefix is LARGER than local ([0x40]),
13164        // so that local is the initiator under the cyclone convention (smaller GUID
13165        // initiates) and actually sends.
13166        let remote = GuidPrefix::from_bytes([0x99; 12]);
13167        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13168            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13169        handle_spdp_datagram(&rt, &make_remote_spdp_beacon_with_flags(remote, flags));
13170
13171        let dgs = {
13172            let mut s = stack.lock().unwrap();
13173            let remote_guid = Guid::new(remote, EntityId::PARTICIPANT).to_bytes();
13174            s.begin_handshake_with(remote, remote_guid, b"fake-remote-cert-der")
13175                .expect("begin_handshake_with")
13176        };
13177        assert_eq!(
13178            dgs.len(),
13179            1,
13180            "auth driver active → the initiator sends exactly one AUTH_REQUEST"
13181        );
13182
13183        rt.shutdown();
13184    }
13185
13186    /// FU2 Gap 7c/d: `enable_security_builtins_with_auth` announces the
13187    /// local `identity_token` in the SPDP beacon (+ stateless/volatile bits),
13188    /// and an incoming peer beacon WITH an `identity_token` kicks off the
13189    /// Auth-Handshake an (Discovery-Trigger).
13190    #[cfg(feature = "security")]
13191    #[test]
13192    fn spdp_beacon_announces_identity_token_and_discovery_triggers_handshake() {
13193        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
13194
13195        let local_prefix = GuidPrefix::from_bytes([0x41; 12]);
13196        let rt = DcpsRuntime::start(41, local_prefix, RuntimeConfig::default()).expect("start");
13197        let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13198        let stack =
13199            rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
13200
13201        // Gap 7c: the beacon now announces identity_token + secure bits.
13202        let beacon_bytes = rt.spdp_beacon.lock().unwrap().serialize().unwrap();
13203        let parsed = zerodds_discovery::spdp::SpdpReader::new()
13204            .parse_datagram(&beacon_bytes)
13205            .unwrap();
13206        assert!(
13207            parsed.data.identity_token.is_some(),
13208            "the beacon must announce PID_IDENTITY_TOKEN"
13209        );
13210        // Cross-vendor: secure vendors validate a remote only when
13211        // SPDP carries **both** tokens. Without PID_PERMISSIONS_TOKEN they treat
13212        // cyclone treats us as non-secure and never starts validate_remote_identity.
13213        assert!(
13214            parsed.data.permissions_token.is_some(),
13215            "the beacon must announce PID_PERMISSIONS_TOKEN (cross-vendor mandatory)"
13216        );
13217        assert_ne!(
13218            parsed.data.builtin_endpoint_set & endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER,
13219            0,
13220            "the beacon must announce the stateless-auth bit"
13221        );
13222
13223        // Gap 7d: peer beacon WITH identity_token + stateless bits → the
13224        // discovery path kicks off begin_handshake_with.
13225        let remote = GuidPrefix::from_bytes([0x99; 12]);
13226        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13227            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13228        let peer_beacon =
13229            make_secure_beacon_with_identity_token(remote, flags, alloc::vec![0x11, 0x22, 0x33]);
13230        handle_spdp_datagram(&rt, &peer_beacon);
13231
13232        // Proof that the discovery trigger fired: the peer is now
13233        // registered in the stack's handshake state. (The earlier length
13234        // probe via a repeated begin_handshake_with no longer applies since the resend path
13235        // resends as the initiator on a repeated call.)
13236        let started = {
13237            let s = stack.lock().unwrap();
13238            s.handshake_peer_count()
13239        };
13240        assert_eq!(
13241            started, 1,
13242            "the discovery trigger must have started the handshake (peer registered)"
13243        );
13244
13245        rt.shutdown();
13246    }
13247
13248    /// FU2 S3: two secure runtimes in the same process MUST find each other via
13249    /// in-process participant discovery and kick off the auth handshake
13250    /// — WITHOUT a single multicast beacon. That was exactly missing:
13251    /// `inproc_inject_publication`/`_subscription` inject only SEDP, the
13252    /// SPDP participant discovery (identity_token + `begin_handshake_with`)
13253    /// ran exclusively over the flaky multicast path.
13254    #[cfg(feature = "security")]
13255    #[test]
13256    fn inproc_participant_discovery_triggers_handshake_without_multicast() {
13257        use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
13258
13259        let a_prefix = GuidPrefix::from_bytes([0x4A; 12]);
13260        let b_prefix = GuidPrefix::from_bytes([0x4B; 12]);
13261        let rt_a = DcpsRuntime::start(47, a_prefix, RuntimeConfig::default()).expect("start a");
13262        let rt_b = DcpsRuntime::start(47, b_prefix, RuntimeConfig::default()).expect("start b");
13263        let auth_a: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13264        let auth_b: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13265        let stack_a =
13266            rt_a.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_a, IdentityHandle(1));
13267        let stack_b =
13268            rt_b.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_b, IdentityHandle(1));
13269
13270        // KEIN handle_spdp_datagram / Multicast — rein in-process.
13271        let a_peers = stack_a.lock().unwrap().handshake_peer_count();
13272        let b_peers = stack_b.lock().unwrap().handshake_peer_count();
13273        assert!(
13274            a_peers >= 1,
13275            "A must have discovered B in-process + started the handshake (got {a_peers})"
13276        );
13277        assert!(
13278            b_peers >= 1,
13279            "B must have discovered A in-process + started the handshake (got {b_peers})"
13280        );
13281
13282        rt_a.shutdown();
13283        rt_b.shutdown();
13284    }
13285
13286    /// Mints a shared CA + two leaf identities (PEM) for the
13287    /// FU2-Handshake-e2e-Test.
13288    #[cfg(feature = "security")]
13289    #[allow(clippy::type_complexity)]
13290    fn mint_handshake_identities() -> ((Vec<u8>, Vec<u8>), (Vec<u8>, Vec<u8>)) {
13291        use rcgen::{CertificateParams, KeyPair};
13292        let mut ca_params =
13293            CertificateParams::new(alloc::vec![alloc::string::String::from("FU2 CA")]).unwrap();
13294        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
13295        let ca_key = KeyPair::generate().unwrap();
13296        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
13297        let ca_pem = ca_cert.pem().into_bytes();
13298        let mint = |name: &str| -> (Vec<u8>, Vec<u8>) {
13299            let mut p =
13300                CertificateParams::new(alloc::vec![alloc::string::String::from(name)]).unwrap();
13301            p.is_ca = rcgen::IsCa::NoCa;
13302            let k = KeyPair::generate().unwrap();
13303            let c = p.signed_by(&k, &ca_cert, &ca_key).unwrap();
13304            (c.pem().into_bytes(), k.serialize_pem().into_bytes())
13305        };
13306        let alice = {
13307            let (cert, key) = mint("alice");
13308            (cert, key)
13309        };
13310        let bob = {
13311            let (cert, key) = mint("bob");
13312            (cert, key)
13313        };
13314        // attach ca_pem to both, so the caller has the trust anchor.
13315        (
13316            ([alice.0, b"\n".to_vec(), ca_pem.clone()].concat(), alice.1),
13317            ([bob.0, b"\n".to_vec(), ca_pem].concat(), bob.1),
13318        )
13319    }
13320
13321    /// FU2 Gap 5 (e2e): a runtime replier (A) and an in-test initiator
13322    /// stack (B) complete a real PKI 3-round handshake via the dispatch path
13323    /// and BOTH derive the same SharedSecret.
13324    /// Verifies the dispatch wiring (`on_stateless_message` →
13325    /// reply/final → completion) in the real runtime context.
13326    #[cfg(feature = "security")]
13327    #[test]
13328    fn handshake_completes_through_runtime_dispatch_e2e() {
13329        use zerodds_discovery::security::SecurityBuiltinStack;
13330        use zerodds_security::authentication::AuthenticationPlugin;
13331        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
13332
13333        // cert_pem here contains Leaf || CA (mint_handshake_identities),
13334        // identity_ca_pem = the same bundle (CA is included).
13335        let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
13336
13337        // A = Runtime (Replier, HOEHERER Prefix). B = in-test Stack
13338        // (initiator, LOWER prefix) — cyclone convention: smaller
13339        // GUID initiiert.
13340        let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
13341        let b_prefix = GuidPrefix::from_bytes([0x10; 12]);
13342        let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
13343        let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
13344
13345        // --- A: runtime with a real PKI plugin ---
13346        let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13347        let a_local = a_pki
13348            .lock()
13349            .unwrap()
13350            .validate_with_config(
13351                IdentityConfig {
13352                    identity_cert_pem: a_cert.clone(),
13353                    identity_ca_pem: a_cert.clone(),
13354                    identity_key_pem: Some(a_key),
13355                },
13356                a_guid,
13357            )
13358            .unwrap();
13359        let a_token = a_pki.lock().unwrap().get_identity_token(a_local).unwrap();
13360        let rt = DcpsRuntime::start(42, a_prefix, RuntimeConfig::default()).expect("start");
13361        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
13362        let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
13363
13364        // --- B: in-test initiator stack with a real PKI plugin ---
13365        let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13366        let b_local = b_pki
13367            .lock()
13368            .unwrap()
13369            .validate_with_config(
13370                IdentityConfig {
13371                    identity_cert_pem: b_cert.clone(),
13372                    identity_ca_pem: b_cert.clone(),
13373                    identity_key_pem: Some(b_key),
13374                },
13375                b_guid,
13376            )
13377            .unwrap();
13378        let b_token = b_pki.lock().unwrap().get_identity_token(b_local).unwrap();
13379        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
13380        let mut b_stack =
13381            SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
13382
13383        let stateless = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13384            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13385
13386        // B discovers A (wired proxies) — via the parsed A beacon.
13387        let a_beacon = make_secure_beacon_with_identity_token(a_prefix, stateless, a_token.clone());
13388        let a_parsed = zerodds_discovery::spdp::SpdpReader::new()
13389            .parse_datagram(&a_beacon)
13390            .unwrap();
13391        b_stack.handle_remote_endpoints(&a_parsed);
13392
13393        // A discovers B → the discovery trigger creates A's peer state (A is
13394        // the replier, sends nothing).
13395        let b_beacon = make_secure_beacon_with_identity_token(b_prefix, stateless, b_token);
13396        handle_spdp_datagram(&rt, &b_beacon);
13397
13398        // B (initiator) starts → AUTH_REQUEST.
13399        let req = b_stack
13400            .begin_handshake_with(a_prefix, a_guid, &a_token)
13401            .unwrap();
13402        assert_eq!(req.len(), 1, "B sends AUTH_REQUEST");
13403
13404        // Pump: REQUEST → A.dispatch → REPLY.
13405        let reply = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
13406        assert_eq!(reply.len(), 1, "A (replier) answers with AUTH reply");
13407
13408        // REPLY → B verarbeitet → FINAL (+ B erreicht Complete).
13409        let b_msgs = b_stack
13410            .stateless_reader
13411            .handle_datagram(&reply[0].bytes)
13412            .unwrap();
13413        assert_eq!(b_msgs.len(), 1);
13414        let (final_dgs, _b_complete) = b_stack.on_stateless_message(a_prefix, &b_msgs[0]).unwrap();
13415        assert_eq!(final_dgs.len(), 1, "B sends AUTH-Final");
13416
13417        // FINAL → A.dispatch → A erreicht Complete.
13418        let _ =
13419            dispatch_security_builtin_datagram(&rt, &final_dgs[0].bytes, Duration::from_secs(1));
13420
13421        // Both sides must now have derived the same SharedSecret.
13422        let a_secret = {
13423            let s = a_stack.lock().unwrap();
13424            s.peer_secret(b_prefix)
13425                .expect("A must have authenticated B")
13426        };
13427        let b_secret = b_stack
13428            .peer_secret(a_prefix)
13429            .expect("B must have authenticated A");
13430        let a_bytes = a_pki
13431            .lock()
13432            .unwrap()
13433            .secret_bytes(a_secret)
13434            .unwrap()
13435            .to_vec();
13436        let b_bytes = b_pki
13437            .lock()
13438            .unwrap()
13439            .secret_bytes(b_secret)
13440            .unwrap()
13441            .to_vec();
13442        assert_eq!(a_bytes.len(), 32);
13443        assert_eq!(
13444            a_bytes, b_bytes,
13445            "runtime dispatch + in-test stack derive the same secret"
13446        );
13447
13448        rt.shutdown();
13449    }
13450
13451    /// FU2 S1.5 (e2e): after the auth handshake the runtime dispatch
13452    /// (A, replier) and a reference peer (B, stack+gate, initiator) over
13453    /// the Kx-protected VolatileSecure channel automatically exchange their data
13454    /// crypto tokens — afterwards secured user DATA round-trips in BOTH
13455    /// directions. **The secured-DATA proof via the runtime dispatch.**
13456    #[cfg(feature = "security")]
13457    #[test]
13458    #[serial_test::serial(dcps_security_e2e)]
13459    fn secured_data_round_trips_through_runtime_dispatch_e2e() {
13460        use zerodds_discovery::security::SecurityBuiltinStack;
13461        use zerodds_security::authentication::{AuthenticationPlugin, SharedSecretProvider};
13462        use zerodds_security::generic_message::{
13463            MessageIdentity, ParticipantGenericMessage, class_id,
13464        };
13465        use zerodds_security::token::DataHolder;
13466        use zerodds_security_crypto::{AesGcmCryptoPlugin, Suite};
13467        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
13468        use zerodds_security_runtime::{ProtectionLevel, SharedSecurityGate};
13469
13470        // Couples the pki plugin (behind a mutex) as the SharedSecretProvider to
13471        // the crypto plugin — like SecurityProfile in the FFI (Gap 1).
13472        struct PkiProvider(Arc<Mutex<PkiAuthenticationPlugin>>);
13473        impl SharedSecretProvider for PkiProvider {
13474            fn get_shared_secret(
13475                &self,
13476                h: zerodds_security::authentication::SharedSecretHandle,
13477            ) -> Option<Vec<u8>> {
13478                self.0.lock().ok()?.get_shared_secret(h)
13479            }
13480        }
13481        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>"#;
13482        let gov = || zerodds_security_permissions::parse_governance_xml(GOV).unwrap();
13483        let gate_with = |pki: &Arc<Mutex<PkiAuthenticationPlugin>>| {
13484            SharedSecurityGate::new(
13485                0,
13486                gov(),
13487                Box::new(AesGcmCryptoPlugin::with_secret_provider(
13488                    Suite::Aes128Gcm,
13489                    Arc::new(PkiProvider(pki.clone())) as Arc<dyn SharedSecretProvider>,
13490                )),
13491            )
13492        };
13493        let fake_rtps = |prefix: GuidPrefix, body: &[u8]| -> Vec<u8> {
13494            let mut m = Vec::new();
13495            m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13496            m.extend_from_slice(&prefix.to_bytes());
13497            m.extend_from_slice(body);
13498            m
13499        };
13500
13501        let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
13502        let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
13503        let b_prefix = GuidPrefix::from_bytes([0x10; 12]); // B < A → B initiator (cyclone convention)
13504        let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
13505        let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
13506        let a_key_pk = a_prefix.to_bytes();
13507        let b_key_pk = b_prefix.to_bytes();
13508
13509        // --- A: runtime with auth + gate (sharing pki_a) ---
13510        let pki_a = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13511        let a_local = pki_a
13512            .lock()
13513            .unwrap()
13514            .validate_with_config(
13515                IdentityConfig {
13516                    identity_cert_pem: a_cert.clone(),
13517                    identity_ca_pem: a_cert.clone(),
13518                    identity_key_pem: Some(a_key),
13519                },
13520                a_guid,
13521            )
13522            .unwrap();
13523        let a_token = pki_a.lock().unwrap().get_identity_token(a_local).unwrap();
13524        let gate_a = Arc::new(gate_with(&pki_a));
13525        let rt = DcpsRuntime::start(
13526            43,
13527            a_prefix,
13528            RuntimeConfig {
13529                security: Some(gate_a.clone()),
13530                ..RuntimeConfig::default()
13531            },
13532        )
13533        .expect("start");
13534        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_a.clone();
13535        let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
13536
13537        // --- B: in-test Stack + Gate (sharing pki_b), Initiator ---
13538        let pki_b = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13539        let b_local = pki_b
13540            .lock()
13541            .unwrap()
13542            .validate_with_config(
13543                IdentityConfig {
13544                    identity_cert_pem: b_cert.clone(),
13545                    identity_ca_pem: b_cert.clone(),
13546                    identity_key_pem: Some(b_key),
13547                },
13548                b_guid,
13549            )
13550            .unwrap();
13551        let b_token = pki_b.lock().unwrap().get_identity_token(b_local).unwrap();
13552        let gate_b = gate_with(&pki_b);
13553        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_b.clone();
13554        let mut stack_b =
13555            SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
13556
13557        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13558            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
13559            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
13560            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
13561        let a_beacon = make_secure_beacon_with_identity_token(a_prefix, flags, a_token.clone());
13562        stack_b.handle_remote_endpoints(
13563            &zerodds_discovery::spdp::SpdpReader::new()
13564                .parse_datagram(&a_beacon)
13565                .unwrap(),
13566        );
13567        // Wire A's stack deterministically (no handle_spdp_datagram —
13568        // a running runtime + trigger otherwise produces non-deterministic
13569        // proxy wirings via parallel/loopback beacons). A is the replier:
13570        // begin_handshake_with only sets up the peer state.
13571        let b_beacon = make_secure_beacon_with_identity_token(b_prefix, flags, b_token.clone());
13572        let b_parsed = zerodds_discovery::spdp::SpdpReader::new()
13573            .parse_datagram(&b_beacon)
13574            .unwrap();
13575        {
13576            let mut s = a_stack.lock().unwrap();
13577            s.handle_remote_endpoints(&b_parsed);
13578            s.begin_handshake_with(b_prefix, b_guid, &b_token).unwrap();
13579        }
13580
13581        // --- Stateless-Handshake pumpen (B initiiert) ---
13582        // A is the replier and derives the secret already at begin_handshake_
13583        // reply → A's response to the request contains BOTH: the
13584        // AUTH reply (stateless) AND A's Kx-encrypted crypto token
13585        // (volatile, automatically via the dispatch).
13586        let decode_route = |dgs: &[zerodds_rtps::message_builder::OutboundDatagram]| {
13587            let mut stateless = Vec::new();
13588            let mut volatile = Vec::new();
13589            for dg in dgs {
13590                let parsed = zerodds_rtps::datagram::decode_datagram(&dg.bytes).unwrap();
13591                let is_vol = parsed.submessages.iter().any(|sub| {
13592                    // Klartext-Pfad (unprotected): DATA an den VolatileSecure-Reader.
13593                    matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Data(d)
13594                        if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER)
13595                    // Cross-vendor path (protected): the volatile crypto-token DATA
13596                    // is SEC_*-protected (protect_volatile_outbound) -> the inner
13597                    // DATA is encrypted and recognizable only by the prepended SEC_PREFIX
13598                    // submessage (id 0x31). Stateless AUTH stays plaintext.
13599                    || matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Unknown { id: 0x31, .. })
13600                });
13601                if is_vol {
13602                    volatile.push(dg.bytes.clone());
13603                } else {
13604                    stateless.push(dg.bytes.clone());
13605                }
13606            }
13607            (stateless, volatile)
13608        };
13609
13610        let req = stack_b
13611            .begin_handshake_with(a_prefix, a_guid, &a_token)
13612            .unwrap();
13613        let a_resp = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
13614        let (a_stateless, a_volatile) = decode_route(&a_resp);
13615        assert!(
13616            !a_volatile.is_empty(),
13617            "A dispatch must send A's crypto token"
13618        );
13619
13620        // B verarbeitet A's AUTH-Reply → Final + B completes.
13621        let mut b_remote_id = None;
13622        let mut b_secret = None;
13623        let mut b_final = Vec::new();
13624        for sl in &a_stateless {
13625            for m in stack_b.stateless_reader.handle_datagram(sl).unwrap() {
13626                let (out, comp) = stack_b.on_stateless_message(a_prefix, &m).unwrap();
13627                b_final.extend(out);
13628                if let Some((id, sec)) = comp {
13629                    b_remote_id = Some(id);
13630                    b_secret = Some(sec);
13631                }
13632            }
13633        }
13634        let b_remote_id = b_remote_id.expect("B remote identity");
13635        let b_secret = b_secret.expect("B completes");
13636
13637        // B registers A's Kx, installs A's crypto token (from a_volatile).
13638        gate_b
13639            .register_remote_by_guid_from_secret(a_key_pk, b_remote_id, b_secret)
13640            .unwrap();
13641        // A's volatile crypto token is cross-vendor SEC_*-protected
13642        // (protect_volatile_outbound). B must decrypt the SEC_PREFIX/BODY/POSTFIX sequence
13643        // with A's Kx key to the inner DATA submessage before the
13644        // volatile_reader can process it — mirrors unprotect_volatile_
13645        // datagram im Live-Dispatch.
13646        let unprotect_vol_b = |bytes: &[u8]| -> Option<Vec<u8>> {
13647            let subs = walk_submessages(bytes);
13648            let prefix_pos = subs.iter().position(|(id, _, _)| *id == SMID_SEC_PREFIX)?;
13649            let postfix_idx = subs[prefix_pos..]
13650                .iter()
13651                .position(|(id, _, _)| *id == SMID_SEC_POSTFIX)
13652                .map(|i| prefix_pos + i)?;
13653            let (_, p_start, _) = subs[prefix_pos];
13654            let (_, q_start, q_total) = subs[postfix_idx];
13655            let data_submsg = gate_b
13656                .decode_kx_datawriter_from(&a_key_pk, &bytes[p_start..q_start + q_total])
13657                .ok()?;
13658            let mut out = Vec::with_capacity(bytes.len());
13659            out.extend_from_slice(&bytes[..20]);
13660            for (i, &(_, start, total)) in subs.iter().enumerate() {
13661                if i < prefix_pos || i > postfix_idx {
13662                    out.extend_from_slice(&bytes[start..start + total]);
13663                } else if i == prefix_pos {
13664                    out.extend_from_slice(&data_submsg);
13665                }
13666            }
13667            Some(out)
13668        };
13669        let mut b_installed = 0;
13670        for vol in &a_volatile {
13671            let vol_plain = unprotect_vol_b(vol).unwrap_or_else(|| vol.clone());
13672            let parsed = zerodds_rtps::datagram::decode_datagram(&vol_plain).unwrap();
13673            let vol_src = parsed.header.guid_prefix;
13674            for sub in parsed.submessages {
13675                if let zerodds_rtps::datagram::ParsedSubmessage::Data(d) = sub {
13676                    if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
13677                        for m in stack_b.volatile_reader.handle_data(vol_src, &d).unwrap() {
13678                            if m.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS {
13679                                // plaintext keymat (confidentiality was provided by the SEC_*
13680                                // protection of the volatile DATA, decrypted above) —
13681                                // install directly, no transform_kx_inbound.
13682                                let token = m.message_data[0]
13683                                    .binary_property(CRYPTO_TOKEN_PROP)
13684                                    .unwrap();
13685                                gate_b
13686                                    .set_remote_data_token_by_guid(&a_key_pk, token)
13687                                    .unwrap();
13688                                b_installed += 1;
13689                            }
13690                        }
13691                    }
13692                }
13693            }
13694        }
13695        assert!(b_installed >= 1, "B must install A's crypto token");
13696
13697        // B builds + sends its crypto token — plaintext keymat in the
13698        // ParticipantGenericMessage (cross-vendor: confidentiality via SEC_*
13699        // protection of the transporting volatile DATA, not via token-internal
13700        // Kx encryption).
13701        let b_data_token = gate_b.local_token().unwrap();
13702        let b_crypto_msg = ParticipantGenericMessage {
13703            message_identity: MessageIdentity {
13704                source_guid: b_guid,
13705                sequence_number: 1,
13706            },
13707            related_message_identity: MessageIdentity::default(),
13708            destination_participant_key: a_guid,
13709            destination_endpoint_key: [0; 16],
13710            source_endpoint_key: [0; 16],
13711            message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
13712            message_data: alloc::vec![
13713                DataHolder::new("DDS:Crypto:AES_GCM_GMAC")
13714                    .with_binary_property(CRYPTO_TOKEN_PROP, b_data_token)
13715            ],
13716        };
13717        let b_volatile = stack_b.volatile_writer.write(&b_crypto_msg).unwrap();
13718        // SEC_* submessage protection with A's Kx key (mirrors protect_volatile_
13719        // datagram in the live path): B encrypts the DATA submessage, A's
13720        // dispatch decrypts it via unprotect_volatile_datagram.
13721        let protect_vol_b = |bytes: &[u8]| -> Vec<u8> {
13722            let subs = walk_submessages(bytes);
13723            if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
13724                return bytes.to_vec();
13725            }
13726            let mut out = Vec::with_capacity(bytes.len() + 64);
13727            out.extend_from_slice(&bytes[..20]);
13728            for (id, start, total) in subs {
13729                let submsg = &bytes[start..start + total];
13730                if id == SMID_DATA {
13731                    out.extend_from_slice(
13732                        &gate_b.encode_kx_datawriter_for(&a_key_pk, submsg).unwrap(),
13733                    );
13734                } else {
13735                    out.extend_from_slice(submsg);
13736                }
13737            }
13738            out
13739        };
13740        let b_vol_protected = protect_vol_b(&b_volatile[0].bytes);
13741
13742        // B's Final + B's Crypto-Token an A's Dispatch: A installiert B's
13743        // Data token (automatically via install_crypto_token).
13744        for f in &b_final {
13745            dispatch_security_builtin_datagram(&rt, &f.bytes, Duration::from_secs(1));
13746        }
13747        dispatch_security_builtin_datagram(&rt, &b_vol_protected, Duration::from_secs(1));
13748
13749        // --- Secured DATA in both directions ---
13750        let msg_ab = fake_rtps(a_prefix, b"[A->B secured payload]");
13751        let wire_ab = gate_a
13752            .transform_outbound_for(&b_key_pk, &msg_ab, ProtectionLevel::Encrypt)
13753            .unwrap();
13754        assert_eq!(
13755            gate_b.transform_inbound_from(&a_key_pk, &wire_ab).unwrap(),
13756            msg_ab,
13757            "A->B secured DATA must round-trip"
13758        );
13759        let msg_ba = fake_rtps(b_prefix, b"[B->A secured payload]");
13760        let wire_ba = gate_b
13761            .transform_outbound_for(&a_key_pk, &msg_ba, ProtectionLevel::Encrypt)
13762            .unwrap();
13763        assert_eq!(
13764            gate_a.transform_inbound_from(&b_key_pk, &wire_ba).unwrap(),
13765            msg_ba,
13766            "B->A secured DATA must round-trip (A's dispatch installed B's token)"
13767        );
13768
13769        rt.shutdown();
13770    }
13771
13772    #[test]
13773    fn c34c_enable_security_builtins_replays_known_peers() {
13774        // Order reversed: SPDP discovery first, plugin-
13775        // activation afterward. enable_security_builtins must catch up on already-
13776        // known peers. Plus: demux without a plugin (before enable)
13777        // is a no-op + does not panic.
13778        let rt = DcpsRuntime::start(
13779            76,
13780            GuidPrefix::from_bytes([0x76; 12]),
13781            RuntimeConfig::default(),
13782        )
13783        .expect("start");
13784
13785        // Demux without a plugin: silent no-op
13786        dispatch_security_builtin_datagram(&rt, &[0u8; 16], Duration::from_secs(1));
13787
13788        let remote = GuidPrefix::from_bytes([0x77; 12]);
13789        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13790            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13791        let dg = make_remote_spdp_beacon_with_flags(remote, flags);
13792        handle_spdp_datagram(&rt, &dg);
13793
13794        let stack = rt.enable_security_builtins(VendorId::ZERODDS);
13795        {
13796            let s = stack.lock().unwrap();
13797            assert_eq!(
13798                s.stateless_writer.reader_proxy_count(),
13799                1,
13800                "late plugin activation must catch up on known peers"
13801            );
13802        }
13803
13804        rt.shutdown();
13805    }
13806
13807    /// #29 regression: the earlier per-peer once-guard blocked late-matched
13808    /// user-endpoint tokens. `pending_endpoint_tokens` must, with already-sent
13809    /// builtin tokens, let through EXACTLY the new user token — not treat the whole
13810    /// peer as "done".
13811    #[cfg(feature = "security")]
13812    #[test]
13813    fn pending_endpoint_tokens_keeps_late_user_token_after_builtins_sent() {
13814        use zerodds_security::generic_message::ParticipantGenericMessage;
13815        // An early-sent builtin token (secure-SEDP) ...
13816        let builtin = ParticipantGenericMessage {
13817            source_endpoint_key: [0xff; 16],
13818            destination_endpoint_key: [0xfe; 16],
13819            ..Default::default()
13820        };
13821        // ... and a late-matched user-endpoint token.
13822        let user = ParticipantGenericMessage {
13823            source_endpoint_key: [0x03; 16],
13824            destination_endpoint_key: [0x04; 16],
13825            ..Default::default()
13826        };
13827        let mut sent = alloc::collections::BTreeSet::new();
13828        sent.insert(endpoint_token_key(&builtin));
13829
13830        let pending = pending_endpoint_tokens(vec![builtin.clone(), user.clone()], &sent);
13831
13832        assert_eq!(pending.len(), 1, "only the new user token may be pending");
13833        assert_eq!(
13834            pending[0].source_endpoint_key, user.source_endpoint_key,
13835            "the let-through token must be the user-endpoint token"
13836        );
13837        // Idempotency: after sending, nothing is pending anymore.
13838        let mut sent2 = sent.clone();
13839        sent2.insert(endpoint_token_key(&user));
13840        assert!(
13841            pending_endpoint_tokens(vec![builtin, user], &sent2).is_empty(),
13842            "already-sent tokens must not become pending again"
13843        );
13844    }
13845}