Skip to main content

zerodds_dcps/
publisher.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Publisher + DataWriter — the send end of the DCPS API.
4//!
5//! Spec reference: OMG DDS 1.4 §2.2.2.4 `Publisher`, §2.2.2.4.2
6//! `DataWriter`.
7//!
8//! # Scope v1.2
9//!
10//! - `Publisher::create_datawriter<T>(topic, qos)` → `DataWriter<T>`.
11//! - `DataWriter::write(&sample)` encodes via `T::encode` and hands off
12//!   to an **in-memory queue** (wiring to the ReliableWriter happens in
13//!   the runtime).
14//! - No matching against remote readers yet.
15//! - No QoS conflict check yet.
16//!
17//! # Thread safety
18//!
19//! `DataWriter` is `Send`+`Sync` via `Arc<Mutex<_>>`. Multiple
20//! application threads may call `write()` in parallel.
21
22extern crate alloc;
23use alloc::boxed::Box;
24use alloc::string::ToString;
25use alloc::sync::Arc;
26use alloc::vec::Vec;
27use core::marker::PhantomData;
28
29#[cfg(feature = "std")]
30use std::sync::Mutex;
31
32use crate::dds_type::DdsType;
33use crate::entity::StatusMask;
34use crate::error::{DdsError, Result};
35#[cfg(feature = "std")]
36use crate::instance_handle::{HANDLE_NIL, InstanceHandle};
37#[cfg(feature = "std")]
38use crate::instance_tracker::InstanceTracker;
39use crate::listener::{ArcDataWriterListener, ArcPublisherListener};
40use crate::qos::{DataWriterQos, PublisherQos};
41#[cfg(feature = "std")]
42use crate::time::{Time, get_current_time};
43use crate::topic::Topic;
44
45#[cfg(feature = "std")]
46use crate::runtime::DcpsRuntime;
47#[cfg(feature = "std")]
48use zerodds_qos::ReliabilityKind;
49#[cfg(feature = "std")]
50use zerodds_rtps::wire_types::EntityId;
51
52/// Publisher — entity group for DataWriters.
53///
54/// In DDS 1.4 the publisher has its own QoS (Partition, GroupData,
55/// Presentation). v1.2 implements only the API shape without partition
56/// matching.
57#[derive(Debug)]
58pub struct Publisher {
59    pub(crate) inner: Arc<PublisherInner>,
60}
61
62pub(crate) struct PublisherInner {
63    /// Mutable QoS. .1 (entity lifecycle): set_qos checks immutability
64    /// after enable().
65    #[cfg(feature = "std")]
66    pub(crate) qos: std::sync::Mutex<PublisherQos>,
67    #[cfg(not(feature = "std"))]
68    #[allow(dead_code)]
69    pub(crate) qos: PublisherQos,
70    /// Entity lifecycle (DCPS §2.2.2.1).
71    pub(crate) entity_state: alloc::sync::Arc<crate::entity::EntityState>,
72    /// Runtime handle (when the publisher was created by a live
73    /// participant). None in offline mode → DataWriters fall back to the
74    /// in-memory queue.
75    #[cfg(feature = "std")]
76    pub(crate) runtime: Option<Arc<DcpsRuntime>>,
77    /// optionaler [`ArcPublisherListener`] + [`StatusMask`]
78    /// (Spec §2.2.2.4.3.x set_listener / Bubble-Up §2.2.4.2.3).
79    #[cfg(feature = "std")]
80    pub(crate) listener: std::sync::Mutex<Option<(ArcPublisherListener, StatusMask)>>,
81    /// Weak back-pointer to the participant — for bubble-up from the
82    /// publisher to the participant. Set by
83    /// `DomainParticipant::create_publisher`. `Weak` avoids a refcount
84    /// cycle participant↔publisher.
85    #[cfg(feature = "std")]
86    pub(crate) participant:
87        std::sync::Mutex<Option<alloc::sync::Weak<crate::participant::ParticipantInner>>>,
88    /// `suspend_publications` flag (Spec §2.2.2.4.1.10). When `true`, the
89    /// publisher has hinted that writes should be buffered — the writer
90    /// may use that as an optimization hint (e.g. coalescing). Not
91    /// strictly enforced, because the spec explicitly defines it as a
92    /// "hint to the Service".
93    suspended: core::sync::atomic::AtomicBool,
94    /// DataWriter handles (tracked per `create_datawriter`) for recursive
95    /// `DomainParticipant::contains_entity` (Spec §2.2.2.2.1.10).
96    #[cfg(feature = "std")]
97    pub(crate) datawriters:
98        std::sync::Mutex<alloc::vec::Vec<crate::instance_handle::InstanceHandle>>,
99}
100
101// Manual Debug impl, because `dyn PublisherListener` does not implement
102// Debug. We only print "Some/None" and the mask.
103impl core::fmt::Debug for PublisherInner {
104    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
105        let listener_present = self.listener.lock().map(|s| s.is_some()).unwrap_or(false);
106        f.debug_struct("PublisherInner")
107            .field("entity_state", &self.entity_state)
108            .field("listener_present", &listener_present)
109            .finish_non_exhaustive()
110    }
111}
112
113impl Publisher {
114    #[cfg(feature = "std")]
115    pub(crate) fn new(qos: PublisherQos, runtime: Option<Arc<DcpsRuntime>>) -> Self {
116        Self {
117            inner: Arc::new(PublisherInner {
118                qos: std::sync::Mutex::new(qos),
119                entity_state: crate::entity::EntityState::new(),
120                runtime,
121                listener: std::sync::Mutex::new(None),
122                participant: std::sync::Mutex::new(None),
123                suspended: core::sync::atomic::AtomicBool::new(false),
124                datawriters: std::sync::Mutex::new(alloc::vec::Vec::new()),
125            }),
126        }
127    }
128
129    #[cfg(not(feature = "std"))]
130    pub(crate) fn new(qos: PublisherQos) -> Self {
131        Self {
132            inner: Arc::new(PublisherInner {
133                qos,
134                entity_state: crate::entity::EntityState::new(),
135                suspended: core::sync::atomic::AtomicBool::new(false),
136            }),
137        }
138    }
139
140    /// Spec §2.2.2.2.1.10 — `true` if `handle` is a DataWriter created
141    /// through this publisher.
142    #[cfg(feature = "std")]
143    #[must_use]
144    pub fn contains_writer(&self, handle: crate::instance_handle::InstanceHandle) -> bool {
145        self.inner
146            .datawriters
147            .lock()
148            .map(|v| v.contains(&handle))
149            .unwrap_or(false)
150    }
151
152    /// Sets the `PublisherListener` + StatusMask. `None` clears the slot.
153    /// Spec §2.2.2.4.3.x.
154    #[cfg(feature = "std")]
155    pub fn set_listener(&self, listener: Option<ArcPublisherListener>, mask: StatusMask) {
156        if let Ok(mut slot) = self.inner.listener.lock() {
157            *slot = listener.map(|l| (l, mask));
158        }
159        self.inner.entity_state.set_listener_mask(mask);
160    }
161
162    /// Current listener clone, if present.
163    #[cfg(feature = "std")]
164    #[must_use]
165    pub fn get_listener(&self) -> Option<ArcPublisherListener> {
166        self.inner
167            .listener
168            .lock()
169            .ok()
170            .and_then(|s| s.as_ref().map(|(l, _)| Arc::clone(l)))
171    }
172
173    /// Sets the weak back-pointer to the participant. Called by
174    /// `DomainParticipant::create_publisher`.
175    #[cfg(feature = "std")]
176    pub(crate) fn attach_participant(
177        &self,
178        participant: alloc::sync::Weak<crate::participant::ParticipantInner>,
179    ) {
180        if let Ok(mut slot) = self.inner.participant.lock() {
181            *slot = Some(participant);
182        }
183    }
184
185    /// Returns the [`crate::listener_dispatch::WriterListenerChain`] for
186    /// a writer of this publisher — the reader-path counterpart in
187    /// Subscriber. Clones all three listener stages under their mutexes
188    /// and returns the bundle lock-free (lock discipline).
189    #[cfg(feature = "std")]
190    #[must_use]
191    pub(crate) fn snapshot_writer_chain(
192        &self,
193        writer_listener: Option<(ArcDataWriterListener, StatusMask)>,
194    ) -> crate::listener_dispatch::WriterListenerChain {
195        let publisher = self
196            .inner
197            .listener
198            .lock()
199            .ok()
200            .and_then(|s| s.as_ref().map(|(l, m)| (Arc::clone(l), *m)));
201        let participant = {
202            let weak = self.inner.participant.lock().ok().and_then(|s| s.clone());
203            weak.and_then(|w| w.upgrade()).and_then(|inner| {
204                inner
205                    .listener
206                    .lock()
207                    .ok()
208                    .and_then(|s| s.as_ref().map(|(l, m)| (Arc::clone(l), *m)))
209            })
210        };
211        crate::listener_dispatch::WriterListenerChain {
212            writer: writer_listener,
213            publisher,
214            participant,
215        }
216    }
217
218    /// Spec §2.2.2.4.1.10 `suspend_publications` — a hint to the Service
219    /// that subsequent `write()` calls may be buffered (e.g. for
220    /// coalescing). Has no mandatory semantics for the caller; the flag
221    /// is readable via `is_suspended()` for the writer implementation.
222    ///
223    /// Idempotent: a second `suspend_publications()` without a
224    /// `resume_publications()` in between is allowed.
225    pub fn suspend_publications(&self) {
226        self.inner
227            .suspended
228            .store(true, core::sync::atomic::Ordering::Release);
229    }
230
231    /// Spec §2.2.2.4.1.11 `resume_publications` — counterpart to
232    /// `suspend_publications`. With the suspend flag active the behavior
233    /// is "Service can stop coalescing"; with the flag inactive it is a
234    /// no-op.
235    pub fn resume_publications(&self) {
236        self.inner
237            .suspended
238            .store(false, core::sync::atomic::Ordering::Release);
239    }
240
241    /// `true` if `suspend_publications()` is active and
242    /// `resume_publications()` has not yet been called. Read by the
243    /// writer send path as a coalescing hint.
244    #[must_use]
245    pub fn is_suspended(&self) -> bool {
246        self.inner
247            .suspended
248            .load(core::sync::atomic::Ordering::Acquire)
249    }
250
251    /// Spec §2.2.2.4.1.13 `copy_from_topic_qos` — copies the policies
252    /// shareable between topic and DataWriter QoS from `topic_qos` into
253    /// `dw_qos`. The spec's list of common policies (DCPS 1.4
254    /// §2.2.2.4.1.13): DURABILITY, DEADLINE, LATENCY_BUDGET, LIVELINESS,
255    /// RELIABILITY, DESTINATION_ORDER, HISTORY, RESOURCE_LIMITS,
256    /// TRANSPORT_PRIORITY, LIFESPAN, OWNERSHIP.
257    ///
258    /// # Errors
259    /// `DdsError::BadParameter` if the result yields an inconsistent QoS
260    /// combination — validated by the caller DataWriter's
261    /// QoS-compatibility check (analogous to `set_qos`).
262    pub fn copy_from_topic_qos(
263        dw_qos: &mut DataWriterQos,
264        topic_qos: &crate::qos::TopicQos,
265    ) -> Result<()> {
266        // The following fields are present in both QoS structs and are
267        // overwritten 1:1. DataWriter-only policies (OWNERSHIP_STRENGTH,
268        // PARTITION, RESOURCE_LIMITS, HISTORY, LIFESPAN, DEADLINE,
269        // LIVELINESS, OWNERSHIP) are left untouched, because TopicQos
270        // currently does not carry them. If TopicQos is extended with one
271        // of these fields, this list MUST be extended too — Spec
272        // §2.2.2.4.1.13.
273        dw_qos.durability = topic_qos.durability;
274        dw_qos.reliability = topic_qos.reliability;
275        Ok(())
276    }
277
278    /// Creates a typed `DataWriter<T>`. Spec §2.2.2.4.1.5
279    /// `create_datawriter`.
280    ///
281    /// # Errors
282    /// - `BadParameter` if `topic.type_name() != T::TYPE_NAME` (should be
283    ///   statically guaranteed, but we check defensively).
284    pub fn create_datawriter<T: DdsType + Send + 'static>(
285        &self,
286        topic: &Topic<T>,
287        qos: DataWriterQos,
288    ) -> Result<DataWriter<T>> {
289        if topic.type_name() != T::TYPE_NAME {
290            return Err(DdsError::BadParameter {
291                what: "topic.type_name mismatch",
292            });
293        }
294        #[cfg(feature = "std")]
295        if let Some(rt) = self.inner.runtime.as_ref() {
296            // Live mode: register a real user writer with the runtime.
297            // Matching and user-data flow run over SEDP + UDP from now on.
298            let reliable = qos.reliability.kind == ReliabilityKind::Reliable;
299            // Derive the entityKind from the type keyedness (Spec §9.3.1.2:
300            // 0x02=WithKey / 0x03=NoKey). A keyless type (`HAS_KEY=false`)
301            // MUST produce a NoKey writer, otherwise cross-vendor readers
302            // (CycloneDDS/ROS 2) reject the endpoint match due to an
303            // entityKind mismatch (keyed vs no-key) — silently, without a
304            // log.
305            let eid = rt.register_user_writer_kind(
306                crate::runtime::UserWriterConfig {
307                    topic_name: topic.name().into(),
308                    type_name: T::TYPE_NAME.into(),
309                    reliable,
310                    durability: qos.durability.kind,
311                    deadline: qos.deadline,
312                    lifespan: qos.lifespan,
313                    liveliness: qos.liveliness,
314                    ownership: qos.ownership.kind,
315                    ownership_strength: qos.ownership_strength.value,
316                    partition: qos.partition.names.clone(),
317                    user_data: qos.user_data.value.clone(),
318                    topic_data: qos.topic_data.value.clone(),
319                    group_data: qos.group_data.value.clone(),
320                    // F-TYPES-3: pass on the topic type identifier.
321                    type_identifier: T::TYPE_IDENTIFIER.clone(),
322                    // Per-writer DataRepresentation override from the QoS
323                    // (`None` = runtime default). XTypes 1.3 §7.6.3.1.2.
324                    data_representation_offer: qos.data_representation.clone(),
325                },
326                T::HAS_KEY,
327            )?;
328            // Spec §2.2.3.17 HISTORY: wire the writer's KeepLast depth onto
329            // the runtime writer slot so the TransientLocal retain path caps
330            // per-instance retained samples (and the late-join replay respects
331            // depth). KeepAll → unbounded (usize::MAX). A non-positive depth is
332            // clamped to 1 by `set_user_writer_history_depth`.
333            {
334                use crate::qos::HistoryKind;
335                let depth = match qos.history.kind {
336                    HistoryKind::KeepLast => qos.history.depth.max(1) as usize,
337                    HistoryKind::KeepAll => usize::MAX,
338                };
339                let _ = rt.set_user_writer_history_depth(eid, depth);
340            }
341            // XTypes 1.3 §7.6.3.1.2 / RTPS §10.5 — the user-payload encapsulation
342            // header MUST declare the type's extensibility honestly: @final →
343            // (PLAIN_)CDR2 0x07, @appendable → D_CDR2 0x09, @mutable → PL_CDR2
344            // 0x0b. The generated `encode()` already frames the body correctly
345            // (DHEADER for appendable, EMHEADER for mutable), but the writer
346            // slot defaults its `wire_extensibility` to Final, so without this
347            // it stamps a 0x07 header onto an appendable/mutable body. Lenient
348            // readers (Cyclone/FastDDS/RTI) tolerate the mismatch; a strict
349            // reader (OpenDDS `EncapsulationHeader::to_encoding`) rejects the
350            // sample ("expected APPENDABLE extensibility, but got ..."). Wire the
351            // type's declared extensibility through so the header matches.
352            {
353                use crate::dds_type::Extensibility as E;
354                use zerodds_types::qos::ExtensibilityForRepr as Efr;
355                let efr = match T::EXTENSIBILITY {
356                    E::Final => Efr::Final,
357                    E::Appendable => Efr::Appendable,
358                    E::Mutable => Efr::Mutable,
359                };
360                let _ = rt.set_user_writer_wire_extensibility(eid, efr);
361            }
362            let dw =
363                DataWriter::new_live(topic.clone(), qos, self.inner.clone(), Arc::clone(rt), eid);
364            // Match the encapsulation header to the BE body the writer will emit
365            // (see `DataWriter::write` / `ZERODDS_WIRE_BIG_ENDIAN`): without this
366            // the slot would stamp a little-endian encap id on big-endian bytes.
367            if dw.big_endian {
368                let _ = rt.set_user_writer_byte_order_override(eid, true);
369            }
370            // Spec §2.2.3.5 — with Durability=Transient/Persistent, pass
371            // the writer's own backend to the runtime so that the match
372            // path re-injects the backend samples into the HistoryCache on
373            // the first late-joiner match (see
374            // `DcpsRuntime::attach_durability_backend`).
375            if let Some(backend) = dw.durability_backend() {
376                let _ = rt.attach_durability_backend(eid, backend);
377            }
378            self.track_writer(dw.entity_state.instance_handle());
379            return Ok(dw);
380        }
381        let dw = DataWriter::new_offline(topic.clone(), qos, self.inner.clone());
382        #[cfg(feature = "std")]
383        self.track_writer(dw.entity_state.instance_handle());
384        Ok(dw)
385    }
386
387    #[cfg(feature = "std")]
388    fn track_writer(&self, handle: crate::instance_handle::InstanceHandle) {
389        if let Ok(mut list) = self.inner.datawriters.lock() {
390            list.push(handle);
391        }
392        // Propagate to the participant for recursive contains_entity.
393        if let Ok(slot) = self.inner.participant.lock() {
394            if let Some(weak) = slot.as_ref() {
395                if let Some(p_inner) = weak.upgrade() {
396                    if let Ok(mut dws) = p_inner.datawriters.lock() {
397                        dws.push(handle);
398                    }
399                }
400            }
401        }
402    }
403}
404
405// ============================================================================
406// Entity trait (DCPS §2.2.2.1) —
407// ============================================================================
408
409#[cfg(feature = "std")]
410impl crate::entity::Entity for Publisher {
411    type Qos = PublisherQos;
412
413    fn get_qos(&self) -> Self::Qos {
414        self.inner.qos.lock().map(|q| q.clone()).unwrap_or_default()
415    }
416
417    fn set_qos(&self, qos: Self::Qos) -> Result<()> {
418        // PublisherQos has no immutable fields per DDS spec §2.2.3 —
419        // Partition / GroupData / Presentation are all Changeable=YES.
420        // So we can simply adopt them both pre- and post-enable.
421        if let Ok(mut current) = self.inner.qos.lock() {
422            *current = qos;
423        }
424        Ok(())
425    }
426
427    fn enable(&self) -> Result<()> {
428        self.inner.entity_state.enable();
429        Ok(())
430    }
431
432    fn entity_state(&self) -> alloc::sync::Arc<crate::entity::EntityState> {
433        alloc::sync::Arc::clone(&self.inner.entity_state)
434    }
435}
436
437/// Typed DataWriter — sends samples to all matched readers of the topic.
438///
439/// Two modes:
440/// - **Live** (`runtime: Some`, `entity_id: Some`): write() delegates to
441///   the runtime → ReliableWriter → UDP.
442/// - **Offline** (offline fallback, runtime=None): write() queues
443///   in-memory; for unit tests without a network.
444pub struct DataWriter<T: DdsType> {
445    topic: Topic<T>,
446    qos: Mutex<DataWriterQos>,
447    /// Entity lifecycle (DCPS §2.2.2.1).
448    entity_state: Arc<crate::entity::EntityState>,
449    /// Parent publisher (clone of the `Arc`) — for bubble-up to the
450    /// publisher and participant listeners.
451    publisher: Arc<PublisherInner>,
452    /// Optional `DataWriterListener` + `StatusMask`.
453    #[cfg(feature = "std")]
454    listener: Mutex<Option<(ArcDataWriterListener, StatusMask)>>,
455    /// Last seen number of matched readers. Used by `poll_status_changes`
456    /// (called lazily from public-API paths) to drive delta detection for
457    /// `on_publication_matched` — Spec §2.2.4.2.4.4.
458    #[cfg(feature = "std")]
459    last_match_count: std::sync::atomic::AtomicI64,
460    /// Last seen offered_deadline_missed counter.
461    #[cfg(feature = "std")]
462    last_offered_deadline_missed: std::sync::atomic::AtomicU64,
463    /// Last seen liveliness_lost counter.
464    #[cfg(feature = "std")]
465    last_liveliness_lost: std::sync::atomic::AtomicU64,
466    /// Last seen offered_incompatible_qos.total_count.
467    #[cfg(feature = "std")]
468    last_offered_incompatible_qos: std::sync::atomic::AtomicI64,
469    /// Offline fallback queue.
470    queue: Arc<Mutex<Vec<Vec<u8>>>>,
471    /// Drain notify pair (Spec §2.2.3.19 RESOURCE_LIMITS reliable block).
472    /// `write()` blocks on the condvar when the queue is full + RELIABLE +
473    /// `max_blocking_time > 0`; `__drain_pending` notifies all waiting
474    /// writer threads.
475    #[cfg(feature = "std")]
476    drain_signal: Arc<std::sync::Condvar>,
477    #[cfg(feature = "std")]
478    runtime: Option<Arc<DcpsRuntime>>,
479    #[cfg(feature = "std")]
480    entity_id: Option<EntityId>,
481    /// Instance bookkeeping.
482    #[cfg(feature = "std")]
483    instances: InstanceTracker,
484    /// Local publication handle — entered into
485    /// `SampleInfo.publication_handle` on the reader side once live wiring
486    /// takes effect.
487    #[cfg(feature = "std")]
488    publication_handle: InstanceHandle,
489    /// Spec §2.2.3.5 DurabilityServiceQosPolicy: with
490    /// Durability=Transient/Persistent the writer additionally stores
491    /// samples in a backend so that late-joiner readers can still obtain
492    /// them after the writer's history cleanup.
493    #[cfg(feature = "std")]
494    durability_backend: Option<Arc<dyn crate::durability_service::DurabilityBackend>>,
495    /// Monotonically increasing writer sequence for durability-backend
496    /// storage (DDS 1.4 §2.2.3.5 + backend replay order).
497    #[cfg(feature = "std")]
498    durability_seq: std::sync::atomic::AtomicU64,
499    /// Optionally configured Flatdata SlotBackend for the same-host
500    /// zero-copy path (`zerodds-flatdata-1.0` §4.1 + §8.1). Set via
501    /// `set_flat_backend` (see the `flatdata_integration` module); with
502    /// `None`, `write_flat()` falls back to the classic UDP path.
503    #[cfg(all(feature = "std", feature = "flatdata-integration"))]
504    pub(crate) flat_backend: Mutex<
505        Option<(
506            Arc<dyn zerodds_flatdata::SlotBackend>,
507            u32, // active_readers_mask
508        )>,
509    >,
510    /// Emit the user payload big-endian (encapsulation `*_BE` id + BE body via
511    /// `DdsType::encode_be`) instead of the little-endian default. Set from
512    /// `ZERODDS_WIRE_BIG_ENDIAN` at creation. The matching `*_BE` encap header is
513    /// stamped by wiring the runtime slot's byte-order override in
514    /// `create_datawriter`. Standard DDS readers decode per-sample endianness
515    /// from the encap, so this stays cross-vendor interoperable.
516    #[cfg(feature = "std")]
517    big_endian: bool,
518    _t: PhantomData<fn() -> T>,
519}
520
521impl<T: DdsType> core::fmt::Debug for DataWriter<T> {
522    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
523        f.debug_struct("DataWriter")
524            .field("topic", &self.topic.name())
525            .field("type", &T::TYPE_NAME)
526            .field("qos", &self.qos)
527            .finish_non_exhaustive()
528    }
529}
530
531/// RAII teardown (Spec §2.2.2.4.1.2 — deleting a `DataWriter`). Dropping the
532/// user's handle deregisters the writer from the runtime: it removes the slot
533/// (stopping the RTPS heartbeats), rebuilds the intra-runtime route, and sends
534/// an SEDP dispose so remote peers drop the matched writer at once instead of
535/// waiting for a liveliness timeout. Offline writers (no runtime) are no-ops.
536#[cfg(feature = "std")]
537impl<T: DdsType> Drop for DataWriter<T> {
538    fn drop(&mut self) {
539        if let (Some(rt), Some(eid)) = (self.runtime.as_ref(), self.entity_id) {
540            rt.remove_user_writer(eid);
541        }
542    }
543}
544
545impl<T: DdsType> DataWriter<T> {
546    #[cfg(feature = "std")]
547    fn new_offline(topic: Topic<T>, qos: DataWriterQos, publisher: Arc<PublisherInner>) -> Self {
548        let tracker = InstanceTracker::new();
549        let pub_handle = InstanceHandle::from_raw(0xFFFF_0000_0000_0001);
550        let backend = Self::build_durability_backend(&qos);
551        Self {
552            topic,
553            qos: Mutex::new(qos),
554            entity_state: crate::entity::EntityState::new(),
555            publisher,
556            listener: Mutex::new(None),
557            last_match_count: std::sync::atomic::AtomicI64::new(-1),
558            last_offered_deadline_missed: std::sync::atomic::AtomicU64::new(0),
559            last_liveliness_lost: std::sync::atomic::AtomicU64::new(0),
560            last_offered_incompatible_qos: std::sync::atomic::AtomicI64::new(-1),
561            queue: Arc::new(Mutex::new(Vec::new())),
562            #[cfg(feature = "std")]
563            drain_signal: Arc::new(std::sync::Condvar::new()),
564            runtime: None,
565            entity_id: None,
566            instances: tracker,
567            publication_handle: pub_handle,
568            durability_backend: backend,
569            durability_seq: std::sync::atomic::AtomicU64::new(1),
570            #[cfg(feature = "flatdata-integration")]
571            flat_backend: Mutex::new(None),
572            big_endian: std::env::var_os("ZERODDS_WIRE_BIG_ENDIAN").is_some(),
573            _t: PhantomData,
574        }
575    }
576
577    /// Returns the durability backend (None for Volatile/TransientLocal).
578    /// Test/inspection helper — Spec §2.2.3.5.
579    #[doc(hidden)]
580    #[cfg(feature = "std")]
581    #[must_use]
582    pub fn durability_backend(
583        &self,
584    ) -> Option<Arc<dyn crate::durability_service::DurabilityBackend>> {
585        self.durability_backend.clone()
586    }
587
588    /// Spec §2.2.3.5: with Durability=Transient the writer creates an
589    /// in-memory backend. Persistent without a root path is not
590    /// auto-configured — the caller must `set_durability_backend`.
591    /// Default path for Persistent: the `ZERODDS_DURABILITY_DIR` env var,
592    /// otherwise `std::env::temp_dir().join("zerodds-durability")`. The
593    /// caller can override the path via the env var for production
594    /// deployments (e.g. `/var/lib/zerodds/durability`).
595    #[cfg(feature = "std")]
596    fn build_durability_backend(
597        qos: &DataWriterQos,
598    ) -> Option<Arc<dyn crate::durability_service::DurabilityBackend>> {
599        match qos.durability.kind {
600            zerodds_qos::DurabilityKind::Transient => Some(Arc::new(
601                crate::durability_service::InMemoryDurabilityBackend::new(qos.durability_service),
602            )),
603            zerodds_qos::DurabilityKind::Persistent => {
604                let root = std::env::var_os("ZERODDS_DURABILITY_DIR")
605                    .map(std::path::PathBuf::from)
606                    .unwrap_or_else(|| std::env::temp_dir().join("zerodds-durability"));
607                match crate::durability_service::OnDiskDurabilityBackend::new(
608                    root,
609                    qos.durability_service,
610                ) {
611                    Ok(b) => Some(Arc::new(b)),
612                    Err(_) => None,
613                }
614            }
615            _ => None,
616        }
617    }
618
619    #[cfg(feature = "std")]
620    fn new_live(
621        topic: Topic<T>,
622        qos: DataWriterQos,
623        publisher: Arc<PublisherInner>,
624        runtime: Arc<DcpsRuntime>,
625        entity_id: EntityId,
626    ) -> Self {
627        let tracker = InstanceTracker::new();
628        // We derive the publication handle from the EntityId — that makes it
629        // reproducible across test runs and avoids a pool collision with
630        // instance handles. The spec only says it is an opaque u64 anyway.
631        let key = entity_id.entity_key;
632        let pub_handle = InstanceHandle::from_raw(
633            0xFFFF_0000_0000_0000
634                | (u64::from(key[0]) << 16)
635                | (u64::from(key[1]) << 8)
636                | u64::from(key[2]),
637        );
638        let backend = Self::build_durability_backend(&qos);
639        Self {
640            topic,
641            qos: Mutex::new(qos),
642            entity_state: crate::entity::EntityState::new(),
643            publisher,
644            listener: Mutex::new(None),
645            last_match_count: std::sync::atomic::AtomicI64::new(-1),
646            last_offered_deadline_missed: std::sync::atomic::AtomicU64::new(0),
647            last_liveliness_lost: std::sync::atomic::AtomicU64::new(0),
648            last_offered_incompatible_qos: std::sync::atomic::AtomicI64::new(-1),
649            queue: Arc::new(Mutex::new(Vec::new())),
650            #[cfg(feature = "std")]
651            drain_signal: Arc::new(std::sync::Condvar::new()),
652            runtime: Some(runtime),
653            entity_id: Some(entity_id),
654            instances: tracker,
655            publication_handle: pub_handle,
656            durability_backend: backend,
657            durability_seq: std::sync::atomic::AtomicU64::new(1),
658            #[cfg(feature = "flatdata-integration")]
659            flat_backend: Mutex::new(None),
660            big_endian: std::env::var_os("ZERODDS_WIRE_BIG_ENDIAN").is_some(),
661            _t: PhantomData,
662        }
663    }
664
665    #[cfg(not(feature = "std"))]
666    fn new(topic: Topic<T>, qos: DataWriterQos, publisher: Arc<PublisherInner>) -> Self {
667        Self {
668            topic,
669            qos,
670            publisher,
671            queue: Arc::new(Mutex::new(Vec::new())),
672            #[cfg(feature = "std")]
673            drain_signal: Arc::new(std::sync::Condvar::new()),
674            _t: PhantomData,
675        }
676    }
677
678    /// The topic that is sent to.
679    #[must_use]
680    pub fn topic(&self) -> &Topic<T> {
681        &self.topic
682    }
683
684    /// This writer's 16-byte RTPS GUID, when it is bound to a live runtime
685    /// (online participant). `None` in offline mode. Used by the iceoryx-cyclone
686    /// bridge to stamp the cross-vendor PSMX chunk with the writer's real GUID
687    /// so a peer that discovered it over RTPS associates the SHM sample with it.
688    #[cfg(feature = "std")]
689    #[must_use]
690    pub fn rtps_guid(&self) -> Option<[u8; 16]> {
691        Some(self.runtime.as_ref()?.writer_guid(self.entity_id?))
692    }
693
694    /// Sets the `DataWriterListener` + StatusMask. `None` clears the slot.
695    /// Spec §2.2.2.4.2.x set_listener.
696    #[cfg(feature = "std")]
697    pub fn set_listener(&self, listener: Option<ArcDataWriterListener>, mask: StatusMask) {
698        if let Ok(mut slot) = self.listener.lock() {
699            *slot = listener.map(|l| (l, mask));
700        }
701        self.entity_state.set_listener_mask(mask);
702    }
703
704    /// Current listener clone, if present.
705    #[cfg(feature = "std")]
706    #[must_use]
707    pub fn get_listener(&self) -> Option<ArcDataWriterListener> {
708        self.listener
709            .lock()
710            .ok()
711            .and_then(|s| s.as_ref().map(|(l, _)| Arc::clone(l)))
712    }
713
714    /// Snapshot of the bubble-up chain (writer → publisher → participant).
715    /// Used for hot-path listener dispatch.
716    #[cfg(feature = "std")]
717    #[must_use]
718    pub(crate) fn listener_chain(&self) -> crate::listener_dispatch::WriterListenerChain {
719        let writer = self
720            .listener
721            .lock()
722            .ok()
723            .and_then(|s| s.as_ref().map(|(l, m)| (Arc::clone(l), *m)));
724        // We pass the writer clone on to the publisher snapshot, which
725        // fills in the publisher and participant stages.
726        let pub_handle = Publisher {
727            inner: Arc::clone(&self.publisher),
728        };
729        pub_handle.snapshot_writer_chain(writer)
730    }
731
732    /// Current QoS (cloned, .1).
733    #[must_use]
734    pub fn qos(&self) -> DataWriterQos {
735        self.qos.lock().map(|q| q.clone()).unwrap_or_default()
736    }
737
738    /// Sends a sample to all matched readers.
739    ///
740    /// **Spec §2.2.3.19 RESOURCE_LIMITS reliable block:** If the local
741    /// writer cache has reached `max_samples` AND Reliability=RELIABLE AND
742    /// `max_blocking_time > 0`, `write()` blocks until a reader ACK frees
743    /// the slot or the timeout expires. In best-effort mode or with
744    /// `max_blocking_time = 0`, `write()` fails immediately with
745    /// `OutOfResources`.
746    ///
747    /// # Errors
748    /// - `WireError` if `T::encode` fails.
749    /// - `OutOfResources` if the queue is full + best-effort/no blocking
750    ///   time, or if the block timeout expired before a drain.
751    /// - `PreconditionNotMet` on lock poisoning.
752    pub fn write(&self, sample: &T) -> Result<()> {
753        let mut buf = Vec::new();
754        // Big-endian wire (encap `*_BE`, set up in `create_datawriter`) uses the
755        // BE body encoder; the default is little-endian.
756        let enc = if self.big_endian {
757            sample.encode_be(&mut buf)
758        } else {
759            sample.encode(&mut buf)
760        };
761        enc.map_err(|e| DdsError::WireError {
762            message: e.to_string(),
763        })?;
764        #[cfg(feature = "metrics")]
765        crate::metrics::inc_sample_written(self.topic.name());
766        #[cfg(feature = "metrics")]
767        crate::metrics::record_sample_size(self.topic.name(), buf.len());
768        // Cross-vendor same-host zero-copy: if this writer was
769        // `enable_cyclone_iox`-ed, additionally publish a Cyclone-PSMX
770        // serialized chunk over iceoryx (best-effort; the RTPS path below
771        // still runs for non-iox peers).
772        #[cfg(all(feature = "std", feature = "cyclone-iox", target_os = "linux"))]
773        crate::cyclone_iox_integration::publish(self.topic.name(), sample);
774        // Spec §2.2.3.5 DurabilityServiceQosPolicy: also store the sample
775        // in the backend (Transient/Persistent) so that late-joiner
776        // readers can still obtain it after the writer's history cleanup.
777        #[cfg(feature = "std")]
778        if let Some(backend) = self.durability_backend.as_ref() {
779            let key_bytes = Self::keyhash_and_holder(sample)
780                .map(|(kh, _)| kh)
781                .unwrap_or([0u8; 16]);
782            // Monotonic writer sequence for backend replay order
783            // (DDS 1.4 §2.2.3.5).
784            let seq = self
785                .durability_seq
786                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
787            let _ = backend.store(crate::durability_service::DurabilitySample {
788                topic: self.topic.name().to_string(),
789                instance_key: key_bytes,
790                sequence: seq,
791                payload: buf.clone(),
792                created_at: std::time::SystemTime::now(),
793            });
794        }
795        // Live mode: delegate to the runtime → ReliableWriter → UDP.
796        #[cfg(feature = "std")]
797        if let (Some(rt), Some(eid)) = (&self.runtime, self.entity_id) {
798            return rt.write_user_sample(eid, buf);
799        }
800        // Offline fallback: in-memory queue with RESOURCE_LIMITS block.
801        #[cfg(feature = "std")]
802        {
803            let qos = self.qos.lock().map(|q| q.clone()).unwrap_or_default();
804            let max_samples = qos.resource_limits.max_samples;
805            let reliable = qos.reliability.kind == ReliabilityKind::Reliable;
806            let max_block = qos.reliability.max_blocking_time;
807            let max_block_dur = core::time::Duration::from_nanos(
808                u64::try_from(max_block.seconds).unwrap_or(0) * 1_000_000_000
809                    + u64::from(max_block.fraction),
810            );
811
812            let mut q = self
813                .queue
814                .lock()
815                .map_err(|_| DdsError::PreconditionNotMet {
816                    reason: "datawriter queue poisoned",
817                })?;
818            if max_samples > 0 && q.len() >= max_samples as usize {
819                if !reliable || max_block_dur.is_zero() {
820                    return Err(DdsError::OutOfResources {
821                        what: "datawriter queue full (best-effort or no max_blocking_time)",
822                    });
823                }
824                // Reliable + max_blocking_time > 0 → wait_timeout on the condvar.
825                let deadline = std::time::Instant::now() + max_block_dur;
826                loop {
827                    let now = std::time::Instant::now();
828                    if now >= deadline {
829                        return Err(DdsError::Timeout);
830                    }
831                    let remaining = deadline - now;
832                    let (g, _) = self.drain_signal.wait_timeout(q, remaining).map_err(|_| {
833                        DdsError::PreconditionNotMet {
834                            reason: "datawriter queue poisoned",
835                        }
836                    })?;
837                    q = g;
838                    if q.len() < max_samples as usize {
839                        break;
840                    }
841                    // otherwise spurious wakeup → keep waiting.
842                }
843            }
844            q.push(buf);
845            Ok(())
846        }
847        #[cfg(not(feature = "std"))]
848        {
849            let mut q = self
850                .queue
851                .lock()
852                .map_err(|_| DdsError::PreconditionNotMet {
853                    reason: "datawriter queue poisoned",
854                })?;
855            q.push(buf);
856            Ok(())
857        }
858    }
859
860    /// Number of samples written so far. Test helper, replaced by real
861    /// HistoryCache counters in the runtime.
862    #[must_use]
863    pub fn samples_pending(&self) -> usize {
864        self.queue.lock().map(|q| q.len()).unwrap_or(0)
865    }
866
867    /// Number of matched remote readers. Always 0 in offline mode.
868    ///
869    /// Spec: OMG DDS 1.4 §2.2.2.4.2.11 `get_matched_subscriptions`.
870    /// That returns a list; this returns only the count, the full list
871    /// comes with listener callbacks.
872    ///
873    /// Side effect — on a change of the matched count relative to the
874    /// last call, `on_publication_matched` is fired via the bubble-up
875    /// chain (Spec §2.2.4.2.4.4).
876    #[must_use]
877    pub fn matched_subscription_count(&self) -> usize {
878        #[cfg(feature = "std")]
879        if let (Some(rt), Some(eid)) = (&self.runtime, self.entity_id) {
880            let n = rt.user_writer_matched_count(eid);
881            self.poll_publication_matched(n);
882            return n;
883        }
884        0
885    }
886
887    /// Compares the `current` count with `last_match_count` and fires
888    /// `on_publication_matched` if the value has changed. Initially
889    /// `last_match_count == -1`, i.e. the first call with n>=0 always
890    /// triggers.
891    #[cfg(feature = "std")]
892    pub(crate) fn poll_publication_matched(&self, current: usize) {
893        let curr = current as i64;
894        let prev = self
895            .last_match_count
896            .swap(curr, std::sync::atomic::Ordering::AcqRel);
897        if prev == curr {
898            return;
899        }
900        let total = if curr > prev.max(0) {
901            curr
902        } else {
903            prev.max(0)
904        };
905        let delta = curr - prev.max(0);
906        let status = crate::status::PublicationMatchedStatus {
907            total_count: total as i32,
908            total_count_change: delta.max(0) as i32,
909            current_count: curr as i32,
910            current_count_change: delta as i32,
911            last_subscription_handle: crate::instance_handle::HANDLE_NIL,
912        };
913        let chain = self.listener_chain();
914        crate::listener_dispatch::dispatch_publication_matched(
915            &chain,
916            self.entity_state.instance_handle(),
917            status,
918        );
919    }
920
921    /// Delta detection for `on_offered_deadline_missed`. Reads the
922    /// counter from the runtime and fires the listener on a delta. Spec
923    /// §2.2.4.2.4.1.
924    #[cfg(feature = "std")]
925    pub(crate) fn poll_offered_deadline_missed(&self, current: u64) {
926        let prev = self
927            .last_offered_deadline_missed
928            .swap(current, std::sync::atomic::Ordering::AcqRel);
929        if current == prev {
930            return;
931        }
932        let total_change = current.saturating_sub(prev);
933        let status = crate::status::OfferedDeadlineMissedStatus {
934            total_count: current as i32,
935            total_count_change: total_change as i32,
936            last_instance_handle: crate::instance_handle::HANDLE_NIL,
937        };
938        let chain = self.listener_chain();
939        crate::listener_dispatch::dispatch_offered_deadline_missed(
940            &chain,
941            self.entity_state.instance_handle(),
942            status,
943        );
944    }
945
946    /// Delta detection for `on_liveliness_lost`. Spec §2.2.4.2.4.3.
947    #[cfg(feature = "std")]
948    pub(crate) fn poll_liveliness_lost(&self, current: u64) {
949        let prev = self
950            .last_liveliness_lost
951            .swap(current, std::sync::atomic::Ordering::AcqRel);
952        if current == prev {
953            return;
954        }
955        let total_change = current.saturating_sub(prev);
956        let status = crate::status::LivelinessLostStatus {
957            total_count: current as i32,
958            total_count_change: total_change as i32,
959        };
960        let chain = self.listener_chain();
961        crate::listener_dispatch::dispatch_liveliness_lost(
962            &chain,
963            self.entity_state.instance_handle(),
964            status,
965        );
966    }
967
968    /// Delta detection for `on_offered_incompatible_qos`.
969    /// Spec §2.2.4.2.4.2.
970    #[cfg(feature = "std")]
971    pub(crate) fn poll_offered_incompatible_qos(
972        &self,
973        snapshot: crate::status::OfferedIncompatibleQosStatus,
974    ) {
975        let curr = i64::from(snapshot.total_count);
976        let prev = self
977            .last_offered_incompatible_qos
978            .swap(curr, std::sync::atomic::Ordering::AcqRel);
979        if curr == prev {
980            return;
981        }
982        let delta = curr - prev.max(0);
983        let status = crate::status::OfferedIncompatibleQosStatus {
984            total_count: curr as i32,
985            total_count_change: delta.max(0) as i32,
986            last_policy_id: snapshot.last_policy_id,
987            policies: snapshot.policies,
988        };
989        let chain = self.listener_chain();
990        crate::listener_dispatch::dispatch_offered_incompatible_qos(
991            &chain,
992            self.entity_state.instance_handle(),
993            status,
994        );
995    }
996
997    /// Blocks until at least `min_count` remote readers are matched or
998    /// `timeout` elapses. Event-driven via a runtime condvar (D.5e
999    /// phase 1) — wakes up directly when SEDP propagates a match, no more
1000    /// 20-ms polling.
1001    ///
1002    /// Related to OMG DDS 1.4 §2.2.2.4.2.22 `wait_for_acknowledgments`,
1003    /// but focused on matching rather than ACK. Covers the typical
1004    /// producer pattern "first create the writer, then wait for
1005    /// subscribers, then write".
1006    ///
1007    /// # Errors
1008    /// [`DdsError::Timeout`] if `min_count` is not reached within the
1009    /// time window.
1010    #[cfg(feature = "std")]
1011    pub fn wait_for_matched_subscription(
1012        &self,
1013        min_count: usize,
1014        timeout: core::time::Duration,
1015    ) -> Result<()> {
1016        let deadline = std::time::Instant::now() + timeout;
1017        loop {
1018            if self.matched_subscription_count() >= min_count {
1019                return Ok(());
1020            }
1021            let now = std::time::Instant::now();
1022            if now >= deadline {
1023                return Err(DdsError::Timeout);
1024            }
1025            if let Some(rt) = self.runtime.as_ref() {
1026                let _ = rt.wait_match_event(deadline - now);
1027            } else {
1028                std::thread::sleep(core::time::Duration::from_millis(20));
1029            }
1030        }
1031    }
1032
1033    /// Counter for offered-deadline violations (Spec §2.2.4.2.9
1034    /// `OFFERED_DEADLINE_MISSED_STATUS`). Monotonically increasing;
1035    /// increments by 1 per expired deadline window without a write.
1036    /// Always 0 in offline mode or with `deadline=INFINITE`.
1037    ///
1038    /// Fires `on_offered_deadline_missed` over the bubble-up chain on a
1039    /// delta relative to the last call, if applicable.
1040    #[must_use]
1041    pub fn offered_deadline_missed_count(&self) -> u64 {
1042        #[cfg(feature = "std")]
1043        if let (Some(rt), Some(eid)) = (&self.runtime, self.entity_id) {
1044            let n = rt.user_writer_offered_deadline_missed(eid);
1045            self.poll_offered_deadline_missed(n);
1046            return n;
1047        }
1048        0
1049    }
1050
1051    /// Counter for LivelinessLost detections (Spec §2.2.4.2.10).
1052    /// Triggers `on_liveliness_lost` via bubble-up, if applicable.
1053    #[must_use]
1054    pub fn liveliness_lost_count(&self) -> u64 {
1055        #[cfg(feature = "std")]
1056        if let (Some(rt), Some(eid)) = (&self.runtime, self.entity_id) {
1057            let n = rt.user_writer_liveliness_lost(eid);
1058            self.poll_liveliness_lost(n);
1059            return n;
1060        }
1061        0
1062    }
1063
1064    /// Current `OfferedIncompatibleQosStatus` (Spec §2.2.4.2.4.2).
1065    /// Triggers `on_offered_incompatible_qos`, if applicable.
1066    #[must_use]
1067    pub fn offered_incompatible_qos_status(&self) -> crate::status::OfferedIncompatibleQosStatus {
1068        #[cfg(feature = "std")]
1069        if let (Some(rt), Some(eid)) = (&self.runtime, self.entity_id) {
1070            let s = rt.user_writer_offered_incompatible_qos(eid);
1071            self.poll_offered_incompatible_qos(s.clone());
1072            return s;
1073        }
1074        crate::status::OfferedIncompatibleQosStatus::default()
1075    }
1076
1077    /// Polls all statuses once and fires pending listeners. Convenience
1078    /// helper for tests + periodic tick callers.
1079    #[cfg(feature = "std")]
1080    pub fn drive_listeners(&self) {
1081        let _ = self.matched_subscription_count();
1082        let _ = self.offered_deadline_missed_count();
1083        let _ = self.liveliness_lost_count();
1084        let _ = self.offered_incompatible_qos_status();
1085    }
1086
1087    /// Manual liveliness assert. Spec §2.2.2.4.2.20 `assert_liveliness`.
1088    /// Sets the `last_liveliness_assert` timestamp; a no-op with
1089    /// automatic liveliness (every write asserts anyway).
1090    #[cfg(feature = "std")]
1091    pub fn assert_liveliness(&self) {
1092        if let (Some(rt), Some(eid)) = (&self.runtime, self.entity_id) {
1093            rt.assert_writer_liveliness_eid(eid);
1094        }
1095    }
1096
1097    /// Blocks until all matched remote readers have acknowledged all
1098    /// samples written so far, or `timeout` elapses.
1099    ///
1100    /// Spec: OMG DDS 1.4 §2.2.2.4.2.22 `wait_for_acknowledgments`.
1101    /// Returns `Ok(())` immediately in offline mode and without matched
1102    /// readers.
1103    ///
1104    /// # Errors
1105    /// [`DdsError::Timeout`] if not all samples are acknowledged within
1106    /// the time window.
1107    #[cfg(feature = "std")]
1108    pub fn wait_for_acknowledgments(&self, timeout: core::time::Duration) -> Result<()> {
1109        let deadline = std::time::Instant::now() + timeout;
1110        loop {
1111            let all_acked = match (&self.runtime, self.entity_id) {
1112                (Some(rt), Some(eid)) => rt.user_writer_all_acknowledged(eid),
1113                _ => true, // offline: nothing to acknowledge
1114            };
1115            if all_acked {
1116                return Ok(());
1117            }
1118            let now = std::time::Instant::now();
1119            if now >= deadline {
1120                return Err(DdsError::Timeout);
1121            }
1122            // D.5e phase 1: event-driven via the runtime ack-event condvar.
1123            if let Some(rt) = self.runtime.as_ref() {
1124                let _ = rt.wait_ack_event(deadline - now);
1125            } else {
1126                std::thread::sleep(core::time::Duration::from_millis(20));
1127            }
1128        }
1129    }
1130
1131    /// Takes all pending samples out of the offline queue. For tests
1132    /// only; removed with live-mode wiring.
1133    #[doc(hidden)]
1134    pub fn __drain_pending(&self) -> Vec<Vec<u8>> {
1135        let drained = self
1136            .queue
1137            .lock()
1138            .map(|mut q| core::mem::take(&mut *q))
1139            .unwrap_or_default();
1140        // Spec §2.2.3.19: drain signal to waiting `write()` threads.
1141        #[cfg(feature = "std")]
1142        self.drain_signal.notify_all();
1143        drained
1144    }
1145
1146    // ========================================================================
1147    // Instance-API.4 / DDS 1.4 §2.2.2.4.2.{5,7,10,13,14}
1148    // ========================================================================
1149
1150    /// Local publication handle of this DataWriter (Spec §2.2.2.5.1.11).
1151    /// Passed along in the `publication_handle` field of `SampleInfo`.
1152    /// **Note**: this is NOT the same handle as the entity
1153    /// `InstanceHandle` (Spec §2.2.2.1.1) — see [`Self::instance_handle`].
1154    #[cfg(feature = "std")]
1155    #[must_use]
1156    pub fn publication_handle(&self) -> InstanceHandle {
1157        self.publication_handle
1158    }
1159
1160    /// Spec §2.2.2.1.1 `get_instance_handle` — entity identifier of this
1161    /// DataWriter for comparisons via
1162    /// `DomainParticipant::contains_entity`.
1163    #[must_use]
1164    pub fn instance_handle(&self) -> InstanceHandle {
1165        self.entity_state.instance_handle()
1166    }
1167
1168    /// Returns the current [`InstanceTracker`] (shared with the internal
1169    /// bookkeeping). Mainly for tests / inspection.
1170    #[cfg(feature = "std")]
1171    #[must_use]
1172    /// Returns (runtime, EntityId) if the writer runs in live mode.
1173    /// Cross-crate hook for the FFI layer (zerodds-c-api), which needs to
1174    /// call rt.write_user_lifecycle directly.
1175    #[doc(hidden)]
1176    #[cfg(feature = "std")]
1177    pub fn runtime_handle(&self) -> Option<(Arc<DcpsRuntime>, EntityId)> {
1178        match (&self.runtime, self.entity_id) {
1179            (Some(rt), Some(eid)) => Some((Arc::clone(rt), eid)),
1180            _ => None,
1181        }
1182    }
1183
1184    /// Returns the writer's shared instance tracker (test and inspection
1185    /// helper, Spec §2.2.2.4.2.5+ lifecycle bookkeeping).
1186    pub fn instance_tracker(&self) -> InstanceTracker {
1187        self.instances.clone()
1188    }
1189
1190    /// Computes the KeyHash + the PLAIN_CDR2-BE key holder for a sample.
1191    /// Returns `None` for non-keyed topics.
1192    #[cfg(feature = "std")]
1193    fn keyhash_and_holder(sample: &T) -> Option<(crate::instance_tracker::KeyHash, Vec<u8>)> {
1194        if !T::HAS_KEY {
1195            return None;
1196        }
1197        let mut holder = crate::dds_type::PlainCdr2BeKeyHolder::new();
1198        sample.encode_key_holder_be(&mut holder);
1199        let bytes = holder.as_bytes().to_vec();
1200        let max = T::KEY_HOLDER_MAX_SIZE.unwrap_or(usize::MAX);
1201        let kh = crate::dds_type::compute_key_hash(&bytes, max);
1202        Some((kh, bytes))
1203    }
1204
1205    /// Registers an instance with the DataWriter and returns its stable
1206    /// [`InstanceHandle`]. Spec §2.2.2.4.2.5 `register_instance`.
1207    ///
1208    /// For non-keyed topics the call returns [`HANDLE_NIL`] (each sample
1209    /// is its own "instance"; the spec explicitly says register/
1210    /// unregister/dispose are optional here).
1211    ///
1212    /// # Errors
1213    /// Currently the call cannot fail. Later (live mode) resource limits
1214    /// may return an `OutOfResources` error here.
1215    #[cfg(feature = "std")]
1216    pub fn register_instance(&self, instance: &T) -> Result<InstanceHandle> {
1217        self.register_instance_w_timestamp(instance, get_current_time())
1218    }
1219
1220    /// Like `register_instance`, but with an explicit timestamp.
1221    /// Spec §2.2.2.4.2.6.
1222    #[cfg(feature = "std")]
1223    pub fn register_instance_w_timestamp(
1224        &self,
1225        instance: &T,
1226        timestamp: Time,
1227    ) -> Result<InstanceHandle> {
1228        let Some((kh, holder)) = Self::keyhash_and_holder(instance) else {
1229            return Ok(HANDLE_NIL);
1230        };
1231        Ok(self.instances.register(kh, holder, Some(timestamp)))
1232    }
1233
1234    /// Turns a sample value into its corresponding local
1235    /// [`InstanceHandle`], or [`HANDLE_NIL`] if unknown / non-keyed.
1236    /// Spec §2.2.2.4.2.14 `lookup_instance`.
1237    #[cfg(feature = "std")]
1238    #[must_use]
1239    pub fn lookup_instance(&self, instance: &T) -> InstanceHandle {
1240        let Some((kh, _)) = Self::keyhash_and_holder(instance) else {
1241            return HANDLE_NIL;
1242        };
1243        self.instances.lookup(&kh).unwrap_or(HANDLE_NIL)
1244    }
1245
1246    /// Removes the instance from the writer set (Spec §2.2.2.4.2.7).
1247    /// Sets the lifecycle state to `NOT_ALIVE_NO_WRITERS` once the last
1248    /// writer has unregistered.
1249    ///
1250    /// # Errors
1251    /// `BadParameter` if `handle` does not match the instance of
1252    /// `instance` (the spec requires this consistency check). If
1253    /// `handle == HANDLE_NIL`, the handle is derived from `instance`.
1254    #[cfg(feature = "std")]
1255    pub fn unregister_instance(&self, instance: &T, handle: InstanceHandle) -> Result<()> {
1256        self.unregister_instance_w_timestamp(instance, handle, get_current_time())
1257    }
1258
1259    /// Like `unregister_instance`, but with a timestamp. Spec §2.2.2.4.2.8.
1260    ///
1261    /// Spec §2.2.3.21 WriterDataLifecycle: if
1262    /// `autodispose_unregistered_instances=true` (default), the instance
1263    /// is also disposed in addition to being unregistered — readers then
1264    /// see both `NOT_ALIVE_DISPOSED` and `NOT_ALIVE_NO_WRITERS`.
1265    #[cfg(feature = "std")]
1266    pub fn unregister_instance_w_timestamp(
1267        &self,
1268        instance: &T,
1269        handle: InstanceHandle,
1270        timestamp: Time,
1271    ) -> Result<()> {
1272        let resolved = self.resolve_handle(instance, handle)?;
1273        let autodispose = self
1274            .qos
1275            .lock()
1276            .map(|q| q.writer_data_lifecycle.autodispose_unregistered_instances)
1277            .unwrap_or(true);
1278        if autodispose && !self.instances.dispose(resolved, Some(timestamp)) {
1279            return Err(DdsError::BadParameter {
1280                what: "unknown instance handle",
1281            });
1282        }
1283        if !self.instances.unregister(resolved, Some(timestamp)) {
1284            return Err(DdsError::BadParameter {
1285                what: "unknown instance handle",
1286            });
1287        }
1288        // Wire side (Spec §9.6.3.9 PID_STATUS_INFO): send a lifecycle
1289        // marker to all matched readers. With autodispose=true we set both
1290        // bits, otherwise only UNREGISTERED.
1291        #[cfg(feature = "std")]
1292        if let (Some(rt), Some(eid), Some((kh, _))) = (
1293            &self.runtime,
1294            self.entity_id,
1295            Self::keyhash_and_holder(instance),
1296        ) {
1297            let mut bits = zerodds_rtps::inline_qos::status_info::UNREGISTERED;
1298            if autodispose {
1299                bits |= zerodds_rtps::inline_qos::status_info::DISPOSED;
1300            }
1301            let _ = rt.write_user_lifecycle(eid, kh, bits);
1302        }
1303        Ok(())
1304    }
1305
1306    /// Disposes an instance (Spec §2.2.2.4.2.10). Marks it as
1307    /// `NOT_ALIVE_DISPOSED`; readers then see a sample with
1308    /// `valid_data == false`.
1309    ///
1310    /// # Errors
1311    /// Like `unregister_instance`.
1312    #[cfg(feature = "std")]
1313    pub fn dispose(&self, instance: &T, handle: InstanceHandle) -> Result<()> {
1314        self.dispose_w_timestamp(instance, handle, get_current_time())
1315    }
1316
1317    /// Like `dispose`, but with a timestamp. Spec §2.2.2.4.2.11.
1318    #[cfg(feature = "std")]
1319    pub fn dispose_w_timestamp(
1320        &self,
1321        instance: &T,
1322        handle: InstanceHandle,
1323        timestamp: Time,
1324    ) -> Result<()> {
1325        let resolved = self.resolve_handle(instance, handle)?;
1326        if !self.instances.dispose(resolved, Some(timestamp)) {
1327            return Err(DdsError::BadParameter {
1328                what: "unknown instance handle",
1329            });
1330        }
1331        // Wire side (Spec §9.6.3.9 PID_STATUS_INFO).
1332        #[cfg(feature = "std")]
1333        if let (Some(rt), Some(eid), Some((kh, _))) = (
1334            &self.runtime,
1335            self.entity_id,
1336            Self::keyhash_and_holder(instance),
1337        ) {
1338            let _ =
1339                rt.write_user_lifecycle(eid, kh, zerodds_rtps::inline_qos::status_info::DISPOSED);
1340        }
1341        Ok(())
1342    }
1343
1344    /// Returns the sample value with only the `@key` fields populated
1345    /// (Spec §2.2.2.4.2.13 `get_key_value`). Implementation: we
1346    /// reconstruct `T` via `decode` from the stored PLAIN_CDR2-BE key
1347    /// holder. For this to work, `T::decode` must accept a key-only
1348    /// stream — for simple records this is trivially the case.
1349    ///
1350    /// # Errors
1351    /// * `BadParameter` if the handle is unknown.
1352    /// * `WireError` if the key holder cannot be reconstructed via
1353    ///   `T::decode`.
1354    #[cfg(feature = "std")]
1355    pub fn get_key_value(&self, handle: InstanceHandle) -> Result<T> {
1356        let Some(bytes) = self.instances.get_key_holder(handle) else {
1357            return Err(DdsError::BadParameter {
1358                what: "unknown instance handle",
1359            });
1360        };
1361        T::decode(&bytes).map_err(|e| DdsError::WireError {
1362            message: alloc::string::ToString::to_string(&e),
1363        })
1364    }
1365
1366    /// Helper: `handle == HANDLE_NIL` → derive from `instance`.
1367    /// Otherwise: check that `handle` matches the instance of `instance`.
1368    #[cfg(feature = "std")]
1369    fn resolve_handle(&self, instance: &T, handle: InstanceHandle) -> Result<InstanceHandle> {
1370        let derived = self.lookup_instance(instance);
1371        if handle.is_nil() {
1372            if derived.is_nil() {
1373                return Err(DdsError::BadParameter {
1374                    what: "instance not registered",
1375                });
1376            }
1377            return Ok(derived);
1378        }
1379        if !derived.is_nil() && derived != handle {
1380            return Err(DdsError::BadParameter {
1381                what: "handle does not match instance key",
1382            });
1383        }
1384        Ok(handle)
1385    }
1386
1387    /// Writes a sample with an explicit timestamp (Spec §2.2.2.4.2.16
1388    /// `write_w_timestamp`) and updates the instance bookkeeping.
1389    ///
1390    /// # Errors
1391    /// Like [`Self::write`].
1392    #[cfg(feature = "std")]
1393    pub fn write_w_timestamp(&self, sample: &T, timestamp: Time) -> Result<()> {
1394        // Auto-register: if the instance is not yet known, we register it
1395        // implicitly (Spec §2.2.2.4.2.16 allows that).
1396        if let Some((kh, holder)) = Self::keyhash_and_holder(sample) {
1397            if self.instances.lookup(&kh).is_none() {
1398                self.instances.register(kh, holder, Some(timestamp));
1399            } else {
1400                // On re-activation after Dispose / NoWriters, register
1401                // bumps the generation counter but also adds a writer
1402                // count that we don't want — so decrement it again right
1403                // away.
1404                let prev = self.instances.get_by_keyhash(&kh);
1405                if let Some(state) = prev {
1406                    if !matches!(state.kind, crate::sample_info::InstanceStateKind::Alive) {
1407                        self.instances.register(kh, holder, Some(timestamp));
1408                        self.instances.unregister(state.handle, Some(timestamp));
1409                    }
1410                }
1411            }
1412        }
1413        self.write(sample)
1414    }
1415}
1416
1417#[cfg(feature = "std")]
1418impl<T: DdsType> crate::entity::Entity for DataWriter<T> {
1419    type Qos = DataWriterQos;
1420
1421    fn get_qos(&self) -> Self::Qos {
1422        self.qos.lock().map(|q| q.clone()).unwrap_or_default()
1423    }
1424
1425    /// Spec §2.2.3 / §2.2.2.4.2: DURABILITY, RELIABILITY, HISTORY,
1426    /// RESOURCE_LIMITS, OWNERSHIP, LIVELINESS are Changeable=NO post-enable.
1427    fn set_qos(&self, qos: Self::Qos) -> Result<()> {
1428        let enabled = self.entity_state.is_enabled();
1429        if let Ok(mut current) = self.qos.lock() {
1430            if enabled {
1431                if current.durability != qos.durability {
1432                    return Err(crate::entity::immutable_if_enabled("DURABILITY"));
1433                }
1434                if current.reliability != qos.reliability {
1435                    return Err(crate::entity::immutable_if_enabled("RELIABILITY"));
1436                }
1437                if current.history != qos.history {
1438                    return Err(crate::entity::immutable_if_enabled("HISTORY"));
1439                }
1440                if current.resource_limits != qos.resource_limits {
1441                    return Err(crate::entity::immutable_if_enabled("RESOURCE_LIMITS"));
1442                }
1443                if current.ownership != qos.ownership {
1444                    return Err(crate::entity::immutable_if_enabled("OWNERSHIP"));
1445                }
1446                if current.liveliness != qos.liveliness {
1447                    return Err(crate::entity::immutable_if_enabled("LIVELINESS"));
1448                }
1449            }
1450            *current = qos;
1451        }
1452        Ok(())
1453    }
1454
1455    fn enable(&self) -> Result<()> {
1456        self.entity_state.enable();
1457        Ok(())
1458    }
1459
1460    fn entity_state(&self) -> Arc<crate::entity::EntityState> {
1461        Arc::clone(&self.entity_state)
1462    }
1463}
1464
1465// ---- Boxed type-mapped variant, so the publisher can hold a
1466// heterogeneous writer list (live-mode preparation) ----
1467#[allow(dead_code)]
1468pub(crate) trait AnyDataWriter: Send + Sync + core::fmt::Debug {
1469    fn topic_name(&self) -> &str;
1470    fn type_name(&self) -> &'static str;
1471}
1472
1473impl<T: DdsType + Send + 'static> AnyDataWriter for DataWriter<T>
1474where
1475    T: Send + Sync,
1476{
1477    fn topic_name(&self) -> &str {
1478        self.topic.name()
1479    }
1480    fn type_name(&self) -> &'static str {
1481        T::TYPE_NAME
1482    }
1483}
1484
1485// Silence dead_code on Box<dyn AnyDataWriter> construction helper.
1486#[allow(dead_code)]
1487pub(crate) fn boxed_any_writer<T: DdsType + Send + Sync + 'static>(
1488    w: DataWriter<T>,
1489) -> Box<dyn AnyDataWriter> {
1490    Box::new(w)
1491}
1492
1493#[cfg(test)]
1494#[allow(clippy::expect_used, clippy::unwrap_used)]
1495mod tests {
1496    use super::*;
1497    use crate::dds_type::RawBytes;
1498    use crate::factory::DomainParticipantFactory;
1499    use crate::qos::{DomainParticipantQos, TopicQos};
1500
1501    fn mk_topic() -> Topic<RawBytes> {
1502        let p = DomainParticipantFactory::instance()
1503            .create_participant_offline(0, DomainParticipantQos::default());
1504        Topic::new("Chatter".into(), TopicQos::default(), p)
1505    }
1506
1507    #[test]
1508    fn publisher_creates_datawriter_for_matching_type() {
1509        let p = Publisher::new(PublisherQos::default(), None);
1510        let w = p
1511            .create_datawriter::<RawBytes>(&mk_topic(), DataWriterQos::default())
1512            .unwrap();
1513        assert_eq!(w.topic().name(), "Chatter");
1514    }
1515
1516    /// Regression (ROS-2 cross-vendor): create_datawriter MUST derive the
1517    /// entityKind from `DdsType::HAS_KEY`. `RawBytes` is keyless
1518    /// (`HAS_KEY=false`), so the live writer MUST get a NoKey entityid
1519    /// (0x03) — otherwise a keyless CycloneDDS/ROS-2 reader silently
1520    /// rejects the match (entityKind mismatch). Before the fix, WithKey
1521    /// (0x02) was hard-coded.
1522    #[test]
1523    fn live_datawriter_entity_kind_is_nokey_for_keyless_type() {
1524        use zerodds_rtps::wire_types::EntityKind;
1525        let participant = DomainParticipantFactory::instance()
1526            .create_participant(0, DomainParticipantQos::default())
1527            .expect("live participant");
1528        let topic = participant
1529            .create_topic::<RawBytes>("KindChatter", TopicQos::default())
1530            .expect("topic");
1531        let pubr = participant.create_publisher(PublisherQos::default());
1532        let w = pubr
1533            .create_datawriter::<RawBytes>(&topic, DataWriterQos::default())
1534            .expect("writer");
1535        assert_eq!(
1536            w.entity_id.expect("live writer has entity_id").entity_kind,
1537            EntityKind::UserWriterNoKey,
1538            "keyless type must yield a NoKey writer entityid"
1539        );
1540    }
1541
1542    /// QB-cluster regression: `create_datawriter` MUST wire the History
1543    /// keep-last DEPTH onto the runtime writer slot (DDS 1.4 §2.2.3.18).
1544    /// With TransientLocal + KeepLast(2), writing 5 samples of the same
1545    /// (keyless ⇒ single default) instance must leave only the last 2
1546    /// retained for late-join replay. Before the fix the slot kept the
1547    /// default depth (1) regardless of QoS — and the retain path would not
1548    /// cap at the requested 2.
1549    #[test]
1550    fn create_datawriter_wires_history_keep_last_depth() {
1551        use crate::qos::{DurabilityKind, DurabilityQosPolicy, HistoryKind, HistoryQosPolicy};
1552        let participant = DomainParticipantFactory::instance()
1553            .create_participant(0, DomainParticipantQos::default())
1554            .expect("live participant");
1555        let topic = participant
1556            .create_topic::<RawBytes>("HistDepth", TopicQos::default())
1557            .expect("topic");
1558        let pubr = participant.create_publisher(PublisherQos::default());
1559        let qos = DataWriterQos {
1560            durability: DurabilityQosPolicy {
1561                kind: DurabilityKind::TransientLocal,
1562            },
1563            history: HistoryQosPolicy {
1564                kind: HistoryKind::KeepLast,
1565                depth: 2,
1566            },
1567            ..DataWriterQos::default()
1568        };
1569        let w = pubr
1570            .create_datawriter::<RawBytes>(&topic, qos)
1571            .expect("writer");
1572        let (rt, eid) = w.runtime_handle().expect("live writer");
1573        for i in 0u8..5 {
1574            w.write(&RawBytes::new(vec![i])).expect("write");
1575        }
1576        assert_eq!(
1577            rt.user_writer_retained_len(eid),
1578            2,
1579            "KeepLast(2) must cap the retained set at 2 for the default instance"
1580        );
1581    }
1582
1583    /// Contrast: KeepAll retains every written sample (depth = unbounded).
1584    #[test]
1585    fn create_datawriter_keep_all_retains_every_sample() {
1586        use crate::qos::{DurabilityKind, DurabilityQosPolicy, HistoryKind, HistoryQosPolicy};
1587        let participant = DomainParticipantFactory::instance()
1588            .create_participant(0, DomainParticipantQos::default())
1589            .expect("live participant");
1590        let topic = participant
1591            .create_topic::<RawBytes>("HistAll", TopicQos::default())
1592            .expect("topic");
1593        let pubr = participant.create_publisher(PublisherQos::default());
1594        let qos = DataWriterQos {
1595            durability: DurabilityQosPolicy {
1596                kind: DurabilityKind::TransientLocal,
1597            },
1598            history: HistoryQosPolicy {
1599                kind: HistoryKind::KeepAll,
1600                depth: 1,
1601            },
1602            ..DataWriterQos::default()
1603        };
1604        let w = pubr
1605            .create_datawriter::<RawBytes>(&topic, qos)
1606            .expect("writer");
1607        let (rt, eid) = w.runtime_handle().expect("live writer");
1608        for i in 0u8..4 {
1609            w.write(&RawBytes::new(vec![i])).expect("write");
1610        }
1611        assert_eq!(
1612            rt.user_writer_retained_len(eid),
1613            4,
1614            "KeepAll must retain every sample of the default instance"
1615        );
1616    }
1617
1618    #[test]
1619    fn datawriter_write_queues_encoded_sample() {
1620        let p = Publisher::new(PublisherQos::default(), None);
1621        let w = p
1622            .create_datawriter::<RawBytes>(&mk_topic(), DataWriterQos::default())
1623            .unwrap();
1624        assert_eq!(w.samples_pending(), 0);
1625        w.write(&RawBytes::new(vec![1, 2, 3])).unwrap();
1626        assert_eq!(w.samples_pending(), 1);
1627        let drained = w.__drain_pending();
1628        assert_eq!(drained, vec![vec![1u8, 2, 3]]);
1629    }
1630
1631    // poll_publication_matched + Listener-Slot-API.
1632
1633    use core::sync::atomic::{AtomicU32, Ordering};
1634
1635    #[test]
1636    fn datawriter_set_listener_stores_arc_and_mask() {
1637        struct L;
1638        impl crate::listener::DataWriterListener for L {}
1639        let p = Publisher::new(PublisherQos::default(), None);
1640        let w = p
1641            .create_datawriter::<RawBytes>(&mk_topic(), DataWriterQos::default())
1642            .unwrap();
1643        assert!(w.get_listener().is_none());
1644        w.set_listener(Some(Arc::new(L)), crate::psm_constants::status::ANY);
1645        assert!(w.get_listener().is_some());
1646        // The mask is mirrored to the EntityState.
1647        assert_eq!(
1648            w.entity_state.listener_mask(),
1649            crate::psm_constants::status::ANY
1650        );
1651    }
1652
1653    #[test]
1654    fn poll_publication_matched_fires_on_count_increase() {
1655        struct Cnt(AtomicU32);
1656        impl crate::listener::DataWriterListener for Cnt {
1657            fn on_publication_matched(
1658                &self,
1659                _w: crate::InstanceHandle,
1660                _s: crate::status::PublicationMatchedStatus,
1661            ) {
1662                self.0.fetch_add(1, Ordering::Relaxed);
1663            }
1664        }
1665        let p = Publisher::new(PublisherQos::default(), None);
1666        let w = p
1667            .create_datawriter::<RawBytes>(&mk_topic(), DataWriterQos::default())
1668            .unwrap();
1669        let cnt = Arc::new(Cnt(AtomicU32::new(0)));
1670        w.set_listener(Some(cnt.clone()), crate::psm_constants::status::ANY);
1671
1672        // 0 → 0 (initial call, AtomicI64 is -1, so there is a delta).
1673        w.poll_publication_matched(0);
1674        assert_eq!(cnt.0.load(Ordering::Relaxed), 1);
1675        // 0 → 1 (change).
1676        w.poll_publication_matched(1);
1677        assert_eq!(cnt.0.load(Ordering::Relaxed), 2);
1678        // 1 → 1 (no delta).
1679        w.poll_publication_matched(1);
1680        assert_eq!(cnt.0.load(Ordering::Relaxed), 2);
1681        // 1 → 2.
1682        w.poll_publication_matched(2);
1683        assert_eq!(cnt.0.load(Ordering::Relaxed), 3);
1684        // 2 → 1 (reader gone).
1685        w.poll_publication_matched(1);
1686        assert_eq!(cnt.0.load(Ordering::Relaxed), 4);
1687    }
1688
1689    #[test]
1690    fn poll_publication_matched_with_no_listener_is_noop() {
1691        let p = Publisher::new(PublisherQos::default(), None);
1692        let w = p
1693            .create_datawriter::<RawBytes>(&mk_topic(), DataWriterQos::default())
1694            .unwrap();
1695        // No listener set — must neither panic nor corrupt the delta
1696        // state.
1697        w.poll_publication_matched(0);
1698        w.poll_publication_matched(5);
1699    }
1700
1701    #[test]
1702    fn poll_publication_matched_bubbles_to_publisher() {
1703        struct PubL(AtomicU32);
1704        impl crate::listener::PublisherListener for PubL {
1705            fn on_publication_matched(
1706                &self,
1707                _w: crate::InstanceHandle,
1708                _s: crate::status::PublicationMatchedStatus,
1709            ) {
1710                self.0.fetch_add(1, Ordering::Relaxed);
1711            }
1712        }
1713        let p = Publisher::new(PublisherQos::default(), None);
1714        let pl = Arc::new(PubL(AtomicU32::new(0)));
1715        p.set_listener(Some(pl.clone()), crate::psm_constants::status::ANY);
1716        let w = p
1717            .create_datawriter::<RawBytes>(&mk_topic(), DataWriterQos::default())
1718            .unwrap();
1719        // No writer listener → the publisher receives it.
1720        w.poll_publication_matched(1);
1721        assert_eq!(pl.0.load(Ordering::Relaxed), 1);
1722    }
1723
1724    // ---- §2.2.2.4.1.10 / .11 suspend/resume_publications ----
1725
1726    #[test]
1727    fn suspend_publications_sets_flag() {
1728        let p = Publisher::new(PublisherQos::default(), None);
1729        assert!(!p.is_suspended());
1730        p.suspend_publications();
1731        assert!(p.is_suspended());
1732    }
1733
1734    #[test]
1735    fn resume_publications_clears_flag() {
1736        let p = Publisher::new(PublisherQos::default(), None);
1737        p.suspend_publications();
1738        p.resume_publications();
1739        assert!(!p.is_suspended());
1740    }
1741
1742    #[test]
1743    fn suspend_publications_is_idempotent() {
1744        let p = Publisher::new(PublisherQos::default(), None);
1745        p.suspend_publications();
1746        p.suspend_publications(); // second call is a no-op
1747        assert!(p.is_suspended());
1748    }
1749
1750    #[test]
1751    fn resume_without_suspend_is_noop() {
1752        let p = Publisher::new(PublisherQos::default(), None);
1753        // Spec §2.2.2.4.1.11 — resume without an active suspend is a no-op.
1754        p.resume_publications();
1755        assert!(!p.is_suspended());
1756    }
1757
1758    // ---- §2.2.2.4.1.13 copy_from_topic_qos ----
1759
1760    #[test]
1761    fn copy_from_topic_qos_copies_durability_and_reliability() {
1762        use crate::qos::{DurabilityKind, ReliabilityKind, TopicQos};
1763        let mut topic = TopicQos::default();
1764        topic.durability.kind = DurabilityKind::TransientLocal;
1765        topic.reliability.kind = ReliabilityKind::Reliable;
1766
1767        let mut dw = DataWriterQos::default();
1768        // Set something different so that we can see the change.
1769        dw.durability.kind = DurabilityKind::Volatile;
1770        Publisher::copy_from_topic_qos(&mut dw, &topic).unwrap();
1771        assert_eq!(dw.durability.kind, DurabilityKind::TransientLocal);
1772        assert_eq!(dw.reliability.kind, ReliabilityKind::Reliable);
1773    }
1774
1775    // ---- §2.2.3.19 RESOURCE_LIMITS Reliable-Block ----
1776
1777    #[test]
1778    fn write_blocks_until_drain_when_reliable_max_samples_reached() {
1779        use crate::qos::{HistoryQosPolicy, ResourceLimitsQosPolicy};
1780        let p = Publisher::new(PublisherQos::default(), None);
1781        let qos = DataWriterQos {
1782            resource_limits: ResourceLimitsQosPolicy {
1783                max_samples: 2,
1784                max_instances: -1,
1785                max_samples_per_instance: -1,
1786            },
1787            reliability: crate::qos::ReliabilityQosPolicy {
1788                kind: ReliabilityKind::Reliable,
1789                max_blocking_time: zerodds_qos::Duration::from_millis(500_i32),
1790            },
1791            ..DataWriterQos::default()
1792        };
1793        let _ = qos.history;
1794        let _ = HistoryQosPolicy::default();
1795        let w = p.create_datawriter::<RawBytes>(&mk_topic(), qos).unwrap();
1796        let s = RawBytes::new(b"x".to_vec());
1797        // First fill both slots (no block).
1798        w.write(&s).unwrap();
1799        w.write(&s).unwrap();
1800        assert_eq!(w.samples_pending(), 2);
1801
1802        // The third write blocks; in a second thread we drain after 50ms.
1803        let w_clone_q = w.queue.clone();
1804        let w_clone_signal = w.drain_signal.clone();
1805        let drain_handle = std::thread::spawn(move || {
1806            std::thread::sleep(core::time::Duration::from_millis(50));
1807            if let Ok(mut q) = w_clone_q.lock() {
1808                let _ = core::mem::take(&mut *q);
1809            }
1810            w_clone_signal.notify_all();
1811        });
1812
1813        let start = std::time::Instant::now();
1814        let res = w.write(&s);
1815        let elapsed = start.elapsed();
1816        drain_handle.join().unwrap();
1817
1818        assert!(res.is_ok(), "write should succeed after drain, got {res:?}");
1819        assert!(
1820            elapsed >= core::time::Duration::from_millis(40)
1821                && elapsed < core::time::Duration::from_millis(450),
1822            "elapsed = {elapsed:?}, expected ~50ms"
1823        );
1824    }
1825
1826    #[test]
1827    fn write_returns_timeout_when_reliable_drain_too_slow() {
1828        use crate::qos::ResourceLimitsQosPolicy;
1829        let p = Publisher::new(PublisherQos::default(), None);
1830        let qos = DataWriterQos {
1831            resource_limits: ResourceLimitsQosPolicy {
1832                max_samples: 1,
1833                max_instances: -1,
1834                max_samples_per_instance: -1,
1835            },
1836            reliability: crate::qos::ReliabilityQosPolicy {
1837                kind: ReliabilityKind::Reliable,
1838                max_blocking_time: zerodds_qos::Duration::from_millis(50_i32),
1839            },
1840            ..DataWriterQos::default()
1841        };
1842        let w = p.create_datawriter::<RawBytes>(&mk_topic(), qos).unwrap();
1843        let s = RawBytes::new(b"x".to_vec());
1844        w.write(&s).unwrap();
1845        // The second write has Reliable + 50ms block; without a drain → timeout.
1846        let res = w.write(&s);
1847        assert!(matches!(res, Err(DdsError::Timeout)));
1848    }
1849
1850    #[test]
1851    fn write_returns_oor_when_best_effort_queue_full() {
1852        use crate::qos::ResourceLimitsQosPolicy;
1853        let p = Publisher::new(PublisherQos::default(), None);
1854        let qos = DataWriterQos {
1855            resource_limits: ResourceLimitsQosPolicy {
1856                max_samples: 1,
1857                max_instances: -1,
1858                max_samples_per_instance: -1,
1859            },
1860            reliability: crate::qos::ReliabilityQosPolicy {
1861                kind: ReliabilityKind::BestEffort,
1862                max_blocking_time: zerodds_qos::Duration::from_millis(0_i32),
1863            },
1864            ..DataWriterQos::default()
1865        };
1866        let w = p.create_datawriter::<RawBytes>(&mk_topic(), qos).unwrap();
1867        let s = RawBytes::new(b"x".to_vec());
1868        w.write(&s).unwrap();
1869        let res = w.write(&s);
1870        assert!(matches!(res, Err(DdsError::OutOfResources { .. })));
1871    }
1872
1873    #[test]
1874    fn write_does_not_block_when_max_samples_unlimited() {
1875        // max_samples = -1 (LENGTH_UNLIMITED) → no cap, no block.
1876        let p = Publisher::new(PublisherQos::default(), None);
1877        let w = p
1878            .create_datawriter::<RawBytes>(&mk_topic(), DataWriterQos::default())
1879            .unwrap();
1880        let s = RawBytes::new(b"x".to_vec());
1881        for _ in 0..50 {
1882            w.write(&s).unwrap();
1883        }
1884        assert_eq!(w.samples_pending(), 50);
1885    }
1886
1887    #[test]
1888    fn copy_from_topic_qos_does_not_touch_writer_only_policies() {
1889        use crate::qos::TopicQos;
1890        let topic = TopicQos::default();
1891        let mut dw = DataWriterQos::default();
1892        // Set ownership_strength to a concrete value; it should remain
1893        // untouched after the copy (no TopicQos counterpart).
1894        dw.ownership_strength.value = 42;
1895        Publisher::copy_from_topic_qos(&mut dw, &topic).unwrap();
1896        assert_eq!(dw.ownership_strength.value, 42);
1897    }
1898}