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}