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