zerodds_dcps/factory.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! `DomainParticipantFactory` — singleton for creating participants
4//! (Spec OMG DDS 1.4 §2.2.2.2.1).
5//!
6//! The spec requires: "The DomainParticipantFactory is a singleton.
7//! The get_instance() method returns a reference to the only
8//! instance of the factory."
9//!
10//! Singleton via `OnceLock`. `create_participant(domain_id, qos)`
11//! creates a new `DomainParticipant`, starts a live runtime, and
12//! returns a cloneable handle. In addition there is
13//! `create_participant_offline(domain_id, qos)` for skeleton tests
14//! without a network, and `create_participant_with_config` for tests
15//! with a custom `RuntimeConfig`.
16
17extern crate alloc;
18
19use alloc::collections::BTreeMap;
20use alloc::vec::Vec;
21
22#[cfg(feature = "std")]
23use std::sync::{Mutex, OnceLock, Weak};
24
25use crate::error::{DdsError, Result};
26use crate::participant::{DomainId, DomainParticipant, ParticipantInner};
27use crate::qos::DomainParticipantQos;
28use crate::runtime::RuntimeConfig;
29
30/// QoS policy for the DomainParticipantFactory itself (Spec
31/// §2.2.2.2.2.6 `DomainParticipantFactoryQos`). Currently a single
32/// field `autoenable_created_entities` (default `true`); later spec
33/// extensions are added here.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct DomainParticipantFactoryQos {
36 /// If `true`, newly created entities are automatically `enable()`-d
37 /// on creation. Spec default: `true`.
38 pub autoenable_created_entities: bool,
39}
40
41impl Default for DomainParticipantFactoryQos {
42 fn default() -> Self {
43 Self {
44 autoenable_created_entities: true,
45 }
46 }
47}
48
49/// Factory singleton.
50#[cfg(feature = "std")]
51#[derive(Debug)]
52pub struct DomainParticipantFactory {
53 /// Registry of all participants created via `create_participant*` —
54 /// indexed by `DomainId`, multiple participants per domain allowed
55 /// (Spec §2.2.2.2.2.4 `lookup_participant` returns "any" participant for
56 /// the given domain).
57 ///
58 /// Stored as **weak** references so the factory never keeps a participant
59 /// alive past the user's last strong handle: dropping the user's
60 /// `DomainParticipant` releases its runtime (threads + sockets) via RAII.
61 /// `lookup_participant` upgrades on demand and garbage-collects dead weaks;
62 /// `delete_participant` stays available for explicit, spec-symmetric cleanup
63 /// but is a no-op once the participant has already dropped.
64 participants: Mutex<BTreeMap<DomainId, Vec<Weak<ParticipantInner>>>>,
65 /// Factory default QoS for newly created participants (Spec
66 /// §2.2.2.2.2.5 `set_default_participant_qos`).
67 default_participant_qos: Mutex<DomainParticipantQos>,
68 /// Factory's own QoS (Spec §2.2.2.2.2.6 `set_qos`/`get_qos`).
69 factory_qos: Mutex<DomainParticipantFactoryQos>,
70}
71
72#[cfg(not(feature = "std"))]
73#[derive(Debug, Default)]
74pub struct DomainParticipantFactory {}
75
76#[cfg(feature = "std")]
77impl DomainParticipantFactory {
78 /// Returns the process-wide factory singleton (Spec §2.2.2.2.2.1
79 /// `get_instance`).
80 pub fn instance() -> &'static Self {
81 static INSTANCE: OnceLock<DomainParticipantFactory> = OnceLock::new();
82 INSTANCE.get_or_init(|| Self {
83 participants: Mutex::new(BTreeMap::new()),
84 default_participant_qos: Mutex::new(DomainParticipantQos::default()),
85 factory_qos: Mutex::new(DomainParticipantFactoryQos::default()),
86 })
87 }
88
89 fn track(&self, p: &DomainParticipant) {
90 if let Ok(mut reg) = self.participants.lock() {
91 reg.entry(p.domain_id()).or_default().push(p.downgrade());
92 }
93 }
94
95 /// Creates a new `DomainParticipant` for the given domain id.
96 /// Starts the `DcpsRuntime` with the default config — UDP sockets +
97 /// SPDP/SEDP threads.
98 ///
99 /// # Errors
100 /// `DdsError::TransportError` if the UDP sockets do not bind.
101 pub fn create_participant(
102 &self,
103 domain_id: DomainId,
104 qos: DomainParticipantQos,
105 ) -> Result<DomainParticipant> {
106 // DDS 1.4 §2.2.3.1 UserDataQosPolicy → SPDP-Beacon PID_USER_DATA.
107 let config = RuntimeConfig {
108 user_data: qos.user_data.value.clone(),
109 ..RuntimeConfig::default()
110 };
111 let p = DomainParticipant::new_with_runtime(domain_id, qos, config)?;
112 self.track(&p);
113 Ok(p)
114 }
115
116 /// Variant with an explicitly passed `RuntimeConfig` (e.g. for
117 /// tests with short SPDP periods).
118 ///
119 /// # Errors
120 /// `DdsError::TransportError` if the UDP sockets do not bind.
121 pub fn create_participant_with_config(
122 &self,
123 domain_id: DomainId,
124 qos: DomainParticipantQos,
125 config: RuntimeConfig,
126 ) -> Result<DomainParticipant> {
127 let p = DomainParticipant::new_with_runtime(domain_id, qos, config)?;
128 self.track(&p);
129 Ok(p)
130 }
131
132 /// Offline variant without a runtime — only for unit tests that
133 /// don't want a network. The returned participant can create
134 /// topics, but no DataWriters/Readers.
135 #[must_use]
136 pub fn create_participant_offline(
137 &self,
138 domain_id: DomainId,
139 qos: DomainParticipantQos,
140 ) -> DomainParticipant {
141 let p = DomainParticipant::new(domain_id, qos);
142 self.track(&p);
143 p
144 }
145
146 /// Spec §2.2.2.2.2.4 `lookup_participant(domain_id)` — returns a
147 /// previously created participant for the same domain id, or `None`
148 /// if none is registered. With several participants for the same
149 /// domain, the implementation returns the first.
150 #[must_use]
151 pub fn lookup_participant(&self, domain_id: DomainId) -> Option<DomainParticipant> {
152 let mut reg = self.participants.lock().ok()?;
153 let mut found = None;
154 let mut now_empty = false;
155 if let Some(vec) = reg.get_mut(&domain_id) {
156 // Upgrade weaks; return the first still-live participant and garbage
157 // -collect any that the user has already dropped.
158 vec.retain(|w| match w.upgrade() {
159 Some(inner) => {
160 if found.is_none() {
161 found = Some(DomainParticipant::from_inner(inner));
162 }
163 true
164 }
165 None => false,
166 });
167 now_empty = vec.is_empty();
168 }
169 if now_empty {
170 reg.remove(&domain_id);
171 }
172 found
173 }
174
175 /// Spec §2.2.2.2.2.3 `delete_participant`. Removes the participant
176 /// from the factory registry and calls `delete_contained_entities`.
177 /// Returns `PreconditionNotMet` if the participant is not in the
178 /// registry.
179 ///
180 /// # Errors
181 /// `DdsError::PreconditionNotMet` if the participant is not
182 /// registered.
183 pub fn delete_participant(&self, p: &DomainParticipant) -> Result<()> {
184 let mut reg = self
185 .participants
186 .lock()
187 .map_err(|_| DdsError::PreconditionNotMet {
188 reason: "factory participants poisoned",
189 })?;
190 let target_handle = p.instance_handle();
191 let did = p.domain_id();
192 let mut found = false;
193 let mut now_empty = false;
194 if let Some(vec) = reg.get_mut(&did) {
195 // Remove the target (matched by handle through an upgrade) and GC any
196 // already-dropped weaks while we hold the lock.
197 vec.retain(|w| match w.upgrade() {
198 Some(inner) => {
199 let is_target =
200 DomainParticipant::from_inner(inner).instance_handle() == target_handle;
201 if is_target {
202 found = true;
203 }
204 !is_target
205 }
206 None => false,
207 });
208 now_empty = vec.is_empty();
209 }
210 if now_empty {
211 reg.remove(&did);
212 }
213 drop(reg);
214 if !found {
215 return Err(DdsError::PreconditionNotMet {
216 reason: "participant not registered with this factory",
217 });
218 }
219 p.delete_contained_entities()
220 }
221
222 /// Spec §2.2.2.2.2.5 `set_default_participant_qos` — default QoS
223 /// for participants created from now on.
224 ///
225 /// # Errors
226 /// `DdsError::PreconditionNotMet` on lock poisoning.
227 pub fn set_default_participant_qos(&self, qos: DomainParticipantQos) -> Result<()> {
228 let mut current =
229 self.default_participant_qos
230 .lock()
231 .map_err(|_| DdsError::PreconditionNotMet {
232 reason: "default qos poisoned",
233 })?;
234 *current = qos;
235 Ok(())
236 }
237
238 /// Spec §2.2.2.2.2.5 `get_default_participant_qos`.
239 #[must_use]
240 pub fn get_default_participant_qos(&self) -> DomainParticipantQos {
241 self.default_participant_qos
242 .lock()
243 .map(|q| q.clone())
244 .unwrap_or_default()
245 }
246
247 /// Spec §2.2.2.2.2.6 `set_qos` (factory-level QoS).
248 ///
249 /// # Errors
250 /// `DdsError::PreconditionNotMet` on lock poisoning.
251 pub fn set_qos(&self, qos: DomainParticipantFactoryQos) -> Result<()> {
252 let mut current = self
253 .factory_qos
254 .lock()
255 .map_err(|_| DdsError::PreconditionNotMet {
256 reason: "factory qos poisoned",
257 })?;
258 *current = qos;
259 Ok(())
260 }
261
262 /// Spec §2.2.2.2.2.6 `get_qos` (factory-level QoS).
263 #[must_use]
264 pub fn get_qos(&self) -> DomainParticipantFactoryQos {
265 self.factory_qos.lock().map(|q| *q).unwrap_or_default()
266 }
267
268 /// Fluent entry point for creating a participant: returns a
269 /// [`ParticipantBuilder`] that collects QoS, a custom
270 /// [`RuntimeConfig`], and (with the `security` feature) a
271 /// [`SecurityBundle`](zerodds_security_runtime::SecurityBundle), then
272 /// materializes the participant on [`ParticipantBuilder::build`].
273 ///
274 /// This is a convenience facade over
275 /// [`Self::create_participant_with_config`] — `build()` resolves to
276 /// the same live runtime path. It exists so security wiring reads as
277 /// one chain:
278 ///
279 /// ```no_run
280 /// # #[cfg(feature = "security")] {
281 /// use zerodds_dcps::DomainParticipantFactory;
282 /// # let security_bundle = zerodds_security_runtime::SecurityBundle::builder().build();
283 /// let participant = DomainParticipantFactory::create(0)
284 /// .with_security(security_bundle)
285 /// .build()?;
286 /// # }
287 /// # Ok::<(), zerodds_dcps::error::DdsError>(())
288 /// ```
289 #[must_use]
290 pub fn create(domain_id: DomainId) -> ParticipantBuilder {
291 ParticipantBuilder::new(domain_id)
292 }
293}
294
295/// Fluent builder for a [`DomainParticipant`], returned by
296/// [`DomainParticipantFactory::create`]. Collects optional QoS, a custom
297/// [`RuntimeConfig`], and a [`SecurityBundle`](zerodds_security_runtime::SecurityBundle)
298/// (under the `security` feature) and creates the participant via the
299/// factory singleton on [`Self::build`].
300#[cfg(feature = "std")]
301#[derive(Debug)]
302pub struct ParticipantBuilder {
303 domain_id: DomainId,
304 qos: DomainParticipantQos,
305 config: Option<RuntimeConfig>,
306}
307
308#[cfg(feature = "std")]
309impl ParticipantBuilder {
310 fn new(domain_id: DomainId) -> Self {
311 Self {
312 domain_id,
313 qos: DomainParticipantQos::default(),
314 config: None,
315 }
316 }
317
318 /// Sets the participant QoS (default: [`DomainParticipantQos::default`]).
319 #[must_use]
320 pub fn with_qos(mut self, qos: DomainParticipantQos) -> Self {
321 self.qos = qos;
322 self
323 }
324
325 /// Sets an explicit [`RuntimeConfig`]. Overrides the config derived
326 /// from QoS / security wiring; subsequent [`Self::with_security`]
327 /// calls mutate this config.
328 #[must_use]
329 pub fn with_config(mut self, config: RuntimeConfig) -> Self {
330 self.config = Some(config);
331 self
332 }
333
334 /// Wires a [`SecurityBundle`](zerodds_security_runtime::SecurityBundle)
335 /// (logging plugin + security profile) into the participant's runtime
336 /// config — see [`RuntimeConfig::with_security_bundle`].
337 #[cfg(feature = "security")]
338 #[must_use]
339 pub fn with_security(mut self, bundle: zerodds_security_runtime::SecurityBundle) -> Self {
340 let base = self.config.take().unwrap_or_default();
341 self.config = Some(base.with_security_bundle(&bundle));
342 self
343 }
344
345 /// Creates the live participant via the factory singleton.
346 ///
347 /// # Errors
348 /// `DdsError::TransportError` if the UDP sockets do not bind.
349 pub fn build(self) -> Result<DomainParticipant> {
350 let factory = DomainParticipantFactory::instance();
351 match self.config {
352 Some(config) => {
353 factory.create_participant_with_config(self.domain_id, self.qos, config)
354 }
355 None => factory.create_participant(self.domain_id, self.qos),
356 }
357 }
358}
359
360#[cfg(test)]
361#[allow(clippy::expect_used, clippy::unwrap_used)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn factory_is_singleton() {
367 let a = DomainParticipantFactory::instance();
368 let b = DomainParticipantFactory::instance();
369 assert!(core::ptr::eq(a, b));
370 }
371
372 #[test]
373 fn factory_creates_participant_with_correct_domain_id() {
374 // Offline variant — no UDP bind in the unit test. The live
375 // variant is tested in runtime.rs.
376 let p = DomainParticipantFactory::instance()
377 .create_participant_offline(7, DomainParticipantQos::default());
378 assert_eq!(p.domain_id(), 7);
379 }
380
381 // ---- §2.2.2.2.2.4 lookup_participant ----
382
383 #[test]
384 fn lookup_participant_finds_registered_offline_participant() {
385 let f = DomainParticipantFactory::instance();
386 // Unique domain id so that no other test participant affects
387 // the lookup.
388 let domain = 91;
389 let _p = f.create_participant_offline(domain, DomainParticipantQos::default());
390 let found = f.lookup_participant(domain);
391 assert!(found.is_some());
392 assert_eq!(found.unwrap().domain_id(), domain);
393 }
394
395 #[test]
396 fn dropping_participant_releases_it_from_the_factory() {
397 // The factory holds only weak references: once the user drops their last
398 // strong handle, the participant is gone (RAII) and `lookup_participant`
399 // returns `None`, garbage-collecting the dead weak. (Pre-weak-refs the
400 // factory kept a strong clone and this would still return `Some`.)
401 let f = DomainParticipantFactory::instance();
402 let domain = 9182; // isolated from other tests
403 {
404 let _p = f.create_participant_offline(domain, DomainParticipantQos::default());
405 assert!(
406 f.lookup_participant(domain).is_some(),
407 "tracked while a strong handle is alive"
408 );
409 } // _p dropped here
410 assert!(
411 f.lookup_participant(domain).is_none(),
412 "factory must not keep the participant alive after the user drops it"
413 );
414 }
415
416 #[test]
417 fn lookup_participant_returns_none_for_unknown_domain() {
418 let f = DomainParticipantFactory::instance();
419 // Very high domain_id value for which nothing likely exists.
420 assert!(f.lookup_participant(60_001).is_none());
421 }
422
423 // ---- §2.2.2.2.2.3 delete_participant ----
424
425 #[test]
426 fn delete_participant_removes_from_registry() {
427 let f = DomainParticipantFactory::instance();
428 let domain = 92;
429 let p = f.create_participant_offline(domain, DomainParticipantQos::default());
430 assert!(f.lookup_participant(domain).is_some());
431 f.delete_participant(&p).unwrap();
432 assert!(f.lookup_participant(domain).is_none());
433 }
434
435 #[test]
436 fn delete_participant_unknown_returns_precondition_not_met() {
437 let f = DomainParticipantFactory::instance();
438 // Create a participant without tracking it via the factory
439 // (DomainParticipant::new directly).
440 let detached =
441 crate::participant::DomainParticipant::new(93, DomainParticipantQos::default());
442 let res = f.delete_participant(&detached);
443 assert!(matches!(
444 res,
445 Err(crate::error::DdsError::PreconditionNotMet { .. })
446 ));
447 }
448
449 // ---- §2.2.2.2.2.5 default_participant_qos ----
450
451 #[test]
452 fn default_participant_qos_roundtrips() {
453 let f = DomainParticipantFactory::instance();
454 let mut new_qos = f.get_default_participant_qos();
455 // We mutate some recognizable default. DomainParticipantQos is
456 // default-constructible; to verify the roundtrip it is enough
457 // to run set/get.
458 new_qos = new_qos.clone();
459 f.set_default_participant_qos(new_qos.clone()).unwrap();
460 let got = f.get_default_participant_qos();
461 // Equality modulo the Equality impl of DomainParticipantQos.
462 assert_eq!(format!("{got:?}"), format!("{new_qos:?}"));
463 }
464
465 // ---- §2.2.2.2.2.6 factory's own QoS ----
466
467 #[test]
468 fn factory_qos_default_is_autoenable_true() {
469 // Spec default §2.2.2.2.2.6: autoenable_created_entities = TRUE.
470 let q = DomainParticipantFactoryQos::default();
471 assert!(q.autoenable_created_entities);
472 }
473
474 #[test]
475 fn factory_set_get_qos_roundtrip() {
476 let f = DomainParticipantFactory::instance();
477 let q = DomainParticipantFactoryQos {
478 autoenable_created_entities: false,
479 };
480 f.set_qos(q).unwrap();
481 let got = f.get_qos();
482 assert!(!got.autoenable_created_entities);
483 // Restore default for other tests.
484 f.set_qos(DomainParticipantFactoryQos::default()).unwrap();
485 }
486}