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