Skip to main content

nros_node/executor/
node_record.rs

1//! Phase 104.C.2 — multi-Node-per-Executor storage.
2//!
3//! Mirrors the `rclcpp` pattern where a single `Executor` holds N
4//! `Node`s via `add_node(...)`. Each Node carries its own
5//! name/namespace + a reference to the Session that backs it +
6//! a default `SchedContext` (Phase 110) handles inherit unless
7//! overridden.
8//!
9//! For Phase 104.C.2 we land the *storage scaffold* + the builder
10//! API. Multi-Session-per-Executor dispatch is a follow-up
11//! (Phase 104.C.3) — today every Node in this list resolves to the
12//! Executor's primary session, which means `node_builder.rmw(name)`
13//! only accepts the same backend the Executor was opened against.
14//! Bridge use cases (two RMW backends concurrent in one Executor)
15//! light up when 104.C.3 adds the per-Node session ref.
16
17use super::{sched_context::SchedContextId, types::NodeError};
18
19/// Opaque handle returned by `Executor::node_builder(...).build()`.
20/// Used in 104.C.3+ to disambiguate handle ownership when multiple
21/// Nodes coexist in one Executor.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub struct NodeId(pub(crate) u8);
24
25impl NodeId {
26    /// Reserved id for the implicit "primary" Node that mirrors the
27    /// pre-Phase 104.C.2 single-Node Executor identity.
28    pub const PRIMARY: NodeId = NodeId(0);
29
30    /// Numeric index into the Executor's node table.
31    pub fn index(self) -> usize {
32        self.0 as usize
33    }
34
35    /// Phase 104.C.8.b / C.9.b — build a `NodeId` from a raw `u8` for
36    /// FFI consumers that store the index in their own struct
37    /// (`nros_node_t.node_id`, `nros_cpp_node_t.node_id`). The value
38    /// is not validated against the executor's `nodes` table — the
39    /// caller is responsible for only constructing ids that
40    /// `node_builder(...).build()` previously returned. Out-of-range
41    /// ids fail loudly at the next `with_node` / `_on(...)` call.
42    pub const fn from_raw(raw: u8) -> NodeId {
43        NodeId(raw)
44    }
45
46    /// Raw u8 form for FFI persistence.
47    pub const fn raw(self) -> u8 {
48        self.0
49    }
50}
51
52/// Per-Node metadata stored inside the Executor.
53///
54/// Phase 104.C.2 keeps the shape minimal — name, namespace,
55/// default SchedContext, optional rmw-name for diagnostics. Future
56/// items: per-Node session reference (104.C.3), per-Node liveliness
57/// state, per-Node parameter overrides.
58pub struct NodeRecord {
59    pub name: heapless::String<64>,
60    pub namespace: heapless::String<64>,
61    /// RMW backend the Node was created against. `None` for the
62    /// implicit primary Node populated from `Executor::open`.
63    pub rmw_name: Option<heapless::String<32>>,
64    /// Per-Node locator override. `None` = use the Executor's
65    /// session-level locator.
66    pub locator: Option<heapless::String<128>>,
67    /// Default `SchedContext` for handles created via this Node.
68    /// Handles may override per-call. `SchedContextId::default()` =
69    /// the executor's auto-created Fifo slot (slot 0).
70    pub default_sched: SchedContextId,
71    /// Phase 104.C.3 — session-slot index. `0` resolves to the
72    /// Executor's primary `session` field; `N >= 1` resolves to
73    /// `extra_sessions[N-1]`. Each Node may bind to a different
74    /// session, enabling multi-RMW bridges in one Executor.
75    pub session_idx: u8,
76    /// Issue #52 / phase-296 residue sweep — per-node QoS-override table, in the
77    /// SAME primitive `(topic, role, policy, value)` code form the C and C++
78    /// ABIs use (`nros_qos_override_t` / `nros_cpp_qos_override_t`).
79    ///
80    /// Codes rather than [`nros_rmw::QoSOverride`] because the entry codegen
81    /// bakes this through `RuntimeCtx`, and `nros-platform` sits BELOW
82    /// `nros-rmw` in the layer graph — a typed field there would invert it.
83    /// Decoded at entity-create time by [`decode_qos_override`].
84    ///
85    /// Empty by default: a system with no overrides pays nothing.
86    pub qos_overrides: &'static [QoSOverrideCode],
87}
88
89/// Issue #52 / 0303 — one baked QoS override, re-exported from `nros-rmw`
90/// where the single decoder lives. Kept as a name here because `NodeRecord`
91/// and the entry bake spell it.
92pub use nros_rmw::QoSOverrideCode;
93
94/// Fold a node's baked override codes into `qos` for one `(topic, role)`.
95/// Thin alias over [`nros_rmw::QoSProfile::apply_override_codes`] — issue 0303
96/// collapsed four copies of this match into that one.
97pub fn apply_qos_override_codes(
98    qos: nros_rmw::QoSProfile,
99    topic: &str,
100    role: nros_rmw::QoSOverrideRole,
101    codes: &[QoSOverrideCode],
102) -> nros_rmw::QoSProfile {
103    qos.apply_override_codes(topic, role, codes)
104}
105
106impl NodeRecord {
107    /// Construct the implicit "primary" NodeRecord that mirrors the
108    /// Executor's pre-104.C.2 single-Node identity. Currently unused
109    /// (the primary Node is implicit until 104.C.3 wires per-Node
110    /// dispatch); kept here for the upcoming migration where every
111    /// Executor will have an explicit entry at slot 0.
112    #[allow(dead_code)]
113    pub(crate) fn new_primary(name: heapless::String<64>, namespace: heapless::String<64>) -> Self {
114        Self {
115            name,
116            namespace,
117            rmw_name: None,
118            locator: None,
119            default_sched: SchedContextId(0),
120            session_idx: 0,
121            qos_overrides: &[],
122        }
123    }
124}
125
126/// Builder returned by `Executor::node_builder(name)`. Chainable
127/// configuration; `.build()` registers the Node with the Executor
128/// and returns a [`NodeId`].
129///
130/// rclcpp-aligned API. Mirrors:
131///
132/// ```ignore
133/// rclcpp::Node::make_shared("my_node",
134///     rclcpp::NodeOptions().use_intra_process_comms(true))
135/// ```
136///
137/// Where rclcpp uses a single `NodeOptions` struct, we expose the
138/// individual setters directly on the builder — fewer cycles when
139/// the user only needs one option.
140pub struct NodeBuilder<'a, 'cfg, 's> {
141    pub(crate) executor: &'a mut super::spin::Executor<'s>,
142    pub(crate) name: &'cfg str,
143    pub(crate) namespace: Option<&'cfg str>,
144    pub(crate) rmw_name: Option<&'cfg str>,
145    pub(crate) locator: Option<&'cfg str>,
146    pub(crate) domain_id: Option<u32>,
147    pub(crate) sched: Option<SchedContextId>,
148    /// Phase 172.K.5 — explicit session slot (index into the sessions opened
149    /// by `open_multi`: 0 = primary, N = `extra_sessions[N-1]`). When set,
150    /// `build()` binds the Node directly to this session and **bypasses** the
151    /// rmw-based `resolve_session_slot` — the planner/generator already knows
152    /// which `SESSION_SPECS` slot each node belongs to (e.g. its domain group),
153    /// so no rmw/domain inference is needed. `None` ⇒ the legacy rmw-resolved
154    /// slot.
155    pub(crate) session_idx: Option<u8>,
156}
157
158impl<'a, 'cfg, 's> NodeBuilder<'a, 'cfg, 's> {
159    /// Select an RMW backend by name. `name` must match a backend
160    /// registered via `nros_rmw_cffi_register_named` (Phase 104.B.2).
161    ///
162    /// In Phase 104.C.2 (current), the name must match the backend
163    /// the Executor was opened against — bridge mode lands in
164    /// 104.C.3 when per-Node sessions are wired. Passing a name
165    /// that doesn't match the Executor's session returns
166    /// `Err(NodeError::BackendMismatch)` from `.build()`.
167    pub fn rmw(mut self, name: &'cfg str) -> Self {
168        self.rmw_name = Some(name);
169        self
170    }
171
172    /// Override the locator for this Node's session. Empty / unset =
173    /// use the Executor's locator.
174    pub fn locator(mut self, locator: &'cfg str) -> Self {
175        self.locator = Some(locator);
176        self
177    }
178
179    /// Override the domain id for this Node's session.
180    pub fn domain_id(mut self, domain_id: u32) -> Self {
181        self.domain_id = Some(domain_id);
182        self
183    }
184
185    /// Phase 172.K.5 — bind this Node to an explicit session slot (index into
186    /// the sessions opened by [`Executor::open_multi`]: `0` = primary,
187    /// `N` = `extra_sessions[N-1]`). Bypasses the rmw-based session resolution
188    /// — the caller (generated multi-domain wiring) already knows the slot.
189    pub fn session_idx(mut self, idx: u8) -> Self {
190        self.session_idx = Some(idx);
191        self
192    }
193
194    /// Namespace for handles created via this Node. Empty = "/".
195    pub fn namespace(mut self, namespace: &'cfg str) -> Self {
196        self.namespace = Some(namespace);
197        self
198    }
199
200    /// Default [`SchedContext`](super::sched_context::SchedContext) for
201    /// handles registered via this Node. Phase 110 integration —
202    /// handles inherit this unless they pass their own SchedContext
203    /// at registration time.
204    pub fn sched(mut self, sched: SchedContextId) -> Self {
205        self.sched = Some(sched);
206        self
207    }
208
209    /// Phase 104.C.3 — pick a session slot for the Node being
210    /// built. Returns `0` for the primary session (no rmw override
211    /// or rmw matches existing) and `N >= 1` for an extra session
212    /// just opened via `CffiRmw::open_with_rmw`.
213    #[cfg(feature = "rmw-cffi")]
214    fn resolve_session_slot(&mut self) -> Result<u8, NodeError> {
215        let Some(rmw) = self.rmw_name else {
216            return Ok(0);
217        };
218
219        // Phase 156 — check primary FIRST. Executor::open* records
220        // `primary_rmw_name` + `primary_locator` so we can detect
221        // when a `.rmw(name)` matches the primary session and
222        // return slot 0 instead of opening a SECOND backend
223        // session against the same singleton (which zenoh-pico's
224        // global g_session forbids). Locator-None means "inherit
225        // primary"; locator-Some must match primary's exactly.
226        // Empty `primary_rmw_name` → constructed via
227        // `from_session(_ptr)` without `open*` recording — fall
228        // through to extras cache + new-session path.
229        if !self.executor.primary_rmw_name.is_empty()
230            && self.executor.primary_rmw_name.as_str() == rmw
231        {
232            let locator_matches = match self.locator {
233                None => true,
234                Some(loc) => self.executor.primary_locator.as_str() == loc,
235            };
236            if locator_matches {
237                return Ok(0);
238            }
239        }
240
241        // Reuse an extra session if one already opened against the
242        // same rmw + locator. Slot 0 (primary) handled by the
243        // primary-identity check above.
244        // Issue 0436 — match the extras' RECORDED identity (rmw + locator). This
245        // is what lets a Node bind to a session `open_multi` opened: previously the
246        // only signal was a prior NodeRecord bound to that slot, so the FIRST node
247        // naming a backend always missed and fell through to opening a SECOND
248        // session against it (which fails — the backend's global state is a
249        // process singleton).
250        for (i, id) in self.executor.extra_session_ids.iter().enumerate() {
251            let (sess_rmw, sess_loc) = id;
252            if sess_rmw.as_str() == rmw {
253                let locator_matches = match self.locator {
254                    // No locator requested → inherit whatever this session opened with.
255                    None => true,
256                    Some(loc) => sess_loc.as_str() == loc,
257                };
258                if locator_matches {
259                    return Ok((i + 1) as u8);
260                }
261            }
262        }
263
264        for (i, sess) in self.executor.extra_sessions.iter().enumerate() {
265            let _ = sess;
266            // Phase 104.C.3 doesn't yet store rmw-name per session;
267            // dedupe by NodeRecord's stored rmw_name + locator.
268            if let Some(prev) = self.executor.nodes.iter().find(|n| {
269                n.session_idx as usize == i + 1
270                    && n.rmw_name.as_deref() == Some(rmw)
271                    && n.locator.as_deref() == self.locator
272            }) {
273                let _ = prev;
274                return Ok((i + 1) as u8);
275            }
276        }
277
278        // First Node naming this rmw → open a new session.
279        let mode = nros_rmw::SessionMode::Client;
280        let locator = self.locator.unwrap_or("");
281        let domain_id = self.domain_id.unwrap_or(0);
282        let cfg = nros_rmw::RmwConfig {
283            locator,
284            mode,
285            domain_id,
286            node_name: self.name,
287            namespace: self.namespace.unwrap_or(""),
288            properties: &[],
289        };
290        let session = nros_rmw_cffi::CffiRmw::open_with_rmw(rmw, &cfg)
291            .map_err(crate::executor::types::NodeError::Transport)?;
292        self.executor
293            .extra_sessions
294            .push(session)
295            .map_err(|_| NodeError::NodeTableFull)?;
296        // Issue 0436 — record identity here too, so the NEXT node naming this
297        // backend reuses the session instead of opening another.
298        {
299            let mut rmw_s = heapless::String::<32>::new();
300            let _ = rmw_s.push_str(rmw);
301            let mut loc_s = heapless::String::<128>::new();
302            let _ = loc_s.push_str(locator);
303            let _ = self.executor.extra_session_ids.push((rmw_s, loc_s));
304        }
305        let idx = self.executor.extra_sessions.len();
306        if idx > u8::MAX as usize {
307            return Err(NodeError::NodeTableFull);
308        }
309        // Phase 104.C.6.b — install the shared wake flag on the
310        // freshly opened extra session so its backend notifications
311        // can short-circuit `spin_once`.
312        //
313        // BOTH flavours, mirroring the construction path in
314        // `Executor::open_in`. Until now only the `std` arm was here, so a
315        // no_std build opening an extra session through THIS path (the
316        // dynamic one — a node naming a second RMW) never installed the
317        // wake callback: arrivals on that session could not short-circuit
318        // `spin_once`, and its traffic waited up to the full spin timeout
319        // instead of waking on arrival. Silent, latency-only, and no_std-only.
320        // The installer already existed; it simply was not called here.
321        //
322        // phase-359 W10 — one call. These were two arms naming two functions,
323        // `install_wake_signal_on_extra` and its `_alloc` mirror; the mirror is
324        // gone, so what was left was the same call written twice under
325        // complementary gates.
326        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
327        self.executor.install_wake_signal_on_extra(idx - 1);
328        Ok(idx as u8)
329    }
330
331    #[cfg(not(feature = "rmw-cffi"))]
332    fn resolve_session_slot(&mut self) -> Result<u8, NodeError> {
333        // Without `rmw-cffi`, only the primary session exists. An
334        // rmw-name override is meaningless; treat as the primary.
335        Ok(0)
336    }
337
338    /// Register the Node with the Executor and return its
339    /// [`NodeId`]. Bumps `Executor.nodes.len()`; fails if the table
340    /// is full (`NROS_EXECUTOR_MAX_NODES` reached) or the name is
341    /// too long.
342    pub fn build(mut self) -> Result<NodeId, NodeError> {
343        if self.name.len() > 64 {
344            return Err(NodeError::NameTooLong);
345        }
346
347        // Phase 104.C.2 — single-session check. rmw mismatch is an
348        // error today; 104.C.3 will accept and open a new session
349        // via the session cache.
350        if let Some(_requested) = self.rmw_name {
351            // No accessor for the current session's rmw name yet —
352            // the registry first-registered slot drives the
353            // singleton. We accept any rmw name in C.2 (no
354            // validation) so consumer code is forward-compatible.
355            // C.3 adds the mismatch check + session-cache lookup.
356        }
357
358        let mut name_buf = heapless::String::<64>::new();
359        name_buf
360            .push_str(self.name)
361            .map_err(|_| NodeError::NameTooLong)?;
362
363        let mut ns_buf = heapless::String::<64>::new();
364        if let Some(ns) = self.namespace {
365            ns_buf.push_str(ns).map_err(|_| NodeError::NameTooLong)?;
366        } else {
367            ns_buf
368                .push_str(self.executor.namespace.as_str())
369                .map_err(|_| NodeError::NameTooLong)?;
370        }
371
372        let mut rmw_buf = None;
373        if let Some(rmw) = self.rmw_name {
374            let mut s = heapless::String::<32>::new();
375            s.push_str(rmw).map_err(|_| NodeError::NameTooLong)?;
376            rmw_buf = Some(s);
377        }
378
379        let mut loc_buf = None;
380        if let Some(loc) = self.locator {
381            let mut s = heapless::String::<128>::new();
382            s.push_str(loc).map_err(|_| NodeError::NameTooLong)?;
383            loc_buf = Some(s);
384        }
385
386        // Phase 172.K.5 — an explicit `.session_idx(n)` binds the Node to a
387        // pre-opened `open_multi` session directly (validated against the
388        // opened set), bypassing rmw resolution. Otherwise (Phase 104.C.3)
389        // resolve by rmw: slot 0 for no/primary-matching rmw, else open/reuse
390        // an extra session.
391        let session_idx = match self.session_idx {
392            Some(idx) => {
393                if idx as usize > self.executor.extra_sessions.len() {
394                    return Err(NodeError::NodeTableFull);
395                }
396                idx
397            }
398            None => self.resolve_session_slot()?,
399        };
400
401        // Phase 272 (RFC-0047) — resolve default_sched with precedence:
402        //   explicit .sched()  >  table lookup  >  SchedContextId(0)
403        //
404        // The lookup borrows `self.executor` immutably and returns a Copy
405        // value, so the borrow ends before the mutable `nodes.push` below.
406        let default_sched = match self.sched {
407            Some(id) => id,
408            None => self
409                .executor
410                .lookup_node_sched(name_buf.as_str(), ns_buf.as_str())
411                .unwrap_or(SchedContextId(0)),
412        };
413
414        let record = NodeRecord {
415            name: name_buf,
416            namespace: ns_buf,
417            rmw_name: rmw_buf,
418            locator: loc_buf,
419            default_sched,
420            session_idx,
421            // Installed after creation by `Executor::set_node_qos_overrides`
422            // (the entry codegen's bake) — a builder never carries them.
423            qos_overrides: &[],
424        };
425
426        self.executor
427            .nodes
428            .push(record)
429            .map_err(|_| NodeError::NodeTableFull)?;
430        let idx = self.executor.nodes.len() - 1;
431        if idx > u8::MAX as usize {
432            // The carved table's capacity (`ExecutorSizing::nodes`) is far
433            // below u8::MAX; defensive only.
434            return Err(NodeError::NodeTableFull);
435        }
436        Ok(NodeId(idx as u8))
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use nros_rmw::{
444        QoSDurabilityPolicy, QoSHistoryPolicy, QoSOverrideRole, QoSProfile, QoSReliabilityPolicy,
445    };
446
447    /// Issue #52 — the primitive codes decode to the same overrides the C and
448    /// C++ ABIs spell, and fold only into the matching `(topic, role)`.
449    #[test]
450    fn codes_fold_into_the_matching_topic_and_role() {
451        const CODES: &[QoSOverrideCode] = &[
452            // /chatter publisher reliability = best_effort
453            ("/chatter", 0, 0, 0),
454            // /chatter subscription depth = 7
455            ("/chatter", 1, 3, 7),
456            // /other publisher durability = transient_local
457            ("/other", 0, 1, 1),
458        ];
459
460        let pub_qos = apply_qos_override_codes(
461            QoSProfile::default(),
462            "/chatter",
463            QoSOverrideRole::Publisher,
464            CODES,
465        );
466        assert_eq!(pub_qos.reliability, QoSReliabilityPolicy::BestEffort);
467        // The subscription-side depth entry must NOT leak onto the publisher.
468        assert_eq!(pub_qos.depth, QoSProfile::default().depth);
469        // Nor the other topic's durability.
470        assert_eq!(pub_qos.durability, QoSProfile::default().durability);
471
472        let sub_qos = apply_qos_override_codes(
473            QoSProfile::default(),
474            "/chatter",
475            QoSOverrideRole::Subscription,
476            CODES,
477        );
478        assert_eq!(sub_qos.depth, 7);
479        assert_eq!(sub_qos.reliability, QoSProfile::default().reliability);
480
481        let other = apply_qos_override_codes(
482            QoSProfile::default(),
483            "/other",
484            QoSOverrideRole::Publisher,
485            CODES,
486        );
487        assert_eq!(other.durability, QoSDurabilityPolicy::TransientLocal);
488    }
489
490    /// An unrecognised role or policy code is SKIPPED, never applied as a
491    /// silently wrong override.
492    #[test]
493    fn unknown_codes_are_skipped_not_guessed() {
494        const BAD: &[QoSOverrideCode] = &[("/t", 9, 0, 1), ("/t", 0, 9, 1)];
495        assert!(nros_rmw::decode_qos_override(&BAD[0]).is_none());
496        assert!(nros_rmw::decode_qos_override(&BAD[1]).is_none());
497        let qos =
498            apply_qos_override_codes(QoSProfile::default(), "/t", QoSOverrideRole::Publisher, BAD);
499        assert_eq!(qos, QoSProfile::default());
500    }
501
502    /// History `keep_all` and reliability `reliable` are the non-default arms —
503    /// pin them so a code-table renumbering cannot pass silently.
504    #[test]
505    fn history_and_reliability_arms_decode() {
506        let qos = apply_qos_override_codes(
507            QoSProfile::default(),
508            "/t",
509            QoSOverrideRole::Subscription,
510            &[("/t", 1, 2, 1), ("/t", 1, 0, 1)],
511        );
512        assert_eq!(qos.history, QoSHistoryPolicy::KeepAll);
513        assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
514    }
515
516    /// An empty table leaves QoS untouched — the common case pays nothing.
517    #[test]
518    fn empty_table_is_a_no_op() {
519        let qos =
520            apply_qos_override_codes(QoSProfile::default(), "/t", QoSOverrideRole::Publisher, &[]);
521        assert_eq!(qos, QoSProfile::default());
522    }
523}