Skip to main content

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}