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}