Skip to main content

zerodds_dcps/
participant.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! DomainParticipant — the "root" entity of a DDS program.
4//!
5//! Spec reference: OMG DDS 1.4 §2.2.2.2 `DomainParticipant`.
6//!
7//! Every DDS program typically opens exactly one `DomainParticipant`
8//! per domain id. The participant:
9//!
10//! - holds the GUID prefix (12 bytes, the base ID for all internal endpoints),
11//! - registers itself via SPDP (Simple Participant Discovery Protocol),
12//! - runs SEDP (Simple Endpoint Discovery Protocol) for
13//!   topic/writer/reader matching,
14//! - is the factory for publishers, subscribers and topics.
15//!
16//! # Modes
17//!
18//! - **Live mode** (`new_with_runtime`, called from
19//!   `DomainParticipantFactory::create_participant`): binds UDP sockets,
20//!   spawns SPDP/SEDP/WLP threads, runs the full discovery protocol and
21//!   the TypeLookup service endpoints (XTypes 1.3 §7.6.3.3.4).
22//! - **Offline mode** (`new`, called from
23//!   `DomainParticipantFactory::create_participant_offline`): no
24//!   sockets, no threads. The topic registry, QoS negotiation and a
25//!   loopback path for unit tests are available.
26//!
27//! Topic registry: the same name + same type yields the same topic
28//! handle (DDS 1.4 §2.2.2.2.1.10 `find_topic`).
29
30extern crate alloc;
31use alloc::collections::{BTreeMap, BTreeSet};
32use alloc::string::String;
33use alloc::sync::Arc;
34use alloc::vec::Vec;
35
36#[cfg(feature = "std")]
37use std::sync::Mutex;
38
39use crate::builtin_subscriber::BuiltinSubscriber;
40use crate::builtin_topics::{ParticipantBuiltinTopicData, TopicBuiltinTopicData};
41use crate::dds_type::DdsType;
42use crate::entity::StatusMask;
43use crate::error::{DdsError, Result};
44use crate::instance_handle::InstanceHandle;
45use crate::listener::ArcDomainParticipantListener;
46use crate::publisher::Publisher;
47use crate::qos::{DomainParticipantQos, PublisherQos, SubscriberQos, TopicQos};
48use crate::subscriber::Subscriber;
49use crate::topic::{
50    ContentFilteredTopic, Topic, TopicDescription, TopicDescriptionHandle, TopicInner,
51};
52
53#[cfg(feature = "std")]
54use crate::runtime::{DcpsRuntime, RuntimeConfig};
55
56/// Domain-id type (Spec: `DomainId_t` = long, i.e. i32).
57pub type DomainId = i32;
58
59/// Shared ignore-list filter of a `DomainParticipant`. Held by the
60/// participant **and** consulted by the `DcpsRuntime` discovery hook
61/// (a clone of the `Arc`). Spec reference: DDS DCPS 1.4 §2.2.2.2.1.14-17
62/// `ignore_participant/topic/publication/subscription`.
63///
64/// Per spec the lists are **monotonically growing**: a handle can be
65/// added, but never removed again. Hence `BTreeSet<InstanceHandle>`
66/// suffices and no generation counters are needed.
67#[derive(Debug, Default)]
68#[cfg(feature = "std")]
69pub(crate) struct IgnoreFilterInner {
70    pub(crate) participants: Mutex<BTreeSet<InstanceHandle>>,
71    pub(crate) topics: Mutex<BTreeSet<InstanceHandle>>,
72    pub(crate) publications: Mutex<BTreeSet<InstanceHandle>>,
73    pub(crate) subscriptions: Mutex<BTreeSet<InstanceHandle>>,
74}
75
76/// Cloneable filter handle (Arc bumps are cheap). The discovery hook may
77/// poke in here in between, without forcing lock cycles on the entire
78/// ParticipantInner.
79#[derive(Clone, Debug, Default)]
80#[cfg(feature = "std")]
81pub struct IgnoreFilter {
82    pub(crate) inner: Arc<IgnoreFilterInner>,
83}
84
85#[cfg(feature = "std")]
86impl IgnoreFilter {
87    /// Check whether a participant handle is ignored.
88    #[must_use]
89    pub fn is_participant_ignored(&self, h: InstanceHandle) -> bool {
90        self.inner
91            .participants
92            .lock()
93            .map(|s| s.contains(&h))
94            .unwrap_or(false)
95    }
96
97    /// Check whether a topic handle is ignored.
98    #[must_use]
99    pub fn is_topic_ignored(&self, h: InstanceHandle) -> bool {
100        self.inner
101            .topics
102            .lock()
103            .map(|s| s.contains(&h))
104            .unwrap_or(false)
105    }
106
107    /// Check whether a publication handle is ignored.
108    #[must_use]
109    pub fn is_publication_ignored(&self, h: InstanceHandle) -> bool {
110        self.inner
111            .publications
112            .lock()
113            .map(|s| s.contains(&h))
114            .unwrap_or(false)
115    }
116
117    /// Check whether a subscription handle is ignored.
118    #[must_use]
119    pub fn is_subscription_ignored(&self, h: InstanceHandle) -> bool {
120        self.inner
121            .subscriptions
122            .lock()
123            .map(|s| s.contains(&h))
124            .unwrap_or(false)
125    }
126}
127
128/// Randomly generated 12-byte participant prefix.
129///
130/// Scheme (Spec `zerodds-zero-copy-1.0` §6 wave 4):
131/// - `bytes[0..4]`: host id (FNV1a hash of the `gethostname` output).
132///   Two participants on the same machine carry the same host-id
133///   prefix → discovery detects a same-host match and can enable a
134///   zero-copy SHM path.
135/// - `bytes[4..8]`: process id (LE).
136/// - `bytes[8..12]`: timestamp + atomic counter, so that a restart of
137///   the same process, or multiple participants in the same process,
138///   get different prefixes.
139///
140/// A cross-host hash collision (4-byte FNV1a) is theoretically possible
141/// but practically negligible; a false-positive same-host match would
142/// only make the SHM setup fail and automatically fall back to the UDP
143/// path.
144#[cfg(feature = "std")]
145fn random_guid_prefix() -> zerodds_rtps::wire_types::GuidPrefix {
146    use std::sync::atomic::{AtomicU32, Ordering};
147    static COUNTER: AtomicU32 = AtomicU32::new(0);
148    let host_id = host_id_bytes();
149    let pid = std::process::id();
150    let t = std::time::SystemTime::now()
151        .duration_since(std::time::UNIX_EPOCH)
152        .map(|d| d.as_nanos() as u64)
153        .unwrap_or(0);
154    let c = COUNTER.fetch_add(1, Ordering::Relaxed);
155    let mut bytes = [0u8; 12];
156    bytes[0..4].copy_from_slice(&host_id);
157    bytes[4..8].copy_from_slice(&pid.to_le_bytes());
158    bytes[8..12].copy_from_slice(&(t as u32).to_le_bytes());
159    bytes[11] = bytes[11].wrapping_add(c as u8);
160    zerodds_rtps::wire_types::GuidPrefix::from_bytes(bytes)
161}
162
163/// Deterministic 4-byte host identifier based on `gethostname`. Cached
164/// per process via `OnceLock`.
165///
166/// FNV1a-32 is enough: we need identity (same-host yes/no), not
167/// cryptographic security. If `gethostname` fails (a CI container
168/// without a hostname), we fall back to a process-local random value —
169/// then no false-positive same-host match occurs with peers on the same
170/// machine, which is safe (only the SHM optimization is missed).
171///
172/// `pub` so that `zerodds-c-api` places the same host identifier in its
173/// GuidPrefix — otherwise two C-FFI processes on the same host would
174/// never see each other as same-host (`is_same_host`), and SHM /
175/// fragmentation optimizations would not apply for any C++/C#/TS
176/// bindings.
177#[cfg(feature = "std")]
178pub fn host_id_bytes() -> [u8; 4] {
179    use std::sync::OnceLock;
180    static HOST_ID: OnceLock<[u8; 4]> = OnceLock::new();
181    *HOST_ID.get_or_init(|| {
182        // Primary: gethostname(3) — works uniformly on Linux, macOS and
183        // the BSDs, without env-var / etc-file sources that are
184        // sometimes missing (macOS has no /etc/hostname; HOSTNAME is
185        // Bash-only and not exported; COMPUTERNAME is Windows).
186        // Previously: 3 sources tried, all silently failed, fell back to
187        // PID+time → a different host_id per process on the same
188        // machine, and same-host optimizations (LOOPBACK_FRAGMENT_SIZE,
189        // same-host SHM) did not apply.
190        let hostname = gethostname_via_libc()
191            .or_else(|| std::env::var("HOSTNAME").ok())
192            .or_else(|| std::env::var("COMPUTERNAME").ok())
193            .or_else(read_etc_hostname);
194        let h = match hostname {
195            Some(s) if !s.is_empty() => fnv1a_32(s.as_bytes()),
196            _ => {
197                // Last fallback: a process-local random value. Then this
198                // process has a unique "host" and makes no
199                // false-positive same-host optimization.
200                let pid = std::process::id();
201                let t = std::time::SystemTime::now()
202                    .duration_since(std::time::UNIX_EPOCH)
203                    .map(|d| d.as_nanos() as u32)
204                    .unwrap_or(0);
205                pid.wrapping_mul(0x9E37_79B1).wrapping_add(t)
206            }
207        };
208        h.to_le_bytes()
209    })
210}
211
212#[cfg(all(feature = "std", unix))]
213#[allow(unsafe_code)]
214fn gethostname_via_libc() -> Option<String> {
215    // POSIX `gethostname(buf, len)` — 256 bytes are enough for all
216    // realistic hostnames (HOST_NAME_MAX is typically 64 or 255).
217    let mut buf = [0u8; 256];
218    // SAFETY: buf is valid writable memory of buf.len() bytes;
219    // gethostname writes at most len bytes and NUL-terminates.
220    let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast::<libc::c_char>(), buf.len()) };
221    if rc != 0 {
222        return None;
223    }
224    // NUL-terminated string; find it + decode UTF-8.
225    let len = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
226    if len == 0 {
227        return None;
228    }
229    core::str::from_utf8(&buf[..len]).ok().map(|s| s.to_owned())
230}
231
232#[cfg(all(feature = "std", not(unix)))]
233fn gethostname_via_libc() -> Option<String> {
234    None
235}
236
237#[cfg(feature = "std")]
238fn read_etc_hostname() -> Option<String> {
239    std::fs::read_to_string("/etc/hostname")
240        .ok()
241        .map(|s| s.trim().to_owned())
242}
243
244#[cfg(feature = "std")]
245fn fnv1a_32(data: &[u8]) -> u32 {
246    let mut h: u32 = 0x811C_9DC5;
247    for &b in data {
248        h ^= u32::from(b);
249        h = h.wrapping_mul(0x0100_0193);
250    }
251    h
252}
253
254/// The participant.
255#[derive(Clone)]
256pub struct DomainParticipant {
257    inner: Arc<ParticipantInner>,
258}
259
260impl core::fmt::Debug for DomainParticipant {
261    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
262        f.debug_struct("DomainParticipant")
263            .field("domain_id", &self.inner.domain_id)
264            .finish_non_exhaustive()
265    }
266}
267
268impl DomainParticipant {
269    /// A **weak** handle to this participant's inner state. The
270    /// `DomainParticipantFactory` stores these so it never keeps a participant
271    /// alive past the user's last strong handle — dropping the user's
272    /// `DomainParticipant` then runs the natural RAII teardown (runtime threads,
273    /// UDP sockets and multicast memberships released via the `Arc<DcpsRuntime>`
274    /// drop chain).
275    pub(crate) fn downgrade(&self) -> alloc::sync::Weak<ParticipantInner> {
276        Arc::downgrade(&self.inner)
277    }
278
279    /// Reconstruct a strong participant handle from inner state — used by the
280    /// factory's `lookup_participant` after `Weak::upgrade`.
281    pub(crate) fn from_inner(inner: Arc<ParticipantInner>) -> Self {
282        Self { inner }
283    }
284}
285
286pub(crate) struct ParticipantInner {
287    pub(crate) domain_id: DomainId,
288    pub(crate) qos: Mutex<DomainParticipantQos>,
289    /// Entity lifecycle (DCPS §2.2.2.1).
290    pub(crate) entity_state: Arc<crate::entity::EntityState>,
291    /// Topic registry (name → TopicInner). Repeated `create_topic` with
292    /// the same name + type return the same handle; with a different
293    /// type → `InconsistentPolicy` error.
294    topics: Mutex<BTreeMap<String, Arc<TopicInner>>>,
295    /// Runtime handle with UDP sockets + discovery threads. `None` when
296    /// the participant was created in offline mode (tests that want no
297    /// networking).
298    #[cfg(feature = "std")]
299    pub(crate) runtime: Option<Arc<DcpsRuntime>>,
300    /// Pre-installed builtin subscriber (DDS 1.4 §2.2.2.2.1.7). Exactly
301    /// one per participant. The sinks are hooked into the runtime
302    /// discovery hook at construction time.
303    pub(crate) builtin_subscriber: Arc<BuiltinSubscriber>,
304    /// Ignore filter (Spec §2.2.2.2.1.14-17). A clone lives in the
305    /// runtime and is checked by the discovery hot path, so that
306    /// SPDP/SEDP samples no longer reach the builtin readers after
307    /// `ignore_*`.
308    #[cfg(feature = "std")]
309    pub(crate) ignore_filter: IgnoreFilter,
310    /// Local publisher registry (for `delete_contained_entities` +
311    /// `contains_entity` per Spec §2.2.2.2.1.10). We track the
312    /// `InstanceHandle` of every publisher created with
313    /// `create_publisher`; `delete_contained_entities` clears the list.
314    /// The actual drop semantics of each publisher happen via the `Arc`
315    /// refcount once the user handle is dropped.
316    publishers: Mutex<Vec<InstanceHandle>>,
317    /// Analogous to `publishers`.
318    subscribers: Mutex<Vec<InstanceHandle>>,
319    /// Aggregate of all DataWriter handles of all publishers of this
320    /// participant (Spec §2.2.2.2.1.10 contains_entity, recursive).
321    /// Pub/Sub register new children via a weak back-reference.
322    pub(crate) datawriters: Mutex<Vec<InstanceHandle>>,
323    /// Aggregate of all DataReader handles of all subscribers of this
324    /// participant.
325    pub(crate) datareaders: Mutex<Vec<InstanceHandle>>,
326    /// Optional [`ArcDomainParticipantListener`] + [`StatusMask`].
327    /// Bubble-up target for all children whose narrower listener does not
328    /// cover the status bit.
329    pub(crate) listener: Mutex<Option<(ArcDomainParticipantListener, StatusMask)>>,
330    /// Built-in DynamicType registry. Automatically populated in `new()`/
331    /// `new_with_runtime()` with the 4 Spec §7.6.5 built-in types
332    /// (`DDS::String`, `DDS::KeyedString`, `DDS::Bytes`,
333    /// `DDS::KeyedBytes`). Retrievable via
334    /// [`DomainParticipant::find_builtin_type`].
335    #[cfg(feature = "std")]
336    pub(crate) type_registry: Mutex<BTreeMap<String, zerodds_types::dynamic::DynamicType>>,
337    /// TypeLookup client state per participant. Pending get-types
338    /// requests are queued here; backoff via `last_attempt_per_hash` so
339    /// that unknown TypeIDs are not re-queried every tick.
340    #[cfg(feature = "std")]
341    pub(crate) type_lookup: Mutex<TypeLookupState>,
342}
343
344/// TypeLookup client state per participant. Tracks pending requests +
345/// backoff timer + retry count per unknown TypeID hash.
346#[cfg(feature = "std")]
347#[derive(Debug, Default)]
348pub(crate) struct TypeLookupState {
349    /// Per TypeID: (last_attempt_instant, retry_count).
350    pub attempts: BTreeMap<zerodds_types::EquivalenceHash, (std::time::Instant, u32)>,
351    /// Optional sink for outgoing TypeLookup requests (test hook). The
352    /// production path would be a reliable writer on the
353    /// `TL_SVC_REQ_WRITER` endpoint; until then the sink queues (test
354    /// mode) or stays None (live mode = no-op).
355    pub outgoing: Vec<(zerodds_types::EquivalenceHash, u64)>,
356}
357
358#[cfg(feature = "std")]
359impl TypeLookupState {
360    /// Backoff period (5s) between retries.
361    pub const BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
362    /// Maximum attempts per unknown TypeID.
363    pub const MAX_ATTEMPTS: u32 = 3;
364}
365
366impl DomainParticipant {
367    /// Offline constructor without a runtime — for skeleton tests.
368    /// Production code goes through `DomainParticipantFactory::
369    /// create_participant`, which automatically starts a runtime.
370    pub(crate) fn new(domain_id: DomainId, qos: DomainParticipantQos) -> Self {
371        let builtin = Arc::new(BuiltinSubscriber::new());
372        let participant = Self {
373            inner: Arc::new(ParticipantInner {
374                domain_id,
375                qos: Mutex::new(qos),
376                entity_state: crate::entity::EntityState::new(),
377                topics: Mutex::new(BTreeMap::new()),
378                #[cfg(feature = "std")]
379                runtime: None,
380                builtin_subscriber: builtin,
381                #[cfg(feature = "std")]
382                ignore_filter: IgnoreFilter::default(),
383                publishers: Mutex::new(Vec::new()),
384                subscribers: Mutex::new(Vec::new()),
385                datawriters: Mutex::new(Vec::new()),
386                datareaders: Mutex::new(Vec::new()),
387                listener: Mutex::new(None),
388                #[cfg(feature = "std")]
389                type_registry: Mutex::new(BTreeMap::new()),
390                #[cfg(feature = "std")]
391                type_lookup: Mutex::new(TypeLookupState::default()),
392            }),
393        };
394        // Auto-register the 4 Spec §7.6.5 built-in types.
395        #[cfg(feature = "std")]
396        participant.register_builtin_types();
397        participant
398    }
399
400    /// Constructor with a live runtime (UDP + discovery). Returns
401    /// `TransportError` if the socket bind fails.
402    ///
403    /// # Errors
404    /// [`DdsError::TransportError`] on bind problems.
405    #[cfg(feature = "std")]
406    pub(crate) fn new_with_runtime(
407        domain_id: DomainId,
408        qos: DomainParticipantQos,
409        config: RuntimeConfig,
410    ) -> Result<Self> {
411        // DDS-Security spec-style logger wireup: if the participant QoS carries
412        // `dds.sec.log.*` properties, materialize the fan-out logger from them
413        // and wire it into the runtime (no-op when absent).
414        #[cfg(feature = "security")]
415        let config = config
416            .with_security_log_properties(&qos.property)
417            .map_err(|_| DdsError::PreconditionNotMet {
418                reason: "invalid dds.sec.log.* security logger configuration",
419            })?;
420        let runtime = DcpsRuntime::start(domain_id, random_guid_prefix(), config)?;
421        let builtin = Arc::new(BuiltinSubscriber::new());
422        // Wire up the discovery hook: from now on the runtime pushes
423        // SPDP/SEDP events into the 4 builtin readers.
424        runtime.attach_builtin_sinks(builtin.sinks());
425        // Share the ignore filter with the runtime, so that the
426        // discovery hot path (handle_spdp_datagram +
427        // push_sedp_events_to_builtin_readers) can consult the lists.
428        let ignore_filter = IgnoreFilter::default();
429        runtime.attach_ignore_filter(ignore_filter.clone());
430        let participant = Self {
431            inner: Arc::new(ParticipantInner {
432                domain_id,
433                qos: Mutex::new(qos),
434                entity_state: crate::entity::EntityState::new(),
435                topics: Mutex::new(BTreeMap::new()),
436                runtime: Some(runtime),
437                builtin_subscriber: builtin,
438                ignore_filter,
439                publishers: Mutex::new(Vec::new()),
440                subscribers: Mutex::new(Vec::new()),
441                datawriters: Mutex::new(Vec::new()),
442                datareaders: Mutex::new(Vec::new()),
443                listener: Mutex::new(None),
444                type_registry: Mutex::new(BTreeMap::new()),
445                type_lookup: Mutex::new(TypeLookupState::default()),
446            }),
447        };
448        // Auto-register the 4 Spec §7.6.5 built-in types.
449        participant.register_builtin_types();
450        Ok(participant)
451    }
452
453    /// Internal access to the runtime — used by Publisher/Subscriber to
454    /// create DataWriter/Reader. `None` when the participant is in
455    /// offline mode.
456    #[cfg(feature = "std")]
457    #[must_use]
458    pub fn runtime(&self) -> Option<&Arc<DcpsRuntime>> {
459        self.inner.runtime.as_ref()
460    }
461
462    /// Domain id.
463    #[must_use]
464    pub fn domain_id(&self) -> DomainId {
465        self.inner.domain_id
466    }
467
468    /// Returns a copy of the DomainParticipantQos (Spec §2.2.2.2.1.4
469    /// `get_qos`).
470    #[must_use]
471    pub fn qos(&self) -> DomainParticipantQos {
472        self.inner.qos.lock().map(|g| g.clone()).unwrap_or_default()
473    }
474
475    /// Sets the DomainParticipantQos (Spec §2.2.2.2.1.3 `set_qos`).
476    ///
477    /// # Errors
478    /// Currently none — the method always returns `Ok(())`. The spec
479    /// allows `IMMUTABLE_POLICY`, which we do not actively produce (all
480    /// policies are mutable in RC1).
481    pub fn set_qos(&self, qos: DomainParticipantQos) -> Result<()> {
482        if let Ok(mut g) = self.inner.qos.lock() {
483            *g = qos;
484        }
485        Ok(())
486    }
487
488    /// Registers the 4 Spec §7.6.5 built-in types
489    /// (`DDS::String`, `DDS::KeyedString`, `DDS::Bytes`, `DDS::KeyedBytes`)
490    /// in the local type registry. Idempotent — a second call overwrites
491    /// the entries deterministically.
492    ///
493    /// Called automatically from `new()`/`new_with_runtime()`, but can
494    /// also be called again after an `unregister_builtin_types()`
495    /// disable.
496    #[cfg(feature = "std")]
497    pub fn register_builtin_types(&self) {
498        if let Ok(types) = zerodds_types::dynamic::all_builtin_types() {
499            if let Ok(mut reg) = self.inner.type_registry.lock() {
500                for (name, t) in types {
501                    reg.insert(name, t);
502                }
503            }
504        }
505    }
506
507    /// Deletes all registered built-in types. Not called from any
508    /// default path today — a test helper for disable-flag tests.
509    #[cfg(feature = "std")]
510    pub fn unregister_builtin_types(&self) {
511        if let Ok(mut reg) = self.inner.type_registry.lock() {
512            reg.retain(|name, _| !zerodds_types::dynamic::is_builtin_type_name(name));
513        }
514    }
515
516    /// Lookup of a built-in type by spec name (Spec §7.6.5). Returns
517    /// `Some(DynamicType)` if the name is known (registered via
518    /// `register_builtin_types`).
519    #[cfg(feature = "std")]
520    #[must_use]
521    pub fn find_builtin_type(&self, name: &str) -> Option<zerodds_types::dynamic::DynamicType> {
522        self.inner
523            .type_registry
524            .lock()
525            .ok()
526            .and_then(|reg| reg.get(name).cloned())
527    }
528
529    /// Number of registered built-in types. After `new()` == 4.
530    #[cfg(feature = "std")]
531    #[must_use]
532    pub fn registered_type_count(&self) -> usize {
533        self.inner
534            .type_registry
535            .lock()
536            .map(|r| r.len())
537            .unwrap_or(0)
538    }
539
540    /// Attempts to queue a TypeLookup request for an unknown
541    /// `EquivalenceHash`. Respects backoff (5s between attempts) and at
542    /// most 3 retries per hash.
543    ///
544    /// Returns: `true` if the request was queued, `false` on backoff
545    /// suppression or max attempts.
546    #[cfg(feature = "std")]
547    pub fn enqueue_type_lookup(&self, hash: zerodds_types::EquivalenceHash) -> bool {
548        let mut state = match self.inner.type_lookup.lock() {
549            Ok(s) => s,
550            Err(_) => return false,
551        };
552        let now = std::time::Instant::now();
553        if let Some((last, retries)) = state.attempts.get(&hash).copied() {
554            if retries >= TypeLookupState::MAX_ATTEMPTS {
555                return false;
556            }
557            if now.duration_since(last) < TypeLookupState::BACKOFF {
558                return false;
559            }
560            state
561                .attempts
562                .insert(hash, (now, retries.saturating_add(1)));
563        } else {
564            state.attempts.insert(hash, (now, 1));
565        }
566        // Next sequence number for the request.
567        let seq = state.outgoing.len() as u64 + 1;
568        state.outgoing.push((hash, seq));
569        true
570    }
571
572    /// Drains the queued TypeLookup requests. Returns `Vec<(hash, seq)>`.
573    /// In a production environment the caller would send the hashes via
574    /// TypeLookupClient + reliable writer to the `TL_SVC_REQ_WRITER`
575    /// endpoint.
576    #[cfg(feature = "std")]
577    #[must_use]
578    pub fn drain_type_lookup_requests(&self) -> Vec<(zerodds_types::EquivalenceHash, u64)> {
579        self.inner
580            .type_lookup
581            .lock()
582            .map(|mut s| core::mem::take(&mut s.outgoing))
583            .unwrap_or_default()
584    }
585
586    /// Receives a TypeLookup reply (TypeObjects per hash). Registers the
587    /// TypeObjects in an internal type-registry mirror — afterwards a
588    /// stalled QoS match can be retried.
589    ///
590    /// Returns the number of successfully registered types.
591    #[cfg(feature = "std")]
592    pub fn ingest_type_lookup_reply(
593        &self,
594        types: Vec<(
595            zerodds_types::EquivalenceHash,
596            zerodds_types::MinimalTypeObject,
597        )>,
598    ) -> usize {
599        let mut count = 0;
600        if let Ok(mut state) = self.inner.type_lookup.lock() {
601            for (hash, _t) in &types {
602                state.attempts.remove(hash);
603                count += 1;
604            }
605        }
606        // Clippy-bait avoidance: the types vec is consumed here; the
607        // actual type-registry insert can be done by the caller
608        // (e.g. via the shared TypeLookupServer.registry).
609        let _ = types;
610        count
611    }
612
613    /// SEDP discovery hook: checks an incoming
614    /// `PublicationBuiltinTopicData` for type hashes that cannot be
615    /// resolved locally. If needed, a TypeLookup request is queued via
616    /// `enqueue_type_lookup`.
617    ///
618    /// The RPC path is live via `DcpsRuntime::send_type_lookup_request`
619    /// on the TL_SVC_REQ_* endpoints (XTypes 1.3 §7.6.3.3.4); this method
620    /// decides per hash whether a re-request is worthwhile (local
621    /// registry lookup + backoff tracking).
622    ///
623    /// Returns: number of unknown hashes queued (max 2 — minimal +
624    /// complete).
625    #[cfg(feature = "std")]
626    pub fn on_remote_publication_discovered(&self, type_information_blob: Option<&[u8]>) -> usize {
627        self.on_remote_type_information(type_information_blob)
628    }
629
630    /// SEDP discovery hook for `SubscriptionBuiltinTopicData`. Symmetric
631    /// to `on_remote_publication_discovered`.
632    #[cfg(feature = "std")]
633    pub fn on_remote_subscription_discovered(&self, type_information_blob: Option<&[u8]>) -> usize {
634        self.on_remote_type_information(type_information_blob)
635    }
636
637    #[cfg(feature = "std")]
638    fn on_remote_type_information(&self, blob: Option<&[u8]>) -> usize {
639        let Some(bytes) = blob else {
640            return 0;
641        };
642        let Ok(ti) = zerodds_types::type_information::TypeInformation::from_bytes_le(bytes) else {
643            return 0;
644        };
645        let mut queued = 0;
646        // Check the minimal hash.
647        if let Some(hash) = extract_equivalence_hash(&ti.minimal.typeid_with_size.type_id) {
648            if !self.has_type_for_hash(hash) && self.enqueue_type_lookup(hash) {
649                queued += 1;
650            }
651        }
652        // Check the complete hash (if present).
653        if let Some(hash) = extract_equivalence_hash(&ti.complete.typeid_with_size.type_id) {
654            if !self.has_type_for_hash(hash) && self.enqueue_type_lookup(hash) {
655                queued += 1;
656            }
657        }
658        queued
659    }
660
661    /// Internal helper — true if the hash is already resolvable in the
662    /// local `TypeLookupServer.registry` (either fed in locally via
663    /// `register_type_object` or populated by a previous `getTypes`
664    /// reply ingest). Prevents us from issuing redundant lookup requests
665    /// for hashes we already know.
666    #[cfg(feature = "std")]
667    fn has_type_for_hash(&self, hash: zerodds_types::EquivalenceHash) -> bool {
668        let Some(rt) = self.inner.runtime.as_ref() else {
669            return false;
670        };
671        let Ok(server) = rt.type_lookup_server.lock() else {
672            return false;
673        };
674        server.registry.get_minimal(&hash).is_some()
675            || server.registry.get_complete(&hash).is_some()
676    }
677
678    /// True if MAX_ATTEMPTS has already been reached for the hash.
679    /// Consulted by the match-retry path: give up eventually instead of
680    /// polling forever.
681    #[cfg(feature = "std")]
682    #[must_use]
683    pub fn type_lookup_exhausted(&self, hash: zerodds_types::EquivalenceHash) -> bool {
684        self.inner
685            .type_lookup
686            .lock()
687            .ok()
688            .and_then(|s| s.attempts.get(&hash).map(|(_, n)| *n))
689            .unwrap_or(0)
690            >= TypeLookupState::MAX_ATTEMPTS
691    }
692
693    /// Creates a typed topic handle. Repeated calls with the same name +
694    /// type return the same handle (ref-shared).
695    ///
696    /// # Errors
697    /// - `InconsistentPolicy` if a topic with this name is already
698    ///   registered under a different type.
699    /// - `BadParameter` for an empty name.
700    pub fn create_topic<T: DdsType>(&self, name: &str, qos: TopicQos) -> Result<Topic<T>> {
701        if name.is_empty() {
702            return Err(DdsError::BadParameter { what: "topic name" });
703        }
704        let mut topics = self
705            .inner
706            .topics
707            .lock()
708            .map_err(|_| DdsError::PreconditionNotMet {
709                reason: "topic registry poisoned",
710            })?;
711        if let Some(existing) = topics.get(name) {
712            if existing.type_name != T::TYPE_NAME {
713                // Inconsistent-topic detection. Bumps the counter on the
714                // existing topic — on the next `inconsistent_topic_status()`
715                // read, the listener is fired via bubble-up.
716                #[cfg(feature = "std")]
717                existing
718                    .inconsistent_topic_count
719                    .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
720                return Err(DdsError::InconsistentPolicy {
721                    what: "topic name reused with different type",
722                });
723            }
724            // Same type → shared handle.
725            return Ok(reconstruct_topic::<T>(existing.clone(), self.clone()));
726        }
727        let topic = Topic::<T>::new(name.into(), qos, self.clone());
728        topics.insert(name.into(), topic_inner(&topic));
729        Ok(topic)
730    }
731
732    /// Immediate local lookup of a topic by name — returns `None` if no
733    /// local `create_topic` with this name has occurred. **Does no
734    /// discovery wait** (that is `find_topic`). Spec reference: OMG
735    /// DDS 1.4 §2.2.2.2.1.12 "lookup_topicdescription".
736    #[must_use]
737    pub fn lookup_topicdescription(&self, name: &str) -> Option<TopicDescriptionHandle> {
738        let topics = self.inner.topics.lock().ok()?;
739        let inner = topics.get(name)?;
740        Some(TopicDescriptionHandle::new(
741            inner.name.clone(),
742            String::from(inner.type_name),
743            self.clone(),
744        ))
745    }
746
747    /// Waits until a topic with the given name is visible via discovery
748    /// (an SEDP publication or subscription) — or until `timeout`
749    /// elapses. Spec reference: OMG DDS 1.4 §2.2.2.2.1.11 `find_topic`.
750    ///
751    /// Returns:
752    /// - `Ok(handle)` with name + type name + participant, if a matching
753    ///   SEDP endpoint became visible during `timeout`. Local topics
754    ///   count as well (no need to wait if `create_topic` already ran).
755    /// - `Err(Timeout)` if `timeout` elapsed.
756    ///
757    /// # Errors
758    /// - `DdsError::Timeout` if `timeout` elapsed without a discovery
759    ///   match.
760    /// - `DdsError::BadParameter` for an empty name.
761    #[cfg(feature = "std")]
762    pub fn find_topic(
763        &self,
764        name: &str,
765        timeout: core::time::Duration,
766    ) -> Result<TopicDescriptionHandle> {
767        if name.is_empty() {
768            return Err(DdsError::BadParameter { what: "topic name" });
769        }
770        let deadline = std::time::Instant::now() + timeout;
771        // Check locally right away — avoids a busy-wait if the topic was
772        // already created locally via create_topic.
773        if let Some(h) = self.lookup_topicdescription(name) {
774            return Ok(h);
775        }
776        // Poll loop over the SEDP cache. The spec leaves the strategy
777        // open; Cyclone DDS polls as well.
778        let poll = core::time::Duration::from_millis(20);
779        loop {
780            if let Some(handle) = self.find_topic_in_sedp(name) {
781                return Ok(handle);
782            }
783            if std::time::Instant::now() >= deadline {
784                return Err(DdsError::Timeout);
785            }
786            std::thread::sleep(poll);
787        }
788    }
789
790    /// Helper: checks the SEDP cache for whether a remote endpoint
791    /// (publication or subscription) has announced a topic with the
792    /// name. Returns the first match (name + type name).
793    #[cfg(feature = "std")]
794    fn find_topic_in_sedp(&self, name: &str) -> Option<TopicDescriptionHandle> {
795        let rt = self.inner.runtime.as_ref()?;
796        let sedp = rt.sedp.lock().ok()?;
797        // Check publications first.
798        for p in sedp.cache().publications() {
799            if p.data.topic_name == name {
800                return Some(TopicDescriptionHandle::new(
801                    p.data.topic_name.clone(),
802                    p.data.type_name.clone(),
803                    self.clone(),
804                ));
805            }
806        }
807        for s in sedp.cache().subscriptions() {
808            if s.data.topic_name == name {
809                return Some(TopicDescriptionHandle::new(
810                    s.data.topic_name.clone(),
811                    s.data.type_name.clone(),
812                    self.clone(),
813                ));
814            }
815        }
816        None
817    }
818
819    /// Creates a `ContentFilteredTopic` as a subset of an existing
820    /// `Topic<T>`. Spec reference: OMG DDS 1.4 §2.2.2.2.1.13
821    /// `create_contentfilteredtopic`.
822    ///
823    /// The `filter_expression` is a SQL subset (see Annex B).
824    /// `filter_parameters` are strings that replace `%0`, `%1`, ... in
825    /// the expression.
826    ///
827    /// # Errors
828    /// - `BadParameter` for an empty name or empty expression.
829    /// - `BadParameter` if the filter expression does not parse.
830    /// - `BadParameter` if a referenced `%N` parameter is not supplied
831    ///   in the `filter_parameters` vec.
832    pub fn create_contentfilteredtopic<T: DdsType>(
833        &self,
834        name: &str,
835        related_topic: &Topic<T>,
836        filter_expression: &str,
837        filter_parameters: alloc::vec::Vec<String>,
838    ) -> Result<ContentFilteredTopic<T>> {
839        if name.is_empty() {
840            return Err(DdsError::BadParameter {
841                what: "content-filtered-topic name",
842            });
843        }
844        if filter_expression.is_empty() {
845            return Err(DdsError::BadParameter {
846                what: "filter expression",
847            });
848        }
849        ContentFilteredTopic::<T>::new(
850            name.into(),
851            related_topic.clone(),
852            filter_expression.into(),
853            filter_parameters,
854            self.clone(),
855        )
856    }
857
858    /// Creates a `MultiTopic` as a combining TopicDescription over 1+
859    /// underlying topics with a SQL subscription expression. Spec
860    /// reference: OMG DDS 1.4 §2.2.2.2.1.15 `create_multitopic`
861    /// (an optional spec feature).
862    ///
863    /// # Errors
864    /// - `BadParameter` for an empty name or type name.
865    /// - `BadParameter` if `related_topic_names` is empty.
866    /// - `BadParameter` if the subscription expression does not parse.
867    /// - `BadParameter` if a referenced `%N` parameter is not supplied
868    ///   in the `expression_parameters` vec.
869    pub fn create_multitopic<T: DdsType>(
870        &self,
871        name: &str,
872        type_name: &str,
873        related_topic_names: alloc::vec::Vec<String>,
874        subscription_expression: &str,
875        expression_parameters: alloc::vec::Vec<String>,
876    ) -> Result<crate::topic::MultiTopic<T>> {
877        if name.is_empty() {
878            return Err(DdsError::BadParameter {
879                what: "multitopic name",
880            });
881        }
882        if type_name.is_empty() {
883            return Err(DdsError::BadParameter {
884                what: "multitopic type_name",
885            });
886        }
887        if subscription_expression.is_empty() {
888            return Err(DdsError::BadParameter {
889                what: "multitopic subscription expression",
890            });
891        }
892        crate::topic::MultiTopic::<T>::new(
893            name.into(),
894            type_name.into(),
895            related_topic_names,
896            subscription_expression.into(),
897            expression_parameters,
898            self.clone(),
899        )
900    }
901
902    /// Deletes a `MultiTopic`. Spec §2.2.2.2.1.16 `delete_multitopic`.
903    /// In v1.2 it is a no-op shim with a participant match check.
904    ///
905    /// # Errors
906    /// `BadParameter` if the MultiTopic belongs to a different
907    /// participant.
908    pub fn delete_multitopic<T: DdsType>(&self, mt: &crate::topic::MultiTopic<T>) -> Result<()> {
909        if mt.get_participant().inner_ptr() != self.inner_ptr() {
910            return Err(DdsError::BadParameter {
911                what: "multitopic belongs to different participant",
912            });
913        }
914        Ok(())
915    }
916
917    /// Deletes a `ContentFilteredTopic`. Spec reference: §2.2.2.2.1.14
918    /// `delete_contentfilteredtopic`.
919    ///
920    /// In Rust, the CFT's lifetime handle is already covered by `Drop` —
921    /// the underlying resources are freed once the
922    /// `ContentFilteredTopic<T>` goes out of scope. This method exists
923    /// for spec compliance of the C++ API and validates the participant
924    /// match (the spec requires `BadParameter` if the CFT belongs to a
925    /// different participant).
926    ///
927    /// # Errors
928    /// - `BadParameter` if the CFT belongs to a different participant.
929    pub fn delete_contentfilteredtopic<T: DdsType>(
930        &self,
931        cft: &ContentFilteredTopic<T>,
932    ) -> Result<()> {
933        if cft.get_participant().inner_ptr() != self.inner_ptr() {
934            return Err(DdsError::BadParameter {
935                what: "cft belongs to different participant",
936            });
937        }
938        Ok(())
939    }
940
941    /// Internal identity pointer for participant comparison (used in
942    /// `delete_contentfilteredtopic` validation).
943    pub(crate) fn inner_ptr(&self) -> *const ParticipantInner {
944        Arc::as_ptr(&self.inner)
945    }
946
947    /// Creates a publisher with the given QoS (the default is enough for
948    /// v1.2).
949    pub fn create_publisher(&self, qos: PublisherQos) -> Publisher {
950        #[cfg(feature = "std")]
951        let p = {
952            let p = Publisher::new(qos, self.inner.runtime.clone());
953            // Wire up the (weak) bubble-up back-pointer, so that writer
954            // events reach the DomainParticipantListener.
955            p.attach_participant(Arc::downgrade(&self.inner));
956            p
957        };
958        #[cfg(not(feature = "std"))]
959        let p = Publisher::new(qos);
960        // Track the handle for contains_entity / delete_contained_entities.
961        if let Ok(mut list) = self.inner.publishers.lock() {
962            list.push(p.inner.entity_state.instance_handle());
963        }
964        p
965    }
966
967    /// Creates a subscriber.
968    pub fn create_subscriber(&self, qos: SubscriberQos) -> Subscriber {
969        #[cfg(feature = "std")]
970        let s = {
971            let s = Subscriber::new(qos, self.inner.runtime.clone());
972            // Wire up the (weak) bubble-up back-pointer.
973            s.attach_participant(Arc::downgrade(&self.inner));
974            s
975        };
976        #[cfg(not(feature = "std"))]
977        let s = Subscriber::new(qos);
978        if let Ok(mut list) = self.inner.subscribers.lock() {
979            list.push(s.inner.entity_state.instance_handle());
980        }
981        s
982    }
983
984    /// Number of currently registered topics. Diagnostic API.
985    #[must_use]
986    pub fn topics_len(&self) -> usize {
987        self.inner.topics.lock().map(|t| t.len()).unwrap_or(0)
988    }
989
990    /// Number of currently discovered remote participants via SPDP.
991    /// Spec: OMG DDS 1.4 §2.2.2.2.1.7 `get_discovered_participants`.
992    /// 0 in offline mode.
993    #[must_use]
994    pub fn discovered_participants_count(&self) -> usize {
995        #[cfg(feature = "std")]
996        if let Some(rt) = self.inner.runtime.as_ref() {
997            return rt.discovered_participants().len();
998        }
999        0
1000    }
1001
1002    /// Number of remote publications currently known in the SEDP cache.
1003    /// Spec: OMG DDS 1.4 §2.2.2.2.1.9 `get_discovered_topics` (~analogous).
1004    #[must_use]
1005    pub fn discovered_publications_count(&self) -> usize {
1006        #[cfg(feature = "std")]
1007        if let Some(rt) = self.inner.runtime.as_ref() {
1008            return rt.discovered_publications_count();
1009        }
1010        0
1011    }
1012
1013    /// Number of remote subscriptions currently known in the SEDP cache.
1014    #[must_use]
1015    pub fn discovered_subscriptions_count(&self) -> usize {
1016        #[cfg(feature = "std")]
1017        if let Some(rt) = self.inner.runtime.as_ref() {
1018            return rt.discovered_subscriptions_count();
1019        }
1020        0
1021    }
1022
1023    // ============================================================
1024    // ignore_* (DDS 1.4 §2.2.2.2.1.14-17)
1025    // ============================================================
1026
1027    /// Marks a discovered remote `DomainParticipant` as "ignored" — all
1028    /// further SPDP beacons with this handle drop out of the builtin
1029    /// reader stream, and at the same time all SEDP endpoints belonging
1030    /// to the same participant prefix are also discarded
1031    /// (Spec §2.2.2.2.1.14).
1032    ///
1033    /// Per spec the action is **monotonic** — a once-ignored participant
1034    /// stays ignored for the lifetime of this participant.
1035    ///
1036    /// # Errors
1037    /// Currently none — the method always returns `Ok(())`. The spec
1038    /// allows `OUT_OF_RESOURCES`, which we do not actively produce.
1039    pub fn ignore_participant(&self, handle: InstanceHandle) -> Result<()> {
1040        #[cfg(feature = "std")]
1041        if let Ok(mut s) = self.inner.ignore_filter.inner.participants.lock() {
1042            s.insert(handle);
1043        }
1044        Ok(())
1045    }
1046
1047    /// Marks a discovered remote topic as "ignored". Spec §2.2.2.2.1.15.
1048    ///
1049    /// # Errors
1050    /// As [`Self::ignore_participant`].
1051    pub fn ignore_topic(&self, handle: InstanceHandle) -> Result<()> {
1052        #[cfg(feature = "std")]
1053        if let Ok(mut s) = self.inner.ignore_filter.inner.topics.lock() {
1054            s.insert(handle);
1055        }
1056        Ok(())
1057    }
1058
1059    /// Marks a discovered remote publication as "ignored".
1060    /// Spec §2.2.2.2.1.16.
1061    ///
1062    /// # Errors
1063    /// As [`Self::ignore_participant`].
1064    pub fn ignore_publication(&self, handle: InstanceHandle) -> Result<()> {
1065        #[cfg(feature = "std")]
1066        if let Ok(mut s) = self.inner.ignore_filter.inner.publications.lock() {
1067            s.insert(handle);
1068        }
1069        Ok(())
1070    }
1071
1072    /// Marks a discovered remote subscription as "ignored".
1073    /// Spec §2.2.2.2.1.17.
1074    ///
1075    /// # Errors
1076    /// As [`Self::ignore_participant`].
1077    pub fn ignore_subscription(&self, handle: InstanceHandle) -> Result<()> {
1078        #[cfg(feature = "std")]
1079        if let Ok(mut s) = self.inner.ignore_filter.inner.subscriptions.lock() {
1080            s.insert(handle);
1081        }
1082        Ok(())
1083    }
1084
1085    /// `true` if `handle` was marked via `ignore_participant`.
1086    #[must_use]
1087    pub fn is_participant_ignored(&self, handle: InstanceHandle) -> bool {
1088        #[cfg(feature = "std")]
1089        return self.inner.ignore_filter.is_participant_ignored(handle);
1090        #[cfg(not(feature = "std"))]
1091        {
1092            let _ = handle;
1093            false
1094        }
1095    }
1096
1097    /// `true` if `handle` was marked via `ignore_topic`.
1098    #[must_use]
1099    pub fn is_topic_ignored(&self, handle: InstanceHandle) -> bool {
1100        #[cfg(feature = "std")]
1101        return self.inner.ignore_filter.is_topic_ignored(handle);
1102        #[cfg(not(feature = "std"))]
1103        {
1104            let _ = handle;
1105            false
1106        }
1107    }
1108
1109    /// `true` if `handle` was marked via `ignore_publication`.
1110    #[must_use]
1111    pub fn is_publication_ignored(&self, handle: InstanceHandle) -> bool {
1112        #[cfg(feature = "std")]
1113        return self.inner.ignore_filter.is_publication_ignored(handle);
1114        #[cfg(not(feature = "std"))]
1115        {
1116            let _ = handle;
1117            false
1118        }
1119    }
1120
1121    /// `true` if `handle` was marked via `ignore_subscription`.
1122    #[must_use]
1123    pub fn is_subscription_ignored(&self, handle: InstanceHandle) -> bool {
1124        #[cfg(feature = "std")]
1125        return self.inner.ignore_filter.is_subscription_ignored(handle);
1126        #[cfg(not(feature = "std"))]
1127        {
1128            let _ = handle;
1129            false
1130        }
1131    }
1132
1133    /// Internal access to the shared ignore filter — used by tests + the
1134    /// runtime discovery hook.
1135    #[cfg(feature = "std")]
1136    #[must_use]
1137    #[allow(dead_code)]
1138    pub(crate) fn ignore_filter(&self) -> IgnoreFilter {
1139        self.inner.ignore_filter.clone()
1140    }
1141
1142    // ============================================================
1143    // delete_contained_entities (DDS 1.4 §2.2.2.2.1.18)
1144    // ============================================================
1145
1146    /// Deletes **all** children held by the participant (publishers,
1147    /// subscribers, topics, builtin reader inboxes). Spec §2.2.2.2.1.18
1148    /// — an analogous counterpart exists in
1149    /// Publisher/Subscriber/DataReader, which is covered here
1150    /// recursively.
1151    ///
1152    /// Offline behavior:
1153    /// - Topic registry cleared (local topics).
1154    /// - Publisher/subscriber trackers cleared.
1155    /// - Builtin-topic reader inboxes cleared (so that `take()` after
1156    ///   `delete_contained_entities` returns an empty vec).
1157    /// - **No** SEDP unannounce — the live behavior handles that once
1158    ///   the runtime gets a `Drop`/`shutdown` handle. Current state: the
1159    ///   runtime thread runs until process exit.
1160    ///
1161    /// # Errors
1162    /// `PreconditionNotMet` if an internal mutex is poisoned.
1163    pub fn delete_contained_entities(&self) -> Result<()> {
1164        // Clear the topic registry.
1165        {
1166            let mut topics =
1167                self.inner
1168                    .topics
1169                    .lock()
1170                    .map_err(|_| DdsError::PreconditionNotMet {
1171                        reason: "topic registry poisoned",
1172                    })?;
1173            topics.clear();
1174        }
1175        // Clear the publisher/subscriber markers.
1176        if let Ok(mut p) = self.inner.publishers.lock() {
1177            p.clear();
1178        }
1179        if let Ok(mut s) = self.inner.subscribers.lock() {
1180            s.clear();
1181        }
1182        // Clear the builtin reader inboxes — after
1183        // delete_contained_entities() the user should see a clean
1184        // builtin subscriber that only delivers new (post-delete)
1185        // discovery events.
1186        let sinks = self.inner.builtin_subscriber.sinks();
1187        if let Ok(mut g) = sinks.participant.lock() {
1188            g.clear();
1189        }
1190        if let Ok(mut g) = sinks.topic.lock() {
1191            g.clear();
1192        }
1193        if let Ok(mut g) = sinks.publication.lock() {
1194            g.clear();
1195        }
1196        if let Ok(mut g) = sinks.subscription.lock() {
1197            g.clear();
1198        }
1199        Ok(())
1200    }
1201
1202    /// Number of publishers tracked via `create_publisher`. Diagnostic
1203    /// API for tests.
1204    #[must_use]
1205    pub fn publishers_len(&self) -> usize {
1206        self.inner.publishers.lock().map(|p| p.len()).unwrap_or(0)
1207    }
1208
1209    /// Number of subscribers tracked via `create_subscriber`.
1210    #[must_use]
1211    pub fn subscribers_len(&self) -> usize {
1212        self.inner.subscribers.lock().map(|s| s.len()).unwrap_or(0)
1213    }
1214
1215    /// Returns this participant's `InstanceHandle`. Identifies the entity
1216    /// to DCPS API consumers (Spec §2.2.2.1.1 `get_instance_handle`).
1217    #[must_use]
1218    pub fn instance_handle(&self) -> InstanceHandle {
1219        self.inner.entity_state.instance_handle()
1220    }
1221
1222    /// This participant's **discovery-space** handle: `InstanceHandle::from_guid`
1223    /// of its participant GUID.
1224    ///
1225    /// Unlike [`Self::instance_handle`] (a local allocator counter), this is the
1226    /// handle the ignore filter and discovery actually key on. It is therefore
1227    /// the correct argument to ignore THIS participant from another one — e.g. a
1228    /// durability service whose sibling ingest/replay participants must ignore
1229    /// each other to avoid an echo loop. `HANDLE_NIL` when offline.
1230    #[cfg(feature = "std")]
1231    #[must_use]
1232    pub fn participant_handle(&self) -> InstanceHandle {
1233        match self.inner.runtime.as_ref() {
1234            Some(rt) => {
1235                let guid = zerodds_rtps::wire_types::Guid::new(
1236                    rt.guid_prefix,
1237                    zerodds_rtps::wire_types::EntityId::PARTICIPANT,
1238                );
1239                crate::instance_handle::InstanceHandle::from_guid(guid)
1240            }
1241            None => crate::instance_handle::HANDLE_NIL,
1242        }
1243    }
1244
1245    /// Spec §2.2.2.2.1.10 `contains_entity` — `true` if `handle` belongs
1246    /// to this participant or one of its directly **or recursively**
1247    /// contained entities.
1248    ///
1249    /// **Included entity types:**
1250    /// - the participant itself
1251    /// - all topics registered via `create_topic`
1252    /// - all publishers/subscribers created via `create_publisher` /
1253    ///   `create_subscriber`
1254    /// - **recursively**: all DataWriter/DataReader created via
1255    ///   `Publisher::create_datawriter` / `Subscriber::create_datareader`.
1256    #[must_use]
1257    pub fn contains_entity(&self, handle: InstanceHandle) -> bool {
1258        if self.instance_handle() == handle {
1259            return true;
1260        }
1261        if let Ok(topics) = self.inner.topics.lock() {
1262            for t in topics.values() {
1263                if t.entity_state.instance_handle() == handle {
1264                    return true;
1265                }
1266            }
1267        }
1268        if let Ok(pubs) = self.inner.publishers.lock() {
1269            if pubs.contains(&handle) {
1270                return true;
1271            }
1272        }
1273        if let Ok(subs) = self.inner.subscribers.lock() {
1274            if subs.contains(&handle) {
1275                return true;
1276            }
1277        }
1278        if let Ok(dws) = self.inner.datawriters.lock() {
1279            if dws.contains(&handle) {
1280                return true;
1281            }
1282        }
1283        if let Ok(drs) = self.inner.datareaders.lock() {
1284            if drs.contains(&handle) {
1285                return true;
1286            }
1287        }
1288        false
1289    }
1290
1291    // ============================================================
1292    // get_discovered_* (DDS 1.4 §2.2.2.2.1.27-30)
1293    // ============================================================
1294
1295    /// Returns the `InstanceHandle`s of all currently discovered remote
1296    /// participants (Spec §2.2.2.2.1.27). Empty in offline mode. Ignored
1297    /// participants do **not** appear.
1298    #[cfg(feature = "std")]
1299    #[must_use]
1300    pub fn get_discovered_participants(&self) -> Vec<InstanceHandle> {
1301        let Some(rt) = self.inner.runtime.as_ref() else {
1302            return Vec::new();
1303        };
1304        let mut out = Vec::new();
1305        for d in rt.discovered_participants() {
1306            let h = InstanceHandle::from_guid(d.data.guid);
1307            if self.is_participant_ignored(h) {
1308                continue;
1309            }
1310            out.push(h);
1311        }
1312        out
1313    }
1314
1315    /// Offline variant (no std → no runtime).
1316    #[cfg(not(feature = "std"))]
1317    #[must_use]
1318    pub fn get_discovered_participants(&self) -> Vec<InstanceHandle> {
1319        Vec::new()
1320    }
1321
1322    /// Returns the `ParticipantBuiltinTopicData` for a handle from
1323    /// `get_discovered_participants` (Spec §2.2.2.2.1.28).
1324    ///
1325    /// # Errors
1326    /// `BadParameter` if `handle` does not reference a discovered
1327    /// participant (or if it was ignored).
1328    #[cfg(feature = "std")]
1329    pub fn get_discovered_participant_data(
1330        &self,
1331        handle: InstanceHandle,
1332    ) -> Result<ParticipantBuiltinTopicData> {
1333        if self.is_participant_ignored(handle) {
1334            return Err(DdsError::BadParameter {
1335                what: "participant handle is ignored",
1336            });
1337        }
1338        let Some(rt) = self.inner.runtime.as_ref() else {
1339            return Err(DdsError::BadParameter {
1340                what: "no runtime — offline participant",
1341            });
1342        };
1343        for d in rt.discovered_participants() {
1344            if InstanceHandle::from_guid(d.data.guid) == handle {
1345                return Ok(ParticipantBuiltinTopicData::from_wire(&d.data));
1346            }
1347        }
1348        Err(DdsError::BadParameter {
1349            what: "unknown participant handle",
1350        })
1351    }
1352
1353    /// Offline variant.
1354    #[cfg(not(feature = "std"))]
1355    pub fn get_discovered_participant_data(
1356        &self,
1357        _handle: InstanceHandle,
1358    ) -> Result<ParticipantBuiltinTopicData> {
1359        Err(DdsError::BadParameter {
1360            what: "no runtime — offline participant",
1361        })
1362    }
1363
1364    /// Returns the `InstanceHandle`s of all currently discovered remote
1365    /// topics. Spec §2.2.2.2.1.29.
1366    ///
1367    /// Topics are discovered indirectly via SEDP pub/sub announcements —
1368    /// per `(topic_name, type_name)` we synthesize a stable key via
1369    /// `TopicBuiltinTopicData::synthesize_key`. Ignored topics do not
1370    /// appear.
1371    #[cfg(feature = "std")]
1372    #[must_use]
1373    pub fn get_discovered_topics(&self) -> Vec<InstanceHandle> {
1374        let Some(rt) = self.inner.runtime.as_ref() else {
1375            return Vec::new();
1376        };
1377        let Ok(sedp) = rt.sedp.lock() else {
1378            return Vec::new();
1379        };
1380        let mut seen = BTreeSet::new();
1381        for p in sedp.cache().publications() {
1382            let key = TopicBuiltinTopicData::synthesize_key(&p.data.topic_name, &p.data.type_name);
1383            let h = InstanceHandle::from_guid(key);
1384            if self.is_topic_ignored(h) {
1385                continue;
1386            }
1387            seen.insert(h);
1388        }
1389        for s in sedp.cache().subscriptions() {
1390            let key = TopicBuiltinTopicData::synthesize_key(&s.data.topic_name, &s.data.type_name);
1391            let h = InstanceHandle::from_guid(key);
1392            if self.is_topic_ignored(h) {
1393                continue;
1394            }
1395            seen.insert(h);
1396        }
1397        seen.into_iter().collect()
1398    }
1399
1400    /// Offline variant.
1401    #[cfg(not(feature = "std"))]
1402    #[must_use]
1403    pub fn get_discovered_topics(&self) -> Vec<InstanceHandle> {
1404        Vec::new()
1405    }
1406
1407    /// Returns the `TopicBuiltinTopicData` for a handle from
1408    /// `get_discovered_topics`. Spec §2.2.2.2.1.30.
1409    ///
1410    /// # Errors
1411    /// `BadParameter` if `handle` does not correspond to a discovered
1412    /// topic (or was ignored).
1413    #[cfg(feature = "std")]
1414    pub fn get_discovered_topic_data(
1415        &self,
1416        handle: InstanceHandle,
1417    ) -> Result<TopicBuiltinTopicData> {
1418        if self.is_topic_ignored(handle) {
1419            return Err(DdsError::BadParameter {
1420                what: "topic handle is ignored",
1421            });
1422        }
1423        let Some(rt) = self.inner.runtime.as_ref() else {
1424            return Err(DdsError::BadParameter {
1425                what: "no runtime — offline participant",
1426            });
1427        };
1428        let Ok(sedp) = rt.sedp.lock() else {
1429            return Err(DdsError::PreconditionNotMet {
1430                reason: "sedp poisoned",
1431            });
1432        };
1433        // First match on the publication side.
1434        for p in sedp.cache().publications() {
1435            let topic = TopicBuiltinTopicData::from_publication(&p.data);
1436            if InstanceHandle::from_guid(topic.key) == handle {
1437                return Ok(topic);
1438            }
1439        }
1440        for s in sedp.cache().subscriptions() {
1441            let topic = TopicBuiltinTopicData::from_subscription(&s.data);
1442            if InstanceHandle::from_guid(topic.key) == handle {
1443                return Ok(topic);
1444            }
1445        }
1446        Err(DdsError::BadParameter {
1447            what: "unknown topic handle",
1448        })
1449    }
1450
1451    /// Offline variant.
1452    #[cfg(not(feature = "std"))]
1453    pub fn get_discovered_topic_data(
1454        &self,
1455        _handle: InstanceHandle,
1456    ) -> Result<TopicBuiltinTopicData> {
1457        Err(DdsError::BadParameter {
1458            what: "no runtime — offline participant",
1459        })
1460    }
1461
1462    /// The participant's builtin subscriber (DDS 1.4 §2.2.2.2.1.7).
1463    ///
1464    /// Always returns the same subscriber handle (exactly one builtin
1465    /// subscriber per participant). It contains 4 pre-created readers for
1466    /// the builtin topics:
1467    ///
1468    /// - `DCPSParticipant` → `ParticipantBuiltinTopicData`
1469    /// - `DCPSTopic` → `TopicBuiltinTopicData`
1470    /// - `DCPSPublication` → `PublicationBuiltinTopicData`
1471    /// - `DCPSSubscription` → `SubscriptionBuiltinTopicData`
1472    ///
1473    /// SPDP/SEDP receive internally triggers a sample insert that can be
1474    /// picked up via `take()`/`read()` (DDS 1.4 §2.2.5).
1475    ///
1476    /// # Example
1477    /// ```
1478    /// use zerodds_dcps::*;
1479    /// let participant = DomainParticipantFactory::instance()
1480    ///     .create_participant_offline(0, DomainParticipantQos::default());
1481    /// let bs = participant.get_builtin_subscriber();
1482    /// let r = bs
1483    ///     .lookup_datareader::<DcpsParticipantBuiltinTopicData>("DCPSParticipant")
1484    ///     .expect("builtin reader");
1485    /// // Initially empty (offline mode → no SPDP receives).
1486    /// assert!(r.take().expect("take").is_empty());
1487    /// ```
1488    #[must_use]
1489    pub fn get_builtin_subscriber(&self) -> Arc<BuiltinSubscriber> {
1490        Arc::clone(&self.inner.builtin_subscriber)
1491    }
1492
1493    // ============================================================
1494    // Listener-Slot (DDS 1.4 §2.2.2.2.3)
1495    // ============================================================
1496
1497    /// Sets the `DomainParticipantListener`. `listener=None` clears the
1498    /// slot. `mask` is the [`StatusMask`] that determines which status
1499    /// bits this listener consumes (Spec §2.2.4.2.3 bubble-up).
1500    pub fn set_listener(&self, listener: Option<ArcDomainParticipantListener>, mask: StatusMask) {
1501        if let Ok(mut slot) = self.inner.listener.lock() {
1502            *slot = listener.map(|l| (l, mask));
1503        }
1504        // Mirror the mask into the EntityState — for get_listener_mask().
1505        self.inner.entity_state.set_listener_mask(mask);
1506    }
1507
1508    /// Returns the currently installed listener clone, if present.
1509    /// Spec §2.2.2.2.3.x get_listener.
1510    #[must_use]
1511    pub fn get_listener(&self) -> Option<ArcDomainParticipantListener> {
1512        self.inner
1513            .listener
1514            .lock()
1515            .ok()
1516            .and_then(|s| s.as_ref().map(|(l, _)| Arc::clone(l)))
1517    }
1518
1519    /// Snapshot of the listener slot (listener + mask) — for the dispatch
1520    /// path. Clones the Arc under the mutex and releases the lock
1521    /// immediately (lock discipline: run callbacks outside).
1522    #[must_use]
1523    #[allow(dead_code)] // used via Topic::listener_chain (cfg(std))
1524    pub(crate) fn snapshot_listener(&self) -> Option<(ArcDomainParticipantListener, StatusMask)> {
1525        self.inner
1526            .listener
1527            .lock()
1528            .ok()
1529            .and_then(|s| s.as_ref().map(|(l, m)| (Arc::clone(l), *m)))
1530    }
1531}
1532
1533// ============================================================================
1534// Entity-Trait (DCPS §2.2.2.1) —
1535// ============================================================================
1536
1537impl crate::entity::Entity for DomainParticipant {
1538    type Qos = DomainParticipantQos;
1539
1540    fn get_qos(&self) -> Self::Qos {
1541        self.inner.qos.lock().map(|q| q.clone()).unwrap_or_default()
1542    }
1543
1544    fn set_qos(&self, qos: Self::Qos) -> Result<()> {
1545        // DomainParticipantQos: USER_DATA + ENTITY_FACTORY are all
1546        // Changeable=YES per Spec §2.2.3 — no immutable check needed.
1547        if let Ok(mut current) = self.inner.qos.lock() {
1548            *current = qos;
1549        }
1550        Ok(())
1551    }
1552
1553    fn enable(&self) -> Result<()> {
1554        self.inner.entity_state.enable();
1555        Ok(())
1556    }
1557
1558    fn entity_state(&self) -> Arc<crate::entity::EntityState> {
1559        Arc::clone(&self.inner.entity_state)
1560    }
1561}
1562
1563// ---- internal helpers ----
1564
1565fn topic_inner<T: DdsType>(t: &Topic<T>) -> Arc<TopicInner> {
1566    t.inner()
1567}
1568
1569/// Extracts the `EquivalenceHash` from a `TypeIdentifier`, if it is one
1570/// of the hash variants.
1571#[cfg(feature = "std")]
1572fn extract_equivalence_hash(
1573    ti: &zerodds_types::TypeIdentifier,
1574) -> Option<zerodds_types::EquivalenceHash> {
1575    use zerodds_types::TypeIdentifier;
1576    match ti {
1577        TypeIdentifier::EquivalenceHashMinimal(h) | TypeIdentifier::EquivalenceHashComplete(h) => {
1578            Some(*h)
1579        }
1580        _ => None,
1581    }
1582}
1583
1584fn reconstruct_topic<T: DdsType>(
1585    inner: Arc<TopicInner>,
1586    participant: DomainParticipant,
1587) -> Topic<T> {
1588    // The TopicInner itself is generic-agnostic (just name +
1589    // type-name string); we set up a new topic handle with the same
1590    // inner. `Topic::new` would create a new inner — but we want to
1591    // share the shared inner.
1592    Topic::<T>::from_inner(inner, participant)
1593}
1594
1595// Topic needs a `from_inner` constructor for this.
1596impl<T: DdsType> Topic<T> {
1597    pub(crate) fn from_inner(inner: Arc<TopicInner>, participant: DomainParticipant) -> Self {
1598        Self::_from_inner_impl(inner, participant)
1599    }
1600}
1601
1602// Since `Topic<T>` keeps its inner private, we also need a
1603// `_from_inner_impl` shortcut in the topic module. It is right next to
1604// the constructor.
1605
1606#[cfg(test)]
1607#[allow(clippy::expect_used, clippy::unwrap_used)]
1608mod tests {
1609    use super::*;
1610    use crate::dds_type::RawBytes;
1611
1612    #[test]
1613    fn participant_created_with_domain_id() {
1614        let p = DomainParticipant::new(42, DomainParticipantQos::default());
1615        assert_eq!(p.domain_id(), 42);
1616        assert_eq!(p.topics_len(), 0);
1617    }
1618
1619    /// Wave 4a (Spec `zerodds-zero-copy-1.0` §6): two GuidPrefixes in the
1620    /// same process share the host-id prefix → `is_same_host = true`.
1621    /// The PID bytes must correspond to `process::id()`.
1622    #[test]
1623    fn random_guid_prefixes_share_host_id_within_process() {
1624        let p1 = random_guid_prefix();
1625        let p2 = random_guid_prefix();
1626        assert_eq!(p1.host_id(), p2.host_id(), "same-host within process");
1627        assert!(p1.is_same_host(p2));
1628
1629        let pid_le = std::process::id().to_le_bytes();
1630        let bytes = p1.to_bytes();
1631        assert_eq!(&bytes[4..8], &pid_le, "PID bytes in prefix[4..8]");
1632
1633        // The counter + time bytes must make the two prefixes
1634        // distinguishable.
1635        assert_ne!(p1, p2, "two prefixes must be distinct");
1636    }
1637
1638    #[test]
1639    fn host_id_bytes_deterministic_within_process() {
1640        let a = host_id_bytes();
1641        let b = host_id_bytes();
1642        assert_eq!(a, b, "OnceLock-cached host-id must be stable");
1643    }
1644
1645    #[test]
1646    fn create_topic_stores_in_registry() {
1647        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1648        let t1 = p
1649            .create_topic::<RawBytes>("Chatter", TopicQos::default())
1650            .unwrap();
1651        let t2 = p
1652            .create_topic::<RawBytes>("Chatter", TopicQos::default())
1653            .unwrap();
1654        assert_eq!(t1.name(), t2.name());
1655        assert_eq!(p.topics_len(), 1);
1656    }
1657
1658    #[test]
1659    fn create_topic_rejects_type_conflict() {
1660        // A second DdsType for the test.
1661        #[derive(Debug)]
1662        struct DummyU32(u32);
1663        impl DdsType for DummyU32 {
1664            const TYPE_NAME: &'static str = "test::DummyU32";
1665            fn encode(
1666                &self,
1667                out: &mut alloc::vec::Vec<u8>,
1668            ) -> core::result::Result<(), crate::dds_type::EncodeError> {
1669                out.extend_from_slice(&self.0.to_le_bytes());
1670                Ok(())
1671            }
1672            fn decode(bytes: &[u8]) -> core::result::Result<Self, crate::dds_type::DecodeError> {
1673                if bytes.len() != 4 {
1674                    return Err(crate::dds_type::DecodeError::Invalid { what: "u32 len" });
1675                }
1676                let mut a = [0u8; 4];
1677                a.copy_from_slice(bytes);
1678                Ok(Self(u32::from_le_bytes(a)))
1679            }
1680        }
1681
1682        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1683        let _ = p
1684            .create_topic::<RawBytes>("X", TopicQos::default())
1685            .unwrap();
1686        let err = p
1687            .create_topic::<DummyU32>("X", TopicQos::default())
1688            .unwrap_err();
1689        assert!(matches!(err, DdsError::InconsistentPolicy { .. }));
1690    }
1691
1692    #[test]
1693    fn create_topic_rejects_empty_name() {
1694        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1695        let err = p
1696            .create_topic::<RawBytes>("", TopicQos::default())
1697            .unwrap_err();
1698        assert!(matches!(err, DdsError::BadParameter { .. }));
1699    }
1700
1701    #[test]
1702    fn lookup_topicdescription_returns_local_topics() {
1703        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1704        let _t = p
1705            .create_topic::<RawBytes>("Hello", TopicQos::default())
1706            .unwrap();
1707        let h = p.lookup_topicdescription("Hello").expect("local lookup");
1708        use crate::topic::TopicDescription as _;
1709        assert_eq!(h.get_name(), "Hello");
1710        assert_eq!(h.get_type_name(), RawBytes::TYPE_NAME);
1711        assert_eq!(h.get_participant().domain_id(), 0);
1712    }
1713
1714    #[test]
1715    fn lookup_topicdescription_none_for_unknown() {
1716        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1717        assert!(p.lookup_topicdescription("Unknown").is_none());
1718    }
1719
1720    // ---- §2.2.2.2.1.10 contains_entity ----
1721
1722    #[test]
1723    fn contains_entity_returns_true_for_self_handle() {
1724        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1725        let h = p.instance_handle();
1726        assert!(p.contains_entity(h));
1727    }
1728
1729    #[test]
1730    fn contains_entity_returns_true_for_local_topic() {
1731        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1732        let t = p
1733            .create_topic::<RawBytes>("Hi", TopicQos::default())
1734            .unwrap();
1735        let topic_handle = t.inner().entity_state.instance_handle();
1736        assert!(p.contains_entity(topic_handle));
1737    }
1738
1739    #[test]
1740    fn contains_entity_returns_true_for_local_publisher() {
1741        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1742        let pub_ = p.create_publisher(PublisherQos::default());
1743        let h = pub_.inner.entity_state.instance_handle();
1744        assert!(p.contains_entity(h));
1745    }
1746
1747    #[test]
1748    fn contains_entity_returns_true_for_local_subscriber() {
1749        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1750        let s = p.create_subscriber(SubscriberQos::default());
1751        let h = s.inner.entity_state.instance_handle();
1752        assert!(p.contains_entity(h));
1753    }
1754
1755    #[test]
1756    fn contains_entity_returns_false_for_unknown_handle() {
1757        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1758        // A different participant has a different handle.
1759        let other = DomainParticipant::new(0, DomainParticipantQos::default());
1760        let other_h = other.instance_handle();
1761        assert!(!p.contains_entity(other_h));
1762    }
1763
1764    #[test]
1765    fn contains_entity_returns_false_for_topic_after_delete() {
1766        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1767        let t = p
1768            .create_topic::<RawBytes>("Tmp", TopicQos::default())
1769            .unwrap();
1770        let topic_handle = t.inner().entity_state.instance_handle();
1771        assert!(p.contains_entity(topic_handle));
1772        p.delete_contained_entities().unwrap();
1773        assert!(!p.contains_entity(topic_handle));
1774    }
1775
1776    #[test]
1777    fn contains_entity_recursive_finds_local_datawriter() {
1778        // §2.2.2.2.1.10 — contains_entity MUST also recognize DataWriter
1779        // handles created via Publisher::create_datawriter.
1780        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1781        let topic = p
1782            .create_topic::<RawBytes>("Hello", TopicQos::default())
1783            .unwrap();
1784        let pub_ = p.create_publisher(PublisherQos::default());
1785        let dw = pub_
1786            .create_datawriter(&topic, crate::qos::DataWriterQos::default())
1787            .unwrap();
1788        let dw_handle = dw.instance_handle();
1789        assert!(p.contains_entity(dw_handle));
1790        // Plus: the publisher itself exposes contains_writer(handle).
1791        assert!(pub_.contains_writer(dw_handle));
1792    }
1793
1794    #[test]
1795    fn contains_entity_recursive_finds_local_datareader() {
1796        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1797        let topic = p
1798            .create_topic::<RawBytes>("Hello2", TopicQos::default())
1799            .unwrap();
1800        let sub = p.create_subscriber(SubscriberQos::default());
1801        let dr = sub
1802            .create_datareader(&topic, crate::qos::DataReaderQos::default())
1803            .unwrap();
1804        let dr_handle = dr.subscription_handle();
1805        assert!(p.contains_entity(dr_handle));
1806        assert!(sub.contains_reader(dr_handle));
1807    }
1808
1809    #[test]
1810    fn contains_entity_recursive_does_not_find_foreign_datawriter() {
1811        // Negative: a DW created via a different participant is NOT
1812        // contained.
1813        let p1 = DomainParticipant::new(0, DomainParticipantQos::default());
1814        let p2 = DomainParticipant::new(1, DomainParticipantQos::default());
1815        let topic = p2
1816            .create_topic::<RawBytes>("Foreign", TopicQos::default())
1817            .unwrap();
1818        let pub2 = p2.create_publisher(PublisherQos::default());
1819        let dw2 = pub2
1820            .create_datawriter(&topic, crate::qos::DataWriterQos::default())
1821            .unwrap();
1822        assert!(!p1.contains_entity(dw2.instance_handle()));
1823        assert!(p2.contains_entity(dw2.instance_handle()));
1824    }
1825
1826    #[cfg(feature = "std")]
1827    #[test]
1828    fn find_topic_returns_immediately_for_local() {
1829        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1830        let _t = p
1831            .create_topic::<RawBytes>("Local", TopicQos::default())
1832            .unwrap();
1833        let started = std::time::Instant::now();
1834        let h = p
1835            .find_topic("Local", core::time::Duration::from_secs(5))
1836            .expect("local find");
1837        // Should be well below the timeout — local is an immediate
1838        // return.
1839        assert!(started.elapsed() < core::time::Duration::from_millis(50));
1840        use crate::topic::TopicDescription as _;
1841        assert_eq!(h.get_name(), "Local");
1842    }
1843
1844    #[cfg(feature = "std")]
1845    #[test]
1846    fn find_topic_times_out_when_unknown() {
1847        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1848        let err = p
1849            .find_topic("NotExists", core::time::Duration::from_millis(80))
1850            .unwrap_err();
1851        assert!(matches!(err, DdsError::Timeout));
1852    }
1853
1854    #[cfg(feature = "std")]
1855    #[test]
1856    fn find_topic_rejects_empty_name() {
1857        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1858        let err = p
1859            .find_topic("", core::time::Duration::from_millis(10))
1860            .unwrap_err();
1861        assert!(matches!(err, DdsError::BadParameter { .. }));
1862    }
1863
1864    #[test]
1865    fn create_contentfilteredtopic_rejects_empty_name() {
1866        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1867        let topic = p
1868            .create_topic::<RawBytes>("Base", TopicQos::default())
1869            .unwrap();
1870        let err = p
1871            .create_contentfilteredtopic("", &topic, "x > 0", alloc::vec::Vec::new())
1872            .unwrap_err();
1873        assert!(matches!(err, DdsError::BadParameter { .. }));
1874    }
1875
1876    #[test]
1877    fn create_contentfilteredtopic_rejects_empty_expression() {
1878        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1879        let topic = p
1880            .create_topic::<RawBytes>("Base", TopicQos::default())
1881            .unwrap();
1882        let err = p
1883            .create_contentfilteredtopic("CF", &topic, "", alloc::vec::Vec::new())
1884            .unwrap_err();
1885        assert!(matches!(err, DdsError::BadParameter { .. }));
1886    }
1887
1888    #[test]
1889    fn delete_contentfilteredtopic_accepts_own() {
1890        let p = DomainParticipant::new(0, DomainParticipantQos::default());
1891        let topic = p
1892            .create_topic::<RawBytes>("Base", TopicQos::default())
1893            .unwrap();
1894        let cft = p
1895            .create_contentfilteredtopic("CF", &topic, "x > 0", alloc::vec::Vec::new())
1896            .unwrap();
1897        p.delete_contentfilteredtopic(&cft).unwrap();
1898    }
1899
1900    #[cfg(feature = "std")]
1901    #[test]
1902    fn find_topic_resolves_via_sedp_subscription() {
1903        // A variant of the discovery hook: this time we inject a
1904        // subscription (reader-side discovery), not a publication.
1905        // find_topic must find both.
1906        use crate::factory::DomainParticipantFactory;
1907        use core::time::Duration as CoreDur;
1908        use zerodds_rtps::publication_data::{DurabilityKind, ReliabilityKind, ReliabilityQos};
1909        use zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData;
1910        use zerodds_rtps::wire_types::{EntityId, Guid, GuidPrefix};
1911
1912        let p = DomainParticipantFactory::instance()
1913            .create_participant_with_config(
1914                43,
1915                DomainParticipantQos::default(),
1916                crate::runtime::RuntimeConfig::default(),
1917            )
1918            .expect("runtime start");
1919
1920        let target_topic = "DiscoveredViaSubSedp";
1921        if let Some(rt) = p.runtime() {
1922            if let Ok(mut sedp) = rt.sedp.lock() {
1923                let prefix = GuidPrefix::from_bytes([0xCD; 12]);
1924                let subdata = SubscriptionBuiltinTopicData {
1925                    key: Guid::new(prefix, EntityId::user_reader_with_key([4, 5, 6])),
1926                    participant_key: Guid::new(prefix, EntityId::PARTICIPANT),
1927                    topic_name: target_topic.into(),
1928                    type_name: "test::SubT".into(),
1929                    durability: DurabilityKind::Volatile,
1930                    reliability: ReliabilityQos {
1931                        kind: ReliabilityKind::Reliable,
1932                        max_blocking_time: zerodds_rtps::participant_data::Duration::from_secs(1),
1933                    },
1934                    ownership: zerodds_qos::OwnershipKind::Shared,
1935                    liveliness: zerodds_qos::LivelinessQosPolicy::default(),
1936                    deadline: zerodds_qos::DeadlineQosPolicy::default(),
1937                    partition: alloc::vec::Vec::new(),
1938                    user_data: alloc::vec::Vec::new(),
1939                    topic_data: alloc::vec::Vec::new(),
1940                    group_data: alloc::vec::Vec::new(),
1941                    type_information: None,
1942                    data_representation: alloc::vec::Vec::new(),
1943                    content_filter: None,
1944                    security_info: None,
1945                    service_instance_name: None,
1946                    related_entity_guid: None,
1947                    topic_aliases: None,
1948                    type_identifier: zerodds_types::TypeIdentifier::None,
1949                    unicast_locators: Vec::new(),
1950                    multicast_locators: Vec::new(),
1951                };
1952                sedp.cache_mut().insert_subscription(subdata, CoreDur::ZERO);
1953            }
1954        }
1955
1956        let h = p
1957            .find_topic(target_topic, CoreDur::from_millis(200))
1958            .expect("find via subscription");
1959        use crate::topic::TopicDescription as _;
1960        assert_eq!(h.get_name(), target_topic);
1961        assert_eq!(h.get_type_name(), "test::SubT");
1962    }
1963
1964    #[cfg(feature = "std")]
1965    #[test]
1966    fn find_topic_resolves_after_sedp_publication() {
1967        // Spec §2.2.2.2.1.11: find_topic must return as soon as a topic
1968        // is visible via discovery. We start a live participant (with a
1969        // real runtime) and inject a publication directly into the SEDP
1970        // cache, to verify the discovery hook without depending on the
1971        // UDP round-trip.
1972        use crate::factory::DomainParticipantFactory;
1973        use core::time::Duration as CoreDur;
1974        use zerodds_rtps::publication_data::{
1975            DurabilityKind, PublicationBuiltinTopicData, ReliabilityKind, ReliabilityQos,
1976        };
1977        use zerodds_rtps::wire_types::{EntityId, Guid, GuidPrefix};
1978
1979        let p = DomainParticipantFactory::instance()
1980            .create_participant_with_config(
1981                42,
1982                DomainParticipantQos::default(),
1983                crate::runtime::RuntimeConfig::default(),
1984            )
1985            .expect("runtime start");
1986
1987        let target_topic = "DiscoveredViaSedp";
1988        let target_type = "test::Discovered";
1989
1990        // Spawn a worker that, after a short delay, injects a publication
1991        // into the SEDP cache.
1992        let p_inject = p.clone();
1993        let topic_name = String::from(target_topic);
1994        let type_name = String::from(target_type);
1995        let join = std::thread::spawn(move || {
1996            std::thread::sleep(CoreDur::from_millis(50));
1997            if let Some(rt) = p_inject.runtime() {
1998                if let Ok(mut sedp) = rt.sedp.lock() {
1999                    let prefix = GuidPrefix::from_bytes([0xAB; 12]);
2000                    let pubdata = PublicationBuiltinTopicData {
2001                        key: Guid::new(prefix, EntityId::user_writer_with_key([1, 2, 3])),
2002                        participant_key: Guid::new(prefix, EntityId::PARTICIPANT),
2003                        topic_name,
2004                        type_name,
2005                        durability: DurabilityKind::Volatile,
2006                        reliability: ReliabilityQos {
2007                            kind: ReliabilityKind::Reliable,
2008                            max_blocking_time: zerodds_rtps::participant_data::Duration::from_secs(
2009                                1,
2010                            ),
2011                        },
2012                        ownership: zerodds_qos::OwnershipKind::Shared,
2013                        ownership_strength: 0,
2014                        liveliness: zerodds_qos::LivelinessQosPolicy::default(),
2015                        deadline: zerodds_qos::DeadlineQosPolicy::default(),
2016                        lifespan: zerodds_qos::LifespanQosPolicy::default(),
2017                        partition: alloc::vec::Vec::new(),
2018                        user_data: alloc::vec::Vec::new(),
2019                        topic_data: alloc::vec::Vec::new(),
2020                        group_data: alloc::vec::Vec::new(),
2021                        type_information: None,
2022                        data_representation: alloc::vec::Vec::new(),
2023                        security_info: None,
2024                        service_instance_name: None,
2025                        related_entity_guid: None,
2026                        topic_aliases: None,
2027                        type_identifier: zerodds_types::TypeIdentifier::None,
2028                        unicast_locators: Vec::new(),
2029                        multicast_locators: Vec::new(),
2030                    };
2031                    sedp.cache_mut().insert_publication(pubdata, CoreDur::ZERO);
2032                }
2033            }
2034        });
2035
2036        let result = p.find_topic(target_topic, CoreDur::from_secs(2));
2037        join.join().expect("inject thread");
2038        let h = result.expect("find_topic should resolve via SEDP");
2039        use crate::topic::TopicDescription as _;
2040        assert_eq!(h.get_name(), target_topic);
2041        assert_eq!(h.get_type_name(), target_type);
2042    }
2043
2044    // ============================================================
2045    // ignore_* / delete_contained_entities / get_discovered_*
2046    // ============================================================
2047
2048    #[test]
2049    fn ignore_participant_records_handle() {
2050        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2051        let h = InstanceHandle::from_raw(0xAA);
2052        assert!(!p.is_participant_ignored(h));
2053        p.ignore_participant(h).unwrap();
2054        assert!(p.is_participant_ignored(h));
2055    }
2056
2057    #[test]
2058    fn ignore_topic_records_handle() {
2059        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2060        let h = InstanceHandle::from_raw(0xBB);
2061        assert!(!p.is_topic_ignored(h));
2062        p.ignore_topic(h).unwrap();
2063        assert!(p.is_topic_ignored(h));
2064    }
2065
2066    #[test]
2067    fn ignore_publication_records_handle() {
2068        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2069        let h = InstanceHandle::from_raw(0xCC);
2070        assert!(!p.is_publication_ignored(h));
2071        p.ignore_publication(h).unwrap();
2072        assert!(p.is_publication_ignored(h));
2073    }
2074
2075    #[test]
2076    fn ignore_subscription_records_handle() {
2077        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2078        let h = InstanceHandle::from_raw(0xDD);
2079        assert!(!p.is_subscription_ignored(h));
2080        p.ignore_subscription(h).unwrap();
2081        assert!(p.is_subscription_ignored(h));
2082    }
2083
2084    #[test]
2085    fn ignore_lists_are_independent() {
2086        // Spec §2.2.2.2.1.14-17: each ignore_* list lives on its own; a
2087        // handle in the topic list does not appear in the participant
2088        // list.
2089        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2090        let h = InstanceHandle::from_raw(0xEE);
2091        p.ignore_topic(h).unwrap();
2092        assert!(p.is_topic_ignored(h));
2093        assert!(!p.is_participant_ignored(h));
2094        assert!(!p.is_publication_ignored(h));
2095        assert!(!p.is_subscription_ignored(h));
2096    }
2097
2098    #[test]
2099    fn ignore_is_monotonic_and_idempotent() {
2100        // A double ignore_participant must not turn into an error, and
2101        // the filter state must not "reverse".
2102        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2103        let h = InstanceHandle::from_raw(0x42);
2104        p.ignore_participant(h).unwrap();
2105        p.ignore_participant(h).unwrap();
2106        assert!(p.is_participant_ignored(h));
2107    }
2108
2109    #[test]
2110    fn delete_contained_entities_clears_topics_and_groups() {
2111        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2112        let _t = p
2113            .create_topic::<RawBytes>("ToBeRemoved", TopicQos::default())
2114            .unwrap();
2115        let _pub_ = p.create_publisher(PublisherQos::default());
2116        let _sub_ = p.create_subscriber(SubscriberQos::default());
2117        assert_eq!(p.topics_len(), 1);
2118        assert_eq!(p.publishers_len(), 1);
2119        assert_eq!(p.subscribers_len(), 1);
2120        p.delete_contained_entities().unwrap();
2121        assert_eq!(p.topics_len(), 0);
2122        assert_eq!(p.publishers_len(), 0);
2123        assert_eq!(p.subscribers_len(), 0);
2124    }
2125
2126    #[test]
2127    fn delete_contained_entities_clears_builtin_reader_inboxes() {
2128        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2129        // Manually inject a builtin sample, so that after the clear we
2130        // can compare against 0.
2131        use crate::builtin_topics::ParticipantBuiltinTopicData as DcpsP;
2132        use zerodds_rtps::wire_types::Guid;
2133        let bs = p.get_builtin_subscriber();
2134        bs.sinks()
2135            .push_participant(&DcpsP {
2136                key: Guid::from_bytes([7u8; 16]),
2137                user_data: alloc::vec::Vec::new(),
2138            })
2139            .unwrap();
2140        let r = bs.participant_reader();
2141        assert_eq!(r.read().unwrap().len(), 1);
2142        p.delete_contained_entities().unwrap();
2143        assert_eq!(r.read().unwrap().len(), 0);
2144    }
2145
2146    #[cfg(feature = "std")]
2147    #[test]
2148    fn get_discovered_participants_offline_is_empty() {
2149        // Without a runtime, get_discovered_participants returns an empty
2150        // vec — Spec §2.2.2.2.1.27 allows that.
2151        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2152        assert!(p.get_discovered_participants().is_empty());
2153    }
2154
2155    #[cfg(feature = "std")]
2156    #[test]
2157    fn get_discovered_participant_data_offline_errors() {
2158        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2159        let err = p
2160            .get_discovered_participant_data(InstanceHandle::from_raw(1))
2161            .unwrap_err();
2162        assert!(matches!(err, DdsError::BadParameter { .. }));
2163    }
2164
2165    #[cfg(feature = "std")]
2166    #[test]
2167    fn get_discovered_topics_offline_is_empty() {
2168        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2169        assert!(p.get_discovered_topics().is_empty());
2170    }
2171
2172    #[cfg(feature = "std")]
2173    #[test]
2174    fn get_discovered_topic_data_offline_errors() {
2175        let p = DomainParticipant::new(0, DomainParticipantQos::default());
2176        let err = p
2177            .get_discovered_topic_data(InstanceHandle::from_raw(1))
2178            .unwrap_err();
2179        assert!(matches!(err, DdsError::BadParameter { .. }));
2180    }
2181
2182    #[cfg(feature = "std")]
2183    #[test]
2184    fn get_discovered_participants_lists_after_spdp_inject() {
2185        // End-to-end: a live participant + one synthetic SPDP beacon of a
2186        // remote participant → get_discovered_participants returns
2187        // exactly one handle, get_discovered_participant_data finds the
2188        // matching wire data.
2189        use crate::factory::DomainParticipantFactory;
2190        let p = DomainParticipantFactory::instance()
2191            .create_participant_with_config(
2192                30,
2193                DomainParticipantQos::default(),
2194                crate::runtime::RuntimeConfig::default(),
2195            )
2196            .expect("rt start");
2197
2198        // Inject directly into the discovered cache via the
2199        // handle_spdp_datagram path. We build a synthetic beacon with the
2200        // same helper as the runtime tests.
2201        use zerodds_rtps::participant_data::ParticipantBuiltinTopicData as WirePart;
2202        use zerodds_rtps::wire_types::{EntityId, Guid, GuidPrefix, ProtocolVersion, VendorId};
2203        let remote = GuidPrefix::from_bytes([0xCA; 12]);
2204        let wire = WirePart {
2205            guid: Guid::new(remote, EntityId::PARTICIPANT),
2206            protocol_version: ProtocolVersion::V2_5,
2207            vendor_id: VendorId::ZERODDS,
2208            default_unicast_locator: None,
2209            default_multicast_locator: None,
2210            metatraffic_unicast_locator: None,
2211            metatraffic_multicast_locator: None,
2212            domain_id: Some(30),
2213            builtin_endpoint_set: 0,
2214            lease_duration: zerodds_rtps::participant_data::Duration::from_secs(100),
2215            user_data: alloc::vec::Vec::new(),
2216            properties: Default::default(),
2217            identity_token: None,
2218            permissions_token: None,
2219            identity_status_token: None,
2220            sig_algo_info: None,
2221            kx_algo_info: None,
2222            sym_cipher_algo_info: None,
2223            participant_security_info: None,
2224        };
2225        let beacon = zerodds_discovery::spdp::SpdpBeacon::new(wire.clone())
2226            .serialize()
2227            .expect("serialize");
2228        if let Some(rt) = p.runtime() {
2229            crate::runtime::handle_spdp_datagram_for_test(rt, &beacon);
2230        }
2231
2232        let handles = p.get_discovered_participants();
2233        assert_eq!(handles.len(), 1);
2234        let data = p
2235            .get_discovered_participant_data(handles[0])
2236            .expect("data lookup");
2237        assert_eq!(data.key, wire.guid);
2238        // Ignore → empty list.
2239        p.ignore_participant(handles[0]).unwrap();
2240        assert!(p.get_discovered_participants().is_empty());
2241        let err = p.get_discovered_participant_data(handles[0]).unwrap_err();
2242        assert!(matches!(err, DdsError::BadParameter { .. }));
2243    }
2244
2245    #[cfg(feature = "std")]
2246    #[test]
2247    fn get_discovered_topics_lists_unique_handles_for_pub_and_sub() {
2248        // Pub + Sub on the same (topic, type) → one topic handle.
2249        use crate::factory::DomainParticipantFactory;
2250        use core::time::Duration as CoreDur;
2251        use zerodds_rtps::publication_data::{
2252            DurabilityKind, PublicationBuiltinTopicData, ReliabilityKind, ReliabilityQos,
2253        };
2254        use zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData;
2255        use zerodds_rtps::wire_types::{EntityId, Guid, GuidPrefix};
2256
2257        let p = DomainParticipantFactory::instance()
2258            .create_participant_with_config(
2259                21,
2260                DomainParticipantQos::default(),
2261                crate::runtime::RuntimeConfig::default(),
2262            )
2263            .expect("rt start");
2264        if let Some(rt) = p.runtime() {
2265            if let Ok(mut sedp) = rt.sedp.lock() {
2266                let prefix = GuidPrefix::from_bytes([0x77; 12]);
2267                let pubdata = PublicationBuiltinTopicData {
2268                    key: Guid::new(prefix, EntityId::user_writer_with_key([1, 2, 3])),
2269                    participant_key: Guid::new(prefix, EntityId::PARTICIPANT),
2270                    topic_name: "SharedTopic".into(),
2271                    type_name: "SharedType".into(),
2272                    durability: DurabilityKind::Volatile,
2273                    reliability: ReliabilityQos {
2274                        kind: ReliabilityKind::Reliable,
2275                        max_blocking_time: zerodds_rtps::participant_data::Duration::from_secs(1),
2276                    },
2277                    ownership: zerodds_qos::OwnershipKind::Shared,
2278                    ownership_strength: 0,
2279                    liveliness: zerodds_qos::LivelinessQosPolicy::default(),
2280                    deadline: zerodds_qos::DeadlineQosPolicy::default(),
2281                    lifespan: zerodds_qos::LifespanQosPolicy::default(),
2282                    partition: alloc::vec::Vec::new(),
2283                    user_data: alloc::vec::Vec::new(),
2284                    topic_data: alloc::vec::Vec::new(),
2285                    group_data: alloc::vec::Vec::new(),
2286                    type_information: None,
2287                    data_representation: alloc::vec::Vec::new(),
2288                    security_info: None,
2289                    service_instance_name: None,
2290                    related_entity_guid: None,
2291                    topic_aliases: None,
2292                    type_identifier: zerodds_types::TypeIdentifier::None,
2293                    unicast_locators: Vec::new(),
2294                    multicast_locators: Vec::new(),
2295                };
2296                let subdata = SubscriptionBuiltinTopicData {
2297                    key: Guid::new(prefix, EntityId::user_reader_with_key([4, 5, 6])),
2298                    participant_key: Guid::new(prefix, EntityId::PARTICIPANT),
2299                    topic_name: "SharedTopic".into(),
2300                    type_name: "SharedType".into(),
2301                    durability: DurabilityKind::Volatile,
2302                    reliability: ReliabilityQos {
2303                        kind: ReliabilityKind::Reliable,
2304                        max_blocking_time: zerodds_rtps::participant_data::Duration::from_secs(1),
2305                    },
2306                    ownership: zerodds_qos::OwnershipKind::Shared,
2307                    liveliness: zerodds_qos::LivelinessQosPolicy::default(),
2308                    deadline: zerodds_qos::DeadlineQosPolicy::default(),
2309                    partition: alloc::vec::Vec::new(),
2310                    user_data: alloc::vec::Vec::new(),
2311                    topic_data: alloc::vec::Vec::new(),
2312                    group_data: alloc::vec::Vec::new(),
2313                    type_information: None,
2314                    data_representation: alloc::vec::Vec::new(),
2315                    content_filter: None,
2316                    security_info: None,
2317                    service_instance_name: None,
2318                    related_entity_guid: None,
2319                    topic_aliases: None,
2320                    type_identifier: zerodds_types::TypeIdentifier::None,
2321                    unicast_locators: Vec::new(),
2322                    multicast_locators: Vec::new(),
2323                };
2324                sedp.cache_mut().insert_publication(pubdata, CoreDur::ZERO);
2325                sedp.cache_mut().insert_subscription(subdata, CoreDur::ZERO);
2326            }
2327        }
2328        let topics = p.get_discovered_topics();
2329        assert_eq!(topics.len(), 1, "Pub+Sub on same topic -> 1 handle");
2330        let data = p.get_discovered_topic_data(topics[0]).expect("topic data");
2331        assert_eq!(data.name, "SharedTopic");
2332        assert_eq!(data.type_name, "SharedType");
2333    }
2334
2335    #[cfg(feature = "std")]
2336    #[test]
2337    fn get_discovered_topic_data_filters_ignored() {
2338        use crate::factory::DomainParticipantFactory;
2339        use core::time::Duration as CoreDur;
2340        use zerodds_rtps::publication_data::{
2341            DurabilityKind, PublicationBuiltinTopicData, ReliabilityKind, ReliabilityQos,
2342        };
2343        use zerodds_rtps::wire_types::{EntityId, Guid, GuidPrefix};
2344
2345        let p = DomainParticipantFactory::instance()
2346            .create_participant_with_config(
2347                22,
2348                DomainParticipantQos::default(),
2349                crate::runtime::RuntimeConfig::default(),
2350            )
2351            .expect("rt start");
2352        if let Some(rt) = p.runtime() {
2353            if let Ok(mut sedp) = rt.sedp.lock() {
2354                let prefix = GuidPrefix::from_bytes([0x55; 12]);
2355                let pubdata = PublicationBuiltinTopicData {
2356                    key: Guid::new(prefix, EntityId::user_writer_with_key([1, 2, 3])),
2357                    participant_key: Guid::new(prefix, EntityId::PARTICIPANT),
2358                    topic_name: "ToIgnore".into(),
2359                    type_name: "T".into(),
2360                    durability: DurabilityKind::Volatile,
2361                    reliability: ReliabilityQos {
2362                        kind: ReliabilityKind::Reliable,
2363                        max_blocking_time: zerodds_rtps::participant_data::Duration::from_secs(1),
2364                    },
2365                    ownership: zerodds_qos::OwnershipKind::Shared,
2366                    ownership_strength: 0,
2367                    liveliness: zerodds_qos::LivelinessQosPolicy::default(),
2368                    deadline: zerodds_qos::DeadlineQosPolicy::default(),
2369                    lifespan: zerodds_qos::LifespanQosPolicy::default(),
2370                    partition: alloc::vec::Vec::new(),
2371                    user_data: alloc::vec::Vec::new(),
2372                    topic_data: alloc::vec::Vec::new(),
2373                    group_data: alloc::vec::Vec::new(),
2374                    type_information: None,
2375                    data_representation: alloc::vec::Vec::new(),
2376                    security_info: None,
2377                    service_instance_name: None,
2378                    related_entity_guid: None,
2379                    topic_aliases: None,
2380                    type_identifier: zerodds_types::TypeIdentifier::None,
2381                    unicast_locators: Vec::new(),
2382                    multicast_locators: Vec::new(),
2383                };
2384                sedp.cache_mut().insert_publication(pubdata, CoreDur::ZERO);
2385            }
2386        }
2387        let topics_before = p.get_discovered_topics();
2388        assert_eq!(topics_before.len(), 1);
2389        // Now ignore the topic — get_discovered_topics must no longer
2390        // list it, get_discovered_topic_data must return BadParameter.
2391        p.ignore_topic(topics_before[0]).unwrap();
2392        assert!(p.get_discovered_topics().is_empty());
2393        let err = p.get_discovered_topic_data(topics_before[0]).unwrap_err();
2394        assert!(matches!(err, DdsError::BadParameter { .. }));
2395    }
2396
2397    #[test]
2398    fn delete_contentfilteredtopic_rejects_foreign() {
2399        let p1 = DomainParticipant::new(0, DomainParticipantQos::default());
2400        let p2 = DomainParticipant::new(1, DomainParticipantQos::default());
2401        let topic = p1
2402            .create_topic::<RawBytes>("Base", TopicQos::default())
2403            .unwrap();
2404        let cft = p1
2405            .create_contentfilteredtopic("CF", &topic, "x > 0", alloc::vec::Vec::new())
2406            .unwrap();
2407        let err = p2.delete_contentfilteredtopic(&cft).unwrap_err();
2408        assert!(matches!(err, DdsError::BadParameter { .. }));
2409    }
2410}