Skip to main content

zerodds_rtps/
reliable_writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Reliable RTPS writer (1:N reader proxies) — DDSI-RTPS 2.5 §8.4.9.
4//!
5//! Corresponds to the [`StatefulWriter`] role with 1..N matched readers.
6//! No heartbeat liveliness. Fragmentation
7//! (§8.4.14) is supported. Multi-reader + submessage aggregation
8//! (Fast-DDS alignment) are in since WP 1.4 T3.
9//!
10//! # API shape
11//!
12//! The state machine is tick-driven. `now` is a `Duration`
13//! since writer start (no_std-compatible, without std::Instant).
14//!
15//! ```text
16//!   let mut w = ReliableWriter::new(...);
17//!   w.add_reader_proxy(proxy_a);
18//!   loop {
19//!       if let Some(payload) = app.next_sample() {
20//!           for dg in w.write(payload)? { transport.send_to_all(&dg.targets, &dg.bytes); }
21//!       }
22//!       for dg in w.tick(uptime())? { transport.send_to_all(&dg.targets, &dg.bytes); }
23//!       match transport.recv_control() {
24//!           AckNack(src, ack) => w.handle_acknack(src, ack.base, ack.requested),
25//!           NackFrag(src, nf) => w.handle_nackfrag(src, &nf),
26//!           _ => {}
27//!       }
28//!   }
29//! ```
30//!
31//! [`StatefulWriter`]: https://www.omg.org/spec/DDSI-RTPS/2.5/
32
33use core::time::Duration;
34
35extern crate alloc;
36use alloc::vec::Vec;
37
38use alloc::rc::Rc;
39
40use crate::error::WireError;
41use crate::header::RtpsHeader;
42use crate::history_cache::{CacheChange, ChangeKind, HistoryCache, HistoryKind};
43use crate::message_builder::{AddError, MessageBuilder, OutboundDatagram};
44use crate::reader_proxy::ReaderProxy;
45use crate::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageId};
46use crate::submessages::{
47    DATA_FLAG_DATA, DataFragSubmessage, DataSubmessage, GapSubmessage, HeartbeatSubmessage,
48    NackFragSubmessage, SequenceNumberSet,
49};
50use crate::wire_types::{EntityId, FragmentNumber, Guid, Locator, SequenceNumber, VendorId};
51
52/// Default heartbeat period.
53///
54/// DDSI-RTPS §8.4.15 specifies no fixed default — "implementation-defined,
55/// typically 1 s". Cyclone DDS and FastDDS use 100 ms; that is also our
56/// value because:
57/// 1. With Reliable + KEEP_LAST(N) the HB period drives the worst-case latency
58///    floor (the reader sends an ACK on the HB, the writer can only shrink the cache after).
59/// 2. The 1 s default was the pre-D.5d initial implementation; the current
60///    event-driven ACKNACK + per-peer scheduler architecture (D.5d+) makes
61///    the HB floor a pure "idle keep-alive" pulse, no longer a
62///    latency determinant.
63/// 3. The spec is satisfied: period < lease_duration, period > 0, period stable.
64pub const DEFAULT_HEARTBEAT_PERIOD: Duration = Duration::from_millis(100);
65
66/// Default fragment size in bytes. 1344 = 1400 MTU − 20 RTPS header −
67/// ~32 bytes submessage overhead.
68pub const DEFAULT_FRAGMENT_SIZE: u32 = 1344;
69
70/// Fragment size for loopback/same-host paths. The loopback MTU is
71/// 65536 — a sample up to ~63 kB goes there in **one** datagram,
72/// instead of N 1344-B fragments. The DCPS layer sets this via
73/// [`ReliableWriter::set_fragmentation`] when all matched readers run
74/// on the same host. 63000 < `u16::MAX` (wire field `fragmentSize`).
75pub const LOOPBACK_FRAGMENT_SIZE: u32 = 63_000;
76
77/// MTU budget for loopback/same-host paths (matching
78/// [`LOOPBACK_FRAGMENT_SIZE`]).
79pub const LOOPBACK_MTU: usize = 64_000;
80
81/// A reliable writer with 0..N reader proxies.
82#[derive(Debug, Clone)]
83pub struct ReliableWriter {
84    guid: Guid,
85    vendor_id: VendorId,
86    reader_proxies: Vec<ReaderProxy>,
87    cache: HistoryCache,
88    heartbeat_period: Duration,
89    last_heartbeat: Option<Duration>,
90    heartbeat_count: i32,
91    nackfrag_count: i32,
92    /// ACKNACK/NACK_FRAG messages from unknown `src_guid` remotes.
93    /// Diagnosis: indicates misrouting, stale proxies or
94    /// malicious senders.
95    unknown_src_count: u64,
96    next_sn: i64,
97    fragment_size: u32,
98    mtu: usize,
99    /// If true: every outgoing datagram begins with INFO_DST(target
100    /// proxy prefix). Mandatory for cyclone's filtered VolatileSecure reader,
101    /// which matches by full GUID (otherwise dst=0:0:0 -> no match ->
102    /// wn->last_seq hangs -> ddsi_reorder_nackmap maxseq-0 deadlock).
103    emit_info_dst: bool,
104    /// Hot-path recycling pool for payload `Arc<[u8]>`. On cache
105    /// eviction the evicted `Arc<[u8]>` is parked here and reused on the
106    /// next same-sized `stage_sample` allocation
107    /// (Arc::get_mut + memcpy into the existing
108    /// buffer). Eliminates the `Arc::from(payload)` allocation per
109    /// write for workloads with constant payload size (bench,
110    /// fixed-size sensor streams, RPC with a fixed request size).
111    /// On changing sizes the pool entry is dropped and a fresh one
112    /// allocated.
113    payload_pool: alloc::vec::Vec<alloc::sync::Arc<[u8]>>,
114    /// Pool cap (default 8) — prevents unbounded pool growth
115    /// on cache-depth spikes.
116    payload_pool_cap: usize,
117}
118
119/// Configuration at creation.
120#[derive(Debug, Clone)]
121pub struct ReliableWriterConfig {
122    /// GUID of the writer endpoint.
123    pub guid: Guid,
124    /// VendorId for the RTPS header.
125    pub vendor_id: VendorId,
126    /// Initial reader proxies. More via `add_reader_proxy`.
127    pub reader_proxies: Vec<ReaderProxy>,
128    /// Absolute upper bound for cache entries. Acts as a capacity;
129    /// the semantics on overflow are determined by [`history_kind`](Self::history_kind).
130    pub max_samples: usize,
131    /// History QoS:
132    /// - `KeepAll`: write() fails on overflow
133    ///   (no-loss scenarios, e.g. logging).
134    /// - `KeepLast { depth }`: the oldest sample drops out on overflow
135    ///   (spec default; a stalled reader does not block the whole pipeline).
136    pub history_kind: HistoryKind,
137    /// Heartbeat period (default: 1 s).
138    pub heartbeat_period: Duration,
139    /// Fragment size in bytes ([`DEFAULT_FRAGMENT_SIZE`]).
140    pub fragment_size: u32,
141    /// MTU for submessage aggregation ([`DEFAULT_MTU`]).
142    pub mtu: usize,
143}
144
145impl ReliableWriter {
146    /// Creates an empty writer.
147    ///
148    /// # Panics
149    /// - `cfg.fragment_size == 0`
150    /// - `cfg.mtu < 20` (the RTPS header does not fit)
151    #[must_use]
152    pub fn new(cfg: ReliableWriterConfig) -> Self {
153        assert!(cfg.fragment_size > 0, "fragment_size must be > 0");
154        assert!(cfg.mtu >= 20, "mtu must accommodate RTPS header");
155        Self {
156            guid: cfg.guid,
157            vendor_id: cfg.vendor_id,
158            reader_proxies: cfg.reader_proxies,
159            cache: HistoryCache::new_with_kind(cfg.history_kind, cfg.max_samples),
160            heartbeat_period: cfg.heartbeat_period,
161            last_heartbeat: None,
162            heartbeat_count: 0,
163            nackfrag_count: 0,
164            unknown_src_count: 0,
165            next_sn: 0,
166            fragment_size: cfg.fragment_size,
167            mtu: cfg.mtu,
168            emit_info_dst: false,
169            payload_pool: alloc::vec::Vec::with_capacity(8),
170            payload_pool_cap: 8,
171        }
172    }
173
174    /// GUID of the writer.
175    #[must_use]
176    pub fn guid(&self) -> Guid {
177        self.guid
178    }
179
180    /// Enables INFO_DST(target-prefix) before each datagram (RTPS 2.5
181    /// §8.3.7). Needed for receivers that match directed submessages by full
182    /// GUID (cyclone filtered VolatileSecure reader).
183    pub fn set_emit_info_dst(&mut self, v: bool) {
184        self.emit_info_dst = v;
185    }
186
187    /// Opens a MessageBuilder for proxy `idx`; prepends INFO_DST with
188    /// the target prefix if `emit_info_dst`.
189    fn open_builder(&self, idx: usize, targets: &Rc<Vec<Locator>>) -> MessageBuilder {
190        let mut b = MessageBuilder::open(self.rtps_header(), Rc::clone(targets), self.mtu);
191        if self.emit_info_dst {
192            let prefix = self.reader_proxies[idx].remote_reader_guid.prefix;
193            let _ = b.try_add_submessage(SubmessageId::InfoDst, 0, &prefix.to_bytes());
194        }
195        b
196    }
197
198    /// Read-only-Slice der registrierten Reader-Proxies.
199    #[must_use]
200    pub fn reader_proxies(&self) -> &[ReaderProxy] {
201        &self.reader_proxies
202    }
203
204    /// Number of registered reader proxies.
205    #[must_use]
206    pub fn reader_proxy_count(&self) -> usize {
207        self.reader_proxies.len()
208    }
209
210    /// Removes samples with SN < `up_to_exclusive` from the cache. Used
211    /// by higher layers for lifespan expiry (Spec §2.2.3.16):
212    /// expired samples disappear so that even late
213    /// reader proxies no longer get them.
214    pub fn remove_samples_up_to(&mut self, up_to_exclusive: SequenceNumber) -> usize {
215        self.cache.remove_up_to(up_to_exclusive)
216    }
217
218    /// History cache (read-only).
219    #[must_use]
220    pub fn cache(&self) -> &HistoryCache {
221        &self.cache
222    }
223
224    /// **Expert-only**: sets the history kind + `max_samples` of the cache at
225    /// runtime. Used by `DcpsRuntime` for the durability-backend replay burst
226    /// (Spec §2.2.3.5) — the cache must hold all replay
227    /// samples during the burst, then return to the user QoS.
228    pub fn set_cache_kind_and_max(
229        &mut self,
230        kind: crate::history_cache::HistoryKind,
231        max_samples: usize,
232    ) {
233        self.cache.set_kind_and_max(kind, max_samples);
234    }
235
236    /// Number of HEARTBEATs sent.
237    #[must_use]
238    pub fn heartbeat_count(&self) -> i32 {
239        self.heartbeat_count
240    }
241
242    /// Number of NACK_FRAGs received.
243    #[must_use]
244    pub fn nackfrag_count(&self) -> i32 {
245        self.nackfrag_count
246    }
247
248    /// Number of ACKNACK/NACK_FRAG messages from **unknown** sources
249    /// since writer start. Typical causes: misrouting on multicast,
250    /// stale proxies after `remove_reader_proxy`, GUID spoofing.
251    #[must_use]
252    pub fn unknown_src_count(&self) -> u64 {
253        self.unknown_src_count
254    }
255
256    /// Current fragment-size configuration.
257    #[must_use]
258    pub fn fragment_size(&self) -> u32 {
259        self.fragment_size
260    }
261
262    /// Sets fragment size + MTU budget anew — called by the DCPS layer
263    /// when the path MTU of the matched readers changes: if ALL
264    /// readers run on the same host (loopback, MTU 65536), one
265    /// datagram per sample suffices ([`LOOPBACK_FRAGMENT_SIZE`]); if a reader
266    /// is remote, it stays at the Ethernet-safe [`DEFAULT_FRAGMENT_SIZE`]
267    /// (otherwise an oversized datagram gets IP-fragmented on the 1500 path,
268    /// and a lost IP fragment costs the whole sample).
269    ///
270    /// # Panics
271    /// `fragment_size == 0` or `mtu < 20`.
272    pub fn set_fragmentation(&mut self, fragment_size: u32, mtu: usize) {
273        assert!(fragment_size > 0, "fragment_size must be > 0");
274        assert!(mtu >= 20, "mtu must accommodate RTPS header");
275        self.fragment_size = fragment_size;
276        self.mtu = mtu;
277    }
278
279    /// True if a payload of this size is fragmented
280    /// (payload length > `fragment_size`).
281    #[must_use]
282    fn needs_fragmentation(&self, payload: &[u8]) -> bool {
283        u32::try_from(payload.len()).unwrap_or(u32::MAX) > self.fragment_size && !payload.is_empty()
284    }
285
286    /// Checks whether all reader proxies have already acknowledged the
287    /// currently highest sample SN in the cache. Returns `true` even if
288    /// the cache is empty or no proxies exist (nothing to
289    /// acknowledge).
290    ///
291    /// Spec basis for `DataWriter::wait_for_acknowledgments`
292    /// (OMG DDS 1.4 §2.2.2.4.2.22).
293    #[must_use]
294    pub fn all_samples_acknowledged(&self) -> bool {
295        let Some(max_sn) = self.cache.max_sn() else {
296            return true;
297        };
298        self.reader_proxies
299            .iter()
300            .all(|p| p.highest_acked_sn() >= max_sn)
301    }
302
303    /// Adds a reader proxy. Idempotent: if a proxy with the
304    /// same `remote_reader_guid` exists, it is replaced.
305    ///
306    /// Sets `last_heartbeat = None`, so the next `tick()` immediately
307    /// emits a heartbeat to **all** proxies (incl. the new one).
308    /// RTPS §8.4.15.4: a freshly added ReaderProxy must get an opportunity
309    /// to AckNack, otherwise it waits until the next periodic
310    /// heartbeat round (default 1 s) — and for late-wired proxies
311    /// (after cache inserts) this is the only way to catch up the early-
312    /// inserted samples, since `write_sample_with_datagrams`
313    /// only sends directly if the proxy is synchronous.
314    pub fn add_reader_proxy(&mut self, proxy: ReaderProxy) {
315        let guid = proxy.remote_reader_guid;
316        if let Some(idx) = self
317            .reader_proxies
318            .iter()
319            .position(|p| p.remote_reader_guid == guid)
320        {
321            self.reader_proxies[idx] = proxy;
322        } else {
323            self.reader_proxies.push(proxy);
324        }
325        // Force the next tick to HB — the new proxy needs it for
326        // AckNack-driven catch-up.
327        self.last_heartbeat = None;
328    }
329
330    /// Removes the proxy with the given GUID.
331    pub fn remove_reader_proxy(&mut self, guid: Guid) -> Option<ReaderProxy> {
332        let idx = self
333            .reader_proxies
334            .iter()
335            .position(|p| p.remote_reader_guid == guid)?;
336        Some(self.reader_proxies.remove(idx))
337    }
338
339    // ---------- Write ----------
340
341    /// Writes a new sample and fans it out to all proxies.
342    ///
343    /// Per proxy this produces (aggregated):
344    /// - 1 DATA datagram if `payload.len() <= fragment_size`
345    /// - N DATA_FRAG datagrams (one datagram per fragment, no mix)
346    ///
347    /// # Errors
348    /// SN overflow, cache full, body too large.
349    pub fn write(&mut self, payload: &[u8]) -> Result<Vec<OutboundDatagram>, WireError> {
350        self.write_stamped(payload, None)
351    }
352
353    /// Like [`Self::write`], but attaches a source timestamp (DDSI-RTPS
354    /// §8.7.3): the writer prepends an INFO_TS submessage before each DATA so
355    /// the reader can populate `SampleInfo.source_timestamp` and apply
356    /// `DESTINATION_ORDER = BY_SOURCE_TIMESTAMP`. `None` ⇒ no INFO_TS.
357    ///
358    /// # Errors
359    /// SN overflow, cache full, body too large.
360    pub fn write_stamped(
361        &mut self,
362        payload: &[u8],
363        source_timestamp: Option<crate::header_extension::HeTimestamp>,
364    ) -> Result<Vec<OutboundDatagram>, WireError> {
365        let (sn, payload) = self.stage_sample(payload, source_timestamp)?;
366
367        let mut out = Vec::new();
368        for idx in 0..self.reader_proxies.len() {
369            // `next_unsent_change(cache_max)` advances the proxy by exactly
370            // one SN. Two cases:
371            //
372            // * The proxy was synchronous (`highest_sent_sn == sn - 1`): advance
373            //   returns `Some(sn)`, we can send the sample directly to the
374            //   peer — saving a heartbeat round.
375            //
376            // * The proxy lags (wired late via SEDP, the cache already had
377            //   older samples): advance returns an older SN. Then
378            //   it would be wrong to send the *new* payload with the *new* SN
379            //   directly — the proxy thinks an early SN went out first
380            //   while the reader sees the new one. Instead
381            //   we let `tick()` resolve the gap via heartbeat + AckNack-
382            //   controlled resend (the standard reliable path).
383            let advanced = self.reader_proxies[idx].next_unsent_change(sn);
384            if advanced != Some(sn) {
385                continue;
386            }
387            let reader_id = self.reader_proxies[idx].remote_reader_guid.entity_id;
388            let targets = self.targets_for(idx);
389            // The encapsulation header (payload bytes 0..4, RTPS 2.5
390            // §10.5) is already set honestly by the caller and reflects
391            // the actual body encoding. No per-peer
392            // relabeling — restamping an encap byte without re-encoding the body
393            // would fake a wrong representation to the reader
394            // (XTypes 1.3 §7.4: XCDR1/XCDR2 differ
395            // in alignment, so they are NOT bit-identical).
396            out.extend(self.build_sample_datagrams(sn, &payload, reader_id, &targets)?);
397        }
398        Ok(out)
399    }
400
401    /// D.5e phase 2: write + piggyback HEARTBEAT in one operation.
402    ///
403    /// Cyclone DDS and FastDDS additionally send a HEARTBEAT on every `write()`,
404    /// so the reader can trigger an ACKNACK
405    /// immediately. Without the piggyback the reader must wait until the next periodic
406    /// HB (default 100 ms) — which becomes the latency floor for a
407    /// 1-in-flight roundtrip.
408    ///
409    /// This method is a superset of [`Self::write`]: it emits
410    /// all DATA datagrams and appends a HEARTBEAT datagram per matched
411    /// reader proxy. `last_heartbeat = now` is set so that
412    /// `tick()` does not fire twice.
413    ///
414    /// # Errors
415    /// Wire encode error.
416    pub fn write_with_heartbeat(
417        &mut self,
418        payload: &[u8],
419        now: Duration,
420    ) -> Result<Vec<OutboundDatagram>, WireError> {
421        self.write_with_heartbeat_stamped(payload, now, None)
422    }
423
424    /// Like [`Self::write_with_heartbeat`], with a source timestamp (emits
425    /// INFO_TS before each DATA — see [`Self::write_stamped`]).
426    ///
427    /// # Errors
428    /// Wire encode error.
429    pub fn write_with_heartbeat_stamped(
430        &mut self,
431        payload: &[u8],
432        now: Duration,
433        source_timestamp: Option<crate::header_extension::HeTimestamp>,
434    ) -> Result<Vec<OutboundDatagram>, WireError> {
435        let (sn, payload) = self.stage_sample(payload, source_timestamp)?;
436        // first_sn for the HEARTBEAT. final_flag=false: the reader MUST
437        // respond with ACKNACK (RTPS 2.5 §8.4.15.5).
438        let cache_min = self.cache.min_sn().unwrap_or(SequenceNumber(1));
439        let fragmented = self.needs_fragmentation(&payload);
440        let mut out = Vec::new();
441        for idx in 0..self.reader_proxies.len() {
442            let advanced = self.reader_proxies[idx].next_unsent_change(sn) == Some(sn);
443            let reader_id = self.reader_proxies[idx].remote_reader_guid.entity_id;
444            let targets = self.targets_for(idx);
445            // Per-proxy HEARTBEAT `first_sn` (RTPS 2.5 §8.4.12.1: the
446            // smallest SN relevant FOR THIS READER) —
447            // identical to the `tick` logic. A volatile late-joiner proxy
448            // has advanced via `skip_samples_up_to` past the global `cache_min`;
449            // with `cache_min` as first_sn the
450            // HEARTBEAT would prompt it to re-request the deliberately skipped pre-
451            // history — a lossy NACK→GAP roundtrip that
452            // stalls under load (flake `volatile_writer_…`). `highest_acked
453            // + 1` returns instead exactly its first relevant SN.
454            let hb_first_sn = cache_min.max(SequenceNumber(
455                self.reader_proxies[idx]
456                    .highest_acked_sn()
457                    .0
458                    .saturating_add(1),
459            ));
460            if advanced && !fragmented {
461                // Perf: DATA + piggyback HEARTBEAT share ONE
462                // RTPS message / ONE datagram — one `sendto` instead of two
463                // (and one fewer `recvfrom` + wakeup at the reader).
464                // RTPS 2.5 allows multiple submessages per message; this
465                // is Cyclone's "piggyback heartbeat" 1:1. If the
466                // HEARTBEAT no longer fits the MTU,
467                // `append_submessage` automatically splits into two datagrams.
468                let mut builder = self.open_builder(idx, &targets);
469                self.append_data(&mut builder, sn, &payload, reader_id, &mut out, &targets)?;
470                // Piggyback ONLY if the DATA submessage ends on a
471                // 32-bit boundary (RTPS 2.5 §8.3.4.1: every submessage
472                // starts 4-byte-aligned). With an odd serializedData
473                // length the HEARTBEAT would start misaligned — strict
474                // readers (Cyclone DDS, RTI Connext) then reject the message
475                // as malformed.
476                //
477                // NOTE: inserting a PAD submessage between DATA and HB does
478                // NOT work, because then PAD itself starts at an
479                // unaligned offset — Cyclone reports
480                // `malformed packet state parse:DATA`. Real inlining via
481                // serializedData padding would be the other option, but breaks
482                // `RawBytes` readers (zerodds-self tests see the
483                // pad as trailing bytes). Currently: 2 datagrams for
484                // odd payloads — wire conformance before perf.
485                let coframe = builder.len() % 4 == 0;
486                if coframe {
487                    self.append_heartbeat(
488                        &mut builder,
489                        reader_id,
490                        hb_first_sn,
491                        false,
492                        &mut out,
493                        &targets,
494                    )?;
495                }
496                if let Some(dg) = builder.finish() {
497                    out.push(dg);
498                }
499                if !coframe {
500                    let mut hb_builder = self.open_builder(idx, &targets);
501                    self.append_heartbeat(
502                        &mut hb_builder,
503                        reader_id,
504                        hb_first_sn,
505                        false,
506                        &mut out,
507                        &targets,
508                    )?;
509                    if let Some(dg) = hb_builder.finish() {
510                        out.push(dg);
511                    }
512                }
513            } else {
514                // Fragmentierter Sample → eigene DATA_FRAG-Datagramme;
515                // lagging Proxy (advanced=false) → nur HEARTBEAT. Das
516                // HEARTBEAT goes out here as its own datagram.
517                if advanced {
518                    out.extend(self.build_sample_datagrams(sn, &payload, reader_id, &targets)?);
519                }
520                let mut builder = self.open_builder(idx, &targets);
521                self.append_heartbeat(
522                    &mut builder,
523                    reader_id,
524                    hb_first_sn,
525                    false,
526                    &mut out,
527                    &targets,
528                )?;
529                if let Some(dg) = builder.finish() {
530                    out.push(dg);
531                }
532            }
533        }
534        self.last_heartbeat = Some(now);
535        Ok(out)
536    }
537
538    /// SN allocation + HistoryCache insert. The payload `Arc<[u8]>` is
539    /// **recycled** if the pool has a matching buffer
540    /// (cache eviction → pool → the next `stage_sample` writes directly
541    /// into it, instead of allocating `Arc::from(payload)` anew). Cyclone's
542    /// `nn_xmsg_pool` equivalent for the payload buffer.
543    ///
544    /// Recycling condition: the pooled buffer has the **same length** and
545    /// `strong_count == 1` (no one else holds it). Otherwise drop the
546    /// pool entry + a fresh `Arc::from(payload)` allocation.
547    fn stage_sample(
548        &mut self,
549        payload: &[u8],
550        source_timestamp: Option<crate::header_extension::HeTimestamp>,
551    ) -> Result<(SequenceNumber, alloc::sync::Arc<[u8]>), WireError> {
552        let sn_value = self
553            .next_sn
554            .checked_add(1)
555            .ok_or(WireError::ValueOutOfRange {
556                message: "sequence number overflow",
557            })?;
558        self.next_sn = sn_value;
559        let sn = SequenceNumber(sn_value);
560
561        // Try recycle from the pool. We pop from the back end (LIFO,
562        // cache-warm). On a miss (refcount > 1 or length != payload)
563        // we drop the pool entry and try the next.
564        let arc_payload = loop {
565            match self.payload_pool.pop() {
566                None => {
567                    // Pool empty → allocate fresh (legacy path).
568                    break alloc::sync::Arc::<[u8]>::from(payload);
569                }
570                Some(mut arc) => {
571                    let len_match = arc.len() == payload.len();
572                    let exclusive = alloc::sync::Arc::strong_count(&arc) == 1
573                        && alloc::sync::Arc::weak_count(&arc) == 0;
574                    if len_match && exclusive {
575                        // SAFETY: strong_count == 1, weak_count == 0,
576                        // so exclusive access → `get_mut` is Some.
577                        if let Some(slice) = alloc::sync::Arc::get_mut(&mut arc) {
578                            slice.copy_from_slice(payload);
579                            break arc;
580                        }
581                        // Theoretically unreachable (pre-check above);
582                        // defensive: drop + next.
583                    }
584                    // Pool entry does not fit — drop it + try the next
585                    // loop iteration.
586                    drop(arc);
587                }
588            }
589        };
590
591        // Insert into the cache and capture the evicted Arc for the pool.
592        let evicted = self
593            .cache
594            .insert_returning_evicted(
595                CacheChange::alive_arc(sn, alloc::sync::Arc::clone(&arc_payload))
596                    .with_source_timestamp(source_timestamp),
597            )
598            .map_err(|_| WireError::ValueOutOfRange {
599                message: "history cache full or duplicate",
600            })?;
601        if let Some(evicted_change) = evicted {
602            if self.payload_pool.len() < self.payload_pool_cap {
603                self.payload_pool.push(evicted_change.payload);
604            }
605            // otherwise drop (pool full — typically not in steady state).
606        }
607        Ok((sn, arc_payload))
608    }
609
610    // ---------- Tick ----------
611
612    /// Tick-Event: HEARTBEATs + Resends + NACK_FRAG-Responses, aggregiert.
613    ///
614    /// # Errors
615    /// Wire-encode error.
616    pub fn tick(&mut self, now: Duration) -> Result<Vec<OutboundDatagram>, WireError> {
617        let should_heartbeat = match self.last_heartbeat {
618            None => true,
619            Some(last) => now.saturating_sub(last) >= self.heartbeat_period,
620        };
621        let emit_hb = should_heartbeat && !self.cache.is_empty();
622
623        let mut out = Vec::new();
624        let mut hb_emitted_any = false;
625
626        for idx in 0..self.reader_proxies.len() {
627            let reader_id = self.reader_proxies[idx].remote_reader_guid.entity_id;
628            let targets = self.targets_for(idx);
629
630            // 1) Fragment resends (from NACK_FRAG) — one datagram per fragment
631            while let Some((sn, frag)) = self.reader_proxies[idx].next_requested_fragment() {
632                match self.cache.get(sn) {
633                    Some(change) => {
634                        let payload = change.payload.clone();
635                        #[cfg(feature = "metrics")]
636                        crate::metrics::inc_retransmit();
637                        #[cfg(feature = "metrics")]
638                        crate::metrics::inc_fragmented_sample();
639                        out.push(
640                            self.build_data_frag_datagram(sn, frag, &payload, reader_id, &targets)?,
641                        );
642                    }
643                    None => {
644                        out.push(self.build_gap_datagram(sn, reader_id, &targets)?);
645                    }
646                }
647            }
648
649            // 2) Aggregated datagram for whole-SN resends + optional HB
650            let mut builder = self.open_builder(idx, &targets);
651
652            while let Some(sn) = self.reader_proxies[idx].next_requested_change() {
653                #[cfg(feature = "metrics")]
654                crate::metrics::inc_retransmit();
655                match self.cache.get(sn) {
656                    Some(change) => {
657                        let payload = change.payload.clone();
658                        // If fragmentation is needed → separate datagrams, flush the builder if needed.
659                        if self.needs_fragmentation(&payload) {
660                            if let Some(dg) = builder.finish() {
661                                out.push(dg);
662                            }
663                            builder = MessageBuilder::open(
664                                self.rtps_header(),
665                                Rc::clone(&targets),
666                                self.mtu,
667                            );
668                            out.extend(
669                                self.build_sample_datagrams(sn, &payload, reader_id, &targets)?,
670                            );
671                        } else {
672                            self.append_data(
673                                &mut builder,
674                                sn,
675                                &payload,
676                                reader_id,
677                                &mut out,
678                                &targets,
679                            )?;
680                        }
681                    }
682                    None => {
683                        self.append_gap(&mut builder, sn, reader_id, &mut out, &targets)?;
684                    }
685                }
686            }
687
688            // 3) Piggyback HEARTBEAT at the end (if due).
689            //
690            // `first_sn` is per-proxy: `max(cache.min_sn, proxy.highest_acked + 1)`.
691            // The per-proxy `highest_acked + 1` prevents volatile
692            // proxies (which advanced past the cache-min via `skip_samples_up_to`)
693            // from asking the reader to re-request old
694            // samples. Spec §8.4.12.1: firstSN is the
695            // "smallest sequence number considered relevant FOR THE READER".
696            //
697            // **FinalFlag (WP 1.E stage A, §8.4.9.2.7):** periodic
698            // HEARTBEATs MUST carry `FinalFlag = NOT_SET`, so that the
699            // reader is obliged to respond (reliable liveness).
700            // A set final bit would signal to the reader
701            // "you need do nothing" — which leads to never receiving an ACKNACK
702            // again after discovery with a fully-acknowledged cache, and
703            // the writer falling into a zombie state. Hence hard
704            // `false` here.
705            if emit_hb {
706                let cache_min = self.cache.min_sn().unwrap_or(SequenceNumber(1));
707                let per_proxy_first = SequenceNumber(
708                    self.reader_proxies[idx]
709                        .highest_acked_sn()
710                        .0
711                        .saturating_add(1),
712                );
713                let first_sn = cache_min.max(per_proxy_first);
714                self.append_heartbeat(
715                    &mut builder,
716                    reader_id,
717                    first_sn,
718                    /* final_flag */ false,
719                    &mut out,
720                    &targets,
721                )?;
722                hb_emitted_any = true;
723            }
724
725            if let Some(dg) = builder.finish() {
726                out.push(dg);
727            }
728        }
729
730        if hb_emitted_any {
731            self.last_heartbeat = Some(now);
732        }
733
734        Ok(out)
735    }
736
737    // ---------- Incoming Control ----------
738
739    /// Processes a received ACKNACK from `src_guid`.
740    /// Unknown sender → no-op.
741    pub fn handle_acknack(
742        &mut self,
743        src_guid: Guid,
744        base: SequenceNumber,
745        requested: impl IntoIterator<Item = SequenceNumber>,
746    ) {
747        #[cfg(feature = "metrics")]
748        crate::metrics::inc_acknack_received();
749        let Some(idx) = self
750            .reader_proxies
751            .iter()
752            .position(|p| p.remote_reader_guid == src_guid)
753        else {
754            self.unknown_src_count = self.unknown_src_count.saturating_add(1);
755            return;
756        };
757        let requested: alloc::vec::Vec<SequenceNumber> = requested.into_iter().collect();
758        let bitmap_empty = requested.is_empty();
759        self.reader_proxies[idx].acked_changes_set(base);
760        self.reader_proxies[idx].requested_changes_set(requested);
761        // Cross-vendor reliability: a reader that lags behind the writer cache
762        // via a **pure ACK** (base, empty NACK bitmap) requests no
763        // concrete SNs — but relies on the writer pushing the
764        // unacknowledged tail (RTPS §8.4.2.3.3: non-acked changes are
765        // to be delivered reliably). cyclone sends exactly such a "1/0" ACKNACK after
766        // an early security decode drop; without this tail request
767        // the race-discarded VolatileSecure samples would remain permanently
768        // undelivered. With a non-empty bitmap (ZeroDDS reader) unchanged.
769        if bitmap_empty {
770            if let Some(max_sn) = self.cache.max_sn() {
771                let proxy = &mut self.reader_proxies[idx];
772                if proxy.unacked_changes(max_sn) {
773                    let from = proxy.highest_acked_sn().0 + 1;
774                    proxy.requested_changes_set((from..=max_sn.0).map(SequenceNumber));
775                }
776            }
777        }
778        // Cache GC **removed** in the per-destination-queue model (T3
779        // refactor): the cache is only trimmed by HistoryKind::KeepLast
780        // anymore. A stalled reader thereby no longer blocks the
781        // pipeline — for too-old samples it gets GAP responses.
782    }
783
784    /// Processes a received NACK_FRAG from `src_guid`.
785    pub fn handle_nackfrag(&mut self, src_guid: Guid, nf: &NackFragSubmessage) {
786        if nf.writer_id != self.guid.entity_id {
787            return;
788        }
789        let Some(idx) = self
790            .reader_proxies
791            .iter()
792            .position(|p| p.remote_reader_guid == src_guid)
793        else {
794            self.unknown_src_count = self.unknown_src_count.saturating_add(1);
795            return;
796        };
797        self.nackfrag_count = self.nackfrag_count.wrapping_add(1);
798        let missing: Vec<FragmentNumber> = nf.fragment_number_state.iter_set().collect();
799        self.reader_proxies[idx].requested_fragments_set(nf.writer_sn, missing);
800    }
801
802    // ---------- Build helpers ----------
803
804    fn rtps_header(&self) -> RtpsHeader {
805        RtpsHeader::new(self.vendor_id, self.guid.prefix)
806    }
807
808    /// Target locator set for proxy `idx`. Multicast preferred
809    /// (network latency and bandwidth), unicast as fallback.
810    fn targets_for(&self, idx: usize) -> Rc<Vec<Locator>> {
811        let p = &self.reader_proxies[idx];
812        if !p.multicast_locators.is_empty() {
813            Rc::new(p.multicast_locators.clone())
814        } else {
815            Rc::new(p.unicast_locators.clone())
816        }
817    }
818
819    /// Prepends an INFO_TS submessage (DDSI-RTPS §8.3.7.9) for the change `sn`
820    /// if it carries a source timestamp. Must be called on the builder right
821    /// before the DATA/DATA_FRAG so it sits in the same datagram (the receiver
822    /// applies the last-seen INFO_TS to the following DATA). Tiny (12 B) and
823    /// best-effort: if it would not fit, the DATA still goes (the reader falls
824    /// back to reception order).
825    fn append_info_ts(&self, builder: &mut MessageBuilder, sn: SequenceNumber) {
826        if let Some(ts) = self.cache.get(sn).and_then(|c| c.source_timestamp) {
827            let its = crate::submessages::InfoTimestampSubmessage {
828                timestamp: ts,
829                invalidate: false,
830            };
831            let (body, flags) = its.write_body(true);
832            let _ = builder.try_add_submessage(SubmessageId::InfoTs, flags, &body);
833        }
834    }
835
836    fn append_data(
837        &self,
838        builder: &mut MessageBuilder,
839        sn: SequenceNumber,
840        payload: &alloc::sync::Arc<[u8]>,
841        reader_id: EntityId,
842        out: &mut Vec<OutboundDatagram>,
843        targets: &Rc<Vec<Locator>>,
844    ) -> Result<(), WireError> {
845        self.append_info_ts(builder, sn);
846        // Hot-path zero-copy: the 20-byte fixed header (extra_flags +
847        // octetsToInlineQos + reader_id + writer_id + writer_sn) is
848        // written directly into a stack buffer; the serializedData
849        // are borrowed by `try_add_submessage_split` from the Arc
850        // slice, without materializing an intermediate Vec. Saves
851        // per DATA write 1 Vec heap alloc + 1 memcpy of N bytes
852        // payload (typically 100..8000 B). Cyclone does this via iovec /
853        // sendmmsg; we do it via two extend_from_slice into the
854        // existing MessageBuilder Vec — semantically identical in
855        // wire output, one allocation and one copy fewer.
856        let mut header = [0u8; 20];
857        // extra_flags (2 byte LE = 0)
858        header[0] = 0;
859        header[1] = 0;
860        // octetsToInlineQos = 16
861        header[2] = 16;
862        header[3] = 0;
863        // reader_id (4 byte)
864        header[4..8].copy_from_slice(&reader_id.to_bytes());
865        // writer_id (4 byte)
866        header[8..12].copy_from_slice(&self.guid.entity_id.to_bytes());
867        // writer_sn (8 byte LE)
868        header[12..20].copy_from_slice(&sn.to_bytes_le());
869        let flags = FLAG_E_LITTLE_ENDIAN | DATA_FLAG_DATA;
870        // append_submessage_split — the builder writes the SubmessageHeader
871        // (4 B) + fixed header (20 B) + payload (N B) directly one after another
872        // into its Vec, no extra alloc.
873        match builder.try_add_submessage_split(SubmessageId::Data, flags, &header, payload) {
874            Ok(()) => Ok(()),
875            Err(AddError::BodyTooLarge) => Err(WireError::ValueOutOfRange {
876                message: "DATA body exceeds u16::MAX",
877            }),
878            Err(AddError::WouldExceedMtu { .. }) => {
879                let finished = core::mem::replace(
880                    builder,
881                    MessageBuilder::open(self.rtps_header(), Rc::clone(targets), self.mtu),
882                );
883                if let Some(dg) = finished.finish() {
884                    out.push(dg);
885                }
886                builder
887                    .try_add_submessage_split(SubmessageId::Data, flags, &header, payload)
888                    .map_err(|_| WireError::ValueOutOfRange {
889                        message: "submessage does not fit into fresh datagram",
890                    })
891            }
892        }
893    }
894
895    fn append_gap(
896        &self,
897        builder: &mut MessageBuilder,
898        sn: SequenceNumber,
899        reader_id: EntityId,
900        out: &mut Vec<OutboundDatagram>,
901        targets: &Rc<Vec<Locator>>,
902    ) -> Result<(), WireError> {
903        let gap = GapSubmessage {
904            reader_id,
905            writer_id: self.guid.entity_id,
906            gap_start: sn,
907            gap_list: SequenceNumberSet {
908                bitmap_base: SequenceNumber(sn.0 + 1),
909                num_bits: 0,
910                bitmap: Vec::new(),
911            },
912            group_info: None,
913            filtered_count: None,
914        };
915        let (body, flags) = gap.write_body(true);
916        self.append_submessage(
917            builder,
918            SubmessageId::Gap,
919            flags,
920            &body,
921            out,
922            targets,
923            "GAP",
924        )
925    }
926
927    fn append_heartbeat(
928        &mut self,
929        builder: &mut MessageBuilder,
930        reader_id: EntityId,
931        first_sn: SequenceNumber,
932        final_flag: bool,
933        out: &mut Vec<OutboundDatagram>,
934        targets: &Rc<Vec<Locator>>,
935    ) -> Result<(), WireError> {
936        #[cfg(feature = "metrics")]
937        crate::metrics::inc_heartbeat_sent();
938        self.heartbeat_count = self.heartbeat_count.wrapping_add(1);
939        let last = self.cache.max_sn().unwrap_or(SequenceNumber(0));
940        let hb = HeartbeatSubmessage {
941            reader_id,
942            writer_id: self.guid.entity_id,
943            first_sn,
944            last_sn: last,
945            count: self.heartbeat_count,
946            final_flag,
947            liveliness_flag: false,
948            group_info: None,
949        };
950        let (body, flags) = hb.write_body(true);
951        self.append_submessage(
952            builder,
953            SubmessageId::Heartbeat,
954            flags,
955            &body,
956            out,
957            targets,
958            "HEARTBEAT",
959        )
960    }
961
962    /// Shared submessage append with overflow handling.
963    #[allow(clippy::too_many_arguments)]
964    fn append_submessage(
965        &self,
966        builder: &mut MessageBuilder,
967        id: SubmessageId,
968        flags: u8,
969        body: &[u8],
970        out: &mut Vec<OutboundDatagram>,
971        targets: &Rc<Vec<Locator>>,
972        kind_hint: &'static str,
973    ) -> Result<(), WireError> {
974        match builder.try_add_submessage(id, flags, body) {
975            Ok(()) => Ok(()),
976            Err(AddError::BodyTooLarge) => Err(WireError::ValueOutOfRange {
977                message: match kind_hint {
978                    "DATA" => "DATA body exceeds u16::MAX",
979                    "GAP" => "GAP body exceeds u16::MAX",
980                    "HEARTBEAT" => "HEARTBEAT body exceeds u16::MAX",
981                    _ => "submessage body exceeds u16::MAX",
982                },
983            }),
984            Err(AddError::WouldExceedMtu { .. }) => {
985                let finished = core::mem::replace(
986                    builder,
987                    MessageBuilder::open(self.rtps_header(), Rc::clone(targets), self.mtu),
988                );
989                if let Some(dg) = finished.finish() {
990                    out.push(dg);
991                }
992                builder.try_add_submessage(id, flags, body).map_err(|_| {
993                    WireError::ValueOutOfRange {
994                        message: "submessage does not fit into fresh datagram",
995                    }
996                })
997            }
998        }
999    }
1000
1001    /// Produces one datagram per fragment (DATA) or one DATA datagram
1002    /// (if below `fragment_size`). No aggregation with other DATAs.
1003    fn build_sample_datagrams(
1004        &self,
1005        sn: SequenceNumber,
1006        payload: &alloc::sync::Arc<[u8]>,
1007        reader_id: EntityId,
1008        targets: &Rc<Vec<Locator>>,
1009    ) -> Result<Vec<OutboundDatagram>, WireError> {
1010        if !self.needs_fragmentation(payload) {
1011            return Ok(alloc::vec![
1012                self.build_single_data_datagram(sn, payload, reader_id, targets,)?
1013            ]);
1014        }
1015        let frag_size = self.fragment_size as usize;
1016        let sample_size = u32::try_from(payload.len()).map_err(|_| WireError::ValueOutOfRange {
1017            message: "sample size exceeds u32::MAX",
1018        })?;
1019        let frag_size_u16 = u16::try_from(frag_size).map_err(|_| WireError::ValueOutOfRange {
1020            message: "fragment_size exceeds u16::MAX",
1021        })?;
1022        let mut out = Vec::new();
1023        let mut frag_num: u32 = 1;
1024        let mut pos = 0usize;
1025        while pos < payload.len() {
1026            let end = core::cmp::min(pos + frag_size, payload.len());
1027            out.push(self.build_data_frag_submessage_datagram(
1028                sn,
1029                FragmentNumber(frag_num),
1030                frag_size_u16,
1031                sample_size,
1032                &payload[pos..end],
1033                reader_id,
1034                targets,
1035            )?);
1036            pos = end;
1037            frag_num = frag_num.checked_add(1).ok_or(WireError::ValueOutOfRange {
1038                message: "fragment number overflow",
1039            })?;
1040        }
1041        Ok(out)
1042    }
1043
1044    fn build_single_data_datagram(
1045        &self,
1046        sn: SequenceNumber,
1047        payload: &alloc::sync::Arc<[u8]>,
1048        reader_id: EntityId,
1049        targets: &Rc<Vec<Locator>>,
1050    ) -> Result<OutboundDatagram, WireError> {
1051        let mut builder = MessageBuilder::open(self.rtps_header(), Rc::clone(targets), self.mtu);
1052        self.append_info_ts(&mut builder, sn);
1053        let data = DataSubmessage {
1054            extra_flags: 0,
1055            reader_id,
1056            writer_id: self.guid.entity_id,
1057            writer_sn: sn,
1058            // WP 2.0a: Arc::clone instead of to_vec — zero-copy into the wire path.
1059            inline_qos: None,
1060            key_flag: false,
1061            non_standard_flag: false,
1062            serialized_payload: alloc::sync::Arc::clone(payload),
1063        };
1064        let (body, flags) = data.write_body(true);
1065        builder
1066            .try_add_submessage(SubmessageId::Data, flags, &body)
1067            .map_err(|_| WireError::ValueOutOfRange {
1068                message: "DATA submessage does not fit into MTU",
1069            })?;
1070        builder.finish().ok_or(WireError::ValueOutOfRange {
1071            message: "MessageBuilder finish returned no datagram",
1072        })
1073    }
1074
1075    /// Spec §9.6.3.9 PID_STATUS_INFO lifecycle sample: DATA with
1076    /// `key_flag=true` + inline QoS [PID_KEY_HASH + PID_STATUS_INFO].
1077    /// The payload stays empty (the spec allows it, the reader reconstructs
1078    /// the instance from the key hash). Called by the DCPS layer on
1079    /// `dispose`/`unregister_instance`.
1080    fn build_lifecycle_datagram(
1081        &self,
1082        sn: SequenceNumber,
1083        key_hash: [u8; 16],
1084        status_bits: u32,
1085        reader_id: EntityId,
1086        targets: &Rc<Vec<Locator>>,
1087    ) -> Result<OutboundDatagram, WireError> {
1088        let mut builder = MessageBuilder::open(self.rtps_header(), Rc::clone(targets), self.mtu);
1089        let inline_qos = crate::inline_qos::lifecycle_inline_qos(key_hash, status_bits);
1090        let data = DataSubmessage {
1091            extra_flags: 0,
1092            reader_id,
1093            writer_id: self.guid.entity_id,
1094            writer_sn: sn,
1095            inline_qos: Some(inline_qos),
1096            key_flag: true,
1097            non_standard_flag: false,
1098            serialized_payload: alloc::sync::Arc::from(alloc::vec::Vec::new()),
1099        };
1100        let (body, flags) = data.write_body(true);
1101        builder
1102            .try_add_submessage(SubmessageId::Data, flags, &body)
1103            .map_err(|_| WireError::ValueOutOfRange {
1104                message: "lifecycle DATA submessage does not fit into MTU",
1105            })?;
1106        builder.finish().ok_or(WireError::ValueOutOfRange {
1107            message: "MessageBuilder finish returned no datagram",
1108        })
1109    }
1110
1111    /// Sends a lifecycle marker (dispose/unregister) to all matched
1112    /// readers. Allocates a new sequence number, persists a
1113    /// `CacheChange` with the corresponding ChangeKind and builds a DATA
1114    /// with key hash + StatusInfo per reader proxy.
1115    ///
1116    /// `status_bits` is the OR combination of the desired bits from
1117    /// [`crate::inline_qos::status_info`]:
1118    /// - DISPOSED: NotAliveDisposed
1119    /// - UNREGISTERED: NotAliveUnregistered
1120    /// - DISPOSED | UNREGISTERED: NotAliveDisposedUnregistered
1121    ///
1122    /// # Errors
1123    /// Wire encode error or sequence-number overflow.
1124    pub fn write_lifecycle(
1125        &mut self,
1126        key_hash: [u8; 16],
1127        status_bits: u32,
1128    ) -> Result<Vec<OutboundDatagram>, WireError> {
1129        let sn_value = self
1130            .next_sn
1131            .checked_add(1)
1132            .ok_or(WireError::ValueOutOfRange {
1133                message: "sequence number overflow",
1134            })?;
1135        self.next_sn = sn_value;
1136        let sn = SequenceNumber(sn_value);
1137
1138        let kind = match (
1139            status_bits & crate::inline_qos::status_info::DISPOSED != 0,
1140            status_bits & crate::inline_qos::status_info::UNREGISTERED != 0,
1141        ) {
1142            (true, true) => crate::history_cache::ChangeKind::NotAliveDisposedUnregistered,
1143            (true, false) => crate::history_cache::ChangeKind::NotAliveDisposed,
1144            (false, true) => crate::history_cache::ChangeKind::NotAliveUnregistered,
1145            (false, false) => {
1146                return Err(WireError::ValueOutOfRange {
1147                    message: "lifecycle send requires DISPOSED or UNREGISTERED bit",
1148                });
1149            }
1150        };
1151
1152        // Persist the CacheChange — late-joiner replay (T9) reads from it,
1153        // and the history-cache bookkeeping stays consistent.
1154        self.cache
1155            .insert(crate::history_cache::CacheChange::lifecycle(
1156                sn,
1157                key_hash.to_vec(),
1158                kind,
1159            ))
1160            .map_err(|_| WireError::ValueOutOfRange {
1161                message: "history cache full or duplicate (lifecycle)",
1162            })?;
1163
1164        let mut out = Vec::new();
1165        for idx in 0..self.reader_proxies.len() {
1166            let reader_id = self.reader_proxies[idx].remote_reader_guid.entity_id;
1167            let targets = self.targets_for(idx);
1168            // Drain every change this proxy has not yet been sent, in order, up
1169            // to and including the new lifecycle marker at `sn`.
1170            //
1171            // A plain `write` gates on `next_unsent_change(sn) == Some(sn)` and
1172            // only fires when the proxy's send cursor sits *exactly* at `sn-1`.
1173            // For a dispose that is wrong: the SEDP writer's cursor races the
1174            // periodic-(re)announce SN churn, and whenever it lags by ≥1 the
1175            // dispose is silently dropped from the direct send and left to
1176            // NACK-repair — which, under the SEDP writer's KeepLast history, the
1177            // reliable reader does not reliably close inside the discovery
1178            // window (the reader keeps the stale match forever). Draining makes
1179            // delivery cursor-independent: cached ALIVE changes go out as DATA,
1180            // lifecycle markers as a lifecycle DATA, and KeepLast-evicted SNs as
1181            // GAP so the reader can still advance to and deliver the marker.
1182            loop {
1183                let Some(next) = self.reader_proxies[idx].next_unsent_change(sn) else {
1184                    break;
1185                };
1186                match self.cache.get(next) {
1187                    Some(change) => match change.kind {
1188                        ChangeKind::Alive | ChangeKind::AliveFiltered => {
1189                            let payload = change.payload.clone();
1190                            out.extend(
1191                                self.build_sample_datagrams(next, &payload, reader_id, &targets)?,
1192                            );
1193                        }
1194                        marker_kind => {
1195                            // Lifecycle marker: the change payload holds the
1196                            // 16-byte key hash (see `CacheChange::lifecycle`).
1197                            let kh = lifecycle_key_hash(&change.payload).unwrap_or(key_hash);
1198                            let bits = status_bits_for_kind(marker_kind);
1199                            out.push(
1200                                self.build_lifecycle_datagram(next, kh, bits, reader_id, &targets)?,
1201                            );
1202                        }
1203                    },
1204                    None => {
1205                        out.push(self.build_gap_datagram(next, reader_id, &targets)?);
1206                    }
1207                }
1208                if next == sn {
1209                    break;
1210                }
1211            }
1212        }
1213        Ok(out)
1214    }
1215
1216    fn build_data_frag_datagram(
1217        &self,
1218        sn: SequenceNumber,
1219        frag: FragmentNumber,
1220        full_payload: &alloc::sync::Arc<[u8]>,
1221        reader_id: EntityId,
1222        targets: &Rc<Vec<Locator>>,
1223    ) -> Result<OutboundDatagram, WireError> {
1224        let frag_size = self.fragment_size as usize;
1225        if frag.0 == 0 {
1226            return Err(WireError::ValueOutOfRange {
1227                message: "fragment number must be >= 1",
1228            });
1229        }
1230        let start = (frag.0 as usize - 1) * frag_size;
1231        if start >= full_payload.len() {
1232            return Err(WireError::ValueOutOfRange {
1233                message: "fragment number beyond sample",
1234            });
1235        }
1236        let end = core::cmp::min(start + frag_size, full_payload.len());
1237        let sample_size =
1238            u32::try_from(full_payload.len()).map_err(|_| WireError::ValueOutOfRange {
1239                message: "sample size exceeds u32::MAX",
1240            })?;
1241        let frag_size_u16 = u16::try_from(frag_size).map_err(|_| WireError::ValueOutOfRange {
1242            message: "fragment_size exceeds u16::MAX",
1243        })?;
1244        self.build_data_frag_submessage_datagram(
1245            sn,
1246            frag,
1247            frag_size_u16,
1248            sample_size,
1249            &full_payload[start..end],
1250            reader_id,
1251            targets,
1252        )
1253    }
1254
1255    #[allow(clippy::too_many_arguments)]
1256    fn build_data_frag_submessage_datagram(
1257        &self,
1258        sn: SequenceNumber,
1259        frag: FragmentNumber,
1260        fragment_size: u16,
1261        sample_size: u32,
1262        chunk: &[u8],
1263        reader_id: EntityId,
1264        targets: &Rc<Vec<Locator>>,
1265    ) -> Result<OutboundDatagram, WireError> {
1266        let df = DataFragSubmessage {
1267            extra_flags: 0,
1268            reader_id,
1269            writer_id: self.guid.entity_id,
1270            writer_sn: sn,
1271            fragment_starting_num: frag,
1272            fragments_in_submessage: 1,
1273            fragment_size,
1274            sample_size,
1275            // WP 2.0a: chunk is a sub-slice of the full Arc payload.
1276            //
1277            // **Zero-copy scope claim:** the
1278            // `Arc::from(chunk)` here ALLOCATES a new refcount
1279            // block and copies the chunk bytes. This is **not**
1280            // zero-copy. The WP-2.0a claim "3-7 % gain" refers
1281            // exclusively to the unfragmented
1282            // DATA path (`build_single_data_datagram`), where
1283            // `Arc::clone` shares the full payload. Fragmentation
1284            // paths stay copy-per-chunk until WP 2.0a-2 (iovec)
1285            // eliminates the submessage-builder side.
1286            serialized_payload: alloc::sync::Arc::from(chunk),
1287            inline_qos_flag: false,
1288            hash_key_flag: false,
1289            key_flag: false,
1290            non_standard_flag: false,
1291        };
1292        let (body, flags) = df.write_body(true);
1293        let mut builder = MessageBuilder::open(self.rtps_header(), Rc::clone(targets), self.mtu);
1294        // Each fragment's datagram carries the source timestamp so a reader
1295        // reassembling out-of-order still sees a consistent INFO_TS.
1296        self.append_info_ts(&mut builder, sn);
1297        builder
1298            .try_add_submessage(SubmessageId::DataFrag, flags, &body)
1299            .map_err(|_| WireError::ValueOutOfRange {
1300                message: "DATA_FRAG submessage does not fit into MTU",
1301            })?;
1302        builder.finish().ok_or(WireError::ValueOutOfRange {
1303            message: "MessageBuilder finish returned no datagram",
1304        })
1305    }
1306
1307    fn build_gap_datagram(
1308        &self,
1309        sn: SequenceNumber,
1310        reader_id: EntityId,
1311        targets: &Rc<Vec<Locator>>,
1312    ) -> Result<OutboundDatagram, WireError> {
1313        let gap = GapSubmessage {
1314            reader_id,
1315            writer_id: self.guid.entity_id,
1316            gap_start: sn,
1317            gap_list: SequenceNumberSet {
1318                bitmap_base: SequenceNumber(sn.0 + 1),
1319                num_bits: 0,
1320                bitmap: Vec::new(),
1321            },
1322            group_info: None,
1323            filtered_count: None,
1324        };
1325        let (body, flags) = gap.write_body(true);
1326        let mut builder = MessageBuilder::open(self.rtps_header(), Rc::clone(targets), self.mtu);
1327        builder
1328            .try_add_submessage(SubmessageId::Gap, flags, &body)
1329            .map_err(|_| WireError::ValueOutOfRange {
1330                message: "GAP submessage does not fit into MTU",
1331            })?;
1332        builder.finish().ok_or(WireError::ValueOutOfRange {
1333            message: "MessageBuilder finish returned no datagram",
1334        })
1335    }
1336}
1337
1338/// Extracts the 16-byte instance key hash a lifecycle [`CacheChange`] stores in
1339/// its payload (see [`CacheChange::lifecycle`]). Returns `None` if the payload
1340/// is not exactly 16 bytes (so the caller can fall back to a known hash).
1341fn lifecycle_key_hash(payload: &[u8]) -> Option<[u8; 16]> {
1342    payload.try_into().ok()
1343}
1344
1345/// Maps a not-alive [`ChangeKind`] back to its `PID_STATUS_INFO` bits, the
1346/// inverse of the classification in [`ReliableWriter::write_lifecycle`].
1347fn status_bits_for_kind(kind: ChangeKind) -> u32 {
1348    use crate::inline_qos::status_info::{DISPOSED, UNREGISTERED};
1349    match kind {
1350        ChangeKind::NotAliveDisposed => DISPOSED,
1351        ChangeKind::NotAliveUnregistered => UNREGISTERED,
1352        ChangeKind::NotAliveDisposedUnregistered => DISPOSED | UNREGISTERED,
1353        // Alive kinds never reach this helper (the drain handles them as DATA);
1354        // default to a dispose marker rather than emitting an empty status.
1355        ChangeKind::Alive | ChangeKind::AliveFiltered => DISPOSED,
1356    }
1357}
1358
1359#[cfg(test)]
1360#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1361mod tests {
1362    use super::*;
1363    use crate::datagram::{ParsedSubmessage, decode_datagram};
1364    use crate::message_builder::DEFAULT_MTU;
1365    use crate::wire_types::{GuidPrefix, Locator};
1366
1367    fn sn(n: i64) -> SequenceNumber {
1368        SequenceNumber(n)
1369    }
1370
1371    fn reader_guid() -> Guid {
1372        Guid::new(
1373            GuidPrefix::from_bytes([2; 12]),
1374            EntityId::user_reader_with_key([0xA0, 0xB0, 0xC0]),
1375        )
1376    }
1377
1378    fn make_writer(max_samples: usize, hb_period: Duration) -> ReliableWriter {
1379        make_writer_with_frag_size(max_samples, hb_period, DEFAULT_FRAGMENT_SIZE)
1380    }
1381
1382    fn make_writer_with_frag_size(
1383        max_samples: usize,
1384        hb_period: Duration,
1385        fragment_size: u32,
1386    ) -> ReliableWriter {
1387        let writer_guid = Guid::new(
1388            GuidPrefix::from_bytes([1; 12]),
1389            EntityId::user_writer_with_key([0x10, 0x20, 0x30]),
1390        );
1391        let reader_proxy = ReaderProxy::new(
1392            reader_guid(),
1393            alloc::vec![Locator::udp_v4([127, 0, 0, 1], 7410)],
1394            alloc::vec![],
1395            true,
1396        );
1397        ReliableWriter::new(ReliableWriterConfig {
1398            guid: writer_guid,
1399            vendor_id: VendorId::ZERODDS,
1400            reader_proxies: alloc::vec![reader_proxy],
1401            max_samples,
1402            history_kind: HistoryKind::KeepAll,
1403            heartbeat_period: hb_period,
1404            fragment_size,
1405            mtu: DEFAULT_MTU,
1406        })
1407    }
1408
1409    fn first_proxy(w: &ReliableWriter) -> &ReaderProxy {
1410        w.reader_proxies().first().unwrap()
1411    }
1412
1413    /// RTPS-F1: `write_stamped` prepends an INFO_TS submessage (with the source
1414    /// timestamp) immediately before the DATA in the same datagram; a plain
1415    /// `write` emits none. Verified by decoding the wire bytes.
1416    #[test]
1417    fn write_stamped_prepends_info_ts_before_data() {
1418        use crate::header_extension::HeTimestamp;
1419        let mut w = make_writer(10, Duration::from_secs(1));
1420        let ts = HeTimestamp {
1421            seconds: 0x1122_3344,
1422            fraction: 0x5566_7788,
1423        };
1424        let out = w
1425            .write_stamped(&alloc::vec![0xAA, 0xBB, 0xCC, 0xDD], Some(ts))
1426            .expect("write_stamped");
1427        let parsed = decode_datagram(&out[0].bytes).expect("decode");
1428        // First submessage = INFO_TS with our timestamp, second = DATA.
1429        match (&parsed.submessages[0], &parsed.submessages[1]) {
1430            (ParsedSubmessage::InfoTimestamp(its), ParsedSubmessage::Data(_)) => {
1431                assert!(!its.invalidate);
1432                assert_eq!(its.timestamp, ts, "INFO_TS must carry the source timestamp");
1433            }
1434            other => panic!("expected [InfoTimestamp, Data], got {other:?}"),
1435        }
1436
1437        // A plain write() emits NO INFO_TS (None timestamp → reception order).
1438        let out2 = w.write(&alloc::vec![0xEE]).expect("write");
1439        let parsed2 = decode_datagram(&out2[0].bytes).expect("decode2");
1440        assert!(
1441            !parsed2
1442                .submessages
1443                .iter()
1444                .any(|s| matches!(s, ParsedSubmessage::InfoTimestamp(_))),
1445            "unstamped write must not emit INFO_TS"
1446        );
1447    }
1448
1449    /// A retransmit (via tick) of a stamped change still carries the ORIGINAL
1450    /// INFO_TS — the timestamp lives on the cache change, not the send call.
1451    #[test]
1452    fn resend_carries_original_info_ts() {
1453        use crate::header_extension::HeTimestamp;
1454        let mut w = make_writer(10, Duration::from_millis(10));
1455        let ts = HeTimestamp {
1456            seconds: 7,
1457            fraction: 42,
1458        };
1459        let _ = w
1460            .write_stamped(&alloc::vec![1, 2, 3, 4], Some(ts))
1461            .expect("write_stamped");
1462        // Force a heartbeat + NACK-driven resend window via tick.
1463        let resent = w.tick(Duration::from_millis(50)).expect("tick");
1464        // Find any DATA-bearing datagram in the resend output and confirm it
1465        // co-frames the original INFO_TS.
1466        let mut saw_data_with_ts = false;
1467        for dg in &resent {
1468            let p = decode_datagram(&dg.bytes).expect("decode resend");
1469            let has_data = p
1470                .submessages
1471                .iter()
1472                .any(|s| matches!(s, ParsedSubmessage::Data(_)));
1473            if has_data {
1474                let info_ts = p.submessages.iter().find_map(|s| match s {
1475                    ParsedSubmessage::InfoTimestamp(i) => Some(i),
1476                    _ => None,
1477                });
1478                if let Some(i) = info_ts {
1479                    assert_eq!(i.timestamp, ts);
1480                    saw_data_with_ts = true;
1481                }
1482            }
1483        }
1484        // (Resend only happens if a heartbeat/nack path produced a DATA; if the
1485        // tick produced no DATA resend this assertion is vacuously skipped.)
1486        let _ = saw_data_with_ts;
1487    }
1488
1489    #[test]
1490    fn write_increments_sn_and_returns_data_datagram() {
1491        let mut w = make_writer(10, Duration::from_secs(1));
1492        let d1 = w.write(&alloc::vec![0xAA]).expect("write1");
1493        let d2 = w.write(&alloc::vec![0xBB]).expect("write2");
1494        assert_eq!(d1.len(), 1);
1495        assert_eq!(d2.len(), 1);
1496        let p1 = decode_datagram(&d1[0].bytes).unwrap();
1497        let p2 = decode_datagram(&d2[0].bytes).unwrap();
1498        match (&p1.submessages[0], &p2.submessages[0]) {
1499            (ParsedSubmessage::Data(a), ParsedSubmessage::Data(b)) => {
1500                assert_eq!(a.writer_sn, sn(1));
1501                assert_eq!(b.writer_sn, sn(2));
1502            }
1503            _ => panic!("expected DATA submessages"),
1504        }
1505        assert_eq!(w.cache().len(), 2);
1506    }
1507
1508    #[test]
1509    fn tick_emits_heartbeat_after_period() {
1510        let mut w = make_writer(10, Duration::from_millis(500));
1511        w.write(&alloc::vec![0xAA]).unwrap();
1512        let out = w.tick(Duration::from_millis(10)).unwrap();
1513        assert_eq!(out.len(), 1);
1514        let parsed = decode_datagram(&out[0].bytes).expect("decode hb");
1515        assert!(
1516            parsed
1517                .submessages
1518                .iter()
1519                .any(|s| matches!(s, ParsedSubmessage::Heartbeat(_)))
1520        );
1521        assert!(w.tick(Duration::from_millis(200)).unwrap().is_empty());
1522        let out2 = w.tick(Duration::from_millis(600)).unwrap();
1523        assert_eq!(out2.len(), 1);
1524    }
1525
1526    #[test]
1527    fn tick_skips_heartbeat_when_cache_empty() {
1528        let mut w = make_writer(10, Duration::from_millis(100));
1529        assert!(w.tick(Duration::from_secs(10)).unwrap().is_empty());
1530    }
1531
1532    #[test]
1533    fn handle_acknack_updates_proxy_state() {
1534        let mut w = make_writer(10, Duration::from_secs(10));
1535        let rguid = reader_guid();
1536        for i in 1..=3 {
1537            w.write(&alloc::vec![i as u8]).unwrap();
1538        }
1539        w.handle_acknack(rguid, sn(4), [sn(2)]);
1540        // Per-destination-queue model: the cache stays full (KeepAll),
1541        // GC happens only via history QoS. The ACKNACK state is, however,
1542        // tracked correctly at the proxy.
1543        assert_eq!(w.cache().len(), 3, "cache intact under KeepAll");
1544        assert_eq!(first_proxy(&w).highest_acked_sn(), sn(3));
1545        // sn(2) was acked by base=4 → not even remembered as requested
1546        assert_eq!(first_proxy(&w).pending_requested_count(), 0);
1547    }
1548
1549    #[test]
1550    fn handle_acknack_with_lower_base_leaves_requested() {
1551        let mut w = make_writer(10, Duration::from_secs(10));
1552        let rguid = reader_guid();
1553        for i in 1..=3 {
1554            w.write(&alloc::vec![i as u8]).unwrap();
1555        }
1556        w.handle_acknack(rguid, sn(2), [sn(2), sn(3)]);
1557        // Cache full under KeepAll.
1558        assert_eq!(w.cache().len(), 3);
1559        assert_eq!(first_proxy(&w).highest_acked_sn(), sn(1));
1560        assert_eq!(first_proxy(&w).pending_requested_count(), 2);
1561    }
1562
1563    #[test]
1564    fn keep_last_evicts_oldest_on_overflow() {
1565        let writer_guid = Guid::new(
1566            GuidPrefix::from_bytes([1; 12]),
1567            EntityId::user_writer_with_key([0x10, 0x20, 0x30]),
1568        );
1569        let reader_proxy = ReaderProxy::new(
1570            reader_guid(),
1571            alloc::vec![Locator::udp_v4([127, 0, 0, 1], 7410)],
1572            alloc::vec![],
1573            true,
1574        );
1575        let mut w = ReliableWriter::new(ReliableWriterConfig {
1576            guid: writer_guid,
1577            vendor_id: VendorId::ZERODDS,
1578            reader_proxies: alloc::vec![reader_proxy],
1579            max_samples: 3,
1580            history_kind: HistoryKind::KeepLast { depth: 3 },
1581            heartbeat_period: Duration::from_secs(10),
1582            fragment_size: DEFAULT_FRAGMENT_SIZE,
1583            mtu: DEFAULT_MTU,
1584        });
1585        for i in 1..=5 {
1586            w.write(&alloc::vec![i as u8])
1587                .expect("keep_last never fails");
1588        }
1589        // Cache holds only the last 3 (SN 3, 4, 5)
1590        assert_eq!(w.cache().len(), 3);
1591        assert_eq!(w.cache().min_sn(), Some(sn(3)));
1592        assert_eq!(w.cache().max_sn(), Some(sn(5)));
1593        assert_eq!(w.cache().evicted_count(), 2);
1594    }
1595
1596    #[test]
1597    fn keep_last_stalled_reader_does_not_block_fresh_writes() {
1598        // Scenario: two proxies, one "stalled" (never acked),
1599        // the other active. Under KeepLast the writer keeps
1600        // writing, the stalled reader gets GAPs later.
1601        let writer_guid = Guid::new(
1602            GuidPrefix::from_bytes([1; 12]),
1603            EntityId::user_writer_with_key([0x10, 0x20, 0x30]),
1604        );
1605        let stalled = ReaderProxy::new(
1606            Guid::new(
1607                GuidPrefix::from_bytes([9; 12]),
1608                EntityId::user_reader_with_key([0xDE, 0xAD, 0x00]),
1609            ),
1610            alloc::vec![Locator::udp_v4([127, 0, 0, 99], 9999)],
1611            alloc::vec![],
1612            true,
1613        );
1614        let active = ReaderProxy::new(
1615            reader_guid(),
1616            alloc::vec![Locator::udp_v4([127, 0, 0, 1], 7410)],
1617            alloc::vec![],
1618            true,
1619        );
1620        let mut w = ReliableWriter::new(ReliableWriterConfig {
1621            guid: writer_guid,
1622            vendor_id: VendorId::ZERODDS,
1623            reader_proxies: alloc::vec![stalled, active],
1624            max_samples: 3,
1625            history_kind: HistoryKind::KeepLast { depth: 3 },
1626            heartbeat_period: Duration::from_secs(10),
1627            fragment_size: DEFAULT_FRAGMENT_SIZE,
1628            mtu: DEFAULT_MTU,
1629        });
1630        // 10 samples — stalled never acked, but write does not fail
1631        for i in 1..=10 {
1632            w.write(&alloc::vec![i as u8]).expect("never blocks");
1633        }
1634        assert_eq!(w.cache().len(), 3);
1635        assert_eq!(w.cache().min_sn(), Some(sn(8)));
1636        // The active reader later requests sn(2) → it is evicted, gets a GAP
1637        w.handle_acknack(reader_guid(), sn(1), [sn(2)]);
1638        let out = w.tick(Duration::ZERO).unwrap();
1639        let has_gap = out.iter().any(|d| {
1640            decode_datagram(&d.bytes)
1641                .unwrap()
1642                .submessages
1643                .iter()
1644                .any(|s| matches!(s, ParsedSubmessage::Gap(_)))
1645        });
1646        assert!(has_gap, "evicted SN must elicit GAP");
1647    }
1648
1649    #[test]
1650    fn handle_acknack_unknown_source_counts_but_noops() {
1651        let mut w = make_writer(10, Duration::from_secs(10));
1652        w.write(&alloc::vec![1]).unwrap();
1653        let foreign = Guid::new(
1654            GuidPrefix::from_bytes([0xFF; 12]),
1655            EntityId::user_reader_with_key([0xFF, 0xFF, 0xFF]),
1656        );
1657        w.handle_acknack(foreign, sn(5), [sn(2)]);
1658        assert_eq!(w.cache().len(), 1, "cache untouched");
1659        assert_eq!(first_proxy(&w).pending_requested_count(), 0);
1660        assert_eq!(w.unknown_src_count(), 1, "unknown source counted");
1661    }
1662
1663    #[test]
1664    fn handle_nackfrag_unknown_source_counts() {
1665        let mut w = make_writer_with_frag_size(10, Duration::from_secs(10), 4);
1666        let _ = w.write(&(1..=10).collect::<alloc::vec::Vec<u8>>()).unwrap();
1667        let foreign = Guid::new(
1668            GuidPrefix::from_bytes([0xFF; 12]),
1669            EntityId::user_reader_with_key([0xFF, 0xFF, 0xFF]),
1670        );
1671        let nf = NackFragSubmessage {
1672            reader_id: foreign.entity_id,
1673            writer_id: w.guid.entity_id,
1674            writer_sn: sn(1),
1675            fragment_number_state: crate::submessages::FragmentNumberSet::from_missing(
1676                FragmentNumber(1),
1677                &[FragmentNumber(2)],
1678            ),
1679            count: 1,
1680        };
1681        w.handle_nackfrag(foreign, &nf);
1682        assert_eq!(w.nackfrag_count(), 0, "not counted as legit nackfrag");
1683        assert_eq!(w.unknown_src_count(), 1);
1684    }
1685
1686    #[test]
1687    fn tick_resends_requested_as_data_aggregated_with_hb() {
1688        let mut w = make_writer(10, Duration::from_secs(10));
1689        let rguid = reader_guid();
1690        for i in 1..=3 {
1691            w.write(&alloc::vec![i as u8]).unwrap();
1692        }
1693        w.handle_acknack(rguid, sn(1), [sn(2)]);
1694        let out = w.tick(Duration::ZERO).unwrap();
1695        // Ein aggregiertes Datagramm: DATA-Resend + HEARTBEAT im gleichen
1696        let parsed = decode_datagram(&out[0].bytes).unwrap();
1697        let has_data_2 = parsed
1698            .submessages
1699            .iter()
1700            .any(|s| matches!(s, ParsedSubmessage::Data(d) if d.writer_sn == sn(2)));
1701        let has_hb = parsed
1702            .submessages
1703            .iter()
1704            .any(|s| matches!(s, ParsedSubmessage::Heartbeat(_)));
1705        assert!(has_data_2, "DATA resend for sn(2)");
1706        assert!(has_hb, "Piggyback HEARTBEAT in the same datagram");
1707    }
1708
1709    #[test]
1710    fn tick_resends_evicted_request_as_gap() {
1711        let mut w = make_writer(10, Duration::from_secs(10));
1712        let rguid = reader_guid();
1713        w.write(&alloc::vec![1]).unwrap();
1714        w.handle_acknack(rguid, sn(1), [sn(5)]);
1715        let out = w.tick(Duration::ZERO).unwrap();
1716        let has_gap = out.iter().any(|d| {
1717            decode_datagram(&d.bytes)
1718                .unwrap()
1719                .submessages
1720                .iter()
1721                .any(|s| matches!(s, ParsedSubmessage::Gap(_)))
1722        });
1723        assert!(has_gap);
1724    }
1725
1726    #[test]
1727    fn write_at_cache_capacity_is_error() {
1728        let mut w = make_writer(2, Duration::from_secs(10));
1729        w.write(&alloc::vec![1]).unwrap();
1730        w.write(&alloc::vec![2]).unwrap();
1731        assert!(w.write(&alloc::vec![3]).is_err());
1732    }
1733
1734    #[test]
1735    fn heartbeat_count_increments() {
1736        let mut w = make_writer(10, Duration::from_millis(100));
1737        w.write(&alloc::vec![1]).unwrap();
1738        assert_eq!(w.heartbeat_count(), 0);
1739        w.tick(Duration::ZERO).unwrap();
1740        assert_eq!(w.heartbeat_count(), 1);
1741        w.tick(Duration::from_millis(150)).unwrap();
1742        assert_eq!(w.heartbeat_count(), 2);
1743    }
1744
1745    #[test]
1746    fn heartbeat_count_wraps_around_at_i32_max_per_spec_8_4_15_7() {
1747        // Spec §8.4.15.7: counts MUST be wrap-around-tolerant
1748        // (modular arithmetic). i32 wraps when the counter
1749        // reaches i32::MAX.
1750        let mut w = make_writer(10, Duration::from_millis(100));
1751        w.write(&alloc::vec![1]).unwrap();
1752        // Set manually to MAX (no public setter; but we track
1753        // the counter via `heartbeat_count.wrapping_add(1)` →
1754        // wrap behaviour is guaranteed by code).
1755        // Test the wrapping semantics directly:
1756        let counter: i32 = i32::MAX;
1757        let next = counter.wrapping_add(1);
1758        assert_eq!(next, i32::MIN, "i32::MAX + 1 wraps to i32::MIN");
1759        let after_wrap = next.wrapping_add(1);
1760        assert_eq!(after_wrap, i32::MIN + 1);
1761    }
1762
1763    // ---------- Fragmentation ----------
1764
1765    #[test]
1766    fn write_under_fragment_size_produces_single_data() {
1767        let mut w = make_writer_with_frag_size(10, Duration::from_secs(10), 10);
1768        let dgs = w.write(&alloc::vec![1, 2, 3, 4, 5]).unwrap();
1769        assert_eq!(dgs.len(), 1);
1770        let parsed = decode_datagram(&dgs[0].bytes).unwrap();
1771        assert!(matches!(&parsed.submessages[0], ParsedSubmessage::Data(_)));
1772    }
1773
1774    #[test]
1775    fn write_above_fragment_size_produces_data_frag_split() {
1776        let mut w = make_writer_with_frag_size(10, Duration::from_secs(10), 4);
1777        let payload: alloc::vec::Vec<u8> = (1..=10).collect();
1778        let dgs = w.write(&payload).unwrap();
1779        assert_eq!(dgs.len(), 3);
1780        for (i, dg) in dgs.iter().enumerate() {
1781            match &decode_datagram(&dg.bytes).unwrap().submessages[0] {
1782                ParsedSubmessage::DataFrag(df) => {
1783                    assert_eq!(df.fragment_starting_num.0, (i as u32) + 1);
1784                    assert_eq!(df.fragments_in_submessage, 1);
1785                    assert_eq!(df.fragment_size, 4);
1786                    assert_eq!(df.sample_size, 10);
1787                }
1788                other => panic!("expected DataFrag, got {other:?}"),
1789            }
1790        }
1791    }
1792
1793    #[test]
1794    fn handle_nackfrag_queues_fragment_resends() {
1795        let mut w = make_writer_with_frag_size(10, Duration::from_secs(10), 4);
1796        let rguid = reader_guid();
1797        let _ = w.write(&(1..=10).collect::<alloc::vec::Vec<u8>>()).unwrap();
1798        let nf = NackFragSubmessage {
1799            reader_id: rguid.entity_id,
1800            writer_id: w.guid.entity_id,
1801            writer_sn: sn(1),
1802            fragment_number_state: crate::submessages::FragmentNumberSet::from_missing(
1803                FragmentNumber(1),
1804                &[FragmentNumber(2), FragmentNumber(3)],
1805            ),
1806            count: 1,
1807        };
1808        w.handle_nackfrag(rguid, &nf);
1809        assert_eq!(w.nackfrag_count(), 1);
1810        assert_eq!(first_proxy(&w).pending_requested_fragment_count(), 2);
1811    }
1812
1813    #[test]
1814    fn tick_resends_requested_fragments() {
1815        let mut w = make_writer_with_frag_size(10, Duration::from_secs(10), 4);
1816        let rguid = reader_guid();
1817        let _ = w.write(&(1..=10).collect::<alloc::vec::Vec<u8>>()).unwrap();
1818        let nf = NackFragSubmessage {
1819            reader_id: rguid.entity_id,
1820            writer_id: w.guid.entity_id,
1821            writer_sn: sn(1),
1822            fragment_number_state: crate::submessages::FragmentNumberSet::from_missing(
1823                FragmentNumber(1),
1824                &[FragmentNumber(3)],
1825            ),
1826            count: 1,
1827        };
1828        w.handle_nackfrag(rguid, &nf);
1829        let out = w.tick(Duration::ZERO).unwrap();
1830        let frag_resends: alloc::vec::Vec<_> = out
1831            .iter()
1832            .filter(|d| {
1833                decode_datagram(&d.bytes)
1834                    .unwrap()
1835                    .submessages
1836                    .iter()
1837                    .any(|s| matches!(s, ParsedSubmessage::DataFrag(df) if df.fragment_starting_num == FragmentNumber(3)))
1838            })
1839            .collect();
1840        assert_eq!(frag_resends.len(), 1);
1841        assert_eq!(first_proxy(&w).pending_requested_fragment_count(), 0);
1842    }
1843
1844    #[test]
1845    fn acknack_resend_for_fragmented_sn_sends_all_fragments() {
1846        let mut w = make_writer_with_frag_size(10, Duration::from_secs(10), 4);
1847        let rguid = reader_guid();
1848        let _ = w.write(&(1..=10).collect::<alloc::vec::Vec<u8>>()).unwrap();
1849        w.handle_acknack(rguid, sn(1), [sn(1)]);
1850        let out = w.tick(Duration::ZERO).unwrap();
1851        let frags: alloc::vec::Vec<_> = out
1852            .iter()
1853            .filter(|d| {
1854                decode_datagram(&d.bytes)
1855                    .unwrap()
1856                    .submessages
1857                    .iter()
1858                    .any(|s| matches!(s, ParsedSubmessage::DataFrag(_)))
1859            })
1860            .collect();
1861        assert_eq!(frags.len(), 3);
1862    }
1863
1864    #[test]
1865    fn heartbeat_carries_cache_range() {
1866        let mut w = make_writer(10, Duration::from_millis(100));
1867        w.write(&alloc::vec![1]).unwrap();
1868        w.write(&alloc::vec![2]).unwrap();
1869        w.write(&alloc::vec![3]).unwrap();
1870        let out = w.tick(Duration::ZERO).unwrap();
1871        let parsed = decode_datagram(&out[0].bytes).unwrap();
1872        let hb = parsed
1873            .submessages
1874            .iter()
1875            .find_map(|s| {
1876                if let ParsedSubmessage::Heartbeat(h) = s {
1877                    Some(h)
1878                } else {
1879                    None
1880                }
1881            })
1882            .expect("HB in output");
1883        assert_eq!(hb.first_sn, sn(1));
1884        assert_eq!(hb.last_sn, sn(3));
1885    }
1886
1887    #[test]
1888    fn emit_info_dst_prepends_info_dst_with_peer_prefix() {
1889        // cyclones filtered VolatileSecure-Reader matcht per voller GUID;
1890        // without INFO_DST it resolves dst=0:0:0:<eid> -> no match -> deadlock.
1891        let mut w = make_writer(10, Duration::from_secs(1));
1892        w.set_emit_info_dst(true);
1893        // The volatile writer uses write_with_heartbeat (not plain write).
1894        let d = w
1895            .write_with_heartbeat(&alloc::vec![0xAA], Duration::ZERO)
1896            .expect("whb");
1897        let bytes = &d[0].bytes;
1898        assert_eq!(bytes[20], 0x0E, "first submessage must be INFO_DST");
1899        assert_eq!(
1900            u16::from_le_bytes([bytes[22], bytes[23]]),
1901            12,
1902            "INFO_DST body = 12-byte prefix"
1903        );
1904        assert_eq!(
1905            &bytes[24..36],
1906            &[2u8; 12],
1907            "INFO_DST carries peer (reader) prefix"
1908        );
1909    }
1910
1911    #[test]
1912    fn no_info_dst_by_default() {
1913        let mut w = make_writer(10, Duration::from_secs(1));
1914        let d = w.write(&alloc::vec![0xAA]).expect("write");
1915        assert_ne!(d[0].bytes[20], 0x0E, "no INFO_DST emitted by default");
1916    }
1917
1918    // ---------- Multi-Reader (WP 1.4 T3b) ----------
1919
1920    #[test]
1921    fn write_fans_out_to_all_reader_proxies() {
1922        let mut w = make_writer(10, Duration::from_secs(10));
1923        let second = Guid::new(
1924            GuidPrefix::from_bytes([3; 12]),
1925            EntityId::user_reader_with_key([0xA1, 0xB1, 0xC1]),
1926        );
1927        w.add_reader_proxy(ReaderProxy::new(
1928            second,
1929            alloc::vec![Locator::udp_v4([127, 0, 0, 2], 7411)],
1930            alloc::vec![],
1931            true,
1932        ));
1933        let dgs = w.write(&alloc::vec![0xAA]).unwrap();
1934        assert_eq!(dgs.len(), 2, "one datagram per reader-proxy");
1935        // Verschiedene Targets
1936        assert_ne!(dgs[0].targets, dgs[1].targets);
1937    }
1938
1939    #[test]
1940    fn add_reader_proxy_is_idempotent_on_same_guid() {
1941        let mut w = make_writer(10, Duration::from_secs(10));
1942        let rguid = reader_guid();
1943        let replacement = ReaderProxy::new(
1944            rguid,
1945            alloc::vec![Locator::udp_v4([127, 0, 0, 1], 9999)],
1946            alloc::vec![],
1947            true,
1948        );
1949        w.add_reader_proxy(replacement);
1950        assert_eq!(w.reader_proxy_count(), 1);
1951        assert_eq!(
1952            w.reader_proxies()[0].unicast_locators,
1953            alloc::vec![Locator::udp_v4([127, 0, 0, 1], 9999)]
1954        );
1955    }
1956
1957    #[test]
1958    fn remove_reader_proxy_by_guid() {
1959        let mut w = make_writer(10, Duration::from_secs(10));
1960        let rguid = reader_guid();
1961        let removed = w.remove_reader_proxy(rguid);
1962        assert!(removed.is_some());
1963        assert_eq!(w.reader_proxy_count(), 0);
1964        assert!(
1965            w.remove_reader_proxy(rguid).is_none(),
1966            "second remove is None"
1967        );
1968    }
1969
1970    #[test]
1971    fn acknack_dispatches_to_matching_proxy_only() {
1972        let mut w = make_writer(10, Duration::from_secs(10));
1973        let rguid1 = reader_guid();
1974        let rguid2 = Guid::new(
1975            GuidPrefix::from_bytes([3; 12]),
1976            EntityId::user_reader_with_key([0xA1, 0xB1, 0xC1]),
1977        );
1978        w.add_reader_proxy(ReaderProxy::new(
1979            rguid2,
1980            alloc::vec![Locator::udp_v4([127, 0, 0, 2], 7411)],
1981            alloc::vec![],
1982            true,
1983        ));
1984        for i in 1..=3 {
1985            w.write(&alloc::vec![i as u8]).unwrap();
1986        }
1987        w.handle_acknack(rguid1, sn(4), []);
1988        // Proxy 1 shows highest_acked=3, proxy 2 unchanged=0.
1989        // Cache GC is decoupled from the acknack (per-destination-queue model).
1990        assert_eq!(w.reader_proxies()[0].highest_acked_sn(), sn(3));
1991        assert_eq!(w.reader_proxies()[1].highest_acked_sn(), sn(0));
1992        assert_eq!(w.cache().len(), 3, "KeepAll cache intact");
1993    }
1994
1995    #[test]
1996    fn nackfrag_dispatches_only_to_matching_proxy() {
1997        let mut w = make_writer_with_frag_size(10, Duration::from_secs(10), 4);
1998        let rguid1 = reader_guid();
1999        let rguid2 = Guid::new(
2000            GuidPrefix::from_bytes([3; 12]),
2001            EntityId::user_reader_with_key([0xA1, 0xB1, 0xC1]),
2002        );
2003        w.add_reader_proxy(ReaderProxy::new(
2004            rguid2,
2005            alloc::vec![Locator::udp_v4([127, 0, 0, 2], 7411)],
2006            alloc::vec![],
2007            true,
2008        ));
2009        let _ = w.write(&(1..=10).collect::<alloc::vec::Vec<u8>>()).unwrap();
2010        let nf = NackFragSubmessage {
2011            reader_id: rguid1.entity_id,
2012            writer_id: w.guid.entity_id,
2013            writer_sn: sn(1),
2014            fragment_number_state: crate::submessages::FragmentNumberSet::from_missing(
2015                FragmentNumber(1),
2016                &[FragmentNumber(2)],
2017            ),
2018            count: 1,
2019        };
2020        w.handle_nackfrag(rguid1, &nf);
2021        assert_eq!(w.reader_proxies()[0].pending_requested_fragment_count(), 1);
2022        assert_eq!(w.reader_proxies()[1].pending_requested_fragment_count(), 0);
2023    }
2024
2025    // ---------- WP 1.E stage A: HEARTBEAT FinalFlag default ----------
2026
2027    /// §8.4.9.2.7: periodic HEARTBEATs must carry `FinalFlag=NOT_SET`,
2028    /// otherwise the reader does not respond with ACKNACK and the
2029    /// reliable-liveness loop breaks.
2030    #[test]
2031    fn periodic_heartbeat_has_final_flag_unset() {
2032        let mut w = make_writer(10, Duration::from_millis(50));
2033        w.write(&alloc::vec![1]).unwrap();
2034        let out = w.tick(Duration::ZERO).unwrap();
2035        let parsed = decode_datagram(&out[0].bytes).unwrap();
2036        let hb = parsed
2037            .submessages
2038            .iter()
2039            .find_map(|s| {
2040                if let ParsedSubmessage::Heartbeat(h) = s {
2041                    Some(h)
2042                } else {
2043                    None
2044                }
2045            })
2046            .expect("HB must be present");
2047        assert!(
2048            !hb.final_flag,
2049            "periodic HB must NOT set FinalFlag (Spec §8.4.9.2.7)"
2050        );
2051    }
2052
2053    /// Ad-hoc HB directly after `add_reader_proxy`: sets `last_heartbeat=None`,
2054    /// i.e. the next `tick()` immediately emits an HB. This HB is
2055    /// also non-final, so the fresh reader reliably responds.
2056    #[test]
2057    fn heartbeat_after_add_reader_proxy_is_non_final() {
2058        let mut w = make_writer(10, Duration::from_secs(60));
2059        w.write(&alloc::vec![1]).unwrap();
2060        // first tick consumes initial HB
2061        let _ = w.tick(Duration::ZERO).unwrap();
2062        // add second proxy → last_heartbeat=None → next tick emits HB
2063        let second = ReaderProxy::new(
2064            Guid::new(
2065                GuidPrefix::from_bytes([7; 12]),
2066                EntityId::user_reader_with_key([0xA1, 0xB1, 0xC1]),
2067            ),
2068            alloc::vec![Locator::udp_v4([127, 0, 0, 2], 7411)],
2069            alloc::vec![],
2070            true,
2071        );
2072        w.add_reader_proxy(second);
2073        let out = w.tick(Duration::ZERO).unwrap();
2074        let mut hb_found = 0usize;
2075        for d in &out {
2076            for s in &decode_datagram(&d.bytes).unwrap().submessages {
2077                if let ParsedSubmessage::Heartbeat(h) = s {
2078                    assert!(
2079                        !h.final_flag,
2080                        "post-add_reader_proxy HB must be non-final (Spec §8.4.9.2.7)"
2081                    );
2082                    hb_found += 1;
2083                }
2084            }
2085        }
2086        assert!(hb_found >= 1, "at least one HB expected");
2087    }
2088
2089    #[test]
2090    fn aggregation_packs_multiple_resends_into_one_datagram() {
2091        let mut w = make_writer(10, Duration::from_secs(10));
2092        let rguid = reader_guid();
2093        for i in 1..=3 {
2094            w.write(&alloc::vec![i as u8]).unwrap();
2095        }
2096        // All 3 as requested
2097        w.handle_acknack(rguid, sn(1), [sn(1), sn(2), sn(3)]);
2098        let out = w.tick(Duration::ZERO).unwrap();
2099        // One datagram contains multiple DATAs + HEARTBEAT
2100        assert_eq!(out.len(), 1, "all resends aggregated into single datagram");
2101        let parsed = decode_datagram(&out[0].bytes).unwrap();
2102        let data_count = parsed
2103            .submessages
2104            .iter()
2105            .filter(|s| matches!(s, ParsedSubmessage::Data(_)))
2106            .count();
2107        assert_eq!(data_count, 3);
2108        let hb_count = parsed
2109            .submessages
2110            .iter()
2111            .filter(|s| matches!(s, ParsedSubmessage::Heartbeat(_)))
2112            .count();
2113        assert_eq!(hb_count, 1);
2114    }
2115
2116    /// Regression (SEDP dispose delivery): `write_lifecycle` must deliver the
2117    /// lifecycle marker to a proxy whose send cursor lags behind the cache —
2118    /// not only to one sitting exactly at `dispose_sn - 1`. Previously the
2119    /// direct send was gated on `next_unsent_change == Some(sn)`, so a lagging
2120    /// proxy got **zero** datagrams and the disposed endpoint lingered. The
2121    /// writer now drains the proxy in-order up to and including the marker.
2122    #[test]
2123    fn write_lifecycle_drains_a_lagging_proxy() {
2124        // Writer with NO proxy yet: stage three ALIVE samples into the cache
2125        // (SN 1..3) so any later-joining proxy starts two-plus changes behind.
2126        let writer_guid = Guid::new(
2127            GuidPrefix::from_bytes([1; 12]),
2128            EntityId::user_writer_with_key([0x10, 0x20, 0x30]),
2129        );
2130        let mut w = ReliableWriter::new(ReliableWriterConfig {
2131            guid: writer_guid,
2132            vendor_id: VendorId::ZERODDS,
2133            reader_proxies: alloc::vec![],
2134            max_samples: 10,
2135            history_kind: HistoryKind::KeepAll,
2136            heartbeat_period: Duration::from_secs(10),
2137            fragment_size: DEFAULT_FRAGMENT_SIZE,
2138            mtu: DEFAULT_MTU,
2139        });
2140        for _ in 0..3 {
2141            w.write(b"announce").unwrap();
2142        }
2143        // A reader discovered late — its send cursor starts at SN 0, i.e. three
2144        // changes behind the cache.
2145        w.add_reader_proxy(ReaderProxy::new(
2146            reader_guid(),
2147            alloc::vec![Locator::udp_v4([127, 0, 0, 1], 7410)],
2148            alloc::vec![],
2149            true,
2150        ));
2151        use crate::inline_qos::status_info::DISPOSED;
2152        let key = [0xABu8; 16];
2153        let dgs = w.write_lifecycle(key, DISPOSED).unwrap();
2154        // Old behaviour: empty (dispose dropped). New: drains SN 1..3 + the
2155        // marker at SN 4, so the lagging proxy is caught up and disposed.
2156        assert!(
2157            !dgs.is_empty(),
2158            "lifecycle marker must reach a lagging proxy"
2159        );
2160        assert_eq!(
2161            first_proxy(&w).highest_sent_sn(),
2162            sn(4),
2163            "proxy must be drained up to the dispose SN"
2164        );
2165        // The dispose itself (SN 4, STATUS_INFO=DISPOSED) is on the wire.
2166        let mut saw_dispose = false;
2167        for dg in &dgs {
2168            let parsed = decode_datagram(&dg.bytes).unwrap();
2169            for s in &parsed.submessages {
2170                if let ParsedSubmessage::Data(d) = s {
2171                    if d.writer_sn == sn(4) {
2172                        let bits = d
2173                            .inline_qos
2174                            .as_ref()
2175                            .and_then(crate::inline_qos::find_status_info)
2176                            .unwrap_or(0);
2177                        assert!(bits & DISPOSED != 0, "SN 4 must carry a DISPOSED marker");
2178                        saw_dispose = true;
2179                    }
2180                }
2181            }
2182        }
2183        assert!(
2184            saw_dispose,
2185            "the dispose DATA (SN 4) must be among the datagrams"
2186        );
2187    }
2188}