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}