nros_node/executor/spin.rs
1//! Executor struct and core spin methods.
2
3use core::{marker::PhantomData, mem::MaybeUninit};
4
5use nros_core::{RosMessage, RosService, ViewableMessage};
6use nros_rmw::{QoSProfile, ServiceInfo, Session, TopicInfo, TransportError};
7
8use crate::{session, timer::TimerDuration};
9
10#[cfg(feature = "safety-e2e")]
11use super::arena::{
12 SubSafetyEntry, sub_safety_has_data, sub_safety_pre_sample, sub_safety_try_process,
13};
14#[cfg(feature = "rmw-cffi")]
15use super::types::ExecutorConfig;
16#[cfg(feature = "alloc")]
17use super::types::SpinOptions;
18use super::{
19 arena::{
20 BufferStrategy, CallbackMeta, EntryKind, GuardConditionEntry, ServiceClientCallbackEntry,
21 ServiceClientRawArenaEntry, ServiceClientSendHeader, SrvEntry, SrvRawEntry,
22 SubBufferedEntry, SubBufferedRawCEntry, SubBufferedRawEntry, SubBufferedRawInfoCEntry,
23 SubBufferedRawInfoEntry, SubBufferedViewEntry, SubInfoEntry, SubInplaceEntry,
24 TimerClockSource, TimerEntry, TimerHeader, TimerOverrunPolicy, TraceName, always_ready,
25 buffered_region_size, drop_entry, guard_has_data, guard_try_process, no_pre_sample,
26 service_client_callback_try_process, service_client_raw_try_process, srv_has_data,
27 srv_raw_has_data, srv_raw_try_process, srv_try_process, sub_buffered_has_data,
28 sub_buffered_raw_c_has_data, sub_buffered_raw_c_try_process, sub_buffered_raw_has_data,
29 sub_buffered_raw_info_c_has_data, sub_buffered_raw_info_c_try_process,
30 sub_buffered_raw_info_has_data, sub_buffered_raw_info_try_process,
31 sub_buffered_raw_try_process, sub_buffered_try_process, sub_buffered_view_has_data,
32 sub_buffered_view_try_process, sub_info_has_data, sub_info_pre_sample,
33 sub_info_try_process, sub_inplace_has_data, sub_inplace_try_process, timer_try_process,
34 },
35 node::NodeHandle,
36 spsc_ring::SpscRing,
37 triple_buffer::TripleBuffer,
38 types::{
39 ExecutorSemantics, GuardCondition, HandleId, InvocationMode, NodeError,
40 RawResponseCallback, RawServiceCallback, RawSubscriptionCallback,
41 RawSubscriptionInfoCallback, ReadinessSnapshot, SpinOnceResult, SpinPeriodPollingResult,
42 Trigger,
43 },
44};
45
46// ============================================================================
47// Phase 8 — callback registration event (paired stubs)
48// ============================================================================
49//
50// `docs/design/callback_tracing.rst`. Paired stubs (the `entry_tiers.rs`
51// idiom) so `emplace_entry` reads identically whether or not the feature is
52// on, and the `#[cfg]` lives in exactly one place.
53
54/// Emit `nros_callback_register(handle, kind, name)` for a newly installed
55/// executor entry.
56#[cfg(feature = "trace-callbacks")]
57#[inline]
58fn trace_register(slot: usize, kind: EntryKind, name: TraceName<'_>) {
59 super::callback_trace::register(slot, kind, name);
60}
61
62#[cfg(not(feature = "trace-callbacks"))]
63#[inline]
64fn trace_register(_slot: usize, _kind: EntryKind, _name: TraceName<'_>) {}
65
66// ============================================================================
67// Executor::open() factory method
68// ============================================================================
69
70/// phase-271 — leak a default-sized (`ExecutorSizing::DEFAULT`) `u64` backing,
71/// yielding the `'static` storage the `alloc` convenience constructors borrow.
72/// One-time, executor-lifetime allocation (the executor lives for the program);
73/// intentionally not freed. `alloc`-only — no_std-no-alloc entries supply their
74/// own `static`/stack backing via `from_session_in` / the `nros::main!` macro.
75#[cfg(feature = "alloc")]
76fn leak_default_backing(sizing: super::storage::ExecutorSizing) -> &'static mut [MaybeUninit<u64>] {
77 alloc::boxed::Box::leak(alloc::boxed::Box::new_uninit_slice(sizing.u64_len()))
78}
79
80#[cfg(feature = "rmw-cffi")]
81impl<'s> Executor<'s> {
82 /// phase-271 — open a new executor session over caller-supplied `backing`,
83 /// sized by `sizing` (per-entry sizing). The core, non-generic sized entry
84 /// point: the `alloc` [`open`](Self::open) convenience leaks a default
85 /// backing and delegates here, and the `nros::main!` macro emits a backing
86 /// sized to the entry's own entity count.
87 ///
88 /// Phase 115.M.4 — auto-registers the cffi vtable for whichever
89 /// backend the build was configured for, mirroring the C++ side's
90 /// `#ifdef NROS_RMW_<NAME>` fan-out in `<nros/node.hpp>`. The
91 /// runtime's atomic vtable slot is idempotent: a re-call of any
92 /// backend's `register()` is a no-op, so the fan-out below is safe
93 /// to invoke on every `Executor::open` (cheaper than a `Once` and
94 /// doesn't pull in `std::sync` for no_std targets).
95 ///
96 /// Connects to the middleware at the locator specified in `config`.
97 ///
98 /// # Safety
99 /// `backing` must be ≥ `sizing.u64_len()` words, live for `'s`, and be
100 /// otherwise untouched while the executor lives (see
101 /// [`from_session_in`](Self::from_session_in)).
102 pub unsafe fn open_in(
103 config: &ExecutorConfig<'_>,
104 backing: &'s mut [MaybeUninit<u64>],
105 sizing: super::storage::ExecutorSizing,
106 ) -> Result<Self, NodeError> {
107 use nros_rmw::Rmw;
108
109 // Phase 128.A.3 / 249 P4b.1 — manifest-driven backend selection.
110 //
111 // Every linked backend self-registered via its `.init_array`
112 // ctor before `main` (RFC-0042 §D3.3), so the registry is
113 // already populated — no runtime section walk.
114 //
115 // 1. Honour `config.rmw` — the caller's explicit backend selection,
116 // mirroring ROS 2's `RMW_IMPLEMENTATION`. On a hosted build
117 // `nros::ExecutorConfigEnvExt::from_env` / `nros::env::resolve_hosted`
118 // fill it from `$NROS_RMW`; issue 0687 moved that read to the edge,
119 // because reading a process environment is what kept `std` in this
120 // crate and an RTOS image has no environment to read.
121 // 2. With no selector, pick the unique registered backend.
122 // Zero registered → `NoBackend`; more than one →
123 // `Ambiguous` (user must select one, or use
124 // `Executor::open_multi`).
125 let selector = config.rmw;
126 match nros_rmw_cffi::resolve_backend(selector.map(str::as_bytes)) {
127 nros_rmw_cffi::BackendResolution::Single(_) => {}
128 // Issue 0436 — these are SELECTION outcomes, not transport failures,
129 // and calling them `ConnectionFailed` actively misleads: a PX4 bridge
130 // that registered two backends reported "connection failed", which
131 // reads as a router/network problem and was chased as one. Nothing is
132 // connected at this point — the resolver has not chosen a backend yet.
133 //
134 // The variant still collapses into `NodeError::Transport` (the enum has
135 // no selection arm, and adding one is an ABI change across the C/C++
136 // seams), but `InvalidConfig` at least says "your configuration is
137 // unresolvable", and the std-gated line below names WHICH outcome.
138 other => {
139 {
140 let why: &str = match other {
141 nros_rmw_cffi::BackendResolution::NoBackend => {
142 "no RMW backend is registered"
143 }
144 nros_rmw_cffi::BackendResolution::Ambiguous => {
145 "more than one RMW backend is registered and no \
146 $NROS_RMW selector was set — name one (e.g. \
147 NROS_RMW=uorb), or open per-backend sessions"
148 }
149 nros_rmw_cffi::BackendResolution::Unknown => {
150 "$NROS_RMW names a backend that is not registered"
151 }
152 nros_rmw_cffi::BackendResolution::Single(_) => unreachable!(),
153 };
154 nros_log::nros_error!(
155 nros_log::get_logger("nros"),
156 "cannot select an RMW backend — {why}"
157 );
158 }
159 let _ = other;
160 return Err(NodeError::Transport(TransportError::InvalidConfig));
161 }
162 }
163
164 let rmw_config = nros_rmw::RmwConfig {
165 locator: config.locator,
166 mode: config.mode,
167 domain_id: config.domain_id,
168 node_name: config.node_name,
169 namespace: config.namespace,
170 properties: &[],
171 };
172 let session = if let Some(name) = selector {
173 // Selector path: route to the specific named backend so
174 // the env-var-disambiguated outcome matches what the
175 // resolver above identified.
176 nros_rmw_cffi::CffiRmw::open_with_rmw(name, &rmw_config)
177 } else {
178 nros_rmw_cffi::CffiRmw.open(&rmw_config)
179 }
180 .map_err(|e| {
181 // Issue 0465 — do not relabel. This used to discard the backend's
182 // error and report `ConnectionFailed` for every open failure, so an
183 // exhausted session pool (`InvalidConfig`) and a router that is not
184 // there produced the same sentence. Same lesson as the selection
185 // arm above: say which failure happened.
186 nros_log::nros_error!(
187 nros_log::get_logger("nros"),
188 "RMW session open failed — {e:?}"
189 );
190 NodeError::Transport(e)
191 })?;
192 // SAFETY: forwarded from this fn's contract — `backing`/`sizing` sized
193 // + alive for `'s`.
194 let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
195 {
196 // `config.clock_us` overrides the constructor's platform default
197 // (issue: assigning `None` here clobbered it and re-enabled the
198 // credit-the-requested-timeout fallback).
199 if let Some(clock) = config.clock_us {
200 executor.clock_us_fn = Some(clock);
201 executor.last_spin_end_us = Some(clock());
202 }
203 // issue 0671 — the SAME rule as `clock_us` directly above, which is
204 // why that one is guarded: a config that does not SPECIFY an epoch
205 // must not be read as "this target HAS no epoch". Assigning `None`
206 // here clobbered the constructor's platform default
207 // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
208 // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
209 // so EVERY hosted node built that way silently lost its wall clock.
210 // With no epoch, `Node::subscription` never attaches the age cell
211 // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
212 // `max_age_ms` contract becomes a silently-dead monitor — the exact
213 // outcome RFC-0052 says must never happen. The rate monitor rides
214 // the GUARDED `clock_us_fn` and kept working, which is why only the
215 // age half went quiet.
216 if let Some(epoch) = config.epoch_us {
217 executor.epoch_us_fn = Some(epoch);
218 }
219 }
220 executor.set_node_identity(config.node_name, config.namespace);
221 // Issue 0656 — beside the identity, for the same reason: an entity needs
222 // both to be addressable, and this half used to be dropped here.
223 executor.domain_id = config.domain_id;
224 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
225 executor.install_wake_signal_on_primary();
226 // Phase 277 W2.c — readiness marker for E2E harnesses. This is the
227 // single call-through `open()`/`open_sized()` share, so it fires on
228 // every platform that reaches here (native, freertos, zephyr,
229 // threadx, …) regardless of which RMW backend or board owns the
230 // boot path. It replaces the per-example synthetic
231 // `log::info!("Publishing messages")` markers W4 removes — those
232 // only proved a callback had fired at least once; this line proves
233 // the session itself is up, before any node/callback exists.
234 //
235 // STABILITY CONTRACT: the leading `"nros: session open"` text is
236 // load-bearing — test harnesses grep for it verbatim. Keep it
237 // stable even if the trailing `(rmw=...)` detail changes.
238 #[cfg(feature = "log")]
239 {
240 if let Some(name) = selector {
241 log::info!("nros: session open (rmw={name})");
242 } else {
243 log::info!("nros: session open");
244 }
245 }
246 Ok(executor)
247 }
248}
249
250#[cfg(all(feature = "rmw-cffi", feature = "alloc"))]
251impl Executor<'static> {
252 /// Open a new executor session using the active RMW backend, at the
253 /// build-time default sizing. Convenience over
254 /// [`open_in`](Self::open_in): leaks a default-sized backing (executor-
255 /// lifetime) so existing callers keep the zero-storage-arg signature.
256 /// Per-entry sizing goes through `open_in` / the `nros::main!` macro.
257 ///
258 /// # Example
259 ///
260 /// ```ignore
261 /// let config = ExecutorConfig::from_env().node_name("my_node");
262 /// let mut executor = Executor::open(&config)?;
263 /// ```
264 pub fn open(config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
265 Self::open_sized(config, super::storage::ExecutorSizing::DEFAULT)
266 }
267
268 /// phase-271 — like [`open`](Self::open) but sized to a caller-supplied
269 /// `sizing` (its own declared topology) instead of the build-time default.
270 /// The `alloc` entry point the `nros::main!` macro's native board path uses
271 /// to size a fat entry (>default `MAX_CBS` callbacks) without a
272 /// workspace-global `NROS_EXECUTOR_MAX_CBS`. Leaks a `sizing`-sized backing
273 /// (executor-lifetime); no-alloc entries use `open_in` with their own static.
274 pub fn open_sized(
275 config: &ExecutorConfig<'_>,
276 sizing: super::storage::ExecutorSizing,
277 ) -> Result<Self, NodeError> {
278 // SAFETY: leaked backing is exactly `sizing.u64_len()` words, `'static`,
279 // uniquely owned by the returned executor.
280 unsafe { Self::open_in(config, leak_default_backing(sizing), sizing) }
281 }
282
283 /// Phase 128.F.1 — explicit per-backend session declaration for
284 /// bridge mode. `specs[0]` becomes the primary session; `specs[1..]`
285 /// open as extras keyed by RMW name. After construction, every
286 /// `create_node_on(name, rmw)` call dispatches to whichever
287 /// session was opened under that RMW name (or, when the rmw name
288 /// matches the primary, the primary session itself).
289 ///
290 /// Single-backend callers should keep using
291 /// [`open`](Self::open) — this entry costs an extra
292 /// `open_with_rmw` per spec and adds no value when only one
293 /// backend is linked.
294 ///
295 /// `$NROS_RMW` env is ignored: bridge mode wants explicit names.
296 ///
297 /// Default-sized `alloc` convenience over
298 /// [`open_multi_in`](Self::open_multi_in) (leaks a default backing).
299 #[cfg(feature = "rmw-cffi")]
300 pub fn open_multi(specs: &[SessionSpec<'_>]) -> Result<Self, NodeError> {
301 let sizing = super::storage::ExecutorSizing::DEFAULT;
302 // SAFETY: leaked backing is exactly `sizing.u64_len()` words, `'static`.
303 unsafe { Self::open_multi_in(specs, leak_default_backing(sizing), sizing) }
304 }
305
306 /// Phase 104.C.1 — open the Executor against a specific RMW
307 /// backend by name. Selects from the named registry (Phase
308 /// 104.B.2). `rmw_name` must match one of the names a backend
309 /// registered under (`"zenoh"`, `"cyclonedds"`, `"xrce"`, …).
310 ///
311 /// Equivalent to [`Executor::open`] when the registry has exactly
312 /// one backend (the default-backend fast path). Use this entry
313 /// point in multi-backend builds where `Executor::open` would
314 /// pick the first-registered slot.
315 ///
316 /// Single-Executor multi-Node multi-RMW (the long-term Design X
317 /// from `docs/roadmap/phase-104-multi-backend-bridges.md`) is
318 /// follow-up work — Phase 104.C.2 + C.3.
319 ///
320 /// Default-sized `alloc` convenience over
321 /// [`open_with_rmw_in`](Self::open_with_rmw_in) (leaks a default backing).
322 #[cfg(feature = "rmw-cffi")]
323 pub fn open_with_rmw(rmw_name: &str, config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
324 let sizing = super::storage::ExecutorSizing::DEFAULT;
325 // SAFETY: leaked backing is exactly `sizing.u64_len()` words, `'static`.
326 unsafe { Self::open_with_rmw_in(rmw_name, config, leak_default_backing(sizing), sizing) }
327 }
328}
329
330// phase-271 — no-alloc sized cores for the bridge/named open paths. In
331// `impl<'s>` (not the `'static` alloc block) so they stay available in
332// `rmw-cffi`-without-`alloc` builds (e.g. the `nros-bridge` no_std default),
333// which is where `open_multi`/`open_with_rmw` lived before.
334#[cfg(feature = "rmw-cffi")]
335impl<'s> Executor<'s> {
336 /// Per-entry-sized [`open_multi`](Self::open_multi): carves `backing` for
337 /// the executor's tables instead of leaking a default one.
338 ///
339 /// # Safety
340 /// `backing`/`sizing` as in [`from_session_in`](Self::from_session_in).
341 pub unsafe fn open_multi_in(
342 specs: &[SessionSpec<'_>],
343 backing: &'s mut [MaybeUninit<u64>],
344 sizing: super::storage::ExecutorSizing,
345 ) -> Result<Self, NodeError> {
346 // Phase 249 P4b.1 — backends self-registered via their
347 // `.init_array` ctor before `main`; no runtime section walk.
348 let primary = specs
349 .first()
350 .ok_or(NodeError::Transport(TransportError::ConnectionFailed))?;
351 let primary_session =
352 nros_rmw_cffi::CffiRmw::open_with_rmw(primary.rmw, &primary.to_rmw_config())
353 .map_err(NodeError::Transport)?;
354 // SAFETY: forwarded from this fn's contract.
355 let mut executor = unsafe { Self::from_session_in(primary_session, backing, sizing) };
356 executor.set_node_identity("", "/");
357 // Phase 156 — see `Executor::open` for primary-identity
358 // recording rationale.
359 let _ = executor.primary_rmw_name.push_str(primary.rmw);
360 let _ = executor.primary_locator.push_str(primary.locator);
361 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
362 executor.install_wake_signal_on_primary();
363
364 for spec in specs.iter().skip(1) {
365 let session = nros_rmw_cffi::CffiRmw::open_with_rmw(spec.rmw, &spec.to_rmw_config())
366 .map_err(NodeError::Transport)?;
367 executor
368 .extra_sessions
369 .push(session)
370 .map_err(|_| NodeError::NodeTableFull)?;
371 // Issue 0436 — record WHICH backend/locator this extra is, so
372 // `NodeBuilder::rmw(name)` can find it instead of opening a second
373 // session against the same (singleton) backend.
374 {
375 let mut rmw_s = heapless::String::<32>::new();
376 let _ = rmw_s.push_str(spec.rmw);
377 let mut loc_s = heapless::String::<128>::new();
378 let _ = loc_s.push_str(spec.locator);
379 let _ = executor.extra_session_ids.push((rmw_s, loc_s));
380 }
381 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
382 {
383 let idx = executor.extra_sessions.len() - 1;
384 executor.install_wake_signal_on_extra(idx);
385 }
386 }
387
388 Ok(executor)
389 }
390
391 /// Per-entry-sized [`open_with_rmw`](Self::open_with_rmw): carves `backing`
392 /// for the executor's tables instead of leaking a default one.
393 ///
394 /// # Safety
395 /// `backing`/`sizing` as in [`from_session_in`](Self::from_session_in).
396 pub unsafe fn open_with_rmw_in(
397 rmw_name: &str,
398 config: &ExecutorConfig<'_>,
399 backing: &'s mut [MaybeUninit<u64>],
400 sizing: super::storage::ExecutorSizing,
401 ) -> Result<Self, NodeError> {
402 if !nros_rmw_cffi::backend_registered() {
403 return Err(NodeError::Transport(TransportError::ConnectionFailed));
404 }
405
406 let rmw_config = nros_rmw::RmwConfig {
407 locator: config.locator,
408 mode: config.mode,
409 domain_id: config.domain_id,
410 node_name: config.node_name,
411 namespace: config.namespace,
412 properties: &[],
413 };
414 let session = nros_rmw_cffi::CffiRmw::open_with_rmw(rmw_name, &rmw_config)
415 .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed))?;
416 // SAFETY: forwarded from this fn's contract.
417 let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
418 {
419 // `config.clock_us` overrides the constructor's platform default
420 // (issue: assigning `None` here clobbered it and re-enabled the
421 // credit-the-requested-timeout fallback).
422 if let Some(clock) = config.clock_us {
423 executor.clock_us_fn = Some(clock);
424 executor.last_spin_end_us = Some(clock());
425 }
426 // issue 0671 — the SAME rule as `clock_us` directly above, which is
427 // why that one is guarded: a config that does not SPECIFY an epoch
428 // must not be read as "this target HAS no epoch". Assigning `None`
429 // here clobbered the constructor's platform default
430 // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
431 // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
432 // so EVERY hosted node built that way silently lost its wall clock.
433 // With no epoch, `Node::subscription` never attaches the age cell
434 // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
435 // `max_age_ms` contract becomes a silently-dead monitor — the exact
436 // outcome RFC-0052 says must never happen. The rate monitor rides
437 // the GUARDED `clock_us_fn` and kept working, which is why only the
438 // age half went quiet.
439 if let Some(epoch) = config.epoch_us {
440 executor.epoch_us_fn = Some(epoch);
441 }
442 }
443 executor.set_node_identity(config.node_name, config.namespace);
444 // Phase 156 — record primary identity for the session-
445 // cache hit path. See `Executor::open` for the rationale.
446 let _ = executor.primary_rmw_name.push_str(rmw_name);
447 let _ = executor.primary_locator.push_str(config.locator);
448 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
449 executor.install_wake_signal_on_primary();
450 Ok(executor)
451 }
452}
453
454/// Phase 128.F.1 — per-backend session declaration for
455/// [`Executor::open_multi`]. Each spec names an RMW backend (must
456/// match one a backend registered under via
457/// `nros_rmw_cffi_register_named` / the `RMW_INIT_ENTRIES` linker
458/// section) and the locator + domain id to open against it.
459#[cfg(feature = "rmw-cffi")]
460#[derive(Clone, Copy)]
461pub struct SessionSpec<'cfg> {
462 pub rmw: &'cfg str,
463 pub locator: &'cfg str,
464 pub domain_id: u32,
465 pub node_name: &'cfg str,
466 pub namespace: &'cfg str,
467}
468
469#[cfg(feature = "rmw-cffi")]
470impl<'cfg> SessionSpec<'cfg> {
471 /// Minimal spec — just RMW name + locator. Domain id defaults to
472 /// 0; node name and namespace are empty.
473 pub const fn new(rmw: &'cfg str, locator: &'cfg str) -> Self {
474 Self {
475 rmw,
476 locator,
477 domain_id: 0,
478 node_name: "",
479 namespace: "",
480 }
481 }
482
483 pub const fn domain_id(mut self, domain_id: u32) -> Self {
484 self.domain_id = domain_id;
485 self
486 }
487
488 pub const fn node_name(mut self, name: &'cfg str) -> Self {
489 self.node_name = name;
490 self
491 }
492
493 pub const fn namespace(mut self, ns: &'cfg str) -> Self {
494 self.namespace = ns;
495 self
496 }
497
498 fn to_rmw_config(self) -> nros_rmw::RmwConfig<'cfg> {
499 nros_rmw::RmwConfig {
500 locator: self.locator,
501 mode: nros_rmw::SessionMode::Client,
502 domain_id: self.domain_id,
503 node_name: self.node_name,
504 namespace: self.namespace,
505 properties: &[],
506 }
507 }
508}
509
510// Phase 128.A.3 — selector for the single-backend resolution path.
511//
512// On hosted (`std`) builds, read `$NROS_RMW`; mirrors ROS 2's
513// `RMW_IMPLEMENTATION`. Returns the name as a byte vector so the
514// caller can pass it to `nros_rmw_cffi::resolve_backend` and (when
515// `Some`) to `CffiRmw::open_with_rmw`.
516//
517// On `no_std` / bare-metal builds, environment variables are not
518// available; resolution always falls through to the single-backend
519// or ambiguous path. Embedded users with multiple backends use the
520// bridge surface `Executor::open_multi` instead.
521// phase-359 W10 / issue 0687 — the private reader that stood here is gone, and
522// so is the shared one that briefly replaced it. `ExecutorConfig::rmw` carries
523// the selection instead: `nros::rmw_selector` reads the variable ONCE, at the
524// hosted edge, and every consumer takes the value.
525
526// ============================================================================
527// SessionStore — owned or borrowed session
528// ============================================================================
529
530/// Session storage: owned or borrowed via raw pointer.
531///
532/// The C API creates a session in `nros_support_init()` before the
533/// executor. `Borrowed` lets the executor use that session without owning it.
534#[allow(clippy::large_enum_variant)]
535pub(crate) enum SessionStore {
536 Owned(session::ConcreteSession),
537 Borrowed(*mut session::ConcreteSession),
538}
539
540impl core::ops::Deref for SessionStore {
541 type Target = session::ConcreteSession;
542 fn deref(&self) -> &session::ConcreteSession {
543 match self {
544 SessionStore::Owned(s) => s,
545 SessionStore::Borrowed(ptr) => unsafe { &**ptr },
546 }
547 }
548}
549
550impl core::ops::DerefMut for SessionStore {
551 fn deref_mut(&mut self) -> &mut session::ConcreteSession {
552 match self {
553 SessionStore::Owned(s) => s,
554 SessionStore::Borrowed(ptr) => unsafe { &mut **ptr },
555 }
556 }
557}
558
559/// Phase 228.E — an opaque, `Send` handle to an [`Executor`]'s RMW session.
560///
561/// In the per-tier model the boot executor opens the one session and hands each
562/// spawned tier task a handle (not a borrow) so the task opens its own
563/// [`Executor`] over that *same* session across the RTOS task boundary. Wrapping
564/// the `pub(crate)` session pointer lets board crates (`nros-board-linux`,
565/// `nros-board-freertos`, …) name + move the handle without naming the session
566/// type. Obtain via [`Executor::session_handle`]; consume via
567/// [`Executor::open_with_session_handle`].
568#[cfg(any(has_rmw, test))]
569pub struct SessionHandle(*mut session::ConcreteSession);
570
571// SAFETY: the per-tier model deliberately shares one session across RTOS tasks;
572// concurrent access is serialized by the RMW backend's internal locks (the RTOS
573// targets build zenoh-pico `Z_FEATURE_MULTI_THREAD=1` — RFC-0032 §5.0). The
574// boot executor owns the session and outlives every tier task.
575#[cfg(any(has_rmw, test))]
576unsafe impl Send for SessionHandle {}
577
578#[cfg(any(has_rmw, test))]
579impl SessionHandle {
580 /// Phase 274.W1 — convert to an opaque `*mut c_void` for C/C++ FFI.
581 ///
582 /// The returned pointer encodes the session address and is valid as long as
583 /// the owning executor lives. Reconstruct via [`Self::from_raw`].
584 pub fn into_raw(self) -> *mut core::ffi::c_void {
585 self.0 as *mut core::ffi::c_void
586 }
587
588 /// Phase 274.W1 — reconstruct a `SessionHandle` from an opaque pointer
589 /// returned by [`Self::into_raw`].
590 ///
591 /// # Safety
592 /// `ptr` must be a pointer obtained from `into_raw()` on a `SessionHandle`
593 /// whose underlying session is still live and owned by its original executor.
594 pub unsafe fn from_raw(ptr: *mut core::ffi::c_void) -> Self {
595 Self(ptr as *mut session::ConcreteSession)
596 }
597}
598
599/// Phase 228.C — pure callback-group filter decision. `None` = wildcard (accept
600/// every group); `Some` = accept only listed groups. Backs
601/// [`Executor::group_active`]; split out so the logic is unit-testable without a
602/// live session.
603pub(crate) fn group_filter_accepts<const N: usize>(
604 active: Option<&[heapless::String<N>]>,
605 group: &str,
606) -> bool {
607 match active {
608 None => true,
609 Some(v) => v.iter().any(|g| g.as_str() == group),
610 }
611}
612
613#[cfg(test)]
614mod group_filter_tests {
615 use super::group_filter_accepts;
616
617 type Group = heapless::String<32>;
618
619 fn group(s: &str) -> Group {
620 let mut g = Group::new();
621 g.push_str(s).unwrap();
622 g
623 }
624
625 #[test]
626 fn wildcard_accepts_all() {
627 assert!(group_filter_accepts::<32>(None, "anything"));
628 }
629
630 #[test]
631 fn set_accepts_only_listed_groups() {
632 let active = [group("ctrl")];
633 assert!(group_filter_accepts(Some(&active[..]), "ctrl"));
634 assert!(!group_filter_accepts(Some(&active[..]), "telem"));
635 }
636
637 /// phase-409 — the wildcard and an EMPTY filter are different answers, and
638 /// the carved table cannot tell them apart on its own (the old
639 /// `Option<Vec>` could). `Executor::active_groups_filtering` is what keeps
640 /// them apart; this pins the distinction at the decision itself.
641 #[test]
642 fn an_empty_filter_is_not_the_wildcard() {
643 let empty: [Group; 0] = [];
644 assert!(!group_filter_accepts(Some(&empty[..]), "anything"));
645 assert!(group_filter_accepts::<32>(None, "anything"));
646 }
647}
648
649// ============================================================================
650// Executor
651// ============================================================================
652
653/// Backend-agnostic executor that owns a session.
654///
655/// Provides `create_node()` for entity creation and `drive_io()` for polling.
656///
657/// # Callback Mode
658///
659/// The executor supports arena-based callback registration via the
660/// `node_mut(id).subscription(t)` builder and
661/// [`register_service()`](Self::register_service), with dispatch via
662/// [`spin_once()`](Self::spin_once). No heap allocation is needed.
663///
664/// The sizes are set via `NROS_EXECUTOR_MAX_CBS` (default 4) and
665/// `NROS_EXECUTOR_ARENA_SIZE` (default 4096) environment variables at build time.
666///
667/// Phase 124.B.2 — opaque context handed to the runtime wake
668/// callback. Backends store the raw pointer + invoke the callback;
669/// the callback decodes back to `&WakeCtx`.
670#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
671pub(crate) struct WakeCtx {
672 pub(crate) flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
673 /// The wake primitive. Phase 130.3 added it beside a `std::sync::Condvar`
674 /// pair and noted that "a future migration to a single primitive flips one
675 /// branch instead of two"; phase-359 W10 is that migration, and this is the
676 /// one that survived.
677 pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
678}
679
680/// Phase 124.B.2 — runtime wake callback.
681///
682/// RT-context contract:
683///
684/// * **Thread-safe**: callable from any thread. The cb is lock-free
685/// on the cv path — no mutex held during `notify_all`. Lost-wakeup
686/// is prevented by the waiter checking `wake_flag` under
687/// `wake_mu` via the `wait_timeout_while` predicate.
688/// * **NOT async-signal-safe on POSIX**: `pthread_cond_signal`
689/// isn't on the POSIX async-signal-safe function list. For POSIX
690/// signal handler wake, use a `signalfd` + select pattern in a
691/// thread that owns the wake duty — that pattern is Linux-only
692/// (`signalfd`/`eventfd` are Linux syscalls, not POSIX), which is
693/// why the `WakeSignalFd` worker is gated `target_os = "linux"`.
694/// * **RTOS ISR**: per-RTOS platform layer wraps the cv with an
695/// ISR-safe primitive (`xSemaphoreGiveFromISR`,
696/// `tx_event_flags_set` from ISR, `k_sem_give` from ISR on
697/// Zephyr). Backend's ISR caller routes through the platform's
698/// `signal_from_isr` API instead of this cb directly.
699/// * **Bounded execution time**: O(1) — atomic store + cv notify.
700/// No allocation, no contended lock.
701///
702/// The cb is the symbol backends invoke from their async wake path
703/// (datagram arrival, worker-thread enqueue, etc.). It does
704/// flag-write + condvar-signal in that order, lock-free.
705#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
706pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb(ctx: *mut core::ffi::c_void) {
707 if ctx.is_null() {
708 return;
709 }
710 // Phase 141.B.2 — capture T0 at cb entry. No-op when the
711 // probe feature is off or no cycle reader is installed.
712 #[cfg(feature = "wake-latency-probe")]
713 super::wake_probe::on_wake();
714 // SAFETY: ctx points at a `WakeCtx` owned by an Executor still
715 // alive at the time of the call. Executor::drop must clear the
716 // callback via `set_wake_callback(None, _)` on all sessions
717 // before dropping wake_ctx; this happens in `install_wake_*`
718 // teardown path.
719 let wake = unsafe { &*(ctx as *const WakeCtx) };
720 // Lock-free: the `flag` store is SeqCst and therefore happens-before any
721 // subsequent acquire in the waiter, so a wake cannot be missed even though
722 // nothing is held here.
723 wake.flag.store(true, core::sync::atomic::Ordering::SeqCst);
724 // phase-359 W10 — ONE primitive. This used to signal both a
725 // `std::sync::Condvar` and (since phase 130.3) the `NodeWake`, "so the cb
726 // keeps working whichever wait primitive spin_once is using". `spin_once`
727 // now has only one, so this has only one to signal.
728 if let Some(nw) = wake.node_wake.as_ref() {
729 nw.signal();
730 }
731}
732
733/// Phase 124.B.7.c — Linux signalfd worker.
734///
735/// Owns a Linux `eventfd` plus a worker task that `read()`s the
736/// fd and forwards it as a wake. The eventfd
737/// write side is async-signal-safe per the kernel contract
738/// (`write(2)` to an eventfd is permitted from signal handlers),
739/// closing the gap that `pthread_cond_signal` leaves open on POSIX.
740///
741/// Lifecycle:
742/// * Constructed lazily in `Executor::signal_fd()` on first
743/// caller request.
744/// * `Drop` writes a shutdown sentinel + joins the worker.
745///
746/// Caller flow (signal handler):
747/// 1. Get fd via `Executor::signal_fd()` before installing the
748/// handler.
749/// 2. Handler does `eventfd_write(fd, 1)` (equivalently,
750/// `write(fd, &1u64, 8)`).
751/// 3. Worker thread reads the fd, signals wake_cv. spin_once
752/// blocked in cv.wait_timeout_while sees flag=true and exits.
753///
754/// phase-359 W10 — the worker is a PLATFORM TASK, not a `std::thread`, and it
755/// forwards through the same [`NodeWake`](super::node_wake::NodeWake) the
756/// runtime wake callback uses rather than a `std::sync::Condvar`. The eventfd
757/// itself is still Linux — that is what makes the write async-signal-safe — so
758/// this stays `target_os = "linux"`; what it no longer is, is std-only.
759#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
760pub struct WakeSignalFd {
761 fd: core::ffi::c_int,
762 ctx: portable_atomic_util::Arc<SignalFdCtx>,
763 task: Option<nros_platform_api::task::PlatformTask>,
764}
765
766/// What the signalfd worker task needs, reachable through one pointer.
767#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
768struct SignalFdCtx {
769 fd: core::ffi::c_int,
770 shutdown: portable_atomic::AtomicBool,
771 /// The executor's wake state — the same `WakeCtx` the runtime callback
772 /// decodes, reached as an address so the context is plainly `Send`.
773 wake_ctx: usize,
774}
775
776/// Worker entry: read the eventfd, forward as a wake, until shut down.
777///
778/// # Safety
779/// `arg` must point at a live [`SignalFdCtx`] whose `wake_ctx` addresses a
780/// `WakeCtx` that outlives this task.
781#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
782unsafe extern "C" fn signal_fd_worker(arg: *mut core::ffi::c_void) -> *mut core::ffi::c_void {
783 // SAFETY: the spawn site passes `Arc::as_ptr` of a ctx it keeps alive until
784 // after the join in `Drop`.
785 let ctx = unsafe { &*(arg as *const SignalFdCtx) };
786 loop {
787 let mut buf = [0u8; 8];
788 // SAFETY: reading 8 bytes from an eventfd into an 8-byte buffer.
789 let n = unsafe { libc::read(ctx.fd, buf.as_mut_ptr() as *mut core::ffi::c_void, 8) };
790 if ctx.shutdown.load(portable_atomic::Ordering::Acquire) {
791 return core::ptr::null_mut();
792 }
793 if n <= 0 {
794 // EINTR / EOF — shutdown was re-checked above, so loop.
795 continue;
796 }
797 // Same effect as `nros_rmw_runtime_wake_cb`, reached directly because
798 // the shutdown check above plus `Drop`'s join is what guarantees the
799 // `WakeCtx` is still alive.
800 // SAFETY: `wake_ctx` addresses the executor's `WakeCtx`, which outlives
801 // this task by that same guarantee.
802 unsafe {
803 let w = &*(ctx.wake_ctx as *const WakeCtx);
804 w.flag.store(true, portable_atomic::Ordering::SeqCst);
805 if let Some(wake) = w.node_wake.as_ref() {
806 wake.signal();
807 }
808 }
809 }
810}
811
812#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
813impl WakeSignalFd {
814 /// Spawn the worker. `wake_ctx_ptr` is the `*const WakeCtx`
815 /// produced by `Executor::wake_ctx_ptr` — same value the
816 /// runtime wake cb decodes.
817 ///
818 /// phase-359 W10 — returns `NodeError` rather than `std::io::Error`. The
819 /// only caller is `Executor::signal_fd`, and the errno detail it used to
820 /// carry had no consumer: the one failure a caller can act on is "this
821 /// platform would not give me the worker", which is what
822 /// `NotInitialized` says — the eventfd and the task are both subsystems
823 /// this capability requires and neither is guaranteed.
824 fn new(wake_ctx_ptr: *const WakeCtx) -> Result<Self, NodeError> {
825 // SAFETY: `eventfd(2)` with a valid flag; returns -1 on failure.
826 let fd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) };
827 if fd < 0 {
828 return Err(NodeError::NotInitialized);
829 }
830 let ctx = portable_atomic_util::Arc::new(SignalFdCtx {
831 fd,
832 shutdown: portable_atomic::AtomicBool::new(false),
833 wake_ctx: wake_ctx_ptr as usize,
834 });
835 let arg = portable_atomic_util::Arc::as_ptr(&ctx) as *mut core::ffi::c_void;
836 // SAFETY: `arg` points at a ctx this struct owns and keeps alive until
837 // after the join in `Drop`.
838 let Some(task) = (unsafe {
839 nros_platform_api::task::PlatformTask::spawn(
840 signal_fd_worker,
841 arg,
842 // A read(2) loop and one atomic store — the smallest stack any
843 // port will honour is plenty.
844 8192,
845 c"nros-wakefd".as_ptr(),
846 )
847 }) else {
848 // SAFETY: nothing else holds the fd — the task never started.
849 unsafe { libc::close(fd) };
850 return Err(NodeError::NotInitialized);
851 };
852 Ok(Self {
853 fd,
854 ctx,
855 task: Some(task),
856 })
857 }
858
859 /// Returns the writable eventfd. The caller (typically a POSIX
860 /// signal handler) writes any non-zero 8-byte value to trigger
861 /// a wake. `write(2)` on an eventfd is async-signal-safe per
862 /// `eventfd(2)` man page.
863 pub fn fd(&self) -> core::ffi::c_int {
864 self.fd
865 }
866}
867
868#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
869impl Drop for WakeSignalFd {
870 fn drop(&mut self) {
871 self.ctx
872 .shutdown
873 .store(true, portable_atomic::Ordering::Release);
874 // Wake the worker so it re-checks shutdown: it is blocked in `read`,
875 // which only this write can release.
876 let one: u64 = 1;
877 // SAFETY: an 8-byte write to our own eventfd.
878 unsafe {
879 libc::write(self.fd, &one as *const u64 as *const core::ffi::c_void, 8);
880 }
881 if let Some(task) = self.task.take() {
882 task.join();
883 }
884 // SAFETY: the worker has exited, so nothing else touches the fd.
885 unsafe { libc::close(self.fd) };
886 }
887}
888
889/// Phase 124.B.7.b — ISR / interrupt-context wake callback.
890///
891/// Same semantics as [`nros_rmw_runtime_wake_cb`] but constrained to
892/// async-signal-safe / ISR-safe primitives.
893///
894/// Per-platform routing:
895///
896/// * **POSIX (std)**: `pthread_cond_signal` is NOT on the POSIX
897/// async-signal-safe function list. Calling from a SIGUSR1
898/// handler is technically UB. Real fix (Phase 124.B.7.c) routes
899/// via `signalfd`/`eventfd` + a runtime worker thread — Linux
900/// only, since neither syscall is POSIX; until that lands,
901/// signal-handler callers MUST use
902/// `nros_guard_condition_trigger` from a **separate thread** (not
903/// from the handler itself), OR set the wake_flag and rely on
904/// the next poll deadline. This cb currently aliases the regular
905/// `wake_cb` and is safe only from non-signal-handler ISR-like
906/// contexts (e.g. timer thread, kernel callback).
907///
908/// * **RTOS no_std (Zephyr/FreeRTOS/ThreadX)**: routes through the
909/// platform-cffi `condvar_signal_from_isr` slot. Each backend
910/// uses its ISR-safe variant — `xSemaphoreGiveFromISR`,
911/// `tx_semaphore_put`, `k_condvar_signal`.
912///
913/// `ctx` semantics identical to [`nros_rmw_runtime_wake_cb`].
914#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
915#[allow(dead_code)] // Public exposure pending B.7.c signalfd worker.
916pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb_from_isr(ctx: *mut core::ffi::c_void) {
917 // Today: alias regular wake_cb. POSIX signal-handler safety
918 // pending B.7.c (signalfd worker-thread forward). Documented in
919 // the contract above so callers know the boundary.
920 unsafe { nros_rmw_runtime_wake_cb(ctx) };
921}
922
923/// Phase 216 follow-up — per-Node dispatch trampoline registered with
924/// [`Executor::register_dispatch_slot`].
925///
926/// The board-side dispatch task (RTIC `__nros_run` / Embassy
927/// `__nros_run_task`) dequeues a `nros_platform::SignaledCallback`
928/// envelope and forwards `(cb_id, ctx_ptr)` into
929/// [`Executor::dispatch_callback`]; that method linear-scans this
930/// slot table and invokes every registered `on_callback` with the
931/// owning Node's per-Node `state` blob. Each Node's
932/// `__nros_node_<pkg>_on_callback` self-filters on its own
933/// `CallbackId` tag set, so a slot whose Node doesn't own this
934/// callback is a cheap no-op string compare.
935///
936/// The shape mirrors the per-pkg `__nros_node_<pkg>_on_callback`
937/// extern "C" trampoline emitted by the `nros::node!()` macro
938/// (see `packages/core/nros-macros/src/lib.rs` Phase 216.A.5).
939///
940/// # Why not `linkme`
941///
942/// `linkme::distributed_slice` hangs on bare-metal Cortex-M /
943/// RISC-V because `cortex_m_rt`'s link script doesn't provide the
944/// `__start_/__stop_` section anchors in a shape that lets the
945/// iterator terminate (see
946/// `packages/rmw/cffi/src/section.rs` Phase 142). Since
947/// stm32f4 RTIC / Embassy boards are the whole point of Phase 216,
948/// the registry uses the explicit `register()` pattern from Phase
949/// 104.A.
950#[derive(Clone, Copy)]
951pub struct DispatchSlot {
952 /// Owning Node's `State` blob — produced by the macro-emitted
953 /// `i()` and round-tripped through
954 /// `nros::__private_node_state_into_raw`. Opaque to the
955 /// executor.
956 pub state: *mut core::ffi::c_void,
957 /// Per-Node `extern "C"` trampoline; signature matches the
958 /// `__nros_node_<pkg>_on_callback` symbol the `nros::node!()`
959 /// macro emits.
960 pub on_callback: unsafe extern "C" fn(
961 state: *mut core::ffi::c_void,
962 cb_id_ptr: *const u8,
963 cb_id_len: usize,
964 ctx: *mut core::ffi::c_void,
965 ),
966}
967
968// SAFETY: `DispatchSlot` carries two raw pointers (`state` + an
969// extern "C" fn pointer). The fn pointer is `Send`/`Sync` by
970// definition; the `state` pointer's `Send`/`Sync` story matches the
971// owning `Executor` (which is `unsafe impl Send`). Treating the
972// slot itself as `Send` keeps the existing `Executor` Send impl
973// intact — see `unsafe impl Send for Executor {}` later in this
974// file.
975unsafe impl Send for DispatchSlot {}
976unsafe impl Sync for DispatchSlot {}
977
978/// Phase 258 (Track 2, 2a) — executor-owned component tick slot.
979///
980/// The layering-clean half of the W0-B `install` seam's tick fix
981/// (phase-257 D2). A `nros`-layer `install`/`register_node_borrowed`
982/// builds an `Arc<ComponentCell>` (the typed/poll-driven component
983/// state) and enrolls it here via [`Executor::enroll_component`]; the
984/// executor then drives `tick` on every enrolled slot at the tail of
985/// each [`spin_once`](Executor::spin_once) — so `install`'d nodes
986/// (C, C++, **and Rust owned-spin**) tick, closing the
987/// service-client/action poll gap that the callback-`Arc`-only
988/// lifetime left open.
989///
990/// Like [`DispatchSlot`] the executor only sees raw pointers + `extern
991/// "C"` fn pointers (no `nros` dep — `nros-node` is the lower layer):
992///
993/// * `state` — a *leaked* `Arc<ComponentCell>` (via `Arc::into_raw`),
994/// re-borrowed by the `nros`-side `tick`/`drop` fns. Unlike a
995/// pub/sub/timer component (kept alive by the executor's per-entity
996/// callback `Arc` clones), a poll-only component has no callbacks, so
997/// the slot must own a clone of the cell — hence the paired `drop`.
998/// * `tick` — `nros`-side `extern "C"` fn that casts `state` back to
999/// `&ComponentCell`, casts `exec_ctx` back to `*mut Executor`, and
1000/// runs that one cell's tick (mirrors `ExecutorNodeRuntime::run_ticks`).
1001/// * `drop` — `nros`-side `extern "C"` fn run on `Executor::drop` that
1002/// reconstitutes + drops the leaked `Arc`, so the executor owns the
1003/// cell's lifetime.
1004///
1005/// Kept a SEPARATE registry from [`DispatchSlot`] on purpose: framework
1006/// dispatch (RTIC / Embassy) is interrupt-driven, name-keyed, and has no
1007/// tick/own concern — mixing the two risks that path.
1008#[derive(Clone, Copy)]
1009pub struct ComponentSlot {
1010 /// Leaked `Arc<ComponentCell>` (opaque to the executor). Owned by
1011 /// this slot — dropped via `drop` on `Executor::drop`.
1012 pub state: *mut core::ffi::c_void,
1013 /// `nros`-side tick trampoline: `(state, exec_ctx)` where `exec_ctx`
1014 /// is `*mut Executor`. Drives one component's `tick`.
1015 pub tick: unsafe extern "C" fn(state: *mut core::ffi::c_void, exec_ctx: *mut core::ffi::c_void),
1016 /// `nros`-side drop trampoline: reconstitutes + drops the leaked
1017 /// `Arc<ComponentCell>` at `state`. Run once on `Executor::drop`.
1018 pub drop: unsafe extern "C" fn(state: *mut core::ffi::c_void),
1019}
1020
1021// SAFETY: same story as `DispatchSlot` — two raw pointers + two extern
1022// "C" fn pointers. The `state` pointer's Send/Sync matches the owning
1023// `Executor` (`unsafe impl Send for Executor`); the fn pointers are
1024// Send/Sync by definition.
1025unsafe impl Send for ComponentSlot {}
1026unsafe impl Sync for ComponentSlot {}
1027
1028/// phase-271 — fixed capacity of the per-spin ready-sets (FIFO bitmap + EDF
1029/// heap) and the dispatch loop's upper bound. The executor's callback index is
1030/// carried in a `u64` active-mask (`1u64 << i`), so a callback table can hold at
1031/// most 64 entries regardless of per-entry sizing; the ready-sets are sized to
1032/// this ceiling (stack-transient) so any entry slice up to 64 dispatches
1033/// correctly. `EdfReadySet`'s presence bitmap independently asserts `N <= 64`.
1034pub(crate) const MAX_CALLBACK_SLOTS: usize = 64;
1035
1036/// Phase 305 W3 (issue 0255) — upper bound on launch remap rules held by one
1037/// executor (across all its nodes). Launch files carry a handful per node;
1038/// raise here if a plan legitimately outgrows it.
1039pub const MAX_REMAPS: usize = 16;
1040
1041/// Phase 305 W3 (issue 0255) — one launch `<remap from= to=/>` rule, scoped to
1042/// the node that declared it. `from`/`to` are stored RAW (as written); the
1043/// lookup in [`Executor::resolve_entity_name_for`] expands both sides against
1044/// the owning node's identity via `crate::names` (exact-FQN match, no
1045/// wildcards).
1046pub(crate) struct RemapRule {
1047 pub(crate) node_name: heapless::String<64>,
1048 pub(crate) namespace: heapless::String<64>,
1049 pub(crate) from: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1050 pub(crate) to: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1051}
1052
1053pub struct Executor<'s> {
1054 /// Issue 0656 — the ROS domain this executor's entities belong to.
1055 ///
1056 /// Retained because it was NOT: `open_in` passed `config.domain_id` into
1057 /// `RmwConfig` and dropped it, so every entity built from the executor
1058 /// (rather than from a `Node`, which keeps its own) declared on domain 0
1059 /// whatever `ROS_DOMAIN_ID` said. The value was read, printed, and then not
1060 /// used where it counts — issue 0161's shape.
1061 pub(crate) domain_id: u32,
1062 pub(crate) session: SessionStore,
1063 /// phase-271 (issue 0110) — the six sized tables are no longer inline
1064 /// arrays baked to `nros-node`'s build-time consts; they borrow
1065 /// caller-owned, per-entry-sized storage (`&'s mut` slices carved from a
1066 /// raw `[MaybeUninit<u64>]` backing by [`super::storage::carve`]). Lets a
1067 /// fat native entry and a lean embedded entry in one shared-target
1068 /// workspace each size to its own topology. `Executor` stays non-generic
1069 /// (lifetime only) so the C/C++ FFI keeps wrapping one concrete type.
1070 pub(crate) arena: &'s mut [MaybeUninit<u8>],
1071 pub(crate) arena_used: usize,
1072 pub(crate) entries: &'s mut [Option<CallbackMeta>],
1073 /// Phase 110.B — registered scheduling contexts. Slot 0 is
1074 /// auto-populated with a `Fifo` SC at construction; every entry
1075 /// without an explicit binding maps to it via
1076 /// `sched_context_bindings`.
1077 pub(crate) sched_contexts: &'s mut [Option<super::sched_context::SchedContext>],
1078 /// Per-entry SC binding parallel to `entries`. Defaults to
1079 /// `SchedContextId(0)` (the auto-created Fifo SC).
1080 pub(crate) sched_context_bindings: &'s mut [super::sched_context::SchedContextId],
1081 /// Phase 110.E — user-space sporadic-server budget state per
1082 /// Sporadic-class SC. Slot indices match `sched_contexts`; non-
1083 /// Sporadic slots stay `None`.
1084 pub(crate) sporadic_states: &'s mut [Option<super::sched_context::SporadicState>],
1085 /// Phase 110.E.b — atomic sporadic state + opaque platform-timer
1086 /// handle for ISR-driven refill. Populated by
1087 /// `register_sporadic_timer`; dropped on Executor `Drop` via the
1088 /// stored `destroy_fn`.
1089 #[cfg(feature = "alloc")]
1090 pub(crate) sporadic_atomic_states: &'s mut [Option<(
1091 portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
1092 OpaqueTimerHandle,
1093 )>],
1094 /// Phase 110.G — major-frame length for time-triggered dispatch.
1095 /// `0` (default) disables the TT gate entirely; non-zero enables
1096 /// gating per
1097 /// `SchedContext.tt_window_offset_us / tt_window_duration_us`.
1098 pub(crate) major_frame_us: u32,
1099 /// Phase 110.F — per-OS-priority worker pool. Lazily populated
1100 /// on first dispatch routing to a non-zero `os_pri`.
1101 ///
1102 /// phase-359 W10 — was `std::collections::HashMap<u8, OsPriorityWorker>`
1103 /// behind `feature = "std"`, because the workers were `std::thread` +
1104 /// `mpsc`. They are platform tasks now (`super::os_priority`), so the pool
1105 /// needs only `alloc` and this capability is no longer std-only.
1106 #[cfg(all(
1107 feature = "alloc",
1108 feature = "rmw-cffi",
1109 feature = "scheduler-os-priority"
1110 ))]
1111 pub(crate) os_priority_pool: super::os_priority::OsPriorityPool,
1112 /// Phase 110.F — caller-supplied `apply_policy` function pointer
1113 /// each worker invokes at startup to elevate its OS priority.
1114 /// `None` = the worker pool is disabled; entries bound to non-
1115 /// zero `os_pri` SCs fall back to the cooperative path.
1116 /// Mirrors `Executor::open_threaded`'s `apply_policy: fn(...)`
1117 /// shape — keeps Executor non-generic over Platform.
1118 // phase-359 W10 — the POLICY is separable from the POOL. Registering it
1119 // needs nothing but the feature; hosting workers needs a platform
1120 // (`rmw-cffi`, the same proxy `node_wake` uses). A build that can register
1121 // but not host falls back to cooperative dispatch, so the public
1122 // `register_os_priority_dispatcher` keeps its old availability.
1123 #[cfg(all(feature = "alloc", feature = "scheduler-os-priority"))]
1124 pub(crate) os_priority_apply_policy:
1125 Option<fn(nros_platform_api::SchedPolicy) -> Result<(), nros_platform_api::SchedError>>,
1126 pub(crate) trigger: Trigger,
1127 pub(crate) semantics: ExecutorSemantics,
1128 /// Node name for entities created via `register_subscription`/`register_service`.
1129 /// Empty means unset — no liveliness tokens will be declared.
1130 pub(crate) node_name: heapless::String<64>,
1131 /// Phase 228.C — per-tier callback-group filter. Wildcard (register every
1132 /// callback — the single-tier degenerate case + today's behaviour) until
1133 /// [`set_active_groups`](Self::set_active_groups) names a non-empty set;
1134 /// after that this tier's executor accepts only callbacks whose
1135 /// `.callback_group()` is in it, and skips the others at registration.
1136 ///
1137 /// phase-409 — CARVED, and the wildcard is a separate flag rather than an
1138 /// `Option` around the table, because the table itself no longer lives in
1139 /// the value. "Filtering with an empty set" and "not filtering" stay
1140 /// distinguishable, which is the whole content of the old `Option`.
1141 pub(crate) active_groups: super::storage::CarvedVec<'s, super::storage::GroupName>,
1142 /// Whether [`active_groups`](Self::active_groups) is a filter at all.
1143 /// `false` = wildcard (the old `None`).
1144 pub(crate) active_groups_filtering: bool,
1145 /// Node namespace (default: "/").
1146 pub(crate) namespace: heapless::String<64>,
1147 /// Phase 104.C.2 — rclcpp-style `add_node` table. Holds the
1148 /// per-Node metadata (name, namespace, rmw, locator, default
1149 /// SchedContext) for every Node attached to this Executor. The
1150 /// implicit "primary" Node (NodeId(0)) mirrors `node_name` +
1151 /// `namespace` above and is auto-populated on first use.
1152 ///
1153 /// phase-409 — CARVED. `NodeId` IS an index into this table, so the
1154 /// carved vector must keep push order and never gain a `swap_remove`.
1155 pub(crate) nodes: super::storage::CarvedVec<'s, super::node_record::NodeRecord>,
1156 /// Phase 272 (RFC-0047) — config-seeded node → sched-context bindings, keyed by the node's
1157 /// fully-qualified `(name, namespace)` pair. `NodeBuilder::build` consults this table to set a
1158 /// node's `default_sched` when no explicit `.sched()` was given. Empty ⇒ every node stays
1159 /// `SchedContextId(0)` (byte-identical to pre-272 behaviour). Sized by `MAX_NODES` (at most
1160 /// one tier per node — RFC-0047 OQ1).
1161 ///
1162 /// phase-409 — CARVED.
1163 pub(crate) node_sched_table: super::storage::CarvedVec<'s, super::storage::NodeSchedEntry>,
1164 /// Phase 273 (RFC-0047) — config-seeded per-callback-group sched bindings, keyed by the node's
1165 /// fully-qualified `(name, namespace)` pair PLUS the callback-group name. Overrides the node
1166 /// default for a callback created in that group. Empty ⇒ no per-group binding (node default
1167 /// stands). Sized by `MAX_CBS` — an upper bound on distinct callback-group bindings (you can
1168 /// never have more distinct group bindings than max callbacks).
1169 ///
1170 /// phase-409 (issue 0961) — CARVED, and it is the reason the phase exists:
1171 /// at ~168 B per slot this was `MAX_CBS` * 168 bytes INSIDE the value, so
1172 /// raising the handle limit from 14 to 36 — a fix for an unrelated failure —
1173 /// added ~3.7 KiB to the stack frame of every function that moves an
1174 /// `Executor`, and overflowed a 320 KiB part's main thread.
1175 pub(crate) group_sched_table: super::storage::CarvedVec<'s, super::storage::GroupSchedEntry>,
1176 /// Phase 305 W3 (issue 0255) — launch-baked per-node remap rules, keyed by
1177 /// the declaring node's `(name, namespace)` identity. Entries store the
1178 /// rule RAW (as written in launch); [`Self::resolve_entity_name`] expands
1179 /// both sides against the owning node's identity at lookup, via the shared
1180 /// `crate::names` seam (the same semantics the Rust `ExecutorSink` path
1181 /// applies). Declaration order is match order (first rule wins).
1182 /// Issue 0563 — CARVED, not inline. As a `heapless::Vec<RemapRule,
1183 /// MAX_REMAPS>` this one field was 6664 bytes of an 11632-byte `Executor`
1184 /// (57%), which is what made building an executor a ~9.3 KB stack
1185 /// temporary and overflowed the Zephyr Cortex-M main stack (issue 0552).
1186 /// It is the seventh sized table; phase-271 moved the other six here.
1187 /// `remap_len` is the fill cursor — occupied slots are `[0, remap_len)`.
1188 pub(crate) remap_table: &'s mut [Option<RemapRule>],
1189 pub(crate) remap_len: usize,
1190 /// Phase 216 follow-up — per-Node dispatch trampoline registry.
1191 ///
1192 /// Populated by [`Executor::register_dispatch_slot`]; walked by
1193 /// [`Executor::dispatch_callback`] each time the board-side
1194 /// dispatch task hands off a `SignaledCallback` envelope.
1195 /// Sized by `MAX_NODES` because the upper-bound is one slot per
1196 /// Node pkg deployed on this executor (the same upper bound used
1197 /// by `nodes` and `extra_sessions`). `MAX_NODES` is driven by the
1198 /// `NROS_EXECUTOR_MAX_NODES` build-script env var (default 4);
1199 /// boards that deploy more Node pkgs raise it at build time.
1200 ///
1201 /// Default is `heapless::Vec::new()` (empty) — Nodes register
1202 /// themselves explicitly via the `register_dispatch_slot` API.
1203 /// The fallback shape avoids the `linkme` hazard on bare-metal
1204 /// Cortex-M / RISC-V (see `DispatchSlot` doc).
1205 ///
1206 /// phase-409 — CARVED (sized by `ExecutorSizing::nodes`, which is what
1207 /// `MAX_NODES` now seeds).
1208 pub(crate) dispatch_slots: super::storage::CarvedVec<'s, DispatchSlot>,
1209 /// Phase 258 (Track 2, 2a) — executor-owned component tick registry.
1210 /// Enrolled by [`Executor::enroll_component`] (from `nros`'s
1211 /// `install`/`register_node_borrowed`); each slot's `tick` runs at the
1212 /// tail of [`spin_once`](Self::spin_once); each slot's `drop` runs on
1213 /// `Executor::drop`. Bounded `MAX_NODES` (matches `dispatch_slots` /
1214 /// `nodes`). See [`ComponentSlot`] for why it's separate from
1215 /// `dispatch_slots`.
1216 ///
1217 /// phase-409 — CARVED.
1218 pub(crate) component_slots: super::storage::CarvedVec<'s, ComponentSlot>,
1219 /// Phase 104.C.3 — extra sessions opened by `node_builder.rmw()`
1220 /// calls that named a backend different from the Executor's
1221 /// primary session. Indexed by `NodeRecord.session_idx`
1222 /// (1..=N maps to `extra_sessions[N-1]`; idx 0 is the primary
1223 /// `self.session`). Sized by `NROS_EXECUTOR_MAX_NODES` since one
1224 /// extra session per Node is the worst case.
1225 ///
1226 /// phase-409 — CARVED, and the biggest single win: `ConcreteSession` is
1227 /// 524 B on the island, so this table alone was ~3.1 KiB of the value.
1228 /// Declared AFTER `session` so field-order drop still closes the primary
1229 /// session before the extras (`CarvedVec` owns its elements' drop).
1230 pub(crate) extra_sessions: super::storage::CarvedVec<'s, session::ConcreteSession>,
1231 /// Issue 0436 — `(rmw_name, locator)` for each entry of `extra_sessions`,
1232 /// the extras' equivalent of `primary_rmw_name` / `primary_locator`.
1233 ///
1234 /// Without it an extra session is ANONYMOUS, and
1235 /// `NodeBuilder::resolve_session_slot` could only recognise one by finding a
1236 /// previously-registered `NodeRecord` bound to it. Sessions opened by
1237 /// `open_multi*` have no such Node yet, so the FIRST `.rmw("zenoh")` node fell
1238 /// through to "open a new session" — a SECOND zenoh session, with an empty
1239 /// locator, which fails (see `primary_rmw_name`'s note: zenoh-pico's global
1240 /// state is a process singleton). That is why the PX4 bridge could open both
1241 /// sessions and then fail to bind its outward Node.
1242 ///
1243 /// Written unconditionally by `open_multi*`, but the only READER is
1244 /// `NodeBuilder::resolve_session_slot`, which is `rmw-cffi`-gated — so
1245 /// without that feature the field is genuinely unread and the
1246 /// workspace's `-D dead_code` is right to say so. Allow it exactly
1247 /// there rather than blanket-allowing a field that must stay live in
1248 /// every configuration that can reach the reader.
1249 ///
1250 /// phase-409 — CARVED.
1251 #[cfg_attr(not(feature = "rmw-cffi"), allow(dead_code))]
1252 pub(crate) extra_session_ids: super::storage::CarvedVec<'s, super::storage::ExtraSessionId>,
1253 /// Phase 156 — primary session's rmw name + locator, captured
1254 /// at `open*` time so `NodeBuilder::resolve_session_slot`'s
1255 /// cache lookup can detect when a `.rmw(name).locator(loc)`
1256 /// matches the primary (slot 0) instead of falling through to
1257 /// `CffiRmw::open_with_rmw` and trying to open a SECOND
1258 /// session against the same backend. zenoh-pico's global state
1259 /// is a process singleton; opening twice fails. Empty when
1260 /// constructed via `from_session(_ptr)` without `open*`
1261 /// recording the metadata; in that case the cache check
1262 /// degrades to "always miss" (today's behaviour).
1263 pub(crate) primary_rmw_name: heapless::String<32>,
1264 pub(crate) primary_locator: heapless::String<128>,
1265 // phase-359 W3 — `portable_atomic_util::Arc`, not `std::sync::Arc`. Same
1266 // atomically-refcounted pointer, available without `std`, and it compiles on
1267 // std too. W3 left the GATE on `std` because the public `halt_flag()` getter
1268 // was std-gated; W10 split that impl, so the field joins `wake_flag` on
1269 // `alloc` — the allocator is the real requirement, and without this a no_std
1270 // executor had no halt flag at all and so could not be stopped.
1271 #[cfg(feature = "alloc")]
1272 pub(crate) halt_flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
1273 /// Phase 104.C.6 — shared executor wake flag. Any source of work
1274 /// (foreign thread handing off a callback, signal handler, future
1275 /// per-session vtable wake hook) sets this; `spin_once` swaps it to
1276 /// `false` on entry and, if it was `true`, polls every session with
1277 /// a 0-ms timeout instead of blocking. Lets one notification wake
1278 /// the executor regardless of which session the user is currently
1279 /// blocked on (the multi-RMW bridge case).
1280 // phase-359 W3 — ONE wake flag, both flavours. W2 had to leave this pair
1281 // alone because `wake_handle()` hands it out as a `std::sync::Arc`;
1282 // converting that signature is what lets the two collapse. Gated on
1283 // `alloc` (which `std` implies) because the Arc needs an allocator.
1284 #[cfg(feature = "alloc")]
1285 pub(crate) wake_flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
1286 /// Phase 124.B.2 — wake condvar paired with `wake_flag`. The
1287 /// runtime-supplied wake callback (`nros_rmw_runtime_wake_cb` in
1288 /// nros-rmw-cffi) writes `wake_flag = true` AND signals
1289 /// `wake_cv` atomically under `wake_mu`. `spin_once` blocks on
1290 /// the cv with a deadline instead of calling `drive_io` with the
1291 /// user's timeout — sub-poll-period wake latency.
1292 ///
1293 /// Poll-only backends (NULL `set_wake_callback` slot) leave the
1294 /// cb uninstalled; the cv wait still fires on its deadline,
1295 /// then drive_io(0) drains whatever the backend's internal
1296 /// poll has buffered.
1297 /// Phase 130.3 — Zephyr+std uses `nros_platform_wake_*` (k_sem)
1298 /// instead of `std::sync::Condvar` because Zephyr's libc
1299 /// `pthread_cond_timedwait` hangs past its deadline. `None`
1300 /// when the platform provider didn't link a wake primitive
1301 /// (e.g. test builds with `rmw-cffi` but no `platform-*`
1302 /// feature); spin_once falls back to driving the transport
1303 /// for the full timeout in that case.
1304 // phase-359 W2 — ONE field, not one per flavour. `portable_atomic_util::Arc`
1305 // compiles on std too, and `std` implies `alloc`, so the std and alloc arms
1306 // were two spellings of the same thing. The inner `NodeWake` was already
1307 // shared; only the Arc differed.
1308 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1309 pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
1310 /// Phase 130.4 — true when at least one session's backend
1311 /// installed the wake callback. Drives whether `spin_once`
1312 /// uses the wake-primitive wait (`NodeWake` / `Condvar`) or
1313 /// just `drive_io(timeout_ms)`. Poll-only backends
1314 /// (XRCE-DDS-Client, current Cyclone/dust-DDS shims) leave
1315 /// this `false`; the wait then becomes a no-op sleep that
1316 /// starves reliable retransmission (Phase 127.C.4 root
1317 /// cause: server's `send_response` flushes 100 ms once, then a
1318 /// blind `wait_ms(100)` sleeps with zero session activity, so
1319 /// the agent's ACK arrives into a stalled session and reliable
1320 /// redelivery never fires).
1321 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1322 pub(crate) has_async_wake: bool,
1323 /// Phase 124.B.2 — opaque context Arc handed to backends via
1324 /// `set_wake_callback`. Lazy-allocated on first install; stays
1325 /// alive for the Executor's lifetime so the raw pointer stored
1326 /// in backends remains valid.
1327 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1328 pub(crate) wake_ctx: Option<portable_atomic_util::Arc<WakeCtx>>,
1329 /// Phase 124.B.7.c — lazily-allocated Linux signalfd worker.
1330 /// Owned by the Executor; spawned on first `signal_fd()` call.
1331 /// Drop joins the worker thread and closes the fd.
1332 #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1333 pub(crate) signal_fd: Option<WakeSignalFd>,
1334 #[cfg(feature = "param-services")]
1335 pub(crate) params: Option<alloc::boxed::Box<crate::parameter_services::ParamState<'s>>>,
1336 /// phase-425 W3b — the `/clock` subscription this image installed, if any.
1337 /// `None` means no time source; the value is the handle so it can be
1338 /// cancelled when `use_sim_time` goes false.
1339 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1340 pub(crate) sim_time_source: Option<HandleId>,
1341 /// phase-425 W3b — the LAST REQUESTED state of `use_sim_time`, which is not
1342 /// the same thing as the installed state: a request can arrive before any
1343 /// node exists (`nros::main!` declares parameters BEFORE it registers
1344 /// components), and there is no node to hang a subscription on until then.
1345 /// `reconcile_ros_time_source` closes the gap on each spin.
1346 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1347 pub(crate) sim_time_requested: bool,
1348 /// Whether `use_sim_time` was ever stated to THIS executor.
1349 ///
1350 /// `sim_time_requested` alone cannot answer it: `false` is both "told to
1351 /// turn it off" and "never told anything", and the two license opposite
1352 /// actions on a PROCESS-GLOBAL gate. Without this, every executor that
1353 /// never heard of the parameter wrote `set_active(false)` on its first
1354 /// spin -- because the gate defaults to TRUE, so `false != is_active()`
1355 /// held -- and switched off a simulated clock somebody else installed.
1356 /// One `cargo test` process is exactly that situation: 344 sibling tests
1357 /// spin an executor each, and the sim-time test failed 3 runs of 3 while
1358 /// passing alone.
1359 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1360 pub(crate) sim_time_stated: bool,
1361 #[cfg(feature = "lifecycle-services")]
1362 pub(crate) lifecycle:
1363 Option<alloc::boxed::Box<crate::lifecycle_services::LifecycleRuntimeState>>,
1364 /// Wall-clock instant at which the previous `spin_once` exited. The
1365 /// timer delta on the next call is measured from this point so any
1366 /// time the caller spent between `spin_once` invocations (e.g. an
1367 /// explicit `thread::sleep`) counts toward timer accumulation just
1368 /// like time spent inside `drive_io`.
1369
1370 /// Monotonic clock endpoint for no_std timer accounting.
1371 // phase-359 W4 — ONE field. The std twin held an `Instant`; both now hold
1372 // µs from the single `now_us()` read.
1373 pub(crate) last_spin_end_us: Option<u64>,
1374 /// The executor's monotonic µs clock: `ExecutorConfig::clock_us` when the
1375 /// caller supplied one, else [`default_clock_us_fn`].
1376 ///
1377 /// phase-359 W10 — no longer `no_std`-only. The std build read an
1378 /// `Instant` through a separate field instead, which is how "what time is
1379 /// it" had two answers in one crate.
1380 pub(crate) clock_us_fn: Option<fn() -> u64>,
1381
1382 /// Consecutive `drive_io` failures on the PRIMARY session (issue 0324).
1383 ///
1384 /// Reset to 0 by any successful drive. A session that has died — router
1385 /// gone, lease expired, socket closed — used to keep returning `Ok(())`
1386 /// from `spin()` forever: the node looked alive, publishes went nowhere,
1387 /// and no callback fired. Nothing in the crate could report it; a
1388 /// `git grep` for a health surface found none, so every such
1389 /// investigation started from packet captures (issue 0268 burned days
1390 /// this way).
1391 ///
1392 /// A COUNTER rather than propagating the error out of `spin_once`,
1393 /// deliberately: `drive_io` returns `Err` for any non-OK backend code, and
1394 /// whether a benign poll timeout maps to one is backend-specific. Aborting
1395 /// the spin on a single failure would risk turning a transient into a dead
1396 /// node. A counter cannot regress behaviour and still makes the condition
1397 /// observable via [`Executor::session_io_failures`].
1398 pub(crate) consecutive_io_failures: u32,
1399 /// RFC-0052 W3b.2 — wall-clock (epoch µs) source for age monitors.
1400 pub(crate) epoch_us_fn: Option<fn() -> u64>,
1401 /// RFC-0052 W3b.4 — baked contract-monitor table (empty = uncontracted
1402 /// image; every monitor path below folds away).
1403 pub(crate) monitor_table: &'static [super::monitor::MonitorSpec],
1404 pub(crate) monitor_states: [super::monitor::MonitorState; super::monitor::MAX_MONITORS],
1405 /// W3b.5 — baked subscriber age-contract table (empty = none).
1406 pub(crate) age_table: &'static [super::monitor::AgeMonitorSpec],
1407 pub(crate) age_states: [super::monitor::AgeState; super::monitor::MAX_MONITORS],
1408 /// W3b.5 — hook invoked on `DeadlineAction::Fault` (panic when unset).
1409 pub(crate) fault_fn: Option<fn(&super::monitor::Violation)>,
1410 /// Issue #515 — the spin cadence audit runs once, on the first spin
1411 /// that carries a non-zero timeout (which is where the tier's
1412 /// declared spin period becomes visible to the executor).
1413 pub(crate) spin_quantization_checked: bool,
1414 /// Issue #514 — violations discarded because the ring was full.
1415 /// Saturating. Without this a never-drained (or slowly-drained)
1416 /// image silently reports a stale prefix of its faults.
1417 pub(crate) monitor_violations_dropped: u32,
1418 /// Release jitter: worst observed lateness of a `spin_period` wake
1419 /// against its own nominal schedule, in microseconds.
1420 ///
1421 /// Issue #515 added a STATIC audit for one cause of cadence error --
1422 /// a period that is not a multiple of the spin period. Nothing measured
1423 /// the actual release instants, and `audit_spin_quantization` says so in
1424 /// as many words: "the rate is preserved and no activation is dropped,
1425 /// so every runtime rule is (correctly) silent -- the jitter stays
1426 /// invisible until someone measures cadence on target."
1427 ///
1428 /// This is that measurement, and it is the one `cyclictest` exists to
1429 /// report: the deviation between a timer's programmed wake-up and the
1430 /// instant the task actually resumed, whose MAXIMUM is the figure of
1431 /// merit for a real-time system.
1432 ///
1433 /// `spin_period` already holds both numbers -- `next_us` is the nominal
1434 /// deadline and `now_us()` the actual -- and when the loop is late it
1435 /// skips the sleep and discards the difference. That difference is the
1436 /// jitter.
1437 pub(crate) max_release_jitter_us: u64,
1438 /// Clock reading at the previous `spin_once` entry, for the interval the
1439 /// jitter is measured over. `None` until the first spin, and reset by
1440 /// `clear_release_jitter_stats` so a window starts clean.
1441 pub(crate) last_spin_entry_us: Option<u64>,
1442 /// Jitter high-water at the previous `release-jitter-runtime` check, so
1443 /// the rule reports the DELTA. Same shape as `overruns_reported` on a
1444 /// timer header, and for the same reason: a maximum that has not moved
1445 /// is the fault already reported, not a new one.
1446 pub(crate) jitter_reported_us: u64,
1447 /// Declared minimum stack headroom in bytes for the thread this executor
1448 /// spins on; `0` (default) disables the rule.
1449 ///
1450 /// An executor-level install rather than a contract field, because the
1451 /// bound cannot be derived from anything already declared -- see
1452 /// `check_stack_headroom`. The entry that spawned the thread is the one
1453 /// that knows what it gave it, so that is where the number comes from.
1454 pub(crate) min_stack_headroom_bytes: usize,
1455 /// Lowest headroom already reported, so the rule fires on new lows only.
1456 /// `usize::MAX` = nothing reported yet.
1457 pub(crate) stack_headroom_reported: usize,
1458 /// The pacing quantum the spin loop was last driven at, in microseconds.
1459 /// This is the bound the jitter rule judges against -- the caller's own
1460 /// declared cadence, so nothing further has to be declared.
1461 pub(crate) spin_nominal_us: u64,
1462 /// Wakes that were already past their nominal deadline, and wakes total.
1463 ///
1464 /// The maximum alone cannot distinguish one bad wake from a loop that is
1465 /// late every single cycle, and those are different faults: the first is
1466 /// a glitch, the second means the period cannot be met at all.
1467 pub(crate) late_wakes: u32,
1468 pub(crate) total_wakes: u32,
1469 /// Issue #514 — log every violation as it is detected. On by
1470 /// default: each rule pushed verdicts into a ring that nothing
1471 /// consumed in a real image, so a violated contract and a met one
1472 /// produced identical target-side output (none). Logging at
1473 /// DETECTION rather than draining the ring keeps
1474 /// [`Executor::drain_violations`] working unchanged for
1475 /// applications that report violations themselves.
1476 pub(crate) report_violations: bool,
1477 /// phase-409 — CARVED, at the fixed `MAX_VIOLATIONS` count (the same
1478 /// reasoning issue 0563 used for `remap_table`: the capability is unchanged,
1479 /// so it needs no new `ExecutorSizing` knob).
1480 pub(crate) monitor_violations: super::storage::CarvedVec<'s, super::monitor::Violation>,
1481 /// Issue 0790 — hooks that run BEFORE the session is closed, while every
1482 /// entity still works. The load-bearing half: a device releasing a bus or
1483 /// parking an actuator has to publish its final state / answer its last
1484 /// request from HERE, because after teardown it cannot.
1485 ///
1486 /// `[Option<_>; N]` rather than a `heapless::Vec`, deliberately: the handle
1487 /// a caller holds IS the slot index, so a removal must leave every other
1488 /// index where it was. A `Vec`'s `swap_remove` would silently re-point one
1489 /// live handle at a different callback, and its `remove` would re-point all
1490 /// of them. Clearing a slot to `None` also lets the next registration reuse
1491 /// it, which a fill cursor could not.
1492 pub(crate) pre_shutdown_hooks:
1493 [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
1494 /// Issue 0790 — hooks that run AFTER the session is closed. rclcpp's
1495 /// `add_on_shutdown_callback` / `rclcpp::on_shutdown`. See
1496 /// [`Self::pre_shutdown_hooks`] for why this is an array.
1497 pub(crate) on_shutdown_hooks:
1498 [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
1499}
1500
1501impl<'s> Executor<'s> {
1502 /// phase-271 — assemble an executor over already-carved, caller-owned
1503 /// storage (`slices`, from [`super::storage::carve`]). Fills every
1504 /// non-storage field and reserves SC slot 0 for the default Fifo SC (carve
1505 /// left it `None`). The single builder shared by every constructor path.
1506 fn assemble(session: SessionStore, slices: super::storage::ExecutorSlices<'s>) -> Self {
1507 let super::storage::ExecutorSlices {
1508 arena,
1509 entries,
1510 sched_contexts,
1511 sched_context_bindings,
1512 sporadic_states,
1513 #[cfg(feature = "alloc")]
1514 sporadic_atomic_states,
1515 remaps,
1516 nodes,
1517 extra_sessions,
1518 extra_session_ids,
1519 node_sched_table,
1520 dispatch_slots,
1521 component_slots,
1522 active_groups,
1523 group_sched_table,
1524 monitor_violations,
1525 } = slices;
1526 // Slot 0 = the auto-created default Fifo SC (see field doc). carve
1527 // initialised the whole table to `None`; populate the reserved slot.
1528 if let Some(slot0) = sched_contexts.first_mut() {
1529 *slot0 = Some(super::sched_context::SchedContext::default());
1530 }
1531 // phase-412 -- stamp the self-report before anything can fail. `init`
1532 // is idempotent, so an image with two executors need not decide which
1533 // one owns the record, and `note_arena_capacity` records the slice this
1534 // executor was actually handed rather than the compiled constant: the
1535 // arena's placement is the caller's choice (issue 0900), so the two can
1536 // legitimately differ and the difference is worth seeing.
1537 crate::boot_report::init();
1538 crate::boot_report::note_arena_capacity(arena.len());
1539 crate::boot_report::checkpoint(crate::boot_report::Stage::ExecutorReady);
1540 Self {
1541 // `assemble` is reached from session-only entry points that have no
1542 // config, so 0 is the floor rather than a choice; `open_in` and any
1543 // binding that knows better overwrite it via `set_domain_id`.
1544 domain_id: 0,
1545 session,
1546 arena,
1547 arena_used: 0,
1548 entries,
1549 sched_contexts,
1550 sched_context_bindings,
1551 sporadic_states,
1552 #[cfg(feature = "alloc")]
1553 sporadic_atomic_states,
1554 major_frame_us: 0,
1555 #[cfg(all(
1556 feature = "alloc",
1557 feature = "rmw-cffi",
1558 feature = "scheduler-os-priority"
1559 ))]
1560 os_priority_pool: super::os_priority::OsPriorityPool::new(),
1561 #[cfg(all(feature = "alloc", feature = "scheduler-os-priority"))]
1562 os_priority_apply_policy: None,
1563 trigger: Trigger::Any,
1564 semantics: ExecutorSemantics::RclcppExecutor,
1565 node_name: heapless::String::new(),
1566 active_groups,
1567 active_groups_filtering: false,
1568 nodes,
1569 node_sched_table,
1570 group_sched_table,
1571 remap_table: remaps,
1572 remap_len: 0,
1573 dispatch_slots,
1574 component_slots,
1575 extra_sessions,
1576 extra_session_ids,
1577 primary_rmw_name: heapless::String::new(),
1578 primary_locator: heapless::String::new(),
1579 namespace: {
1580 let mut ns = heapless::String::new();
1581 let _ = ns.push_str("/");
1582 ns
1583 },
1584 #[cfg(feature = "alloc")]
1585 halt_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
1586 #[cfg(feature = "alloc")]
1587 wake_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
1588 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1589 node_wake: super::node_wake::NodeWake::new().map(portable_atomic_util::Arc::new),
1590 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1591 wake_ctx: None,
1592 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1593 has_async_wake: false,
1594 // Phase 141.A.3 — alloc-mode wake state init. Constructed
1595 // eagerly (NodeWake allocation) so the runtime cb can be
1596 // installed lazily on first session without a fallible
1597 // alloc inside spin_once. `None` when the platform
1598 // provider reports the primitive unavailable (matches
1599 // the std-RTOS path's `node_wake: Option<...>`).
1600 #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1601 signal_fd: None,
1602 #[cfg(feature = "param-services")]
1603 params: None,
1604 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1605 sim_time_source: None,
1606 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1607 sim_time_requested: false,
1608 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1609 sim_time_stated: false,
1610 #[cfg(feature = "lifecycle-services")]
1611 lifecycle: None,
1612 // Initialise the spin endpoint to construction time so the
1613 // very first `spin_once` credits time the caller spent
1614 // *before* it (e.g. setup, an explicit pre-spin sleep) just
1615 // like time spent between later calls.
1616 // One field, one seed now (phase-359 W10): the clock is absolute
1617 // on every flavour, so it is READ here rather than assumed to be
1618 // zero at construction. The std arm used to seed `Some(0)` because
1619 // its epoch WAS construction; with one provider that special case
1620 // is gone.
1621 last_spin_end_us: default_clock_us_fn().map(|clock| clock()),
1622 clock_us_fn: default_clock_us_fn(),
1623 consecutive_io_failures: 0,
1624 // RFC-0052 W3b.5 — a build with a wall clock gets one by default so
1625 // age monitors activate without extra wiring; a build with neither a
1626 // platform port nor a host gets `None`, and its board installs
1627 // `config.epoch_us` in `from_session_in`/`open`.
1628 //
1629 // phase-359 W10 — the cfg pair that used to be here moved into
1630 // `default_epoch_us_fn`, beside the `default_clock_us_fn` it
1631 // mirrors. (The old comment pointed at "the `not(std)` blocks
1632 // above" for the board install; those blocks are no longer
1633 // cfg-gated — the config override applies on every flavour now.)
1634 epoch_us_fn: super::types::default_epoch_us_fn(),
1635 monitor_table: &[],
1636 monitor_states: [super::monitor::MonitorState::default(); super::monitor::MAX_MONITORS],
1637 age_table: &[],
1638 age_states: [super::monitor::AgeState::default(); super::monitor::MAX_MONITORS],
1639 fault_fn: None,
1640 spin_quantization_checked: false,
1641 monitor_violations_dropped: 0,
1642 max_release_jitter_us: 0,
1643 last_spin_entry_us: None,
1644 jitter_reported_us: 0,
1645 min_stack_headroom_bytes: 0,
1646 stack_headroom_reported: usize::MAX,
1647 spin_nominal_us: 0,
1648 late_wakes: 0,
1649 total_wakes: 0,
1650 report_violations: true,
1651 monitor_violations,
1652 // Issue 0790 — both phase tables start empty. An image that
1653 // registers nothing pays these `None`s and a two-slot scan at
1654 // teardown, and nothing else.
1655 pre_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
1656 on_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
1657 }
1658 }
1659
1660 /// Create an owning executor over caller-supplied `backing`, sized by
1661 /// `sizing`. The core, non-generic, per-entry entry point (the `alloc`
1662 /// [`from_session`](Self::from_session) convenience leaks a default backing
1663 /// and calls this; the macro / C FFI pass an entry-sized backing).
1664 ///
1665 /// # Safety
1666 /// `backing` must be ≥ `sizing.u64_len()` words, stay alive for `'s`, and
1667 /// not be otherwise accessed while the executor lives (it aliases it).
1668 /// `sizing.cbs` must be ≤ 64 (the `u64` ready-set bitmask ceiling).
1669 pub unsafe fn from_session_in(
1670 session: session::ConcreteSession,
1671 backing: &'s mut [MaybeUninit<u64>],
1672 sizing: super::storage::ExecutorSizing,
1673 ) -> Self {
1674 let slices = unsafe { super::storage::carve(backing, sizing) };
1675 Self::assemble(SessionStore::Owned(session), slices)
1676 }
1677
1678 /// Create a borrowing executor over caller-supplied `backing`, sized by
1679 /// `sizing`. Counterpart to [`from_session_in`](Self::from_session_in) for
1680 /// the per-tier / C model (the session is borrowed, not owned).
1681 ///
1682 /// # Safety
1683 /// - `session_ptr` must point to a valid session that outlives the executor
1684 /// and is not moved/dropped while it exists.
1685 /// - `backing` obligations as in [`from_session_in`](Self::from_session_in).
1686 pub unsafe fn from_session_ptr_in(
1687 session_ptr: *mut session::ConcreteSession,
1688 backing: &'s mut [MaybeUninit<u64>],
1689 sizing: super::storage::ExecutorSizing,
1690 ) -> Self {
1691 let slices = unsafe { super::storage::carve(backing, sizing) };
1692 Self::assemble(SessionStore::Borrowed(session_ptr), slices)
1693 }
1694}
1695
1696impl Executor<'static> {
1697 /// Create an executor from an already-opened session, using the build-time
1698 /// default sizing (`MAX_CBS`/`MAX_SC`/`ARENA_SIZE`). Convenience for
1699 /// std/alloc callers that don't size per-entry: it leaks a default-sized
1700 /// backing (executor-lifetime, one-time) and calls
1701 /// [`from_session_in`](Self::from_session_in). Per-entry sizing goes through
1702 /// the macro / `open_in` instead.
1703 #[cfg(feature = "alloc")]
1704 pub fn from_session(session: session::ConcreteSession) -> Self {
1705 let sizing = super::storage::ExecutorSizing::DEFAULT;
1706 // SAFETY: the leaked backing is exactly `sizing.u64_len()` words,
1707 // `'static`, and uniquely owned by this executor.
1708 unsafe { Self::from_session_in(session, leak_default_backing(sizing), sizing) }
1709 }
1710
1711 /// [`from_session`](Self::from_session) with an [`ExecutorConfig`], so a
1712 /// caller that brings its own session can also bring its own clock.
1713 ///
1714 /// issue 0709 / issue 0687 — `from_session` takes no config, and that is
1715 /// the path the no-port population uses: it accepts any `Session`, so a
1716 /// consumer with a non-cffi backend reaches the executor through it and had
1717 /// NO way to install `clock_us`. phase-359 W10 argued the `std`-without-a-
1718 /// port clock fallbacks could go because "a caller with a clock installs it
1719 /// through `ExecutorConfig::clock_us`" — true for `open`, false here, which
1720 /// is half of why that deletion was reverted.
1721 ///
1722 /// Only the timing sources are read from `config`; identity (locator,
1723 /// domain, names) belongs to the session the caller already opened. As in
1724 /// [`open_in`](Self::open_in), a `None` field does NOT clobber the
1725 /// platform default — it means "not specified" (the bug issue 0671
1726 /// records).
1727 #[cfg(feature = "alloc")]
1728 pub fn from_session_with(
1729 session: session::ConcreteSession,
1730 config: &super::types::ExecutorConfig<'_>,
1731 ) -> Self {
1732 let mut executor = Self::from_session(session);
1733 if let Some(clock) = config.clock_us {
1734 executor.clock_us_fn = Some(clock);
1735 executor.last_spin_end_us = Some(clock());
1736 }
1737 if let Some(epoch) = config.epoch_us {
1738 executor.epoch_us_fn = Some(epoch);
1739 }
1740 executor
1741 }
1742
1743 /// Create an executor from a borrowed session pointer, default-sized. The
1744 /// `alloc` convenience wrapper over
1745 /// [`from_session_ptr_in`](Self::from_session_ptr_in) — leaks a default
1746 /// backing so existing callers keep the zero-storage-arg signature.
1747 ///
1748 /// # Safety
1749 /// - `session_ptr` must point to a valid, initialized session that lives at
1750 /// least as long as this executor.
1751 /// - The caller must not move or drop the session while the executor exists.
1752 #[cfg(feature = "alloc")]
1753 pub unsafe fn from_session_ptr(session_ptr: *mut session::ConcreteSession) -> Self {
1754 let sizing = super::storage::ExecutorSizing::DEFAULT;
1755 // SAFETY: leaked backing as in `from_session`; session_ptr contract
1756 // forwarded to `from_session_ptr_in`.
1757 unsafe { Self::from_session_ptr_in(session_ptr, leak_default_backing(sizing), sizing) }
1758 }
1759}
1760
1761impl<'s> Executor<'s> {
1762 /// Phase 228.B (RFC-0015) — construct a tier task's executor that **shares**
1763 /// a session opened once by the orchestration `main()`.
1764 ///
1765 /// In the per-tier execution model `main()` opens one RMW session, then
1766 /// spawns one RTOS task per priority tier; each task calls this to get an
1767 /// [`Executor`] over the *same* session (the `Borrowed` session store — this
1768 /// executor neither owns nor closes it), registers its tier's callback
1769 /// groups, and spins. Thin alias over [`Executor::from_session_ptr`].
1770 ///
1771 /// # Safety
1772 /// `session` must outlive every executor/task built from it (the
1773 /// orchestration `main()` holds it and never returns / WFIs), and must not
1774 /// be mutated except through these executors' spin calls.
1775 #[cfg(feature = "alloc")]
1776 pub unsafe fn open_with_session(session: *mut session::ConcreteSession) -> Executor<'static> {
1777 unsafe { Executor::<'static>::from_session_ptr(session) }
1778 }
1779
1780 /// phase-271 — per-tier borrowed-session constructor over caller-supplied,
1781 /// per-tier-sized `backing`. The sized counterpart to
1782 /// [`open_with_session`](Self::open_with_session): each RTOS tier task owns
1783 /// its own backing so tiers size independently.
1784 ///
1785 /// # Safety
1786 /// `session` obligations as in [`open_with_session`](Self::open_with_session);
1787 /// `backing`/`sizing` as in [`from_session_ptr_in`](Self::from_session_ptr_in).
1788 pub unsafe fn open_with_session_in(
1789 session: *mut session::ConcreteSession,
1790 backing: &'s mut [MaybeUninit<u64>],
1791 sizing: super::storage::ExecutorSizing,
1792 ) -> Self {
1793 unsafe { Self::from_session_ptr_in(session, backing, sizing) }
1794 }
1795
1796 /// Raw pointer to this executor's RMW session, for the per-tier model:
1797 /// the boot task opens the one session via [`Executor::open`] (the RMW
1798 /// session is a process-wide singleton — opening twice fails), then hands
1799 /// this pointer to each spawned tier task's
1800 /// [`Executor::open_with_session`]. The boot task's executor owns the
1801 /// session and outlives every borrower, so the pointer stays valid for the
1802 /// program's life. Works for both `Owned` and `Borrowed` stores.
1803 ///
1804 /// # Safety
1805 /// The returned pointer aliases `self.session`. Callers must keep `self`
1806 /// alive (not moved/dropped) for as long as any tier executor uses the
1807 /// pointer, and must only touch the session through executor spin calls
1808 /// (the RMW backend serializes concurrent access through its own locks).
1809 pub fn session_ptr(&mut self) -> *mut session::ConcreteSession {
1810 &mut *self.session as *mut session::ConcreteSession
1811 }
1812
1813 /// Opaque, `Send` form of [`session_ptr`](Self::session_ptr) — the per-tier
1814 /// model hands this to each spawned tier task (it can cross the RTOS task /
1815 /// thread boundary, which a bare `*mut` cannot). See [`SessionHandle`].
1816 ///
1817 /// # Safety
1818 /// Same contract as [`session_ptr`](Self::session_ptr): `self` (the session
1819 /// owner) must outlive every executor built from the handle.
1820 pub fn session_handle(&mut self) -> SessionHandle {
1821 SessionHandle(self.session_ptr())
1822 }
1823
1824 /// Open an [`Executor`] over the session a [`SessionHandle`] refers to (the
1825 /// `Borrowed` store — neither owns nor closes it). The tier-task counterpart
1826 /// to [`session_handle`](Self::session_handle).
1827 ///
1828 /// # Safety
1829 /// The handle's session must still be alive (its owning executor not moved
1830 /// or dropped); access only through executor spin calls.
1831 #[cfg(feature = "alloc")]
1832 pub unsafe fn open_with_session_handle(handle: SessionHandle) -> Executor<'static> {
1833 unsafe { Executor::<'static>::open_with_session(handle.0) }
1834 }
1835
1836 /// phase-271 — sized counterpart to
1837 /// [`open_with_session_handle`](Self::open_with_session_handle) (per-tier
1838 /// backing).
1839 ///
1840 /// # Safety
1841 /// As [`open_with_session_handle`](Self::open_with_session_handle) +
1842 /// [`from_session_ptr_in`](Self::from_session_ptr_in).
1843 pub unsafe fn open_with_session_handle_in(
1844 handle: SessionHandle,
1845 backing: &'s mut [MaybeUninit<u64>],
1846 sizing: super::storage::ExecutorSizing,
1847 ) -> Self {
1848 unsafe { Self::open_with_session_in(handle.0, backing, sizing) }
1849 }
1850
1851 /// Phase 228.C — set this tier executor's active callback-group filter. The
1852 /// generated per-tier task calls this before registering nodes; afterwards
1853 /// only callbacks whose `.callback_group()` is in `groups` register here.
1854 /// An empty slice (or never calling it) leaves the wildcard — register all
1855 /// callbacks (the single-tier degenerate case + today's behaviour).
1856 pub fn set_active_groups(&mut self, groups: &[&str]) {
1857 // phase-409 — the table is CARVED and reused, so clear before refilling;
1858 // the old `Option<heapless::Vec>` got a fresh empty vector each call.
1859 self.active_groups.clear();
1860 if groups.is_empty() {
1861 self.active_groups_filtering = false;
1862 return;
1863 }
1864 for g in groups {
1865 let mut s = heapless::String::new();
1866 if s.push_str(g).is_ok() {
1867 let _ = self.active_groups.push(s);
1868 }
1869 }
1870 self.active_groups_filtering = true;
1871 }
1872
1873 /// The current callback-group filter, or `None` for the wildcard.
1874 fn active_group_filter(&self) -> Option<&[super::storage::GroupName]> {
1875 self.active_groups_filtering
1876 .then(|| self.active_groups.as_slice())
1877 }
1878
1879 /// Phase 228.C — whether a callback in `group` should register in this
1880 /// executor under the current filter. The wildcard accepts everything.
1881 pub fn group_active(&self, group: &str) -> bool {
1882 group_filter_accepts(self.active_group_filter(), group)
1883 }
1884
1885 /// Set the node name and namespace used for liveliness tokens.
1886 ///
1887 /// Called by `open()` to propagate config values. When `register_subscription`
1888 /// or `register_service` creates entities, these values are attached to the
1889 /// Phase 156 — record the primary session's backend identity
1890 /// (rmw name + locator) so `NodeBuilder::resolve_session_slot`
1891 /// can detect when a `.rmw(name)` matches the primary instead
1892 /// of opening a SECOND backend session against the same
1893 /// singleton (zenoh-pico's `g_session` is process-wide;
1894 /// opening twice fails). `Executor::open*` calls this
1895 /// automatically; the C surface (`nros_executor_init`) calls
1896 /// it manually because it constructs via `from_session_ptr`
1897 /// which doesn't know the open metadata. Empty strings = "no
1898 /// primary identity tracked"; the cache check degrades to
1899 /// always-miss.
1900 pub fn set_primary_identity(&mut self, rmw_name: &str, locator: &str) {
1901 self.primary_rmw_name.clear();
1902 let _ = self.primary_rmw_name.push_str(rmw_name);
1903 self.primary_locator.clear();
1904 let _ = self.primary_locator.push_str(locator);
1905 }
1906
1907 /// `TopicInfo`/`ServiceInfo` so the zenoh backend can declare liveliness.
1908 /// Issue 0656 — set the ROS domain for entities this executor declares.
1909 ///
1910 /// For bindings that build an executor from an existing session
1911 /// (`from_session_ptr_in`), where no `ExecutorConfig` is available and the
1912 /// domain would otherwise stay at its 0 floor.
1913 pub fn set_domain_id(&mut self, domain_id: u32) {
1914 self.domain_id = domain_id;
1915 }
1916
1917 /// The ROS domain this executor declares entities on.
1918 pub fn domain_id(&self) -> u32 {
1919 self.domain_id
1920 }
1921
1922 pub fn set_node_identity(&mut self, node_name: &str, namespace: &str) {
1923 self.node_name.clear();
1924 let _ = self.node_name.push_str(node_name);
1925 if !namespace.is_empty() {
1926 self.namespace.clear();
1927 let _ = self.namespace.push_str(namespace);
1928 }
1929 }
1930
1931 // =========================================================================
1932 // Phase 305 W3 (issue 0255) — per-node launch remap table
1933 // =========================================================================
1934
1935 /// Record one launch `<remap from= to=/>` rule for the node identified by
1936 /// `(node_name, namespace)`. Rules are matched in declaration order (first
1937 /// wins) by [`Self::resolve_entity_name_for`]. Errors when a string
1938 /// overflows its slot or the table is at [`MAX_REMAPS`] — callers surface
1939 /// this rather than silently dropping a routing rule.
1940 #[allow(clippy::result_unit_err)]
1941 pub fn declare_remap(
1942 &mut self,
1943 node_name: &str,
1944 namespace: &str,
1945 from: &str,
1946 to: &str,
1947 ) -> Result<(), ()> {
1948 let mut rule = RemapRule {
1949 node_name: heapless::String::new(),
1950 namespace: heapless::String::new(),
1951 from: heapless::String::new(),
1952 to: heapless::String::new(),
1953 };
1954 rule.node_name.push_str(node_name)?;
1955 let ns = if namespace.is_empty() { "/" } else { namespace };
1956 rule.namespace.push_str(ns)?;
1957 rule.from.push_str(from)?;
1958 rule.to.push_str(to)?;
1959 // Same contract as the `heapless::Vec::push` this replaces: full table
1960 // is an error the caller surfaces, never a silently dropped rule.
1961 let slot = self.remap_table.get_mut(self.remap_len).ok_or(())?;
1962 *slot = Some(rule);
1963 self.remap_len += 1;
1964 Ok(())
1965 }
1966
1967 /// Resolve a source-level entity name for the node identified by
1968 /// `(node_name, namespace)`: ROS 2 name expansion (`~`/relative → FQN)
1969 /// plus this node's declared remap rules (exact-FQN match, first rule
1970 /// wins). Nodes with no rules still get expansion. Errors on an
1971 /// unexpandable name (see `crate::names::expand_name`).
1972 #[allow(clippy::result_unit_err)]
1973 pub fn resolve_entity_name_for(
1974 &self,
1975 node_name: &str,
1976 namespace: &str,
1977 source: &str,
1978 ) -> Result<crate::names::ResolvedName, ()> {
1979 let ns = if namespace.is_empty() { "/" } else { namespace };
1980 let rules = self.remap_table[..self.remap_len]
1981 .iter()
1982 .flatten()
1983 .filter(|r| r.node_name.as_str() == node_name && r.namespace.as_str() == ns)
1984 .map(|r| (r.from.as_str(), r.to.as_str()));
1985 crate::names::resolve_name(source, node_name, ns, rules)
1986 }
1987
1988 /// [`Self::resolve_entity_name_for`] against the executor's CURRENT node
1989 /// identity (`set_node_identity`) — the nros-c registration sites set that
1990 /// identity per node immediately before registering each entity.
1991 #[allow(clippy::result_unit_err)]
1992 pub fn resolve_entity_name(&self, source: &str) -> Result<crate::names::ResolvedName, ()> {
1993 self.resolve_entity_name_for(self.node_name.as_str(), self.namespace.as_str(), source)
1994 }
1995
1996 // =========================================================================
1997 // Phase 272 (RFC-0047) — node-name → sched-context table
1998 // =========================================================================
1999
2000 /// Seed a config-resolved tier binding by `(name, namespace)` before the
2001 /// node is built. `NodeBuilder::build` consults this table when no
2002 /// explicit `.sched()` override is given — the table entry then wins over
2003 /// the `SchedContextId(0)` default (precedence: explicit > table > 0).
2004 ///
2005 /// Call BEFORE `node_builder(name).build()`. An existing entry for the
2006 /// same `(name, namespace)` key is overwritten (last-write wins). Overflow
2007 /// past `MAX_NODES` is silently ignored. An empty `namespace` is normalised
2008 /// to `"/"` to match what `NodeBuilder::build` computes for a root-NS node.
2009 pub fn bind_node_name_sched(
2010 &mut self,
2011 name: &str,
2012 namespace: &str,
2013 sc: super::sched_context::SchedContextId,
2014 ) {
2015 let norm_ns = if namespace.is_empty() { "/" } else { namespace };
2016 // Overwrite if there is already an entry for this (name, ns) pair.
2017 for entry in self.node_sched_table.iter_mut() {
2018 if entry.0.as_str() == name && entry.1.as_str() == norm_ns {
2019 entry.2 = sc;
2020 return;
2021 }
2022 }
2023 // New entry — build the heapless strings and push. Silently ignore
2024 // if the name/ns is too long or the table is at capacity.
2025 let mut name_s = heapless::String::<64>::new();
2026 let mut ns_s = heapless::String::<64>::new();
2027 if name_s.push_str(name).is_err() || ns_s.push_str(norm_ns).is_err() {
2028 return;
2029 }
2030 let _ = self.node_sched_table.push((name_s, ns_s, sc));
2031 }
2032
2033 /// Look up the seeded sched-context for `(name, namespace)`. Returns
2034 /// `None` when the table has no entry for this pair (unseed → default 0).
2035 /// `pub(super)` — visible only within the `executor` module (sibling
2036 /// `node_record` calls it from `NodeBuilder::build`).
2037 pub(super) fn lookup_node_sched(
2038 &self,
2039 name: &str,
2040 namespace: &str,
2041 ) -> Option<super::sched_context::SchedContextId> {
2042 for entry in self.node_sched_table.iter() {
2043 if entry.0.as_str() == name && entry.1.as_str() == namespace {
2044 return Some(entry.2);
2045 }
2046 }
2047 None
2048 }
2049
2050 // =========================================================================
2051 // Phase 273 (RFC-0047) — per-callback-group → sched-context table
2052 // =========================================================================
2053
2054 /// Seed a config-resolved tier binding by `(name, namespace, group)` before
2055 /// entities are registered. `apply_node_default_sched` consults this table
2056 /// first (group table > node default > `SchedContextId(0)`).
2057 ///
2058 /// Call BEFORE entity creation. An existing entry for the same
2059 /// `(name, namespace, group)` key is overwritten (last-write wins). Overflow
2060 /// past `MAX_CBS` is silently ignored. An empty `namespace` is normalised to
2061 /// `"/"` to match `NodeBuilder::build`. Mirror of `bind_node_name_sched`.
2062 pub fn bind_group_sched(
2063 &mut self,
2064 name: &str,
2065 namespace: &str,
2066 group: &str,
2067 sc: super::sched_context::SchedContextId,
2068 ) {
2069 let norm_ns = if namespace.is_empty() { "/" } else { namespace };
2070 // Overwrite if there is already an entry for this (name, ns, group).
2071 for entry in self.group_sched_table.iter_mut() {
2072 if entry.0.as_str() == name && entry.1.as_str() == norm_ns && entry.2.as_str() == group
2073 {
2074 entry.3 = sc;
2075 return;
2076 }
2077 }
2078 // New entry — build the heapless strings and push. Silently ignore
2079 // if name/ns/group is too long or the table is at capacity.
2080 let mut name_s = heapless::String::<64>::new();
2081 let mut ns_s = heapless::String::<64>::new();
2082 let mut grp_s = heapless::String::<32>::new();
2083 if name_s.push_str(name).is_err()
2084 || ns_s.push_str(norm_ns).is_err()
2085 || grp_s.push_str(group).is_err()
2086 {
2087 return;
2088 }
2089 let _ = self.group_sched_table.push((name_s, ns_s, grp_s, sc));
2090 }
2091
2092 /// Look up the seeded sched-context for `(name, namespace, group)`. Returns
2093 /// `None` when the table has no entry for this triple.
2094 fn lookup_group_sched(
2095 &self,
2096 name: &str,
2097 namespace: &str,
2098 group: &str,
2099 ) -> Option<super::sched_context::SchedContextId> {
2100 for entry in self.group_sched_table.iter() {
2101 if entry.0.as_str() == name
2102 && entry.1.as_str() == namespace
2103 && entry.2.as_str() == group
2104 {
2105 return Some(entry.3);
2106 }
2107 }
2108 None
2109 }
2110
2111 // =========================================================================
2112 // Phase 110.B — SchedContext API
2113 // =========================================================================
2114
2115 /// Identifier of the auto-created default `Fifo`-class scheduling
2116 /// context. Every callback registered without an explicit
2117 /// [`bind_handle_to_sched_context`] binds to this SC.
2118 pub fn default_sched_context_id(&self) -> super::sched_context::SchedContextId {
2119 super::sched_context::SchedContextId(0)
2120 }
2121
2122 /// Register a new scheduling context. Returns a [`SchedContextId`]
2123 /// callers pass to [`bind_handle_to_sched_context`] to attach
2124 /// callbacks. Phase 110.B.
2125 pub fn create_sched_context(
2126 &mut self,
2127 sc: super::sched_context::SchedContext,
2128 ) -> Result<super::sched_context::SchedContextId, NodeError> {
2129 // Slot 0 is reserved for the default Fifo SC; search 1..MAX_SC.
2130 for (i, slot) in self.sched_contexts.iter_mut().enumerate().skip(1) {
2131 if slot.is_none() {
2132 *slot = Some(sc);
2133 // Phase 110.E — Sporadic-class SCs get a sibling
2134 // `SporadicState` entry that the spin_once dispatch
2135 // path consults each cycle to refill the budget at
2136 // period boundaries and skip dispatch when budget
2137 // is exhausted.
2138 if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
2139 let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
2140 let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
2141 self.sporadic_states[i] =
2142 Some(super::sched_context::SporadicState::new(budget, period));
2143 }
2144 return Ok(super::sched_context::SchedContextId(i as u8));
2145 }
2146 }
2147 Err(NodeError::NoSchedContextSlot)
2148 }
2149
2150 /// RFC-0052 W3b.2 — wall-clock µs since the UNIX epoch, when this
2151 /// target has an epoch source (config `epoch_us`, defaulted from
2152 /// `SystemTime` on hosted configs). `None` = no wall clock; age
2153 /// monitors must not have been baked (the emitter refuses).
2154 pub fn epoch_now_us(&self) -> Option<u64> {
2155 self.epoch_us_fn.map(|f| f())
2156 }
2157
2158 /// RFC-0052 W3b.4 — install the baked contract-monitor table. Call
2159 /// BEFORE entity creation so `create_publisher` can attach each
2160 /// contracted endpoint's counter cell. Mirrors `set_qos_overrides`:
2161 /// `&'static`, codegen-baked, empty by default.
2162 pub fn set_monitor_table(&mut self, table: &'static [super::monitor::MonitorSpec]) {
2163 self.monitor_table = table;
2164 }
2165
2166 /// The installed monitor table (empty unless the entry set one).
2167 #[must_use]
2168 pub fn monitor_table(&self) -> &'static [super::monitor::MonitorSpec] {
2169 self.monitor_table
2170 }
2171
2172 /// W3b.5 — install the baked subscriber age-contract table. Call
2173 /// BEFORE entity creation so `create_subscription` can attach each
2174 /// contracted endpoint's age cell (needs an epoch source — see
2175 /// `ExecutorConfig::epoch_us`; without one the take path records
2176 /// nothing and age monitors stay silent).
2177 pub fn set_age_table(&mut self, table: &'static [super::monitor::AgeMonitorSpec]) {
2178 self.age_table = table;
2179 }
2180
2181 /// The installed age table (empty unless the entry set one).
2182 #[must_use]
2183 pub fn age_table(&self) -> &'static [super::monitor::AgeMonitorSpec] {
2184 self.age_table
2185 }
2186
2187 /// W3b.5 — override the wall-clock (epoch µs) source age monitors take
2188 /// message stamps against. Hosted builds default to `SystemTime`; a
2189 /// board with a synced RTC installs its own here (or via
2190 /// `ExecutorConfig::epoch_us`). Call BEFORE entity creation — the age
2191 /// hook captures this at `create_subscription` time.
2192 pub fn set_epoch_clock(&mut self, epoch_us: fn() -> u64) {
2193 self.epoch_us_fn = Some(epoch_us);
2194 }
2195
2196 /// W3b.5 — resolve a subscription's age hook at registration time:
2197 /// exact topic match against the baked age table, only for stamped
2198 /// types (`M::STAMP_OFFSET`) and only when an epoch source exists.
2199 fn age_lookup<M: RosMessage>(&self, topic: &str) -> Option<super::arena::AgeMon> {
2200 M::STAMP_OFFSET?;
2201 let epoch = self.epoch_us_fn?;
2202 self.age_table
2203 .iter()
2204 .find(|a| a.topic == topic)
2205 .map(|a| (a.cell, epoch))
2206 }
2207
2208 /// W3b.5 — install the `DeadlineAction::Fault` hook. Without one a
2209 /// fault-class deadline miss panics (watchdog-visible stop on
2210 /// embedded targets).
2211 /// Record one `spin_once` entry against the caller's intended cadence.
2212 ///
2213 /// Late is `(now - last_entry) - timeout`, clamped at zero: arriving early
2214 /// is not jitter, it is a poll that had nothing to wait for. A zero
2215 /// timeout claims no cadence and is skipped entirely, which keeps
2216 /// `Future::wait`-style busy spins out of the statistic.
2217 fn record_release_jitter(&mut self, timeout: core::time::Duration) {
2218 let nominal_us = timeout.as_micros().min(u64::MAX as u128) as u64;
2219 if nominal_us == 0 {
2220 return;
2221 }
2222 let Some(now) = self.now_us() else {
2223 return;
2224 };
2225 self.spin_nominal_us = nominal_us;
2226 if let Some(last) = self.last_spin_entry_us {
2227 let interval = now.saturating_sub(last);
2228 self.total_wakes = self.total_wakes.saturating_add(1);
2229 if let Some(late) = interval.checked_sub(nominal_us)
2230 && late > 0
2231 {
2232 self.late_wakes = self.late_wakes.saturating_add(1);
2233 if late > self.max_release_jitter_us {
2234 self.max_release_jitter_us = late;
2235 }
2236 }
2237 }
2238 self.last_spin_entry_us = Some(now);
2239 }
2240
2241 /// Release-jitter statistics from the spin loop: worst lateness in
2242 /// microseconds, the number of wakes that were already late, and the
2243 /// number of wakes total.
2244 ///
2245 /// The maximum is the figure of merit, and the ratio is what tells the
2246 /// two failures apart: one late wake in ten thousand is a glitch, ten
2247 /// thousand in ten thousand means the period cannot be met at all.
2248 ///
2249 /// Zero on a build with no clock -- `spin_period` refuses to run at all
2250 /// there (issue 0709), so there is nothing to have measured.
2251 pub fn release_jitter(&self) -> (u64, u32, u32) {
2252 (
2253 self.max_release_jitter_us,
2254 self.late_wakes,
2255 self.total_wakes,
2256 )
2257 }
2258
2259 /// Reset the release-jitter statistics. For monitoring code that logs
2260 /// and clears per window, so a single early outlier does not pin the
2261 /// maximum for the life of the process.
2262 pub fn clear_release_jitter_stats(&mut self) {
2263 self.max_release_jitter_us = 0;
2264 self.late_wakes = 0;
2265 self.total_wakes = 0;
2266 self.last_spin_entry_us = None;
2267 self.jitter_reported_us = 0;
2268 }
2269
2270 pub fn set_fault_handler(&mut self, f: fn(&super::monitor::Violation)) {
2271 self.fault_fn = Some(f);
2272 }
2273
2274 /// Issue #515 — warn about periods the spin cadence cannot express.
2275 ///
2276 /// A timer fires on the first spin at or after its period elapses, so
2277 /// a period that is not an integer multiple of the executor's spin
2278 /// period lands on the spin grid instead of the declared cadence: a
2279 /// 33 ms timer on a 5 ms spin alternates 35 ms / 30 ms, mean 33.0.
2280 /// The rate is preserved and no activation is dropped, so every
2281 /// runtime rule is (correctly) silent — the jitter stays invisible
2282 /// until someone measures cadence on target.
2283 ///
2284 /// Runs ONCE, on the first spin carrying a non-zero timeout, because
2285 /// that timeout is where the tier's declared spin period becomes
2286 /// visible down here. A resolve-time diagnostic would be better
2287 /// still — the toolchain holds both numbers before the image is
2288 /// built — but this backstop needs no board or codegen change and
2289 /// catches hand-written spin loops too.
2290 fn audit_spin_quantization(&mut self, spin_us: u64) {
2291 if spin_us == 0 {
2292 return;
2293 }
2294 let arena_ptr = self.arena.as_ptr() as *const u8;
2295 for i in 0..self.entries.len() {
2296 let Some(meta) = self.entries[i].as_ref() else {
2297 continue;
2298 };
2299 if !matches!(meta.kind, EntryKind::Timer) {
2300 continue;
2301 }
2302 // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
2303 // whose leading layout is `TimerHeader`.
2304 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
2305 let period_us = header.period_us;
2306 if period_us == 0 || period_us % spin_us == 0 {
2307 continue;
2308 }
2309 // The two periods the timer will actually alternate between.
2310 let early_us = (period_us / spin_us) * spin_us;
2311 let late_us = early_us.saturating_add(spin_us);
2312 nros_log::nros_warn!(
2313 nros_log::get_logger("nros"),
2314 "timer period {} us is not a multiple of the {} us spin period: activations will alternate between {} us and {} us (mean cadence preserved)",
2315 period_us,
2316 spin_us,
2317 early_us,
2318 late_us
2319 );
2320 }
2321 }
2322
2323 /// Issue #514 — whether the executor logs each violation as it is
2324 /// detected (the default).
2325 ///
2326 /// Turn this off in an application that reports violations its own
2327 /// way via [`Self::drain_violations`]; the ring is unaffected
2328 /// either way.
2329 pub fn set_report_violations(&mut self, enabled: bool) {
2330 self.report_violations = enabled;
2331 }
2332
2333 /// Issue #514 — violations discarded because the ring was full.
2334 ///
2335 /// Non-zero means the image produced faults faster than they were
2336 /// reported, so the reported set is a prefix, not the whole story.
2337 pub fn violations_dropped(&self) -> u32 {
2338 self.monitor_violations_dropped
2339 }
2340
2341 /// RFC-0052 W3b.4 — drain pending contract violations (rate rule for
2342 /// now; age/latency land with W3b.5). The entry glue calls this after
2343 /// `spin_once` and feeds each entry to the `nros-diagnostics`
2344 /// reporter. Draining clears the ring.
2345 pub fn drain_violations(&mut self, mut f: impl FnMut(&super::monitor::Violation)) {
2346 for v in self.monitor_violations.iter() {
2347 f(v);
2348 }
2349 self.monitor_violations.clear();
2350 }
2351
2352 /// THE monotonic-µs read. phase-359 W4 — every consumer goes through here.
2353 ///
2354 /// Before this there were FIVE spellings of "what time is it": this one,
2355 /// `last_spin_end: Instant`, and two ad-hoc `static EPOCH: OnceLock<Instant>`
2356 /// blocks — plus `PlatformClock`, which nothing called because `Executor` is
2357 /// deliberately non-generic and the trait's methods are associated fns.
2358 ///
2359 /// `None` means no clock is available (no_std with no injected hook).
2360 /// Callers must degrade, not guess: a missing clock is why the sporadic
2361 /// refill and the major-frame phase behaved differently on no_std.
2362 fn now_us(&mut self) -> Option<u64> {
2363 self.clock_us_fn.map(|clock| clock())
2364 }
2365
2366 /// Run the rate/latency/age checks over the baked tables (single
2367 /// branch each when empty).
2368 fn run_contract_monitors(&mut self) {
2369 if !self.monitor_table.is_empty()
2370 && let Some(now_us) = self.now_us()
2371 {
2372 {
2373 for (i, spec) in self
2374 .monitor_table
2375 .iter()
2376 .take(super::monitor::MAX_MONITORS)
2377 .enumerate()
2378 {
2379 if let Some(v) =
2380 super::monitor::check_rate(spec, &mut self.monitor_states[i], now_us)
2381 {
2382 if self.report_violations {
2383 super::monitor::log_violation(&v);
2384 }
2385 if self.monitor_violations.push(v).is_err() {
2386 self.monitor_violations_dropped =
2387 self.monitor_violations_dropped.saturating_add(1);
2388 }
2389 }
2390 if let Some(v) =
2391 super::monitor::check_latency(spec, &mut self.monitor_states[i])
2392 {
2393 if self.report_violations {
2394 super::monitor::log_violation(&v);
2395 }
2396 if self.monitor_violations.push(v).is_err() {
2397 self.monitor_violations_dropped =
2398 self.monitor_violations_dropped.saturating_add(1);
2399 }
2400 }
2401 }
2402 }
2403 }
2404 if !self.age_table.is_empty() {
2405 for (i, spec) in self
2406 .age_table
2407 .iter()
2408 .take(super::monitor::MAX_MONITORS)
2409 .enumerate()
2410 {
2411 if let Some(v) = super::monitor::check_age(spec, &mut self.age_states[i]) {
2412 if self.report_violations {
2413 super::monitor::log_violation(&v);
2414 }
2415 if self.monitor_violations.push(v).is_err() {
2416 self.monitor_violations_dropped =
2417 self.monitor_violations_dropped.saturating_add(1);
2418 }
2419 }
2420 }
2421 }
2422 }
2423
2424 /// Issue #505 — report activations dropped by
2425 /// [`TimerOverrunPolicy::Skip`](super::arena::TimerOverrunPolicy)
2426 /// since the last check.
2427 ///
2428 /// Unlike the rate/age/latency rules this needs no baked spec table:
2429 /// every periodic timer counts its own overruns, and a dropped
2430 /// activation is a contract failure for any declared period. It runs
2431 /// on the same tick so violations land in the same ring the entry
2432 /// glue drains.
2433 /// Declare the minimum stack headroom this executor's thread must keep,
2434 /// in bytes. `0` (the default) disables the `stack-headroom-runtime`
2435 /// rule.
2436 ///
2437 /// Set by the entry that spawned the thread, because it is the only
2438 /// party that knows what stack it handed over: the executor never sees
2439 /// `stack_bytes`, and no portable query returns a task's total stack, so
2440 /// neither an absolute floor nor a percentage can be inferred here.
2441 pub fn set_min_stack_headroom_bytes(&mut self, bytes: usize) {
2442 self.min_stack_headroom_bytes = bytes;
2443 }
2444
2445 /// Report a spin thread that has come closer to the end of its stack
2446 /// than `set_min_stack_headroom_bytes` allows.
2447 ///
2448 /// Runs on the same tick as the other rules and feeds the same ring.
2449 /// Costs one platform query per tick, and nothing at all when no minimum
2450 /// was declared.
2451 fn check_stack_headroom_rule(&mut self) {
2452 if self.min_stack_headroom_bytes == 0 {
2453 return;
2454 }
2455 let unused = nros_platform_api::stack_unused_bytes();
2456 // 0 means the port does not instrument stacks, not that the stack is
2457 // full. Reporting a violation there would be a fault invented from an
2458 // absence of data.
2459 if unused == 0 {
2460 return;
2461 }
2462 if let Some(v) = super::monitor::check_stack_headroom(
2463 unused,
2464 self.min_stack_headroom_bytes,
2465 &mut self.stack_headroom_reported,
2466 ) {
2467 if self.report_violations {
2468 super::monitor::log_violation(&v);
2469 }
2470 if self.monitor_violations.push(v).is_err() {
2471 self.monitor_violations_dropped = self.monitor_violations_dropped.saturating_add(1);
2472 }
2473 }
2474 }
2475
2476 /// Issue #515 — report a spin wake a whole period late.
2477 ///
2478 /// Runs on the same tick as `check_timer_overruns` and feeds the same
2479 /// ring, so the entry glue drains it with the other rules and no caller
2480 /// needs to know this one exists. Like that rule it needs no spec table:
2481 /// the bound is the spin period the caller already passes in.
2482 fn check_release_jitter_rule(&mut self) {
2483 let (max_us, _late, _total) = self.release_jitter();
2484 let period_us = self.spin_nominal_us;
2485 if let Some(v) =
2486 super::monitor::check_release_jitter(max_us, &mut self.jitter_reported_us, period_us)
2487 {
2488 if self.report_violations {
2489 super::monitor::log_violation(&v);
2490 }
2491 if self.monitor_violations.push(v).is_err() {
2492 self.monitor_violations_dropped = self.monitor_violations_dropped.saturating_add(1);
2493 }
2494 }
2495 }
2496
2497 fn check_timer_overruns(&mut self) {
2498 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
2499 for i in 0..self.entries.len() {
2500 let Some(meta) = self.entries[i].as_ref() else {
2501 continue;
2502 };
2503 if !matches!(meta.kind, EntryKind::Timer) {
2504 continue;
2505 }
2506 // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
2507 // which shares its leading layout with `TimerHeader`. The
2508 // baseline lives in the header too, so this needs no state
2509 // parallel to `entries` (whose capacity is a runtime slice
2510 // length, not a const).
2511 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
2512 if let Some(v) = super::monitor::check_timer_overrun(
2513 header.overruns,
2514 &mut header.overruns_reported,
2515 0,
2516 ) {
2517 if self.report_violations {
2518 super::monitor::log_violation(&v);
2519 }
2520 if self.monitor_violations.push(v).is_err() {
2521 self.monitor_violations_dropped =
2522 self.monitor_violations_dropped.saturating_add(1);
2523 }
2524 }
2525 }
2526 }
2527
2528 /// RFC-0052 / phase-296 W3a — replace the DEFAULT scheduling context
2529 /// (slot 0, the SC every unbound callback dispatches through).
2530 ///
2531 /// The run_tiers model runs one Executor per tier, so a tier-wide
2532 /// scheduling policy (`[tiers.<t>] class/budget_us/period_us` and the
2533 /// TT window) is exactly "this executor's default SC". Boards call
2534 /// this once, before entity creation; explicit per-handle/per-group
2535 /// bindings still take precedence (they never resolve to slot 0).
2536 ///
2537 /// Sporadic-class SCs get the same sibling `SporadicState` the
2538 /// `create_sched_context` path builds, so budget refill/exhaustion
2539 /// applies to the default queue too.
2540 pub fn set_default_sched_context(&mut self, sc: super::sched_context::SchedContext) {
2541 self.sched_contexts[0] = Some(sc);
2542 if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
2543 let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
2544 let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
2545 self.sporadic_states[0] =
2546 Some(super::sched_context::SporadicState::new(budget, period));
2547 } else {
2548 self.sporadic_states[0] = None;
2549 }
2550 }
2551
2552 /// Bind a registered callback to a scheduling context. The next
2553 /// `spin_once` cycle dispatches the callback through that SC's
2554 /// queue (FIFO bitmap or EDF heap). Phase 110.B.
2555 pub fn bind_handle_to_sched_context(
2556 &mut self,
2557 handle: HandleId,
2558 sc_id: super::sched_context::SchedContextId,
2559 ) -> Result<(), NodeError> {
2560 let i = handle.0;
2561 if i >= self.entries.len() {
2562 return Err(NodeError::InvalidSchedContextBinding);
2563 }
2564 if self.entries[i].is_none() {
2565 return Err(NodeError::InvalidSchedContextBinding);
2566 }
2567 let sc_idx = sc_id.0 as usize;
2568 if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
2569 return Err(NodeError::InvalidSchedContextBinding);
2570 }
2571 self.sched_context_bindings[i] = sc_id;
2572 Ok(())
2573 }
2574
2575 /// Phase 110.F — opt in to per-callback OS-priority dispatch.
2576 /// Once registered, every `spin_once` cycle routes ready entries
2577 /// whose bound SC has `os_pri > 0` onto a worker thread the OS
2578 /// scheduler has elevated to that numeric priority. Workers are
2579 /// spawned lazily on first use and self-halt when the Executor
2580 /// drops.
2581 ///
2582 /// `apply_policy` is the same `fn(SchedPolicy) -> Result<(),
2583 /// SchedError>` shape `open_threaded` takes — keeps the
2584 /// Executor non-generic over Platform.
2585 ///
2586 /// Calling this with `apply_policy = noop` is fine for testing
2587 /// (workers spawn but don't actually elevate priority); real
2588 /// hard-RT use needs `CAP_SYS_NICE` on Linux or the equivalent
2589 /// kernel config on RTOSes.
2590 #[cfg(all(feature = "alloc", feature = "scheduler-os-priority"))]
2591 pub fn register_os_priority_dispatcher(
2592 &mut self,
2593 apply_policy: fn(
2594 nros_platform_api::SchedPolicy,
2595 ) -> Result<(), nros_platform_api::SchedError>,
2596 ) {
2597 self.os_priority_apply_policy = Some(apply_policy);
2598 }
2599
2600 /// Phase 110.G — enable time-triggered dispatch by setting the
2601 /// executor's major-frame length. Once set, every `spin_once`
2602 /// cycle gates dispatch through each entry's bound SC's
2603 /// `tt_window_offset_us` / `tt_window_duration_us` fields:
2604 /// dispatch only fires when the current monotonic time falls
2605 /// inside the window `[off, off + duration) mod major_frame`.
2606 ///
2607 /// `major_frame_us = 0` disables the TT gate (default state).
2608 /// Setting a non-zero major frame after callbacks are already
2609 /// registered is allowed — TT gates take effect on the next
2610 /// `spin_once` cycle.
2611 pub fn register_time_triggered_dispatcher(&mut self, major_frame_us: u32) {
2612 self.major_frame_us = major_frame_us;
2613 }
2614
2615 /// Phase 110.G — apply a declarative cyclic schedule.
2616 ///
2617 /// One-shot helper that wraps the underlying primitives:
2618 /// validates the schedule (`major_frame > 0`, no overlapping
2619 /// windows, every window fits inside the major frame), sets the
2620 /// executor's major-frame length, then materialises one
2621 /// `SchedContext` per window with `class = TimeTriggered` +
2622 /// the window's offset / duration. Returns the per-window
2623 /// [`SchedContextId`] array so callers can immediately
2624 /// `bind_handle_to_sched_context(handle, sc_id)` for their
2625 /// subscription / timer handles.
2626 ///
2627 /// `N` is the schedule's *declared* maximum window count;
2628 /// `schedule.window_count` gates how many SCs are actually
2629 /// created. Unused trailing slots return
2630 /// `SchedContextId::default()` (sentinel — callers must respect
2631 /// `window_count`).
2632 pub fn apply_time_triggered_schedule<const N: usize>(
2633 &mut self,
2634 schedule: &super::sched_context::TimeTriggeredSchedule<N>,
2635 ) -> Result<
2636 [super::sched_context::SchedContextId; N],
2637 super::sched_context::TimeTriggeredScheduleError,
2638 > {
2639 schedule.validate()?;
2640 self.major_frame_us = schedule.major_frame_us;
2641 // SC slot 0 is the auto-created default; reusing it as a
2642 // sentinel for unused trailing slots is safe because the
2643 // caller respects `schedule.window_count`.
2644 let mut ids: [super::sched_context::SchedContextId; N] =
2645 [super::sched_context::SchedContextId(0); N];
2646 for (i, window) in schedule.windows[..schedule.window_count].iter().enumerate() {
2647 // Deprecation note on `SchedClass::TimeTriggered`: TT
2648 // is implemented as a per-SC *window gate* on top of
2649 // the existing class-based dispatch (Fifo here keeps
2650 // the EDF / Sporadic budgets out of the picture for
2651 // pure cyclic schedules). The window-gate fields set
2652 // below are what `spin_once`'s 110.G runtime gate
2653 // actually reads.
2654 let sc = super::sched_context::SchedContext {
2655 tt_window_offset_us: super::sched_context::OptUs::from_us(window.offset_us),
2656 tt_window_duration_us: super::sched_context::OptUs::from_us(window.duration_us),
2657 ..super::sched_context::SchedContext::new_fifo()
2658 };
2659 ids[i] = self.create_sched_context(sc).map_err(|_| {
2660 super::sched_context::TimeTriggeredScheduleError::WindowCountOverflow
2661 })?;
2662 }
2663 Ok(ids)
2664 }
2665
2666 /// Phase 110.E.b — register an ISR-driven refill timer for an
2667 /// already-created Sporadic SC. The caller invokes their
2668 /// platform's `PlatformTimer::create_periodic` with the returned
2669 /// `Arc<AtomicSporadicState>` as `user_data` and the
2670 /// `atomic_sporadic_refill_thunk` as the callback, then hands
2671 /// the resulting platform handle to this method via
2672 /// `OpaqueTimerHandle::new(handle, destroy_fn)`.
2673 ///
2674 /// The Executor stores both the Arc and the handle so Drop can
2675 /// clean them up. Calling this on a non-Sporadic SC returns
2676 /// `Err(InvalidSchedContextBinding)`.
2677 #[cfg(feature = "alloc")]
2678 pub fn register_sporadic_timer(
2679 &mut self,
2680 sc_id: super::sched_context::SchedContextId,
2681 timer: OpaqueTimerHandle,
2682 ) -> Result<portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>, NodeError>
2683 {
2684 let i = sc_id.0 as usize;
2685 if i >= self.sched_contexts.len() {
2686 return Err(NodeError::InvalidSchedContextBinding);
2687 }
2688 let sc = self.sched_contexts[i]
2689 .as_ref()
2690 .ok_or(NodeError::InvalidSchedContextBinding)?;
2691 if !matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
2692 return Err(NodeError::InvalidSchedContextBinding);
2693 }
2694 let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
2695 let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
2696 let state = portable_atomic_util::Arc::new(super::sched_context::AtomicSporadicState::new(
2697 budget, period,
2698 ));
2699 self.sporadic_atomic_states[i] = Some((portable_atomic_util::Arc::clone(&state), timer));
2700 Ok(state)
2701 }
2702
2703 /// Inspect a registered scheduling context. Phase 110.B.
2704 pub fn sched_context(
2705 &self,
2706 sc_id: super::sched_context::SchedContextId,
2707 ) -> Option<&super::sched_context::SchedContext> {
2708 self.sched_contexts.get(sc_id.0 as usize)?.as_ref()
2709 }
2710
2711 /// Phase 104.C.2 — start a rclcpp-style Node builder for this
2712 /// Executor. The returned [`NodeBuilder`](super::node_record::NodeBuilder)
2713 /// is chainable:
2714 ///
2715 /// ```ignore
2716 /// let id = exec.node_builder("ingress")
2717 /// .rmw("zenoh")
2718 /// .locator("tcp/127.0.0.1:7447")
2719 /// .sched(my_sc_id)
2720 /// .build()?;
2721 /// ```
2722 ///
2723 /// In Phase 104.C.2 the Node table is storage-only — all
2724 /// registered Nodes share the Executor's primary session. Per-
2725 /// Node session binding (the bridge feature) lands in Phase
2726 /// 104.C.3 when the session cache is wired.
2727 pub fn node_builder<'a, 'cfg>(
2728 &'a mut self,
2729 name: &'cfg str,
2730 ) -> super::node_record::NodeBuilder<'a, 'cfg, 's> {
2731 super::node_record::NodeBuilder {
2732 executor: self,
2733 name,
2734 namespace: None,
2735 rmw_name: None,
2736 locator: None,
2737 domain_id: None,
2738 sched: None,
2739 session_idx: None,
2740 }
2741 }
2742
2743 /// Return the Node table — Phase 104.C.2 read accessor.
2744 pub fn nodes(&self) -> &[super::node_record::NodeRecord] {
2745 &self.nodes
2746 }
2747
2748 /// Borrow a Node's metadata by id, returning `None` if the id
2749 /// is out of range.
2750 pub fn node(&self, id: super::node_record::NodeId) -> Option<&super::node_record::NodeRecord> {
2751 self.nodes.get(id.index())
2752 }
2753
2754 /// Phase 189.M1 — an executor-borrowing node handle for the entity builders
2755 /// (`exec.node_mut(id).subscription(t)...` / `.create_subscription(...)`).
2756 /// A short-lived `&mut Executor` borrow — use one at a time; entity handles
2757 /// are owned and outlive it (see `NodeCtx`).
2758 pub fn node_mut(&mut self, id: super::node_record::NodeId) -> super::node::NodeCtx<'_, 's> {
2759 super::node::NodeCtx::new(self, id)
2760 }
2761
2762 /// Issue #52 — install the baked QoS-override table on one node. Every
2763 /// entity created on it AFTERWARDS folds the matching `(topic, role)`
2764 /// entries into its QoS, before the backend-compat check — so an override
2765 /// the active RMW cannot honour still errors loudly rather than silently
2766 /// downgrading.
2767 ///
2768 /// Called by the generated entry (`nros::main!` → the register seam) right
2769 /// after the node is created and before the component declares entities.
2770 /// Unknown `node_id` is a no-op.
2771 pub fn set_node_qos_overrides(
2772 &mut self,
2773 node_id: super::node_record::NodeId,
2774 overrides: &'static [super::node_record::QoSOverrideCode],
2775 ) {
2776 if let Some(r) = self.nodes.get_mut(node_id.index()) {
2777 r.qos_overrides = overrides;
2778 }
2779 }
2780
2781 /// Phase 104.C.3 — resolve a session-slot index to a mutable
2782 /// session reference. Slot 0 = the Executor's primary session;
2783 /// slots 1..=N = the `extra_sessions` vec opened by
2784 /// `node_builder.rmw(name)` calls that named a backend
2785 /// different from the primary.
2786 pub(crate) fn session_at_mut(&mut self, idx: u8) -> Option<&mut session::ConcreteSession> {
2787 if idx == 0 {
2788 Some(&mut *self.session)
2789 } else {
2790 self.extra_sessions.get_mut((idx - 1) as usize)
2791 }
2792 }
2793
2794 /// Phase 104.C.9.b — resolve the per-Node session for direct
2795 /// entity creation paths (C++ FFI publisher / subscription /
2796 /// service that bypass the `register_*_on` arena dispatch).
2797 /// Returns `None` when `node_id` is out of range or the Node's
2798 /// `session_idx` lands outside the executor's session table.
2799 pub fn node_session_mut(
2800 &mut self,
2801 node_id: super::node_record::NodeId,
2802 ) -> Option<&mut session::ConcreteSession> {
2803 let session_idx = self.nodes.get(node_id.index())?.session_idx;
2804 self.session_at_mut(session_idx)
2805 }
2806
2807 /// Phase 189.M1 — create a typed publisher bound to a node's session.
2808 /// Backs `node.publisher(t).typed::<M>().build()` on the
2809 /// executor-borrowing [`NodeCtx`](super::node::NodeCtx); the returned
2810 /// handle is owned and outlives the `NodeCtx`.
2811 pub fn create_publisher_on<M: crate::rmw_type_registry::MessageForRmw>(
2812 &mut self,
2813 node_id: super::node_record::NodeId,
2814 topic_name: &str,
2815 qos: QoSProfile,
2816 ) -> Result<crate::executor::handles::EmbeddedPublisher<M>, NodeError> {
2817 // RFC-0088 / phase-421 W1 — the message's declared format must be the
2818 // one the linked backend speaks. Universal: the const lives on
2819 // `RosMessage`, which `MessageForRmw` requires under every backend.
2820 crate::format_check::assert_message_format::<M>();
2821 // Phase 212.K.7.6.b — register `M`'s cyclonedds descriptor before
2822 // creating the underlying publisher handle. No-op for other RMWs.
2823 crate::rmw_type_registry::register_type::<M>()?;
2824 let handle = self.create_raw_publisher_handle_on(
2825 node_id,
2826 topic_name,
2827 <M as RosMessage>::TYPE_NAME,
2828 <M as RosMessage>::TYPE_HASH,
2829 qos,
2830 )?;
2831 // RFC-0052 W3b.4 — attach the contracted endpoint's counter cell.
2832 let monitor = self
2833 .monitor_table
2834 .iter()
2835 .find(|m| m.topic == topic_name)
2836 .map(|m| m.cell);
2837 Ok(crate::executor::handles::EmbeddedPublisher {
2838 handle,
2839 event_regs: crate::executor::handles::empty_event_regs(),
2840 monitor,
2841 epoch: self.epoch_us_fn,
2842 _phantom: PhantomData,
2843 })
2844 }
2845
2846 /// Phase 189.M1 — create a generic (type-erased) publisher bound to a
2847 /// node's session. Backs `node.publisher(t).generic(ty, hash).build()`;
2848 /// the bridge re-publishes through this handle on the dest session.
2849 pub fn create_publisher_raw_on(
2850 &mut self,
2851 node_id: super::node_record::NodeId,
2852 topic_name: &str,
2853 type_name: &str,
2854 type_hash: &str,
2855 qos: QoSProfile,
2856 ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
2857 let handle =
2858 self.create_raw_publisher_handle_on(node_id, topic_name, type_name, type_hash, qos)?;
2859 Ok(crate::executor::handles::EmbeddedRawPublisher {
2860 handle,
2861 arena: crate::executor::handles::TxArena::new(),
2862 event_regs: crate::executor::handles::empty_event_regs(),
2863 })
2864 }
2865
2866 /// Shared prelude for the publisher-on-node paths: resolve the node's
2867 /// identity + session slot, build the [`TopicInfo`], validate QoS, and
2868 /// create the backend publisher handle. Mirrors
2869 /// `register_subscription_buffered_raw_on`'s session resolution so a
2870 /// bridge's source sub + dest pub agree on topic construction.
2871 fn create_raw_publisher_handle_on(
2872 &mut self,
2873 node_id: super::node_record::NodeId,
2874 topic_name: &str,
2875 type_name: &str,
2876 type_hash: &str,
2877 qos: QoSProfile,
2878 ) -> Result<session::RmwPublisher, NodeError> {
2879 let (node_name, ns, session_idx, overrides) = {
2880 let r = self
2881 .nodes
2882 .get(node_id.index())
2883 .ok_or(NodeError::InvalidSchedContextBinding)?;
2884 (
2885 r.name.clone(),
2886 r.namespace.clone(),
2887 r.session_idx,
2888 r.qos_overrides,
2889 )
2890 };
2891 // Issue #52 — fold the node's baked overrides for this topic BEFORE
2892 // `validate_against`, so an override the backend cannot honour errors
2893 // loudly instead of being silently dropped.
2894 let qos = super::node_record::apply_qos_override_codes(
2895 qos,
2896 topic_name,
2897 nros_rmw::QoSOverrideRole::Publisher,
2898 overrides,
2899 );
2900 let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
2901 .with_domain(self.domain_id)
2902 .with_namespace(&ns);
2903 if !node_name.is_empty() {
2904 topic = topic.with_node_name(&node_name);
2905 }
2906 let session = self
2907 .session_at_mut(session_idx)
2908 .ok_or(NodeError::BackendMismatch)?;
2909 qos.validate_against(Session::supported_qos_policies(session))
2910 .map_err(NodeError::Transport)?;
2911 session
2912 .create_publisher(&topic, qos)
2913 .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))
2914 }
2915
2916 /// Phase 124.B.1 — install the executor's wake callback onto the
2917 /// primary session. Best-effort: backends that don't override
2918 /// `Session::set_wake_callback` (poll-only XRCE, bare-metal)
2919 /// ignore the call and continue to be drained on the executor's
2920 /// deadline-bound cv-wait boundary.
2921 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2922 fn install_wake_signal_on_primary(&mut self) {
2923 use nros_rmw::Session as _;
2924 let ctx = self.wake_ctx_ptr();
2925 // SAFETY: `ctx` points at executor-owned wake state that outlives
2926 // the session callback installation and is cleared on executor drop.
2927 unsafe {
2928 self.session
2929 .set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
2930 }
2931 if self.session.supports_wake_callback() {
2932 self.has_async_wake = true;
2933 }
2934 }
2935
2936 /// Phase 124.B.1 — install the wake callback onto an extra
2937 /// session opened by `node_builder.rmw(...)`. Called from
2938 /// `NodeBuilder::build()` right after `extra_sessions.push(...)`.
2939 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2940 pub(crate) fn install_wake_signal_on_extra(&mut self, idx: usize) {
2941 use nros_rmw::Session as _;
2942 let ctx = self.wake_ctx_ptr();
2943 if let Some(s) = self.extra_sessions.get_mut(idx) {
2944 // SAFETY: same executor-owned wake state as the primary session;
2945 // the extra session is owned by this executor.
2946 unsafe {
2947 s.set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
2948 }
2949 if s.supports_wake_callback() {
2950 self.has_async_wake = true;
2951 }
2952 }
2953 }
2954
2955 /// Phase 124.B.2 — opaque context pointer the runtime wake
2956 /// callback receives. Encodes `(flag, mu, cv)` as a borrowed
2957 /// `&WakeCtx` reference; the callback decodes via
2958 /// `*const WakeCtx`.
2959 ///
2960 /// Lifetime: tied to the Executor instance. WakeCtx storage
2961 /// lives inside Executor (lazy-allocated on first install), so
2962 /// the pointer stays valid as long as the Executor is.
2963 /// Phase 124.B.7.c — POSIX signal-handler-safe wake fd.
2964 ///
2965 /// Returns a Linux `eventfd` that callers (typically POSIX
2966 /// signal handlers) can `write(fd, &1u64, 8)` to from any
2967 /// context, including signal handlers. A runtime-owned worker
2968 /// thread reads the fd and signals `wake_cv`, unblocking
2969 /// `spin_once`.
2970 ///
2971 /// The worker thread is spawned lazily on first call and
2972 /// joined on Executor drop. Linux-only and gated behind
2973 /// `feature = "signal-fd-wake"`; binaries that don't install
2974 /// signal handlers shouldn't enable it.
2975 ///
2976 /// Returns the raw fd. The Executor retains ownership; do not
2977 /// `close()` it from the caller.
2978 #[cfg(all(feature = "signal-fd-wake", feature = "rmw-cffi", target_os = "linux"))]
2979 /// phase-359 W10 — was `std::io::Result`. Same values, an error type that
2980 /// does not require `std`.
2981 pub fn signal_fd(&mut self) -> Result<core::ffi::c_int, NodeError> {
2982 let ctx_ptr = self.wake_ctx_ptr() as *const WakeCtx;
2983 if self.signal_fd.is_none() {
2984 self.signal_fd = Some(WakeSignalFd::new(ctx_ptr)?);
2985 }
2986 Ok(self.signal_fd.as_ref().expect("just set").fd())
2987 }
2988
2989 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2990 fn wake_ctx_ptr(&mut self) -> *mut core::ffi::c_void {
2991 if self.wake_ctx.is_none() {
2992 self.wake_ctx = Some(portable_atomic_util::Arc::new(WakeCtx {
2993 flag: self.wake_flag.clone(),
2994 node_wake: self.node_wake.clone(),
2995 }));
2996 }
2997 let arc = self.wake_ctx.as_ref().expect("just set");
2998 portable_atomic_util::Arc::as_ptr(arc) as *mut core::ffi::c_void
2999 }
3000
3001 /// Phase 104.C.4 — apply a Node's default SchedContext to a
3002 /// freshly-registered handle. Called from every `_inner`
3003 /// register variant after the entry slot is committed. No-op
3004 /// when `node_id` is None (legacy path), when the Node is
3005 /// out of range, or when the Node's `default_sched` is the
3006 /// auto-created Fifo slot (0) which matches the executor's
3007 /// default binding already.
3008 ///
3009 /// Phase 273 (RFC-0047) — extends with an optional `group` name.
3010 /// Precedence: **group table > node default > no binding** (SC 0).
3011 /// When `group` is `Some(g)`, consults `group_sched_table` first;
3012 /// if no entry exists for `(name, namespace, g)` falls back to the
3013 /// node's `default_sched`. When `group` is `None` the group table
3014 /// is not consulted (unchanged phase-272 path).
3015 ///
3016 /// Handles can still override per-call via
3017 /// `bind_handle_to_sched_context(handle, sc_id)` post-register.
3018 pub(crate) fn apply_node_default_sched(
3019 &mut self,
3020 slot: usize,
3021 node_id: Option<super::node_record::NodeId>,
3022 group: Option<&str>,
3023 ) {
3024 let Some(id) = node_id else { return };
3025 // Copy name, namespace, and default_sched out so the borrow on
3026 // `self.nodes` is released before the immutable `lookup_group_sched`
3027 // borrow and the mutable `sched_context_bindings` write below.
3028 let (name, namespace, node_sc) = {
3029 let Some(rec) = self.nodes.get(id.index()) else {
3030 return;
3031 };
3032 (rec.name.clone(), rec.namespace.clone(), rec.default_sched)
3033 };
3034 // Phase 273: group table > node default.
3035 let sc = match group {
3036 Some(g) => self
3037 .lookup_group_sched(name.as_str(), namespace.as_str(), g)
3038 .unwrap_or(node_sc),
3039 None => node_sc,
3040 };
3041 if sc.0 == 0 {
3042 return;
3043 }
3044 if slot >= self.entries.len() {
3045 return;
3046 }
3047 let sc_idx = sc.0 as usize;
3048 if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
3049 return;
3050 }
3051 self.sched_context_bindings[slot] = sc;
3052 }
3053
3054 /// Phase 104.C.3.2 — scoped Node-handle access. The closure
3055 /// receives a [`Node`] bound to the requested [`NodeId`]'s
3056 /// session + identity. Use the standard `Node::create_publisher`,
3057 /// `create_subscription`, etc. APIs inside.
3058 ///
3059 /// rclcpp-aligned bridge pattern:
3060 ///
3061 /// ```ignore
3062 /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
3063 /// let node_out = exec.node_builder("egress").rmw("xrce").build()?;
3064 ///
3065 /// let pub_out = exec.with_node(node_out, |n| {
3066 /// n.create_publisher::<Int32>("/fwd")
3067 /// })??;
3068 ///
3069 /// exec.with_node(node_in, |n| {
3070 /// n.create_subscription_buffered::<Int32, _, 1024>(
3071 /// "/src", qos(), move |m| { let _ = pub_out.publish(m); }
3072 /// )
3073 /// })??;
3074 /// ```
3075 ///
3076 /// The closure can return any type; double-`?` unwraps the
3077 /// outer `Result<R, NodeError>` from `with_node` and the inner
3078 /// result returned by the closure.
3079 /// Phase 104.C.3.3.d — flat-Result variant of
3080 /// [`with_node`](Self::with_node). When the closure already
3081 /// returns `Result<R, NodeError>`, this avoids the double-`?`:
3082 ///
3083 /// ```ignore
3084 /// // Without `with_node_try`:
3085 /// let pub_ = exec.with_node(id, |n| n.create_publisher(...))??;
3086 ///
3087 /// // With `with_node_try`:
3088 /// let pub_ = exec.with_node_try(id, |n| n.create_publisher(...))?;
3089 /// ```
3090 pub fn with_node_try<R>(
3091 &mut self,
3092 id: super::node_record::NodeId,
3093 f: impl FnOnce(&mut NodeHandle<'_>) -> Result<R, NodeError>,
3094 ) -> Result<R, NodeError> {
3095 self.with_node(id, f)?
3096 }
3097
3098 pub fn with_node<R>(
3099 &mut self,
3100 id: super::node_record::NodeId,
3101 f: impl FnOnce(&mut NodeHandle<'_>) -> R,
3102 ) -> Result<R, NodeError> {
3103 let (name, ns, session_idx) = {
3104 let r = self
3105 .nodes
3106 .get(id.index())
3107 .ok_or(NodeError::InvalidSchedContextBinding)?;
3108 (r.name.clone(), r.namespace.clone(), r.session_idx)
3109 };
3110 let monitors = self.monitor_table;
3111 let age_monitors = self.age_table;
3112 let epoch = self.epoch_us_fn;
3113 let domain_id = self.domain_id;
3114 let session = self
3115 .session_at_mut(session_idx)
3116 .ok_or(NodeError::BackendMismatch)?;
3117 // SAFETY: short-lived scoped reference. `Node::new` takes
3118 // `&mut ConcreteSession`; lifetime is bound to this fn's
3119 // body via the closure's borrow of `node`.
3120 // issue 0801 (second half) — the executor's domain, NOT a literal 0.
3121 // 429d5a581 fixed the ELEVEN arena `TopicInfo`s and left the THREE
3122 // `NodeHandle::new` sites, so an entity created through a node HANDLE
3123 // (`with_node`, `node`, `node_on`) still declared on domain 0 while the
3124 // arena path declared on the configured one. Same split the issue is
3125 // about, one constructor over: `loan_e2e` publishes through a handle and
3126 // subscribes through the arena, so it delivered on domain 0 and on
3127 // nothing else.
3128 let mut node = NodeHandle::new(name, ns, session, domain_id);
3129 // RFC-0052 W3b.4/.5 — seed the baked monitor tables so contracted
3130 // publishers/subscribers attach their cells without entry glue.
3131 node.set_monitors(monitors);
3132 node.set_age_monitors(age_monitors, epoch);
3133 Ok(f(&mut node))
3134 }
3135
3136 /// Find a registered executor node by final name and namespace.
3137 pub fn node_id_by_name(
3138 &self,
3139 name: &str,
3140 namespace: &str,
3141 ) -> Option<super::node_record::NodeId> {
3142 self.nodes
3143 .iter()
3144 .enumerate()
3145 .find(|(_, node)| node.name.as_str() == name && node.namespace.as_str() == namespace)
3146 .map(|(index, _)| super::node_record::NodeId::from_raw(index as u8))
3147 }
3148
3149 /// Create a node on this executor.
3150 ///
3151 /// Registers the node in the executor's table, deduplicating on
3152 /// `(name, namespace)` — phase-376 W5/B1.
3153 ///
3154 /// Until 2026-08-24 this path registered NOTHING. It built a `NodeHandle`
3155 /// and returned it, so `node_id_by_name` could not find a node the caller
3156 /// had just created, and two calls with one name handed out two handles the
3157 /// executor had never heard of. `create_node_on_with_domain` had the dedup
3158 /// (phase-267 added it there when N bridge endpoints overflowed the table);
3159 /// the plain path never got it.
3160 ///
3161 /// That is a prerequisite for the `create_node` vtable slot, whose contract
3162 /// is that the runtime calls it ONCE per distinct `(name, namespace)`:
3163 /// without a registry to check, the runtime would call it once per
3164 /// `create_node` and every backend would need its own dedup — which is the
3165 /// registry the slot exists to delete (zenoh's `ensure_node_liveliness`
3166 /// linear-scans `per_node_liveliness` for exactly this reason).
3167 ///
3168 /// The table is bounded by `MAX_NODES` (`NROS_EXECUTOR_MAX_NODES`, default
3169 /// 4), so a caller creating a fifth DISTINCT node now gets
3170 /// `NodeError::NodeTableFull` where it previously got a handle. That is the
3171 /// bound doing its job: a node the executor does not know about cannot
3172 /// carry a sched context, a QoS override, or a graph identity. Repeated
3173 /// calls with the SAME name are free.
3174 pub fn create_node(&mut self, name: &str) -> Result<NodeHandle<'_>, NodeError> {
3175 if name.len() > 64 {
3176 return Err(NodeError::NameTooLong);
3177 }
3178
3179 let mut node_name = heapless::String::<64>::new();
3180 node_name
3181 .push_str(name)
3182 .map_err(|_| NodeError::NameTooLong)?;
3183
3184 // Dedup against the executor's own namespace — the one this handle
3185 // will carry. `node_builder` resolves the session slot to 0 (primary)
3186 // when no rmw name is given, which is what this path has always used.
3187 if self
3188 .node_id_by_name(node_name.as_str(), self.namespace.as_str())
3189 .is_none()
3190 {
3191 self.node_builder(name).build()?;
3192 }
3193
3194 // issue 0801 (second half) — the executor's domain, NOT a literal 0.
3195 // 429d5a581 fixed the ELEVEN arena `TopicInfo`s and left the THREE
3196 // `NodeHandle::new` sites, so an entity created through a node HANDLE
3197 // (`with_node`, `node`, `node_on`) still declared on domain 0 while the
3198 // arena path declared on the configured one. Same split the issue is
3199 // about, one constructor over: `loan_e2e` publishes through a handle and
3200 // subscribes through the arena, so it delivered on domain 0 and on
3201 // nothing else.
3202 let domain_id = self.domain_id;
3203 let mut node = NodeHandle::new(
3204 node_name,
3205 self.namespace.clone(),
3206 &mut self.session,
3207 domain_id,
3208 );
3209 node.set_monitors(self.monitor_table);
3210 node.set_age_monitors(self.age_table, self.epoch_us_fn);
3211 Ok(node)
3212 }
3213
3214 /// Phase 128.F.2 — bridge-mode node factory. Registers a Node
3215 /// bound to the named RMW backend by opening (or reusing) an
3216 /// extra session via `node_builder().rmw(rmw).build()`, then
3217 /// returns a [`Node`] borrowing that session. Use when the
3218 /// binary intentionally links more than one backend and a Node
3219 /// must speak a specific one.
3220 ///
3221 /// The single-backend common case should keep using
3222 /// [`create_node`](Self::create_node) — this entry costs an
3223 /// extra session lookup and serves no purpose when only one
3224 /// backend is registered.
3225 #[cfg(feature = "rmw-cffi")]
3226 pub fn create_node_on(&mut self, name: &str, rmw: &str) -> Result<NodeHandle<'_>, NodeError> {
3227 self.create_node_on_with_domain(name, rmw, None, None)
3228 }
3229
3230 /// Like [`create_node_on`](Self::create_node_on) but pins the extra
3231 /// session's domain id. Required for a multi-domain config-driven bridge:
3232 /// an extra RMW session's participant domain follows the **node builder's**
3233 /// `domain_id` (`resolve_session_slot` → `domain_id.unwrap_or(0)`), NOT the
3234 /// `SessionSpec`'s — so without this an egress on a non-zero domain silently
3235 /// opens on domain 0 and never matches its receiver (phase-267 issue 0109).
3236 /// `None` domain preserves the legacy domain-0 default. `locator` pins the
3237 /// extra session's address — REQUIRED for an agent-based backend (xrce: the
3238 /// Micro-XRCE-DDS Agent addr) whose session can't be opened locator-less;
3239 /// `None` keeps the rmw-default (cyclonedds is domain-discovered, no locator).
3240 pub fn create_node_on_with_domain(
3241 &mut self,
3242 name: &str,
3243 rmw: &str,
3244 domain_id: Option<u32>,
3245 locator: Option<&str>,
3246 ) -> Result<NodeHandle<'_>, NodeError> {
3247 if name.len() > 64 {
3248 return Err(NodeError::NameTooLong);
3249 }
3250 // Reuse an existing Node of this name rather than growing the node table
3251 // (phase-267 non-flat): a config-driven bridge calls this once per bridge
3252 // ENDPOINT, and the same session node (`s0`/`s1`) recurs across every
3253 // `[[bridge]]`. Without dedup, N bridges push 2N records and overflow
3254 // `MAX_NODES`. Names are unique per session in a generated bridge config,
3255 // so matching by name is unambiguous.
3256 let session_idx = if let Some(rec) = self.nodes.iter().find(|n| n.name.as_str() == name) {
3257 rec.session_idx
3258 } else {
3259 // Register the Node (opens an extra session under `rmw` if
3260 // none exists yet for that backend).
3261 let mut builder = self.node_builder(name).rmw(rmw);
3262 if let Some(d) = domain_id {
3263 builder = builder.domain_id(d);
3264 }
3265 if let Some(loc) = locator {
3266 builder = builder.locator(loc);
3267 }
3268 let id = builder.build()?;
3269 self.node(id).ok_or(NodeError::NodeTableFull)?.session_idx
3270 };
3271
3272 let mut node_name = heapless::String::<64>::new();
3273 node_name
3274 .push_str(name)
3275 .map_err(|_| NodeError::NameTooLong)?;
3276 let namespace = self.namespace.clone();
3277 let monitors = self.monitor_table;
3278 let age_monitors = self.age_table;
3279 let epoch = self.epoch_us_fn;
3280 let domain_id = self.domain_id;
3281 let session = self
3282 .session_at_mut(session_idx)
3283 .ok_or(NodeError::NodeTableFull)?;
3284 // issue 0801 (second half) — the executor's domain, NOT a literal 0.
3285 // 429d5a581 fixed the ELEVEN arena `TopicInfo`s and left the THREE
3286 // `NodeHandle::new` sites, so an entity created through a node HANDLE
3287 // (`with_node`, `node`, `node_on`) still declared on domain 0 while the
3288 // arena path declared on the configured one. Same split the issue is
3289 // about, one constructor over: `loan_e2e` publishes through a handle and
3290 // subscribes through the arena, so it delivered on domain 0 and on
3291 // nothing else.
3292 let mut node = NodeHandle::new(node_name, namespace, session, domain_id);
3293 node.set_monitors(monitors);
3294 node.set_age_monitors(age_monitors, epoch);
3295 Ok(node)
3296 }
3297
3298 /// Drive transport I/O (poll network, dispatch callbacks).
3299 #[allow(dead_code)]
3300 pub(crate) fn drive_io(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
3301 self.session
3302 .drive_io(timeout_ms)
3303 .map_err(|_| NodeError::Transport(TransportError::PollFailed))
3304 }
3305
3306 /// Close the underlying session, running the shutdown hooks around it.
3307 ///
3308 /// Issue 0790. The order is the feature:
3309 ///
3310 /// 1. every registered PRE-shutdown hook, while the session is still open
3311 /// and every entity still works — this is where a node publishes a final
3312 /// state, answers a last request, parks an actuator or releases a bus;
3313 /// 2. the session close;
3314 /// 3. every registered ON-shutdown hook.
3315 ///
3316 /// A hook runs EXACTLY ONCE: each phase table is emptied before its first
3317 /// hook is invoked, so a second `close()` — or the [`Drop`] sweep after one
3318 /// — finds nothing left to run. Step 2 runs even
3319 /// if a pre-shutdown hook was registered and step 3 even if the close
3320 /// failed — a hook cannot strand the session, and a dead session must not
3321 /// strand the hooks.
3322 ///
3323 /// # This is a CLEAN-STOP facility and nothing more
3324 ///
3325 /// A watchdog reset, a hard fault or a panic does not come through here, so
3326 /// these hooks do not run then. Nothing in a fixed static table can promise
3327 /// otherwise, and an API that implied it would be worse than none: hardware
3328 /// that must be safe across an abnormal stop needs a hardware answer (a
3329 /// pull-down, a watchdog-driven output disable), not a callback.
3330 pub fn close(&mut self) -> Result<(), NodeError> {
3331 self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
3332 let result = self
3333 .session
3334 .close()
3335 .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed));
3336 self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
3337 result
3338 }
3339
3340 /// Register a hook to run BEFORE the session is closed (issue 0790).
3341 ///
3342 /// rclcpp's `Context::add_pre_shutdown_callback`. Returns the handle
3343 /// [`Self::remove_pre_shutdown_callback`] takes, or
3344 /// [`NodeError::ShutdownCallbacksFull`] when the phase table is full —
3345 /// raise `NROS_EXECUTOR_MAX_SHUTDOWN_CBS` (default 2) at build time.
3346 ///
3347 /// # Safety
3348 /// `callback` must be safe to invoke exactly once with `context`, and
3349 /// `context` must stay valid until the hook runs or is removed. The hook
3350 /// runs on whichever task calls [`Self::close`] (or drops the executor),
3351 /// which is not necessarily the task that registered it.
3352 pub unsafe fn add_pre_shutdown_callback(
3353 &mut self,
3354 callback: super::types::ShutdownCallbackFn,
3355 context: *mut core::ffi::c_void,
3356 ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
3357 Self::claim_shutdown_slot(
3358 &mut self.pre_shutdown_hooks,
3359 super::types::ShutdownPhase::Pre,
3360 callback,
3361 context,
3362 )
3363 }
3364
3365 /// Register a hook to run AFTER the session is closed (issue 0790).
3366 ///
3367 /// rclcpp's `Context::add_on_shutdown_callback` / `rclcpp::on_shutdown`.
3368 /// Entities are gone by the time it runs, so anything that needs the wire
3369 /// belongs in [`Self::add_pre_shutdown_callback`] instead.
3370 ///
3371 /// # Safety
3372 /// Same contract as [`Self::add_pre_shutdown_callback`].
3373 pub unsafe fn add_on_shutdown_callback(
3374 &mut self,
3375 callback: super::types::ShutdownCallbackFn,
3376 context: *mut core::ffi::c_void,
3377 ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
3378 Self::claim_shutdown_slot(
3379 &mut self.on_shutdown_hooks,
3380 super::types::ShutdownPhase::Post,
3381 callback,
3382 context,
3383 )
3384 }
3385
3386 /// Remove a pre-shutdown hook. `true` if `handle` named a live one.
3387 ///
3388 /// rclcpp's `Context::remove_pre_shutdown_callback`, and `bool` for the
3389 /// same reason: "it was not there" is an ordinary answer (the hook may
3390 /// already have run), not an error. A handle issued for the OTHER phase
3391 /// returns `false` and removes nothing — that is what the phase tag in
3392 /// [`ShutdownCallbackHandle`] buys.
3393 ///
3394 /// [`ShutdownCallbackHandle`]: super::types::ShutdownCallbackHandle
3395 pub fn remove_pre_shutdown_callback(
3396 &mut self,
3397 handle: super::types::ShutdownCallbackHandle,
3398 ) -> bool {
3399 Self::release_shutdown_slot(
3400 &mut self.pre_shutdown_hooks,
3401 super::types::ShutdownPhase::Pre,
3402 handle,
3403 )
3404 }
3405
3406 /// Remove an on-shutdown hook. See [`Self::remove_pre_shutdown_callback`].
3407 pub fn remove_on_shutdown_callback(
3408 &mut self,
3409 handle: super::types::ShutdownCallbackHandle,
3410 ) -> bool {
3411 Self::release_shutdown_slot(
3412 &mut self.on_shutdown_hooks,
3413 super::types::ShutdownPhase::Post,
3414 handle,
3415 )
3416 }
3417
3418 /// How many hooks are currently registered for `phase`. Diagnostic /
3419 /// test surface, and what a "did my registration land?" assertion reads.
3420 pub fn shutdown_callback_count(&self, phase: super::types::ShutdownPhase) -> usize {
3421 let table = match phase {
3422 super::types::ShutdownPhase::Pre => &self.pre_shutdown_hooks,
3423 super::types::ShutdownPhase::Post => &self.on_shutdown_hooks,
3424 };
3425 table.iter().flatten().count()
3426 }
3427
3428 /// Claim the first free slot of a phase table.
3429 fn claim_shutdown_slot(
3430 table: &mut [Option<super::types::ShutdownHook>],
3431 phase: super::types::ShutdownPhase,
3432 callback: super::types::ShutdownCallbackFn,
3433 context: *mut core::ffi::c_void,
3434 ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
3435 for (index, slot) in table.iter_mut().enumerate() {
3436 if slot.is_some() {
3437 continue;
3438 }
3439 let handle = super::types::ShutdownCallbackHandle::new(phase, index)
3440 .ok_or(NodeError::ShutdownCallbacksFull)?;
3441 *slot = Some(super::types::ShutdownHook { callback, context });
3442 return Ok(handle);
3443 }
3444 Err(NodeError::ShutdownCallbacksFull)
3445 }
3446
3447 /// Clear a slot of a phase table, rejecting a handle from the other phase.
3448 fn release_shutdown_slot(
3449 table: &mut [Option<super::types::ShutdownHook>],
3450 phase: super::types::ShutdownPhase,
3451 handle: super::types::ShutdownCallbackHandle,
3452 ) -> bool {
3453 if handle.phase() != Some(phase) {
3454 return false;
3455 }
3456 match table.get_mut(handle.index()) {
3457 Some(slot) => slot.take().is_some(),
3458 None => false,
3459 }
3460 }
3461
3462 /// Run — and consume — every hook registered for `phase`, in registration
3463 /// order.
3464 ///
3465 /// The table is EMPTIED BEFORE the first callback runs, not slot by slot as
3466 /// the loop walks it, and both halves of that matter:
3467 ///
3468 /// * "exactly once" becomes a property of the table rather than of the
3469 /// caller — a second `close()`, or the `Drop` sweep after one, finds
3470 /// nothing left to run;
3471 /// * the `&mut self` borrow ENDS before any foreign code is invoked. A hook
3472 /// is an `extern "C" fn` that may hold a raw pointer back to this
3473 /// executor (the C and C++ shims hand out exactly that), so a hook that
3474 /// registers or removes another one must not be running inside a live
3475 /// `&mut` into the table it is touching.
3476 pub(crate) fn run_shutdown_hooks(&mut self, phase: super::types::ShutdownPhase) {
3477 let table = match phase {
3478 super::types::ShutdownPhase::Pre => &mut self.pre_shutdown_hooks,
3479 super::types::ShutdownPhase::Post => &mut self.on_shutdown_hooks,
3480 };
3481 // `ShutdownHook` is `Copy` (a fn pointer and a raw pointer), so this is
3482 // a register-width move of a table whose default size is two slots —
3483 // not a reason to keep the borrow open across the calls.
3484 let hooks = *table;
3485 *table = [None; crate::config::MAX_SHUTDOWN_CBS];
3486 for hook in hooks.iter().flatten() {
3487 // SAFETY: the `add_*_shutdown_callback` caller promised `callback`
3488 // is safe to invoke once with `context`, and that `context` stays
3489 // valid until the hook runs or is removed. This is that one call,
3490 // and the table is already cleared so it cannot happen again.
3491 unsafe {
3492 (hook.callback)(hook.context);
3493 }
3494 }
3495 }
3496
3497 /// Phase 216 follow-up — register a per-Node dispatch trampoline.
3498 ///
3499 /// The board-side Entry pkg (or the macro-emitted
3500 /// `register_dispatch(executor)` wrapper, once wired) calls this
3501 /// once per deployed Node pkg, handing in the
3502 /// `__nros_node_<pkg>_on_callback` symbol + the Node's per-pkg
3503 /// `state` blob. [`Executor::dispatch_callback`] then linear-scans
3504 /// the registered slots when the dispatch task hands off a
3505 /// `SignaledCallback`.
3506 ///
3507 /// Returns `Err(())` when the registry is full (`MAX_NODES`
3508 /// entries — raise via `NROS_EXECUTOR_MAX_NODES` at build time).
3509 ///
3510 /// # Safety
3511 ///
3512 /// `state` must outlive the executor (the typical shape is a
3513 /// `*mut State` produced by
3514 /// `nros::__private_node_state_into_raw` from the
3515 /// macro-emitted `i()`; that pointer's lifetime IS the
3516 /// `Executor`'s by construction). `on_callback` must be safe to
3517 /// invoke with `(state, cb_id_ptr, cb_id_len, ctx)` matching the
3518 /// per-Node `__nros_node_<pkg>_on_callback` ABI emitted by the
3519 /// `nros::node!()` macro (Phase 216.A.5).
3520 #[allow(clippy::result_unit_err)]
3521 pub fn register_dispatch_slot(
3522 &mut self,
3523 state: *mut core::ffi::c_void,
3524 on_callback: unsafe extern "C" fn(
3525 *mut core::ffi::c_void,
3526 *const u8,
3527 usize,
3528 *mut core::ffi::c_void,
3529 ),
3530 ) -> Result<(), ()> {
3531 self.dispatch_slots
3532 .push(DispatchSlot { state, on_callback })
3533 .map_err(|_| ())
3534 }
3535
3536 /// Phase 216 follow-up — current registered dispatch-slot count.
3537 /// Diagnostic / test surface.
3538 pub fn dispatch_slot_count(&self) -> usize {
3539 self.dispatch_slots.len()
3540 }
3541
3542 /// Phase 258 (Track 2, 2a) — enroll a component into the executor-owned
3543 /// tick registry. Called by `nros`'s `install`/`register_node_borrowed`
3544 /// after it builds the `Arc<ComponentCell>`: `state` is the leaked
3545 /// `Arc<ComponentCell>` (the slot takes ownership), `tick`/`drop` are the
3546 /// `nros`-side trampolines (see [`ComponentSlot`]). The slot's `tick`
3547 /// runs at the tail of every [`spin_once`](Self::spin_once); its `drop`
3548 /// runs once on `Executor::drop`.
3549 ///
3550 /// Returns `Err(())` when the registry is full (`MAX_NODES` — raise via
3551 /// `NROS_EXECUTOR_MAX_NODES` at build time). On error the caller still
3552 /// owns `state` (the slot was not stored) and must drop it.
3553 ///
3554 /// # Safety
3555 /// `state` must be a `*mut` produced by leaking the component cell the
3556 /// `tick`/`drop` trampolines expect (an `Arc<ComponentCell>` via
3557 /// `Arc::into_raw` in the canonical `nros` caller), and must remain valid
3558 /// until the matching `drop` runs. `tick` must be safe to invoke with
3559 /// `(state, exec_ctx = *mut Executor)` each spin; `drop` must be safe to
3560 /// invoke exactly once with `state`.
3561 #[allow(clippy::result_unit_err)]
3562 pub unsafe fn enroll_component(
3563 &mut self,
3564 state: *mut core::ffi::c_void,
3565 tick: unsafe extern "C" fn(*mut core::ffi::c_void, *mut core::ffi::c_void),
3566 drop: unsafe extern "C" fn(*mut core::ffi::c_void),
3567 ) -> Result<(), ()> {
3568 self.component_slots
3569 .push(ComponentSlot { state, tick, drop })
3570 .map_err(|_| ())
3571 }
3572
3573 /// Phase 258 (Track 2, 2a) — current enrolled component-slot count.
3574 /// Diagnostic / test surface.
3575 pub fn component_slot_count(&self) -> usize {
3576 self.component_slots.len()
3577 }
3578
3579 /// issue #140 — the enrolled components' opaque `state` pointers, in enroll
3580 /// order. Each is the *leaked* `Arc<ComponentCell>` `enroll_component` was
3581 /// handed (see [`ComponentSlot::state`]); the `nros` layer re-borrows them
3582 /// to fold per-component dispatch counters into
3583 /// `observed_callback_counts` — install-seam components
3584 /// (`register_node_borrowed`) live ONLY here, not in
3585 /// `ExecutorNodeRuntime::components`, so the hosted-spin counts read zero
3586 /// without this surface.
3587 pub fn enrolled_component_states(&self) -> impl Iterator<Item = *mut core::ffi::c_void> + '_ {
3588 self.component_slots.iter().map(|slot| slot.state)
3589 }
3590
3591 /// Phase 216 final dispatch hook — stable entry point the
3592 /// framework's dispatch task (RTIC `__nros_run` /
3593 /// Embassy `__nros_run_task`) calls for each `SignaledCallback`
3594 /// envelope it dequeues from the board-side SPSC / Embassy
3595 /// channel.
3596 ///
3597 /// ## Signature shape
3598 ///
3599 /// `nros-node` sits below `nros` in the dep graph, so the typed
3600 /// `nros::CallbackId<'_>` / `nros::CallbackCtx<'_>` types
3601 /// referenced in the Phase 216 design notes cannot appear in the
3602 /// signature here. The macro emit translates the dequeued
3603 /// envelope to the layer-clean `(cb_id: &str, ctx: *mut c_void)`
3604 /// pair before calling this method; the per-Node `on_callback`
3605 /// trampoline ABI (Phase 216.A.5,
3606 /// `__nros_node_<pkg>_on_callback(state, cb_id_ptr, cb_id_len,
3607 /// ctx)`) uses the same untyped shape on the other side of the
3608 /// fence, so the round-trip stays type-consistent.
3609 ///
3610 /// ## Body — linear scan of the dispatch registry
3611 ///
3612 /// Each registered [`DispatchSlot`] holds an
3613 /// `__nros_node_<pkg>_on_callback` fn pointer + the owning Node's
3614 /// `state` blob. The macro-emitted trampoline body
3615 /// `match`es on `CallbackId` tags the Node declared and is a
3616 /// no-op for non-matching `cb_id`s — at most one Node per
3617 /// `cb_id` actually acts, the rest are cheap string-compare
3618 /// no-ops. This mirrors the strategy
3619 /// `ExecutorNodeRuntime::dispatch_callback` uses in
3620 /// `packages/api/nros/src/node_runtime.rs:470`.
3621 ///
3622 /// ## What's NOT auto-wired today
3623 ///
3624 /// The `nros::node!()` macro doesn't yet emit a
3625 /// `register_dispatch(executor)` wrapper that pushes the per-pkg
3626 /// `(state, on_callback)` into this registry. Until that wiring
3627 /// lands (Phase 216 follow-up — see commit msg), downstream
3628 /// consumers (board's `init_hardware`, or the codegen-emitted
3629 /// `run_plan`) must call
3630 /// [`Executor::register_dispatch_slot`] explicitly with the
3631 /// `__nros_node_<pkg>_on_callback` symbol + a `state` blob from
3632 /// the macro-emitted `i()`.
3633 //
3634 // `ctx` is an opaque FFI cookie forwarded verbatim to each slot's
3635 // `on_callback`; this fn never dereferences it (the registered callback
3636 // does, under the `register_dispatch_slot` safety contract), so it is sound
3637 // to call from safe code.
3638 #[allow(clippy::not_unsafe_ptr_arg_deref)]
3639 pub fn dispatch_callback(&mut self, cb_id: &str, ctx: *mut core::ffi::c_void) {
3640 let cb_id_ptr = cb_id.as_ptr();
3641 let cb_id_len = cb_id.len();
3642 // Snapshot pointer + length to avoid an outstanding borrow
3643 // across the unsafe fn calls below; each `DispatchSlot` is
3644 // `Copy`, so iterating by value sidesteps any aliasing
3645 // worry the borrow checker would flag if a slot's
3646 // `on_callback` re-entered the executor.
3647 for slot in self.dispatch_slots.iter().copied() {
3648 // SAFETY: caller of `register_dispatch_slot` guaranteed
3649 // `state` outlives the executor + `on_callback` matches
3650 // the per-Node `__nros_node_<pkg>_on_callback` ABI;
3651 // `cb_id_ptr`/`cb_id_len` describe the live `&str` the
3652 // caller passed in.
3653 unsafe {
3654 (slot.on_callback)(slot.state, cb_id_ptr, cb_id_len, ctx);
3655 }
3656 }
3657 }
3658
3659 /// Get a reference to the underlying session.
3660 pub fn session(&self) -> &session::ConcreteSession {
3661 &self.session
3662 }
3663
3664 /// Get a mutable reference to the underlying session.
3665 pub fn session_mut(&mut self) -> &mut session::ConcreteSession {
3666 &mut self.session
3667 }
3668
3669 /// Phase 124.F.3 — session-level connectivity probe. Wire-level
3670 /// round-trip "is the peer / agent / router still reachable?"
3671 /// — cheaper than the service-availability probe (no discovery
3672 /// state required).
3673 ///
3674 /// Returns `Ok(())` on reply within `timeout_ms`,
3675 /// `Err(NodeError::Transport(Timeout))` on no reply,
3676 /// `Err(NodeError::Transport(Unsupported))` when the active
3677 /// backend can't probe.
3678 ///
3679 /// Mirrors micro-ROS's `rmw_uros_ping_agent`. Useful for
3680 /// reconnect-on-link-loss patterns: bare-metal code can call
3681 /// `ping(100)` periodically and tear down / re-open the session
3682 /// on timeout.
3683 pub fn ping(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
3684 use nros_rmw::Session;
3685 self.session
3686 .ping_session(timeout_ms)
3687 .map_err(NodeError::Transport)
3688 }
3689
3690 /// phase-381 W4 — every node on the graph, with its namespace.
3691 ///
3692 /// `visit(name, namespace, enclave)` is called once per node and returns
3693 /// `false` to stop early. A VISITOR rather than a returned collection
3694 /// because there is no allocator at this layer and the graph has no bound
3695 /// the caller can know; peak extra memory is one entry. Every string is
3696 /// BORROWED for the duration of the call.
3697 ///
3698 /// `enclave` is `None` where the backend does not track one, which is what
3699 /// lets one call answer both `rmw_get_node_names` and
3700 /// `rmw_get_node_names_with_enclaves`.
3701 ///
3702 /// **This reports what has already been DISCOVERED, and never blocks.** The
3703 /// first call after startup legitimately sees a partial graph — the backend
3704 /// keeps a standing query fed by `spin`, so the view fills in over
3705 /// successive calls. Code that waits for a peer should poll, not call once
3706 /// and conclude. An empty result is "nobody seen yet", never "nobody
3707 /// exists".
3708 ///
3709 /// `Err(Transport(Unsupported))` from a backend with no graph — distinct
3710 /// from an empty graph, deliberately.
3711 pub fn get_node_names(
3712 &mut self,
3713 visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
3714 ) -> Result<(), NodeError> {
3715 use nros_rmw::Session;
3716 self.session
3717 .get_node_names(visit)
3718 .map_err(NodeError::Transport)
3719 }
3720
3721 /// phase-381 W4 — every topic on the graph, with the types on it.
3722 ///
3723 /// `visit(topic_name, types)` is called once per distinct TOPIC — a topic
3724 /// carrying two types is one call with two entries, not two calls. `types`
3725 /// may legitimately be empty on a partially discovered graph: reporting the
3726 /// name without a type beats dropping it.
3727 ///
3728 /// Same discovery caveat as [`Self::get_node_names`].
3729 pub fn get_topic_names_and_types(
3730 &mut self,
3731 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
3732 ) -> Result<(), NodeError> {
3733 use nros_rmw::Session;
3734 self.session
3735 .get_topic_names_and_types(visit)
3736 .map_err(NodeError::Transport)
3737 }
3738
3739 /// phase-381 W4 — every service on the graph, with its types.
3740 /// As [`Self::get_topic_names_and_types`], over servers and clients.
3741 pub fn get_service_names_and_types(
3742 &mut self,
3743 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
3744 ) -> Result<(), NodeError> {
3745 use nros_rmw::Session;
3746 self.session
3747 .get_service_names_and_types(visit)
3748 .map_err(NodeError::Transport)
3749 }
3750
3751 /// phase-381 W4 — how many publishers are visible on `topic_name`.
3752 ///
3753 /// `topic_name` is a ROS name (`"/chatter"`). A count reflects what has
3754 /// been DISCOVERED, so it can be low right after startup and is never a
3755 /// proof of absence — see [`Self::get_node_names`].
3756 pub fn count_publishers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
3757 use nros_rmw::Session;
3758 self.session
3759 .count_publishers(topic_name)
3760 .map_err(NodeError::Transport)
3761 }
3762
3763 /// phase-381 W4 — how many subscribers are visible on `topic_name`.
3764 /// See [`Self::count_publishers`] for the caveats.
3765 pub fn count_subscribers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
3766 use nros_rmw::Session;
3767 self.session
3768 .count_subscribers(topic_name)
3769 .map_err(NodeError::Transport)
3770 }
3771
3772 /// phase-381 W4 — what one named node PUBLISHES, with the types.
3773 ///
3774 /// `visit(topic_name, types)` per distinct topic. A node the graph has not
3775 /// discovered yields no visits, which is not an error — see
3776 /// [`Self::get_node_names`] for why an empty answer means "not seen yet".
3777 pub fn get_publisher_names_and_types_by_node(
3778 &mut self,
3779 node_name: &str,
3780 node_namespace: &str,
3781 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
3782 ) -> Result<(), NodeError> {
3783 use nros_rmw::{GraphEntityKind, Session};
3784 self.session
3785 .get_names_and_types_by_node(
3786 GraphEntityKind::Publisher,
3787 node_name,
3788 node_namespace,
3789 visit,
3790 )
3791 .map_err(NodeError::Transport)
3792 }
3793
3794 /// phase-381 W4 — what one named node SUBSCRIBES to, with the types.
3795 ///
3796 /// **`subscription`, not `subscriber`** — this is rclrs's spelling
3797 /// (`get_subscription_names_and_types_by_node`), and the Rust surface takes
3798 /// its vocabulary from rclrs so a user porting Rust ROS 2 code types what
3799 /// they already know. The C surface says `subscriber` because rcl does, and
3800 /// the vtable slot says `subscriber` because upstream rmw does. Three
3801 /// layers, three upstreams, one word each — not drift. Issue 0788 owns the
3802 /// wider verb sweep.
3803 pub fn get_subscription_names_and_types_by_node(
3804 &mut self,
3805 node_name: &str,
3806 node_namespace: &str,
3807 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
3808 ) -> Result<(), NodeError> {
3809 use nros_rmw::{GraphEntityKind, Session};
3810 self.session
3811 .get_names_and_types_by_node(
3812 GraphEntityKind::Subscriber,
3813 node_name,
3814 node_namespace,
3815 visit,
3816 )
3817 .map_err(NodeError::Transport)
3818 }
3819
3820 /// phase-381 W4 — what services one named node SERVES, with the types.
3821 pub fn get_service_names_and_types_by_node(
3822 &mut self,
3823 node_name: &str,
3824 node_namespace: &str,
3825 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
3826 ) -> Result<(), NodeError> {
3827 use nros_rmw::{GraphEntityKind, Session};
3828 self.session
3829 .get_names_and_types_by_node(GraphEntityKind::Service, node_name, node_namespace, visit)
3830 .map_err(NodeError::Transport)
3831 }
3832
3833 /// phase-381 W4 — what services one named node CALLS, with the types.
3834 pub fn get_client_names_and_types_by_node(
3835 &mut self,
3836 node_name: &str,
3837 node_namespace: &str,
3838 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
3839 ) -> Result<(), NodeError> {
3840 use nros_rmw::{GraphEntityKind, Session};
3841 self.session
3842 .get_names_and_types_by_node(GraphEntityKind::Client, node_name, node_namespace, visit)
3843 .map_err(NodeError::Transport)
3844 }
3845
3846 /// phase-381 W4 — the publishers on `topic_name`, one visit each.
3847 ///
3848 /// Each `GraphEndpointInfo` BORROWS its strings for the duration of the
3849 /// visit; copy anything kept.
3850 ///
3851 /// It carries no QoS. The granted profile is what would answer "why is
3852 /// nothing arriving", and no backend can read one back yet — reporting the
3853 /// remote's DECLARED profile instead would be a confident wrong answer, so
3854 /// the field is absent rather than misleading.
3855 pub fn get_publishers_info_by_topic(
3856 &mut self,
3857 topic_name: &str,
3858 visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
3859 ) -> Result<(), NodeError> {
3860 use nros_rmw::Session;
3861 self.session
3862 .get_endpoint_info_by_topic(true, topic_name, visit)
3863 .map_err(NodeError::Transport)
3864 }
3865
3866 /// phase-381 W4 — the subscriptions on `topic_name`, one visit each.
3867 /// See [`Self::get_publishers_info_by_topic`].
3868 pub fn get_subscriptions_info_by_topic(
3869 &mut self,
3870 topic_name: &str,
3871 visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
3872 ) -> Result<(), NodeError> {
3873 use nros_rmw::Session;
3874 self.session
3875 .get_endpoint_info_by_topic(false, topic_name, visit)
3876 .map_err(NodeError::Transport)
3877 }
3878
3879 /// Get a mutable reference to an action client core in the arena by entry index.
3880 ///
3881 /// # Safety
3882 /// The caller must ensure that `entry_index` refers to an `ActionClientRawArenaEntry`.
3883 pub unsafe fn action_client_core_mut(
3884 &mut self,
3885 entry_index: usize,
3886 ) -> Option<&mut super::action_core::ActionClientCore> {
3887 let meta = self.entries.get(entry_index)?.as_ref()?;
3888 if !matches!(meta.kind, EntryKind::ActionClient) {
3889 return None;
3890 }
3891 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
3892 unsafe {
3893 let entry_ptr = arena_ptr.add(meta.offset)
3894 as *mut super::arena::ActionClientRawArenaEntry<
3895 { crate::config::DEFAULT_RX_BUF_SIZE },
3896 { crate::config::DEFAULT_RX_BUF_SIZE },
3897 { crate::config::DEFAULT_RX_BUF_SIZE },
3898 >;
3899 Some(&mut (*entry_ptr).core)
3900 }
3901 }
3902
3903 /// Get a mutable reference to a service-client arena entry (Phase 82).
3904 ///
3905 /// Returns `None` if `entry_index` doesn't refer to a service client
3906 /// entry. The default reply buffer size is assumed because the C API
3907 /// always uses the default — the entry was registered via
3908 /// `register_service_client_raw_sized::<DEFAULT_RX_BUF_SIZE>`.
3909 ///
3910 /// # Safety
3911 /// `entry_index` must refer to a `ServiceClientRawArenaEntry`.
3912 pub unsafe fn service_client_entry_mut(
3913 &mut self,
3914 entry_index: usize,
3915 ) -> Option<&mut super::arena::ServiceClientRawArenaEntry<{ crate::config::DEFAULT_RX_BUF_SIZE }>>
3916 {
3917 let meta = self.entries.get(entry_index)?.as_ref()?;
3918 if !matches!(meta.kind, EntryKind::ServiceClient) {
3919 return None;
3920 }
3921 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
3922 unsafe {
3923 let entry_ptr = arena_ptr.add(meta.offset)
3924 as *mut super::arena::ServiceClientRawArenaEntry<
3925 { crate::config::DEFAULT_RX_BUF_SIZE },
3926 >;
3927 Some(&mut *entry_ptr)
3928 }
3929 }
3930
3931 /// Set the executor-level trigger condition.
3932 ///
3933 /// Controls which handles must be ready before `spin_once` dispatches
3934 /// callbacks. Defaults to [`Trigger::AnyReady`](crate::Trigger).
3935 pub fn set_trigger(&mut self, trigger: Trigger) {
3936 self.trigger = trigger;
3937 }
3938
3939 /// Set the executor data communication semantics.
3940 ///
3941 /// Choose between `Direct` (process in place) and `LET`
3942 /// (snapshot-then-process) semantics. See [`ExecutorSemantics`].
3943 pub fn set_semantics(&mut self, semantics: ExecutorSemantics) {
3944 self.semantics = semantics;
3945 }
3946
3947 /// Set the invocation mode for a specific handle.
3948 ///
3949 /// Controls whether the callback fires on every spin
3950 /// ([`Always`](InvocationMode::Always)) or only when new data
3951 /// arrives ([`OnNewData`](InvocationMode::OnNewData), the default).
3952 pub fn set_invocation(&mut self, id: HandleId, mode: InvocationMode) {
3953 if let Some(Some(meta)) = self.entries.get_mut(id.0) {
3954 meta.invocation = mode;
3955 }
3956 }
3957
3958 // ========================================================================
3959 // Arena-based callback registration
3960 // ========================================================================
3961
3962 /// Arena bytes claimed by registered entities so far.
3963 ///
3964 /// EXACT, not a worst case: the arena is a bump allocator and every
3965 /// `arena_alloc` charges `size_of::<T>()`, so nothing is reserved per slot.
3966 /// `ARENA_SIZE` itself is derived the other way — every slot budgeted at
3967 /// the ActionClient worst case — which is issue 0900, and this accessor is
3968 /// how an image can find out what it actually needs.
3969 pub fn arena_used(&self) -> usize {
3970 self.arena_used
3971 }
3972
3973 /// Total arena bytes this executor was given.
3974 ///
3975 /// The arena is a borrowed slice; whether its storage is stack or `.bss` is
3976 /// the caller's choice, not a property of this type (see
3977 /// `report_arena_headroom`).
3978 pub fn arena_capacity(&self) -> usize {
3979 self.arena.len()
3980 }
3981
3982 /// Bump-allocate space for `T` in the arena. Returns the byte offset.
3983 pub(crate) fn arena_alloc<T>(&mut self) -> Result<usize, NodeError> {
3984 let align = core::mem::align_of::<T>();
3985 let size = core::mem::size_of::<T>();
3986 let aligned_offset = (self.arena_used + align - 1) & !(align - 1);
3987 let new_used = aligned_offset + size;
3988 if new_used > self.arena.len() {
3989 // issue 0900 — `BufferTooSmall` is returned by a dozen other paths,
3990 // so on a target where a return code is all you get, arena
3991 // exhaustion is indistinguishable from a message that did not fit.
3992 super::arena::report_arena_exhausted(
3993 new_used - self.arena.len(),
3994 self.arena_used,
3995 self.arena.len(),
3996 );
3997 crate::boot_report::note_alloc_failed(size, new_used - self.arena.len());
3998 return Err(NodeError::BufferTooSmall);
3999 }
4000 self.arena_used = new_used;
4001 crate::boot_report::note_alloc(size, new_used);
4002 Ok(aligned_offset)
4003 }
4004
4005 /// Bump-allocate space for `T` plus `trailing_bytes` extra bytes.
4006 ///
4007 /// Returns `(entry_offset, trailing_offset)`. The trailing region starts
4008 /// immediately after `T` (aligned to 8 bytes).
4009 pub(crate) fn arena_alloc_with_trailing<T>(
4010 &mut self,
4011 trailing_bytes: usize,
4012 ) -> Result<(usize, usize), NodeError> {
4013 let align = core::mem::align_of::<T>();
4014 let entry_size = core::mem::size_of::<T>();
4015 let entry_offset = self.arena_used.next_multiple_of(align);
4016 // Trailing region starts on an 8-byte (u64) boundary after the entry.
4017 let trailing_offset =
4018 (entry_offset + entry_size).next_multiple_of(core::mem::align_of::<u64>());
4019 let new_used = trailing_offset + trailing_bytes;
4020 if new_used > self.arena.len() {
4021 // This path reported NOTHING until phase-412's self-report went in,
4022 // while its sibling `arena_alloc` has named the knob since issue
4023 // 0900. Half of arena exhaustion was therefore silent -- and it is
4024 // the half carrying buffered subscriptions and action entries,
4025 // which is what an island image actually allocates.
4026 super::arena::report_arena_exhausted(
4027 new_used - self.arena.len(),
4028 self.arena_used,
4029 self.arena.len(),
4030 );
4031 crate::boot_report::note_alloc_failed(
4032 new_used - entry_offset,
4033 new_used - self.arena.len(),
4034 );
4035 return Err(NodeError::BufferTooSmall);
4036 }
4037 self.arena_used = new_used;
4038 crate::boot_report::note_alloc(new_used - entry_offset, new_used);
4039 Ok((entry_offset, trailing_offset))
4040 }
4041
4042 /// Find the next free entry slot index.
4043 pub(crate) fn next_entry_slot(&self) -> Result<usize, NodeError> {
4044 self.entries
4045 .iter()
4046 .position(|e| e.is_none())
4047 // Issue 0095 — the callback-entry table (`NROS_EXECUTOR_MAX_CBS`,
4048 // default 4) is full. Distinct from `BufferTooSmall` so the register
4049 // seam can tell the user to raise the knob.
4050 .ok_or(NodeError::ExecutorFull)
4051 }
4052
4053 /// Install a finished [`CallbackMeta`] into its slot.
4054 ///
4055 /// phase-8 (`docs/design/callback_tracing.rst`) — the ONE choke point
4056 /// every registration site funnels through, so the
4057 /// `nros_callback_register(handle, kind, name)` event is emitted once,
4058 /// here, rather than at the 25 sites that build a `CallbackMeta`. The
4059 /// design's registration half is deliberately EXHAUSTIVE across every
4060 /// `EntryKind` even though the leaf hooks are staged: a callback that was
4061 /// registered but never observed then prints as "registered, not
4062 /// instrumented" instead of being silently absent, so an incomplete hook
4063 /// set announces itself in the output rather than looking like a
4064 /// measurement.
4065 ///
4066 /// It is the assignment — not [`next_entry_slot`](Self::next_entry_slot)
4067 /// — because a slot is claimed BEFORE the fallible work (session lookup,
4068 /// arena allocation, handle creation) that can still return `Err`.
4069 /// Emitting at slot-claim time would announce callbacks that do not
4070 /// exist.
4071 pub(crate) fn emplace_entry(&mut self, slot: usize, meta: CallbackMeta, name: TraceName<'_>) {
4072 trace_register(slot, meta.kind, name);
4073 self.entries[slot] = Some(meta);
4074 }
4075
4076 /// Typed buffered subscription core (the `node_mut(id).subscription(t)
4077 /// .typed::<M>()` builder lowers here). Routes the typed subscription
4078 /// through the [`NodeId`]'s session + identity (rclcpp `add_node` pattern).
4079 ///
4080 /// Phase 403 W2 -- `rx_bytes` overrides how many bytes each buffer slot
4081 /// claims from the arena. `None` means `RX_BUF`, which is what every caller
4082 /// did before the knob existed and is what every caller that does not opt in
4083 /// still does: the default derivation is byte-for-byte unchanged.
4084 ///
4085 /// It is a RUNTIME `usize` and not a second const generic because on THIS
4086 /// path `RX_BUF` was never an array length -- it reaches
4087 /// `buffered_region_size`, `TripleBuffer::init` and `SpscRing::init`, all of
4088 /// which take a plain `usize`. That is the only reason W2's per-type sizing
4089 /// is expressible here at all: `Sub<{ M::BOUND }>` is
4090 /// `error: generic parameters may not be used in const operations` on stable
4091 /// (rustc 1.97.1), so the sibling `register_subscription_with_info_sized_inner`
4092 /// / `..._with_safety_sized_inner`, whose entries really do hold
4093 /// `[u8; RX_BUF]`, still need `nros::rx_buffer_for!` at the call site.
4094 pub(crate) fn register_subscription_buffered_on<M, F, const RX_BUF: usize>(
4095 &mut self,
4096 node_id: super::node_record::NodeId,
4097 topic_name: &str,
4098 qos: QoSProfile,
4099 callback: F,
4100 group: Option<&str>,
4101 rx_bytes: Option<usize>,
4102 ) -> Result<HandleId, NodeError>
4103 where
4104 M: crate::rmw_type_registry::MessageForRmw + 'static,
4105 F: FnMut(&M) + 'static,
4106 {
4107 type Entry<M, F> = SubBufferedEntry<M, F>;
4108
4109 // RFC-0088 / phase-421 W1 — the message's declared format must be the
4110 // one the linked backend speaks. Universal: the const lives on
4111 // `RosMessage`, which `MessageForRmw` requires under every backend.
4112 crate::format_check::assert_message_format::<M>();
4113 // Phase 212.K.7.6.b — see `create_publisher_on`.
4114 crate::rmw_type_registry::register_type::<M>()?;
4115
4116 let slot = self.next_entry_slot()?;
4117 let (node_name, ns, session_idx) = {
4118 let r = self
4119 .nodes
4120 .get(node_id.index())
4121 .ok_or(NodeError::InvalidSchedContextBinding)?;
4122 (r.name.clone(), r.namespace.clone(), r.session_idx)
4123 };
4124 let mut topic = TopicInfo::new(
4125 topic_name,
4126 <M as RosMessage>::TYPE_NAME,
4127 <M as RosMessage>::TYPE_HASH,
4128 )
4129 .with_domain(self.domain_id)
4130 .with_namespace(&ns)
4131 // Phase 231 (RFC-0038) — hand the backend a receive-buffer size so it
4132 // can size-class its receive storage (zenoh-pico: small vs large).
4133 //
4134 // Phase 392 W3a — the TYPE's bound, not `RX_BUF`. The arena slot size
4135 // says nothing about the message: a 64-byte type and a 4 KiB type both
4136 // hinted the same number, so the class was chosen from a value unrelated
4137 // to what arrives. Falls back to `RX_BUF` for an unbounded type, where
4138 // no bound exists to state (phase 380).
4139 .with_rx_buffer_hint(crate::rmw_type_registry::subscription_rx_hint::<M>(RX_BUF));
4140 if !node_name.is_empty() {
4141 topic = topic.with_node_name(&node_name);
4142 }
4143 // W3b.5 — contracted-endpoint age hook (None = free).
4144 let age_mon = self.age_lookup::<M>(topic_name);
4145 let handle = {
4146 let session = self
4147 .session_at_mut(session_idx)
4148 .ok_or(NodeError::BackendMismatch)?;
4149 session
4150 .create_subscription(&topic, qos)
4151 .map_err(NodeError::Transport)?
4152 };
4153
4154 // Phase 231 Wave 0.2 (RFC-0038) — in-place dispatch when the backend
4155 // advertises it: deserialize straight from the borrowed receive slot,
4156 // no arena buffer (copy #1 removed). Else the buffered path below.
4157 {
4158 use nros_rmw::Subscription as _;
4159 if handle.supports_process_in_place() {
4160 let entry_offset = self.arena_alloc::<SubInplaceEntry<M, F>>()?;
4161 unsafe {
4162 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4163 let entry_ptr = arena_ptr.add(entry_offset) as *mut SubInplaceEntry<M, F>;
4164 core::ptr::write(
4165 entry_ptr,
4166 SubInplaceEntry {
4167 handle,
4168 callback,
4169 age_mon,
4170 _phantom: PhantomData,
4171 },
4172 );
4173 }
4174 let meta = CallbackMeta {
4175 offset: entry_offset,
4176 kind: EntryKind::Subscription,
4177 try_process: sub_inplace_try_process::<M, F>,
4178 has_data: sub_inplace_has_data::<M, F>,
4179 pre_sample: no_pre_sample,
4180 invocation: InvocationMode::OnNewData,
4181 drop_fn: drop_entry::<SubInplaceEntry<M, F>>,
4182 };
4183 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
4184 self.apply_node_default_sched(slot, Some(node_id), group);
4185 return Ok(HandleId(slot));
4186 }
4187 }
4188
4189 // Phase 403 W2 -- one number for the whole allocation. The region size,
4190 // the strategy's slot size and the pointer arithmetic must agree, so
4191 // they read the same local rather than each spelling `RX_BUF`.
4192 let slot_size = rx_bytes.unwrap_or(RX_BUF);
4193
4194 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_size);
4195
4196 let (entry_offset, trailing_offset) =
4197 self.arena_alloc_with_trailing::<Entry<M, F>>(trailing_bytes)?;
4198
4199 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
4200
4201 let buffer = if qos.depth <= 1 {
4202 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_size) })
4203 } else {
4204 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_size, qos.depth as usize) })
4205 };
4206
4207 unsafe {
4208 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4209 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<M, F>;
4210 core::ptr::write(
4211 entry_ptr,
4212 Entry {
4213 handle,
4214 buffer,
4215 callback,
4216 age_mon,
4217 _phantom: PhantomData,
4218 },
4219 );
4220 }
4221
4222 let meta = CallbackMeta {
4223 offset: entry_offset,
4224 kind: EntryKind::Subscription,
4225 try_process: sub_buffered_try_process::<M, F>,
4226 has_data: sub_buffered_has_data::<M, F>,
4227 pre_sample: no_pre_sample,
4228 invocation: InvocationMode::OnNewData,
4229 drop_fn: drop_entry::<Entry<M, F>>,
4230 };
4231 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
4232 // Phase 104.C.4 — apply Node's default SchedContext.
4233 self.apply_node_default_sched(slot, Some(node_id), group);
4234 Ok(HandleId(slot))
4235 }
4236
4237 /// Generic (type-erased) buffered subscription core (the
4238 /// `node_mut(id).subscription(t).generic(ty, hash)` builder lowers here).
4239 /// Routes the subscriber creation through the [`NodeId`]'s
4240 /// session + identity (rclcpp `add_node` pattern).
4241 ///
4242 /// Use this in bridge code where two Nodes bind to different RMW
4243 /// backends:
4244 ///
4245 /// ```ignore
4246 /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
4247 /// let pub_out = exec.with_node(node_out, |n| {
4248 /// n.create_publisher_raw("/fwd", TYPE, HASH)
4249 /// })??;
4250 /// exec.register_subscription_buffered_raw_on::<_, 1024>(
4251 /// node_in, "/src", TYPE, HASH, qos(),
4252 /// move |bytes: &[u8]| { let _ = pub_out.publish_raw(bytes); },
4253 /// )?;
4254 /// ```
4255 pub(crate) fn register_subscription_buffered_raw_on<F, const RX_BUF: usize>(
4256 &mut self,
4257 node_id: super::node_record::NodeId,
4258 topic_name: &str,
4259 type_name: &str,
4260 type_hash: &str,
4261 qos: QoSProfile,
4262 callback: F,
4263 ) -> Result<HandleId, NodeError>
4264 where
4265 F: FnMut(&[u8]) + 'static,
4266 {
4267 // Pull the Node's identity + session slot out first so the
4268 // mutable session borrow doesn't conflict with the arena
4269 // alloc inside `add_arena_subscription_callback`.
4270 let (node_name, ns, session_idx, overrides) = {
4271 let r = self
4272 .nodes
4273 .get(node_id.index())
4274 .ok_or(NodeError::InvalidSchedContextBinding)?;
4275 (
4276 r.name.clone(),
4277 r.namespace.clone(),
4278 r.session_idx,
4279 r.qos_overrides,
4280 )
4281 };
4282 // Issue #52 — fold the node's baked overrides for this topic before the
4283 // backend-compat check runs inside `create_subscription`.
4284 let qos = super::node_record::apply_qos_override_codes(
4285 qos,
4286 topic_name,
4287 nros_rmw::QoSOverrideRole::Subscription,
4288 overrides,
4289 );
4290 let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
4291 .with_domain(self.domain_id)
4292 .with_namespace(&ns);
4293 if !node_name.is_empty() {
4294 topic = topic.with_node_name(&node_name);
4295 }
4296 let handle = {
4297 let session = self
4298 .session_at_mut(session_idx)
4299 .ok_or(NodeError::BackendMismatch)?;
4300 session
4301 .create_subscription(&topic, qos)
4302 .map_err(NodeError::Transport)?
4303 };
4304 let handle_id = self.add_arena_subscription_callback::<F, RX_BUF>(handle, qos, callback)?;
4305 // Phase 104.C.4 — apply Node's default SchedContext.
4306 self.apply_node_default_sched(handle_id.0, Some(node_id), None);
4307 Ok(handle_id)
4308 }
4309
4310 /// Register a borrowed (zero-copy) buffered subscription (Phase 229.6,
4311 /// issue 0007 / RFC-0033 `borrowed` mode).
4312 ///
4313 /// `B` is the code-generated borrowed-message marker (e.g. `ImageViewable`)
4314 /// implementing [`ViewableMessage`](nros_core::ViewableMessage); the
4315 /// callback receives `&B::View<'a>` — a lifetime-carrying message whose
4316 /// unbounded sequence/string fields borrow directly from the receive buffer
4317 /// (no `heapless::Vec` copy). The view is valid only for the callback's
4318 /// duration.
4319 ///
4320 /// **Triple-buffer only.** A borrowed view must reference exactly one
4321 /// well-defined buffer slot for the callback's duration; an SPSC ring
4322 /// (`qos.depth > 1`) keeps several samples in flight with no single such
4323 /// slot. `qos.depth > 1` is therefore rejected with
4324 /// [`TransportError::Unsupported`].
4325 pub(crate) fn register_subscription_buffered_borrowed_on<B, F, const RX_BUF: usize>(
4326 &mut self,
4327 node_id: super::node_record::NodeId,
4328 topic_name: &str,
4329 qos: QoSProfile,
4330 callback: F,
4331 ) -> Result<HandleId, NodeError>
4332 where
4333 B: nros_core::ViewableMessage + 'static,
4334 F: for<'a> FnMut(&B::View<'a>) + 'static,
4335 {
4336 type Entry<B, F> = SubBufferedViewEntry<B, F>;
4337
4338 // Borrowed views require a single well-defined slot (triple buffer).
4339 if qos.depth > 1 {
4340 return Err(NodeError::Transport(TransportError::Unsupported));
4341 }
4342
4343 let slot = self.next_entry_slot()?;
4344 let (node_name, ns, session_idx) = {
4345 let r = self
4346 .nodes
4347 .get(node_id.index())
4348 .ok_or(NodeError::InvalidSchedContextBinding)?;
4349 (r.name.clone(), r.namespace.clone(), r.session_idx)
4350 };
4351 let mut topic = TopicInfo::new(
4352 topic_name,
4353 <B as ViewableMessage>::TYPE_NAME,
4354 <B as ViewableMessage>::TYPE_HASH,
4355 )
4356 .with_domain(self.domain_id)
4357 .with_namespace(&ns);
4358 if !node_name.is_empty() {
4359 topic = topic.with_node_name(&node_name);
4360 }
4361 let handle = {
4362 let session = self
4363 .session_at_mut(session_idx)
4364 .ok_or(NodeError::BackendMismatch)?;
4365 session
4366 .create_subscription(&topic, qos)
4367 .map_err(NodeError::Transport)?
4368 };
4369
4370 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, RX_BUF);
4371 let (entry_offset, trailing_offset) =
4372 self.arena_alloc_with_trailing::<Entry<B, F>>(trailing_bytes)?;
4373 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
4374
4375 // depth <= 1 guaranteed above → always triple buffer.
4376 let buffer = BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, RX_BUF) });
4377
4378 unsafe {
4379 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4380 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<B, F>;
4381 core::ptr::write(
4382 entry_ptr,
4383 Entry {
4384 handle,
4385 buffer,
4386 callback,
4387 _phantom: PhantomData,
4388 },
4389 );
4390 }
4391
4392 let meta = CallbackMeta {
4393 offset: entry_offset,
4394 kind: EntryKind::Subscription,
4395 try_process: sub_buffered_view_try_process::<B, F>,
4396 has_data: sub_buffered_view_has_data::<B, F>,
4397 pre_sample: no_pre_sample,
4398 invocation: InvocationMode::OnNewData,
4399 drop_fn: drop_entry::<Entry<B, F>>,
4400 };
4401 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
4402 self.apply_node_default_sched(slot, Some(node_id), None);
4403 Ok(HandleId(slot))
4404 }
4405
4406 /// Register a raw (type-erased) buffered subscription whose callback
4407 /// also receives a [`RawMessageInfo`](nros_core::RawMessageInfo)
4408 /// carrying the sample's wire **attachment** (Phase 189.M1).
4409 ///
4410 /// Backs the `node.subscription(t).generic(..).message_info().build(cb)`
4411 /// builder — the cross-RMW bridge reads the `bridge_origin` tag from
4412 /// `info.attachment()` for echo suppression. One sample per
4413 /// `spin_once`; the attachment is staged in a flat per-entry buffer
4414 /// (cap [`RAW_INFO_ATT_CAP`](super::arena::RAW_INFO_ATT_CAP)).
4415 pub fn register_subscription_buffered_raw_info_on<F, const RX_BUF: usize>(
4416 &mut self,
4417 node_id: super::node_record::NodeId,
4418 topic_name: &str,
4419 type_name: &str,
4420 type_hash: &str,
4421 qos: QoSProfile,
4422 callback: F,
4423 ) -> Result<HandleId, NodeError>
4424 where
4425 F: FnMut(&[u8], &nros_core::RawMessageInfo) + 'static,
4426 {
4427 type Entry<F, const N: usize> = SubBufferedRawInfoEntry<F, N>;
4428
4429 let slot = self.next_entry_slot()?;
4430 let (node_name, ns, session_idx) = {
4431 let r = self
4432 .nodes
4433 .get(node_id.index())
4434 .ok_or(NodeError::InvalidSchedContextBinding)?;
4435 (r.name.clone(), r.namespace.clone(), r.session_idx)
4436 };
4437 let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
4438 .with_domain(self.domain_id)
4439 .with_namespace(&ns);
4440 if !node_name.is_empty() {
4441 topic = topic.with_node_name(&node_name);
4442 }
4443 let handle = {
4444 let session = self
4445 .session_at_mut(session_idx)
4446 .ok_or(NodeError::BackendMismatch)?;
4447 session
4448 .create_subscription(&topic, qos)
4449 .map_err(NodeError::Transport)?
4450 };
4451
4452 let offset = self.arena_alloc::<Entry<F, RX_BUF>>()?;
4453 unsafe {
4454 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4455 let entry_ptr = arena_ptr.add(offset) as *mut Entry<F, RX_BUF>;
4456 core::ptr::write(
4457 entry_ptr,
4458 Entry {
4459 handle,
4460 buffer: [0u8; RX_BUF],
4461 att: [0u8; super::arena::RAW_INFO_ATT_CAP],
4462 callback,
4463 },
4464 );
4465 }
4466
4467 let meta = CallbackMeta {
4468 offset,
4469 kind: EntryKind::Subscription,
4470 try_process: sub_buffered_raw_info_try_process::<F, RX_BUF>,
4471 has_data: sub_buffered_raw_info_has_data::<F, RX_BUF>,
4472 pre_sample: no_pre_sample,
4473 invocation: InvocationMode::OnNewData,
4474 drop_fn: drop_entry::<Entry<F, RX_BUF>>,
4475 };
4476 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
4477 self.apply_node_default_sched(slot, Some(node_id), None);
4478 Ok(HandleId(slot))
4479 }
4480
4481 /// Phase 250 (Wave 2) — register a generic (type-erased) raw subscription
4482 /// that surfaces E2E [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC +
4483 /// sequence gap/dup) alongside the raw CDR bytes
4484 /// (`FnMut(&[u8], &IntegrityStatus)`). The type-erased analog of
4485 /// [`register_subscription_with_safety_sized_inner`]: the validator lives in
4486 /// the `RmwSubscriber` (`take_validated`), so the subscriber is created
4487 /// plainly and no `register_type::<M>()` is needed (the declarative `Node`
4488 /// path is generic). Used by the declarative runtime's `.safety()` opt-in.
4489 #[cfg(feature = "safety-e2e")]
4490 pub fn register_subscription_buffered_raw_safety_on<F, const RX_BUF: usize>(
4491 &mut self,
4492 node_id: super::node_record::NodeId,
4493 topic_name: &str,
4494 type_name: &str,
4495 type_hash: &str,
4496 qos: QoSProfile,
4497 callback: F,
4498 ) -> Result<HandleId, NodeError>
4499 where
4500 F: FnMut(&[u8], &nros_rmw::IntegrityStatus) + 'static,
4501 {
4502 use super::arena::{
4503 SubBufferedRawSafetyEntry, sub_buffered_raw_safety_has_data,
4504 sub_buffered_raw_safety_try_process,
4505 };
4506 type Entry<F, const N: usize> = SubBufferedRawSafetyEntry<F, N>;
4507
4508 let slot = self.next_entry_slot()?;
4509 let (node_name, ns, session_idx) = {
4510 let r = self
4511 .nodes
4512 .get(node_id.index())
4513 .ok_or(NodeError::InvalidSchedContextBinding)?;
4514 (r.name.clone(), r.namespace.clone(), r.session_idx)
4515 };
4516 let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
4517 .with_domain(self.domain_id)
4518 .with_namespace(&ns);
4519 if !node_name.is_empty() {
4520 topic = topic.with_node_name(&node_name);
4521 }
4522 let handle = {
4523 let session = self
4524 .session_at_mut(session_idx)
4525 .ok_or(NodeError::BackendMismatch)?;
4526 session
4527 .create_subscription(&topic, qos)
4528 .map_err(NodeError::Transport)?
4529 };
4530
4531 let offset = self.arena_alloc::<Entry<F, RX_BUF>>()?;
4532 unsafe {
4533 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4534 let entry_ptr = arena_ptr.add(offset) as *mut Entry<F, RX_BUF>;
4535 core::ptr::write(
4536 entry_ptr,
4537 Entry {
4538 handle,
4539 buffer: [0u8; RX_BUF],
4540 callback,
4541 },
4542 );
4543 }
4544
4545 let meta = CallbackMeta {
4546 offset,
4547 kind: EntryKind::Subscription,
4548 try_process: sub_buffered_raw_safety_try_process::<F, RX_BUF>,
4549 has_data: sub_buffered_raw_safety_has_data::<F, RX_BUF>,
4550 pre_sample: no_pre_sample,
4551 invocation: InvocationMode::OnNewData,
4552 drop_fn: drop_entry::<Entry<F, RX_BUF>>,
4553 };
4554 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
4555 self.apply_node_default_sched(slot, Some(node_id), None);
4556 Ok(HandleId(slot))
4557 }
4558
4559 /// Register a raw byte-shaped callback against a pre-built
4560 /// `RmwSubscriber` handle.
4561 ///
4562 /// Backend-agnostic primitive — the caller is responsible for
4563 /// obtaining the handle by whatever route the active backend
4564 /// supports:
4565 ///
4566 /// - **Generic ROS-typed flow**: call `Session::create_subscription`
4567 /// on `self.session_mut()` with a [`TopicInfo`]. The
4568 /// `node_mut(id).subscription(t).generic(ty, hash)` builder is the
4569 /// convenience wrapper for this path.
4570 /// - **Backend-specific flow** (e.g. uORB needs `&'static orb_metadata`):
4571 /// reach into the concrete session via [`Self::session_mut`] and
4572 /// call its backend-specific create method, then hand the handle
4573 /// here. `nros-px4::uorb::create_subscription_with_callback` is
4574 /// the example.
4575 ///
4576 /// The arena-store + vtable wiring is identical to
4577 /// `register_subscription_buffered_raw`; the only thing that varies is
4578 /// where the handle came from. Callback fires on every message
4579 /// delivery during [`spin_once`](Self::spin_once); bytes are
4580 /// passed as `&[u8]`.
4581 pub fn add_arena_subscription_callback<F, const RX_BUF: usize>(
4582 &mut self,
4583 handle: session::RmwSubscriber,
4584 qos: QoSProfile,
4585 callback: F,
4586 ) -> Result<HandleId, NodeError>
4587 where
4588 F: FnMut(&[u8]) + 'static,
4589 {
4590 type Entry<F> = SubBufferedRawEntry<F>;
4591
4592 let slot = self.next_entry_slot()?;
4593 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, RX_BUF);
4594
4595 let (entry_offset, trailing_offset) =
4596 self.arena_alloc_with_trailing::<Entry<F>>(trailing_bytes)?;
4597
4598 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
4599
4600 let buffer = if qos.depth <= 1 {
4601 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, RX_BUF) })
4602 } else {
4603 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, RX_BUF, qos.depth as usize) })
4604 };
4605
4606 unsafe {
4607 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4608 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<F>;
4609 core::ptr::write(
4610 entry_ptr,
4611 Entry {
4612 handle,
4613 buffer,
4614 callback,
4615 },
4616 );
4617 }
4618
4619 let meta = CallbackMeta {
4620 offset: entry_offset,
4621 kind: EntryKind::Subscription,
4622 try_process: sub_buffered_raw_try_process::<F>,
4623 has_data: sub_buffered_raw_has_data::<F>,
4624 pre_sample: no_pre_sample,
4625 invocation: InvocationMode::OnNewData,
4626 drop_fn: drop_entry::<Entry<F>>,
4627 };
4628 self.emplace_entry(slot, meta, TraceName::Slot("sub", slot));
4629 Ok(HandleId(slot))
4630 }
4631
4632 pub(crate) fn register_subscription_with_info_sized_inner<M, F, const RX_BUF: usize>(
4633 &mut self,
4634 node_id: Option<super::node_record::NodeId>,
4635 topic_name: &str,
4636 qos: QoSProfile,
4637 callback: F,
4638 ) -> Result<HandleId, NodeError>
4639 where
4640 M: crate::rmw_type_registry::MessageForRmw + 'static,
4641 F: FnMut(&M, Option<&nros_core::MessageInfo>) + 'static,
4642 {
4643 type Entry<M, F, const N: usize> = SubInfoEntry<M, F, N>;
4644
4645 // Phase 212.K.7.6.b — see `create_publisher_on`.
4646 crate::rmw_type_registry::register_type::<M>()?;
4647
4648 let slot = self.next_entry_slot()?;
4649 let (node_name, ns, session_idx) = match node_id {
4650 Some(id) => {
4651 let r = self
4652 .nodes
4653 .get(id.index())
4654 .ok_or(NodeError::InvalidSchedContextBinding)?;
4655 (r.name.clone(), r.namespace.clone(), r.session_idx)
4656 }
4657 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
4658 };
4659 let mut topic = TopicInfo::new(
4660 topic_name,
4661 <M as RosMessage>::TYPE_NAME,
4662 <M as RosMessage>::TYPE_HASH,
4663 )
4664 .with_domain(self.domain_id)
4665 .with_namespace(&ns);
4666 if !node_name.is_empty() {
4667 topic = topic.with_node_name(&node_name);
4668 }
4669 let handle = {
4670 let session = self
4671 .session_at_mut(session_idx)
4672 .ok_or(NodeError::BackendMismatch)?;
4673 session
4674 .create_subscription(&topic, qos)
4675 .map_err(NodeError::Transport)?
4676 };
4677
4678 let offset = self.arena_alloc::<Entry<M, F, RX_BUF>>()?;
4679
4680 unsafe {
4681 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4682 let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F, RX_BUF>;
4683 core::ptr::write(
4684 entry_ptr,
4685 Entry {
4686 handle,
4687 buffer: [0u8; RX_BUF],
4688 sampled_len: 0,
4689 callback,
4690 _phantom: PhantomData,
4691 },
4692 );
4693 }
4694
4695 let meta = CallbackMeta {
4696 offset,
4697 kind: EntryKind::Subscription,
4698 try_process: sub_info_try_process::<M, F, RX_BUF>,
4699 has_data: sub_info_has_data::<M, F, RX_BUF>,
4700 pre_sample: sub_info_pre_sample::<M, F, RX_BUF>,
4701 invocation: InvocationMode::OnNewData,
4702 drop_fn: drop_entry::<Entry<M, F, RX_BUF>>,
4703 };
4704 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
4705 self.apply_node_default_sched(slot, node_id, None);
4706 Ok(HandleId(slot))
4707 }
4708
4709 #[cfg(feature = "safety-e2e")]
4710 pub(crate) fn register_subscription_with_safety_sized_inner<M, F, const RX_BUF: usize>(
4711 &mut self,
4712 node_id: Option<super::node_record::NodeId>,
4713 topic_name: &str,
4714 qos: QoSProfile,
4715 callback: F,
4716 ) -> Result<HandleId, NodeError>
4717 where
4718 M: crate::rmw_type_registry::MessageForRmw + 'static,
4719 F: FnMut(&M, &nros_rmw::IntegrityStatus) + 'static,
4720 {
4721 type Entry<M, F, const N: usize> = SubSafetyEntry<M, F, N>;
4722
4723 // Phase 212.K.7.6.b — see `create_publisher_on`.
4724 crate::rmw_type_registry::register_type::<M>()?;
4725
4726 let slot = self.next_entry_slot()?;
4727 let (node_name, ns, session_idx) = match node_id {
4728 Some(id) => {
4729 let r = self
4730 .nodes
4731 .get(id.index())
4732 .ok_or(NodeError::InvalidSchedContextBinding)?;
4733 (r.name.clone(), r.namespace.clone(), r.session_idx)
4734 }
4735 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
4736 };
4737 let mut topic = TopicInfo::new(
4738 topic_name,
4739 <M as RosMessage>::TYPE_NAME,
4740 <M as RosMessage>::TYPE_HASH,
4741 )
4742 .with_domain(self.domain_id)
4743 .with_namespace(&ns);
4744 if !node_name.is_empty() {
4745 topic = topic.with_node_name(&node_name);
4746 }
4747 let handle = {
4748 let session = self
4749 .session_at_mut(session_idx)
4750 .ok_or(NodeError::BackendMismatch)?;
4751 session
4752 .create_subscription(&topic, qos)
4753 .map_err(NodeError::Transport)?
4754 };
4755
4756 let offset = self.arena_alloc::<Entry<M, F, RX_BUF>>()?;
4757
4758 unsafe {
4759 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4760 let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F, RX_BUF>;
4761 core::ptr::write(
4762 entry_ptr,
4763 Entry {
4764 handle,
4765 buffer: [0u8; RX_BUF],
4766 sampled_len: 0,
4767 callback,
4768 _phantom: PhantomData,
4769 },
4770 );
4771 }
4772
4773 let meta = CallbackMeta {
4774 offset,
4775 kind: EntryKind::Subscription,
4776 try_process: sub_safety_try_process::<M, F, RX_BUF>,
4777 has_data: sub_safety_has_data::<M, F, RX_BUF>,
4778 pre_sample: sub_safety_pre_sample::<M, F, RX_BUF>,
4779 invocation: InvocationMode::OnNewData,
4780 drop_fn: drop_entry::<Entry<M, F, RX_BUF>>,
4781 };
4782 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
4783 self.apply_node_default_sched(slot, node_id, None);
4784 Ok(HandleId(slot))
4785 }
4786
4787 /// Register a service callback with the default buffer size.
4788 ///
4789 /// The callback is stored in the arena and invoked during [`spin_once()`](Self::spin_once).
4790 pub fn register_service<Svc, F>(
4791 &mut self,
4792 service_name: &str,
4793 callback: F,
4794 ) -> Result<HandleId, NodeError>
4795 where
4796 Svc: RosService + 'static,
4797 Svc::Request: crate::rmw_type_registry::MessageForRmw,
4798 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
4799 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
4800 {
4801 self.register_service_sized::<Svc, F, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, callback)
4802 }
4803
4804 /// Register a service callback with custom request/reply buffer sizes.
4805 pub fn register_service_sized<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
4806 &mut self,
4807 service_name: &str,
4808 callback: F,
4809 ) -> Result<HandleId, NodeError>
4810 where
4811 Svc: RosService + 'static,
4812 Svc::Request: crate::rmw_type_registry::MessageForRmw,
4813 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
4814 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
4815 {
4816 type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
4817
4818 // Phase 212.K.7.7.b — register both halves of the service round-trip
4819 // under cyclonedds. No-op for other RMWs. Mirrors the K.7.6.b hook
4820 // on `Node::create_service_sized`.
4821 crate::rmw_type_registry::register_type::<Svc::Request>()?;
4822 crate::rmw_type_registry::register_type::<Svc::Reply>()?;
4823
4824 let slot = self.next_entry_slot()?;
4825 let node_name: heapless::String<64> = self.node_name.clone();
4826 let ns: heapless::String<64> = self.namespace.clone();
4827 let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
4828 .with_domain(self.domain_id)
4829 .with_namespace(&ns);
4830 if !node_name.is_empty() {
4831 info = info.with_node_name(&node_name);
4832 }
4833 let handle = self
4834 .session
4835 .create_service(&info, QoSProfile::services_default())
4836 .map_err(NodeError::Transport)?;
4837
4838 let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
4839
4840 // SAFETY: same guarantees as register_subscription_sized.
4841 unsafe {
4842 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4843 let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
4844 core::ptr::write(
4845 entry_ptr,
4846 Entry {
4847 handle,
4848 req_buffer: [0u8; REQ_BUF],
4849 reply_buffer: [0u8; REPLY_BUF],
4850 callback,
4851 _phantom: PhantomData,
4852 },
4853 );
4854 }
4855
4856 let meta = CallbackMeta {
4857 offset,
4858 kind: EntryKind::Service,
4859 try_process: srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
4860 has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
4861 pre_sample: no_pre_sample,
4862 invocation: InvocationMode::OnNewData,
4863 drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
4864 };
4865 self.emplace_entry(slot, meta, TraceName::Text(service_name));
4866 Ok(HandleId(slot))
4867 }
4868
4869 /// Phase 104.C.3.3.a — Node-aware variant of
4870 /// [`register_service_sized`](Self::register_service_sized).
4871 pub fn register_service_sized_on<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
4872 &mut self,
4873 node_id: super::node_record::NodeId,
4874 service_name: &str,
4875 qos: QoSProfile,
4876 callback: F,
4877 ) -> Result<HandleId, NodeError>
4878 where
4879 Svc: RosService + 'static,
4880 Svc::Request: crate::rmw_type_registry::MessageForRmw,
4881 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
4882 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
4883 {
4884 type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
4885
4886 // Phase 212.K.7.7.b — see `register_service_sized`.
4887 crate::rmw_type_registry::register_type::<Svc::Request>()?;
4888 crate::rmw_type_registry::register_type::<Svc::Reply>()?;
4889
4890 let slot = self.next_entry_slot()?;
4891 let (node_name, ns, session_idx) = {
4892 let r = self
4893 .nodes
4894 .get(node_id.index())
4895 .ok_or(NodeError::InvalidSchedContextBinding)?;
4896 (r.name.clone(), r.namespace.clone(), r.session_idx)
4897 };
4898 let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
4899 .with_domain(self.domain_id)
4900 .with_namespace(&ns);
4901 if !node_name.is_empty() {
4902 info = info.with_node_name(&node_name);
4903 }
4904 let handle = {
4905 let session = self
4906 .session_at_mut(session_idx)
4907 .ok_or(NodeError::BackendMismatch)?;
4908 // Phase 193.5 — validate against the backend's supported policies
4909 // (no silent downgrade); request/reply effectively requires RELIABLE.
4910 qos.validate_against(session.supported_qos_policies())
4911 .map_err(NodeError::Transport)?;
4912 session
4913 .create_service(&info, qos)
4914 .map_err(NodeError::Transport)?
4915 };
4916
4917 let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
4918 unsafe {
4919 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4920 let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
4921 core::ptr::write(
4922 entry_ptr,
4923 Entry {
4924 handle,
4925 req_buffer: [0u8; REQ_BUF],
4926 reply_buffer: [0u8; REPLY_BUF],
4927 callback,
4928 _phantom: PhantomData,
4929 },
4930 );
4931 }
4932
4933 let meta = CallbackMeta {
4934 offset,
4935 kind: EntryKind::Service,
4936 try_process: srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
4937 has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
4938 pre_sample: no_pre_sample,
4939 invocation: InvocationMode::OnNewData,
4940 drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
4941 };
4942 self.emplace_entry(slot, meta, TraceName::Text(service_name));
4943 self.apply_node_default_sched(slot, Some(node_id), None);
4944 Ok(HandleId(slot))
4945 }
4946
4947 /// Phase 104.C.3.3.a — Node-aware variant of
4948 /// [`register_service`](Self::register_service).
4949 pub fn register_service_on<Svc, F>(
4950 &mut self,
4951 node_id: super::node_record::NodeId,
4952 service_name: &str,
4953 callback: F,
4954 ) -> Result<HandleId, NodeError>
4955 where
4956 Svc: RosService + 'static,
4957 Svc::Request: crate::rmw_type_registry::MessageForRmw,
4958 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
4959 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
4960 {
4961 self.register_service_sized_on::<
4962 Svc,
4963 F,
4964 { crate::config::DEFAULT_RX_BUF_SIZE },
4965 { crate::config::DEFAULT_RX_BUF_SIZE },
4966 >(node_id, service_name, QoSProfile::services_default(), callback)
4967 }
4968
4969 // ========================================================================
4970 // Timer registration
4971 // ========================================================================
4972
4973 /// Register a repeating timer callback.
4974 ///
4975 /// The callback fires every `period` milliseconds during [`spin_once()`](Self::spin_once).
4976 /// The timer delta is approximated by the `timeout_ms` argument to `spin_once`.
4977 pub fn register_timer<F>(
4978 &mut self,
4979 period: TimerDuration,
4980 callback: F,
4981 ) -> Result<HandleId, NodeError>
4982 where
4983 F: FnMut() + 'static,
4984 {
4985 let slot = self.next_entry_slot()?;
4986 let offset = self.arena_alloc::<TimerEntry<F>>()?;
4987
4988 unsafe {
4989 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4990 let entry_ptr = arena_ptr.add(offset) as *mut TimerEntry<F>;
4991 core::ptr::write(
4992 entry_ptr,
4993 TimerEntry {
4994 period_us: period.as_micros(),
4995 elapsed_us: 0,
4996 overruns: 0,
4997 overruns_reported: 0,
4998 oneshot: false,
4999 fired: false,
5000 cancelled: false,
5001 overrun_policy: TimerOverrunPolicy::default(),
5002 clock_source: TimerClockSource::Steady,
5003 last_clock_ns: 0,
5004 callback,
5005 },
5006 );
5007 }
5008
5009 let meta = CallbackMeta {
5010 offset,
5011 kind: EntryKind::Timer,
5012 try_process: timer_try_process::<F>,
5013 has_data: always_ready,
5014 pre_sample: no_pre_sample,
5015 invocation: InvocationMode::Always,
5016 drop_fn: drop_entry::<TimerEntry<F>>,
5017 };
5018 self.emplace_entry(slot, meta, TraceName::TimerPeriod(period.as_micros()));
5019 Ok(HandleId(slot))
5020 }
5021
5022 /// Register a repeating timer driven by a CLOCK rather than by the spin
5023 /// delta — phase-425 W4, the shape rclcpp spells
5024 /// `create_timer(node, clock, period, cb)`.
5025 ///
5026 /// [`register_timer`](Self::register_timer) is the wall timer: it consumes
5027 /// the executor's monotonic spin delta and no simulator can slow it down.
5028 /// This one reads `source` on every poll and advances by the difference, so
5029 /// a [`TimerClockSource::Ros`] timer follows `/clock` — it stops while the
5030 /// simulator is paused, halves with a bag replayed at 0.5x, and restarts
5031 /// its period on a backwards jump instead of stalling for the length of it.
5032 ///
5033 /// With no `/clock` source installed, a `Ros` timer reads system time and
5034 /// behaves like a wall timer with NTP steps, which is the same fallback
5035 /// `rclcpp::Clock` has: a node written for simulation still runs standalone.
5036 pub fn register_timer_on_clock<F>(
5037 &mut self,
5038 period: TimerDuration,
5039 source: TimerClockSource,
5040 callback: F,
5041 ) -> Result<HandleId, NodeError>
5042 where
5043 F: FnMut() + 'static,
5044 {
5045 let slot = self.next_entry_slot()?;
5046 let offset = self.arena_alloc::<TimerEntry<F>>()?;
5047
5048 unsafe {
5049 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5050 let entry_ptr = arena_ptr.add(offset) as *mut TimerEntry<F>;
5051 core::ptr::write(
5052 entry_ptr,
5053 TimerEntry {
5054 period_us: period.as_micros(),
5055 elapsed_us: 0,
5056 overruns: 0,
5057 overruns_reported: 0,
5058 oneshot: false,
5059 fired: false,
5060 cancelled: false,
5061 overrun_policy: TimerOverrunPolicy::default(),
5062 clock_source: source,
5063 // Seeded HERE rather than on the first poll: a zero would
5064 // make the first delta the whole epoch, which `Skip` would
5065 // then coalesce into one immediate activation and `CatchUp`
5066 // into a replay burst of ~10^9 periods.
5067 last_clock_ns: source.now_ns(),
5068 callback,
5069 },
5070 );
5071 }
5072
5073 let meta = CallbackMeta {
5074 offset,
5075 kind: EntryKind::Timer,
5076 try_process: timer_try_process::<F>,
5077 has_data: always_ready,
5078 pre_sample: no_pre_sample,
5079 invocation: InvocationMode::Always,
5080 drop_fn: drop_entry::<TimerEntry<F>>,
5081 };
5082 self.emplace_entry(slot, meta, TraceName::TimerPeriod(period.as_micros()));
5083 Ok(HandleId(slot))
5084 }
5085
5086 /// Register a one-shot timer callback.
5087 ///
5088 /// The callback fires once after `delay` milliseconds, then becomes inert.
5089 pub fn register_timer_oneshot<F>(
5090 &mut self,
5091 delay: TimerDuration,
5092 callback: F,
5093 ) -> Result<HandleId, NodeError>
5094 where
5095 F: FnMut() + 'static,
5096 {
5097 let slot = self.next_entry_slot()?;
5098 let offset = self.arena_alloc::<TimerEntry<F>>()?;
5099
5100 unsafe {
5101 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5102 let entry_ptr = arena_ptr.add(offset) as *mut TimerEntry<F>;
5103 core::ptr::write(
5104 entry_ptr,
5105 TimerEntry {
5106 period_us: delay.as_micros(),
5107 elapsed_us: 0,
5108 overruns: 0,
5109 overruns_reported: 0,
5110 oneshot: true,
5111 fired: false,
5112 cancelled: false,
5113 overrun_policy: TimerOverrunPolicy::default(),
5114 clock_source: TimerClockSource::Steady,
5115 last_clock_ns: 0,
5116 callback,
5117 },
5118 );
5119 }
5120
5121 let meta = CallbackMeta {
5122 offset,
5123 kind: EntryKind::Timer,
5124 try_process: timer_try_process::<F>,
5125 has_data: always_ready,
5126 pre_sample: no_pre_sample,
5127 invocation: InvocationMode::Always,
5128 drop_fn: drop_entry::<TimerEntry<F>>,
5129 };
5130 self.emplace_entry(slot, meta, TraceName::TimerPeriod(delay.as_micros()));
5131 Ok(HandleId(slot))
5132 }
5133
5134 /// Phase 273 (RFC-0047) — register a repeating timer callback bound to a
5135 /// specific node and optional callback group. The group name is threaded to
5136 /// `apply_node_default_sched` so the seeded `group_sched_table` assigns
5137 /// the timer's callback to the group's `SchedContext`. When `group` is
5138 /// `None` the node's `default_sched` applies (phase-272 behavior).
5139 ///
5140 /// This is the executor-level primitive called by the Rust `_in` API
5141 /// (`NodeCtx::create_timer_in`) and the C/C++ group-aware timer FFI.
5142 pub fn register_timer_on<F>(
5143 &mut self,
5144 node_id: Option<super::node_record::NodeId>,
5145 period: TimerDuration,
5146 callback: F,
5147 group: Option<&str>,
5148 ) -> Result<HandleId, NodeError>
5149 where
5150 F: FnMut() + 'static,
5151 {
5152 let slot = self.next_entry_slot()?;
5153 let offset = self.arena_alloc::<TimerEntry<F>>()?;
5154
5155 unsafe {
5156 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5157 let entry_ptr = arena_ptr.add(offset) as *mut TimerEntry<F>;
5158 core::ptr::write(
5159 entry_ptr,
5160 TimerEntry {
5161 period_us: period.as_micros(),
5162 elapsed_us: 0,
5163 overruns: 0,
5164 overruns_reported: 0,
5165 oneshot: false,
5166 fired: false,
5167 cancelled: false,
5168 overrun_policy: TimerOverrunPolicy::default(),
5169 clock_source: TimerClockSource::Steady,
5170 last_clock_ns: 0,
5171 callback,
5172 },
5173 );
5174 }
5175
5176 let meta = CallbackMeta {
5177 offset,
5178 kind: EntryKind::Timer,
5179 try_process: timer_try_process::<F>,
5180 has_data: always_ready,
5181 pre_sample: no_pre_sample,
5182 invocation: InvocationMode::Always,
5183 drop_fn: drop_entry::<TimerEntry<F>>,
5184 };
5185 self.emplace_entry(slot, meta, TraceName::TimerPeriod(period.as_micros()));
5186 // Phase 273 — apply group sched binding (group > node default > SC 0).
5187 self.apply_node_default_sched(slot, node_id, group);
5188 Ok(HandleId(slot))
5189 }
5190
5191 // ========================================================================
5192 // Raw callback registration (for C API)
5193 // ========================================================================
5194
5195 /// The kept C-FFI subscription core (Phase 189.M2.b): registers a
5196 /// raw `RawSubscriptionCallback` fn-ptr + `context` against an
5197 /// optional node's session. The Rust ergonomic surface is the
5198 /// `node.subscription(t)` builder (closures); this is the single
5199 /// primitive the `nros-c` thin wrapper lowers to. `node_id == None`
5200 /// is the legacy single-node path.
5201 #[allow(clippy::too_many_arguments)]
5202 pub fn add_arena_subscription_c_callback<const RX_BUF: usize>(
5203 &mut self,
5204 node_id: Option<super::node_record::NodeId>,
5205 topic_name: &str,
5206 type_name: &str,
5207 type_hash: &str,
5208 qos: QoSProfile,
5209 callback: RawSubscriptionCallback,
5210 context: *mut core::ffi::c_void,
5211 group: Option<&str>,
5212 // phase-402 W2 / issue 0896 — bytes the caller expects to receive; 0 =
5213 // no opinion. A size-classing backend (zenoh-pico) routes on this, and
5214 // a subscription that states nothing takes the SMALL class whatever its
5215 // message type. The C path had no way to say it until the options
5216 // struct existed.
5217 rx_buffer_hint: usize,
5218 ) -> Result<HandleId, NodeError> {
5219 let slot = self.next_entry_slot()?;
5220 let (node_name, ns, session_idx) = match node_id {
5221 Some(id) => {
5222 let r = self
5223 .nodes
5224 .get(id.index())
5225 .ok_or(NodeError::InvalidSchedContextBinding)?;
5226 (r.name.clone(), r.namespace.clone(), r.session_idx)
5227 }
5228 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
5229 };
5230 let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
5231 .with_domain(self.domain_id)
5232 .with_namespace(&ns);
5233 if !node_name.is_empty() {
5234 topic = topic.with_node_name(&node_name);
5235 }
5236 // phase-402 W2 — only when the caller actually stated one:
5237 // `with_rx_buffer_hint(0)` would be a claim of "zero bytes", not
5238 // "no opinion".
5239 if rx_buffer_hint != 0 {
5240 topic = topic.with_rx_buffer_hint(rx_buffer_hint);
5241 }
5242 let handle = {
5243 let session = self
5244 .session_at_mut(session_idx)
5245 .ok_or(NodeError::BackendMismatch)?;
5246 session
5247 .create_subscription(&topic, qos)
5248 .map_err(NodeError::Transport)?
5249 };
5250
5251 // phase-403 W3/W5 -- size the arena slot from the TYPE, not from the
5252 // image-wide default. `rx_buffer_hint` already arrives here (phase-402
5253 // routed it to the backend's payload class and stopped); spending it on
5254 // the allocation as well is what makes the buffer per-type.
5255 //
5256 // This is the whole saving on the raw path. `RX_BUF` is
5257 // DEFAULT_RX_BUF_SIZE, so every slot -- publishers and timers included --
5258 // was charged the largest subscription's buffer. Measured on
5259 // mr-canhubk344: 36 handles at the correct 2052-byte bound wanted a
5260 // 242096-byte arena of a 77968-byte region; only 13 of those handles
5261 // receive anything.
5262 //
5263 // 0 keeps the old behaviour, and it means "this CALLER stated nothing"
5264 // rather than "this type is unbounded" -- an unbounded type is a build
5265 // error now, so a zero here is a caller that did not ask, never a type
5266 // that could not answer.
5267 let rx_bytes = if rx_buffer_hint != 0 {
5268 rx_buffer_hint
5269 } else {
5270 RX_BUF
5271 };
5272
5273 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, rx_bytes);
5274
5275 let (entry_offset, trailing_offset) =
5276 self.arena_alloc_with_trailing::<SubBufferedRawCEntry>(trailing_bytes)?;
5277
5278 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
5279
5280 let buffer = if qos.depth <= 1 {
5281 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, rx_bytes) })
5282 } else {
5283 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, rx_bytes, qos.depth as usize) })
5284 };
5285
5286 unsafe {
5287 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5288 let entry_ptr = arena_ptr.add(entry_offset) as *mut SubBufferedRawCEntry;
5289 core::ptr::write(
5290 entry_ptr,
5291 SubBufferedRawCEntry {
5292 handle,
5293 buffer,
5294 callback,
5295 context,
5296 },
5297 );
5298 }
5299
5300 let meta = CallbackMeta {
5301 offset: entry_offset,
5302 kind: EntryKind::Subscription,
5303 try_process: sub_buffered_raw_c_try_process,
5304 has_data: sub_buffered_raw_c_has_data,
5305 pre_sample: no_pre_sample,
5306 invocation: InvocationMode::OnNewData,
5307 drop_fn: drop_entry::<SubBufferedRawCEntry>,
5308 };
5309 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
5310 self.apply_node_default_sched(slot, node_id, group);
5311 Ok(HandleId(slot))
5312 }
5313
5314 /// Phase 189.M3.4 — register a raw C-fn-ptr subscription whose callback
5315 /// also receives the sample's wire **attachment**
5316 /// ([`RawSubscriptionInfoCallback`]: `(data, len, attachment, att_len,
5317 /// context)`) — the C analog of the Rust
5318 /// `node.subscription(t).generic(..).message_info()` builder. Backs the C
5319 /// FFI `nros_executor_add_subscription_raw_with_info`. Flat per-entry
5320 /// payload + attachment buffers (cap [`RAW_INFO_ATT_CAP`](super::arena::RAW_INFO_ATT_CAP));
5321 /// one sample per `spin_once`.
5322 #[allow(clippy::too_many_arguments)]
5323 pub fn add_arena_subscription_c_info_callback<const RX_BUF: usize>(
5324 &mut self,
5325 node_id: Option<super::node_record::NodeId>,
5326 topic_name: &str,
5327 type_name: &str,
5328 type_hash: &str,
5329 qos: QoSProfile,
5330 callback: RawSubscriptionInfoCallback,
5331 context: *mut core::ffi::c_void,
5332 // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
5333 // opinion. It now buys BOTH halves: the BACKEND's payload size-class
5334 // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
5335 // survives only as the fallback for a caller that states nothing, which
5336 // is exactly its role on the plain C path.
5337 rx_buffer_hint: usize,
5338 ) -> Result<HandleId, NodeError> {
5339 type Entry = SubBufferedRawInfoCEntry;
5340
5341 let slot = self.next_entry_slot()?;
5342 let (node_name, ns, session_idx) = match node_id {
5343 Some(id) => {
5344 let r = self
5345 .nodes
5346 .get(id.index())
5347 .ok_or(NodeError::InvalidSchedContextBinding)?;
5348 (r.name.clone(), r.namespace.clone(), r.session_idx)
5349 }
5350 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
5351 };
5352 let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
5353 .with_domain(self.domain_id)
5354 .with_namespace(&ns);
5355 if !node_name.is_empty() {
5356 topic = topic.with_node_name(&node_name);
5357 }
5358 // phase-408 W5a — only when the caller actually stated one:
5359 // `with_rx_buffer_hint(0)` would be a claim of "zero bytes", not
5360 // "no opinion".
5361 if rx_buffer_hint != 0 {
5362 topic = topic.with_rx_buffer_hint(rx_buffer_hint);
5363 }
5364 let handle = {
5365 let session = self
5366 .session_at_mut(session_idx)
5367 .ok_or(NodeError::BackendMismatch)?;
5368 session
5369 .create_subscription(&topic, qos)
5370 .map_err(NodeError::Transport)?
5371 };
5372
5373 // phase-408 W5b — the hint sizes the ARENA too, not just the backend's
5374 // size class. ONE slot, not `buffered_region_size`: this entry hands the
5375 // sample's attachment to the callback alongside the payload, so it
5376 // dispatches exactly one sample per spin and has no queue to size. 0
5377 // still means "this caller stated nothing", so `RX_BUF` — which is
5378 // `DEFAULT_RX_BUF_SIZE` at every call site — is what it falls back to,
5379 // and an unhinted registration claims the same bytes it always did.
5380 let rx_bytes = if rx_buffer_hint != 0 {
5381 rx_buffer_hint
5382 } else {
5383 RX_BUF
5384 };
5385
5386 let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
5387 unsafe {
5388 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5389 let buf = super::arena::TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
5390 let entry_ptr = arena_ptr.add(offset) as *mut Entry;
5391 core::ptr::write(
5392 entry_ptr,
5393 Entry {
5394 handle,
5395 buffer: buf,
5396 att: [0u8; super::arena::RAW_INFO_ATT_CAP],
5397 callback,
5398 context,
5399 },
5400 );
5401 }
5402
5403 let meta = CallbackMeta {
5404 offset,
5405 kind: EntryKind::Subscription,
5406 try_process: sub_buffered_raw_info_c_try_process,
5407 has_data: sub_buffered_raw_info_c_has_data,
5408 pre_sample: no_pre_sample,
5409 invocation: InvocationMode::OnNewData,
5410 drop_fn: drop_entry::<Entry>,
5411 };
5412 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
5413 self.apply_node_default_sched(slot, node_id, None);
5414 Ok(HandleId(slot))
5415 }
5416
5417 /// Phase 269 W3 — register a raw C-fn-ptr subscription whose callback
5418 /// ALSO surfaces the sample's E2E integrity status (CRC + sequence gap/dup)
5419 /// alongside the CDR bytes — the C/C++ component-callback analog of Rust's
5420 /// `register_subscription_buffered_raw_safety_on` (`FnMut(&[u8], &IntegrityStatus)`).
5421 ///
5422 /// The executor validates the sample via `take_validated` and unpacks the
5423 /// [`nros_rmw::IntegrityStatus`] into three plain scalars (gap, duplicate,
5424 /// crc_valid) before calling `callback`. This avoids introducing a
5425 /// cbindgen-visible struct at the executor layer; the C/C++ headers pack them
5426 /// back into their local integrity-status typedef.
5427 ///
5428 /// Requires the `safety-e2e` feature. Backed by [`SubBufferedRawSafetyCEntry`].
5429 #[cfg(feature = "safety-e2e")]
5430 #[allow(clippy::too_many_arguments)]
5431 pub fn add_arena_subscription_c_validated_callback<const RX_BUF: usize>(
5432 &mut self,
5433 node_id: Option<super::node_record::NodeId>,
5434 topic_name: &str,
5435 type_name: &str,
5436 type_hash: &str,
5437 qos: QoSProfile,
5438 callback: super::types::RawSubscriptionSafetyCallback,
5439 context: *mut core::ffi::c_void,
5440 // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
5441 // opinion. It now buys BOTH halves: the BACKEND's payload size-class
5442 // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
5443 // survives only as the fallback for a caller that states nothing, which
5444 // is exactly its role on the plain C path.
5445 rx_buffer_hint: usize,
5446 ) -> Result<HandleId, NodeError> {
5447 use super::arena::{
5448 SubBufferedRawSafetyCEntry, TrailingBuf, sub_buffered_raw_safety_c_has_data,
5449 sub_buffered_raw_safety_c_try_process,
5450 };
5451 type Entry = SubBufferedRawSafetyCEntry;
5452
5453 let slot = self.next_entry_slot()?;
5454 let (node_name, ns, session_idx) = match node_id {
5455 Some(id) => {
5456 let r = self
5457 .nodes
5458 .get(id.index())
5459 .ok_or(NodeError::InvalidSchedContextBinding)?;
5460 (r.name.clone(), r.namespace.clone(), r.session_idx)
5461 }
5462 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
5463 };
5464 let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
5465 .with_domain(self.domain_id)
5466 .with_namespace(&ns);
5467 if !node_name.is_empty() {
5468 topic = topic.with_node_name(&node_name);
5469 }
5470 // phase-408 W5a — only when the caller actually stated one:
5471 // `with_rx_buffer_hint(0)` would be a claim of "zero bytes", not
5472 // "no opinion".
5473 if rx_buffer_hint != 0 {
5474 topic = topic.with_rx_buffer_hint(rx_buffer_hint);
5475 }
5476 let handle = {
5477 let session = self
5478 .session_at_mut(session_idx)
5479 .ok_or(NodeError::BackendMismatch)?;
5480 session
5481 .create_subscription(&topic, qos)
5482 .map_err(NodeError::Transport)?
5483 };
5484
5485 // phase-408 W5b — same as the info sibling: one flat slot in the
5486 // trailing region, sized from the hint, `RX_BUF` only as the
5487 // stated-nothing fallback. The integrity status is per-sample side
5488 // data, so this path also dispatches one sample per spin and wants no
5489 // queue.
5490 let rx_bytes = if rx_buffer_hint != 0 {
5491 rx_buffer_hint
5492 } else {
5493 RX_BUF
5494 };
5495
5496 let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
5497 unsafe {
5498 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5499 let buf = TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
5500 let entry_ptr = arena_ptr.add(offset) as *mut Entry;
5501 core::ptr::write(
5502 entry_ptr,
5503 Entry {
5504 handle,
5505 buffer: buf,
5506 callback,
5507 context,
5508 },
5509 );
5510 }
5511
5512 let meta = CallbackMeta {
5513 offset,
5514 kind: EntryKind::Subscription,
5515 try_process: sub_buffered_raw_safety_c_try_process,
5516 has_data: sub_buffered_raw_safety_c_has_data,
5517 pre_sample: no_pre_sample,
5518 invocation: InvocationMode::OnNewData,
5519 drop_fn: drop_entry::<Entry>,
5520 };
5521 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
5522 self.apply_node_default_sched(slot, node_id, None);
5523 Ok(HandleId(slot))
5524 }
5525
5526 /// Register a raw (untyped) service callback.
5527 ///
5528 /// Register a raw (untyped) service callback with the default buffer size.
5529 ///
5530 /// The callback receives and produces CDR bytes without typed
5531 /// deserialization/serialization. Used by the C API wrapper.
5532 pub fn register_service_raw(
5533 &mut self,
5534 service_name: &str,
5535 service_type: &str,
5536 service_hash: &str,
5537 callback: RawServiceCallback,
5538 context: *mut core::ffi::c_void,
5539 ) -> Result<HandleId, NodeError> {
5540 self.register_service_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(
5541 service_name,
5542 service_type,
5543 service_hash,
5544 QoSProfile::services_default(),
5545 callback,
5546 context,
5547 )
5548 }
5549
5550 /// Register a raw (untyped) service callback with custom buffer sizes + QoS.
5551 ///
5552 /// `REQ_BUF` and `REPLY_BUF` set the stack-allocated CDR buffers
5553 /// for the request and reply respectively. Increase for services
5554 /// with large payloads (e.g., parameter services). `qos` applies to both
5555 /// the request + reply endpoints (Phase 193.2c).
5556 #[allow(clippy::too_many_arguments)]
5557 pub fn register_service_raw_sized<const REQ_BUF: usize, const REPLY_BUF: usize>(
5558 &mut self,
5559 service_name: &str,
5560 service_type: &str,
5561 service_hash: &str,
5562 qos: QoSProfile,
5563 callback: RawServiceCallback,
5564 context: *mut core::ffi::c_void,
5565 ) -> Result<HandleId, NodeError> {
5566 self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF>(
5567 None,
5568 service_name,
5569 service_type,
5570 service_hash,
5571 qos,
5572 callback,
5573 context,
5574 )
5575 }
5576
5577 /// Phase 104.C.3.3.a — Node-aware variant of
5578 /// [`register_service_raw_sized`]. C-FFI path.
5579 #[allow(clippy::too_many_arguments)]
5580 pub fn register_service_raw_sized_on<const REQ_BUF: usize, const REPLY_BUF: usize>(
5581 &mut self,
5582 node_id: super::node_record::NodeId,
5583 service_name: &str,
5584 service_type: &str,
5585 service_hash: &str,
5586 qos: QoSProfile,
5587 callback: RawServiceCallback,
5588 context: *mut core::ffi::c_void,
5589 ) -> Result<HandleId, NodeError> {
5590 self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF>(
5591 Some(node_id),
5592 service_name,
5593 service_type,
5594 service_hash,
5595 qos,
5596 callback,
5597 context,
5598 )
5599 }
5600
5601 #[allow(clippy::too_many_arguments)]
5602 fn register_service_raw_sized_inner<const REQ_BUF: usize, const REPLY_BUF: usize>(
5603 &mut self,
5604 node_id: Option<super::node_record::NodeId>,
5605 service_name: &str,
5606 service_type: &str,
5607 service_hash: &str,
5608 qos: QoSProfile,
5609 callback: RawServiceCallback,
5610 context: *mut core::ffi::c_void,
5611 ) -> Result<HandleId, NodeError> {
5612 let slot = self.next_entry_slot()?;
5613 let (node_name, ns, session_idx) = match node_id {
5614 Some(id) => {
5615 let r = self
5616 .nodes
5617 .get(id.index())
5618 .ok_or(NodeError::InvalidSchedContextBinding)?;
5619 (r.name.clone(), r.namespace.clone(), r.session_idx)
5620 }
5621 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
5622 };
5623 let mut info = ServiceInfo::new(service_name, service_type, service_hash)
5624 .with_domain(self.domain_id)
5625 .with_namespace(&ns);
5626 if !node_name.is_empty() {
5627 info = info.with_node_name(&node_name);
5628 }
5629 let handle = {
5630 let session = self
5631 .session_at_mut(session_idx)
5632 .ok_or(NodeError::BackendMismatch)?;
5633 // Phase 193.5 — validate against the backend's supported policies
5634 // (no silent downgrade); request/reply effectively requires RELIABLE.
5635 qos.validate_against(session.supported_qos_policies())
5636 .map_err(NodeError::Transport)?;
5637 session
5638 .create_service(&info, qos)
5639 .map_err(NodeError::Transport)?
5640 };
5641
5642 let offset = self.arena_alloc::<SrvRawEntry<REQ_BUF, REPLY_BUF>>()?;
5643
5644 unsafe {
5645 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5646 let entry_ptr = arena_ptr.add(offset) as *mut SrvRawEntry<REQ_BUF, REPLY_BUF>;
5647 core::ptr::write(
5648 entry_ptr,
5649 SrvRawEntry {
5650 handle,
5651 req_buffer: [0u8; REQ_BUF],
5652 reply_buffer: [0u8; REPLY_BUF],
5653 callback,
5654 context,
5655 },
5656 );
5657 }
5658
5659 let meta = CallbackMeta {
5660 offset,
5661 kind: EntryKind::Service,
5662 try_process: srv_raw_try_process::<REQ_BUF, REPLY_BUF>,
5663 has_data: srv_raw_has_data::<REQ_BUF, REPLY_BUF>,
5664 pre_sample: no_pre_sample,
5665 invocation: InvocationMode::OnNewData,
5666 drop_fn: drop_entry::<SrvRawEntry<REQ_BUF, REPLY_BUF>>,
5667 };
5668 self.emplace_entry(slot, meta, TraceName::Text(service_name));
5669 self.apply_node_default_sched(slot, node_id, None);
5670 Ok(HandleId(slot))
5671 }
5672
5673 // ========================================================================
5674 // Raw service client registration (Phase 82)
5675 // ========================================================================
5676
5677 /// Register a raw (untyped) service client with the default reply
5678 /// buffer size.
5679 ///
5680 /// The client is owned by the executor's arena. Each `spin_once`
5681 /// dispatch polls the in-flight reply slot via `take_response_raw`
5682 /// and fires the registered callback when the response arrives.
5683 /// Used by the C API thin wrapper — see Phase 82.
5684 pub fn register_service_client_raw(
5685 &mut self,
5686 service_name: &str,
5687 service_type: &str,
5688 service_hash: &str,
5689 callback: Option<RawResponseCallback>,
5690 context: *mut core::ffi::c_void,
5691 ) -> Result<HandleId, NodeError> {
5692 self.register_service_client_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }>(
5693 service_name,
5694 service_type,
5695 service_hash,
5696 QoSProfile::services_default(),
5697 callback,
5698 context,
5699 )
5700 }
5701
5702 /// Register a raw service client with a custom reply buffer size + QoS.
5703 ///
5704 /// `qos` applies to the client's request + reply endpoints (Phase 193.3b);
5705 /// defaults to [`QoSProfile::services_default`] via the convenience
5706 /// wrapper.
5707 #[allow(clippy::too_many_arguments)]
5708 pub fn register_service_client_raw_sized<const REPLY_BUF: usize>(
5709 &mut self,
5710 service_name: &str,
5711 service_type: &str,
5712 service_hash: &str,
5713 qos: QoSProfile,
5714 callback: Option<RawResponseCallback>,
5715 context: *mut core::ffi::c_void,
5716 ) -> Result<HandleId, NodeError> {
5717 self.register_service_client_raw_sized_inner::<REPLY_BUF>(
5718 None,
5719 service_name,
5720 service_type,
5721 service_hash,
5722 qos,
5723 callback,
5724 context,
5725 )
5726 }
5727
5728 /// Phase 104.C.3.3.a — Node-aware variant of
5729 /// [`register_service_client_raw_sized`]. Routes the client
5730 /// creation through the named Node's session.
5731 #[allow(clippy::too_many_arguments)]
5732 pub fn register_service_client_raw_sized_on<const REPLY_BUF: usize>(
5733 &mut self,
5734 node_id: super::node_record::NodeId,
5735 service_name: &str,
5736 service_type: &str,
5737 service_hash: &str,
5738 qos: QoSProfile,
5739 callback: Option<RawResponseCallback>,
5740 context: *mut core::ffi::c_void,
5741 ) -> Result<HandleId, NodeError> {
5742 self.register_service_client_raw_sized_inner::<REPLY_BUF>(
5743 Some(node_id),
5744 service_name,
5745 service_type,
5746 service_hash,
5747 qos,
5748 callback,
5749 context,
5750 )
5751 }
5752
5753 #[allow(clippy::too_many_arguments)]
5754 fn register_service_client_raw_sized_inner<const REPLY_BUF: usize>(
5755 &mut self,
5756 node_id: Option<super::node_record::NodeId>,
5757 service_name: &str,
5758 service_type: &str,
5759 service_hash: &str,
5760 qos: QoSProfile,
5761 callback: Option<RawResponseCallback>,
5762 context: *mut core::ffi::c_void,
5763 ) -> Result<HandleId, NodeError> {
5764 let slot = self.next_entry_slot()?;
5765 let (node_name, ns, session_idx) = match node_id {
5766 Some(id) => {
5767 let r = self
5768 .nodes
5769 .get(id.index())
5770 .ok_or(NodeError::InvalidSchedContextBinding)?;
5771 (r.name.clone(), r.namespace.clone(), r.session_idx)
5772 }
5773 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
5774 };
5775 let mut info = ServiceInfo::new(service_name, service_type, service_hash)
5776 .with_domain(self.domain_id)
5777 .with_namespace(&ns);
5778 if !node_name.is_empty() {
5779 info = info.with_node_name(&node_name);
5780 }
5781 let handle = {
5782 let session = self
5783 .session_at_mut(session_idx)
5784 .ok_or(NodeError::BackendMismatch)?;
5785 // Phase 193.5 — validate against the backend's supported policies
5786 // (no silent downgrade); request/reply effectively requires RELIABLE.
5787 qos.validate_against(session.supported_qos_policies())
5788 .map_err(NodeError::Transport)?;
5789 session
5790 .create_client(&info, qos)
5791 .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
5792 };
5793
5794 let offset = self.arena_alloc::<ServiceClientRawArenaEntry<REPLY_BUF>>()?;
5795 unsafe {
5796 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5797 let entry_ptr = arena_ptr.add(offset) as *mut ServiceClientRawArenaEntry<REPLY_BUF>;
5798 core::ptr::write(
5799 entry_ptr,
5800 ServiceClientRawArenaEntry {
5801 handle,
5802 reply_buffer: [0u8; REPLY_BUF],
5803 pending: false,
5804 reply_ready: core::sync::atomic::AtomicBool::new(false),
5805 callback,
5806 context,
5807 },
5808 );
5809 }
5810
5811 let meta = CallbackMeta {
5812 offset,
5813 kind: EntryKind::ServiceClient,
5814 try_process: service_client_raw_try_process::<REPLY_BUF>,
5815 has_data: always_ready,
5816 pre_sample: no_pre_sample,
5817 invocation: InvocationMode::Always,
5818 drop_fn: drop_entry::<ServiceClientRawArenaEntry<REPLY_BUF>>,
5819 };
5820 self.emplace_entry(slot, meta, TraceName::Text(service_name));
5821 self.apply_node_default_sched(slot, node_id, None);
5822 Ok(HandleId(slot))
5823 }
5824
5825 /// RFC-0041 / Phase 239.1 — register a **typed callback** service client.
5826 /// The reply is eager-drained at `spin_once` and dispatched to `callback` as
5827 /// a deserialized `Svc::Reply`. Returns the scheduling [`HandleId`] and a
5828 /// `*mut` to the arena entry's send header (used to build the typed
5829 /// [`ServiceClientCallback`](super::handles::ServiceClientCallback)).
5830 #[allow(clippy::too_many_arguments)]
5831 pub(crate) fn register_service_client_callback<Svc, F, const REPLY_BUF: usize>(
5832 &mut self,
5833 node_id: Option<super::node_record::NodeId>,
5834 service_name: &str,
5835 service_type: &str,
5836 service_hash: &str,
5837 qos: QoSProfile,
5838 callback: F,
5839 ) -> Result<(HandleId, *mut ServiceClientSendHeader<REPLY_BUF>), NodeError>
5840 where
5841 Svc: nros_core::RosService + 'static,
5842 F: FnMut(&Svc::Reply) + 'static,
5843 {
5844 let slot = self.next_entry_slot()?;
5845 let (node_name, ns, session_idx) = match node_id {
5846 Some(id) => {
5847 let r = self
5848 .nodes
5849 .get(id.index())
5850 .ok_or(NodeError::InvalidSchedContextBinding)?;
5851 (r.name.clone(), r.namespace.clone(), r.session_idx)
5852 }
5853 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
5854 };
5855 let mut info = ServiceInfo::new(service_name, service_type, service_hash)
5856 .with_domain(self.domain_id)
5857 .with_namespace(&ns);
5858 if !node_name.is_empty() {
5859 info = info.with_node_name(&node_name);
5860 }
5861 let handle = {
5862 let session = self
5863 .session_at_mut(session_idx)
5864 .ok_or(NodeError::BackendMismatch)?;
5865 qos.validate_against(session.supported_qos_policies())
5866 .map_err(NodeError::Transport)?;
5867 session
5868 .create_client(&info, qos)
5869 .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
5870 };
5871
5872 let offset = self.arena_alloc::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>()?;
5873 let hdr_ptr = unsafe {
5874 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5875 let entry_ptr =
5876 arena_ptr.add(offset) as *mut ServiceClientCallbackEntry<Svc, F, REPLY_BUF>;
5877 core::ptr::write(
5878 entry_ptr,
5879 ServiceClientCallbackEntry {
5880 hdr: ServiceClientSendHeader {
5881 handle,
5882 reply_buffer: [0u8; REPLY_BUF],
5883 pending: false,
5884 reply_ready: core::sync::atomic::AtomicBool::new(false),
5885 },
5886 callback,
5887 _phantom: core::marker::PhantomData,
5888 },
5889 );
5890 &mut (*entry_ptr).hdr as *mut ServiceClientSendHeader<REPLY_BUF>
5891 };
5892
5893 let meta = CallbackMeta {
5894 offset,
5895 kind: EntryKind::ServiceClient,
5896 try_process: service_client_callback_try_process::<Svc, F, REPLY_BUF>,
5897 has_data: always_ready,
5898 pre_sample: no_pre_sample,
5899 invocation: InvocationMode::Always,
5900 drop_fn: drop_entry::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>,
5901 };
5902 self.emplace_entry(slot, meta, TraceName::Text(service_name));
5903 self.apply_node_default_sched(slot, node_id, None);
5904 Ok((HandleId(slot), hdr_ptr))
5905 }
5906
5907 // ========================================================================
5908 // Guard condition registration
5909 // ========================================================================
5910
5911 /// Register a guard condition with a callback.
5912 ///
5913 /// Returns both the [`HandleId`] for trigger configuration and a
5914 /// [`GuardCondition`] for triggering from other threads.
5915 pub fn register_guard_condition<F>(
5916 &mut self,
5917 callback: F,
5918 ) -> Result<(HandleId, GuardCondition), NodeError>
5919 where
5920 F: FnMut() + 'static,
5921 {
5922 let slot = self.next_entry_slot()?;
5923 let offset = self.arena_alloc::<GuardConditionEntry<F>>()?;
5924
5925 unsafe {
5926 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5927 let entry_ptr = arena_ptr.add(offset) as *mut GuardConditionEntry<F>;
5928 core::ptr::write(
5929 entry_ptr,
5930 GuardConditionEntry {
5931 flag: portable_atomic::AtomicBool::new(false),
5932 callback,
5933 },
5934 );
5935
5936 // Create a handle pointing to the flag in the arena
5937 let flag_ptr = &(*entry_ptr).flag as *const portable_atomic::AtomicBool;
5938 #[allow(unused_mut)]
5939 let mut guard_handle = GuardCondition::new(flag_ptr);
5940 // Phase 124.B.5 — wire the wake callback so trigger()
5941 // also signals the executor's wake_cv.
5942 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5943 {
5944 let ctx = self.wake_ctx_ptr();
5945 guard_handle.set_wake_cb(nros_rmw_runtime_wake_cb, ctx);
5946 }
5947
5948 let meta = CallbackMeta {
5949 offset,
5950 kind: EntryKind::GuardCondition,
5951 try_process: guard_try_process::<F>,
5952 has_data: guard_has_data::<F>,
5953 pre_sample: no_pre_sample,
5954 invocation: InvocationMode::OnNewData,
5955 drop_fn: drop_entry::<GuardConditionEntry<F>>,
5956 };
5957 self.emplace_entry(slot, meta, TraceName::Slot("guard", slot));
5958
5959 Ok((HandleId(slot), guard_handle))
5960 }
5961 }
5962
5963 // ========================================================================
5964 // Timer control methods
5965 // ========================================================================
5966
5967 /// Cancel a timer. A cancelled timer will not fire but still accumulates
5968 /// elapsed time. The timer can be restarted with [`reset_timer()`](Self::reset_timer).
5969 pub fn cancel_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
5970 let meta = self
5971 .entries
5972 .get(id.0)
5973 .and_then(|e| e.as_ref())
5974 .ok_or(NodeError::BufferTooSmall)?;
5975 if !matches!(meta.kind, EntryKind::Timer) {
5976 return Err(NodeError::BufferTooSmall);
5977 }
5978 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5979 // SAFETY: meta.offset points to a valid TimerEntry<F> which shares
5980 // layout with TimerHeader for its initial fields (both #[repr(C)]).
5981 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
5982 header.cancelled = true;
5983 Ok(())
5984 }
5985
5986 /// Reset a timer. Clears the cancelled state and resets the elapsed time
5987 /// to zero, so the timer starts a fresh period.
5988 pub fn reset_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
5989 let meta = self
5990 .entries
5991 .get(id.0)
5992 .and_then(|e| e.as_ref())
5993 .ok_or(NodeError::BufferTooSmall)?;
5994 if !matches!(meta.kind, EntryKind::Timer) {
5995 return Err(NodeError::BufferTooSmall);
5996 }
5997 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5998 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
5999 header.cancelled = false;
6000 header.elapsed_us = 0;
6001 Ok(())
6002 }
6003
6004 /// Check if a timer is cancelled.
6005 pub fn timer_is_canceled(&self, id: HandleId) -> bool {
6006 let meta = match self.entries.get(id.0).and_then(|e| e.as_ref()) {
6007 Some(m) if matches!(m.kind, EntryKind::Timer) => m,
6008 _ => return false,
6009 };
6010 let arena_ptr = self.arena.as_ptr() as *const u8;
6011 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
6012 header.cancelled
6013 }
6014
6015 /// Get the period of a timer in milliseconds (truncated — see
6016 /// [`Self::timer_period_us`]), or `None` if the handle is not a
6017 /// valid timer.
6018 pub fn timer_period_ms(&self, id: HandleId) -> Option<u64> {
6019 let meta = self
6020 .entries
6021 .get(id.0)
6022 .and_then(|e| e.as_ref())
6023 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
6024 let arena_ptr = self.arena.as_ptr() as *const u8;
6025 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
6026 Some(header.period_us / 1000)
6027 }
6028
6029 /// Get the period of a timer in microseconds, or `None` if the
6030 /// handle is not a valid timer.
6031 pub fn timer_period_us(&self, id: HandleId) -> Option<u64> {
6032 let meta = self
6033 .entries
6034 .get(id.0)
6035 .and_then(|e| e.as_ref())
6036 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
6037 let arena_ptr = self.arena.as_ptr() as *const u8;
6038 // SAFETY: same layout invariant as `timer_period_ms`.
6039 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
6040 Some(header.period_us)
6041 }
6042
6043 /// Set a timer's overrun policy (issue #505). Timers default to
6044 /// [`TimerOverrunPolicy::Skip`]; switch to
6045 /// [`TimerOverrunPolicy::CatchUp`] for timers whose every activation
6046 /// is a unit of work that must not be lost.
6047 pub fn set_timer_overrun_policy(
6048 &mut self,
6049 id: HandleId,
6050 policy: TimerOverrunPolicy,
6051 ) -> Result<(), NodeError> {
6052 let meta = self
6053 .entries
6054 .get(id.0)
6055 .and_then(|e| e.as_ref())
6056 .ok_or(NodeError::BufferTooSmall)?;
6057 if !matches!(meta.kind, EntryKind::Timer) {
6058 return Err(NodeError::BufferTooSmall);
6059 }
6060 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
6061 // SAFETY: same layout invariant as `cancel_timer`.
6062 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
6063 header.overrun_policy = policy;
6064 Ok(())
6065 }
6066
6067 /// Periods this timer missed and dropped under
6068 /// [`TimerOverrunPolicy::Skip`] (issue #505), or `None` if the handle
6069 /// is not a valid timer.
6070 ///
6071 /// Monotonic and saturating. A growing count is the on-target signal
6072 /// that a tier is not keeping up with its declared cadence — the
6073 /// symptom an external observer would otherwise have to infer by
6074 /// differencing timestamps.
6075 pub fn timer_overruns(&self, id: HandleId) -> Option<u32> {
6076 let meta = self
6077 .entries
6078 .get(id.0)
6079 .and_then(|e| e.as_ref())
6080 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
6081 let arena_ptr = self.arena.as_ptr() as *const u8;
6082 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
6083 Some(header.overruns)
6084 }
6085
6086 // ========================================================================
6087 // spin_once (three-phase: readiness -> trigger -> dispatch)
6088 // ========================================================================
6089
6090 /// Drive I/O and dispatch registered callbacks once.
6091 ///
6092 /// Three-phase execution:
6093 /// 1. **Readiness scan** — query each handle's `has_data()`.
6094 /// 2. **Trigger evaluation** — check if the executor-level trigger passes.
6095 /// 3. **Dispatch** — invoke callbacks according to their `InvocationMode`.
6096 ///
6097 /// Returns a [`SpinOnceResult`] with counts of processed items and errors.
6098 ///
6099 /// # Arguments
6100 /// * `timeout` — upper bound on the I/O wait. Saturated at
6101 /// `i32::MAX` ms (~24 days) for the underlying transport call.
6102 ///
6103 /// Phase 84.D7: unified on `core::time::Duration`. The previous
6104 /// `timeout_ms: i32` signature had a latent footgun where
6105 /// `spin_once(-1)` silently froze timers while still polling I/O;
6106 /// `Duration` has no negative sentinel.
6107 /// Consecutive `drive_io` failures on the primary session (issue 0324).
6108 ///
6109 /// `0` means the last drive succeeded. A value that keeps climbing across
6110 /// spins is a session that is no longer doing I/O — router gone, lease
6111 /// expired, socket closed — which otherwise presents as a node that spins
6112 /// `Ok(())` forever while publishing nowhere and firing no callbacks.
6113 ///
6114 /// Transient failures happen, so a single non-zero reading is not a fault;
6115 /// a threshold (say, "more than a few consecutive spins") is the useful
6116 /// signal. Extra (bridge / multi-domain) sessions are best-effort and are
6117 /// deliberately NOT counted here.
6118 pub fn session_io_failures(&self) -> u32 {
6119 self.consecutive_io_failures
6120 }
6121
6122 /// Whether the primary session drove I/O successfully on the last spin.
6123 ///
6124 /// Convenience over [`Self::session_io_failures`] for the common
6125 /// "is my transport alive?" check.
6126 pub fn session_io_healthy(&self) -> bool {
6127 self.consecutive_io_failures == 0
6128 }
6129
6130 pub fn spin_once(&mut self, timeout: core::time::Duration) -> SpinOnceResult {
6131 let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32;
6132
6133 // phase-425 W3b — bring the `/clock` subscription in line with
6134 // `use_sim_time`. One bool comparison in the settled case; the work only
6135 // happens on a transition, or while a request is waiting for its first
6136 // node to exist.
6137 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
6138 self.reconcile_ros_time_source();
6139
6140 // Release jitter, measured HERE rather than in `spin_period`, because
6141 // this is the function every driver goes through.
6142 //
6143 // The first version instrumented `spin_period` only, and that was the
6144 // same mistake issue 0736 records one layer down: a measurement placed
6145 // on a path no shipped image takes. `nros-cpp`'s tier trampoline paces
6146 // itself with a `spin_once` loop at `spin_period_us` (see its
6147 // `run_components` docs, step 4), so `spin_period` is not what the C++
6148 // entry -- and therefore not what the Zephyr lane -- actually calls.
6149 // The field recorded zero forever while claiming to measure cadence.
6150 //
6151 // `timeout` is the caller's intended pacing quantum: `spin_period`
6152 // passes its period, and the tier loop passes `spin_period_us`. A wake
6153 // that arrives later than that is late by the difference, which is the
6154 // `cyclictest` quantity. A zero timeout means "poll, no cadence
6155 // claimed", so it is not judged.
6156 self.record_release_jitter(timeout);
6157
6158 // issue 0900 — one-shot advisory when the arena is far larger than what
6159 // registered. First spin rather than "end of registration", because
6160 // there is no such point: an app may register lazily.
6161 super::arena::maybe_report_arena_headroom(self.arena_used, self.arena.len());
6162
6163 // phase-412 -- the same moment, recorded where a board with no log sink
6164 // can still be asked. Reaching this stage is the positive result the
6165 // advisory above cannot deliver on the island: registration completed.
6166 crate::boot_report::checkpoint(crate::boot_report::Stage::FirstSpin);
6167
6168 // Phase 110.0 — cap against the backend's next internal-event
6169 // deadline (lease keepalive, heartbeat, ACK-NACK timeout, ...).
6170 // Default backend impl returns `None`, so this is a no-op
6171 // unless the active backend opts in.
6172 #[allow(unused_variables)]
6173 let timeout_ms = match self.session.next_deadline_ms() {
6174 Some(next) => timeout_ms.min(next.min(i32::MAX as u32) as i32),
6175 None => timeout_ms,
6176 };
6177
6178 // Wall-clock-accurate timer accumulation. Measure real time
6179 // since the previous `spin_once` exited (or, on the first call,
6180 // since `drive_io` started). Two failure modes the requested
6181 // `timeout_ms` doesn't capture:
6182 // 1. `drive_io` returns early — e.g. zenoh-pico's condvar wakes
6183 // on data arrival, well under 1 ms.
6184 // 2. The caller spends time outside `spin_once` (explicit sleep,
6185 // ROS-2 cooperative scheduling, etc.) and that time should
6186 // still count toward timers.
6187 // Crediting the requested timeout to timers in either case ticks
6188 // them faster than wall-clock — observed as a 30 Hz control loop
6189 // overshooting to >200 Hz under sustained traffic. Carry the
6190 // sub-ms remainder across calls so precision is preserved.
6191 // phase-359 W4 — the spin-ENTRY read, one spelling. Kept distinct from
6192 // the post-IO read below because the first spin's delta is measured from
6193 // entry; collapsing them would make that delta 0 on the first call only.
6194 let spin_start_us = self.now_us();
6195
6196 // RFC-0052 W3b.4 — contract monitors tick once per spin (window
6197 // logic inside; single branch when the baked table is empty).
6198 self.run_contract_monitors();
6199
6200 // Phase 104.C.6 — shared executor wake. Swap-and-clear the
6201 // wake flag; if it was set before this `spin_once` entered,
6202 // skip the blocking wait on the primary session and poll
6203 // every session non-blockingly. Lets a wake signal from any
6204 // thread (or, post-104.C.6.b, any backend's vtable hook)
6205 // pre-empt whichever session the executor would otherwise
6206 // sleep on. Cost on the no-wake path is one atomic swap.
6207 // phase-359 W10 — ONE swap, at `alloc` scope.
6208 //
6209 // This was `#[cfg(feature = "std")]` while the alloc wait arm did its
6210 // own swap further down. Merging the two arms made that two swaps of
6211 // one flag per spin, the first consuming what the second tested, so the
6212 // arm would have read `was_woken == false` forever. Deleting the outer
6213 // one instead broke a different thing: `wake_flag` is `alloc`-gated and
6214 // "spin_once consumes the wake flag" is an invariant of every alloc
6215 // build, but the merged arm only runs with `rmw-cffi` — so a
6216 // `std`-without-`rmw-cffi` build stopped clearing it, which
6217 // `test_wake_cleared_each_spin` catches. It belongs here, at the scope
6218 // of the field it reads.
6219 #[cfg(feature = "alloc")]
6220 #[allow(unused_variables)]
6221 let was_woken = self
6222 .wake_flag
6223 .swap(false, portable_atomic::Ordering::SeqCst);
6224
6225 // Phase 124.B.4 — condvar-blocked wait.
6226 //
6227 // RT contract:
6228 // * cv.wait_timeout_while: bounded by `timeout_ms`.
6229 // Predicate is O(1) — one atomic swap + Instant::now.
6230 // No allocation. PI-mutex consideration: wake_mu held
6231 // only during predicate check (microseconds);
6232 // contended worst-case = notify_all execution time
6233 // (~10s of µs).
6234 // * Backend's `set_wake_callback`-installed cb is called
6235 // on async data arrival from its transport-notify path
6236 // (worker thread, ISR-safe variant via 124.B.7). The
6237 // runtime cb writes wake_flag + signals wake_cv,
6238 // unblocking this loop sub-poll-period.
6239 // * Poll-only backends (XRCE, bare-metal) leave the slot
6240 // NULL; the cv wait still fires on its deadline, then
6241 // drive_io(0) drains whatever the backend's internal
6242 // poll has buffered. Equivalent to their pre-124
6243 // behaviour minus the blocking wait inside drive_io.
6244 //
6245 // Lost-wakeup safe: SeqCst flag write happens-before
6246 // notify, and the waiter checks the flag under wake_mu in
6247 // the predicate. If wake fires between drain and cv.wait
6248 // entry, the predicate sees flag=true on first eval and
6249 // exits immediately.
6250 // Phase 130.4 — only sleep in the wake wait when a backend actually
6251 // installed `set_wake_callback`. Poll-only backends (XRCE, current
6252 // Cyclone) leave the vtable slot NULL → `has_async_wake == false` →
6253 // drive_io for the caller's full timeout instead of sleeping in a
6254 // never-signaled wait that starves reliable retransmission (Phase
6255 // 127.C.4 root cause: the server's `send_response` flushes 100 ms once,
6256 // then a blind `wait_ms(100)` sleeps with zero session activity, so the
6257 // agent's ACK arrives into a stalled session and reliable redelivery
6258 // never fires).
6259 //
6260 // Phase 248 (C2) — which primitive is a RUNTIME choice from the
6261 // platform vtable's wake probe, not a compile-time per-RTOS `cfg`:
6262 // `node_wake.is_some()` means `nros_platform_wake_*` is linked and
6263 // `nros_rmw_runtime_wake_cb` signals it on transport arrival.
6264 // phase-359 W10 — ONE arm. This was a `std` arm and an `alloc` arm with
6265 // the same body: block on the platform wake primitive when a backend
6266 // installed the callback and nothing has woken us yet, else drive the
6267 // transport for the caller's full timeout. They stopped differing when
6268 // the condvar fallback was deleted — the std arm's `else` branch had
6269 // already become "drive for the full timeout", which is what the alloc
6270 // arm always did — so keeping two was keeping a fork that agreed.
6271 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6272 let primary_drive_timeout_ms = {
6273 if !was_woken
6274 && self.has_async_wake
6275 && let Some(wake) = self.node_wake.as_ref()
6276 {
6277 let _ = wake.wait_ms(timeout_ms as u32);
6278 // Drain any flag the cb set while we were waiting.
6279 let _ = self
6280 .wake_flag
6281 .swap(false, portable_atomic::Ordering::SeqCst);
6282 0
6283 } else {
6284 // No wake primitive linked, or a poll-only backend: drive the
6285 // transport for the full timeout. It still BLOCKS — in the
6286 // transport's own recv — which is where every flavour ended up
6287 // anyway.
6288 timeout_ms
6289 }
6290 };
6291
6292 // std builds without rmw-cffi (mock-session tests, future alternative
6293 // backends) keep the original "drive_io is non-blocking" assumption.
6294 // `std` implies `alloc`, so this is the only std configuration the arm
6295 // above does not cover.
6296 #[cfg(all(feature = "std", not(feature = "rmw-cffi")))]
6297 let primary_drive_timeout_ms = 0;
6298
6299 // no_std without (alloc + rmw-cffi) keeps the legacy
6300 // full-timeout drive_io call.
6301 #[cfg(all(
6302 not(feature = "std"),
6303 not(all(feature = "alloc", feature = "rmw-cffi"))
6304 ))]
6305 let primary_drive_timeout_ms = timeout_ms;
6306
6307 // issue 0324 — the primary session's I/O result is TRACKED, not
6308 // discarded. See `consecutive_io_failures` for why this is a counter
6309 // rather than an early return.
6310 match self.session.drive_io(primary_drive_timeout_ms) {
6311 Ok(()) => self.consecutive_io_failures = 0,
6312 Err(_) => self.consecutive_io_failures = self.consecutive_io_failures.saturating_add(1),
6313 }
6314 for extra in self.extra_sessions.iter_mut() {
6315 // Best-effort BY DESIGN: extra sessions are bridge / multi-domain
6316 // attachments, and one of them failing must not stall the primary
6317 // spin. The old code expressed this the same way it expressed the
6318 // primary's discarded error — identically — so the intent was
6319 // invisible. It is now the only `let _ =` of the two.
6320 let _ = extra.drive_io(0);
6321 }
6322
6323 // phase-359 W4 — ONE clock read per spin, shared by the delta below and
6324 // every consumer further down. Replaces the std/no_std delta pair AND
6325 // two ad-hoc `static EPOCH: OnceLock<Instant>` blocks that each kept
6326 // their own std-only epoch.
6327 let now_us_this_spin = self.now_us();
6328
6329 // Same rule on both flavours: measure elapsed when a clock exists, else
6330 // credit the REQUESTED timeout. That fallback was previously reachable
6331 // only on no_std; on std `Instant` always answered. It stays unreachable
6332 // on std for the same reason — `now_us()` is infallible there — so this
6333 // is one expression, not a behaviour change.
6334 let delta_us = match now_us_this_spin {
6335 Some(now) => {
6336 let prev = self
6337 .last_spin_end_us
6338 .unwrap_or_else(|| spin_start_us.unwrap_or(now));
6339 self.last_spin_end_us = Some(now);
6340 now.saturating_sub(prev)
6341 }
6342 None => (timeout_ms as u64).saturating_mul(1000),
6343 };
6344
6345 if !self.spin_quantization_checked && timeout_ms > 0 {
6346 self.spin_quantization_checked = true;
6347 self.audit_spin_quantization((timeout_ms as u64).saturating_mul(1000));
6348 }
6349
6350 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
6351
6352 // Phase 1: Readiness scan (Phase 110.A.b — backed by FifoReadySet).
6353 //
6354 // `bits` carries data-readiness only (used by trigger eval +
6355 // by `InvocationMode::OnNewData`). `always_mask` carries the
6356 // `InvocationMode::Always` entries that fire regardless of
6357 // data presence. The dispatcher drains
6358 // `FifoReadySet(bits | always_mask)` after the trigger
6359 // passes; `pop_next` yields registration order (lowest bit
6360 // first) so behavior is bit-identical to the pre-refactor
6361 // `for (i, meta) in entries.iter().enumerate()` loop.
6362 let mut bits: u64 = 0;
6363 let mut count: usize = 0;
6364 let mut non_timer_mask: u64 = 0;
6365 let mut always_mask: u64 = 0;
6366
6367 for (i, meta) in self.entries.iter().enumerate() {
6368 if let Some(meta) = meta {
6369 let data_ptr = unsafe { arena_ptr.add(meta.offset) as *const u8 };
6370 if unsafe { (meta.has_data)(data_ptr) } {
6371 bits |= 1u64 << i;
6372 }
6373 if !matches!(meta.kind, EntryKind::Timer | EntryKind::GuardCondition) {
6374 non_timer_mask |= 1u64 << i;
6375 }
6376 if matches!(meta.invocation, InvocationMode::Always) {
6377 always_mask |= 1u64 << i;
6378 }
6379 count += 1;
6380 }
6381 }
6382
6383 let snapshot = ReadinessSnapshot { bits, count };
6384
6385 // Phase 2: Trigger evaluation
6386 let trigger_passes = match &self.trigger {
6387 Trigger::Any => bits & non_timer_mask != 0 || non_timer_mask == 0,
6388 Trigger::All => bits & non_timer_mask == non_timer_mask,
6389 Trigger::One(id) => snapshot.is_ready(*id),
6390 Trigger::AllOf(set) => snapshot.all_ready(*set),
6391 Trigger::AnyOf(set) => snapshot.any_ready(*set),
6392 Trigger::Always => true,
6393 Trigger::Predicate(f) => f(&snapshot),
6394 Trigger::RawPredicate { callback, context } => {
6395 // Convert ReadinessSnapshot bitmask to a bool array for the C callback
6396 let mut ready_array = [false; 64];
6397 for (i, slot) in ready_array
6398 .iter_mut()
6399 .enumerate()
6400 .take(snapshot.count.min(64))
6401 {
6402 *slot = snapshot.bits & (1u64 << i) != 0;
6403 }
6404 // SAFETY: The callback and context are provided by the C API caller.
6405 // The ready_array is valid for snapshot.count elements.
6406 unsafe { callback(ready_array.as_ptr(), snapshot.count, *context) }
6407 }
6408 };
6409
6410 if !trigger_passes {
6411 // Timers still need delta accumulation even when trigger doesn't pass
6412 //
6413 // phase-8 — `.enumerate()` (rather than a bare `.flatten()`) because
6414 // `try_process` now carries the entry's slot index for the callback
6415 // trace hooks. This sweep FIRES timer callbacks, so it is a real
6416 // dispatch path and must attribute them like the drain below does.
6417 for (i, meta) in self
6418 .entries
6419 .iter()
6420 .enumerate()
6421 .filter_map(|(i, e)| e.as_ref().map(|m| (i, m)))
6422 {
6423 if matches!(meta.kind, EntryKind::Timer) {
6424 let data_ptr = unsafe { arena_ptr.add(meta.offset) };
6425 let _ = unsafe { (meta.try_process)(data_ptr, delta_us, i as u8) };
6426 }
6427 }
6428
6429 // Parameter services live outside the arena and must be processed
6430 // regardless of trigger state, otherwise ROS 2 param queries time out.
6431 #[cfg(feature = "param-services")]
6432 {
6433 let mut handled = 0usize;
6434 if let Some(params) = &mut self.params {
6435 {
6436 let crate::parameter_services::ParamState {
6437 server, services, ..
6438 } = &mut **params;
6439 if let Some(services) = services {
6440 handled = services.process_services(server).unwrap_or(0);
6441 }
6442 }
6443 }
6444 // phase-425 W3b — a `ros2 param set … use_sim_time true` arrives
6445 // through this path.
6446 self.note_param_services_ran(handled);
6447 }
6448
6449 // Same treatment for lifecycle services — `ros2 lifecycle get`
6450 // must succeed even when no callbacks fired this tick.
6451 // SAFETY: see the matching invariant on the later call site.
6452 #[cfg(feature = "lifecycle-services")]
6453 if let Some(lc) = &mut self.lifecycle {
6454 let crate::lifecycle_services::LifecycleRuntimeState {
6455 state_machine,
6456 services,
6457 } = &mut **lc;
6458 let _ = unsafe { services.process_services(state_machine) };
6459 }
6460
6461 return SpinOnceResult::new();
6462 }
6463
6464 // Phase 2.5: LET pre-sample (only when LogicalExecutionTime)
6465 //
6466 // Sample all subscription data into entry buffers BEFORE dispatching
6467 // any callbacks. This ensures all callbacks in this cycle see a
6468 // consistent snapshot of data from the same point in time.
6469 // Services are NOT pre-sampled (request-reply is sequential).
6470 if matches!(self.semantics, ExecutorSemantics::LogicalExecutionTime) {
6471 for meta in self.entries.iter().flatten() {
6472 if matches!(meta.kind, EntryKind::Subscription) {
6473 let data_ptr = unsafe { arena_ptr.add(meta.offset) };
6474 unsafe { (meta.pre_sample)(data_ptr) };
6475 }
6476 }
6477 }
6478
6479 // Phase 3: Dispatch (Phase 110.C — bucketed by SC.priority).
6480 //
6481 // Two ready-set families, each split across `Priority::COUNT`
6482 // buckets (Critical / Normal / BestEffort). Per-entry SC
6483 // `class` selects FIFO bitmap vs EDF heap; SC `priority`
6484 // selects the bucket within. Drain order:
6485 // for each bucket in priority order (Critical first):
6486 // drain EDF heap (deadline-priority), then FIFO bitmap
6487 // (registration-order)
6488 // Default workloads — every entry on the auto-default Fifo SC
6489 // (Normal priority) — populate only `fifo[Normal]`, so
6490 // dispatch order is bit-identical to 110.B.b for those.
6491 const NB: usize = super::sched_context::Priority::COUNT;
6492 let mut result = SpinOnceResult::new();
6493 let mut fifo: super::ready_set::BucketedFifoSet<NB, { MAX_CALLBACK_SLOTS }> =
6494 super::ready_set::BucketedFifoSet::new();
6495 let mut edf: super::ready_set::BucketedEdfSet<NB, { MAX_CALLBACK_SLOTS }> =
6496 super::ready_set::BucketedEdfSet::new();
6497 let active_mask = bits | always_mask;
6498
6499 // Phase 110.E — refill any Sporadic SC budgets at period
6500 // boundaries before deciding what to dispatch this cycle.
6501 // Refill is polled (not ISR-driven) — coarse but correct
6502 // upper-bound bandwidth limiter.
6503 // phase-359 W4 — was `#[cfg(feature = "std")]` with NO no_std arm, so
6504 // polled Sporadic budgets never refilled on embedded: they exhausted
6505 // once and stayed exhausted. That was a consequence of "no clock on
6506 // no_std", which is false whenever a `clock_us` hook is injected. Now it
6507 // runs on either flavour when a clock exists, and is skipped when none
6508 // does — identical to today's behaviour in that case.
6509 if let Some(now_us) = now_us_this_spin {
6510 let now_ms = now_us / 1000;
6511 // issue 0736 — REFILL only. This used to also charge the cycle's
6512 // `delta_us` as the per-SC consumption estimate ("worst-case
6513 // attribution", pending a higher-precision clock hook). That hook
6514 // landed, and the attribution was not merely coarse: `delta_us` is
6515 // the wall-clock gap between spins, not CPU the callbacks spent, so
6516 // wherever the gap exceeds the budget the SC is exhausted on every
6517 // spin regardless of what it ran. Consumption is charged from
6518 // measured callback runtime in `consume_dispatch_runtime_us`.
6519 for slot in self.sporadic_states.iter_mut().flatten() {
6520 let _ = slot.refill(now_ms);
6521 }
6522 }
6523
6524 for i in 0..self.entries.len() {
6525 if active_mask & (1u64 << i) == 0 {
6526 continue;
6527 }
6528 let sc_idx = self.sched_context_bindings[i].0 as usize;
6529 let sc_class_priority_deadline = self
6530 .sched_contexts
6531 .get(sc_idx)
6532 .and_then(|s| s.as_ref())
6533 .map(|sc| {
6534 (
6535 sc.class,
6536 sc.priority.index(),
6537 sc.deadline_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX),
6538 )
6539 });
6540 let (sc_class, bucket, deadline_us) = sc_class_priority_deadline.unwrap_or((
6541 super::sched_context::SchedClass::Fifo,
6542 super::sched_context::Priority::Normal.index(),
6543 u32::MAX,
6544 ));
6545 // Phase 110.E — Sporadic SC dispatch is suppressed when
6546 // its budget is exhausted. Atomic path (110.E.b PlatformTimer
6547 // refill) takes precedence when registered; polled path
6548 // (cycle-level delta_us attribution) handles the unregistered
6549 // case. Either way, exhausted budget skips dispatch.
6550 if matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
6551 #[cfg(feature = "alloc")]
6552 let atomic_has_budget = self
6553 .sporadic_atomic_states
6554 .get(sc_idx)
6555 .and_then(|s| s.as_ref())
6556 .map(|(state, _)| state.has_budget());
6557 #[cfg(not(feature = "alloc"))]
6558 let atomic_has_budget: Option<bool> = None;
6559 let has_budget = match atomic_has_budget {
6560 Some(b) => b,
6561 None => self
6562 .sporadic_states
6563 .get(sc_idx)
6564 .and_then(|s| s.as_ref())
6565 .map(|s| s.budget_remaining_us > 0)
6566 .unwrap_or(true),
6567 };
6568 if !has_budget {
6569 // issue 0736 — keep the TIMER'S CLOCK honest even while the
6570 // budget gates its dispatch.
6571 //
6572 // A timer's `elapsed_us` only advances inside
6573 // `timer_try_process`, which is reached only when the entry
6574 // is DISPATCHED, and each dispatch is handed just THIS
6575 // cycle's `delta_us`. So every cycle skipped here was
6576 // dropped from the timer's sense of time: it did not fire,
6577 // it did not learn that its period had passed, and — because
6578 // the overrun counter is driven by that same `elapsed_us` —
6579 // it did not count the activation it missed.
6580 //
6581 // That is why the throttle was invisible. Measured on
6582 // nuttx-arm/rust: ~238 activations lost against ~40
6583 // reported, so five in six missed firings were unaccounted
6584 // ANYWHERE. The tier ran at a quarter of its declared rate
6585 // and the only honest signal was the delivery count itself.
6586 //
6587 // Advancing the clock here does not dispatch anything and
6588 // does not defeat the budget. It makes the entry's own
6589 // accounting independent of whether the executor CHOSE to
6590 // run it, which is the property every "did this meet its
6591 // declaration?" question needs, and it lets the existing
6592 // `Skip` policy count the backlog and the existing
6593 // `timer-overrun-runtime` rule report it.
6594 if let Some(meta) = self.entries[i].as_ref()
6595 && matches!(meta.kind, EntryKind::Timer)
6596 {
6597 // SAFETY: a Timer entry's arena slot holds a
6598 // `TimerEntry<F>`, whose leading layout IS
6599 // `TimerHeader` — the same cast the overrun reporter
6600 // one screen up already relies on.
6601 let header =
6602 unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
6603 header.elapsed_us = header.elapsed_us.saturating_add(delta_us);
6604 }
6605 // issue 0736 — a budget skip used to be a bare `continue`,
6606 // which is the silent-drop shape 0737 gated one layer out:
6607 // the entry is simply not dispatched and nothing anywhere
6608 // says so, so a permanently starved tier is
6609 // indistinguishable from an idle one. It took a hand-run
6610 // 45 s image and a per-executor probe to learn that this
6611 // line was skipping 96 % of one tier's spins.
6612 // The rolling window catches a budget that THROTTLES; the
6613 // streak below catches one that starves outright. issue
6614 // 0736 needed both: the measured case skipped ~3 spins in
6615 // 4 without ever reaching a streak, so the tier ran at a
6616 // quarter of its declared rate in silence.
6617 if let Some((skips, total)) = self
6618 .sporadic_states
6619 .get_mut(sc_idx)
6620 .and_then(|st| st.as_mut())
6621 .and_then(|st| st.take_budget_window())
6622 {
6623 nros_log::nros_warn!(
6624 nros_log::get_logger("nros"),
6625 "sporadic budget throttled sched context {}: {} of the last {} dispatch opportunities were skipped for want of budget. The declared budget_us/period_us cannot sustain this tier's callbacks on this target.",
6626 sc_idx,
6627 skips,
6628 total
6629 );
6630 }
6631 if let Some(streak) = self
6632 .sporadic_states
6633 .get_mut(sc_idx)
6634 .and_then(|st| st.as_mut())
6635 .and_then(|st| st.note_budget_skip())
6636 {
6637 nros_log::nros_warn!(
6638 nros_log::get_logger("nros"),
6639 "sporadic budget exhausted for {} consecutive spins (sched context {}): its callbacks are not being dispatched. Either the callback runtime exceeds the declared budget_us per period_us, or the declaration is unsatisfiable on this target.",
6640 streak,
6641 sc_idx
6642 );
6643 }
6644 continue;
6645 }
6646 // Phase 110.E.b follow-up — per-callback runtime
6647 // accounting (replaces this cycle-level attribution)
6648 // is applied at dispatch time below via
6649 // `consume_dispatch_runtime_us`. We only update the
6650 // polled-path `SporadicState` (no_std fallback) here
6651 // because the atomic path now records actual
6652 // wall-clock per-callback runtime. The
6653 // `delta_us` over-attribution that previously hit the
6654 // atomic state was a worst-case bandwidth limiter;
6655 // per-callback measurement is strictly tighter.
6656 #[cfg(not(feature = "alloc"))]
6657 {
6658 let _ = sc_idx; // polled-state path lives in
6659 // `sporadic_states`; this branch is a no-op when
6660 // the atomic path is enabled.
6661 }
6662 }
6663 // Phase 110.F — per-callback OS priority routing. Entries
6664 // bound to an SC with `os_pri > 0` dispatch onto a worker
6665 // thread the OS has elevated to that priority; the
6666 // cooperative path is skipped for those entries. Workers
6667 // are spawned lazily.
6668 #[cfg(all(
6669 feature = "alloc",
6670 feature = "rmw-cffi",
6671 feature = "scheduler-os-priority"
6672 ))]
6673 {
6674 let os_pri = self
6675 .sched_contexts
6676 .get(sc_idx)
6677 .and_then(|s| s.as_ref())
6678 .map(|sc| sc.os_pri)
6679 .unwrap_or(0);
6680 if os_pri > 0
6681 && let Some(apply_policy) = self.os_priority_apply_policy
6682 && let Some(meta) = self.entries[i].as_ref()
6683 {
6684 let item = super::os_priority::WorkItem {
6685 arena_base: arena_ptr as usize,
6686 arena_offset: meta.offset,
6687 try_process: meta.try_process,
6688 delta_us,
6689 // phase-8 — the worker runs the leaf on a DIFFERENT
6690 // thread, so it has to carry the slot index with it;
6691 // nothing on the far side can recover it from the
6692 // arena address alone.
6693 desc_idx: i as u8,
6694 };
6695 // phase-359 W10 — `try_dispatch` now reports whether the
6696 // entry was actually handed to a worker. It can decline:
6697 // the mailbox is bounded, the pool is capacity-limited, and
6698 // a platform without a wake primitive hosts no workers at
6699 // all. Previously the spawn was infallible (`.expect`) and
6700 // an unbounded `mpsc` never refused, so this branch always
6701 // `continue`d. Falling through to the cooperative path is
6702 // the honest answer to a decline — the callback still runs,
6703 // just without the OS priority guarantee, which is exactly
6704 // what an entry got before this feature existed.
6705 if self
6706 .os_priority_pool
6707 .try_dispatch(os_pri, apply_policy, item)
6708 {
6709 continue;
6710 }
6711 }
6712 }
6713 // Phase 110.G — TT window gate, orthogonal to class.
6714 // Skips dispatch when the SC has a TT window AND the
6715 // current monotonic time is outside it. Both gates apply
6716 // independently — a Sporadic SC with a TT window must
6717 // pass both.
6718 if self.major_frame_us > 0 {
6719 let sc_opt = self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref());
6720 if let Some(sc) = sc_opt {
6721 let off = sc.tt_window_offset_us.get().map(|nz| nz.get()).unwrap_or(0);
6722 let dur = sc
6723 .tt_window_duration_us
6724 .get()
6725 .map(|nz| nz.get())
6726 .unwrap_or(0);
6727 if dur > 0 {
6728 // Compute current phase within the major
6729 // frame using the accumulated `delta_us` clock
6730 // (std-only precise; no_std uses `delta_us`
6731 // approximation from spin cadence).
6732 // phase-359 W4 — the shared read. The no_std arm used
6733 // to be `now_us = delta_us`, i.e. a per-spin INTERVAL
6734 // used as an absolute phase clock, which the comment
6735 // above called an approximation. It is now the real
6736 // clock when one is injected, and falls back to the old
6737 // approximation only when none is.
6738 let now_us = now_us_this_spin.unwrap_or(delta_us);
6739 let phase = (now_us % self.major_frame_us as u64) as u32;
6740 let in_window = if off + dur <= self.major_frame_us {
6741 phase >= off && phase < off + dur
6742 } else {
6743 // Window wraps the major frame boundary.
6744 let end = (off as u64 + dur as u64) % self.major_frame_us as u64;
6745 phase >= off || (phase as u64) < end
6746 };
6747 if !in_window {
6748 continue;
6749 }
6750 }
6751 }
6752 }
6753 let is_edf = matches!(sc_class, super::sched_context::SchedClass::Edf);
6754 let job = super::types::ActiveJob {
6755 sort_key: if is_edf { deadline_us } else { i as u32 },
6756 desc_idx: i as super::types::DescIdx,
6757 };
6758 if is_edf {
6759 let _ = edf.insert_into(bucket, job);
6760 } else {
6761 let _ = fifo.insert_into(bucket, job);
6762 }
6763 }
6764
6765 // SAFETY: each `desc_idx` we pop was set above only when the
6766 // corresponding `entries[i]` slot was `Some`; no Executor
6767 // mutation happens between that scan and this dispatch.
6768 let dispatch_one = |meta: &CallbackMeta,
6769 desc_idx: usize,
6770 arena_ptr: *mut u8,
6771 delta_us: u64,
6772 result: &mut SpinOnceResult| {
6773 // Phase 141.B.2 — capture T1 at subscription dispatch
6774 // entry. Probe pairs it with the most recent T0 from
6775 // `nros_rmw_runtime_wake_cb` (std + alloc variants)
6776 // and pushes `T1 - T0` onto the ring buffer 141.C
6777 // drains. No-op when the probe feature is off or
6778 // no cycle reader is installed. Other entry kinds
6779 // (Service / Timer / GuardCondition) skip the probe
6780 // because the 141 acceptance is specifically
6781 // wake-to-subscription-dispatch latency.
6782 #[cfg(feature = "wake-latency-probe")]
6783 if matches!(meta.kind, EntryKind::Subscription) {
6784 super::wake_probe::on_dispatch();
6785 }
6786 let data_ptr = unsafe { arena_ptr.add(meta.offset) };
6787 // phase-8 — `desc_idx` is the entry slot index, threaded through so
6788 // the leaf hooks in `arena.rs` can name the callback they bracket.
6789 // `MAX_CALLBACK_SLOTS` is 64 (enforced by the `u64` ready-set
6790 // bitmask), so the `as u8` cannot truncate a live slot.
6791 match unsafe { (meta.try_process)(data_ptr, delta_us, desc_idx as u8) } {
6792 Ok(true) => match meta.kind {
6793 EntryKind::Subscription => result.subscriptions_processed += 1,
6794 EntryKind::Service
6795 | EntryKind::ServiceClient
6796 | EntryKind::ActionServer
6797 | EntryKind::ActionClient => result.services_handled += 1,
6798 EntryKind::Timer => result.timers_fired += 1,
6799 EntryKind::GuardCondition => {}
6800 },
6801 Ok(false) => {}
6802 Err(_) => match meta.kind {
6803 EntryKind::Subscription => result.subscription_errors += 1,
6804 EntryKind::Service
6805 | EntryKind::ServiceClient
6806 | EntryKind::ActionServer
6807 | EntryKind::ActionClient => result.service_errors += 1,
6808 EntryKind::Timer | EntryKind::GuardCondition => {}
6809 },
6810 }
6811 };
6812
6813 // Phase 110.E.b follow-up — per-callback runtime accounting
6814 // for Sporadic SCs. Wall-clock-measure each dispatch and
6815 // consume the elapsed microseconds from the bound SC's
6816 // atomic budget. This replaces the cycle-level over-
6817 // attribution that previously charged the FULL `delta_us`
6818 // against every Sporadic SC regardless of which entries
6819 // actually fired — accurate per-callback measurement is the
6820 // shape the design doc's per-callback runtime acceptance
6821 // calls out.
6822 //
6823 // phase-359 W10 — this was `feature = "std"`-gated, because it "needs a
6824 // `core::time::Instant`-equivalent monotonic clock ... until a
6825 // board-side monotonic-microsecond accessor lands". That accessor
6826 // landed: `now_us()` reads the platform monotonic counter on every
6827 // flavour. So per-callback sporadic accounting is no longer something
6828 // only a hosted build gets, and the no_std fallback to polled
6829 // `SporadicState` cycle deltas is no longer the only option on target.
6830 let consume_dispatch_runtime_us =
6831 |desc_idx: usize,
6832 elapsed_us: u32,
6833 sched_context_bindings: &[super::sched_context::SchedContextId],
6834 sched_contexts: &[Option<super::sched_context::SchedContext>],
6835 sporadic_states: &mut [Option<super::sched_context::SporadicState>],
6836 #[cfg(feature = "alloc")] sporadic_atomic_states: &[Option<(
6837 portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
6838 OpaqueTimerHandle,
6839 )>]| {
6840 let sc_idx = sched_context_bindings[desc_idx].0 as usize;
6841 let sc_class = sched_contexts
6842 .get(sc_idx)
6843 .and_then(|s| s.as_ref())
6844 .map(|sc| sc.class)
6845 .unwrap_or(super::sched_context::SchedClass::Fifo);
6846 if !matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
6847 return;
6848 }
6849 #[cfg(feature = "alloc")]
6850 if let Some((state, _)) =
6851 sporadic_atomic_states.get(sc_idx).and_then(|s| s.as_ref())
6852 {
6853 state.consume(elapsed_us);
6854 // Unconditional, unlike the overrun record below: the
6855 // budget is sized FROM this number, so it has to exist
6856 // before the budget is right rather than only after it
6857 // is wrong.
6858 state.record_exec(elapsed_us);
6859 // Phase 110.E.b — overrun detection. Cooperative
6860 // single-thread can't preempt a runaway callback,
6861 // so post-dispatch wall-clock comparison delivers
6862 // the same observable signal as the design's
6863 // oneshot-IRQ-and-cancel pattern, without needing
6864 // a separate timer per SC. `budget_capacity_us` is
6865 // the per-period budget the SC was sized against;
6866 // any callback exceeding that has run past its
6867 // bandwidth allotment.
6868 if elapsed_us > state.budget_capacity_us {
6869 state.record_overrun(elapsed_us - state.budget_capacity_us);
6870 }
6871 }
6872 // issue 0736 — the POLLED state is charged unconditionally,
6873 // and it is the one that matters: `has_budget` prefers the
6874 // atomic state only when a refill timer was registered, which
6875 // no board or entry does. Before this, the alloc arm charged a
6876 // state nothing populated and the no_std arm discarded the
6877 // measurement outright (`let _ = (sc_idx, elapsed_us)`), so no
6878 // shipped image recorded per-callback runtime anywhere — while
6879 // the comment above claimed it did.
6880 if let Some(state) = sporadic_states.get_mut(sc_idx).and_then(|s| s.as_mut()) {
6881 state.consume(elapsed_us);
6882 }
6883 };
6884
6885 // W3b.5 — post-dispatch contract checks. `lat_active` gates the
6886 // per-dispatch publish-count snapshot (attribution of dispatch
6887 // elapsed time to monitored publishers whose counter advanced);
6888 // `dl_active` gates elapsed measurement for deadline actions. The third
6889 // term used to be `cfg!(feature = "std")` — "std measures anyway for
6890 // the sporadic path" — which made a COMPILE-TIME flavour stand in for
6891 // "is there a clock". It is now the runtime question it always was.
6892 let mon_table = self.monitor_table;
6893 let lat_active = mon_table.iter().any(|m| m.max_latency_ms > 0);
6894 let dl_active = self.sched_contexts.iter().flatten().any(|sc| {
6895 sc.deadline_us.is_some()
6896 && !matches!(
6897 sc.deadline_action,
6898 super::sched_context::DeadlineAction::Ignore
6899 )
6900 });
6901 let mon_clock = self.clock_us_fn;
6902 // phase-359 W4 — one predicate, no cfg block. `cfg!` is an expression,
6903 // so the flavour difference stays a value instead of a branch: std
6904 // measures unconditionally because the sporadic runtime accounting
6905 // below (itself std-only) consumes the result, which is exactly what
6906 // the comment above described and what the four-arm sites encoded.
6907 let measure_us = lat_active || dl_active || mon_clock.is_some();
6908 // phase-359 W4 — ONE hoisted µs reader for the per-dispatch latency
6909 // measurement below. Hoisted because `now_us()` takes `&mut self` and
6910 // the dispatch loop already holds borrows; the std arm copies the epoch
6911 // (a `Copy` `Instant`) so reading inside the loop touches no `self`.
6912 // This is the last cfg pair in the timing path: the two call sites it
6913 // serves each had FOUR arms (start + elapsed, per flavour).
6914 let read_us = move || mon_clock.map(|c| c()).unwrap_or(0);
6915 // Deferred deadline-miss violations (the loop body holds an
6916 // immutable borrow of `self.entries`, so the ring is fed after).
6917 let mut deadline_misses: heapless::Vec<
6918 super::monitor::Violation,
6919 { super::monitor::MAX_VIOLATIONS },
6920 > = heapless::Vec::new();
6921 // SCs whose remaining callbacks this cycle are skipped
6922 // (`DeadlineAction::Skip`) — bitmask over SC slots.
6923 let mut skipped_scs: u64 = 0;
6924
6925 // For each priority bucket (Critical → Normal → BestEffort),
6926 // drain EDF first then FIFO so an EDF callback in this bucket
6927 // beats a FIFO peer at the same priority, but no lower-priority
6928 // entry runs while a higher-priority bucket has work pending.
6929 // Strict static priority across buckets; non-preemptive within
6930 // an in-flight callback (see Phase 110.D).
6931 for bucket in 0..NB {
6932 while let Some(job) = edf.pop_from(bucket) {
6933 let i = job.desc_idx as usize;
6934 let sc_idx = self.sched_context_bindings[i].0 as usize;
6935 if sc_idx < 64 && skipped_scs & (1u64 << sc_idx) != 0 {
6936 continue; // W3b.5 DeadlineAction::Skip containment
6937 }
6938 if let Some(meta) = self.entries[i].as_ref() {
6939 let counts_before = snapshot_pub_counts(mon_table, lat_active);
6940 // Measured only when a consumer wants it, matching the
6941 // no_std arm this replaces — the std arm used to measure
6942 // unconditionally.
6943 let start_us = measure_us.then(&read_us);
6944 dispatch_one(meta, i, arena_ptr, delta_us, &mut result);
6945 let elapsed_us: Option<u32> = start_us
6946 .map(|t0| read_us().saturating_sub(t0))
6947 .map(|d| d.min(u32::MAX as u64) as u32);
6948 if let Some(elapsed_us) = elapsed_us {
6949 consume_dispatch_runtime_us(
6950 i,
6951 elapsed_us,
6952 &self.sched_context_bindings[..],
6953 &self.sched_contexts[..],
6954 &mut self.sporadic_states[..],
6955 #[cfg(feature = "alloc")]
6956 &self.sporadic_atomic_states[..],
6957 );
6958 }
6959 if let Some(elapsed_us) = elapsed_us {
6960 attribute_latency(mon_table, lat_active, &counts_before, elapsed_us);
6961 check_deadline_miss(
6962 self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref()),
6963 sc_idx,
6964 elapsed_us,
6965 &mut deadline_misses,
6966 &mut skipped_scs,
6967 self.fault_fn,
6968 );
6969 }
6970 }
6971 }
6972 while let Some(job) = fifo.pop_from(bucket) {
6973 let i = job.desc_idx as usize;
6974 let sc_idx = self.sched_context_bindings[i].0 as usize;
6975 if sc_idx < 64 && skipped_scs & (1u64 << sc_idx) != 0 {
6976 continue; // W3b.5 DeadlineAction::Skip containment
6977 }
6978 if let Some(meta) = self.entries[i].as_ref() {
6979 let counts_before = snapshot_pub_counts(mon_table, lat_active);
6980 // Measured only when a consumer wants it, matching the
6981 // no_std arm this replaces — the std arm used to measure
6982 // unconditionally.
6983 let start_us = measure_us.then(&read_us);
6984 dispatch_one(meta, i, arena_ptr, delta_us, &mut result);
6985 let elapsed_us: Option<u32> = start_us
6986 .map(|t0| read_us().saturating_sub(t0))
6987 .map(|d| d.min(u32::MAX as u64) as u32);
6988 if let Some(elapsed_us) = elapsed_us {
6989 consume_dispatch_runtime_us(
6990 i,
6991 elapsed_us,
6992 &self.sched_context_bindings[..],
6993 &self.sched_contexts[..],
6994 &mut self.sporadic_states[..],
6995 #[cfg(feature = "alloc")]
6996 &self.sporadic_atomic_states[..],
6997 );
6998 }
6999 if let Some(elapsed_us) = elapsed_us {
7000 attribute_latency(mon_table, lat_active, &counts_before, elapsed_us);
7001 check_deadline_miss(
7002 self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref()),
7003 sc_idx,
7004 elapsed_us,
7005 &mut deadline_misses,
7006 &mut skipped_scs,
7007 self.fault_fn,
7008 );
7009 }
7010 }
7011 }
7012 }
7013
7014 // W3b.5 — feed deferred deadline misses into the violation ring.
7015 for v in deadline_misses {
7016 if self.report_violations {
7017 super::monitor::log_violation(&v);
7018 }
7019 if self.monitor_violations.push(v).is_err() {
7020 self.monitor_violations_dropped = self.monitor_violations_dropped.saturating_add(1);
7021 }
7022 }
7023
7024 // Issue #505 — same ring, same cycle. This runs AFTER dispatch,
7025 // unlike the windowed rate/age/latency rules at the top of the
7026 // spin: the activations it reports were dropped by the dispatch
7027 // that just happened, and a tier that is stalling should not have
7028 // to wait for the next spin to say so.
7029 self.check_timer_overruns();
7030 self.check_release_jitter_rule();
7031 self.check_stack_headroom_rule();
7032
7033 // Process parameter services (outside the arena)
7034 #[cfg(feature = "param-services")]
7035 let mut handled = 0usize;
7036 #[cfg(feature = "param-services")]
7037 if let Some(params) = &mut self.params {
7038 {
7039 let crate::parameter_services::ParamState {
7040 server, services, ..
7041 } = &mut **params;
7042 if let Some(services) = services
7043 && let Ok(n) = services.process_services(server)
7044 {
7045 result.services_handled += n;
7046 handled = n;
7047 }
7048 }
7049 }
7050 #[cfg(feature = "param-services")]
7051 self.note_param_services_ran(handled);
7052
7053 // Process lifecycle services (outside the arena).
7054 //
7055 // SAFETY: `change_state` dispatches a user-supplied C callback through a
7056 // raw function pointer stored in `LifecyclePollingNodeCtx`. The caller
7057 // of `register_lifecycle_services` guarantees the callback/context pair
7058 // stays live for as long as the executor (see that method's docs).
7059 #[cfg(feature = "lifecycle-services")]
7060 if let Some(lc) = &mut self.lifecycle {
7061 let crate::lifecycle_services::LifecycleRuntimeState {
7062 state_machine,
7063 services,
7064 } = &mut **lc;
7065 if let Ok(n) = unsafe { services.process_services(state_machine) } {
7066 result.services_handled += n;
7067 }
7068 }
7069
7070 // Phase 258 (Track 2, 2a) — executor-owned component tick pass.
7071 // Mirrors `ExecutorNodeRuntime::run_ticks`: after the transport +
7072 // callbacks have been pumped, drive each enrolled component's `tick`
7073 // (service-client/action poll, etc.). `exec_ctx` hands the component
7074 // the whole executor as a raw `*mut Executor` so its tick can
7075 // reborrow it (the same disjoint-field raw-ptr pattern run_ticks
7076 // uses). Index-iterate over `Copy` slots so no borrow of
7077 // `self.component_slots` is held while `tick` runs (which aliases
7078 // `self` through `exec_ctx`).
7079 let exec_ctx = self as *mut Executor as *mut core::ffi::c_void;
7080 let slot_count = self.component_slots.len();
7081 for i in 0..slot_count {
7082 let slot = self.component_slots[i];
7083 // SAFETY: `slot.state` is the leaked cell the matching `tick`
7084 // expects (enrolled via `enroll_component`); `exec_ctx` is a live
7085 // `*mut Executor` for `self`. The slot was copied out, so no
7086 // borrow of `component_slots` is outstanding during the call.
7087 unsafe {
7088 (slot.tick)(slot.state, exec_ctx);
7089 }
7090 }
7091
7092 result
7093 }
7094
7095 /// Drive I/O and dispatch callbacks in an infinite loop.
7096 ///
7097 /// Each iteration calls [`spin_once(timeout_ms)`](Self::spin_once),
7098 /// which pumps the transport and dispatches all registered callbacks.
7099 ///
7100 /// This is the primary run loop for embedded applications:
7101 ///
7102 /// ```ignore
7103 /// let mut executor = Executor::open(&config)?;
7104 /// executor.register_subscription::<Int32, _>("/topic", |msg| { /* ... */ })?;
7105 /// executor.spin(10); // never returns
7106 /// ```
7107 pub fn spin(&mut self, timeout: core::time::Duration) -> ! {
7108 loop {
7109 self.spin_once(timeout);
7110 }
7111 }
7112
7113 /// Phase 104.C.3.3.c — rclcpp-`spin()`-shape no-arg variant.
7114 /// Defaults the per-iteration timeout to 50 ms, which keeps
7115 /// idle binaries from busy-spinning while staying responsive
7116 /// enough for default-QoS messaging.
7117 pub fn spin_default(&mut self) -> ! {
7118 self.spin(core::time::Duration::from_millis(50))
7119 }
7120
7121 /// Drive I/O and dispatch callbacks asynchronously.
7122 ///
7123 /// Runs forever, yielding between poll cycles so that other async tasks
7124 /// (e.g., [`Promise`](super::handles::Promise)) can make progress.
7125 ///
7126 /// Uses only `core::future` — no external async runtime dependency.
7127 ///
7128 /// # Usage patterns
7129 ///
7130 /// ```ignore
7131 /// // Pattern 1: select with a promise (embassy-futures)
7132 /// use embassy_futures::select::{select, Either};
7133 /// let promise = client.call(&req)?;
7134 /// let Either::Second(reply) = select(executor.spin_async(), promise).await
7135 /// else { unreachable!() };
7136 ///
7137 /// // Pattern 2: manual polling (no async runtime)
7138 /// let mut promise = client.call(&req)?;
7139 /// loop {
7140 /// executor.spin_once(core::time::Duration::from_millis(10));
7141 /// if let Ok(Some(r)) = promise.take() { break r; }
7142 /// }
7143 /// ```
7144 pub async fn spin_async(&mut self) -> ! {
7145 loop {
7146 self.spin_once(core::time::Duration::from_millis(1));
7147 core::future::poll_fn::<(), _>(|cx| {
7148 cx.waker().wake_by_ref();
7149 core::task::Poll::Pending
7150 })
7151 .await;
7152 }
7153 }
7154
7155 // ========================================================================
7156 // spin_one_period (no_std)
7157 // ========================================================================
7158
7159 /// Process one iteration and return remaining sleep time.
7160 ///
7161 /// This is `no_std` compatible — the caller is responsible for the actual
7162 /// delay using platform-specific sleep.
7163 ///
7164 /// # Arguments
7165 /// * `period_ms` - Target period in milliseconds
7166 /// * `elapsed_ms` - Time elapsed since last call (used for timer ticking)
7167 ///
7168 /// # Example
7169 ///
7170 /// ```ignore
7171 /// loop {
7172 /// let r = executor.spin_one_period(10, elapsed_ms);
7173 /// platform_sleep_ms(r.remaining_ms);
7174 /// }
7175 /// ```
7176 pub fn spin_one_period(&mut self, period_ms: u64, elapsed_ms: u64) -> SpinPeriodPollingResult {
7177 let result = self.spin_once(core::time::Duration::from_millis(elapsed_ms));
7178 SpinPeriodPollingResult {
7179 work: result,
7180 remaining_ms: period_ms.saturating_sub(elapsed_ms),
7181 }
7182 }
7183}
7184
7185// ============================================================================
7186// Parameter services (cfg param-services)
7187// ============================================================================
7188
7189#[cfg(feature = "param-services")]
7190impl<'s> Executor<'s> {
7191 /// Register the 6 ROS 2 parameter services for this node.
7192 ///
7193 /// Creates service servers for `get_parameters`, `set_parameters`,
7194 /// `set_parameters_atomically`, `list_parameters`, `describe_parameters`,
7195 /// and `get_parameter_types`.
7196 ///
7197 /// The service names follow the ROS 2 convention: `/{namespace}/{node_name}/{suffix}`.
7198 /// For the default namespace `/`, this becomes `/{node_name}/{suffix}` (e.g.
7199 /// `/sentinel/list_parameters`).
7200 ///
7201 /// Parameter services are stored outside the arena and don't consume
7202 /// callback slots.
7203 ///
7204 /// # Example
7205 ///
7206 /// ```ignore
7207 /// let config = ExecutorConfig::from_env().node_name("talker");
7208 /// let mut executor = Executor::open(&config)?;
7209 /// executor.register_parameter_services()?;
7210 /// executor.declare_parameter("start_value", ParameterValue::Integer(0));
7211 /// ```
7212 pub fn register_parameter_services(&mut self) -> Result<(), NodeError> {
7213 use crate::parameter_services::{
7214 DescribeParameters, GetParameterTypes, GetParameters, ListParameters,
7215 PARAM_SERVICE_BUFFER_SIZE, ParameterServiceServers, SetParameters,
7216 SetParametersAtomically,
7217 };
7218 use nros_core::RosService;
7219
7220 type PSrv<Svc> = super::handles::EmbeddedServiceServer<
7221 Svc,
7222 PARAM_SERVICE_BUFFER_SIZE,
7223 PARAM_SERVICE_BUFFER_SIZE,
7224 >;
7225
7226 // Build the node FQN from namespace + node_name, following ROS 2 convention.
7227 // Default namespace "/" → "/{node_name}"; otherwise "/{namespace}/{node_name}".
7228 let mut node_fqn = heapless::String::<256>::new();
7229 let ns: &str = &self.namespace;
7230 let nn: &str = &self.node_name;
7231 if ns.is_empty() || ns == "/" {
7232 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
7233 node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
7234 } else {
7235 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
7236 node_fqn
7237 .push_str(ns.trim_matches('/'))
7238 .map_err(|_| NodeError::NameTooLong)?;
7239 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
7240 node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
7241 }
7242
7243 /// Build a service name like `{node_fqn}/{suffix}` and create the server handle.
7244 fn create_param_srv<Svc: RosService>(
7245 session: &mut session::ConcreteSession,
7246 domain_id: u32,
7247 node_fqn: &str,
7248 namespace: &str,
7249 node_name: &str,
7250 suffix: &str,
7251 ) -> Result<session::RmwServiceServer, NodeError> {
7252 let mut name = heapless::String::<256>::new();
7253 name.push_str(node_fqn)
7254 .map_err(|_| NodeError::NameTooLong)?;
7255 name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
7256 name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
7257 let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
7258 // issue 0824 follow-up — `domain_id` is a PARAMETER, not `self.domain_id`:
7259 // this is a nested `fn`, not a method, so `self` is not in scope and
7260 // the original spelling was E0434. It only fails under feature sets
7261 // that compile this arm, which is why `--all-features` caught it and
7262 // the narrower lanes did not.
7263 // issue 0824 follow-up — `domain_id` is a PARAMETER, not `self.domain_id`:
7264 // this is a nested `fn`, not a method, so `self` is not in scope and
7265 // the original spelling was E0434. It only fails under feature sets
7266 // that compile this arm, which is why `--all-features` caught it and
7267 // the narrower lanes did not.
7268 .with_domain(domain_id)
7269 .with_namespace(namespace);
7270 if !node_name.is_empty() {
7271 info = info.with_node_name(node_name);
7272 }
7273 // issue 0793 — the parameter services get the PARAMETER profile
7274 // (`rmw_qos_profile_parameters`: KEEP_LAST(1000), reliable,
7275 // volatile), not the generic services one. `QOS_PROFILE_PARAMETERS`
7276 // existed with the right depth and had no caller, so every parameter
7277 // server ran on a depth-10 queue while ROS 2 gives them 1000 — which
7278 // matters exactly when a tool sets many parameters at once, the case
7279 // the deep queue is for.
7280 session
7281 .create_service(&info, QoSProfile::parameters_default())
7282 .map_err(NodeError::Transport)
7283 }
7284
7285 let get_handle = create_param_srv::<GetParameters>(
7286 &mut self.session,
7287 self.domain_id,
7288 &node_fqn,
7289 ns,
7290 nn,
7291 "get_parameters",
7292 )?;
7293 let set_handle = create_param_srv::<SetParameters>(
7294 &mut self.session,
7295 self.domain_id,
7296 &node_fqn,
7297 ns,
7298 nn,
7299 "set_parameters",
7300 )?;
7301 let set_atomic_handle = create_param_srv::<SetParametersAtomically>(
7302 &mut self.session,
7303 self.domain_id,
7304 &node_fqn,
7305 ns,
7306 nn,
7307 "set_parameters_atomically",
7308 )?;
7309 let list_handle = create_param_srv::<ListParameters>(
7310 &mut self.session,
7311 self.domain_id,
7312 &node_fqn,
7313 ns,
7314 nn,
7315 "list_parameters",
7316 )?;
7317 let desc_handle = create_param_srv::<DescribeParameters>(
7318 &mut self.session,
7319 self.domain_id,
7320 &node_fqn,
7321 ns,
7322 nn,
7323 "describe_parameters",
7324 )?;
7325 let types_handle = create_param_srv::<GetParameterTypes>(
7326 &mut self.session,
7327 self.domain_id,
7328 &node_fqn,
7329 ns,
7330 nn,
7331 "get_parameter_types",
7332 )?;
7333
7334 let servers = ParameterServiceServers::new(
7335 PSrv::<GetParameters> {
7336 handle: get_handle,
7337 req_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7338 reply_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7339 _phantom: core::marker::PhantomData,
7340 },
7341 PSrv::<SetParameters> {
7342 handle: set_handle,
7343 req_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7344 reply_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7345 _phantom: core::marker::PhantomData,
7346 },
7347 PSrv::<SetParametersAtomically> {
7348 handle: set_atomic_handle,
7349 req_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7350 reply_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7351 _phantom: core::marker::PhantomData,
7352 },
7353 PSrv::<ListParameters> {
7354 handle: list_handle,
7355 req_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7356 reply_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7357 _phantom: core::marker::PhantomData,
7358 },
7359 PSrv::<DescribeParameters> {
7360 handle: desc_handle,
7361 req_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7362 reply_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7363 _phantom: core::marker::PhantomData,
7364 },
7365 PSrv::<GetParameterTypes> {
7366 handle: types_handle,
7367 req_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7368 reply_buffer: [0u8; PARAM_SERVICE_BUFFER_SIZE],
7369 _phantom: core::marker::PhantomData,
7370 },
7371 );
7372
7373 // Issue 0745 — PRESERVE an already-initialized store: launch-param
7374 // seeding may have run before any node existed (services can't be
7375 // registered that early). Overwriting here would wipe the seeds.
7376 let services_box: alloc::boxed::Box<dyn crate::parameter_services::ParamServiceProcessor> =
7377 alloc::boxed::Box::new(servers);
7378 match &mut self.params {
7379 Some(params) => params.services = Some(services_box),
7380 None => {
7381 // Issue 0756 — see new_param_state: never build a ParamState
7382 // by value, it does not fit an embedded thread stack.
7383 self.params = Some(Self::new_param_state(Some(services_box)));
7384 }
7385 }
7386
7387 Ok(())
7388 }
7389
7390 // phase-359 W10 / issue 0080 — `enable_parameter_persistence{,_with}` are
7391 // GONE with the seam they attached. Issue 0080 ruled on-device parameter
7392 // persistence a non-goal in July and listed both by name; nothing in the
7393 // tree ever called them, and the only backend that could be passed
7394 // (`FileParamStore`) is deleted. Runtime get/set/describe stay — it is the
7395 // PERSISTENCE half that was dropped.
7396}
7397
7398// ============================================================================
7399// Lifecycle services (cfg lifecycle-services)
7400// ============================================================================
7401
7402#[cfg(feature = "lifecycle-services")]
7403impl<'s> Executor<'s> {
7404 /// Register the five REP-2002 lifecycle services on this executor.
7405 ///
7406 /// After this call, `ros2 lifecycle set|get|list|nodes` can drive the
7407 /// stored [`LifecyclePollingNodeCtx`](crate::lifecycle::LifecyclePollingNodeCtx)
7408 /// through the node's lifecycle. The state machine is created fresh
7409 /// (starting in `Unconfigured`); callers register their transition
7410 /// callbacks via [`Executor::lifecycle_state_machine_mut`].
7411 ///
7412 /// # Safety
7413 /// Registered callbacks on the state machine are C FFI function pointers.
7414 /// The caller must keep the callback code and any context it captures
7415 /// valid for as long as the executor processes services.
7416 pub fn register_lifecycle_services(&mut self) -> Result<(), NodeError> {
7417 use crate::{
7418 lifecycle::LifecyclePollingNodeCtx,
7419 lifecycle_services::{
7420 ChangeState, GetAvailableStates, GetAvailableTransitions, GetState,
7421 LIFECYCLE_SERVICE_BUFFER_SIZE, LifecycleRuntimeState, LifecycleServiceServers,
7422 },
7423 };
7424 use nros_core::RosService;
7425
7426 type LcSrv<Svc> = super::handles::EmbeddedServiceServer<
7427 Svc,
7428 LIFECYCLE_SERVICE_BUFFER_SIZE,
7429 LIFECYCLE_SERVICE_BUFFER_SIZE,
7430 >;
7431
7432 // Build the node FQN from namespace + node_name (same convention as
7433 // register_parameter_services).
7434 let mut node_fqn = heapless::String::<256>::new();
7435 let ns: &str = &self.namespace;
7436 let nn: &str = &self.node_name;
7437 if ns.is_empty() || ns == "/" {
7438 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
7439 node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
7440 } else {
7441 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
7442 node_fqn
7443 .push_str(ns.trim_matches('/'))
7444 .map_err(|_| NodeError::NameTooLong)?;
7445 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
7446 node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
7447 }
7448
7449 fn create_lc_srv<Svc: RosService>(
7450 session: &mut session::ConcreteSession,
7451 domain_id: u32,
7452 node_fqn: &str,
7453 namespace: &str,
7454 node_name: &str,
7455 suffix: &str,
7456 ) -> Result<session::RmwServiceServer, NodeError> {
7457 let mut name = heapless::String::<256>::new();
7458 name.push_str(node_fqn)
7459 .map_err(|_| NodeError::NameTooLong)?;
7460 name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
7461 name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
7462 let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
7463 .with_domain(domain_id)
7464 .with_namespace(namespace);
7465 if !node_name.is_empty() {
7466 info = info.with_node_name(node_name);
7467 }
7468 session
7469 .create_service(&info, QoSProfile::services_default())
7470 .map_err(NodeError::Transport)
7471 }
7472
7473 let cs_handle = create_lc_srv::<ChangeState>(
7474 &mut self.session,
7475 self.domain_id,
7476 &node_fqn,
7477 ns,
7478 nn,
7479 "change_state",
7480 )?;
7481 let gs_handle = create_lc_srv::<GetState>(
7482 &mut self.session,
7483 self.domain_id,
7484 &node_fqn,
7485 ns,
7486 nn,
7487 "get_state",
7488 )?;
7489 let gas_handle = create_lc_srv::<GetAvailableStates>(
7490 &mut self.session,
7491 self.domain_id,
7492 &node_fqn,
7493 ns,
7494 nn,
7495 "get_available_states",
7496 )?;
7497 let gat_handle = create_lc_srv::<GetAvailableTransitions>(
7498 &mut self.session,
7499 self.domain_id,
7500 &node_fqn,
7501 ns,
7502 nn,
7503 "get_available_transitions",
7504 )?;
7505 let gtg_handle = create_lc_srv::<GetAvailableTransitions>(
7506 &mut self.session,
7507 self.domain_id,
7508 &node_fqn,
7509 ns,
7510 nn,
7511 "get_transition_graph",
7512 )?;
7513
7514 let servers = LifecycleServiceServers::new(
7515 LcSrv::<ChangeState> {
7516 handle: cs_handle,
7517 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7518 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7519 _phantom: core::marker::PhantomData,
7520 },
7521 LcSrv::<GetState> {
7522 handle: gs_handle,
7523 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7524 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7525 _phantom: core::marker::PhantomData,
7526 },
7527 LcSrv::<GetAvailableStates> {
7528 handle: gas_handle,
7529 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7530 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7531 _phantom: core::marker::PhantomData,
7532 },
7533 LcSrv::<GetAvailableTransitions> {
7534 handle: gat_handle,
7535 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7536 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7537 _phantom: core::marker::PhantomData,
7538 },
7539 LcSrv::<GetAvailableTransitions> {
7540 handle: gtg_handle,
7541 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7542 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
7543 _phantom: core::marker::PhantomData,
7544 },
7545 );
7546
7547 self.lifecycle = Some(alloc::boxed::Box::new(LifecycleRuntimeState {
7548 state_machine: LifecyclePollingNodeCtx::new(),
7549 services: alloc::boxed::Box::new(servers),
7550 }));
7551
7552 Ok(())
7553 }
7554
7555 /// Mutable access to the lifecycle state machine, if registered.
7556 ///
7557 /// Used to register transition callbacks before spinning and to read the
7558 /// current state from application code.
7559 pub fn lifecycle_state_machine_mut(
7560 &mut self,
7561 ) -> Option<&mut crate::lifecycle::LifecyclePollingNodeCtx> {
7562 self.lifecycle.as_mut().map(|lc| &mut lc.state_machine)
7563 }
7564
7565 /// Immutable access to the lifecycle state machine, if registered.
7566 pub fn lifecycle_state_machine(&self) -> Option<&crate::lifecycle::LifecyclePollingNodeCtx> {
7567 self.lifecycle.as_ref().map(|lc| &lc.state_machine)
7568 }
7569
7570 /// Register a safe lifecycle node (issue 0335 / phase-317).
7571 ///
7572 /// Binds the five REP-2002 lifecycle services and wires each transition to
7573 /// the [`LifecycleCallbacks`](crate::lifecycle::LifecycleCallbacks) trait
7574 /// impl — the safe Rust counterpart to the C++ `nros::LifecycleNode`. No
7575 /// `unsafe` in user code; alloc-free (monomorphized trampolines).
7576 ///
7577 /// `node` must outlive this executor's lifecycle registration: the context
7578 /// pointer stored here is dereferenced on every transition until the
7579 /// executor is finalized. The executor spins single-threaded, so the
7580 /// `&mut node` reconstituted inside a callback is never aliased.
7581 pub fn register_lifecycle_node<T: crate::lifecycle::LifecycleCallbacks>(
7582 &mut self,
7583 node: &mut T,
7584 ) -> Result<(), NodeError> {
7585 use crate::lifecycle::{LifecycleCallbackSlot as Slot, trampolines};
7586
7587 self.register_lifecycle_services()?;
7588 let sm = self
7589 .lifecycle_state_machine_mut()
7590 .ok_or(NodeError::NotInitialized)?;
7591 sm.set_context(node as *mut T as *mut core::ffi::c_void);
7592 sm.register(Slot::Configure, Some(trampolines::on_configure::<T>));
7593 sm.register(Slot::Activate, Some(trampolines::on_activate::<T>));
7594 sm.register(Slot::Deactivate, Some(trampolines::on_deactivate::<T>));
7595 sm.register(Slot::Cleanup, Some(trampolines::on_cleanup::<T>));
7596 sm.register(Slot::Shutdown, Some(trampolines::on_shutdown::<T>));
7597 sm.register(Slot::Error, Some(trampolines::on_error::<T>));
7598 Ok(())
7599 }
7600}
7601
7602// ============================================================================
7603// Parameter declaration API (cfg param-services)
7604// ============================================================================
7605
7606#[cfg(feature = "param-services")]
7607impl<'s> Executor<'s> {
7608 /// Issue 0745 — lazily create the parameter STORE (no services yet).
7609 /// Launch-param seeding runs before any node is constructed; the six
7610 /// service servers attach later in `register_parameter_services`,
7611 /// which preserves this store.
7612 fn ensure_parameter_store(&mut self) {
7613 if self.params.is_none() {
7614 self.params = Some(Self::new_param_state(None));
7615 }
7616 }
7617
7618 /// Produce the parameter slot table the executor's store will borrow.
7619 ///
7620 /// phase-382 W2' — `ParameterServer` no longer OWNS its slots; it borrows
7621 /// a caller-placed [`nros_params::ParameterTable`]. W3' carves that table
7622 /// out of the caller's executor backing, at which point this function goes
7623 /// away. Until then the executor has no caller-supplied home to borrow
7624 /// from, so it makes one: a single heap allocation, leaked, one per
7625 /// executor that touches parameters. The leak is what buys the `'static`
7626 /// the borrow needs without making `ParamState` self-referential (a
7627 /// `ParamState` that owned the storage AND a server borrowing it is not
7628 /// expressible), and it is bounded — `ensure_parameter_store` runs this
7629 /// at most once per executor.
7630 ///
7631 /// Issue 0756 — the placement dance is still here because the SIZE is
7632 /// still here; it only moved off `ParameterServer` and onto
7633 /// `ParameterStorage`. That storage is 285,184 bytes at the default
7634 /// `MAX_PARAMETERS=32` and 2,281,472 at 256 (`ParameterValue` is sized by
7635 /// its `StringArray` variant, so every slot costs ~8.5 KiB regardless of
7636 /// what it holds). `Box::new(ParameterStorage::new())` materialises all of
7637 /// that on the caller's stack before copying it into the allocation,
7638 /// because Rust has no placement-new. On the Zephyr lane that silently
7639 /// overran the thread stack: an image built with 256 slots boots to
7640 /// `dds_create_participant` and hangs with no fault and no output, while
7641 /// 32 — which fits the 512 KiB main stack the cyclonedds snippet asks for
7642 /// — runs clean. Initialising through the allocation bounds the largest
7643 /// stack temporary at one slot, so the knob no longer decides whether boot
7644 /// survives.
7645 fn leak_parameter_storage() -> nros_params::ParameterTable<'static> {
7646 let mut uninit = alloc::boxed::Box::<nros_params::ParameterStorage>::new_uninit();
7647 // Safety: `new_uninit` gives a correctly-sized, correctly-aligned
7648 // allocation for exactly this type, and `init_in_place` writes every
7649 // slot exactly once before `assume_init` observes it.
7650 unsafe {
7651 nros_params::ParameterStorage::init_in_place(uninit.as_mut_ptr());
7652 alloc::boxed::Box::leak(uninit.assume_init()).as_table()
7653 }
7654 }
7655
7656 /// Build the executor's `ParamState`.
7657 ///
7658 /// phase-382 W2' — this is a plain `Box::new` again: `ParameterServer` is
7659 /// now a table borrow plus a count, so `ParamState` is tens of bytes and
7660 /// nothing about constructing one depends on `MAX_PARAMETERS`. The bulk
7661 /// (and issue 0756's placement requirement with it) lives in
7662 /// [`leak_parameter_storage`](Self::leak_parameter_storage).
7663 fn new_param_state(
7664 services: Option<alloc::boxed::Box<dyn crate::parameter_services::ParamServiceProcessor>>,
7665 ) -> alloc::boxed::Box<crate::parameter_services::ParamState<'s>> {
7666 alloc::boxed::Box::new(crate::parameter_services::ParamState {
7667 server: nros_params::ParameterServer::new_in(Self::leak_parameter_storage()),
7668 services,
7669 })
7670 }
7671
7672 /// Declare a parameter with a value. Returns `true` if successful.
7673 pub fn declare_parameter(&mut self, name: &str, value: nros_params::ParameterValue) -> bool {
7674 self.ensure_parameter_store();
7675 // phase-425 W3b — `use_sim_time` is RESERVED, exactly as in ROS 2: its
7676 // value is not a value the app reads, it is the switch that attaches the
7677 // time source. This is the one seam every language funnels through
7678 // (`nros::main!`'s launch bakes via `apply_param_services`, nros-c's
7679 // `nros_parameter_declare_*`, nros-cpp's `params_shim`), so hooking it
7680 // here covers all of them instead of once per entry path.
7681 self.note_reserved_parameter(name, &value);
7682 if let Some(params) = &mut self.params {
7683 params.server.declare(name, value)
7684 } else {
7685 false
7686 }
7687 }
7688
7689 /// phase-425 W3b — record a reserved parameter's effect. Today that is
7690 /// `use_sim_time` and nothing else.
7691 ///
7692 /// A non-bool `use_sim_time` is IGNORED rather than rejected: parameter
7693 /// declaration has no channel to report a complaint on (it returns "did the
7694 /// store take it"), and refusing the declaration outright would fail a node
7695 /// for a parameter ROS 2 lets it declare. The time source simply does not
7696 /// attach, which is the same outcome as `false`.
7697 #[cfg_attr(
7698 not(all(feature = "sim-time", any(has_rmw, test))),
7699 allow(unused_variables)
7700 )]
7701 fn note_reserved_parameter(&mut self, name: &str, value: &nros_params::ParameterValue) {
7702 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
7703 if name == crate::time_source::USE_SIM_TIME_PARAM
7704 && let nros_params::ParameterValue::Bool(enable) = value
7705 {
7706 self.sim_time_requested = *enable;
7707 self.sim_time_stated = true;
7708 }
7709 }
7710
7711 /// phase-425 W3b — a parameter service handled `n` requests this spin.
7712 ///
7713 /// The hook exists so `use_sim_time` can be re-read after a runtime
7714 /// `ros2 param set`, WITHOUT a per-spin scan of the store. It takes the
7715 /// count unconditionally and ignores it when `sim-time` is off, rather than
7716 /// the call sites carrying the feature test: `handled` was then assigned and
7717 /// never read in the `param-services`-without-`sim-time` combo, which is a
7718 /// `-D warnings` error `check-build` catches and the narrower per-crate
7719 /// clippy runs do not.
7720 #[inline]
7721 fn note_param_services_ran(&mut self, handled: usize) {
7722 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
7723 if handled > 0 {
7724 self.refresh_use_sim_time_from_store();
7725 }
7726 let _ = handled;
7727 }
7728
7729 /// Declare a parameter with a value and descriptor. Returns `true` if successful.
7730 pub fn declare_parameter_with_descriptor(
7731 &mut self,
7732 name: &str,
7733 value: nros_params::ParameterValue,
7734 descriptor: nros_params::ParameterDescriptor,
7735 ) -> bool {
7736 self.ensure_parameter_store();
7737 if let Some(params) = &mut self.params {
7738 params
7739 .server
7740 .declare_with_descriptor(name, value, Some(descriptor))
7741 } else {
7742 false
7743 }
7744 }
7745
7746 /// Get a parameter value by name.
7747 pub fn get_parameter(&self, name: &str) -> Option<&nros_params::ParameterValue> {
7748 self.params.as_ref()?.server.get(name)
7749 }
7750
7751 /// Get an integer parameter value by name (convenience).
7752 pub fn get_parameter_integer(&self, name: &str) -> Option<i64> {
7753 self.params.as_ref()?.server.get_integer(name)
7754 }
7755
7756 /// Get a reference to the parameter server (if registered).
7757 pub fn params(&self) -> Option<&nros_params::ParameterServer<'s>> {
7758 self.params.as_ref().map(|p| &p.server)
7759 }
7760
7761 /// Get a mutable reference to the parameter server (if registered).
7762 pub fn params_mut(&mut self) -> Option<&mut nros_params::ParameterServer<'s>> {
7763 self.params.as_mut().map(|p| &mut p.server)
7764 }
7765
7766 /// Create a typed parameter builder (rclrs-compatible API).
7767 ///
7768 /// Returns a [`ParameterBuilder`] for fluent parameter declaration with
7769 /// `.default()`, `.description()`, `.range()`, and terminal methods
7770 /// `.mandatory()`, `.optional()`, or `.read_only()`.
7771 ///
7772 /// Returns [`NodeError::NotInitialized`] if parameter services have
7773 /// not been registered yet — call [`register_parameter_services`]
7774 /// first.
7775 ///
7776 /// # Example
7777 ///
7778 /// ```ignore
7779 /// let max_speed = executor.parameter::<f64>("max_speed")?
7780 /// .default(25.0)
7781 /// .description("Maximum velocity (m/s)")
7782 /// .read_only()?;
7783 /// ```
7784 ///
7785 /// [`ParameterBuilder`]: nros_params::ParameterBuilder
7786 /// [`register_parameter_services`]: Self::register_parameter_services
7787 pub fn parameter<'a, T: nros_params::ParameterVariant>(
7788 &'a mut self,
7789 name: &'a str,
7790 ) -> Result<nros_params::ParameterBuilder<'a, 's, T>, NodeError> {
7791 let server = self
7792 .params
7793 .as_mut()
7794 .map(|p| &mut p.server)
7795 .ok_or(NodeError::NotInitialized)?;
7796 Ok(nros_params::ParameterBuilder::new(server, name))
7797 }
7798}
7799
7800// ============================================================================
7801// std-gated spin and halt methods
7802// ============================================================================
7803
7804// phase-359 W10 — this impl used to be ONE `#[cfg(feature = "std")]` block.
7805// Only the wall-clock spin loops in it need `std` (`Instant`, `thread::sleep`,
7806// `thread::spawn`); `halt` / `is_halted` / `wake` are plain atomic stores on
7807// flags the struct already owns, and `halt_flag` / `wake_handle` only need
7808// `Arc`. Gating the BLOCK rather than the items made a no_std image unable to
7809// stop its own executor — `ExecutorNodeRuntime::spin` carried a matching `std`
7810// gate for no reason but this one.
7811// phase-359 W10 — `alloc`, not `std`. The wall-clock spin loops were the
7812// reason this block was std-gated: `Instant` for the deadline and
7813// `thread::sleep` for the pacing. Both now go through the platform — the
7814// executor's own `now_us()` and `nros_platform_sleep_us` — so a no_std
7815// image can run the same blocking loops a hosted one does instead of
7816// hand-rolling them in its BSP.
7817#[cfg(feature = "alloc")]
7818impl<'s> Executor<'s> {
7819 /// Blocking spin loop with configurable exit conditions.
7820 ///
7821 /// Runs until one of:
7822 /// - [`halt()`](Self::halt) is called (from another thread or signal handler)
7823 /// - Timeout expires (if set in options)
7824 /// - Max callbacks reached (if set in options)
7825 /// - `only_next` is true (single iteration)
7826 ///
7827 /// # Example
7828 ///
7829 /// ```ignore
7830 /// // Spin forever until halted
7831 /// executor.spin_blocking(SpinOptions::default())?;
7832 ///
7833 /// // Spin with 5-second timeout
7834 /// executor.spin_blocking(SpinOptions::new().timeout(core::time::Duration::from_secs(5)))?;
7835 ///
7836 /// // Single iteration
7837 /// executor.spin_blocking(SpinOptions::spin_once())?;
7838 /// ```
7839 pub fn spin_blocking(&mut self, opts: SpinOptions) -> Result<(), NodeError> {
7840 const POLL_INTERVAL: core::time::Duration = core::time::Duration::from_millis(10);
7841
7842 // phase-359 W10 — the executor's own clock. `now_us()` reads the
7843 // PLATFORM's monotonic counter, and nothing else: the platform API is
7844 // the clock, so a build with a port has one and a build without has
7845 // none. There is no `std::time::Instant` third answer any more, which
7846 // is what used to make "what time is it" depend on whether some crate
7847 // in the graph happened to name `std`.
7848 //
7849 // `None` means this build has NO clock at all — and issue 0709 is what
7850 // the previous answer here cost. It read: "a timeout cannot be honoured
7851 // then, and pretending otherwise would exit immediately or never; the
7852 // honest reading is no deadline". It then picked NEVER. A caller that
7853 // asked for 50 ms got an infinite loop, silently, in the one API whose
7854 // whole contract is "returns after N ms" — ten hours of a CI lane
7855 // before anyone read it as stuck rather than slow.
7856 //
7857 // Neither of the two silent readings is honest. An unmet precondition
7858 // is an ERROR (repo rule, fail-loud): the caller supplied a time
7859 // quantity this build cannot measure, and only they can decide what to
7860 // do about it. An UNTIMED `spin_blocking` still runs until halt, which
7861 // is a promise this build can keep.
7862 let start_us = self.now_us();
7863 if opts.timeout.is_some() && start_us.is_none() {
7864 nros_log::nros_error!(
7865 nros_log::get_logger("nros"),
7866 "spin_blocking: a timeout was requested but this build has no clock \
7867 — install one with `ExecutorConfig::clock_us` (issue 0709)"
7868 );
7869 return Err(NodeError::NotInitialized);
7870 }
7871 // `as_micros()` rather than a hand-rolled `ms * 1_000`: the multiply
7872 // was unchecked, and the unit conversion is `Duration`'s job.
7873 let timeout_us = opts.timeout.map(|d| d.as_micros() as u64);
7874 let mut total_callbacks = 0usize;
7875
7876 self.halt_flag
7877 .store(false, core::sync::atomic::Ordering::SeqCst);
7878
7879 loop {
7880 if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
7881 break;
7882 }
7883
7884 if let (Some(start), Some(limit)) = (start_us, timeout_us)
7885 && self
7886 .now_us()
7887 .is_some_and(|now| now.saturating_sub(start) >= limit)
7888 {
7889 break;
7890 }
7891
7892 let result = self.spin_once(POLL_INTERVAL);
7893 total_callbacks += result.total();
7894
7895 if opts.max_callbacks.is_some_and(|max| total_callbacks >= max) {
7896 break;
7897 }
7898
7899 if opts.only_next {
7900 break;
7901 }
7902 }
7903
7904 Ok(())
7905 }
7906
7907 /// Execute one period with wall-clock overrun detection.
7908 ///
7909 /// Calls [`spin_once()`](Self::spin_once), measures wall-clock time, sleeps
7910 /// for the remainder if under budget.
7911 ///
7912 /// # Example
7913 ///
7914 /// ```ignore
7915 /// let period = core::time::Duration::from_millis(10);
7916 /// let result = executor.spin_one_period_timed(period);
7917 /// if result.overrun {
7918 /// log::warn!("Period overrun: {:?}", result.elapsed);
7919 /// }
7920 /// ```
7921 pub fn spin_one_period_timed(
7922 &mut self,
7923 period: core::time::Duration,
7924 ) -> super::types::SpinPeriodResult {
7925 let start_us = self.now_us();
7926 let result = self.spin_once(period);
7927 // With no clock there is nothing to measure and nothing to sleep off;
7928 // report zero elapsed and no overrun, which is what an unmeasured
7929 // period is.
7930 let elapsed = start_us
7931 .zip(self.now_us())
7932 .map(|(s, e)| core::time::Duration::from_micros(e.saturating_sub(s)))
7933 .unwrap_or_default();
7934 let overrun = elapsed > period;
7935 if !overrun && start_us.is_some() {
7936 platform_sleep(period - elapsed);
7937 }
7938 super::types::SpinPeriodResult {
7939 work: result,
7940 overrun,
7941 elapsed,
7942 }
7943 }
7944
7945 /// Spin at a fixed rate with drift compensation. Blocks until halted.
7946 ///
7947 /// Uses wall-clock time to maintain the target rate. The next invocation
7948 /// time is accumulated (not reset to `now + period`) to prevent cumulative
7949 /// drift.
7950 ///
7951 /// # Example
7952 ///
7953 /// ```ignore
7954 /// // 100Hz control loop — blocks until halt() is called
7955 /// executor.spin_period(core::time::Duration::from_millis(10))?;
7956 /// ```
7957 pub fn spin_period(&mut self, period: core::time::Duration) -> Result<(), NodeError> {
7958 self.halt_flag
7959 .store(false, core::sync::atomic::Ordering::SeqCst);
7960 let period_us = period.as_micros().min(u64::MAX as u128) as u64;
7961 // Absolute next-deadline in the executor's own clock. issue 0709 — the
7962 // sibling of `spin_blocking`'s guard above: with no clock there is no
7963 // pacing, and the loop would run as fast as `spin_once` returns while
7964 // the caller believes it is running at `period`. A requested period
7965 // this build cannot honour is a configuration error, not a silent
7966 // busy-loop.
7967 let Some(start_us) = self.now_us() else {
7968 nros_log::nros_error!(
7969 nros_log::get_logger("nros"),
7970 "spin_period: a period was requested but this build has no clock \
7971 — install one with `ExecutorConfig::clock_us` (issue 0709)"
7972 );
7973 return Err(NodeError::NotInitialized);
7974 };
7975 let mut next_us = Some(start_us + period_us);
7976
7977 loop {
7978 if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
7979 break;
7980 }
7981
7982 self.spin_once(period);
7983
7984 if let Some(next) = next_us {
7985 // Jitter is recorded in `spin_once` above, which this loop
7986 // calls every iteration -- counting it here as well would
7987 // double every wake on this one driver.
7988 if let Some(now) = self.now_us()
7989 && now < next
7990 {
7991 platform_sleep(core::time::Duration::from_micros(next - now));
7992 }
7993 // Accumulate to prevent drift (not = now + period)
7994 next_us = Some(next + period_us);
7995 }
7996 }
7997 Ok(())
7998 }
7999}
8000
8001#[cfg(feature = "alloc")]
8002impl<'s> Executor<'s> {
8003 /// Phase 110.D.b — move this Executor onto a fresh OS thread,
8004 /// apply a per-thread scheduling policy via the caller-supplied
8005 /// `apply_policy` function, and run the spin loop until
8006 /// [`ThreadHandle::halt`] fires.
8007 ///
8008 /// The function-pointer indirection on `apply_policy` lets the
8009 /// caller pass any platform's `PlatformScheduler::set_current_thread_policy`
8010 /// without forcing `Executor` to be generic over the platform —
8011 /// keeps the existing `Executor` type stable.
8012 ///
8013 /// Multi-executor preemption (the actual hard-RT win) comes from
8014 /// the OS scheduler — call `open_threaded` once per criticality
8015 /// tier, each with its own policy / priority. The kernel handles
8016 /// preemption across executors; within a single executor,
8017 /// dispatch remains non-preemptive (110.A–C bucketed sets).
8018 ///
8019 /// # Safety
8020 ///
8021 /// Moves `self` across thread boundaries. `Executor` contains a
8022 /// raw `*mut session::ConcreteSession` when constructed via
8023 /// `from_session_ptr`; the caller must ensure that pointer's
8024 /// referent stays valid across the lifetime of the spawned thread
8025 /// and that no other thread mutates the session concurrently.
8026 /// `from_session` (Owned) is safer — `ConcreteSession` ownership
8027 /// transfers cleanly into the thread.
8028 #[cfg(feature = "alloc")]
8029 pub unsafe fn open_threaded(
8030 self,
8031 policy: nros_platform_api::SchedPolicy,
8032 apply_policy: fn(
8033 nros_platform_api::SchedPolicy,
8034 ) -> Result<(), nros_platform_api::SchedError>,
8035 spin_period: core::time::Duration,
8036 ) -> ThreadHandle
8037 where
8038 // phase-271 — the spawned thread owns `self` for an unbounded lifetime,
8039 // so its borrowed storage must be `'static` (a leaked/`static` backing —
8040 // the `alloc` convenience constructors or a program-lifetime region).
8041 's: 'static,
8042 {
8043 let halt = self.halt_flag.clone();
8044 // phase-359 W10 — a platform task, not `std::thread`. The two other
8045 // executor-owned workers moved earlier; this is the third and last, and
8046 // it is the one the ABI fits best: `open_threaded` exists to give a
8047 // spin loop an OS SCHEDULING POLICY, which is not something a build
8048 // without an OS was ever going to get from `std`.
8049 //
8050 // The closure becomes a heap context because the entry point crosses C
8051 // as `*mut c_void`. It is reclaimed by the trampoline on exit rather
8052 // than leaked: unlike a tier task, this loop RETURNS — that is what
8053 // `halt` is for.
8054 let ctx = alloc::boxed::Box::into_raw(alloc::boxed::Box::new(ThreadedCtx {
8055 executor: self,
8056 policy,
8057 apply_policy,
8058 spin_period,
8059 }));
8060 // SAFETY: `ctx` stays live until the trampoline reclaims it, which
8061 // happens on the spawned task and only after the loop exits.
8062 let task = unsafe {
8063 nros_platform_api::task::PlatformTask::spawn(
8064 threaded_spin_trampoline,
8065 ctx as *mut core::ffi::c_void,
8066 OPEN_THREADED_STACK_BYTES,
8067 c"nros-exec".as_ptr(),
8068 )
8069 };
8070 let Some(task) = task else {
8071 // The platform refused (or cannot size) a task. Reclaim the
8072 // context and hand back a handle that owns nothing — `halt` and
8073 // `join` stay callable, which keeps the caller's shape identical to
8074 // the success path.
8075 //
8076 // SAFETY: nothing was spawned, so this pointer has no other owner.
8077 drop(unsafe { alloc::boxed::Box::from_raw(ctx) });
8078 return ThreadHandle { task: None, halt };
8079 };
8080 ThreadHandle {
8081 task: Some(task),
8082 halt,
8083 }
8084 }
8085
8086 /// Request the executor to stop spinning.
8087 ///
8088 /// Sets a flag that causes [`spin_blocking()`](Self::spin_blocking) or
8089 /// [`spin_period()`](Self::spin_period) to exit on the next iteration.
8090 /// Safe to call from another thread or signal handler.
8091 ///
8092 /// Also raises the Phase 104.C.6 wake flag so a `spin_once` already
8093 /// blocked inside a backend's `drive_io` falls through to the halt
8094 /// check on its next loop iteration instead of waiting out its full
8095 /// `timeout_ms` first.
8096 pub fn halt(&self) {
8097 self.halt_flag
8098 .store(true, core::sync::atomic::Ordering::SeqCst);
8099 self.wake_flag
8100 .store(true, core::sync::atomic::Ordering::SeqCst);
8101 }
8102
8103 /// Check if halt has been requested.
8104 pub fn is_halted(&self) -> bool {
8105 self.halt_flag.load(core::sync::atomic::Ordering::SeqCst)
8106 }
8107
8108 /// Phase 104.C.6 — wake the executor from another thread / ISR /
8109 /// signal handler.
8110 ///
8111 /// Sets the shared `wake_flag`. The next `spin_once` swap-clears the
8112 /// flag, skips the blocking wait on the primary session, and polls
8113 /// every session non-blockingly so whatever queued the wake is
8114 /// observed in a single iteration. Idempotent — multiple `wake()`
8115 /// calls collapse into one observed wake per `spin_once`.
8116 pub fn wake(&self) {
8117 self.wake_flag
8118 .store(true, core::sync::atomic::Ordering::SeqCst);
8119 }
8120}
8121
8122#[cfg(feature = "alloc")]
8123impl<'s> Executor<'s> {
8124 /// Get a clone of the halt flag for use in signal handlers or other threads.
8125 ///
8126 /// # Example
8127 ///
8128 /// ```ignore
8129 /// let halt = executor.halt_flag();
8130 /// std::thread::spawn(move || {
8131 /// std::thread::sleep(Duration::from_secs(5));
8132 /// halt.store(true, Ordering::SeqCst);
8133 /// });
8134 /// executor.spin_blocking(SpinOptions::default())?;
8135 /// ```
8136 pub fn halt_flag(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
8137 self.halt_flag.clone()
8138 }
8139
8140 /// Phase 104.C.6 — clone of the shared wake flag for cross-thread
8141 /// use (signal handlers, foreign threads, future per-backend vtable
8142 /// wake hooks).
8143 ///
8144 /// # Example
8145 ///
8146 /// ```ignore
8147 /// let wake = executor.wake_handle();
8148 /// std::thread::spawn(move || {
8149 /// // ... compute something ...
8150 /// // hand off to executor by setting the flag.
8151 /// wake.store(true, Ordering::SeqCst);
8152 /// });
8153 /// loop { executor.spin_once(Duration::from_millis(100)); }
8154 /// ```
8155 pub fn wake_handle(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
8156 self.wake_flag.clone()
8157 }
8158}
8159
8160/// Phase 110.E.b — opaque per-platform timer handle. Stores the
8161/// raw platform handle (POSIX `timer_t` boxed via `PosixTimerHandle`,
8162/// FreeRTOS `TimerHandle_t`, etc.) plus a destroy thunk so the
8163/// Executor can clean up without being generic over the platform.
8164///
8165/// Caller of `register_sporadic_timer` builds this via
8166/// `OpaqueTimerHandle::new(handle, destroy_fn)` after their
8167/// `PlatformTimer::create_periodic` call returns.
8168#[cfg(feature = "alloc")]
8169pub struct OpaqueTimerHandle {
8170 handle: *mut core::ffi::c_void,
8171 destroy_fn: extern "C" fn(*mut core::ffi::c_void),
8172}
8173
8174#[cfg(feature = "alloc")]
8175unsafe impl Send for OpaqueTimerHandle {}
8176#[cfg(feature = "alloc")]
8177unsafe impl Sync for OpaqueTimerHandle {}
8178
8179#[cfg(feature = "alloc")]
8180impl OpaqueTimerHandle {
8181 /// # Safety
8182 /// `handle` must be a live platform-specific timer handle that
8183 /// `destroy_fn` knows how to drop. Caller surrenders ownership
8184 /// of the underlying handle to the Executor.
8185 pub unsafe fn new(
8186 handle: *mut core::ffi::c_void,
8187 destroy_fn: extern "C" fn(*mut core::ffi::c_void),
8188 ) -> Self {
8189 Self { handle, destroy_fn }
8190 }
8191}
8192
8193#[cfg(feature = "alloc")]
8194impl Drop for OpaqueTimerHandle {
8195 fn drop(&mut self) {
8196 if !self.handle.is_null() {
8197 (self.destroy_fn)(self.handle);
8198 self.handle = core::ptr::null_mut();
8199 }
8200 }
8201}
8202
8203/// Handle returned from [`Executor::open_threaded`]. Holds the
8204/// spawned thread's join handle and a clone of the executor's halt
8205/// flag. Drop runs `halt() + join()` so the thread can't outlive the
8206/// handle.
8207#[cfg(feature = "alloc")]
8208pub struct ThreadHandle {
8209 task: Option<nros_platform_api::task::PlatformTask>,
8210 halt: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
8211}
8212
8213/// Default stack for an `open_threaded` spin loop. `std::thread`'s default was
8214/// 2 MiB; the executor's own storage is caller-carved and lives elsewhere, so
8215/// this carries call frames only — the same reasoning (and size) as the NuttX
8216/// tier spawn, which measured this against a real RTOS default.
8217#[cfg(feature = "alloc")]
8218const OPEN_THREADED_STACK_BYTES: usize = 65536;
8219
8220/// What [`Executor::open_threaded`] hands its task, in place of a closure's
8221/// captures. Owned by the trampoline, which reclaims it when the loop exits.
8222#[cfg(feature = "alloc")]
8223struct ThreadedCtx {
8224 executor: Executor<'static>,
8225 policy: nros_platform_api::SchedPolicy,
8226 apply_policy: fn(nros_platform_api::SchedPolicy) -> Result<(), nros_platform_api::SchedError>,
8227 spin_period: core::time::Duration,
8228}
8229
8230/// The spawned spin loop.
8231///
8232/// # Safety
8233/// `arg` must be the `Box<ThreadedCtx>` raw pointer `open_threaded` created,
8234/// passed exactly once.
8235#[cfg(feature = "alloc")]
8236unsafe extern "C" fn threaded_spin_trampoline(
8237 arg: *mut core::ffi::c_void,
8238) -> *mut core::ffi::c_void {
8239 // SAFETY: the caller's contract — this is the pointer `open_threaded`
8240 // leaked, and the task is the only consumer of it.
8241 let mut ctx = unsafe { alloc::boxed::Box::from_raw(arg as *mut ThreadedCtx) };
8242 // Apply the requested OS scheduling policy to this fresh task. Failure is
8243 // reported but not propagated — a runtime that fails to lift to SCHED_FIFO
8244 // still spins correctly at SCHED_OTHER (just without RT guarantees).
8245 let _ = (ctx.apply_policy)(ctx.policy);
8246 while !ctx.executor.is_halted() {
8247 ctx.executor.spin_once(ctx.spin_period);
8248 }
8249 core::ptr::null_mut()
8250}
8251
8252#[cfg(feature = "alloc")]
8253impl ThreadHandle {
8254 /// Signal the spawned executor thread to stop. The thread exits
8255 /// on its next `spin_once` iteration.
8256 pub fn halt(&self) {
8257 self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
8258 }
8259
8260 /// Wait for the spawned task to exit. After `join`, calling it again is a
8261 /// no-op.
8262 ///
8263 /// phase-359 W10 — was `std::thread::Result<()>`, which carried a panic
8264 /// payload this can no longer produce: a platform task has no unwinding
8265 /// join. Same two outcomes, an error type that does not require `std` —
8266 /// the trade `signal_fd` already made in this campaign. `NotInitialized`
8267 /// means the platform refused to host the task at spawn time — nothing was
8268 /// started, so there is nothing to wait for. (An existing variant rather
8269 /// than a new one: `NodeError` is mapped across the C and C++ FFI, so a new
8270 /// variant is a gate-checked ABI change and this needs no new meaning.)
8271 pub fn join(mut self) -> Result<(), NodeError> {
8272 self.halt();
8273 match self.task.take() {
8274 Some(t) => {
8275 t.join();
8276 Ok(())
8277 }
8278 None => Err(NodeError::NotInitialized),
8279 }
8280 }
8281}
8282
8283#[cfg(feature = "alloc")]
8284impl Drop for ThreadHandle {
8285 fn drop(&mut self) {
8286 self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
8287 if let Some(t) = self.task.take() {
8288 t.join();
8289 }
8290 }
8291}
8292
8293// SAFETY: Phase 110.D.b — `Executor` contains a raw `*mut
8294// session::ConcreteSession` only on the `from_session_ptr` (Borrowed)
8295// path; the `from_session` (Owned) path is plain Send-able. The
8296// `unsafe fn open_threaded` entry point documents the safety
8297// contract for Borrowed sessions; for Owned sessions the Send claim
8298// is unconditional.
8299// phase-359 W10 — `alloc`, not `std`: this assertion exists for
8300// `open_threaded`, which now hands the executor to a PLATFORM task. The
8301// crossing is the same one; only the thing doing the crossing changed.
8302#[cfg(feature = "alloc")]
8303unsafe impl<'s> Send for Executor<'s> {}
8304
8305// =============================================================================
8306// Phase 110.F — `OsPriorityWorker` + `WorkItem`
8307// =============================================================================
8308
8309// phase-359 W10 — `OsPriorityWorker` / `WorkItem` moved to
8310// `super::os_priority` and were rewritten off `std::thread` + `std::sync::mpsc`
8311// + `HashMap` onto the platform task ABI, a bounded `heapless` mailbox and a
8312// `NodeWake` doorbell. The capability is no longer std-only; see that module
8313// for what changed semantically (bounded mailbox, capacity-limited pool, and
8314// no pool on a platform without a wake primitive).
8315
8316impl<'s> Drop for Executor<'s> {
8317 fn drop(&mut self) {
8318 // Issue 0790 — a teardown that never went through `close()` still gets
8319 // its ordered shutdown hooks, and gets them in the SAME order. The C
8320 // API's `nros_executor_fini` is exactly that path: it drops the
8321 // executor in place and leaves the session to `nros_support_fini`, so
8322 // without this the whole facility would be silently inert for every C
8323 // entry. Entities are still live at the top of `drop` — the component
8324 // cells and the arena entries below are what tears them down — so this
8325 // is the last moment a pre-shutdown hook can do what it exists to do.
8326 //
8327 // After a normal `close()` both tables are already empty and these two
8328 // calls are a pair of `None` scans.
8329 self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
8330 // Phase 258 (Track 2, 2a) — release executor-owned component cells
8331 // first (before the arena entries), so a component's `drop`
8332 // trampoline can still touch its own (cell-owned) state. Each slot
8333 // owns a leaked `Arc<ComponentCell>`; its `drop` reconstitutes +
8334 // drops that Arc exactly once.
8335 for slot in self.component_slots.iter() {
8336 // SAFETY: `slot.state` is the leaked cell enrolled via
8337 // `enroll_component`; `slot.drop` is its matching trampoline, run
8338 // exactly once here (slots are not removed before Drop).
8339 unsafe {
8340 (slot.drop)(slot.state);
8341 }
8342 }
8343 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8344 for meta in self.entries.iter().flatten() {
8345 // SAFETY: each entry was written by `ptr::write` in `add_*` and
8346 // has not been dropped yet. `drop_fn` matches the concrete type.
8347 unsafe {
8348 let data_ptr = arena_ptr.add(meta.offset);
8349 (meta.drop_fn)(data_ptr);
8350 }
8351 }
8352 // Issue 0790 — the post-teardown half, after the entities are gone.
8353 // Same "already empty after `close()`" note as the pre pass above.
8354 self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
8355 }
8356}
8357
8358#[cfg(all(test, feature = "std", not(feature = "rmw-cffi")))]
8359mod dispatch_registry_tests {
8360 //! Phase 216 follow-up — `Executor::register_dispatch_slot` +
8361 //! `Executor::dispatch_callback` round-trip.
8362 //!
8363 //! Uses `MockSession` (same pattern as
8364 //! `lifecycle_services::tests::mock_integration`) so the test
8365 //! doesn't need a live RMW backend. Gated `not(feature =
8366 //! "rmw-cffi")` because under `rmw-cffi` the `ConcreteSession`
8367 //! type alias resolves to the cffi session, which `MockSession`
8368 //! can't impersonate.
8369
8370 extern crate alloc;
8371
8372 use super::Executor;
8373 use crate::mock::MockSession;
8374 use std::sync::Mutex;
8375
8376 static CAPTURED: Mutex<alloc::vec::Vec<(usize, alloc::vec::Vec<u8>, usize)>> =
8377 Mutex::new(alloc::vec::Vec::new());
8378
8379 /// Test trampoline matching the per-Node
8380 /// `__nros_node_<pkg>_on_callback` ABI shape (Phase 216.A.5).
8381 unsafe extern "C" fn recording_on_callback(
8382 state: *mut core::ffi::c_void,
8383 cb_id_ptr: *const u8,
8384 cb_id_len: usize,
8385 ctx: *mut core::ffi::c_void,
8386 ) {
8387 // SAFETY: caller (test body below) holds storage live;
8388 // `cb_id_ptr..len` points into a `&str` literal.
8389 let cb_id_bytes = unsafe { core::slice::from_raw_parts(cb_id_ptr, cb_id_len).to_vec() };
8390 let mut guard = CAPTURED.lock().expect("CAPTURED poisoned");
8391 guard.push((state as usize, cb_id_bytes, ctx as usize));
8392 }
8393
8394 #[test]
8395 fn register_dispatch_slot_round_trip() {
8396 let session = MockSession::new();
8397 let mut executor: Executor = Executor::from_session(session);
8398
8399 // Pre-condition: empty registry.
8400 assert_eq!(executor.dispatch_slot_count(), 0);
8401
8402 // Two distinct "states" so we prove every slot gets called
8403 // with its OWN state.
8404 let mut state_blob_a: u32 = 0xABCD_0001;
8405 let mut state_blob_b: u32 = 0xABCD_0002;
8406 let state_a_ptr = &mut state_blob_a as *mut u32 as *mut core::ffi::c_void;
8407 let state_b_ptr = &mut state_blob_b as *mut u32 as *mut core::ffi::c_void;
8408
8409 executor
8410 .register_dispatch_slot(state_a_ptr, recording_on_callback)
8411 .expect("register slot A");
8412 executor
8413 .register_dispatch_slot(state_b_ptr, recording_on_callback)
8414 .expect("register slot B");
8415 assert_eq!(executor.dispatch_slot_count(), 2);
8416
8417 let mut ctx_blob: u32 = 0xFEED_BEEF;
8418 let ctx_ptr = &mut ctx_blob as *mut u32 as *mut core::ffi::c_void;
8419 let cb_id = "/talker/timer/publish";
8420
8421 CAPTURED.lock().expect("CAPTURED poisoned").clear();
8422 executor.dispatch_callback(cb_id, ctx_ptr);
8423
8424 let captured = CAPTURED.lock().expect("CAPTURED poisoned").clone();
8425 assert_eq!(
8426 captured.len(),
8427 2,
8428 "every registered slot must be invoked — linear scan, \
8429 no self-filter at the registry layer"
8430 );
8431 // The carved dispatch table iterates in insertion order.
8432 assert_eq!(captured[0].0, state_a_ptr as usize, "slot A's state");
8433 assert_eq!(captured[1].0, state_b_ptr as usize, "slot B's state");
8434 for (idx, capture) in captured.iter().enumerate() {
8435 assert_eq!(
8436 capture.1.as_slice(),
8437 cb_id.as_bytes(),
8438 "slot {idx} cb_id bytes round-trip"
8439 );
8440 assert_eq!(
8441 capture.2, ctx_ptr as usize,
8442 "slot {idx} ctx pointer round-trip"
8443 );
8444 }
8445 }
8446
8447 #[test]
8448 fn register_dispatch_slot_capacity_full() {
8449 let session = MockSession::new();
8450 let mut executor: Executor = Executor::from_session(session);
8451
8452 let mut state_blob: u32 = 0;
8453 let state_ptr = &mut state_blob as *mut u32 as *mut core::ffi::c_void;
8454
8455 // `MAX_NODES` slots fit; the next one must error.
8456 for _ in 0..crate::config::MAX_NODES {
8457 executor
8458 .register_dispatch_slot(state_ptr, recording_on_callback)
8459 .expect("under-capacity push must succeed");
8460 }
8461 assert_eq!(executor.dispatch_slot_count(), crate::config::MAX_NODES);
8462 let overflow = executor.register_dispatch_slot(state_ptr, recording_on_callback);
8463 assert!(
8464 overflow.is_err(),
8465 "over-capacity push must return Err(()) — raise \
8466 NROS_EXECUTOR_MAX_NODES at build time to grow the registry"
8467 );
8468 }
8469}
8470
8471/// Phase 274.W1 — borrowed-executor session sharing + active-groups gating.
8472///
8473/// Validates three primitives introduced for RFC-0015 Model 1:
8474/// - `session_handle` / `open_with_session_handle` (Borrowed session store —
8475/// the borrowed executor does not own or close the session on drop).
8476/// - `set_active_groups` + `group_active` (callback-group filter gating).
8477///
8478/// Uses `MockSession` (same pattern as `dispatch_registry_tests`); gated
8479/// `not(feature = "rmw-cffi")` for the same reason.
8480#[cfg(all(test, feature = "std", not(feature = "rmw-cffi")))]
8481mod p274_w1_tier_executor_tests {
8482 use super::Executor;
8483 use crate::mock::MockSession;
8484
8485 #[test]
8486 fn session_handle_borrowed_executor_shares_session_ptr() {
8487 // Open the primary executor (session owner).
8488 let session = MockSession::new();
8489 let mut primary = Executor::from_session(session);
8490
8491 // Record the primary's session pointer for later comparison.
8492 let primary_session_ptr = primary.session_ptr();
8493
8494 // Get the opaque session handle (into_raw for C FFI; here we keep it raw).
8495 let handle = primary.session_handle();
8496
8497 // Open a second executor over the SAME session (Borrowed — does not own it).
8498 // SAFETY: `primary` (the session owner) outlives `borrowed` in this scope.
8499 let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
8500
8501 // Both executors must expose the same session pointer.
8502 assert_eq!(
8503 primary.session_ptr(),
8504 borrowed.session_ptr(),
8505 "borrowed executor must share the primary's session pointer"
8506 );
8507 assert_eq!(
8508 borrowed.session_ptr(),
8509 primary_session_ptr,
8510 "session pointer must be stable across session_handle / open_with_session_handle"
8511 );
8512
8513 // Drop the borrowed executor — the primary's session must remain valid.
8514 drop(borrowed);
8515
8516 // Primary still exposes the same session pointer (session was NOT closed by drop).
8517 assert_eq!(
8518 primary.session_ptr(),
8519 primary_session_ptr,
8520 "primary session pointer must be unchanged after dropping the borrowed executor"
8521 );
8522 }
8523
8524 #[test]
8525 fn set_active_groups_gates_group_active() {
8526 let session = MockSession::new();
8527 let mut primary = Executor::from_session(session);
8528 let handle = primary.session_handle();
8529
8530 // SAFETY: primary outlives borrowed.
8531 let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
8532
8533 // Before gating: wildcard — every group is accepted.
8534 assert!(
8535 borrowed.group_active("ctrl"),
8536 "default (wildcard) must accept every group"
8537 );
8538 assert!(
8539 borrowed.group_active("telem"),
8540 "default (wildcard) must accept every group"
8541 );
8542
8543 // Gate borrowed to only the "ctrl" group (one-tier filter).
8544 borrowed.set_active_groups(&["ctrl"]);
8545
8546 assert!(
8547 borrowed.group_active("ctrl"),
8548 "\"ctrl\" must be active after set_active_groups([\"ctrl\"])"
8549 );
8550 assert!(
8551 !borrowed.group_active("telem"),
8552 "\"telem\" must NOT be active when only \"ctrl\" is gated"
8553 );
8554 assert!(
8555 !borrowed.group_active("planning"),
8556 "\"planning\" must NOT be active when only \"ctrl\" is gated"
8557 );
8558
8559 // Primary is unaffected (it still uses the wildcard).
8560 assert!(
8561 primary.group_active("telem"),
8562 "primary executor must remain unaffected (still wildcard)"
8563 );
8564
8565 // Clear the filter on borrowed — back to wildcard.
8566 borrowed.set_active_groups(&[]);
8567 assert!(
8568 borrowed.group_active("telem"),
8569 "after clearing, borrowed must accept all groups again"
8570 );
8571 }
8572
8573 #[test]
8574 fn session_handle_into_raw_from_raw_round_trip() {
8575 let session = MockSession::new();
8576 let mut primary = Executor::from_session(session);
8577 let session_ptr = primary.session_ptr();
8578
8579 let handle = primary.session_handle();
8580 let raw = handle.into_raw();
8581
8582 // into_raw must return a non-null pointer matching the session address.
8583 assert!(!raw.is_null());
8584 assert_eq!(raw as *mut _, session_ptr);
8585
8586 // from_raw must reconstruct a handle that opens the same borrowed executor.
8587 // SAFETY: primary still owns the session, raw is its address.
8588 let handle2 = unsafe { crate::executor::SessionHandle::from_raw(raw) };
8589 let mut borrowed = unsafe { Executor::open_with_session_handle(handle2) };
8590 assert_eq!(
8591 borrowed.session_ptr(),
8592 session_ptr,
8593 "from_raw reconstructed handle must open executor on the same session"
8594 );
8595 }
8596}
8597
8598/// W3b.5 — snapshot the monitored publishers' counters before a dispatch
8599/// (only when a latency contract exists; otherwise a zeroed array that
8600/// `attribute_latency` never reads).
8601fn snapshot_pub_counts(
8602 table: &'static [super::monitor::MonitorSpec],
8603 active: bool,
8604) -> [u32; super::monitor::MAX_MONITORS] {
8605 let mut counts = [0u32; super::monitor::MAX_MONITORS];
8606 if active {
8607 for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
8608 counts[k] = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
8609 }
8610 }
8611 counts
8612}
8613
8614/// W3b.5 — attribute one dispatch's elapsed time to every monitored
8615/// publisher whose counter advanced during it (an upper bound on the
8616/// node-path take → publish latency: the callback deserialized, ran, and
8617/// published within `elapsed_us`).
8618fn attribute_latency(
8619 table: &'static [super::monitor::MonitorSpec],
8620 active: bool,
8621 counts_before: &[u32; super::monitor::MAX_MONITORS],
8622 elapsed_us: u32,
8623) {
8624 if !active {
8625 return;
8626 }
8627 for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
8628 if spec.max_latency_ms == 0 {
8629 continue;
8630 }
8631 let now = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
8632 if now != counts_before[k] {
8633 spec.cell
8634 .max_latency_us
8635 .fetch_max(elapsed_us, core::sync::atomic::Ordering::Relaxed);
8636 }
8637 }
8638}
8639
8640/// W3b.5 — enforce the bound SC's deadline after a dispatch. A miss maps
8641/// through [`DeadlineAction`](super::sched_context::DeadlineAction):
8642/// `Warn`/`Skip`/`Fault` all report; `Skip` additionally masks the SC's
8643/// remaining callbacks for this cycle; `Fault` invokes the fault hook
8644/// (panic when none is registered).
8645fn check_deadline_miss(
8646 sc: Option<&super::sched_context::SchedContext>,
8647 sc_idx: usize,
8648 elapsed_us: u32,
8649 misses: &mut heapless::Vec<super::monitor::Violation, { super::monitor::MAX_VIOLATIONS }>,
8650 skipped_scs: &mut u64,
8651 fault_fn: Option<fn(&super::monitor::Violation)>,
8652) {
8653 use super::sched_context::DeadlineAction;
8654 let Some(sc) = sc else { return };
8655 let Some(deadline_us) = sc.deadline_us.get().map(|nz| nz.get()) else {
8656 return;
8657 };
8658 if matches!(sc.deadline_action, DeadlineAction::Ignore) || elapsed_us <= deadline_us {
8659 return;
8660 }
8661 let v = super::monitor::Violation {
8662 rule: "deadline-miss-runtime",
8663 // Entries carry no name at this altitude; the SC slot stands in.
8664 fqn: "sched-context",
8665 measured: elapsed_us,
8666 declared: deadline_us,
8667 };
8668 match sc.deadline_action {
8669 DeadlineAction::Ignore => {}
8670 DeadlineAction::Warn => {
8671 let _ = misses.push(v);
8672 }
8673 DeadlineAction::Skip => {
8674 if sc_idx < 64 {
8675 *skipped_scs |= 1u64 << sc_idx;
8676 }
8677 let _ = misses.push(v);
8678 }
8679 DeadlineAction::Fault => {
8680 if let Some(f) = fault_fn {
8681 f(&v);
8682 let _ = misses.push(v);
8683 } else {
8684 panic!(
8685 "nros: deadline fault — dispatch ran {elapsed_us} us past a {deadline_us} us deadline"
8686 );
8687 }
8688 }
8689 }
8690}
8691
8692// Timer-accounting clock default (issue: no_std tiers with no `clock_us`
8693// credited each spin the REQUESTED timeout — spin.rs `delta_us` fallback —
8694// so shared-session wakes made low-rate timers fire early (a 100 ms timer
8695// at ~67 ms on Zephyr native_sim) and tick rounding made fast tiers fire
8696// late. Measured on-target; mechanism documented at the fallback site.)
8697//
8698// Every platform port exports `nros_platform_clock_ns` through the same
8699// linkage contract the wake primitives rely on (`nros_platform_export_clock!`
8700// in nros-platform-cffi), so an rmw-cffi no_std build can safely default the
8701// executor clock to it; `ExecutorConfig::clock_us` stays as an override.
8702#[cfg(feature = "rmw-cffi")]
8703unsafe extern "C" {
8704 fn nros_platform_clock_ns() -> u64;
8705}
8706
8707#[cfg(feature = "rmw-cffi")]
8708fn default_platform_clock_us() -> u64 {
8709 // SAFETY: bare query of the platform's monotonic ns counter; the symbol
8710 // is guaranteed by whichever platform port linked the binary (the same
8711 // contract `nros_platform_wake_*` already depends on).
8712 //
8713 // RFC-0073 made the ABI nanoseconds; the executor's own accounting is
8714 // still microseconds (`clock_us_fn`, `delta_us`), so the division lives
8715 // here rather than being pushed into every port.
8716 unsafe { nros_platform_clock_ns() / 1_000 }
8717}
8718
8719/// Sleep, through the platform ABI.
8720///
8721/// phase-359 W10 — the spin loops used `std::thread::sleep`. Every port already
8722/// exports `nros_platform_sleep_us` (the ABI's own pacing primitive, the one an
8723/// RTOS build has always used), so a hosted loop and an embedded one now pace
8724/// the same way. The µs entry point is used rather than `_ms` because
8725/// `spin_one_period_timed` sleeps off a remainder, which rounds badly at
8726/// millisecond granularity on short periods.
8727#[cfg(feature = "alloc")]
8728pub(crate) fn platform_sleep(d: core::time::Duration) {
8729 unsafe extern "C" {
8730 fn nros_platform_sleep_us(us: usize);
8731 }
8732 let us = d.as_micros().min(usize::MAX as u128) as usize;
8733 if us == 0 {
8734 return;
8735 }
8736 // SAFETY: a bare pacing call with no pointer arguments, guaranteed by
8737 // whichever port linked the binary — the same contract the clock and wake
8738 // symbols in this file already rely on.
8739 unsafe { nros_platform_sleep_us(us) }
8740}
8741
8742/// A monotonic µs reader, or `None` when this build has no clock at all.
8743///
8744/// phase-359 W10 — one provider, chosen by what is LINKED rather than by which
8745/// flavour the crate was built in. W4 unified the executor's clock ACCESSOR and
8746/// said the provider was the last piece; this is it.
8747///
8748/// `rmw-cffi` means a platform port is linked, and every port exports
8749/// `nros_platform_clock_ns` under the same contract the wake primitives use —
8750/// so a hosted build reads the same counter an embedded one does, instead of
8751/// `Instant` on one and the platform on the other. `Instant` survives only
8752/// where there is no port to ask: `std` without `rmw-cffi`.
8753///
8754/// phase-359 W10 follow-up — this comment claimed the metadata PROBE was that
8755/// configuration, and **it is not**. The probe's generated manifest deps
8756/// `nros-platform-cffi` with `posix-c-port` (issue 0288 layer 5, so it links),
8757/// and its `nros` resolves with `rmw-cffi` ON — feature-unified from the
8758/// component's board crate. Measured on a real probe tree: `alloc default
8759/// macros rmw-cffi ros-humble std`. It takes the PORT arm.
8760///
8761/// What is real: `nros-rmw-metadata` and `nros-tests` do resolve `nros` with
8762/// `std` and no `rmw-cffi`, and have no port crate in their graphs. But nothing
8763/// in either uses this arm — deleting it and building the workspace
8764/// `--all-targets` is clean, and there is no in-tree caller of
8765/// `nros::time::now` at all. So the arm serves an OUT-OF-TREE contract only: a
8766/// consumer with `std`, no `rmw-cffi`, and its own `Session`.
8767pub(crate) fn default_clock_us_fn() -> Option<fn() -> u64> {
8768 #[cfg(feature = "rmw-cffi")]
8769 {
8770 Some(default_platform_clock_us)
8771 }
8772 #[cfg(not(feature = "rmw-cffi"))]
8773 {
8774 None
8775 }
8776}
8777
8778// =============================================================================
8779// phase-425 W3b — the `/clock` time source's executor half.
8780//
8781// Its OWN impl block, gated on `sim-time`, because the methods were first
8782// written next to `declare_parameter` — which lives in a
8783// `#[cfg(feature = "param-services")]` block, so `reconcile_ros_time_source`
8784// silently disappeared in every combo without parameter services, including the
8785// one `spin_once` calls it from.
8786// =============================================================================
8787#[cfg(all(feature = "sim-time", any(has_rmw, test)))]
8788impl<'s> Executor<'s> {
8789 /// phase-425 W3b — bring the `/clock` subscription in line with the last
8790 /// requested `use_sim_time`.
8791 ///
8792 /// Called at the head of every spin, and cheap when there is nothing to do:
8793 /// the common case is one bool comparison. It is a RECONCILE rather than an
8794 /// action at the request site because the request routinely arrives before
8795 /// there is a node to hang the subscription on — `nros::main!` emits
8796 /// `apply_param_services` before its per-node `register` calls, by design,
8797 /// so the store exists when each cell is created.
8798 ///
8799 /// Turning it off stops SAMPLES from being installed; the subscription
8800 /// itself stays, because the executor has no entity removal and inventing
8801 /// one for this would be a much larger change than the switch is worth. The
8802 /// gate is `time_source::set_active`, which the subscription callback reads.
8803 ///
8804 /// Turning it off also does NOT clear the override: a node that stops
8805 /// listening keeps the last simulated time rather than jumping back to the
8806 /// wall clock, which every ROS-time timer would otherwise absorb as a
8807 /// backwards jump.
8808 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
8809 pub(crate) fn reconcile_ros_time_source(&mut self) {
8810 // An executor nobody told about `use_sim_time` has NO opinion, and the
8811 // gate is process-global, so writing its default here is not a no-op --
8812 // it is one executor overruling another. Say nothing until asked.
8813 if !self.sim_time_stated {
8814 return;
8815 }
8816 if self.sim_time_requested == crate::time_source::is_active()
8817 && (!self.sim_time_requested || self.sim_time_source.is_some())
8818 {
8819 return;
8820 }
8821 crate::time_source::set_active(self.sim_time_requested);
8822 if self.sim_time_requested && self.sim_time_source.is_none() {
8823 // No node yet — stay pending and try again next spin. Not an error:
8824 // it is the ordinary order of a generated entry, which declares
8825 // parameters before it registers components.
8826 if self.nodes.is_empty() {
8827 return;
8828 }
8829 if let Ok(handle) = self.install_ros_time_source(
8830 super::node_record::NodeId::PRIMARY,
8831 crate::time_source::CLOCK_TOPIC,
8832 ) {
8833 self.sim_time_source = Some(handle);
8834 }
8835 }
8836 }
8837
8838 /// phase-425 W3b — re-read `use_sim_time` from the parameter store.
8839 ///
8840 /// Called after a parameter service actually handled something, which is
8841 /// what makes a runtime `ros2 param set <node> use_sim_time true` work
8842 /// without a per-spin scan of the store. The declaration path does not need
8843 /// it — `declare_parameter` records the value directly.
8844 #[cfg(all(feature = "sim-time", feature = "param-services", any(has_rmw, test)))]
8845 fn refresh_use_sim_time_from_store(&mut self) {
8846 if let Some(params) = self.params.as_ref()
8847 && let Some(enable) = params
8848 .server
8849 .get_bool(crate::time_source::USE_SIM_TIME_PARAM)
8850 {
8851 self.sim_time_requested = enable;
8852 self.sim_time_stated = true;
8853 }
8854 }
8855
8856 /// phase-425 W3b — the installed `/clock` subscription, if any.
8857 ///
8858 /// The HANDLE rather than a bool, because "did we subscribe twice" is the
8859 /// question a reconciliation loop has to be able to answer: a second
8860 /// registration takes a new slot, so a stable handle across spins is the
8861 /// evidence that the loop is idempotent.
8862 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
8863 pub fn ros_time_source_handle(&self) -> Option<HandleId> {
8864 self.sim_time_source
8865 }
8866
8867 /// phase-425 W3b — whether a `/clock` subscription is currently installed.
8868 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
8869 pub fn ros_time_source_installed(&self) -> bool {
8870 self.sim_time_source.is_some()
8871 }
8872
8873 /// phase-425 W3 — subscribe `topic` and install every sample as this
8874 /// image's ROS time. The registration behind
8875 /// [`NodeCtx::install_ros_time_source`](super::node::NodeCtx::install_ros_time_source)
8876 /// and behind the `use_sim_time` reconciliation, so both spell the QoS and
8877 /// the conversion exactly once.
8878 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
8879 pub(crate) fn install_ros_time_source(
8880 &mut self,
8881 node_id: super::node_record::NodeId,
8882 topic: &str,
8883 ) -> Result<HandleId, NodeError> {
8884 self.register_subscription_buffered_on::<
8885 nros_rosgraph_msgs::msg::Clock,
8886 _,
8887 { crate::config::DEFAULT_RX_BUF_SIZE },
8888 >(
8889 node_id,
8890 topic,
8891 QoSProfile::clock_default(),
8892 |msg: &nros_rosgraph_msgs::msg::Clock| {
8893 // The `use_sim_time` gate is read HERE rather than by removing
8894 // the subscription, because there is no entity removal. An
8895 // image that never touches `use_sim_time` and installs the
8896 // source explicitly is active by default.
8897 if !crate::time_source::is_active() {
8898 return;
8899 }
8900 if let Some(nanos) =
8901 crate::time_source::override_nanos(msg.clock.sec, msg.clock.nanosec)
8902 {
8903 nros_core::clock::Clock::set_ros_time_override(nanos);
8904 }
8905 },
8906 None, // no group — node default
8907 None, // the configured default RX buffer, unchanged
8908 )
8909 }
8910}