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// phase-427 — unconditional: `spin_forever` takes `SpinOptions` and is not
17// `alloc`-gated (a bare-metal entry is exactly the caller that needs it).
18use super::{
19    arena::{
20        BufferStrategy, CallbackMeta, EntryKind, GuardConditionEntry, ServiceClientCallbackEntry,
21        ServiceClientRawArenaEntry, ServiceClientSendHeader, SrvEntry, SrvRawEntry,
22        SubBufferedEntry, SubBufferedRawCEntry, SubBufferedRawEntry, SubBufferedRawInfoCEntry,
23        SubBufferedRawInfoEntry, SubBufferedTypedCEntry, SubBufferedViewEntry, SubInfoEntry,
24        SubInplaceEntry, TimerClockSource, TimerEntry, TimerHeader, TimerOverrunPolicy, TraceName,
25        always_ready, buffered_region_size, drop_entry, guard_has_data, guard_try_process,
26        no_pre_sample, service_client_callback_try_process, service_client_raw_try_process,
27        srv_has_data, srv_raw_has_data, srv_raw_try_process, srv_try_process,
28        sub_buffered_has_data, sub_buffered_raw_c_has_data, sub_buffered_raw_c_try_process,
29        sub_buffered_raw_has_data, sub_buffered_raw_info_c_has_data,
30        sub_buffered_raw_info_c_try_process, sub_buffered_raw_info_has_data,
31        sub_buffered_raw_info_try_process, sub_buffered_raw_try_process, sub_buffered_try_process,
32        sub_buffered_typed_c_has_data, sub_buffered_typed_c_try_process,
33        sub_buffered_view_has_data, sub_buffered_view_try_process, sub_info_has_data,
34        sub_info_pre_sample, sub_info_try_process, sub_inplace_has_data, sub_inplace_try_process,
35        timer_try_process,
36    },
37    node::NodeHandle,
38    spsc_ring::SpscRing,
39    triple_buffer::TripleBuffer,
40    types::{
41        ExecutorSemantics, GuardCondition, HandleId, InvocationMode, NodeError,
42        RawMessageDeserializeFn, RawResponseCallback, RawServiceCallback, RawSubscriptionCallback,
43        RawSubscriptionInfoCallback, ReadinessSnapshot, SpinOnceResult, SpinOptions,
44        SpinPeriodPollingResult, Trigger, TypedSubscriptionCallback,
45    },
46};
47
48// ============================================================================
49// phase-456 W8 — one subscription registration, stated as one value
50// ============================================================================
51
52/// What a subscription registration STATES — phase-456 W8.
53///
54/// The thirteen `register_subscription_*` / `add_arena_subscription_*` entry
55/// points differ in their DELIVERY SHAPE (typed, raw, borrowed, with-info,
56/// validated) and used to differ in their LANGUAGE, and each re-derived the
57/// same prologue from positional parameters: node identity, topic info, the
58/// declared-depth check, the backend call. Twelve copies of it, and exactly
59/// ONE of them asked the backend whether it dispatches in place.
60///
61/// This is that prologue's argument, as one value. A struct rather than nine
62/// parameters because the fields are independently optional and a caller must
63/// be able to say "I state nothing here" without reordering a call — the same
64/// reason `DescriptorInputs` is a struct one layer up.
65///
66/// Two of its fields are the axes phase-456 W8 found under issue 1319's five
67/// rows: [`backend_hint`](Self::backend_hint) is "does this site know the
68/// type's bound", and [`in_place_capable`](Self::in_place_capable) is what
69/// lets `Executor::open_subscription` ask the third axis once, for every
70/// caller, in every language.
71pub(crate) struct SubscriptionRequest<'a> {
72    /// `None` is the legacy single-node path: identity comes from the
73    /// executor's own name and namespace and the session is slot 0.
74    pub(crate) node_id: Option<super::node_record::NodeId>,
75    pub(crate) topic_name: &'a str,
76    pub(crate) type_name: &'a str,
77    pub(crate) type_hash: &'a str,
78    pub(crate) qos: QoSProfile,
79    /// Bytes the CALL SITE says it expects to receive, for the BACKEND's
80    /// payload size class (phase-231 / RFC-0038, phase-402 W2).
81    ///
82    /// `None` — and, for a C caller whose ABI spells it `0`, a zero — means
83    /// "this site stated nothing", which is a different claim from "zero
84    /// bytes": `with_rx_buffer_hint(0)` would be the second.
85    pub(crate) backend_hint: Option<usize>,
86    /// Bytes ONE receive slot claims from the arena, already derived by the
87    /// caller that has the type.
88    ///
89    /// Not the same number as [`Self::backend_hint`] — the typed Rust path
90    /// hints the type's bound and sizes the slot from
91    /// `default_subscription_rx_bytes`, which may differ — so both travel.
92    /// Ignored entirely when [`SubscriptionOpen::in_place`] comes back true.
93    pub(crate) slot_bytes: usize,
94    /// Can THIS delivery shape dispatch out of the backend's own receive slot?
95    ///
96    /// A property of the shape, not of the backend: `process_raw_in_place`
97    /// hands the callback borrowed BYTES and nothing else, so a shape that
98    /// must also deliver a wire attachment (`_info`), an integrity status
99    /// (`_validated` / `_safety`) or a `MessageInfo` cannot use it however
100    /// capable the backend is. Stated per call site so an omission is a line
101    /// someone wrote rather than a question nobody asked — which is exactly
102    /// what the C path was before W8.
103    pub(crate) in_place_capable: bool,
104}
105
106/// The result of `Executor::open_subscription`: a claimed slot, a live backend
107/// subscriber, and the ONE answer to the in-place question.
108pub(crate) struct SubscriptionOpen {
109    /// The claimed entry slot. Claimed BEFORE the fallible work, per
110    /// `Executor::emplace_entry`'s rule.
111    pub(crate) slot: usize,
112    pub(crate) handle: session::RmwSubscriber,
113    /// **The only place in this crate that
114    /// `Subscription::supports_process_in_place` is read.**
115    ///
116    /// Before phase-456 W8 the read lived inside
117    /// `register_subscription_buffered_on`, which is the Rust typed path and
118    /// only that: every C and C++ subscription on zenoh or XRCE therefore
119    /// allocated a receive region the backend does not need, at the same order
120    /// as issue 1340's ~9.7 KiB per subscription. The irony phase-456 W8
121    /// measured is that the C callback is the BETTER candidate —
122    /// `RawSubscriptionCallback` is already `(const uint8_t*, size_t, void*)`,
123    /// a borrowed-bytes signature, where the Rust typed path has to produce an
124    /// owned `&M` from somewhere.
125    pub(crate) in_place: bool,
126    /// [`SubscriptionRequest::slot_bytes`], carried through so a caller's arena
127    /// arithmetic reads ONE local rather than re-deriving it.
128    pub(crate) slot_bytes: usize,
129    /// The QoS the backend was actually given — phase-454 W13 through
130    /// phase-456 W8.
131    ///
132    /// NOT the caller's `req.qos`. `declared_qos::honour` REWRITES the depth
133    /// when the system declared one, and the twelve prologues this function
134    /// replaced each passed that rewritten value on to the entry they built.
135    /// Returning it is what keeps that true with one honouring site: a caller
136    /// that stored `req.qos` instead would size its entry from a depth the
137    /// backend never saw, and nothing would fail — the subscription would
138    /// simply hold a different number of samples than the build reserved for
139    /// it.
140    pub(crate) qos: QoSProfile,
141}
142
143// ============================================================================
144// Phase 8 — callback registration event (paired stubs)
145// ============================================================================
146//
147// `docs/design/callback_tracing.rst`. Paired stubs (the `entry_tiers.rs`
148// idiom) so `emplace_entry` reads identically whether or not the feature is
149// on, and the `#[cfg]` lives in exactly one place.
150
151/// Emit `nros_callback_register(handle, kind, name)` for a newly installed
152/// executor entry.
153#[cfg(feature = "trace-callbacks")]
154#[inline]
155fn trace_register(slot: usize, kind: EntryKind, name: TraceName<'_>) {
156    super::callback_trace::register(slot, kind, name);
157}
158
159#[cfg(not(feature = "trace-callbacks"))]
160#[inline]
161fn trace_register(_slot: usize, _kind: EntryKind, _name: TraceName<'_>) {}
162
163// ============================================================================
164// Executor::open() factory method
165// ============================================================================
166
167/// phase-271 / phase-392 W6 — the `'static` `u64` backing the `alloc`
168/// convenience constructors borrow, sized to `sizing`.
169///
170/// **The named static first, the heap second.** phase-271 made this a
171/// `Box::leak`, and a leak has no symbol: `mem-report` reads an ELF's symbol
172/// table, so the largest single RAM consumer of every Rust image was the one
173/// thing the memory instrument could not see (RFC-0002 § 4.4b). It is served
174/// from `executor::backing::EXECUTOR_BACKING` now — same bytes, same
175/// executor-lifetime reservation, in `.bss` where the linker can print them and
176/// out of the allocator arena that used to pay for them.
177///
178/// The heap arm is still reached, and both cases are legitimate rather than
179/// degraded: a SECOND executor opened through an `alloc` convenience
180/// constructor, and an entry sized past the build-time default. A tiered boot's
181/// spawned tiers are NOT that second executor any more: they open over the
182/// entry's `TierExecutorBacking` slots (`open_with_session_slot`, issue 1571). Neither is an error, so neither
183/// warns — `report_arena_headroom` is where an over-provisioned executor gets
184/// told about itself, and a duplicate line here would just be noise.
185///
186/// `alloc`-only — no_std-no-alloc entries supply their own `static`/stack
187/// backing via `open_in` / `from_session_in`.
188#[cfg(feature = "alloc")]
189fn default_backing(sizing: super::storage::ExecutorSizing) -> &'static mut [MaybeUninit<u64>] {
190    let words = sizing.u64_len();
191    match super::backing::take(words) {
192        Some(backing) => backing,
193        None => alloc::boxed::Box::leak(alloc::boxed::Box::new_uninit_slice(words)),
194    }
195}
196
197#[cfg(feature = "rmw-cffi")]
198impl<'s> Executor<'s> {
199    /// phase-271 — open a new executor session over caller-supplied `backing`,
200    /// sized by `sizing` (per-entry sizing). The core, non-generic sized entry
201    /// point: the `alloc` [`open`](Self::open) convenience leaks a default
202    /// backing and delegates here, and the `nros::main!` macro emits a backing
203    /// sized to the entry's own entity count.
204    ///
205    /// Phase 115.M.4 — auto-registers the cffi vtable for whichever
206    /// backend the build was configured for, mirroring the C++ side's
207    /// `#ifdef NROS_RMW_<NAME>` fan-out in `<rclcpp/node.hpp>`. The
208    /// runtime's atomic vtable slot is idempotent: a re-call of any
209    /// backend's `register()` is a no-op, so the fan-out below is safe
210    /// to invoke on every `Executor::open` (cheaper than a `Once` and
211    /// doesn't pull in `std::sync` for no_std targets).
212    ///
213    /// Connects to the middleware at the locator specified in `config`.
214    ///
215    /// # Safety
216    /// `backing` must be ≥ `sizing.u64_len()` words, live for `'s`, and be
217    /// otherwise untouched while the executor lives (see
218    /// [`from_session_in`](Self::from_session_in)).
219    pub unsafe fn open_in(
220        config: &ExecutorConfig<'_>,
221        backing: &'s mut [MaybeUninit<u64>],
222        sizing: super::storage::ExecutorSizing,
223    ) -> Result<Self, NodeError> {
224        use nros_rmw::Rmw;
225
226        // Phase 128.A.3 / 249 P4b.1 — manifest-driven backend selection.
227        //
228        // Every linked backend self-registered via its `.init_array`
229        // ctor before `main` (RFC-0042 §D3.3), so the registry is
230        // already populated — no runtime section walk.
231        //
232        // 1. Honour `config.rmw` — the caller's explicit backend selection,
233        //    mirroring ROS 2's `RMW_IMPLEMENTATION`. On a hosted build
234        //    `nros::ExecutorConfigEnvExt::from_env` / `nros::env::resolve_hosted`
235        //    fill it from `$NROS_RMW`; issue 0687 moved that read to the edge,
236        //    because reading a process environment is what kept `std` in this
237        //    crate and an RTOS image has no environment to read.
238        // 2. With no selector, pick the unique registered backend.
239        //    Zero registered → `NoBackend`; more than one →
240        //    `Ambiguous` (user must select one, or use
241        //    `Executor::open_multi`).
242        let selector = config.rmw;
243        match nros_rmw_cffi::resolve_backend(selector.map(str::as_bytes)) {
244            nros_rmw_cffi::BackendResolution::Single(_) => {}
245            // Issue 0436 — these are SELECTION outcomes, not transport failures,
246            // and calling them `ConnectionFailed` actively misleads: a PX4 bridge
247            // that registered two backends reported "connection failed", which
248            // reads as a router/network problem and was chased as one. Nothing is
249            // connected at this point — the resolver has not chosen a backend yet.
250            //
251            // The variant still collapses into `NodeError::Transport` (the enum has
252            // no selection arm, and adding one is an ABI change across the C/C++
253            // seams), but `InvalidConfig` at least says "your configuration is
254            // unresolvable", and the std-gated line below names WHICH outcome.
255            other => {
256                {
257                    let why: &str = match other {
258                        nros_rmw_cffi::BackendResolution::NoBackend => {
259                            "no RMW backend is registered"
260                        }
261                        nros_rmw_cffi::BackendResolution::Ambiguous => {
262                            "more than one RMW backend is registered and no \
263                             $NROS_RMW selector was set — name one (e.g. \
264                             NROS_RMW=uorb), or open per-backend sessions"
265                        }
266                        nros_rmw_cffi::BackendResolution::Unknown => {
267                            "$NROS_RMW names a backend that is not registered"
268                        }
269                        nros_rmw_cffi::BackendResolution::Single(_) => unreachable!(),
270                    };
271                    nros_log::log_error!(
272                        nros_log::get_logger("nros"),
273                        "cannot select an RMW backend — {why}"
274                    );
275                }
276                let _ = other;
277                return Err(NodeError::Transport(TransportError::InvalidConfig));
278            }
279        }
280
281        let rmw_config = nros_rmw::RmwConfig {
282            locator: config.locator,
283            mode: config.mode,
284            domain_id: config.domain_id,
285            node_name: config.node_name,
286            namespace: config.namespace,
287            properties: &[],
288        };
289        let session = if let Some(name) = selector {
290            // Selector path: route to the specific named backend so
291            // the env-var-disambiguated outcome matches what the
292            // resolver above identified.
293            nros_rmw_cffi::CffiRmw::open_with_rmw(name, &rmw_config)
294        } else {
295            nros_rmw_cffi::CffiRmw.open(&rmw_config)
296        }
297        .map_err(|e| {
298            // Issue 0465 — do not relabel. This used to discard the backend's
299            // error and report `ConnectionFailed` for every open failure, so an
300            // exhausted session pool (`InvalidConfig`) and a router that is not
301            // there produced the same sentence. Same lesson as the selection
302            // arm above: say which failure happened.
303            nros_log::log_error!(
304                nros_log::get_logger("nros"),
305                "RMW session open failed — {e:?}"
306            );
307            NodeError::Transport(e)
308        })?;
309        // SAFETY: forwarded from this fn's contract — `backing`/`sizing` sized
310        // + alive for `'s`.
311        let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
312        {
313            // `config.clock_us` overrides the constructor's platform default
314            // (issue: assigning `None` here clobbered it and re-enabled the
315            // credit-the-requested-timeout fallback).
316            if let Some(clock) = config.clock_us {
317                executor.clock_us_fn = Some(clock);
318                executor.last_spin_end_us = Some(clock());
319            }
320            // issue 0671 — the SAME rule as `clock_us` directly above, which is
321            // why that one is guarded: a config that does not SPECIFY an epoch
322            // must not be read as "this target HAS no epoch". Assigning `None`
323            // here clobbered the constructor's platform default
324            // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
325            // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
326            // so EVERY hosted node built that way silently lost its wall clock.
327            // With no epoch, `Node::subscription` never attaches the age cell
328            // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
329            // `max_age_ms` contract becomes a silently-dead monitor — the exact
330            // outcome RFC-0052 says must never happen. The rate monitor rides
331            // the GUARDED `clock_us_fn` and kept working, which is why only the
332            // age half went quiet.
333            if let Some(epoch) = config.epoch_us {
334                executor.epoch_us_fn = Some(epoch);
335            }
336        }
337        executor.set_node_identity(config.node_name, config.namespace);
338        // Issue 0656 — beside the identity, for the same reason: an entity needs
339        // both to be addressable, and this half used to be dropped here.
340        executor.domain_id = config.domain_id;
341        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
342        executor.install_wake_signal_on_primary();
343        // Phase 277 W2.c — readiness marker for E2E harnesses. This is the
344        // single call-through `open()`/`open_sized()` share, so it fires on
345        // every platform that reaches here (native, freertos, zephyr,
346        // threadx, …) regardless of which RMW backend or board owns the
347        // boot path. It replaces the per-example synthetic
348        // `log::info!("Publishing messages")` markers W4 removes — those
349        // only proved a callback had fired at least once; this line proves
350        // the session itself is up, before any node/callback exists.
351        //
352        // STABILITY CONTRACT: the leading `"nros: session open"` text is
353        // load-bearing — test harnesses grep for it verbatim. Keep it
354        // stable even if the trailing `(rmw=...)` detail changes.
355        #[cfg(feature = "log")]
356        {
357            if let Some(name) = selector {
358                log::info!("nros: session open (rmw={name})");
359            } else {
360                log::info!("nros: session open");
361            }
362        }
363        Ok(executor)
364    }
365}
366
367#[cfg(all(feature = "rmw-cffi", feature = "alloc"))]
368impl Executor<'static> {
369    /// Open a new executor session using the active RMW backend, at the
370    /// build-time default sizing. Convenience over
371    /// [`open_in`](Self::open_in): leaks a default-sized backing (executor-
372    /// lifetime) so existing callers keep the zero-storage-arg signature.
373    /// Per-entry sizing goes through `open_in` / the `nros::main!` macro.
374    ///
375    /// # Example
376    ///
377    /// ```ignore
378    /// let config = ExecutorConfig::from_env().node_name("my_node");
379    /// let mut executor = Executor::open(&config)?;
380    /// ```
381    pub fn open(config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
382        Self::open_sized(config, super::storage::ExecutorSizing::DEFAULT)
383    }
384
385    /// phase-271 — like [`open`](Self::open) but sized to a caller-supplied
386    /// `sizing` (its own declared topology) instead of the build-time default.
387    /// The `alloc` entry point the `nros::main!` macro's native board path uses
388    /// to size a fat entry (>default `MAX_CBS` callbacks) without a
389    /// workspace-global `NROS_EXECUTOR_MAX_CBS`. Leaks a `sizing`-sized backing
390    /// (executor-lifetime); no-alloc entries use `open_in` with their own static.
391    pub fn open_sized(
392        config: &ExecutorConfig<'_>,
393        sizing: super::storage::ExecutorSizing,
394    ) -> Result<Self, NodeError> {
395        // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`,
396        // uniquely owned by the returned executor.
397        unsafe { Self::open_in(config, default_backing(sizing), sizing) }
398    }
399
400    /// Phase 128.F.1 — explicit per-backend session declaration for
401    /// bridge mode. `specs[0]` becomes the primary session; `specs[1..]`
402    /// open as extras keyed by RMW name. After construction, every
403    /// `create_node_on(name, rmw)` call dispatches to whichever
404    /// session was opened under that RMW name (or, when the rmw name
405    /// matches the primary, the primary session itself).
406    ///
407    /// Single-backend callers should keep using
408    /// [`open`](Self::open) — this entry costs an extra
409    /// `open_with_rmw` per spec and adds no value when only one
410    /// backend is linked.
411    ///
412    /// `$NROS_RMW` env is ignored: bridge mode wants explicit names.
413    ///
414    /// Default-sized `alloc` convenience over
415    /// [`open_multi_in`](Self::open_multi_in) (leaks a default backing).
416    #[cfg(feature = "rmw-cffi")]
417    pub fn open_multi(specs: &[SessionSpec<'_>]) -> Result<Self, NodeError> {
418        let sizing = super::storage::ExecutorSizing::DEFAULT;
419        // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`.
420        unsafe { Self::open_multi_in(specs, default_backing(sizing), sizing) }
421    }
422
423    /// Phase 104.C.1 — open the Executor against a specific RMW
424    /// backend by name. Selects from the named registry (Phase
425    /// 104.B.2). `rmw_name` must match one of the names a backend
426    /// registered under (`"zenoh"`, `"cyclonedds"`, `"xrce"`, …).
427    ///
428    /// Equivalent to [`Executor::open`] when the registry has exactly
429    /// one backend (the default-backend fast path). Use this entry
430    /// point in multi-backend builds where `Executor::open` would
431    /// pick the first-registered slot.
432    ///
433    /// Single-Executor multi-Node multi-RMW (the long-term Design X
434    /// from `docs/roadmap/phase-104-multi-backend-bridges.md`) is
435    /// follow-up work — Phase 104.C.2 + C.3.
436    ///
437    /// Default-sized `alloc` convenience over
438    /// [`open_with_rmw_in`](Self::open_with_rmw_in) (leaks a default backing).
439    #[cfg(feature = "rmw-cffi")]
440    pub fn open_with_rmw(rmw_name: &str, config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
441        let sizing = super::storage::ExecutorSizing::DEFAULT;
442        // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`.
443        unsafe { Self::open_with_rmw_in(rmw_name, config, default_backing(sizing), sizing) }
444    }
445}
446
447// phase-271 — no-alloc sized cores for the bridge/named open paths. In
448// `impl<'s>` (not the `'static` alloc block) so they stay available in
449// `rmw-cffi`-without-`alloc` builds (e.g. the `nros-bridge` no_std default),
450// which is where `open_multi`/`open_with_rmw` lived before.
451#[cfg(feature = "rmw-cffi")]
452impl<'s> Executor<'s> {
453    /// Per-entry-sized [`open_multi`](Self::open_multi): carves `backing` for
454    /// the executor's tables instead of leaking a default one.
455    ///
456    /// # Safety
457    /// `backing`/`sizing` as in [`from_session_in`](Self::from_session_in).
458    pub unsafe fn open_multi_in(
459        specs: &[SessionSpec<'_>],
460        backing: &'s mut [MaybeUninit<u64>],
461        sizing: super::storage::ExecutorSizing,
462    ) -> Result<Self, NodeError> {
463        // Phase 249 P4b.1 — backends self-registered via their
464        // `.init_array` ctor before `main`; no runtime section walk.
465        let primary = specs
466            .first()
467            .ok_or(NodeError::Transport(TransportError::ConnectionFailed))?;
468        let primary_session =
469            nros_rmw_cffi::CffiRmw::open_with_rmw(primary.rmw, &primary.to_rmw_config())
470                .map_err(NodeError::Transport)?;
471        // SAFETY: forwarded from this fn's contract.
472        let mut executor = unsafe { Self::from_session_in(primary_session, backing, sizing) };
473        executor.set_node_identity("", "/");
474        // Phase 156 — see `Executor::open` for primary-identity
475        // recording rationale.
476        let _ = executor.primary_rmw_name.push_str(primary.rmw);
477        let _ = executor.primary_locator.push_str(primary.locator);
478        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
479        executor.install_wake_signal_on_primary();
480
481        for spec in specs.iter().skip(1) {
482            let session = nros_rmw_cffi::CffiRmw::open_with_rmw(spec.rmw, &spec.to_rmw_config())
483                .map_err(NodeError::Transport)?;
484            executor
485                .extra_sessions
486                .push(session)
487                .map_err(|_| NodeError::NodeTableFull)?;
488            // Issue 0436 — record WHICH backend/locator this extra is, so
489            // `NodeBuilder::rmw(name)` can find it instead of opening a second
490            // session against the same (singleton) backend.
491            {
492                let mut rmw_s = heapless::String::<32>::new();
493                let _ = rmw_s.push_str(spec.rmw);
494                let mut loc_s = heapless::String::<128>::new();
495                let _ = loc_s.push_str(spec.locator);
496                let _ = executor.extra_session_ids.push((rmw_s, loc_s));
497            }
498            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
499            {
500                let idx = executor.extra_sessions.len() - 1;
501                executor.install_wake_signal_on_extra(idx);
502            }
503        }
504
505        Ok(executor)
506    }
507
508    /// Per-entry-sized [`open_with_rmw`](Self::open_with_rmw): carves `backing`
509    /// for the executor's tables instead of leaking a default one.
510    ///
511    /// # Safety
512    /// `backing`/`sizing` as in [`from_session_in`](Self::from_session_in).
513    pub unsafe fn open_with_rmw_in(
514        rmw_name: &str,
515        config: &ExecutorConfig<'_>,
516        backing: &'s mut [MaybeUninit<u64>],
517        sizing: super::storage::ExecutorSizing,
518    ) -> Result<Self, NodeError> {
519        if !nros_rmw_cffi::backend_registered() {
520            return Err(NodeError::Transport(TransportError::ConnectionFailed));
521        }
522
523        let rmw_config = nros_rmw::RmwConfig {
524            locator: config.locator,
525            mode: config.mode,
526            domain_id: config.domain_id,
527            node_name: config.node_name,
528            namespace: config.namespace,
529            properties: &[],
530        };
531        let session = nros_rmw_cffi::CffiRmw::open_with_rmw(rmw_name, &rmw_config)
532            .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed))?;
533        // SAFETY: forwarded from this fn's contract.
534        let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
535        {
536            // `config.clock_us` overrides the constructor's platform default
537            // (issue: assigning `None` here clobbered it and re-enabled the
538            // credit-the-requested-timeout fallback).
539            if let Some(clock) = config.clock_us {
540                executor.clock_us_fn = Some(clock);
541                executor.last_spin_end_us = Some(clock());
542            }
543            // issue 0671 — the SAME rule as `clock_us` directly above, which is
544            // why that one is guarded: a config that does not SPECIFY an epoch
545            // must not be read as "this target HAS no epoch". Assigning `None`
546            // here clobbered the constructor's platform default
547            // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
548            // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
549            // so EVERY hosted node built that way silently lost its wall clock.
550            // With no epoch, `Node::subscription` never attaches the age cell
551            // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
552            // `max_age_ms` contract becomes a silently-dead monitor — the exact
553            // outcome RFC-0052 says must never happen. The rate monitor rides
554            // the GUARDED `clock_us_fn` and kept working, which is why only the
555            // age half went quiet.
556            if let Some(epoch) = config.epoch_us {
557                executor.epoch_us_fn = Some(epoch);
558            }
559        }
560        executor.set_node_identity(config.node_name, config.namespace);
561        // Phase 156 — record primary identity for the session-
562        // cache hit path. See `Executor::open` for the rationale.
563        let _ = executor.primary_rmw_name.push_str(rmw_name);
564        let _ = executor.primary_locator.push_str(config.locator);
565        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
566        executor.install_wake_signal_on_primary();
567        Ok(executor)
568    }
569}
570
571/// Phase 128.F.1 — per-backend session declaration for
572/// [`Executor::open_multi`]. Each spec names an RMW backend (must
573/// match one a backend registered under via
574/// `nros_rmw_cffi_register_named` / the `RMW_INIT_ENTRIES` linker
575/// section) and the locator + domain id to open against it.
576#[cfg(feature = "rmw-cffi")]
577#[derive(Clone, Copy)]
578pub struct SessionSpec<'cfg> {
579    pub rmw: &'cfg str,
580    pub locator: &'cfg str,
581    pub domain_id: u32,
582    pub node_name: &'cfg str,
583    pub namespace: &'cfg str,
584}
585
586#[cfg(feature = "rmw-cffi")]
587impl<'cfg> SessionSpec<'cfg> {
588    /// Minimal spec — just RMW name + locator. Domain id defaults to
589    /// 0; node name and namespace are empty.
590    pub const fn new(rmw: &'cfg str, locator: &'cfg str) -> Self {
591        Self {
592            rmw,
593            locator,
594            domain_id: 0,
595            node_name: "",
596            namespace: "",
597        }
598    }
599
600    pub const fn domain_id(mut self, domain_id: u32) -> Self {
601        self.domain_id = domain_id;
602        self
603    }
604
605    pub const fn node_name(mut self, name: &'cfg str) -> Self {
606        self.node_name = name;
607        self
608    }
609
610    pub const fn namespace(mut self, ns: &'cfg str) -> Self {
611        self.namespace = ns;
612        self
613    }
614
615    fn to_rmw_config(self) -> nros_rmw::RmwConfig<'cfg> {
616        nros_rmw::RmwConfig {
617            locator: self.locator,
618            mode: nros_rmw::SessionMode::Client,
619            domain_id: self.domain_id,
620            node_name: self.node_name,
621            namespace: self.namespace,
622            properties: &[],
623        }
624    }
625}
626
627// Phase 128.A.3 — selector for the single-backend resolution path.
628//
629// On hosted (`std`) builds, read `$NROS_RMW`; mirrors ROS 2's
630// `RMW_IMPLEMENTATION`. Returns the name as a byte vector so the
631// caller can pass it to `nros_rmw_cffi::resolve_backend` and (when
632// `Some`) to `CffiRmw::open_with_rmw`.
633//
634// On `no_std` / bare-metal builds, environment variables are not
635// available; resolution always falls through to the single-backend
636// or ambiguous path. Embedded users with multiple backends use the
637// bridge surface `Executor::open_multi` instead.
638// phase-359 W10 / issue 0687 — the private reader that stood here is gone, and
639// so is the shared one that briefly replaced it. `ExecutorConfig::rmw` carries
640// the selection instead: `nros::rmw_selector` reads the variable ONCE, at the
641// hosted edge, and every consumer takes the value.
642
643// ============================================================================
644// SessionStore — owned or borrowed session
645// ============================================================================
646
647/// Session storage: owned or borrowed via raw pointer.
648///
649/// The C API creates a session in `rclc_support_init()` before the
650/// executor. `Borrowed` lets the executor use that session without owning it.
651#[allow(clippy::large_enum_variant)]
652pub(crate) enum SessionStore {
653    Owned(session::ConcreteSession),
654    Borrowed(*mut session::ConcreteSession),
655}
656
657impl core::ops::Deref for SessionStore {
658    type Target = session::ConcreteSession;
659    fn deref(&self) -> &session::ConcreteSession {
660        match self {
661            SessionStore::Owned(s) => s,
662            SessionStore::Borrowed(ptr) => unsafe { &**ptr },
663        }
664    }
665}
666
667impl core::ops::DerefMut for SessionStore {
668    fn deref_mut(&mut self) -> &mut session::ConcreteSession {
669        match self {
670            SessionStore::Owned(s) => s,
671            SessionStore::Borrowed(ptr) => unsafe { &mut **ptr },
672        }
673    }
674}
675
676/// Phase 228.E — an opaque, `Send` handle to an [`Executor`]'s RMW session.
677///
678/// In the per-tier model the boot executor opens the one session and hands each
679/// spawned tier task a handle (not a borrow) so the task opens its own
680/// [`Executor`] over that *same* session across the RTOS task boundary. Wrapping
681/// the `pub(crate)` session pointer lets board crates (`nros-board-linux`,
682/// `nros-board-freertos`, …) name + move the handle without naming the session
683/// type. Obtain via [`Executor::session_handle`]; consume via
684/// [`Executor::open_with_session_handle`].
685#[cfg(any(has_rmw, test))]
686pub struct SessionHandle(*mut session::ConcreteSession);
687
688// SAFETY: the per-tier model deliberately shares one session across RTOS tasks;
689// concurrent access is serialized by the RMW backend's internal locks (the RTOS
690// targets build zenoh-pico `Z_FEATURE_MULTI_THREAD=1` — RFC-0032 §5.0). The
691// boot executor owns the session and outlives every tier task.
692#[cfg(any(has_rmw, test))]
693unsafe impl Send for SessionHandle {}
694
695#[cfg(any(has_rmw, test))]
696impl SessionHandle {
697    /// Phase 274.W1 — convert to an opaque `*mut c_void` for C/C++ FFI.
698    ///
699    /// The returned pointer encodes the session address and is valid as long as
700    /// the owning executor lives. Reconstruct via [`Self::from_raw`].
701    pub fn into_raw(self) -> *mut core::ffi::c_void {
702        self.0 as *mut core::ffi::c_void
703    }
704
705    /// Phase 274.W1 — reconstruct a `SessionHandle` from an opaque pointer
706    /// returned by [`Self::into_raw`].
707    ///
708    /// # Safety
709    /// `ptr` must be a pointer obtained from `into_raw()` on a `SessionHandle`
710    /// whose underlying session is still live and owned by its original executor.
711    pub unsafe fn from_raw(ptr: *mut core::ffi::c_void) -> Self {
712        Self(ptr as *mut session::ConcreteSession)
713    }
714}
715
716/// Phase 228.C — pure callback-group filter decision. `None` = wildcard (accept
717/// every group); `Some` = accept only listed groups. Backs
718/// [`Executor::group_active`]; split out so the logic is unit-testable without a
719/// live session.
720pub(crate) fn group_filter_accepts(
721    active: Option<&[super::storage::ActiveGroup]>,
722    node: u64,
723    group: &str,
724) -> bool {
725    match active {
726        None => true,
727        Some(v) => v.iter().any(|(n, g)| *n == node && g.as_str() == group),
728    }
729}
730
731/// Bytes the parameter store asks the platform heap for, in ONE allocation.
732///
733/// Issue 1706 -- the size of THIS build's store, which folds every knob that
734/// shapes it (`MAX_PARAMETERS`, which a contract's `params:` derives, and the
735/// board's string/array capacities). No formula and no measured constant: the
736/// compiler's own answer, so the number reported below cannot drift from the
737/// allocation it describes.
738#[cfg(all(feature = "param-store", feature = "alloc"))]
739pub const PARAMETER_STORE_BYTES: usize = core::mem::size_of::<nros_params::ParameterStorage>();
740
741/// Issue 1706 -- a TEST seam standing in for a platform heap that returns NULL
742/// for the store's request, on the current thread only (tests run in
743/// parallel). Not a test `#[global_allocator]`: `check-feature-contract`
744/// clause (e) allows exactly one, `nros-platform`'s.
745#[cfg(all(test, feature = "std", feature = "param-store"))]
746pub(crate) struct RefuseParameterStore;
747
748#[cfg(all(test, feature = "std", feature = "param-store"))]
749std::thread_local! {
750    static REFUSE_PARAMETER_STORE: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
751}
752
753#[cfg(all(test, feature = "std", feature = "param-store"))]
754impl RefuseParameterStore {
755    // Only `executor::tests` arms it, and that module is mock-session only.
756    #[cfg(not(feature = "rmw-cffi"))]
757    pub(crate) fn arm() -> Self {
758        REFUSE_PARAMETER_STORE.with(|c| c.set(true));
759        Self
760    }
761
762    fn armed() -> bool {
763        REFUSE_PARAMETER_STORE.with(core::cell::Cell::get)
764    }
765}
766
767#[cfg(all(test, feature = "std", feature = "param-store"))]
768impl Drop for RefuseParameterStore {
769    fn drop(&mut self) {
770        REFUSE_PARAMETER_STORE.with(|c| c.set(false));
771    }
772}
773
774/// Issue 1706 -- say, once, that the parameter store did not fit.
775///
776/// The RFC-0100 D11 shape, one layer up from Cyclone's boot check: the
777/// failure is named where it is decided, with the number a reader needs to
778/// size the heap, instead of surfacing as an anonymous halt in the allocator.
779/// What it can name is the REQUEST; the heap's own report (Zephyr's `HEAP
780/// EXHAUSTED` line, the board's `nros: heap peak` line) carries the capacity.
781#[cfg(feature = "param-store")]
782#[cold]
783fn report_parameter_store_refused() {
784    // Issue 1812 -- with no heap there was no allocation to refuse: the
785    // backing carved no store, and that is the whole story.
786    #[cfg(not(feature = "alloc"))]
787    {
788        let logger = nros_log::get_logger("nros");
789        nros_log::log_error!(
790            logger,
791            "parameter store refused: this executor's backing carves none and the build has no heap"
792        );
793        nros_log::log_error!(
794            logger,
795            "every parameter declaration on this executor is refused (issue 1812)"
796        );
797        nros_log::log_error!(
798            logger,
799            "open it with ExecutorSizing::params > 0 (NROS_PARAM_STORE=1, NROS_MAX_PARAMETERS)"
800        );
801    }
802    #[cfg(feature = "alloc")]
803    report_parameter_heap_store_refused();
804}
805
806/// The `alloc` half of [`report_parameter_store_refused`]: the heap refused
807/// the store's one allocation (issue 1706).
808#[cfg(all(feature = "param-store", feature = "alloc"))]
809#[cold]
810fn report_parameter_heap_store_refused() {
811    let slots = nros_params::MAX_PARAMETERS;
812    // THREE short lines, not one sentence: a log backend truncates a long
813    // line (Zephyr's cut the first draft at its slot count), and the number
814    // is the one part that must survive.
815    let logger = nros_log::get_logger("nros");
816    nros_log::log_error!(
817        logger,
818        "parameter store refused: needs {} bytes in one allocation ({} slots x {})",
819        PARAMETER_STORE_BYTES,
820        slots,
821        PARAMETER_STORE_BYTES / if slots == 0 { 1 } else { slots }
822    );
823    nros_log::log_error!(
824        logger,
825        "every parameter declaration on this executor is refused (issue 1706)"
826    );
827    // phase-382 W3' -- the remedy that takes the store OUT of the heap comes
828    // first: a build that knows the image builds a store carves it from the
829    // executor backing instead (`nros_params::IMPLIED_STORE_SLOTS`).
830    nros_log::log_error!(
831        logger,
832        "declare the parameters in the contract (params:) or state NROS_PARAM_STORE=1"
833    );
834    nros_log::log_error!(
835        logger,
836        "and the store is carved from the executor backing; or raise the image heap"
837    );
838}
839
840/// `text` typed as `declared`'s type — phase-482 W5. `None` when it does not
841/// parse as that type, or the type takes no override yet (arrays).
842///
843/// A `NotSet` declaration has no type to follow, so the text decides: `true` /
844/// `false`, then an integer, then a double, then a string — the order rcl's
845/// YAML reading gives an unquoted scalar.
846#[cfg(all(feature = "param-store", feature = "alloc"))]
847fn typed_param_override(
848    text: &str,
849    declared: &nros_params::ParameterValue,
850) -> Option<nros_params::ParameterValue> {
851    use nros_params::ParameterValue as V;
852    let boolean = |t: &str| match t {
853        "true" | "True" | "TRUE" => Some(true),
854        "false" | "False" | "FALSE" => Some(false),
855        _ => None,
856    };
857    let string = |t: &str| heapless::String::try_from(t).ok().map(V::String);
858    match declared {
859        V::Bool(_) => boolean(text).map(V::Bool),
860        V::Integer(_) => text.parse::<i64>().ok().map(V::Integer),
861        V::Double(_) => text.parse::<f64>().ok().map(V::Double),
862        V::String(_) => string(text),
863        V::NotSet => boolean(text)
864            .map(V::Bool)
865            .or_else(|| text.parse::<i64>().ok().map(V::Integer))
866            .or_else(|| text.parse::<f64>().ok().map(V::Double))
867            .or_else(|| string(text)),
868        _ => None,
869    }
870}
871
872#[cfg(test)]
873mod group_filter_tests {
874    use super::group_filter_accepts;
875    use crate::executor::storage::{ActiveGroup, node_identity_hash};
876
877    type Group = heapless::String<32>;
878
879    fn entry(node: &str, ns: &str, g: &str) -> ActiveGroup {
880        let mut s = Group::new();
881        s.push_str(g).unwrap();
882        (node_identity_hash(node, ns), s)
883    }
884
885    #[test]
886    fn wildcard_accepts_all() {
887        assert!(group_filter_accepts(
888            None,
889            node_identity_hash("n", "/"),
890            "anything"
891        ));
892    }
893
894    #[test]
895    fn set_accepts_only_listed_groups() {
896        let active = [entry("n", "/", "ctrl")];
897        let n = node_identity_hash("n", "/");
898        assert!(group_filter_accepts(Some(&active[..]), n, "ctrl"));
899        assert!(!group_filter_accepts(Some(&active[..]), n, "telem"));
900    }
901
902    /// issue 1172 — the SAME group id on a DIFFERENT node is not this tier's.
903    ///
904    /// This is the case the whole change exists for: two nodes may legitimately
905    /// declare `ctrl`, pinned to different tiers, and the old filter — keyed on
906    /// the group name alone — admitted both onto whichever tier asked first.
907    #[test]
908    fn the_same_group_on_another_node_is_not_accepted() {
909        let active = [entry("talker", "/", "ctrl")];
910        assert!(group_filter_accepts(
911            Some(&active[..]),
912            node_identity_hash("talker", "/"),
913            "ctrl"
914        ));
915        assert!(
916            !group_filter_accepts(
917                Some(&active[..]),
918                node_identity_hash("listener", "/"),
919                "ctrl"
920            ),
921            "another node's identically-named group must NOT be admitted"
922        );
923    }
924
925    /// The namespace is part of the identity, not decoration: two nodes may
926    /// share a bare name under different namespaces, which is legal in ROS.
927    #[test]
928    fn the_same_name_under_another_namespace_is_a_different_node() {
929        let active = [entry("talker", "/left", "ctrl")];
930        assert!(!group_filter_accepts(
931            Some(&active[..]),
932            node_identity_hash("talker", "/right"),
933            "ctrl"
934        ));
935    }
936
937    /// An empty namespace and `/` are the SAME node. Both ends normalise, and
938    /// if only one did, a filter would never match what it named.
939    #[test]
940    fn an_empty_namespace_normalises_to_root() {
941        assert_eq!(
942            node_identity_hash("talker", ""),
943            node_identity_hash("talker", "/")
944        );
945    }
946
947    /// The separator is load-bearing: without it `("ab","c")` and `("a","bc")`
948    /// would hash alike, and a node name may contain any namespace character.
949    #[test]
950    fn the_name_namespace_split_cannot_be_forged() {
951        assert_ne!(
952            node_identity_hash("ab", "/c"),
953            node_identity_hash("a", "b/c")
954        );
955    }
956
957    /// phase-409 — the wildcard and an EMPTY filter are different answers, and
958    /// the carved table cannot tell them apart on its own (the old
959    /// `Option<Vec>` could). `Executor::active_groups_filtering` is what keeps
960    /// them apart; this pins the distinction at the decision itself.
961    #[test]
962    fn an_empty_filter_is_not_the_wildcard() {
963        let empty: [ActiveGroup; 0] = [];
964        let n = node_identity_hash("n", "/");
965        assert!(!group_filter_accepts(Some(&empty[..]), n, "anything"));
966        assert!(group_filter_accepts(None, n, "anything"));
967    }
968}
969
970// ============================================================================
971// Executor
972// ============================================================================
973
974/// Backend-agnostic executor that owns a session.
975///
976/// Provides `create_node()` for entity creation and `drive_io()` for polling.
977///
978/// # Callback Mode
979///
980/// The executor supports arena-based callback registration via the
981/// `node_mut(id).subscription(t)` builder and
982/// [`register_service()`](Self::register_service), with dispatch via
983/// [`spin_once()`](Self::spin_once). No heap allocation is needed.
984///
985/// The sizes are set via `NROS_EXECUTOR_MAX_CBS` (default 4) and
986/// `NROS_EXECUTOR_ARENA_SIZE` (default 4096) environment variables at build time.
987///
988/// Phase 124.B.2 — opaque context handed to the runtime wake
989/// callback. Backends store the raw pointer + invoke the callback;
990/// the callback decodes back to `&WakeCtx`.
991#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
992pub(crate) struct WakeCtx {
993    pub(crate) flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
994    /// The wake primitive. Phase 130.3 added it beside a `std::sync::Condvar`
995    /// pair and noted that "a future migration to a single primitive flips one
996    /// branch instead of two"; phase-359 W10 is that migration, and this is the
997    /// one that survived.
998    pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
999}
1000
1001/// Phase 124.B.2 — runtime wake callback.
1002///
1003/// RT-context contract:
1004///
1005/// * **Thread-safe**: callable from any thread. The cb is lock-free
1006///   on the cv path — no mutex held during `notify_all`. Lost-wakeup
1007///   is prevented by the waiter checking `wake_flag` under
1008///   `wake_mu` via the `wait_timeout_while` predicate.
1009/// * **NOT async-signal-safe on POSIX**: `pthread_cond_signal`
1010///   isn't on the POSIX async-signal-safe function list. For POSIX
1011///   signal handler wake, use a `signalfd` + select pattern in a
1012///   thread that owns the wake duty — that pattern is Linux-only
1013///   (`signalfd`/`eventfd` are Linux syscalls, not POSIX), which is
1014///   why the `WakeSignalFd` worker is gated `target_os = "linux"`.
1015/// * **RTOS ISR**: per-RTOS platform layer wraps the cv with an
1016///   ISR-safe primitive (`xSemaphoreGiveFromISR`,
1017///   `tx_event_flags_set` from ISR, `k_sem_give` from ISR on
1018///   Zephyr). Backend's ISR caller routes through the platform's
1019///   `signal_from_isr` API instead of this cb directly.
1020/// * **Bounded execution time**: O(1) — atomic store + cv notify.
1021///   No allocation, no contended lock.
1022///
1023/// The cb is the symbol backends invoke from their async wake path
1024/// (datagram arrival, worker-thread enqueue, etc.). It does
1025/// flag-write + condvar-signal in that order, lock-free.
1026#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1027pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb(ctx: *mut core::ffi::c_void) {
1028    if ctx.is_null() {
1029        return;
1030    }
1031    // Phase 141.B.2 — capture T0 at cb entry. No-op when the
1032    // probe feature is off or no cycle reader is installed.
1033    #[cfg(feature = "wake-latency-probe")]
1034    super::wake_probe::on_wake();
1035    // SAFETY: ctx points at a `WakeCtx` owned by an Executor still
1036    // alive at the time of the call. `Executor::clear_wake_signal`
1037    // discharges that obligation — `set_wake_callback(None, _)` on the
1038    // primary and every extra session — from `Executor::close` (before
1039    // the session closes) and from the end of `Executor::drop` (before
1040    // `wake_ctx` is dropped). Issue 1385: until then this comment named
1041    // an "`install_wake_*` teardown path" that did not exist, and
1042    // `set_wake_callback(None` occurred nowhere in the tree but here.
1043    let wake = unsafe { &*(ctx as *const WakeCtx) };
1044    // Lock-free: the `flag` store is SeqCst and therefore happens-before any
1045    // subsequent acquire in the waiter, so a wake cannot be missed even though
1046    // nothing is held here.
1047    wake.flag.store(true, core::sync::atomic::Ordering::SeqCst);
1048    // phase-359 W10 — ONE primitive. This used to signal both a
1049    // `std::sync::Condvar` and (since phase 130.3) the `NodeWake`, "so the cb
1050    // keeps working whichever wait primitive spin_once is using". `spin_once`
1051    // now has only one, so this has only one to signal.
1052    if let Some(nw) = wake.node_wake.as_ref() {
1053        nw.signal();
1054    }
1055}
1056
1057/// phase-436 — the callback a backend holds when several executors share its
1058/// session: signal every member of the [`WakeFanout`](super::wake_fanout)
1059/// whose address is `ctx`. The entry is a `static`, so `ctx` never dangles.
1060#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1061pub(crate) unsafe extern "C" fn nros_rmw_fanout_wake_cb(ctx: *mut core::ffi::c_void) {
1062    if ctx.is_null() {
1063        return;
1064    }
1065    // SAFETY: `ctx` is `WakeFanout::as_ctx` of a `static` pool entry.
1066    let fanout = unsafe { &*(ctx as *const super::wake_fanout::WakeFanout) };
1067    fanout.fire(nros_rmw_runtime_wake_cb);
1068}
1069
1070/// phase-467 Row 8 — the symbol a backend invokes when the ROS GRAPH changes.
1071///
1072/// `rmw_event_callback_t`'s shape, so it fits the ABI slot unchanged:
1073/// `user_data` plus a coalesced count, and no payload. The count is IGNORED on
1074/// purpose — a guard condition is a level, not a queue, so N changes observed
1075/// before the executor spins are one dispatch. That is the same collapse
1076/// `guard_try_process` already performs on a flag several `trigger()`s set.
1077///
1078/// RT-context contract: identical to [`nros_rmw_runtime_wake_cb`] and for the
1079/// same reason — a relaxed-store-plus-wake, O(1), no allocation, no lock. Both
1080/// are called from a transport or worker thread.
1081///
1082/// # Safety
1083///
1084/// `user_data` must be the pointer `Executor::install_graph_change_guard`
1085/// passed: a `GuardCondition` in a box that executor owns. The obligation is
1086/// discharged by `clear_graph_change_signal`, which retires the pointer from
1087/// the backend BEFORE the box is freed, from `close()` and from `drop`. Issue
1088/// 1385 is what the other ordering costs.
1089#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1090pub(crate) unsafe extern "C" fn nros_rmw_runtime_graph_change_cb(
1091    user_data: *const core::ffi::c_void,
1092    _count: usize,
1093) {
1094    if user_data.is_null() {
1095        return;
1096    }
1097    // SAFETY: see this function's `# Safety`.
1098    let guard = unsafe { &*(user_data as *const GuardCondition) };
1099    guard.trigger();
1100}
1101
1102/// Phase 124.B.7.c — Linux signalfd worker.
1103///
1104/// Owns a Linux `eventfd` plus a worker task that `read()`s the
1105/// fd and forwards it as a wake. The eventfd
1106/// write side is async-signal-safe per the kernel contract
1107/// (`write(2)` to an eventfd is permitted from signal handlers),
1108/// closing the gap that `pthread_cond_signal` leaves open on POSIX.
1109///
1110/// Lifecycle:
1111///   * Constructed lazily in `Executor::signal_fd()` on first
1112///     caller request.
1113///   * `Drop` writes a shutdown sentinel + joins the worker.
1114///
1115/// Caller flow (signal handler):
1116///   1. Get fd via `Executor::signal_fd()` before installing the
1117///      handler.
1118///   2. Handler does `eventfd_write(fd, 1)` (equivalently,
1119///      `write(fd, &1u64, 8)`).
1120///   3. Worker thread reads the fd, signals wake_cv. spin_once
1121///      blocked in cv.wait_timeout_while sees flag=true and exits.
1122///
1123/// phase-359 W10 — the worker is a PLATFORM TASK, not a `std::thread`, and it
1124/// forwards through the same [`NodeWake`](super::node_wake::NodeWake) the
1125/// runtime wake callback uses rather than a `std::sync::Condvar`. The eventfd
1126/// itself is still Linux — that is what makes the write async-signal-safe — so
1127/// this stays `target_os = "linux"`; what it no longer is, is std-only.
1128#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1129pub struct WakeSignalFd {
1130    fd: core::ffi::c_int,
1131    ctx: portable_atomic_util::Arc<SignalFdCtx>,
1132    task: Option<nros_platform_api::task::PlatformTask>,
1133}
1134
1135/// What the signalfd worker task needs, reachable through one pointer.
1136#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1137struct SignalFdCtx {
1138    fd: core::ffi::c_int,
1139    shutdown: portable_atomic::AtomicBool,
1140    /// The executor's wake state — the same `WakeCtx` the runtime callback
1141    /// decodes, reached as an address so the context is plainly `Send`.
1142    wake_ctx: usize,
1143}
1144
1145/// Worker entry: read the eventfd, forward as a wake, until shut down.
1146///
1147/// # Safety
1148/// `arg` must point at a live [`SignalFdCtx`] whose `wake_ctx` addresses a
1149/// `WakeCtx` that outlives this task.
1150#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1151unsafe extern "C" fn signal_fd_worker(arg: *mut core::ffi::c_void) -> *mut core::ffi::c_void {
1152    // SAFETY: the spawn site passes `Arc::as_ptr` of a ctx it keeps alive until
1153    // after the join in `Drop`.
1154    let ctx = unsafe { &*(arg as *const SignalFdCtx) };
1155    loop {
1156        let mut buf = [0u8; 8];
1157        // SAFETY: reading 8 bytes from an eventfd into an 8-byte buffer.
1158        let n = unsafe { libc::read(ctx.fd, buf.as_mut_ptr() as *mut core::ffi::c_void, 8) };
1159        if ctx.shutdown.load(portable_atomic::Ordering::Acquire) {
1160            return core::ptr::null_mut();
1161        }
1162        if n <= 0 {
1163            // EINTR / EOF — shutdown was re-checked above, so loop.
1164            continue;
1165        }
1166        // Same effect as `nros_rmw_runtime_wake_cb`, reached directly because
1167        // the shutdown check above plus `Drop`'s join is what guarantees the
1168        // `WakeCtx` is still alive.
1169        // SAFETY: `wake_ctx` addresses the executor's `WakeCtx`, which outlives
1170        // this task by that same guarantee.
1171        unsafe {
1172            let w = &*(ctx.wake_ctx as *const WakeCtx);
1173            w.flag.store(true, portable_atomic::Ordering::SeqCst);
1174            if let Some(wake) = w.node_wake.as_ref() {
1175                wake.signal();
1176            }
1177        }
1178    }
1179}
1180
1181#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1182impl WakeSignalFd {
1183    /// Spawn the worker. `wake_ctx_ptr` is the `*const WakeCtx`
1184    /// produced by `Executor::wake_ctx_ptr` — same value the
1185    /// runtime wake cb decodes.
1186    ///
1187    /// phase-359 W10 — returns `NodeError` rather than `std::io::Error`. The
1188    /// only caller is `Executor::signal_fd`, and the errno detail it used to
1189    /// carry had no consumer: the one failure a caller can act on is "this
1190    /// platform would not give me the worker", which is what
1191    /// `NotInitialized` says — the eventfd and the task are both subsystems
1192    /// this capability requires and neither is guaranteed.
1193    fn new(wake_ctx_ptr: *const WakeCtx) -> Result<Self, NodeError> {
1194        // SAFETY: `eventfd(2)` with a valid flag; returns -1 on failure.
1195        let fd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) };
1196        if fd < 0 {
1197            return Err(NodeError::NotInitialized);
1198        }
1199        let ctx = portable_atomic_util::Arc::new(SignalFdCtx {
1200            fd,
1201            shutdown: portable_atomic::AtomicBool::new(false),
1202            wake_ctx: wake_ctx_ptr as usize,
1203        });
1204        let arg = portable_atomic_util::Arc::as_ptr(&ctx) as *mut core::ffi::c_void;
1205        // SAFETY: `arg` points at a ctx this struct owns and keeps alive until
1206        // after the join in `Drop`.
1207        let Some(task) = (unsafe {
1208            nros_platform_api::task::PlatformTask::spawn(
1209                signal_fd_worker,
1210                arg,
1211                // A read(2) loop and one atomic store — the smallest stack any
1212                // port will honour is plenty.
1213                8192,
1214                c"nros-wakefd".as_ptr(),
1215            )
1216        }) else {
1217            // SAFETY: nothing else holds the fd — the task never started.
1218            unsafe { libc::close(fd) };
1219            return Err(NodeError::NotInitialized);
1220        };
1221        Ok(Self {
1222            fd,
1223            ctx,
1224            task: Some(task),
1225        })
1226    }
1227
1228    /// Returns the writable eventfd. The caller (typically a POSIX
1229    /// signal handler) writes any non-zero 8-byte value to trigger
1230    /// a wake. `write(2)` on an eventfd is async-signal-safe per
1231    /// `eventfd(2)` man page.
1232    pub fn fd(&self) -> core::ffi::c_int {
1233        self.fd
1234    }
1235}
1236
1237#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1238impl Drop for WakeSignalFd {
1239    fn drop(&mut self) {
1240        self.ctx
1241            .shutdown
1242            .store(true, portable_atomic::Ordering::Release);
1243        // Wake the worker so it re-checks shutdown: it is blocked in `read`,
1244        // which only this write can release.
1245        let one: u64 = 1;
1246        // SAFETY: an 8-byte write to our own eventfd.
1247        unsafe {
1248            libc::write(self.fd, &one as *const u64 as *const core::ffi::c_void, 8);
1249        }
1250        if let Some(task) = self.task.take() {
1251            task.join();
1252        }
1253        // SAFETY: the worker has exited, so nothing else touches the fd.
1254        unsafe { libc::close(self.fd) };
1255    }
1256}
1257
1258/// Phase 124.B.7.b — ISR / interrupt-context wake callback.
1259///
1260/// Same semantics as [`nros_rmw_runtime_wake_cb`] but constrained to
1261/// async-signal-safe / ISR-safe primitives.
1262///
1263/// Per-platform routing:
1264///
1265/// * **POSIX (std)**: `pthread_cond_signal` is NOT on the POSIX
1266///   async-signal-safe function list. Calling from a SIGUSR1
1267///   handler is technically UB. Real fix (Phase 124.B.7.c) routes
1268///   via `signalfd`/`eventfd` + a runtime worker thread — Linux
1269///   only, since neither syscall is POSIX; until that lands,
1270///   signal-handler callers MUST use
1271///   `rclc_guard_condition_trigger` from a **separate thread** (not
1272///   from the handler itself), OR set the wake_flag and rely on
1273///   the next poll deadline. This cb currently aliases the regular
1274///   `wake_cb` and is safe only from non-signal-handler ISR-like
1275///   contexts (e.g. timer thread, kernel callback).
1276///
1277/// * **RTOS no_std (Zephyr/FreeRTOS/ThreadX)**: routes through the
1278///   platform-cffi `condvar_signal_from_isr` slot. Each backend
1279///   uses its ISR-safe variant — `xSemaphoreGiveFromISR`,
1280///   `tx_semaphore_put`, `k_condvar_signal`.
1281///
1282/// `ctx` semantics identical to [`nros_rmw_runtime_wake_cb`].
1283#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1284#[allow(dead_code)] // Public exposure pending B.7.c signalfd worker.
1285pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb_from_isr(ctx: *mut core::ffi::c_void) {
1286    // Today: alias regular wake_cb. POSIX signal-handler safety
1287    // pending B.7.c (signalfd worker-thread forward). Documented in
1288    // the contract above so callers know the boundary.
1289    unsafe { nros_rmw_runtime_wake_cb(ctx) };
1290}
1291
1292/// Phase 216 follow-up — per-Node dispatch trampoline registered with
1293/// [`Executor::register_dispatch_slot`].
1294///
1295/// The board-side dispatch task (RTIC `__nros_run` / Embassy
1296/// `__nros_run_task`) dequeues a `nros_platform::SignaledCallback`
1297/// envelope and forwards `(cb_id, ctx_ptr)` into
1298/// [`Executor::dispatch_callback`]; that method linear-scans this
1299/// slot table and invokes every registered `on_callback` with the
1300/// owning Node's per-Node `state` blob. Each Node's
1301/// `__nros_node_<pkg>_on_callback` self-filters on its own
1302/// `CallbackId` tag set, so a slot whose Node doesn't own this
1303/// callback is a cheap no-op string compare.
1304///
1305/// The shape mirrors the per-pkg `__nros_node_<pkg>_on_callback`
1306/// extern "C" trampoline emitted by the `nros::node!()` macro
1307/// (see `packages/core/nros-macros/src/lib.rs` Phase 216.A.5).
1308///
1309/// # Why not `linkme`
1310///
1311/// `linkme::distributed_slice` hangs on bare-metal Cortex-M /
1312/// RISC-V because `cortex_m_rt`'s link script doesn't provide the
1313/// `__start_/__stop_` section anchors in a shape that lets the
1314/// iterator terminate (see
1315/// `packages/rmw/cffi/src/section.rs` Phase 142). Since
1316/// stm32f4 RTIC / Embassy boards are the whole point of Phase 216,
1317/// the registry uses the explicit `register()` pattern from Phase
1318/// 104.A.
1319#[derive(Clone, Copy)]
1320pub struct DispatchSlot {
1321    /// Owning Node's `State` blob — produced by the macro-emitted
1322    /// `i()` and round-tripped through
1323    /// `nros::__private_node_state_into_raw`. Opaque to the
1324    /// executor.
1325    pub state: *mut core::ffi::c_void,
1326    /// Per-Node `extern "C"` trampoline; signature matches the
1327    /// `__nros_node_<pkg>_on_callback` symbol the `nros::node!()`
1328    /// macro emits.
1329    pub on_callback: unsafe extern "C" fn(
1330        state: *mut core::ffi::c_void,
1331        cb_id_ptr: *const u8,
1332        cb_id_len: usize,
1333        ctx: *mut core::ffi::c_void,
1334    ),
1335}
1336
1337// SAFETY: `DispatchSlot` carries two raw pointers (`state` + an
1338// extern "C" fn pointer). The fn pointer is `Send`/`Sync` by
1339// definition; the `state` pointer's `Send`/`Sync` story matches the
1340// owning `Executor` (which is `unsafe impl Send`). Treating the
1341// slot itself as `Send` keeps the existing `Executor` Send impl
1342// intact — see `unsafe impl Send for Executor {}` later in this
1343// file.
1344unsafe impl Send for DispatchSlot {}
1345unsafe impl Sync for DispatchSlot {}
1346
1347/// Phase 258 (Track 2, 2a) — executor-owned component tick slot.
1348///
1349/// The layering-clean half of the W0-B `install` seam's tick fix
1350/// (phase-257 D2). A `nros`-layer `install`/`register_node_borrowed`
1351/// builds an `Arc<ComponentCell>` (the typed/poll-driven component
1352/// state) and enrolls it here via [`Executor::enroll_component`]; the
1353/// executor then drives `tick` on every enrolled slot at the tail of
1354/// each [`spin_once`](Executor::spin_once) — so `install`'d nodes
1355/// (C, C++, **and Rust owned-spin**) tick, closing the
1356/// service-client/action poll gap that the callback-`Arc`-only
1357/// lifetime left open.
1358///
1359/// Like [`DispatchSlot`] the executor only sees raw pointers + `extern
1360/// "C"` fn pointers (no `nros` dep — `nros-node` is the lower layer):
1361///
1362/// * `state` — a *leaked* `Arc<ComponentCell>` (via `Arc::into_raw`),
1363///   re-borrowed by the `nros`-side `tick`/`drop` fns. Unlike a
1364///   pub/sub/timer component (kept alive by the executor's per-entity
1365///   callback `Arc` clones), a poll-only component has no callbacks, so
1366///   the slot must own a clone of the cell — hence the paired `drop`.
1367/// * `tick` — `nros`-side `extern "C"` fn that casts `state` back to
1368///   `&ComponentCell`, casts `exec_ctx` back to `*mut Executor`, and
1369///   runs that one cell's tick (mirrors `ExecutorNodeRuntime::run_ticks`).
1370/// * `drop` — `nros`-side `extern "C"` fn run on `Executor::drop` that
1371///   reconstitutes + drops the leaked `Arc`, so the executor owns the
1372///   cell's lifetime.
1373///
1374/// Kept a SEPARATE registry from [`DispatchSlot`] on purpose: framework
1375/// dispatch (RTIC / Embassy) is interrupt-driven, name-keyed, and has no
1376/// tick/own concern — mixing the two risks that path.
1377#[derive(Clone, Copy)]
1378pub struct ComponentSlot {
1379    /// Leaked `Arc<ComponentCell>` (opaque to the executor). Owned by
1380    /// this slot — dropped via `drop` on `Executor::drop`.
1381    pub state: *mut core::ffi::c_void,
1382    /// `nros`-side tick trampoline: `(state, exec_ctx)` where `exec_ctx`
1383    /// is `*mut Executor`. Drives one component's `tick`.
1384    pub tick: unsafe extern "C" fn(state: *mut core::ffi::c_void, exec_ctx: *mut core::ffi::c_void),
1385    /// `nros`-side drop trampoline: reconstitutes + drops the leaked
1386    /// `Arc<ComponentCell>` at `state`. Run once on `Executor::drop`.
1387    pub drop: unsafe extern "C" fn(state: *mut core::ffi::c_void),
1388}
1389
1390// SAFETY: same story as `DispatchSlot` — two raw pointers + two extern
1391// "C" fn pointers. The `state` pointer's Send/Sync matches the owning
1392// `Executor` (`unsafe impl Send for Executor`); the fn pointers are
1393// Send/Sync by definition.
1394unsafe impl Send for ComponentSlot {}
1395unsafe impl Sync for ComponentSlot {}
1396
1397/// phase-271 — fixed capacity of the per-spin ready-sets (FIFO bitmap + EDF
1398/// heap) and the dispatch loop's upper bound. The executor's callback index is
1399/// carried in a `u64` active-mask (`1u64 << i`), so a callback table can hold at
1400/// most 64 entries regardless of per-entry sizing; the ready-sets are sized to
1401/// this ceiling (stack-transient) so any entry slice up to 64 dispatches
1402/// correctly. `EdfReadySet`'s presence bitmap independently asserts `N <= 64`.
1403pub(crate) const MAX_CALLBACK_SLOTS: usize = 64;
1404
1405/// Issue 1667 — the arena's allocation grain. Every region the arena hands out
1406/// starts on it and is a multiple of it, so a released region is always big
1407/// enough, and aligned enough, to hold its own free-list node.
1408pub(crate) const ARENA_GRAIN: usize = core::mem::align_of::<u64>();
1409
1410/// Issue 1667 — the smallest region the arena hands out: one free-list node
1411/// (`next`, `len`), rounded to the grain.
1412pub(crate) const ARENA_MIN_REGION: usize =
1413    (2 * core::mem::size_of::<usize>()).next_multiple_of(ARENA_GRAIN);
1414
1415/// Issue 1667 — the end of the free list.
1416const ARENA_FREE_NONE: usize = usize::MAX;
1417
1418/// Issue 1496 — one arena region, as an allocation hands it out.
1419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1420pub(crate) struct FreedRegion {
1421    pub(crate) offset: usize,
1422    pub(crate) len: usize,
1423}
1424
1425/// Phase 305 W3 (issue 0255) — upper bound on launch remap rules held by one
1426/// executor (across all its nodes). Launch files carry a handful per node;
1427/// raise here if a plan legitimately outgrows it.
1428pub const MAX_REMAPS: usize = 16;
1429
1430/// Which of [`crate::names::resolve_name_layered`]'s two tiers a stored rule
1431/// belongs to.
1432#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1433pub(crate) enum RemapTier {
1434    /// Projected from the launch file (`declare_remap`, which the generated
1435    /// entry calls). AUTHORITATIVE, scoped to one `(name, namespace)`.
1436    Launch,
1437    /// Parsed from `--ros-args` (`crate::ros_args::install_argv_remaps`).
1438    /// FALLBACK — consulted only when no launch rule matched. Scoped to every
1439    /// node, or to the nodes of one NAME (rcl matches a `node:` prefix
1440    /// against the name alone).
1441    Argv,
1442}
1443
1444/// Phase 305 W3 (issue 0255) — one remap rule. `from`/`to` are stored RAW (as
1445/// written); the lookup in [`Executor::resolve_entity_name_for`] expands both
1446/// sides against the owning node's identity via `crate::names` (exact-FQN
1447/// match, no wildcards).
1448///
1449/// The table holds BOTH tiers, so each rule records which one it belongs to.
1450/// The tier is still decided at resolution, by
1451/// [`crate::names::resolve_name_layered`]'s two parameters, never by where a
1452/// rule sits in this table — `tier_rules` splits the table into those two
1453/// arguments. That is what keeps declaration order from deciding between the
1454/// channels: an argv rule installed before a launch rule still loses to it.
1455pub(crate) struct RemapRule {
1456    /// For [`RemapTier::Launch`]: the owning node. For [`RemapTier::Argv`]:
1457    /// the node name a `name:from:=to` rule was restricted to, or EMPTY for a
1458    /// rule that applies to every node.
1459    pub(crate) node_name: heapless::String<64>,
1460    /// For [`RemapTier::Launch`] only (normalised, `/` for root). Unused by
1461    /// argv rules, whose node prefix carries no namespace.
1462    pub(crate) namespace: heapless::String<64>,
1463    pub(crate) from: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1464    pub(crate) to: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1465    pub(crate) tier: RemapTier,
1466}
1467
1468impl RemapRule {
1469    /// Does this rule belong to `tier` and apply to the node `(node_name, ns)`?
1470    /// `ns` must already be normalised (`/` for root).
1471    fn applies(&self, tier: RemapTier, node_name: &str, ns: &str) -> bool {
1472        self.tier == tier
1473            && match tier {
1474                RemapTier::Launch => {
1475                    self.node_name.as_str() == node_name && self.namespace.as_str() == ns
1476                }
1477                RemapTier::Argv => {
1478                    self.node_name.is_empty() || self.node_name.as_str() == node_name
1479                }
1480            }
1481    }
1482}
1483
1484/// The rules of one tier that apply to one node, in declaration order, as the
1485/// `(from, to)` pairs [`crate::names::resolve_name_layered`] takes. The ONE
1486/// place a stored table is split into the two tiers — the executor's resolver,
1487/// `NodeHandle`, and the Rust component road (through
1488/// `crate::ros_args::argv_fallback`) all read through it.
1489pub(crate) fn tier_rules<'r>(
1490    table: &'r [Option<RemapRule>],
1491    tier: RemapTier,
1492    node_name: &'r str,
1493    namespace: &'r str,
1494) -> impl Iterator<Item = (&'r str, &'r str)> + 'r {
1495    let ns = if namespace.is_empty() { "/" } else { namespace };
1496    table
1497        .iter()
1498        .flatten()
1499        .filter(move |r| r.applies(tier, node_name, ns))
1500        .map(|r| (r.from.as_str(), r.to.as_str()))
1501}
1502
1503/// The executor's halt flag, in the one shape each configuration can hold.
1504///
1505/// Issue 1177. With an allocator it is refcounted, because `halt_flag()` hands
1506/// a caller its own handle to set from a signal handler or another thread.
1507/// Without one there is no handle to hand out -- a C caller reaches the
1508/// executor through its own pointer and calls `cancel()` directly -- so a plain
1509/// atomic is the whole requirement.
1510///
1511/// `Arc<AtomicBool>` derefs to `AtomicBool`, so every `load`/`store` below is
1512/// written once and only construction and the getter are cfg'd.
1513#[cfg(feature = "alloc")]
1514pub(crate) type HaltFlag = portable_atomic_util::Arc<portable_atomic::AtomicBool>;
1515/// See [`HaltFlag`] above -- the no-allocator shape.
1516#[cfg(not(feature = "alloc"))]
1517pub(crate) type HaltFlag = portable_atomic::AtomicBool;
1518
1519pub struct Executor<'s> {
1520    /// Issue 0656 — the ROS domain this executor's entities belong to.
1521    ///
1522    /// Retained because it was NOT: `open_in` passed `config.domain_id` into
1523    /// `RmwConfig` and dropped it, so every entity built from the executor
1524    /// (rather than from a `Node`, which keeps its own) declared on domain 0
1525    /// whatever `ROS_DOMAIN_ID` said. The value was read, printed, and then not
1526    /// used where it counts — issue 0161's shape.
1527    pub(crate) domain_id: u32,
1528    pub(crate) session: SessionStore,
1529    /// phase-271 (issue 0110) — the six sized tables are no longer inline
1530    /// arrays baked to `nros-node`'s build-time consts; they borrow
1531    /// caller-owned, per-entry-sized storage (`&'s mut` slices carved from a
1532    /// raw `[MaybeUninit<u64>]` backing by [`super::storage::carve`]). Lets a
1533    /// fat native entry and a lean embedded entry in one shared-target
1534    /// workspace each size to its own topology. `Executor` stays non-generic
1535    /// (lifetime only) so the C/C++ FFI keeps wrapping one concrete type.
1536    pub(crate) arena: &'s mut [MaybeUninit<u8>],
1537    pub(crate) arena_used: usize,
1538    /// Issue 1496 / 1667 — the head of the FREE LIST: arena regions given back
1539    /// by a released entry, reused first-fit by the next allocation that fits.
1540    ///
1541    /// The list is INTRUSIVE — each free region holds its own node
1542    /// (`next`, `len`) in its first bytes — and kept in address order, so a
1543    /// release coalesces with both neighbours and nothing bounds how many holes
1544    /// there are. 1496's form was a fixed table of 8 that dropped a region when
1545    /// full and dropped any split tail under 64 B, and those bytes were gone
1546    /// for good: measured under a churning workload (issue 1667), three live
1547    /// entries leaked the whole 74 KiB arena inside 20 000 create/release
1548    /// rounds. A hole that ends at `arena_used` is given back to the bump
1549    /// pointer instead of being listed.
1550    pub(crate) arena_free_head: usize,
1551    /// Issue 1631 — the region the most recent ENTRY allocation handed out,
1552    /// waiting for `emplace_entry` to record it in the entry's
1553    /// `CallbackMeta::arena_len`. Every registration allocates its entry and
1554    /// then emplaces it, so the pair is adjacent; a registration that fails
1555    /// between them leaves a stale value that the next allocation overwrites.
1556    pub(crate) pending_region: Option<FreedRegion>,
1557    pub(crate) entries: &'s mut [Option<CallbackMeta>],
1558    /// Phase 110.B — registered scheduling contexts. Slot 0 is
1559    /// auto-populated with a `Fifo` SC at construction; every entry
1560    /// without an explicit binding maps to it via
1561    /// `sched_context_bindings`.
1562    pub(crate) sched_contexts: &'s mut [Option<super::sched_context::SchedContext>],
1563    /// Per-entry SC binding parallel to `entries`. Defaults to
1564    /// `SchedContextId(0)` (the auto-created Fifo SC).
1565    pub(crate) sched_context_bindings: &'s mut [super::sched_context::SchedContextId],
1566    /// Phase 110.E — user-space sporadic-server budget state per
1567    /// Sporadic-class SC. Slot indices match `sched_contexts`; non-
1568    /// Sporadic slots stay `None`.
1569    pub(crate) sporadic_states: &'s mut [Option<super::sched_context::SporadicState>],
1570    /// Phase 110.E.b — atomic sporadic state + opaque platform-timer
1571    /// handle for ISR-driven refill. Populated by
1572    /// `register_sporadic_timer`; dropped on Executor `Drop` via the
1573    /// stored `destroy_fn`.
1574    #[cfg(feature = "alloc")]
1575    pub(crate) sporadic_atomic_states: &'s mut [Option<(
1576        portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
1577        OpaqueTimerHandle,
1578    )>],
1579    /// Phase 110.G — major-frame length for time-triggered dispatch.
1580    /// `0` (default) disables the TT gate entirely; non-zero enables
1581    /// gating per
1582    /// `SchedContext.tt_window_offset_us / tt_window_duration_us`.
1583    pub(crate) major_frame_us: u32,
1584    /// Phase 110.F — per-OS-priority worker pool. Lazily populated
1585    /// on first dispatch routing to a non-zero `os_pri`.
1586    ///
1587    /// phase-359 W10 — was `std::collections::HashMap<u8, OsPriorityWorker>`
1588    /// behind `feature = "std"`, because the workers were `std::thread` +
1589    /// `mpsc`. They are platform tasks now (`super::os_priority`), so the pool
1590    /// needs only `alloc` and this capability is no longer std-only.
1591    #[cfg(all(
1592        feature = "alloc",
1593        feature = "rmw-cffi",
1594        feature = "scheduler-os-priority"
1595    ))]
1596    pub(crate) os_priority_pool: super::os_priority::OsPriorityPool,
1597    /// Phase 110.F — caller-supplied `apply_policy` function pointer
1598    /// each worker invokes at startup to elevate its OS priority.
1599    /// `None` = the worker pool is disabled; entries bound to non-
1600    /// zero `os_pri` SCs fall back to the cooperative path.
1601    /// Mirrors `Executor::open_threaded`'s `apply_policy: fn(...)`
1602    /// shape — keeps Executor non-generic over Platform.
1603    // phase-359 W10 — the POLICY is separable from the POOL. Registering it
1604    // needs nothing but the feature; hosting workers needs a platform
1605    // (`rmw-cffi`, the same proxy `node_wake` uses). A build that can register
1606    // but not host falls back to cooperative dispatch, so the public
1607    // `register_os_priority_dispatcher` keeps its old availability.
1608    #[cfg(all(feature = "alloc", feature = "scheduler-os-priority"))]
1609    pub(crate) os_priority_apply_policy:
1610        Option<fn(nros_platform_api::SchedPolicy) -> Result<(), nros_platform_api::SchedError>>,
1611    pub(crate) trigger: Trigger,
1612    pub(crate) semantics: ExecutorSemantics,
1613    /// Node name for entities created via `register_subscription`/`register_service`.
1614    /// Empty means unset — no liveliness tokens will be declared.
1615    pub(crate) node_name: heapless::String<64>,
1616    /// Phase 228.C — per-tier callback-group filter. Wildcard (register every
1617    /// callback — the single-tier degenerate case + today's behaviour) until
1618    /// [`set_active_groups`](Self::set_active_groups) names a non-empty set;
1619    /// after that this tier's executor accepts only callbacks whose
1620    /// `.callback_group()` is in it, and skips the others at registration.
1621    ///
1622    /// phase-409 — CARVED, and the wildcard is a separate flag rather than an
1623    /// `Option` around the table, because the table itself no longer lives in
1624    /// the value. "Filtering with an empty set" and "not filtering" stay
1625    /// distinguishable, which is the whole content of the old `Option`.
1626    pub(crate) active_groups: super::storage::CarvedVec<'s, super::storage::ActiveGroup>,
1627    /// Whether [`active_groups`](Self::active_groups) is a filter at all.
1628    /// `false` = wildcard (the old `None`).
1629    pub(crate) active_groups_filtering: bool,
1630    /// Node namespace (default: "/").
1631    pub(crate) namespace: heapless::String<64>,
1632    /// Phase 104.C.2 — rclcpp-style `add_node` table. Holds the
1633    /// per-Node metadata (name, namespace, rmw, locator, default
1634    /// SchedContext) for every Node attached to this Executor. The
1635    /// implicit "primary" Node (NodeId(0)) mirrors `node_name` +
1636    /// `namespace` above and is auto-populated on first use.
1637    ///
1638    /// phase-409 — CARVED. `NodeId` IS an index into this table, so the
1639    /// carved vector must keep push order and never gain a `swap_remove`.
1640    pub(crate) nodes: super::storage::CarvedVec<'s, super::node_record::NodeRecord>,
1641    /// Phase 272 (RFC-0047) — config-seeded node → sched-context bindings, keyed by the node's
1642    /// fully-qualified `(name, namespace)` pair. `NodeBuilder::build` consults this table to set a
1643    /// node's `default_sched` when no explicit `.sched()` was given. Empty ⇒ every node stays
1644    /// `SchedContextId(0)` (byte-identical to pre-272 behaviour). Sized by `MAX_NODES` (at most
1645    /// one tier per node — RFC-0047 OQ1).
1646    ///
1647    /// phase-409 — CARVED.
1648    pub(crate) node_sched_table: super::storage::CarvedVec<'s, super::storage::NodeSchedEntry>,
1649    /// Phase 273 (RFC-0047) — config-seeded per-callback-group sched bindings, keyed by the node's
1650    /// fully-qualified `(name, namespace)` pair PLUS the callback-group name. Overrides the node
1651    /// default for a callback created in that group. Empty ⇒ no per-group binding (node default
1652    /// stands). Sized by `MAX_CBS` — an upper bound on distinct callback-group bindings (you can
1653    /// never have more distinct group bindings than max callbacks).
1654    ///
1655    /// phase-409 (issue 0961) — CARVED, and it is the reason the phase exists:
1656    /// at ~168 B per slot this was `MAX_CBS` * 168 bytes INSIDE the value, so
1657    /// raising the handle limit from 14 to 36 — a fix for an unrelated failure —
1658    /// added ~3.7 KiB to the stack frame of every function that moves an
1659    /// `Executor`, and overflowed a 320 KiB part's main thread.
1660    pub(crate) group_sched_table: super::storage::CarvedVec<'s, super::storage::GroupSchedEntry>,
1661    /// Phase 305 W3 (issue 0255) — launch-baked per-node remap rules, keyed by
1662    /// the declaring node's `(name, namespace)` identity. Entries store the
1663    /// rule RAW (as written in launch); [`Self::resolve_entity_name`] expands
1664    /// both sides against the owning node's identity at lookup, via the shared
1665    /// `crate::names` seam (the same semantics the Rust `ExecutorSink` path
1666    /// applies). Declaration order is match order (first rule wins).
1667    /// Issue 0563 — CARVED, not inline. As a `heapless::Vec<RemapRule,
1668    /// MAX_REMAPS>` this one field was 6664 bytes of an 11632-byte `Executor`
1669    /// (57%), which is what made building an executor a ~9.3 KB stack
1670    /// temporary and overflowed the Zephyr Cortex-M main stack (issue 0552).
1671    /// It is the seventh sized table; phase-271 moved the other six here.
1672    /// `remap_len` is the fill cursor — occupied slots are `[0, remap_len)`.
1673    pub(crate) remap_table: &'s mut [Option<RemapRule>],
1674    pub(crate) remap_len: usize,
1675    /// Phase 216 follow-up — per-Node dispatch trampoline registry.
1676    ///
1677    /// Populated by [`Executor::register_dispatch_slot`]; walked by
1678    /// [`Executor::dispatch_callback`] each time the board-side
1679    /// dispatch task hands off a `SignaledCallback` envelope.
1680    /// Sized by `MAX_NODES` because the upper-bound is one slot per
1681    /// Node pkg deployed on this executor (the same upper bound used
1682    /// by `nodes` and `extra_sessions`). `MAX_NODES` is driven by the
1683    /// `NROS_EXECUTOR_MAX_NODES` build-script env var (default 4);
1684    /// boards that deploy more Node pkgs raise it at build time.
1685    ///
1686    /// Default is `heapless::Vec::new()` (empty) — Nodes register
1687    /// themselves explicitly via the `register_dispatch_slot` API.
1688    /// The fallback shape avoids the `linkme` hazard on bare-metal
1689    /// Cortex-M / RISC-V (see `DispatchSlot` doc).
1690    ///
1691    /// phase-409 — CARVED (sized by `ExecutorSizing::nodes`, which is what
1692    /// `MAX_NODES` now seeds).
1693    pub(crate) dispatch_slots: super::storage::CarvedVec<'s, DispatchSlot>,
1694    /// Phase 258 (Track 2, 2a) — executor-owned component tick registry.
1695    /// Enrolled by [`Executor::enroll_component`] (from `nros`'s
1696    /// `install`/`register_node_borrowed`); each slot's `tick` runs at the
1697    /// tail of [`spin_once`](Self::spin_once); each slot's `drop` runs on
1698    /// `Executor::drop`. Bounded `MAX_NODES` (matches `dispatch_slots` /
1699    /// `nodes`). See [`ComponentSlot`] for why it's separate from
1700    /// `dispatch_slots`.
1701    ///
1702    /// phase-409 — CARVED.
1703    pub(crate) component_slots: super::storage::CarvedVec<'s, ComponentSlot>,
1704    /// Phase 104.C.3 — extra sessions opened by `node_builder.rmw()`
1705    /// calls that named a backend different from the Executor's
1706    /// primary session. Indexed by `NodeRecord.session_idx`
1707    /// (1..=N maps to `extra_sessions[N-1]`; idx 0 is the primary
1708    /// `self.session`). Sized by `NROS_EXECUTOR_MAX_NODES` since one
1709    /// extra session per Node is the worst case.
1710    ///
1711    /// phase-409 — CARVED, and the biggest single win: `ConcreteSession` is
1712    /// 524 B on the island, so this table alone was ~3.1 KiB of the value.
1713    /// Declared AFTER `session` so field-order drop still closes the primary
1714    /// session before the extras (`CarvedVec` owns its elements' drop).
1715    pub(crate) extra_sessions: super::storage::CarvedVec<'s, session::ConcreteSession>,
1716    /// Issue 0436 — `(rmw_name, locator)` for each entry of `extra_sessions`,
1717    /// the extras' equivalent of `primary_rmw_name` / `primary_locator`.
1718    ///
1719    /// Without it an extra session is ANONYMOUS, and
1720    /// `NodeBuilder::resolve_session_slot` could only recognise one by finding a
1721    /// previously-registered `NodeRecord` bound to it. Sessions opened by
1722    /// `open_multi*` have no such Node yet, so the FIRST `.rmw("zenoh")` node fell
1723    /// through to "open a new session" — a SECOND zenoh session, with an empty
1724    /// locator, which fails (see `primary_rmw_name`'s note: zenoh-pico's global
1725    /// state is a process singleton). That is why the PX4 bridge could open both
1726    /// sessions and then fail to bind its outward Node.
1727    ///
1728    /// Written unconditionally by `open_multi*`, but the only READER is
1729    /// `NodeBuilder::resolve_session_slot`, which is `rmw-cffi`-gated — so
1730    /// without that feature the field is genuinely unread and the
1731    /// workspace's `-D dead_code` is right to say so. Allow it exactly
1732    /// there rather than blanket-allowing a field that must stay live in
1733    /// every configuration that can reach the reader.
1734    ///
1735    /// phase-409 — CARVED.
1736    #[cfg_attr(not(feature = "rmw-cffi"), allow(dead_code))]
1737    pub(crate) extra_session_ids: super::storage::CarvedVec<'s, super::storage::ExtraSessionId>,
1738    /// Phase 156 — primary session's rmw name + locator, captured
1739    /// at `open*` time so `NodeBuilder::resolve_session_slot`'s
1740    /// cache lookup can detect when a `.rmw(name).locator(loc)`
1741    /// matches the primary (slot 0) instead of falling through to
1742    /// `CffiRmw::open_with_rmw` and trying to open a SECOND
1743    /// session against the same backend. zenoh-pico's global state
1744    /// is a process singleton; opening twice fails. Empty when
1745    /// constructed via `from_session(_ptr)` without `open*`
1746    /// recording the metadata; in that case the cache check
1747    /// degrades to "always miss" (today's behaviour).
1748    pub(crate) primary_rmw_name: heapless::String<32>,
1749    pub(crate) primary_locator: heapless::String<128>,
1750    // phase-359 W3 — `portable_atomic_util::Arc`, not `std::sync::Arc`. Same
1751    // atomically-refcounted pointer, available without `std`, and it compiles on
1752    // std too. W3 left the GATE on `std` because the public `halt_flag()` getter
1753    // was std-gated; W10 split that impl, so the field joined `wake_flag` on
1754    // `alloc`.
1755    //
1756    // Issue 1177 — the ALLOCATOR was never the requirement of the FLAG, only of
1757    // SHARING it. The flag is an `AtomicBool`; what needs an allocator is
1758    // `halt_flag()`, the getter that hands a caller its own refcounted handle to
1759    // set from a signal handler or another thread. Gating the flag on `alloc`
1760    // therefore gated the ability to STOP a no_std executor on a feature it does
1761    // not need -- and `nros-c`'s spin loop calls `cancel`/`is_halted`/
1762    // `enter_spin_loop`/`exit_spin_loop`/`is_spinning` unconditionally, so an
1763    // embedded image did not merely lose cancellation, it failed to compile.
1764    //
1765    // `HaltFlag` is the one type name that differs, so every reader below is
1766    // written once: `Arc<AtomicBool>` derefs to `AtomicBool`, so `load`/`store`
1767    // are identical on both sides and only construction and the getter are
1768    // cfg'd.
1769    pub(crate) halt_flag: HaltFlag,
1770    /// Is a spin loop running right now? — the observable behind
1771    /// `is_spinning()` (phase-417 W4.c, rclcpp's `Executor::is_spinning`).
1772    ///
1773    /// A plain atomic, not refcounted like `halt_flag`: nothing hands this one
1774    /// out. `halt_flag()` is a public getter that gives a caller its own
1775    /// handle to set from another thread; `spinning` is only ever set by the
1776    /// executor's own loops through `&self`, so it needs no shared ownership.
1777    ///
1778    /// Issue 1177 — this used to ride `alloc` "for the same reason the impl
1779    /// that reads it does". It never needed an allocator itself; it was gated
1780    /// by ASSOCIATION with `halt_flag`, and when that association turned out to
1781    /// be wrong this went with it.
1782    pub(crate) spinning: portable_atomic::AtomicBool,
1783    /// Phase 104.C.6 — shared executor wake flag. Any source of work
1784    /// (foreign thread handing off a callback, signal handler, future
1785    /// per-session vtable wake hook) sets this; `spin_once` swaps it to
1786    /// `false` on entry and, if it was `true`, polls every session with
1787    /// a 0-ms timeout instead of blocking. Lets one notification wake
1788    /// the executor regardless of which session the user is currently
1789    /// blocked on (the multi-RMW bridge case).
1790    // phase-359 W3 — ONE wake flag, both flavours. W2 had to leave this pair
1791    // alone because `wake_handle()` hands it out as a `std::sync::Arc`;
1792    // converting that signature is what lets the two collapse. Gated on
1793    // `alloc` (which `std` implies) because the Arc needs an allocator.
1794    #[cfg(feature = "alloc")]
1795    pub(crate) wake_flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
1796    /// Phase 124.B.2 — wake condvar paired with `wake_flag`. The
1797    /// runtime-supplied wake callback (`nros_rmw_runtime_wake_cb` in
1798    /// nros-rmw-cffi) writes `wake_flag = true` AND signals
1799    /// `wake_cv` atomically under `wake_mu`. `spin_once` blocks on
1800    /// the cv with a deadline instead of calling `drive_io` with the
1801    /// user's timeout — sub-poll-period wake latency.
1802    ///
1803    /// Poll-only backends (NULL `set_wake_callback` slot) leave the
1804    /// cb uninstalled; the cv wait still fires on its deadline,
1805    /// then drive_io(0) drains whatever the backend's internal
1806    /// poll has buffered.
1807    /// Phase 130.3 — Zephyr+std uses `nros_platform_wake_*` (k_sem)
1808    /// instead of `std::sync::Condvar` because Zephyr's libc
1809    /// `pthread_cond_timedwait` hangs past its deadline. `None`
1810    /// when the platform provider didn't link a wake primitive
1811    /// (e.g. test builds with `rmw-cffi` but no `platform-*`
1812    /// feature); spin_once falls back to driving the transport
1813    /// for the full timeout in that case.
1814    // phase-359 W2 — ONE field, not one per flavour. `portable_atomic_util::Arc`
1815    // compiles on std too, and `std` implies `alloc`, so the std and alloc arms
1816    // were two spellings of the same thing. The inner `NodeWake` was already
1817    // shared; only the Arc differed.
1818    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1819    pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
1820    /// Phase 130.4 — true when at least one session's backend
1821    /// installed the wake callback. Drives whether `spin_once`
1822    /// uses the wake-primitive wait (`NodeWake` / `Condvar`) or
1823    /// just `drive_io(timeout_ms)`. Poll-only backends
1824    /// (XRCE-DDS-Client, the dust-DDS shim) leave this `false`;
1825    /// Cyclone sets it — it fills the slot and backs it with a real
1826    /// `dds_set_listener` listener; the wait then becomes a no-op sleep that
1827    /// starves reliable retransmission (Phase 127.C.4 root
1828    /// cause: server's `send_response` flushes 100 ms once, then a
1829    /// blind `wait_ms(100)` sleeps with zero session activity, so
1830    /// the agent's ACK arrives into a stalled session and reliable
1831    /// redelivery never fires).
1832    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1833    pub(crate) has_async_wake: bool,
1834    /// Phase 124.B.2 — opaque context Arc handed to backends via
1835    /// `set_wake_callback`. Lazy-allocated on first install; stays
1836    /// alive for the Executor's lifetime so the raw pointer stored
1837    /// in backends remains valid.
1838    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1839    pub(crate) wake_ctx: Option<portable_atomic_util::Arc<WakeCtx>>,
1840    /// Issue 1385 — has THIS executor handed `wake_ctx`'s pointer to a
1841    /// backend? The predicate `clear_wake_signal` reads at teardown.
1842    ///
1843    /// Not `wake_ctx.is_some()`: the ctx is also allocated by `signal_fd()`
1844    /// and by every guard condition, neither of which installs anything on a
1845    /// session. Clearing on those would clear a callback this executor never
1846    /// owned. (Since phase-436 a tier over a BORROWED session does install: it
1847    /// joins the session's wake fan-out, and its teardown is leaving it.)
1848    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1849    pub(crate) wake_cb_installed: bool,
1850    /// phase-436 (RFC-0106 D5) — something other than the backend can signal
1851    /// this executor's wake object: a guard condition, triggered from another
1852    /// thread. A poll-only backend waits inside its own `recv`, which that
1853    /// signal cannot end, so the wait is driven in slices of
1854    /// [`POLL_ONLY_WAKE_SLICE_MS`] while this is set. Never cleared: a guard
1855    /// outliving its registration costs slicing, never a missed wake.
1856    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1857    pub(crate) has_cross_thread_waker: bool,
1858    /// phase-436 — this executor's place in the wake fan-out of its PRIMARY
1859    /// session, when it has one. Every executor over a session joins the same
1860    /// entry, so an arrival wakes all of them, not only the one that happened
1861    /// to install into the backend's single slot. See `wake_fanout`.
1862    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1863    pub(crate) wake_membership: Option<super::wake_fanout::Membership>,
1864    /// phase-467 Row 8 — the graph-change target the BACKEND holds a pointer
1865    /// to, owned here.
1866    ///
1867    /// This box is the whole answer to issue 1385 on this path. The backend
1868    /// fires the graph-change edge from a transport or worker thread, so
1869    /// whatever it dereferences must outlive every such call — and the thing
1870    /// the caller has in hand is a `rcl_guard_condition_t` in ITS storage,
1871    /// which the caller may free or reuse at any time. Handing that pointer
1872    /// over would BE 1385. So the executor keeps its own handle to the same
1873    /// arena flag, at a heap address that does not move when the executor
1874    /// does, and [`clear_graph_change_signal`] retires the backend's pointer
1875    /// BEFORE this box drops.
1876    ///
1877    /// [`clear_graph_change_signal`]: Self::clear_graph_change_signal
1878    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1879    pub(crate) graph_change_target: Option<alloc::boxed::Box<GuardCondition>>,
1880    /// Phase 124.B.7.c — lazily-allocated Linux signalfd worker.
1881    /// Owned by the Executor; spawned on first `signal_fd()` call.
1882    /// Drop joins the worker thread and closes the fd.
1883    #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1884    pub(crate) signal_fd: Option<WakeSignalFd>,
1885    /// Issue 1812 — CARVED from the backing's [`ParamHome`](super::storage::ParamHome)
1886    /// when the sizing carves a store; with `alloc`, one heap allocation
1887    /// otherwise. Without `alloc` there is no heap arm, and an executor whose
1888    /// backing carves no home refuses parameters by name instead.
1889    #[cfg(feature = "param-store")]
1890    pub(crate) params: Option<crate::param_store::Placed<'s, crate::param_store::ParamState<'s>>>,
1891    /// phase-382 W3' — the parameter store's slots, CARVED from this
1892    /// executor's backing and still uninitialised. Taken (left empty) the
1893    /// first time something builds the store; empty from the start when the
1894    /// sizing carved none, in which case the store comes from the heap.
1895    #[cfg(feature = "param-store")]
1896    pub(crate) param_slots: &'s mut [MaybeUninit<nros_params::ParameterSlot>],
1897    /// Issue 1812 — the services' home, CARVED beside the store's slots and
1898    /// still uninitialised. Taken with them; empty when the sizing carved no
1899    /// store.
1900    #[cfg(feature = "param-store")]
1901    pub(crate) param_home: &'s mut [MaybeUninit<super::storage::ParamHome>],
1902    /// Issue 1706 -- the parameter store's one allocation was refused by the
1903    /// platform heap. Recorded so the refusal is REPORTED once and not retried
1904    /// on every declaration: a retry cannot succeed on a heap that only shrinks
1905    /// from here, and on Zephyr each attempt prints its own `HEAP EXHAUSTED`.
1906    #[cfg(feature = "param-store")]
1907    pub(crate) param_store_refused: bool,
1908    /// phase-446 W6 -- the parameters each node's contract DECLARES, baked by
1909    /// the entry from the SystemModel. Empty (nothing checked) until an entry
1910    /// hands one over with [`Self::set_declared_params`].
1911    #[cfg(feature = "param-store")]
1912    pub(crate) declared_params: nros_params::DeclaredParams,
1913    /// phase-425 W3b — the `/clock` subscription this image installed, if any.
1914    /// `None` means no time source; the value is the handle so it can be
1915    /// cancelled when `use_sim_time` goes false.
1916    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1917    pub(crate) sim_time_source: Option<HandleId>,
1918    /// phase-425 W3b — the LAST REQUESTED state of `use_sim_time`, which is not
1919    /// the same thing as the installed state: a request can arrive before any
1920    /// node exists (`nros::main!` declares parameters BEFORE it registers
1921    /// components), and there is no node to hang a subscription on until then.
1922    /// `reconcile_ros_time_source` closes the gap on each spin.
1923    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1924    pub(crate) sim_time_requested: bool,
1925    /// Whether `use_sim_time` was ever stated to THIS executor.
1926    ///
1927    /// `sim_time_requested` alone cannot answer it: `false` is both "told to
1928    /// turn it off" and "never told anything", and the two license opposite
1929    /// actions on a PROCESS-GLOBAL gate. Without this, every executor that
1930    /// never heard of the parameter wrote `set_active(false)` on its first
1931    /// spin -- because the gate defaults to TRUE, so `false != is_active()`
1932    /// held -- and switched off a simulated clock somebody else installed.
1933    /// One `cargo test` process is exactly that situation: 344 sibling tests
1934    /// spin an executor each, and the sim-time test failed 3 runs of 3 while
1935    /// passing alone.
1936    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1937    pub(crate) sim_time_stated: bool,
1938    /// phase-430 W1 — the one-shot that says "`use_sim_time` is on and `/clock`
1939    /// has never spoken", so a MISCONFIGURED image is distinguishable from a
1940    /// merely PAUSED one. Armed when the source attaches, disarmed by the first
1941    /// sample or by the report. The policy, and the argument for its unit, are
1942    /// in [`crate::time_source::SilenceWatch`].
1943    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1944    pub(crate) sim_time_silence: crate::time_source::SilenceWatch,
1945    #[cfg(feature = "lifecycle-services")]
1946    pub(crate) lifecycle:
1947        Option<alloc::boxed::Box<crate::lifecycle_services::LifecycleRuntimeState>>,
1948    /// Wall-clock instant at which the previous `spin_once` exited. The
1949    /// timer delta on the next call is measured from this point so any
1950    /// time the caller spent between `spin_once` invocations (e.g. an
1951    /// explicit `thread::sleep`) counts toward timer accumulation just
1952    /// like time spent inside `drive_io`.
1953
1954    /// Monotonic clock endpoint for no_std timer accounting.
1955    // phase-359 W4 — ONE field. The std twin held an `Instant`; both now hold
1956    // µs from the single `now_us()` read.
1957    pub(crate) last_spin_end_us: Option<u64>,
1958    /// The executor's monotonic µs clock: `ExecutorConfig::clock_us` when the
1959    /// caller supplied one, else [`default_clock_us_fn`].
1960    ///
1961    /// phase-359 W10 — no longer `no_std`-only. The std build read an
1962    /// `Instant` through a separate field instead, which is how "what time is
1963    /// it" had two answers in one crate.
1964    pub(crate) clock_us_fn: Option<fn() -> u64>,
1965
1966    /// Consecutive `drive_io` failures on the PRIMARY session (issue 0324).
1967    ///
1968    /// Reset to 0 by any successful drive. A session that has died — router
1969    /// gone, lease expired, socket closed — used to keep returning `Ok(())`
1970    /// from `spin()` forever: the node looked alive, publishes went nowhere,
1971    /// and no callback fired. Nothing in the crate could report it; a
1972    /// `git grep` for a health surface found none, so every such
1973    /// investigation started from packet captures (issue 0268 burned days
1974    /// this way).
1975    ///
1976    /// A COUNTER rather than propagating the error out of `spin_once`,
1977    /// deliberately: `drive_io` returns `Err` for any non-OK backend code, and
1978    /// whether a benign poll timeout maps to one is backend-specific. Aborting
1979    /// the spin on a single failure would risk turning a transient into a dead
1980    /// node. A counter cannot regress behaviour and still makes the condition
1981    /// observable via [`Executor::session_io_failures`].
1982    pub(crate) consecutive_io_failures: u32,
1983    /// RFC-0052 W3b.2 — wall-clock (epoch µs) source for age monitors.
1984    pub(crate) epoch_us_fn: Option<fn() -> u64>,
1985    /// phase-483 W3 — the component registration frame the `nros` facade
1986    /// installs for the duration of a component's `register`, so the
1987    /// declarative entity constructors on [`Node`](super::node::Node) can reach
1988    /// the sink that owns the component's cell. Opaque here: nros-node sits
1989    /// below the crate that defines the frame. `None` outside a registration.
1990    pub(crate) component_frame: Option<core::ptr::NonNull<()>>,
1991    /// RFC-0052 W3b.4 — baked contract-monitor table (empty = uncontracted
1992    /// image; every monitor path below folds away).
1993    pub(crate) monitor_table: &'static [super::monitor::MonitorSpec],
1994    pub(crate) monitor_states: [super::monitor::MonitorState; super::monitor::MAX_MONITORS],
1995    /// Alive supervision, one record per SchedContext: dispatches are counted
1996    /// in the dispatch path where the SC index is already resolved, and read
1997    /// once per monitor tick. CARVED from the backing like `sporadic_states`.
1998    /// It began as an inline `[_; MAX_SC]` pair on the claim that `MAX_SC` is a
1999    /// build-time constant; it is a SIZING KNOB (`ExecutorSizing::sc`), so the
2000    /// header grew with it and `the_executor_value_does_not_scale_with_the_knobs`
2001    /// caught it (issue 0961).
2002    pub(crate) alive_slots: &'s mut [super::monitor::AliveSlot],
2003    /// phase-476 W0 — per-callback-slot lifetime tags, parallel to
2004    /// `entries`: the generation a handle must match, and the owning node.
2005    pub(crate) slot_tags: &'s mut [super::types::SlotTag],
2006    /// W3b.5 — baked subscriber age-contract table (empty = none).
2007    pub(crate) age_table: &'static [super::monitor::AgeMonitorSpec],
2008    pub(crate) age_states: [super::monitor::AgeState; super::monitor::MAX_AGE_MONITORS],
2009    /// phase-462 W3b — baked hazard table (empty = no contracted hazard). Its
2010    /// state lives in the rows' own `static` cells, so a hazard costs this
2011    /// value nothing.
2012    pub(crate) hazard_table: &'static [super::monitor::HazardSpec],
2013    /// W3b.5 — hook invoked on `DeadlineAction::Fault` (panic when unset).
2014    pub(crate) fault_fn: Option<fn(&super::monitor::Violation)>,
2015    /// phase-503 — the reaction hook and its context: called when a
2016    /// supervised route passes its deadline (`route-deadline-runtime`), and on
2017    /// `DeadlineAction::Fault` when no `fault_fn` is set.
2018    pub(crate) reaction_hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
2019    /// Issue #515 — the spin cadence audit runs once, on the first spin
2020    /// that carries a non-zero timeout (which is where the tier's
2021    /// declared spin period becomes visible to the executor).
2022    pub(crate) spin_quantization_checked: bool,
2023    /// phase-436 W1 — the cadence the quantization audit (issue 0515) actually
2024    /// ran against. Recorded because the audit only LOGS: without this a
2025    /// regression that fed it the wrong number is invisible to a test, and
2026    /// capping the park by the timer deadline is exactly such a regression
2027    /// waiting to happen.
2028    pub(crate) spin_quantization_us: u64,
2029    /// Release jitter: worst observed lateness of a `spin_period` wake
2030    /// against its own nominal schedule, in microseconds.
2031    ///
2032    /// Issue #515 added a STATIC audit for one cause of cadence error --
2033    /// a period that is not a multiple of the spin period. Nothing measured
2034    /// the actual release instants, and `audit_spin_quantization` says so in
2035    /// as many words: "the rate is preserved and no activation is dropped,
2036    /// so every runtime rule is (correctly) silent -- the jitter stays
2037    /// invisible until someone measures cadence on target."
2038    ///
2039    /// This is that measurement, and it is the one `cyclictest` exists to
2040    /// report: the deviation between a timer's programmed wake-up and the
2041    /// instant the task actually resumed, whose MAXIMUM is the figure of
2042    /// merit for a real-time system.
2043    ///
2044    /// `spin_period` already holds both numbers -- `next_us` is the nominal
2045    /// deadline and `now_us()` the actual -- and when the loop is late it
2046    /// skips the sleep and discards the difference. That difference is the
2047    /// jitter.
2048    pub(crate) max_release_jitter_us: u64,
2049    /// Clock reading at the previous `spin_once` entry, for the interval the
2050    /// jitter is measured over. `None` until the first spin, and reset by
2051    /// `clear_release_jitter_stats` so a window starts clean.
2052    pub(crate) last_spin_entry_us: Option<u64>,
2053    /// phase-474 I11 -- the release grid of a `spin_once(timeout)` loop with
2054    /// no declared cadence. See [`ReleaseGrid`].
2055    pub(crate) grid: ReleaseGrid,
2056    /// Jitter high-water at the previous `release-jitter-runtime` check, so
2057    /// the rule reports the DELTA. Same shape as `overruns_reported` on a
2058    /// timer header, and for the same reason: a maximum that has not moved
2059    /// is the fault already reported, not a new one.
2060    pub(crate) jitter_reported_us: u64,
2061    /// Declared minimum stack headroom in bytes for the thread this executor
2062    /// spins on; `0` (default) disables the rule.
2063    ///
2064    /// An executor-level install rather than a contract field, because the
2065    /// bound cannot be derived from anything already declared -- see
2066    /// `check_stack_headroom`. The entry that spawned the thread is the one
2067    /// that knows what it gave it, so that is where the number comes from.
2068    pub(crate) min_stack_headroom_bytes: usize,
2069    /// Lowest headroom already reported, so the rule fires on new lows only.
2070    /// `usize::MAX` = nothing reported yet.
2071    pub(crate) stack_headroom_reported: usize,
2072    /// phase-436 E3 — a minimum is set but the port reports no stack data,
2073    /// so `check_stack_headroom_rule` will skip every tick. Recorded when the
2074    /// bound is set; see `stack_headroom_blind`.
2075    pub(crate) stack_headroom_blind: bool,
2076    /// phase-436 E4 — when the headroom query last ran (`None` = never; a
2077    /// clockless build stores `Some(0)` to mean "has run"), spins since, and
2078    /// how many queries actually reached the port.
2079    pub(crate) stack_headroom_last_check_us: Option<u64>,
2080    pub(crate) stack_headroom_spins_since_check: u32,
2081    pub(crate) stack_headroom_checks: u32,
2082    /// The pacing quantum the spin loop was last driven at, in microseconds.
2083    /// This is the bound the jitter rule judges against -- the caller's own
2084    /// declared cadence, so nothing further has to be declared.
2085    pub(crate) spin_nominal_us: u64,
2086    /// Cadence DECLARED by the driver, in microseconds; `0` = not declared.
2087    ///
2088    /// Takes precedence over the `spin_once` timeout when judging release
2089    /// jitter, because the two are not the same quantity. The timeout is how
2090    /// long a spin may BLOCK waiting for work; the cadence is how often the
2091    /// loop intends to come round. A driver that blocks up to 10 ms and then
2092    /// sleeps 30 ms has a 30 ms cadence and a 10 ms timeout, and judging it
2093    /// against the timeout reports a loop that is late on every single wake.
2094    pub(crate) spin_nominal_declared_us: u64,
2095    /// phase-436 A2b -- the declared cadence is met by the `spin_once` WAIT
2096    /// itself (`set_spin_nominal_wait_paced_us`), not by a sleep the caller
2097    /// adds between spins (`set_spin_nominal_us`). A wait-paced cadence runs
2098    /// on the release grid and is worth one park granule; a self-paced one is
2099    /// judged from entry to entry and is worth a microsecond.
2100    pub(crate) spin_nominal_wait_paced: bool,
2101    /// phase-436 — the bound the last park was computed with, in us, and the
2102    /// source that won it. See `WakeSourceId`.
2103    pub(crate) last_park_bound_us: u64,
2104    pub(crate) last_park_source: WakeSourceId,
2105    /// phase-436 W2 — the park bound after rounding UP to what the platform's
2106    /// wake primitive can express. Kept beside the request rather than
2107    /// replacing it: reporting both is what makes the rounding auditable
2108    /// instead of silent (issue 1194).
2109    pub(crate) last_park_achieved_us: u64,
2110    /// phase-436 W6 — registered platform deadline sources, and how many are
2111    /// live. Inline table: no allocator on the targets this exists for.
2112    pub(crate) wake_sources: [Option<WakeSourceSlot>; MAX_WAKE_SOURCES],
2113    pub(crate) wake_source_count: usize,
2114    /// The single thing that actually blocks. Many sources say WHEN; one
2115    /// primitive does the waiting.
2116    pub(crate) park_primitive: Option<(ParkUntilFn, *mut core::ffi::c_void)>,
2117    /// phase-436 W7.b (issue 1242) — the finest park the INSTALLED primitive
2118    /// can actually express, in microseconds. `0` = none installed, fall back
2119    /// to the `wake_wait_ms` ABI floor.
2120    pub(crate) park_granularity_declared_us: u64,
2121    /// Wakes that were already past their nominal deadline, and wakes total.
2122    ///
2123    /// The maximum alone cannot distinguish one bad wake from a loop that is
2124    /// late every single cycle, and those are different faults: the first is
2125    /// a glitch, the second means the period cannot be met at all.
2126    pub(crate) late_wakes: u32,
2127    pub(crate) total_wakes: u32,
2128    /// phase-436 A2c -- late wakes whose lateness exceeds the granularity the
2129    /// measurement was made at (`release_jitter_granularity_us`). On a loop
2130    /// paced by a tick-granular park nearly every wake is late by a fraction
2131    /// of a tick, by design; `late_wakes` counts those, this does not.
2132    pub(crate) late_wakes_beyond_granule: u32,
2133    /// Issue 0514 / 1635 / phase-474 I1 -- what happens to a detected contract
2134    /// violation: the log switch, the drain-and-report hook, the image's sink,
2135    /// and the ring (CARVED, phase-409) with its total and dropped counters.
2136    /// See [`super::monitor::ViolationChannel`].
2137    pub(crate) violations: super::monitor::ViolationChannel<'s>,
2138    /// Issue 0790 — hooks that run BEFORE the session is closed, while every
2139    /// entity still works. The load-bearing half: a device releasing a bus or
2140    /// parking an actuator has to publish its final state / answer its last
2141    /// request from HERE, because after teardown it cannot.
2142    ///
2143    /// `[Option<_>; N]` rather than a `heapless::Vec`, deliberately: the handle
2144    /// a caller holds IS the slot index, so a removal must leave every other
2145    /// index where it was. A `Vec`'s `swap_remove` would silently re-point one
2146    /// live handle at a different callback, and its `remove` would re-point all
2147    /// of them. Clearing a slot to `None` also lets the next registration reuse
2148    /// it, which a fill cursor could not.
2149    pub(crate) pre_shutdown_hooks:
2150        [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
2151    /// Issue 0790 — hooks that run AFTER the session is closed. rclcpp's
2152    /// `add_on_shutdown_callback` / `rclcpp::on_shutdown`. See
2153    /// [`Self::pre_shutdown_hooks`] for why this is an array.
2154    pub(crate) on_shutdown_hooks:
2155        [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
2156}
2157
2158impl<'s> Executor<'s> {
2159    /// phase-271 — assemble an executor over already-carved, caller-owned
2160    /// storage (`slices`, from [`super::storage::carve`]). Fills every
2161    /// non-storage field and reserves SC slot 0 for the default Fifo SC (carve
2162    /// left it `None`). The single builder shared by every constructor path.
2163    fn assemble(session: SessionStore, slices: super::storage::ExecutorSlices<'s>) -> Self {
2164        let super::storage::ExecutorSlices {
2165            arena,
2166            entries,
2167            sched_contexts,
2168            sched_context_bindings,
2169            sporadic_states,
2170            #[cfg(feature = "alloc")]
2171            sporadic_atomic_states,
2172            remaps,
2173            nodes,
2174            extra_sessions,
2175            extra_session_ids,
2176            node_sched_table,
2177            dispatch_slots,
2178            component_slots,
2179            active_groups,
2180            group_sched_table,
2181            monitor_violations,
2182            alive_slots,
2183            slot_tags,
2184            params: param_slots,
2185            param_home,
2186        } = slices;
2187        // phase-382 W3' — only an executor that can build a store keeps the
2188        // carved slots; without `param-services` the region (sized by the
2189        // build, not by a feature, so the layout never depends on one) is
2190        // simply never initialised. The services' home (issue 1812) is
2191        // zero-sized without the feature.
2192        #[cfg(not(feature = "param-store"))]
2193        let _ = (param_slots, param_home);
2194        // Slot 0 = the auto-created default Fifo SC (see field doc). carve
2195        // initialised the whole table to `None`; populate the reserved slot.
2196        if let Some(slot0) = sched_contexts.first_mut() {
2197            *slot0 = Some(super::sched_context::SchedContext::default());
2198        }
2199        // phase-412 -- stamp the self-report before anything can fail. `init`
2200        // is idempotent, so an image with two executors need not decide which
2201        // one owns the record, and `note_arena_capacity` records the slice this
2202        // executor was actually handed rather than the compiled constant: the
2203        // arena's placement is the caller's choice (issue 0900), so the two can
2204        // legitimately differ and the difference is worth seeing.
2205        crate::boot_report::init();
2206        crate::boot_report::note_arena_capacity(arena.len());
2207        crate::boot_report::checkpoint(crate::boot_report::Stage::ExecutorReady);
2208        Self {
2209            // `assemble` is reached from session-only entry points that have no
2210            // config, so 0 is the floor rather than a choice; `open_in` and any
2211            // binding that knows better overwrite it via `set_domain_id`.
2212            domain_id: 0,
2213            session,
2214            arena,
2215            arena_used: 0,
2216            arena_free_head: ARENA_FREE_NONE,
2217            pending_region: None,
2218            entries,
2219            sched_contexts,
2220            sched_context_bindings,
2221            sporadic_states,
2222            #[cfg(feature = "alloc")]
2223            sporadic_atomic_states,
2224            major_frame_us: 0,
2225            #[cfg(all(
2226                feature = "alloc",
2227                feature = "rmw-cffi",
2228                feature = "scheduler-os-priority"
2229            ))]
2230            os_priority_pool: super::os_priority::OsPriorityPool::new(),
2231            #[cfg(all(feature = "alloc", feature = "scheduler-os-priority"))]
2232            os_priority_apply_policy: None,
2233            trigger: Trigger::Any,
2234            semantics: ExecutorSemantics::RclcppExecutor,
2235            node_name: heapless::String::new(),
2236            active_groups,
2237            active_groups_filtering: false,
2238            nodes,
2239            node_sched_table,
2240            group_sched_table,
2241            remap_table: remaps,
2242            remap_len: 0,
2243            dispatch_slots,
2244            component_slots,
2245            extra_sessions,
2246            extra_session_ids,
2247            primary_rmw_name: heapless::String::new(),
2248            primary_locator: heapless::String::new(),
2249            namespace: {
2250                let mut ns = heapless::String::new();
2251                let _ = ns.push_str("/");
2252                ns
2253            },
2254            // Issue 1177 -- the only cfg the halt flag still needs. `HaltFlag`
2255            // is refcounted with an allocator and plain without one, and
2256            // nothing below this line can tell the difference.
2257            #[cfg(feature = "alloc")]
2258            halt_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
2259            #[cfg(not(feature = "alloc"))]
2260            halt_flag: portable_atomic::AtomicBool::new(false),
2261            spinning: portable_atomic::AtomicBool::new(false),
2262            #[cfg(feature = "alloc")]
2263            wake_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
2264            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2265            node_wake: super::node_wake::NodeWake::new().map(portable_atomic_util::Arc::new),
2266            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2267            wake_ctx: None,
2268            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2269            wake_cb_installed: false,
2270            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2271            has_cross_thread_waker: false,
2272            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2273            wake_membership: None,
2274            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2275            graph_change_target: None,
2276            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2277            has_async_wake: false,
2278            // Phase 141.A.3 — alloc-mode wake state init. Constructed
2279            // eagerly (NodeWake allocation) so the runtime cb can be
2280            // installed lazily on first session without a fallible
2281            // alloc inside spin_once. `None` when the platform
2282            // provider reports the primitive unavailable (matches
2283            // the std-RTOS path's `node_wake: Option<...>`).
2284            #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
2285            signal_fd: None,
2286            #[cfg(feature = "param-store")]
2287            params: None,
2288            #[cfg(feature = "param-store")]
2289            param_slots,
2290            #[cfg(feature = "param-store")]
2291            param_home,
2292            #[cfg(feature = "param-store")]
2293            param_store_refused: false,
2294            #[cfg(feature = "param-store")]
2295            declared_params: nros_params::DeclaredParams::EMPTY,
2296            #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2297            sim_time_source: None,
2298            #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2299            sim_time_requested: false,
2300            #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2301            sim_time_stated: false,
2302            #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2303            sim_time_silence: crate::time_source::SilenceWatch::new(),
2304            #[cfg(feature = "lifecycle-services")]
2305            lifecycle: None,
2306            // Initialise the spin endpoint to construction time so the
2307            // very first `spin_once` credits time the caller spent
2308            // *before* it (e.g. setup, an explicit pre-spin sleep) just
2309            // like time spent between later calls.
2310            // One field, one seed now (phase-359 W10): the clock is absolute
2311            // on every flavour, so it is READ here rather than assumed to be
2312            // zero at construction. The std arm used to seed `Some(0)` because
2313            // its epoch WAS construction; with one provider that special case
2314            // is gone.
2315            last_spin_end_us: default_clock_us_fn().map(|clock| clock()),
2316            clock_us_fn: default_clock_us_fn(),
2317            consecutive_io_failures: 0,
2318            // RFC-0052 W3b.5 — a build with a wall clock gets one by default so
2319            // age monitors activate without extra wiring; a build with neither a
2320            // platform port nor a host gets `None`, and its board installs
2321            // `config.epoch_us` in `from_session_in`/`open`.
2322            //
2323            // phase-359 W10 — the cfg pair that used to be here moved into
2324            // `default_epoch_us_fn`, beside the `default_clock_us_fn` it
2325            // mirrors. (The old comment pointed at "the `not(std)` blocks
2326            // above" for the board install; those blocks are no longer
2327            // cfg-gated — the config override applies on every flavour now.)
2328            epoch_us_fn: super::types::default_epoch_us_fn(),
2329            component_frame: None,
2330            monitor_table: &[],
2331            monitor_states: [super::monitor::MonitorState::default(); super::monitor::MAX_MONITORS],
2332            age_table: &[],
2333            age_states: [super::monitor::AgeState::default(); super::monitor::MAX_AGE_MONITORS],
2334            hazard_table: &[],
2335            fault_fn: None,
2336            reaction_hook: None,
2337            spin_quantization_checked: false,
2338            spin_quantization_us: 0,
2339            max_release_jitter_us: 0,
2340            last_spin_entry_us: None,
2341            grid: ReleaseGrid {
2342                period_us: 0,
2343                next_us: 0,
2344                skipped: 0,
2345            },
2346            jitter_reported_us: 0,
2347            min_stack_headroom_bytes: 0,
2348            stack_headroom_reported: usize::MAX,
2349            stack_headroom_blind: false,
2350            stack_headroom_last_check_us: None,
2351            stack_headroom_spins_since_check: 0,
2352            stack_headroom_checks: 0,
2353            spin_nominal_us: 0,
2354            spin_nominal_declared_us: 0,
2355            spin_nominal_wait_paced: false,
2356            last_park_bound_us: 0,
2357            last_park_source: WakeSourceId::CallerBudget,
2358            last_park_achieved_us: 0,
2359            wake_sources: [None; MAX_WAKE_SOURCES],
2360            wake_source_count: 0,
2361            park_primitive: None,
2362            park_granularity_declared_us: 0,
2363            late_wakes: 0,
2364            total_wakes: 0,
2365            late_wakes_beyond_granule: 0,
2366            violations: super::monitor::ViolationChannel::new(monitor_violations),
2367            alive_slots,
2368            slot_tags,
2369            // Issue 0790 — both phase tables start empty. An image that
2370            // registers nothing pays these `None`s and a two-slot scan at
2371            // teardown, and nothing else.
2372            pre_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
2373            on_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
2374        }
2375    }
2376
2377    /// Create an owning executor over caller-supplied `backing`, sized by
2378    /// `sizing`. The core, non-generic, per-entry entry point (the `alloc`
2379    /// [`from_session`](Self::from_session) convenience leaks a default backing
2380    /// and calls this; the macro / C FFI pass an entry-sized backing).
2381    ///
2382    /// # Safety
2383    /// `backing` must be ≥ `sizing.u64_len()` words, stay alive for `'s`, and
2384    /// not be otherwise accessed while the executor lives (it aliases it).
2385    /// `sizing.cbs` must be ≤ 64 (the `u64` ready-set bitmask ceiling).
2386    pub unsafe fn from_session_in(
2387        session: session::ConcreteSession,
2388        backing: &'s mut [MaybeUninit<u64>],
2389        sizing: super::storage::ExecutorSizing,
2390    ) -> Self {
2391        let slices = unsafe { super::storage::carve(backing, sizing) };
2392        Self::assemble(SessionStore::Owned(session), slices)
2393    }
2394
2395    /// Create a borrowing executor over caller-supplied `backing`, sized by
2396    /// `sizing`. Counterpart to [`from_session_in`](Self::from_session_in) for
2397    /// the per-tier / C model (the session is borrowed, not owned).
2398    ///
2399    /// # Safety
2400    /// - `session_ptr` must point to a valid session that outlives the executor
2401    ///   and is not moved/dropped while it exists.
2402    /// - `backing` obligations as in [`from_session_in`](Self::from_session_in).
2403    pub unsafe fn from_session_ptr_in(
2404        session_ptr: *mut session::ConcreteSession,
2405        backing: &'s mut [MaybeUninit<u64>],
2406        sizing: super::storage::ExecutorSizing,
2407    ) -> Self {
2408        let slices = unsafe { super::storage::carve(backing, sizing) };
2409        Self::assemble(SessionStore::Borrowed(session_ptr), slices)
2410    }
2411}
2412
2413impl Executor<'static> {
2414    /// Create an executor from an already-opened session, using the build-time
2415    /// default sizing (`MAX_CBS`/`MAX_SC`/`ARENA_SIZE`). Convenience for
2416    /// std/alloc callers that don't size per-entry: it leaks a default-sized
2417    /// backing (executor-lifetime, one-time) and calls
2418    /// [`from_session_in`](Self::from_session_in). Per-entry sizing goes through
2419    /// the macro / `open_in` instead.
2420    #[cfg(feature = "alloc")]
2421    pub fn from_session(session: session::ConcreteSession) -> Self {
2422        let sizing = super::storage::ExecutorSizing::DEFAULT;
2423        // SAFETY: the backing is exactly `sizing.u64_len()` words,
2424        // `'static`, and uniquely owned by this executor.
2425        unsafe { Self::from_session_in(session, default_backing(sizing), sizing) }
2426    }
2427
2428    /// [`from_session`](Self::from_session) with an [`ExecutorConfig`], so a
2429    /// caller that brings its own session can also bring its own clock.
2430    ///
2431    /// issue 0709 / issue 0687 — `from_session` takes no config, and that is
2432    /// the path the no-port population uses: it accepts any `Session`, so a
2433    /// consumer with a non-cffi backend reaches the executor through it and had
2434    /// NO way to install `clock_us`. phase-359 W10 argued the `std`-without-a-
2435    /// port clock fallbacks could go because "a caller with a clock installs it
2436    /// through `ExecutorConfig::clock_us`" — true for `open`, false here, which
2437    /// is half of why that deletion was reverted.
2438    ///
2439    /// Only the timing sources are read from `config`; identity (locator,
2440    /// domain, names) belongs to the session the caller already opened. As in
2441    /// [`open_in`](Self::open_in), a `None` field does NOT clobber the
2442    /// platform default — it means "not specified" (the bug issue 0671
2443    /// records).
2444    #[cfg(feature = "alloc")]
2445    pub fn from_session_with(
2446        session: session::ConcreteSession,
2447        config: &super::types::ExecutorConfig<'_>,
2448    ) -> Self {
2449        let mut executor = Self::from_session(session);
2450        if let Some(clock) = config.clock_us {
2451            executor.clock_us_fn = Some(clock);
2452            executor.last_spin_end_us = Some(clock());
2453        }
2454        if let Some(epoch) = config.epoch_us {
2455            executor.epoch_us_fn = Some(epoch);
2456        }
2457        executor
2458    }
2459
2460    /// Create an executor from a borrowed session pointer, default-sized. The
2461    /// `alloc` convenience wrapper over
2462    /// [`from_session_ptr_in`](Self::from_session_ptr_in) — leaks a default
2463    /// backing so existing callers keep the zero-storage-arg signature.
2464    ///
2465    /// # Safety
2466    /// - `session_ptr` must point to a valid, initialized session that lives at
2467    ///   least as long as this executor.
2468    /// - The caller must not move or drop the session while the executor exists.
2469    #[cfg(feature = "alloc")]
2470    pub unsafe fn from_session_ptr(session_ptr: *mut session::ConcreteSession) -> Self {
2471        let sizing = super::storage::ExecutorSizing::DEFAULT;
2472        // SAFETY: the backing as in `from_session`; session_ptr contract
2473        // forwarded to `from_session_ptr_in`.
2474        unsafe { Self::from_session_ptr_in(session_ptr, default_backing(sizing), sizing) }
2475    }
2476}
2477
2478/// phase-474 I11 -- the release grid of a `spin_once(timeout)` loop with no
2479/// declared cadence: the period it was built for (`0` = no grid yet), the next
2480/// scheduled release, and the releases skipped because the loop came back a
2481/// whole period or more after they were due (the timer `Skip` policy: the
2482/// latest due point is served once, the older ones are dropped and counted,
2483/// never released in a burst). The park is bounded by `next_us`, so a loop is
2484/// released on `anchor + k * period` instead of `period` after its previous
2485/// spin ended. Scales with no knob; named in the header-size budget.
2486#[derive(Clone, Copy, Debug, Default)]
2487pub(crate) struct ReleaseGrid {
2488    pub(crate) period_us: u64,
2489    pub(crate) next_us: u64,
2490    pub(crate) skipped: u32,
2491}
2492
2493/// The two cadences a `period_us` timer alternates between under a `spin_us`
2494/// spin period, or `None` when the audit (issue #515) has nothing to say.
2495///
2496/// Split out of `audit_spin_quantization` so the decision can be asserted:
2497/// the audit only LOGS, so a regression in what it decides is otherwise
2498/// invisible to a test.
2499///
2500/// `None` for a period of zero, for a period that divides the spin period —
2501/// and, since issue 1321, for a timer whose activations are NOT paced by the
2502/// spin period at all. The warning's arithmetic is wall microseconds on both
2503/// sides; a `Ros` timer's activations are quantised by the `/clock` STEP, so
2504/// the sentence it would print ("activations will alternate between X and Y
2505/// us") is about nothing.
2506pub(crate) fn spin_quantization_span(
2507    clock_source: TimerClockSource,
2508    period_us: u64,
2509    spin_us: u64,
2510) -> Option<(u64, u64)> {
2511    if spin_us == 0 || period_us == 0 || period_us.is_multiple_of(spin_us) {
2512        return None;
2513    }
2514    if !clock_source.remaining_is_wall_time() {
2515        return None;
2516    }
2517    let early_us = (period_us / spin_us) * spin_us;
2518    Some((early_us, early_us.saturating_add(spin_us)))
2519}
2520
2521/// phase-436 — which deadline source bounded the last park.
2522///
2523/// Attribution is what turns "why did we wake" from a guess into a number.
2524/// Recording it costs one byte per spin and is the difference between a
2525/// schedulability argument you can audit and one you have to trust.
2526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2527#[repr(u8)]
2528pub enum WakeSourceId {
2529    /// The `spin_once` caller's own timeout — always a member of the source
2530    /// set, which is why the park is never unbounded.
2531    CallerBudget,
2532    /// A registered timer's next expiry (issue 1192).
2533    Timer,
2534    /// The backend's next internal event (lease keepalive, heartbeat,
2535    /// ACK-NACK). Folded into the same `min` as everything else rather than
2536    /// capping the budget separately upstream, so one rule decides the park
2537    /// and one attribution explains it.
2538    Session,
2539    /// A source the PLATFORM registered: a `k_poll` event set, an `epoll`
2540    /// group, a FreeRTOS queue set, a ThreadX event-flags group, or a
2541    /// bare-metal device ISR. The core never names an RTOS; it only asks each
2542    /// registered source when it will next need the executor.
2543    Platform(u8),
2544    /// phase-503 — the earliest deadline of a pending supervised route: the
2545    /// park ends there so `route-deadline-runtime` judges it on time.
2546    RouteDeadline,
2547}
2548
2549/// How many platform sources one executor can carry. A plain const, not a
2550/// sizing knob: the table is inline, so no allocator is required on the no_std
2551/// targets this seam mainly exists for.
2552pub const MAX_WAKE_SOURCES: usize = 8;
2553
2554/// phase-436 (RFC-0106 D5) — the slice a poll-only backend's transport wait is
2555/// cut into while a guard condition can signal the executor from another
2556/// thread: the worst-case latency of such a trigger, and the period at which
2557/// an otherwise idle executor re-enters the backend's `recv`.
2558#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2559pub(crate) const POLL_ONLY_WAKE_SLICE_MS: i32 = 10;
2560
2561/// When this source will next need the executor, in the executor's clock,
2562/// as microseconds FROM NOW.
2563///
2564/// `u64::MAX` means "nothing pending" — the source does not shorten the park.
2565/// That is how an ASYNC source spells itself: a socket or a DDS listener
2566/// contributes no deadline and instead breaks the park by signalling the wake
2567/// primitive, so async and deadline sources compose without a second
2568/// mechanism.
2569pub type NextDeadlineFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void) -> u64;
2570
2571/// Park until `deadline_us` from now, or until something signals, whichever
2572/// comes first.
2573///
2574/// Returns `0` when woken by an event, `1` when the deadline expired, and a
2575/// negative value when this build cannot park at all.
2576///
2577/// A DEADLINE, not a duration, and that distinction is what makes bare metal
2578/// work: `nros-baremetal-common`'s own note says `wfi` with no pending
2579/// interrupt deadlocks, so a board must arm a timer compare BEFORE waiting.
2580/// A `sleep(duration)` seam cannot express that — the sleeper does not know
2581/// whether anyone armed an interrupt. `park_until(deadline)` does, because the
2582/// deadline is exactly what the compare is armed to.
2583pub type ParkUntilFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void, deadline_us: u64) -> i8;
2584
2585/// A registered deadline contributor.
2586#[derive(Clone, Copy)]
2587pub(crate) struct WakeSourceSlot {
2588    pub(crate) next: NextDeadlineFn,
2589    pub(crate) ctx: *mut core::ffi::c_void,
2590}
2591
2592impl<'s> Executor<'s> {
2593    /// Phase 228.B (RFC-0015) — construct a tier task's executor that **shares**
2594    /// a session opened once by the orchestration `main()`.
2595    ///
2596    /// In the per-tier execution model `main()` opens one RMW session, then
2597    /// spawns one RTOS task per priority tier; each task calls this to get an
2598    /// [`Executor`] over the *same* session (the `Borrowed` session store — this
2599    /// executor neither owns nor closes it), registers its tier's callback
2600    /// groups, and spins. Thin alias over [`Executor::from_session_ptr`].
2601    ///
2602    /// # Safety
2603    /// `session` must outlive every executor/task built from it (the
2604    /// orchestration `main()` holds it and never returns / WFIs), and must not
2605    /// be mutated except through these executors' spin calls.
2606    #[cfg(feature = "alloc")]
2607    pub unsafe fn open_with_session(session: *mut session::ConcreteSession) -> Executor<'static> {
2608        #[allow(unused_mut)]
2609        let mut exec = unsafe { Executor::<'static>::from_session_ptr(session) };
2610        // phase-436 — join the session's wake fan-out, so this tier is woken
2611        // by its arrivals instead of sleeping out its whole bound.
2612        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2613        exec.install_wake_signal_on_primary();
2614        exec
2615    }
2616
2617    /// phase-271 — per-tier borrowed-session constructor over caller-supplied,
2618    /// per-tier-sized `backing`. The sized counterpart to
2619    /// [`open_with_session`](Self::open_with_session): each RTOS tier task owns
2620    /// its own backing so tiers size independently.
2621    ///
2622    /// # Safety
2623    /// `session` obligations as in [`open_with_session`](Self::open_with_session);
2624    /// `backing`/`sizing` as in [`from_session_ptr_in`](Self::from_session_ptr_in).
2625    pub unsafe fn open_with_session_in(
2626        session: *mut session::ConcreteSession,
2627        backing: &'s mut [MaybeUninit<u64>],
2628        sizing: super::storage::ExecutorSizing,
2629    ) -> Self {
2630        #[allow(unused_mut)]
2631        let mut exec = unsafe { Self::from_session_ptr_in(session, backing, sizing) };
2632        // phase-436 — as `open_with_session`.
2633        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2634        exec.install_wake_signal_on_primary();
2635        exec
2636    }
2637
2638    /// Raw pointer to this executor's RMW session, for the per-tier model:
2639    /// the boot task opens the one session via [`Executor::open`] (the RMW
2640    /// session is a process-wide singleton — opening twice fails), then hands
2641    /// this pointer to each spawned tier task's
2642    /// [`Executor::open_with_session`]. The boot task's executor owns the
2643    /// session and outlives every borrower, so the pointer stays valid for the
2644    /// program's life. Works for both `Owned` and `Borrowed` stores.
2645    ///
2646    /// # Safety
2647    /// The returned pointer aliases `self.session`. Callers must keep `self`
2648    /// alive (not moved/dropped) for as long as any tier executor uses the
2649    /// pointer, and must only touch the session through executor spin calls
2650    /// (the RMW backend serializes concurrent access through its own locks).
2651    pub fn session_ptr(&mut self) -> *mut session::ConcreteSession {
2652        let ptr = &mut *self.session as *mut session::ConcreteSession;
2653        // phase-436 — the owner of an OWNED session claimed its wake fan-out
2654        // while this executor value could still move (inside `open*`). The
2655        // address handed out here is the one every tier is opened over, so
2656        // the entry is keyed by it from now on.
2657        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2658        if let (SessionStore::Owned(_), Some(m)) = (&self.session, self.wake_membership) {
2659            m.fanout().rekey(ptr as *mut core::ffi::c_void);
2660        }
2661        ptr
2662    }
2663
2664    /// Opaque, `Send` form of [`session_ptr`](Self::session_ptr) — the per-tier
2665    /// model hands this to each spawned tier task (it can cross the RTOS task /
2666    /// thread boundary, which a bare `*mut` cannot). See [`SessionHandle`].
2667    ///
2668    /// # Safety
2669    /// Same contract as [`session_ptr`](Self::session_ptr): `self` (the session
2670    /// owner) must outlive every executor built from the handle.
2671    pub fn session_handle(&mut self) -> SessionHandle {
2672        SessionHandle(self.session_ptr())
2673    }
2674
2675    /// Open an [`Executor`] over the session a [`SessionHandle`] refers to (the
2676    /// `Borrowed` store — neither owns nor closes it). The tier-task counterpart
2677    /// to [`session_handle`](Self::session_handle).
2678    ///
2679    /// # Safety
2680    /// The handle's session must still be alive (its owning executor not moved
2681    /// or dropped); access only through executor spin calls.
2682    #[cfg(feature = "alloc")]
2683    pub unsafe fn open_with_session_handle(handle: SessionHandle) -> Executor<'static> {
2684        unsafe { Executor::<'static>::open_with_session(handle.0) }
2685    }
2686
2687    /// phase-271 — sized counterpart to
2688    /// [`open_with_session_handle`](Self::open_with_session_handle) (per-tier
2689    /// backing).
2690    ///
2691    /// # Safety
2692    /// As [`open_with_session_handle`](Self::open_with_session_handle) +
2693    /// [`from_session_ptr_in`](Self::from_session_ptr_in).
2694    pub unsafe fn open_with_session_handle_in(
2695        handle: SessionHandle,
2696        backing: &'s mut [MaybeUninit<u64>],
2697        sizing: super::storage::ExecutorSizing,
2698    ) -> Self {
2699        unsafe { Self::open_with_session_in(handle.0, backing, sizing) }
2700    }
2701
2702    /// issue 1571 — a SPAWNED tier's executor over its slot of the entry's
2703    /// [`TierExecutorBacking`](super::TierExecutorBacking) — the road every
2704    /// board's `run_tiers` takes for each tier but the boot one.
2705    ///
2706    /// The slot's TYPE is the size: it is `ExecutorSizing::DEFAULT.u64_len()`
2707    /// words and this opens with `ExecutorSizing::DEFAULT`, so the pairing is
2708    /// exact by construction and lives here, once, rather than at five boards.
2709    /// [`open_with_session`](Self::open_with_session) is the heap twin this
2710    /// replaced on those roads.
2711    ///
2712    /// # Safety
2713    /// `session` obligations as in [`open_with_session`](Self::open_with_session).
2714    #[cfg(feature = "alloc")]
2715    pub unsafe fn open_with_session_slot(
2716        session: *mut session::ConcreteSession,
2717        slot: &'s mut super::TierExecutorBackingSlot,
2718    ) -> Self {
2719        unsafe {
2720            Self::open_with_session_in(
2721                session,
2722                &mut slot[..],
2723                super::storage::ExecutorSizing::DEFAULT,
2724            )
2725        }
2726    }
2727
2728    /// issue 1571 — [`open_with_session_slot`](Self::open_with_session_slot)
2729    /// over a [`SessionHandle`], for the boards whose tier tasks carry one.
2730    ///
2731    /// # Safety
2732    /// As [`open_with_session_handle`](Self::open_with_session_handle).
2733    #[cfg(feature = "alloc")]
2734    pub unsafe fn open_with_session_handle_slot(
2735        handle: SessionHandle,
2736        slot: &'s mut super::TierExecutorBackingSlot,
2737    ) -> Self {
2738        unsafe { Self::open_with_session_slot(handle.0, slot) }
2739    }
2740
2741    /// Phase 228.C — set this tier executor's active callback-group filter. The
2742    /// generated per-tier task calls this before registering nodes; afterwards
2743    /// only callbacks whose `.callback_group()` is in `groups` register here.
2744    /// An empty slice (or never calling it) leaves the wildcard — register all
2745    /// callbacks (the single-tier degenerate case + today's behaviour).
2746    pub fn set_active_groups(
2747        &mut self,
2748        groups: &[(&str, &str, &str)],
2749    ) -> Result<(), super::types::BindError> {
2750        // phase-409 — the table is CARVED and reused, so clear before refilling;
2751        // the old `Option<heapless::Vec>` got a fresh empty vector each call.
2752        self.active_groups.clear();
2753        if groups.is_empty() {
2754            self.active_groups_filtering = false;
2755            return Ok(());
2756        }
2757        for (name, ns, group) in groups {
2758            // issue 1172 — this used to be `if s.push_str(g).is_ok()`, which
2759            // DROPPED an oversized group name and left the tier filtering on a
2760            // set that silently lacked it. It fails closed (the entity is never
2761            // created), so the symptom is a missing entity with nothing to read
2762            // that says why. Same contract as `declare_remap` below: too long,
2763            // or table full, is the caller's error.
2764            let key = super::storage::node_identity_hash(name, ns);
2765            let mut s = heapless::String::new();
2766            let why = if s.push_str(group).is_err() {
2767                Some(super::types::BindError::NameTooLong)
2768            } else if self.active_groups.push((key, s)).is_err() {
2769                Some(super::types::BindError::TableFull)
2770            } else {
2771                None
2772            };
2773            if let Some(why) = why {
2774                // FAIL CLOSED, and say so. Returning here with a PARTIAL table
2775                // would leave the tier filtering on a subset — the same silent
2776                // narrowing, one step later. An empty table with filtering ON
2777                // accepts NOTHING (`group_filter_accepts` matches on an empty
2778                // slice), so the tier registers no entities at all: visible in
2779                // behaviour, where a missing entry is not. The `Err` is for the
2780                // caller that can report it; the state is safe for the one that
2781                // cannot.
2782                self.active_groups.clear();
2783                self.active_groups_filtering = true;
2784                return Err(why);
2785            }
2786        }
2787        self.active_groups_filtering = true;
2788        Ok(())
2789    }
2790
2791    /// The current callback-group filter, or `None` for the wildcard.
2792    fn active_group_filter(&self) -> Option<&[super::storage::ActiveGroup]> {
2793        self.active_groups_filtering
2794            .then(|| self.active_groups.as_slice())
2795    }
2796
2797    /// Phase 228.C — whether a callback in `group` should register in this
2798    /// executor under the current filter. The wildcard accepts everything.
2799    pub fn group_active(&self, node_name: &str, node_namespace: &str, group: &str) -> bool {
2800        group_filter_accepts(
2801            self.active_group_filter(),
2802            super::storage::node_identity_hash(node_name, node_namespace),
2803            group,
2804        )
2805    }
2806
2807    /// Set the node name and namespace used for liveliness tokens.
2808    ///
2809    /// Called by `open()` to propagate config values. When `register_subscription`
2810    /// or `register_service` creates entities, these values are attached to the
2811    /// Phase 156 — record the primary session's backend identity
2812    /// (rmw name + locator) so `NodeBuilder::resolve_session_slot`
2813    /// can detect when a `.rmw(name)` matches the primary instead
2814    /// of opening a SECOND backend session against the same
2815    /// singleton (zenoh-pico's `g_session` is process-wide;
2816    /// opening twice fails). `Executor::open*` calls this
2817    /// automatically; the C surface (`rclc_executor_init`) calls
2818    /// it manually because it constructs via `from_session_ptr`
2819    /// which doesn't know the open metadata. Empty strings = "no
2820    /// primary identity tracked"; the cache check degrades to
2821    /// always-miss.
2822    pub fn set_primary_identity(&mut self, rmw_name: &str, locator: &str) {
2823        self.primary_rmw_name.clear();
2824        let _ = self.primary_rmw_name.push_str(rmw_name);
2825        self.primary_locator.clear();
2826        let _ = self.primary_locator.push_str(locator);
2827    }
2828
2829    /// `TopicInfo`/`ServiceInfo` so the zenoh backend can declare liveliness.
2830    /// Issue 0656 — set the ROS domain for entities this executor declares.
2831    ///
2832    /// For bindings that build an executor from an existing session
2833    /// (`from_session_ptr_in`), where no `ExecutorConfig` is available and the
2834    /// domain would otherwise stay at its 0 floor.
2835    pub fn set_domain_id(&mut self, domain_id: u32) {
2836        self.domain_id = domain_id;
2837    }
2838
2839    /// The ROS domain this executor declares entities on.
2840    pub fn domain_id(&self) -> u32 {
2841        self.domain_id
2842    }
2843
2844    pub fn set_node_identity(&mut self, node_name: &str, namespace: &str) {
2845        self.node_name.clear();
2846        let _ = self.node_name.push_str(node_name);
2847        if !namespace.is_empty() {
2848            self.namespace.clear();
2849            let _ = self.namespace.push_str(namespace);
2850        }
2851    }
2852
2853    // =========================================================================
2854    // Phase 305 W3 (issue 0255) — per-node launch remap table
2855    // =========================================================================
2856
2857    /// Record one launch `<remap from= to=/>` rule for the node identified by
2858    /// `(node_name, namespace)`. Rules are matched in declaration order (first
2859    /// wins) by [`Self::resolve_entity_name_for`]. Errors when a string
2860    /// overflows its slot or the table is at `MAX_REMAPS` — callers surface
2861    /// this rather than silently dropping a routing rule.
2862    ///
2863    /// **This declares an AUTHORITATIVE rule.** Declaration order settles
2864    /// which of two launch rules for one name wins; it is not how an argv rule
2865    /// is ranked against a launch rule. The `--ros-args` rules share this table
2866    /// but are tagged as the fallback tier, and resolution reads both tiers
2867    /// through [`crate::names::resolve_name_layered`] — so an argv rule
2868    /// installed BEFORE a launch rule still loses to it.
2869    #[allow(clippy::result_unit_err)]
2870    pub fn declare_remap(
2871        &mut self,
2872        node_name: &str,
2873        namespace: &str,
2874        from: &str,
2875        to: &str,
2876    ) -> Result<(), ()> {
2877        let mut rule = RemapRule {
2878            node_name: heapless::String::new(),
2879            namespace: heapless::String::new(),
2880            from: heapless::String::new(),
2881            to: heapless::String::new(),
2882            tier: RemapTier::Launch,
2883        };
2884        rule.node_name.push_str(node_name)?;
2885        let ns = if namespace.is_empty() { "/" } else { namespace };
2886        rule.namespace.push_str(ns)?;
2887        rule.from.push_str(from)?;
2888        rule.to.push_str(to)?;
2889        // Same contract as the `heapless::Vec::push` this replaces: full table
2890        // is an error the caller surfaces, never a silently dropped rule.
2891        let slot = self.remap_table.get_mut(self.remap_len).ok_or(())?;
2892        *slot = Some(rule);
2893        self.remap_len += 1;
2894        Ok(())
2895    }
2896
2897    /// Resolve a source-level entity name for the node identified by
2898    /// `(node_name, namespace)`: ROS 2 name expansion (`~`/relative → FQN)
2899    /// plus this node's declared remap rules (exact-FQN match, first rule
2900    /// wins). Nodes with no rules still get expansion. Errors on an
2901    /// unexpandable name (see `crate::names::expand_name`).
2902    ///
2903    /// Two tiers, through [`crate::names::resolve_name_layered`]: the rules
2904    /// [`Self::declare_remap`] recorded for this node are AUTHORITATIVE (the
2905    /// generated entry declares the launch projection there), and the
2906    /// `--ros-args` rules `crate::ros_args::install_argv_remaps` put in the
2907    /// same table are the FALLBACK, consulted only when no launch rule
2908    /// matched. rcl does the same with its local and global arguments
2909    /// (`rcl_remap_topic_name`, Humble `rcl/remap.h`).
2910    #[allow(clippy::result_unit_err)]
2911    pub fn resolve_entity_name_for(
2912        &self,
2913        node_name: &str,
2914        namespace: &str,
2915        source: &str,
2916    ) -> Result<crate::names::ResolvedName, ()> {
2917        let ns = if namespace.is_empty() { "/" } else { namespace };
2918        let table = &self.remap_table[..self.remap_len];
2919        crate::names::resolve_name_layered(
2920            source,
2921            node_name,
2922            ns,
2923            tier_rules(table, RemapTier::Launch, node_name, ns),
2924            tier_rules(table, RemapTier::Argv, node_name, ns),
2925        )
2926    }
2927
2928    /// [`Self::resolve_entity_name_for`] against the executor's CURRENT node
2929    /// identity (`set_node_identity`) — the nros-c registration sites set that
2930    /// identity per node immediately before registering each entity.
2931    #[allow(clippy::result_unit_err)]
2932    pub fn resolve_entity_name(&self, source: &str) -> Result<crate::names::ResolvedName, ()> {
2933        self.resolve_entity_name_for(self.node_name.as_str(), self.namespace.as_str(), source)
2934    }
2935
2936    // =========================================================================
2937    // Phase 272 (RFC-0047) — node-name → sched-context table
2938    // =========================================================================
2939
2940    /// Seed a config-resolved tier binding by `(name, namespace)` before the
2941    /// node is built. `NodeBuilder::build` consults this table when no
2942    /// explicit `.sched()` override is given — the table entry then wins over
2943    /// the `SchedContextId(0)` default (precedence: explicit > table > 0).
2944    ///
2945    /// Call BEFORE `node_builder(name).build()`. An existing entry for the
2946    /// same `(name, namespace)` key is overwritten (last-write wins). Overflow
2947    /// past `MAX_NODES` is an `Err`, never a dropped binding — issue 1172. An
2948    /// empty `namespace` is normalised
2949    /// to `"/"` to match what `NodeBuilder::build` computes for a root-NS node.
2950    pub fn bind_node_name_sched(
2951        &mut self,
2952        name: &str,
2953        namespace: &str,
2954        sc: super::sched_context::SchedContextId,
2955    ) -> Result<(), super::types::BindError> {
2956        let norm_ns = if namespace.is_empty() { "/" } else { namespace };
2957        // Overwrite if there is already an entry for this (name, ns) pair.
2958        for entry in self.node_sched_table.iter_mut() {
2959            if entry.0.as_str() == name && entry.1.as_str() == norm_ns {
2960                entry.2 = sc;
2961                return Ok(());
2962            }
2963        }
2964        // New entry. Too long, or table full, is an ERROR the caller surfaces
2965        // — never a dropped binding. A binding that vanishes leaves the node on
2966        // the wrong sched context with nothing to read that says so.
2967        let mut name_s = heapless::String::<64>::new();
2968        let mut ns_s = heapless::String::<64>::new();
2969        name_s
2970            .push_str(name)
2971            .map_err(|_| super::types::BindError::NameTooLong)?;
2972        ns_s.push_str(norm_ns)
2973            .map_err(|_| super::types::BindError::NameTooLong)?;
2974        self.node_sched_table
2975            .push((name_s, ns_s, sc))
2976            .map_err(|_| super::types::BindError::TableFull)?;
2977        Ok(())
2978    }
2979
2980    /// Look up the seeded sched-context for `(name, namespace)`. Returns
2981    /// `None` when the table has no entry for this pair (unseed → default 0).
2982    /// `pub(super)` — visible only within the `executor` module (sibling
2983    /// `node_record` calls it from `NodeBuilder::build`).
2984    pub(super) fn lookup_node_sched(
2985        &self,
2986        name: &str,
2987        namespace: &str,
2988    ) -> Option<super::sched_context::SchedContextId> {
2989        for entry in self.node_sched_table.iter() {
2990            if entry.0.as_str() == name && entry.1.as_str() == namespace {
2991                return Some(entry.2);
2992            }
2993        }
2994        None
2995    }
2996
2997    // =========================================================================
2998    // Phase 273 (RFC-0047) — per-callback-group → sched-context table
2999    // =========================================================================
3000
3001    /// Seed a config-resolved tier binding by `(name, namespace, group)` before
3002    /// entities are registered. `apply_node_default_sched` consults this table
3003    /// first (group table > node default > `SchedContextId(0)`).
3004    ///
3005    /// Call BEFORE entity creation. An existing entry for the same
3006    /// `(name, namespace, group)` key is overwritten (last-write wins). Overflow
3007    /// past `MAX_CBS` is an `Err`, never a dropped binding — issue 1172. An
3008    /// empty `namespace` is normalised to
3009    /// `"/"` to match `NodeBuilder::build`. Mirror of `bind_node_name_sched`.
3010    pub fn bind_group_sched(
3011        &mut self,
3012        name: &str,
3013        namespace: &str,
3014        group: &str,
3015        sc: super::sched_context::SchedContextId,
3016    ) -> Result<(), super::types::BindError> {
3017        let norm_ns = if namespace.is_empty() { "/" } else { namespace };
3018        // Overwrite if there is already an entry for this (name, ns, group).
3019        for entry in self.group_sched_table.iter_mut() {
3020            if entry.0.as_str() == name && entry.1.as_str() == norm_ns && entry.2.as_str() == group
3021            {
3022                entry.3 = sc;
3023                return Ok(());
3024            }
3025        }
3026        // Same contract as the two above.
3027        let mut name_s = heapless::String::<64>::new();
3028        let mut ns_s = heapless::String::<64>::new();
3029        let mut grp_s = heapless::String::<32>::new();
3030        name_s
3031            .push_str(name)
3032            .map_err(|_| super::types::BindError::NameTooLong)?;
3033        ns_s.push_str(norm_ns)
3034            .map_err(|_| super::types::BindError::NameTooLong)?;
3035        grp_s
3036            .push_str(group)
3037            .map_err(|_| super::types::BindError::NameTooLong)?;
3038        self.group_sched_table
3039            .push((name_s, ns_s, grp_s, sc))
3040            .map_err(|_| super::types::BindError::TableFull)?;
3041        Ok(())
3042    }
3043
3044    /// Look up the seeded sched-context for `(name, namespace, group)`. Returns
3045    /// `None` when the table has no entry for this triple.
3046    fn lookup_group_sched(
3047        &self,
3048        name: &str,
3049        namespace: &str,
3050        group: &str,
3051    ) -> Option<super::sched_context::SchedContextId> {
3052        for entry in self.group_sched_table.iter() {
3053            if entry.0.as_str() == name
3054                && entry.1.as_str() == namespace
3055                && entry.2.as_str() == group
3056            {
3057                return Some(entry.3);
3058            }
3059        }
3060        None
3061    }
3062
3063    // =========================================================================
3064    // Phase 110.B — SchedContext API
3065    // =========================================================================
3066
3067    /// Identifier of the auto-created default `Fifo`-class scheduling
3068    /// context. Every callback registered without an explicit
3069    /// [`Self::bind_handle_to_sched_context`] binds to this SC.
3070    pub fn default_sched_context_id(&self) -> super::sched_context::SchedContextId {
3071        super::sched_context::SchedContextId(0)
3072    }
3073
3074    /// Register a new scheduling context. Returns a [`SchedContextId`](crate::executor::sched_context::SchedContextId)
3075    /// callers pass to [`Self::bind_handle_to_sched_context`] to attach
3076    /// callbacks. Phase 110.B.
3077    pub fn create_sched_context(
3078        &mut self,
3079        sc: super::sched_context::SchedContext,
3080    ) -> Result<super::sched_context::SchedContextId, NodeError> {
3081        // Slot 0 is reserved for the default Fifo SC; search 1..MAX_SC.
3082        for (i, slot) in self.sched_contexts.iter_mut().enumerate().skip(1) {
3083            if slot.is_none() {
3084                *slot = Some(sc);
3085                // Phase 110.E — Sporadic-class SCs get a sibling
3086                // `SporadicState` entry that the spin_once dispatch
3087                // path consults each cycle to refill the budget at
3088                // period boundaries and skip dispatch when budget
3089                // is exhausted.
3090                if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
3091                    let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
3092                    let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
3093                    self.sporadic_states[i] =
3094                        Some(super::sched_context::SporadicState::new(budget, period));
3095                }
3096                return Ok(super::sched_context::SchedContextId(i as u8));
3097            }
3098        }
3099        Err(NodeError::NoSchedContextSlot)
3100    }
3101
3102    /// RFC-0052 W3b.2 — wall-clock µs since the UNIX epoch, when this
3103    /// target has an epoch source (config `epoch_us`, defaulted from
3104    /// `SystemTime` on hosted configs). `None` = no wall clock; age
3105    /// monitors must not have been baked (the emitter refuses).
3106    pub fn epoch_now_us(&self) -> Option<u64> {
3107        self.epoch_us_fn.map(|f| f())
3108    }
3109
3110    /// RFC-0052 W3b.4 — install the baked contract-monitor table. Call
3111    /// BEFORE entity creation so `create_publisher` can attach each
3112    /// contracted endpoint's counter cell. Mirrors `set_qos_overrides`:
3113    /// `&'static`, codegen-baked, empty by default.
3114    ///
3115    /// phase-496 W1 -- the executor watches at most
3116    /// [`MAX_MONITORS`](super::monitor::MAX_MONITORS) rows
3117    /// (`NROS_EXECUTOR_MAX_MONITORS`). A generated entry cannot reach this with
3118    /// more: its table carries a compile-time assertion that names the knob,
3119    /// and the C++ installer refuses at runtime. A hand-written longer table is
3120    /// a debug-build panic naming the knob, because in release the spin loop
3121    /// would inspect only the first `MAX_MONITORS` and leave the rest silently
3122    /// unwatched.
3123    pub fn set_monitor_table(&mut self, table: &'static [super::monitor::MonitorSpec]) {
3124        if let Err(full) = super::monitor::check_table_capacity(table.len(), 0)
3125            && cfg!(debug_assertions)
3126        {
3127            panic!("set_monitor_table: {full}");
3128        }
3129        self.monitor_table = table;
3130    }
3131
3132    /// phase-496 W1 -- install BOTH baked monitor tables, or neither.
3133    ///
3134    /// The refusing installer: a table longer than this executor watches
3135    /// (`MAX_MONITORS` / `MAX_AGE_MONITORS`) is `Err`, and its `Display` names
3136    /// the knob to raise, before anything is installed. Nothing is truncated:
3137    /// the spin loop inspects only the first `MAX_*` specs, so accepting a
3138    /// longer table would boot with its tail silently unwatched. The C ABI's
3139    /// `nros_cpp_install_monitors` refuses on the same check.
3140    pub fn try_set_monitor_tables(
3141        &mut self,
3142        table: &'static [super::monitor::MonitorSpec],
3143        ages: &'static [super::monitor::AgeMonitorSpec],
3144    ) -> Result<(), super::monitor::MonitorTableFull> {
3145        super::monitor::check_table_capacity(table.len(), ages.len())?;
3146        self.monitor_table = table;
3147        self.age_table = ages;
3148        Ok(())
3149    }
3150
3151    /// The installed monitor table (empty unless the entry set one).
3152    #[must_use]
3153    pub fn monitor_table(&self) -> &'static [super::monitor::MonitorSpec] {
3154        self.monitor_table
3155    }
3156
3157    /// Issue 1676 — the counter cell a publisher on `topic_name` bumps, by
3158    /// exact topic match against the installed table (`None` = uncontracted).
3159    pub(crate) fn publisher_monitor_cell(
3160        &self,
3161        topic_name: &str,
3162    ) -> Option<&'static super::monitor::PubMonitorCell> {
3163        self.monitor_table
3164            .iter()
3165            .find(|m| m.topic == topic_name)
3166            .map(|m| m.cell)
3167    }
3168
3169    /// W3b.5 — install the baked subscriber age-contract table. Call
3170    /// BEFORE entity creation so `create_subscription` can attach each
3171    /// contracted endpoint's age cell (needs an epoch source — see
3172    /// `ExecutorConfig::epoch_us`; without one the take path records
3173    /// nothing and age monitors stay silent).
3174    ///
3175    /// phase-496 W1 -- at most
3176    /// [`MAX_AGE_MONITORS`](super::monitor::MAX_AGE_MONITORS) rows
3177    /// (`NROS_EXECUTOR_MAX_AGE_MONITORS`), on the same terms as
3178    /// [`Self::set_monitor_table`].
3179    pub fn set_age_table(&mut self, table: &'static [super::monitor::AgeMonitorSpec]) {
3180        if let Err(full) = super::monitor::check_table_capacity(0, table.len())
3181            && cfg!(debug_assertions)
3182        {
3183            panic!("set_age_table: {full}");
3184        }
3185        self.age_table = table;
3186    }
3187
3188    /// The installed age table (empty unless the entry set one).
3189    #[must_use]
3190    pub fn age_table(&self) -> &'static [super::monitor::AgeMonitorSpec] {
3191        self.age_table
3192    }
3193
3194    /// phase-462 W3b — install the baked hazard table. Each row's guards name
3195    /// rows of the tables [`Self::set_monitor_table`] and
3196    /// [`Self::set_age_table`] install, so install those first; a guard whose
3197    /// row is absent never faults. The observer runs after the monitor rows on
3198    /// every monitor tick and reports `hazard-detected`, `hazard-reaction` and
3199    /// `hazard-recovered` on the same drain.
3200    pub fn set_hazard_table(&mut self, table: &'static [super::monitor::HazardSpec]) {
3201        self.hazard_table = table;
3202    }
3203
3204    /// The installed hazard table (empty unless the entry set one).
3205    #[must_use]
3206    pub fn hazard_table(&self) -> &'static [super::monitor::HazardSpec] {
3207        self.hazard_table
3208    }
3209
3210    /// W3b.5 — override the wall-clock (epoch µs) source age monitors take
3211    /// message stamps against. Hosted builds default to `SystemTime`; a
3212    /// board with a synced RTC installs its own here (or via
3213    /// `ExecutorConfig::epoch_us`). Call BEFORE entity creation — the age
3214    /// hook captures this at `create_subscription` time.
3215    pub fn set_epoch_clock(&mut self, epoch_us: fn() -> u64) {
3216        self.epoch_us_fn = Some(epoch_us);
3217    }
3218
3219    /// W3b.5 — resolve a subscription's age hook at registration time:
3220    /// exact topic match against the baked age table, only for stamped
3221    /// types (`M::STAMP_OFFSET`) and only when an epoch source exists.
3222    fn age_lookup<M: RosMessage>(&self, topic: &str) -> Option<super::arena::AgeMon> {
3223        M::STAMP_OFFSET?;
3224        let epoch = self.epoch_us_fn?;
3225        self.age_table
3226            .iter()
3227            .find(|a| a.topic == topic)
3228            .map(|a| (a.cell, epoch))
3229    }
3230
3231    /// W3b.5 — install the `DeadlineAction::Fault` hook. Without one a
3232    /// fault-class deadline miss panics (watchdog-visible stop on
3233    /// embedded targets).
3234    /// Plan one `spin_once` against the caller's intended cadence, and return
3235    /// the park budget in microseconds.
3236    ///
3237    /// phase-474 I11 -- two cases:
3238    ///
3239    /// * **No declared cadence, non-zero timeout** (the generated entries'
3240    ///   `spin_once(10)` loop, `spin`, `spin_period`): the timeout is the
3241    ///   period and the loop runs on an absolute RELEASE GRID. The budget is
3242    ///   the time to the next grid point, not a whole period from now, so the
3243    ///   previous spin's work no longer pushes every later release back. A
3244    ///   loop that comes back after its next point was due is released at
3245    ///   once, late by the difference; one that comes back a whole period or
3246    ///   more after it is released once for the LATEST due point and the
3247    ///   older ones are skipped and counted -- the timer `Skip` policy, never
3248    ///   a burst. Lateness is judged at the release (`record_grid_release`).
3249    ///   A change of timeout re-anchors the grid at this entry.
3250    /// * **A declared cadence** (`set_spin_nominal_us`, the tier loops that
3251    ///   sleep between spins themselves): unchanged. Late is
3252    ///   `(now - last_entry) - cadence`, clamped at zero, and the budget is
3253    ///   the timeout.
3254    ///
3255    /// A zero timeout claims no cadence and is skipped entirely, which keeps
3256    /// `Future::wait`-style busy spins out of the statistic and off the grid.
3257    fn record_release_jitter(&mut self, timeout: core::time::Duration) -> u64 {
3258        let timeout_us = timeout.as_micros().min(u64::MAX as u128) as u64;
3259        if !self.spin_nominal_self_paced() {
3260            if timeout_us == 0 {
3261                return 0;
3262            }
3263            let Some(now) = self.now_us() else {
3264                return timeout_us;
3265            };
3266            // phase-436 A2b -- a wait-paced declaration supplies the grid's
3267            // period; otherwise the timeout stands in for it.
3268            let period_us = if self.spin_nominal_declared_us != 0 {
3269                self.spin_nominal_declared_us
3270            } else {
3271                timeout_us
3272            };
3273            self.spin_nominal_us = period_us;
3274            if self.grid.period_us != period_us {
3275                // First periodic spin, or a new period: anchor here, so the
3276                // first park is a whole period exactly as before.
3277                self.grid.period_us = period_us;
3278                self.grid.next_us = now.saturating_add(period_us);
3279            } else if now >= self.grid.next_us.saturating_add(period_us) {
3280                // The loop came back a whole period or more after its next
3281                // release was due: serve the latest due point once and drop
3282                // the ones before it. The first dropped point is a release
3283                // that never happened, so its lateness at this moment (a
3284                // period or more) is what the jitter rule judges.
3285                let missed = (now - self.grid.next_us) / period_us;
3286                self.note_dropped_release(now - self.grid.next_us);
3287                self.grid.skipped = self
3288                    .grid
3289                    .skipped
3290                    .saturating_add(missed.min(u32::MAX as u64) as u32);
3291                self.grid.next_us = self
3292                    .grid
3293                    .next_us
3294                    .saturating_add(missed.saturating_mul(period_us));
3295            }
3296            // The timeout still bounds the wait. With a declared period it
3297            // may be shorter than the period (a caller that passes whole
3298            // milliseconds for a 1.5 ms cadence); a wake before the grid
3299            // point is then not a release, and the next spin parks again.
3300            return self.grid.next_us.saturating_sub(now).min(timeout_us);
3301        }
3302        // phase-474 I7 -- the violation reporter's own time since the last
3303        // sample is not a late release (see `flush_violation_reports`). Taken
3304        // first, so a spin that judges nothing does not carry it forward.
3305        let report_us = core::mem::take(&mut self.violations.reports.debt_us);
3306        let nominal_us = self.spin_nominal_declared_us;
3307        let Some(now) = self.now_us() else {
3308            return timeout_us;
3309        };
3310        self.spin_nominal_us = nominal_us;
3311        if let Some(last) = self.last_spin_entry_us {
3312            let interval = now.saturating_sub(last).saturating_sub(report_us);
3313            self.total_wakes = self.total_wakes.saturating_add(1);
3314            if let Some(late) = interval.checked_sub(nominal_us)
3315                && late > 0
3316            {
3317                self.count_late_wake(late);
3318            }
3319        }
3320        self.last_spin_entry_us = Some(now);
3321        timeout_us
3322    }
3323
3324    /// phase-474 I11 -- judge a grid release, at the clock read after the
3325    /// park. A spin that wakes before its grid point (data arrived, a timer
3326    /// or another source was due first) is not a release: it dispatches and
3327    /// leaves the grid where it was. One that wakes at or after it is THE
3328    /// release of that point, late by `now - grid.next_us` (less the
3329    /// violation reporter's own time, phase-474 I7), and the grid advances by
3330    /// one period. Points the park itself overslept by a whole period are
3331    /// skipped at the next entry, not served in a burst.
3332    fn record_grid_release(&mut self, now: u64) {
3333        if self.spin_nominal_self_paced() || self.grid.period_us == 0 {
3334            return;
3335        }
3336        if now < self.grid.next_us {
3337            return;
3338        }
3339        let report_us = core::mem::take(&mut self.violations.reports.debt_us);
3340        let late = now
3341            .saturating_sub(self.grid.next_us)
3342            .saturating_sub(report_us);
3343        self.total_wakes = self.total_wakes.saturating_add(1);
3344        if late > 0 {
3345            self.count_late_wake(late);
3346        }
3347        self.grid.next_us = self.grid.next_us.saturating_add(self.grid.period_us);
3348        if now >= self.grid.next_us {
3349            // The park overslept the following point(s) too. This wake serves
3350            // one release; the points it ran past are skipped, not released
3351            // back to back on the next spins.
3352            let missed = (now - self.grid.next_us) / self.grid.period_us + 1;
3353            self.grid.skipped = self
3354                .grid
3355                .skipped
3356                .saturating_add(missed.min(u32::MAX as u64) as u32);
3357            self.grid.next_us = self
3358                .grid
3359                .next_us
3360                .saturating_add(missed.saturating_mul(self.grid.period_us));
3361        }
3362    }
3363
3364    /// One wake that came back `late_us` (> 0) after its release was due.
3365    fn count_late_wake(&mut self, late_us: u64) {
3366        self.late_wakes = self.late_wakes.saturating_add(1);
3367        if late_us > self.release_jitter_granularity_us() {
3368            self.late_wakes_beyond_granule = self.late_wakes_beyond_granule.saturating_add(1);
3369        }
3370        if late_us > self.max_release_jitter_us {
3371            self.max_release_jitter_us = late_us;
3372        }
3373    }
3374
3375    /// A cadence the caller meets with its own sleep between spins.
3376    fn spin_nominal_self_paced(&self) -> bool {
3377        self.spin_nominal_declared_us != 0 && !self.spin_nominal_wait_paced
3378    }
3379
3380    /// The lateness of a grid release that was dropped rather than served,
3381    /// into the high-water the jitter rule reads (not into the wake counts:
3382    /// no wake happened).
3383    fn note_dropped_release(&mut self, late_us: u64) {
3384        let late = late_us.saturating_sub(core::mem::take(&mut self.violations.reports.debt_us));
3385        if late > self.max_release_jitter_us {
3386            self.max_release_jitter_us = late;
3387        }
3388    }
3389
3390    /// phase-474 I11 -- grid releases skipped since the last
3391    /// `clear_release_jitter_stats`, because the loop came back after they
3392    /// were due. Zero for a declared-cadence or zero-timeout caller.
3393    pub fn skipped_releases(&self) -> u32 {
3394        self.grid.skipped
3395    }
3396
3397    /// Release-jitter statistics from the spin loop: worst lateness in
3398    /// microseconds, the number of wakes that were already late, and the
3399    /// number of wakes total.
3400    ///
3401    /// The maximum is the figure of merit, and the ratio is what tells the
3402    /// two failures apart: one late wake in ten thousand is a glitch, ten
3403    /// thousand in ten thousand means the period cannot be met at all.
3404    ///
3405    /// Zero on a build with no clock -- `spin_period` refuses to run at all
3406    /// there (issue 0709), so there is nothing to have measured.
3407    pub fn release_jitter(&self) -> (u64, u32, u32) {
3408        (
3409            self.max_release_jitter_us,
3410            self.late_wakes,
3411            self.total_wakes,
3412        )
3413    }
3414
3415    /// phase-436 A2c -- of the `late` wakes `release_jitter` counts, those
3416    /// late by more than [`Self::release_jitter_granularity_us`].
3417    ///
3418    /// A wake is late at one microsecond past its release. A loop paced by a
3419    /// tick-granular park comes back up to a tick past it on every wake, by
3420    /// design, so `late` approaches `total` on a loop that is keeping its
3421    /// period perfectly well. This count leaves that rounding out: it is the
3422    /// lateness the measurement can actually resolve. Cleared with the rest.
3423    pub fn late_wakes_beyond_granularity(&self) -> u32 {
3424        self.late_wakes_beyond_granule
3425    }
3426
3427    /// Reset the release-jitter statistics. For monitoring code that logs
3428    /// and clears per window, so a single early outlier does not pin the
3429    /// maximum for the life of the process.
3430    pub fn clear_release_jitter_stats(&mut self) {
3431        self.max_release_jitter_us = 0;
3432        self.late_wakes = 0;
3433        self.late_wakes_beyond_granule = 0;
3434        self.total_wakes = 0;
3435        self.last_spin_entry_us = None;
3436        self.jitter_reported_us = 0;
3437        self.grid.skipped = 0;
3438    }
3439
3440    pub fn set_fault_handler(&mut self, f: fn(&super::monitor::Violation)) {
3441        self.fault_fn = Some(f);
3442    }
3443
3444    /// phase-503 — install the REACTION hook: `f(event, ctx)` is called on the
3445    /// spin thread when a supervised route passes its deadline
3446    /// (`route-deadline-runtime`), with the row's reaction id, row index and
3447    /// mechanism. It also stands in for [`Self::set_fault_handler`] on a
3448    /// `DeadlineAction::Fault` when no fault handler is set (reaction id 0,
3449    /// the sched-context slot as the row), so a C or C++ image has a fault
3450    /// path that does not panic. Replaces any earlier hook; `None` removes it.
3451    ///
3452    /// The hook must not block and must not re-enter this executor: it starts
3453    /// the fallback, or hands it to another thread, and returns.
3454    pub fn set_reaction_hook(
3455        &mut self,
3456        hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
3457    ) {
3458        self.reaction_hook = hook;
3459    }
3460
3461    /// phase-503 — microseconds from now to the earliest deadline of a pending
3462    /// supervised route (0 when one is due), or `None` when none is waiting or
3463    /// the executor has no clock. A port that supervises with its own timer
3464    /// (`MECHANISM_TIMER`) re-arms it from this after each spin.
3465    pub fn route_next_due_us(&self) -> Option<u64> {
3466        // No table, no clock read: this runs on every park.
3467        if self.monitor_table.is_empty() {
3468            return None;
3469        }
3470        let clock = self.clock_us_fn?;
3471        super::monitor::route_next_due_us(self.monitor_table, clock())
3472    }
3473
3474    /// Issue #515 — warn about periods the spin cadence cannot express.
3475    ///
3476    /// A timer fires on the first spin at or after its period elapses, so
3477    /// a period that is not an integer multiple of the executor's spin
3478    /// period lands on the spin grid instead of the declared cadence: a
3479    /// 33 ms timer on a 5 ms spin alternates 35 ms / 30 ms, mean 33.0.
3480    /// The rate is preserved and no activation is dropped, so every
3481    /// runtime rule is (correctly) silent — the jitter stays invisible
3482    /// until someone measures cadence on target.
3483    ///
3484    /// Runs ONCE, on the first spin carrying a non-zero timeout, because
3485    /// that timeout is where the tier's declared spin period becomes
3486    /// visible down here. A resolve-time diagnostic would be better
3487    /// still — the toolchain holds both numbers before the image is
3488    /// built — but this backstop needs no board or codegen change and
3489    /// catches hand-written spin loops too.
3490    /// phase-436 W1 (issue 1192) — how long the executor may park, in
3491    /// microseconds, given the caller's budget.
3492    ///
3493    /// The park used to be bounded by the caller's timeout narrowed by exactly
3494    /// one input, the BACKEND's `next_deadline_ms`. A registered timer is a
3495    /// deadline the EXECUTOR owns, and nothing let it shorten the sleep — so
3496    /// `spin_default`'s 50 ms slept straight past a 10 ms timer, and the
3497    /// activations then arrived in the burst issue 0505 describes.
3498    ///
3499    /// This is the `min` over the deadline sources phase-436 names. The
3500    /// caller's budget is always a member, so the result is never unbounded
3501    /// and never zero-by-omission: with no timers registered the budget
3502    /// stands, which keeps a long idle wait a wait rather than a poll.
3503    ///
3504    /// Distinct from issue 0515's audit, which warns that a period does not
3505    /// divide the spin grid. That takes the grid as given; this moves it.
3506    ///
3507    /// `#[cfg(test)]`-gated (matching `mod tests`'s own gate in `mod.rs`):
3508    /// production code calls `next_wake_bound_attributed_us` directly to get
3509    /// the source alongside the bound, so this `.0`-only convenience has no
3510    /// caller outside the test module and is dead code in any build that
3511    /// does not compile it (every `cargo check`/`cargo doc`, and `cargo test
3512    /// --features rmw-cffi`, which excludes `mod tests` too).
3513    #[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
3514    pub(crate) fn next_wake_bound_us(&self, budget_us: u64) -> u64 {
3515        self.next_wake_bound_attributed_us(budget_us, None).0
3516    }
3517
3518    /// phase-436 W6 — contribute a deadline source.
3519    ///
3520    /// The core never names an RTOS: a port registers `k_poll`, `epoll`, a
3521    /// queue set or a device ISR here, and the executor only ever asks "when
3522    /// will you next need me?".
3523    ///
3524    /// Refuses past capacity rather than dropping silently. A source that was
3525    /// quietly discarded would leave the executor sleeping past a deadline it
3526    /// had been told about, which is the failure this whole phase exists to
3527    /// remove.
3528    pub fn register_wake_source(
3529        &mut self,
3530        next: NextDeadlineFn,
3531        ctx: *mut core::ffi::c_void,
3532    ) -> Result<WakeSourceId, NodeError> {
3533        if self.wake_source_count >= MAX_WAKE_SOURCES {
3534            return Err(NodeError::ExecutorFull);
3535        }
3536        let idx = self.wake_source_count;
3537        self.wake_sources[idx] = Some(WakeSourceSlot { next, ctx });
3538        self.wake_source_count += 1;
3539        Ok(WakeSourceId::Platform(idx as u8))
3540    }
3541
3542    /// Install THE thing that blocks. Singular by nature — only one primitive
3543    /// can actually wait — so this replaces whatever was there.
3544    /// `granularity_us` is the finest park this primitive can actually
3545    /// express. The port states it because only the port knows: the ABI
3546    /// signature is not the limit — an RTOS tick is a second, coarser one, and
3547    /// a timespec primitive is a finer one.
3548    pub fn set_park_primitive(
3549        &mut self,
3550        park: ParkUntilFn,
3551        ctx: *mut core::ffi::c_void,
3552        granularity_us: u64,
3553    ) {
3554        self.park_primitive = Some((park, ctx));
3555        self.park_granularity_declared_us = granularity_us;
3556    }
3557
3558    /// The wake primitive a port must hand its `ParkUntilFn` as `ctx`, or NULL
3559    /// when this build has none.
3560    ///
3561    /// phase-436 W7 — a park that waits on its own fresh object is NOT
3562    /// breakable by the backend's listener, and installing one makes latency
3563    /// worse: once a port has parked, the transport drain drops to
3564    /// non-blocking, so an unbreakable park sleeps out its full deadline with
3565    /// data already waiting. Waiting on THIS object keeps the async break.
3566    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3567    pub fn wake_raw_ptr(&self) -> *mut core::ffi::c_void {
3568        match self.node_wake.as_ref() {
3569            Some(w) => w.raw_ptr(),
3570            None => core::ptr::null_mut(),
3571        }
3572    }
3573
3574    /// Whether a platform park primitive is installed.
3575    pub fn has_park_primitive(&self) -> bool {
3576        self.park_primitive.is_some()
3577    }
3578
3579    /// The bound and the source that produced it: the `min` over every
3580    /// deadline source, with the caller's budget as an ordinary member.
3581    ///
3582    /// The budget being a member is what makes the result never unbounded and
3583    /// never zero-by-omission, and it is why "no sources registered" needs no
3584    /// special case.
3585    pub(crate) fn next_wake_bound_attributed_us(
3586        &self,
3587        budget_us: u64,
3588        session_us: Option<u64>,
3589    ) -> (u64, WakeSourceId) {
3590        let mut best = (budget_us, WakeSourceId::CallerBudget);
3591
3592        // The backend's next internal event is a MEMBER, not a pre-cap. It was
3593        // folded into the budget upstream, which meant a park the session
3594        // actually won reported `CallerBudget` — the attribution named the
3595        // wrong source, and "why did we wake" is the question attribution
3596        // exists to answer.
3597        if let Some(s) = session_us
3598            && s < best.0
3599        {
3600            best = (s, WakeSourceId::Session);
3601        }
3602
3603        if let Some(timer_us) = self.next_timer_deadline_us()
3604            && timer_us < best.0
3605        {
3606            best = (timer_us, WakeSourceId::Timer);
3607        }
3608
3609        // phase-503 — a supervised route's deadline is the executor's own, like
3610        // a timer's: the park must end there, or a route whose output never
3611        // comes is judged only when something else wakes the loop.
3612        if let Some(due_us) = self.route_next_due_us()
3613            && due_us < best.0
3614        {
3615            best = (due_us, WakeSourceId::RouteDeadline);
3616        }
3617
3618        for (i, slot) in self.wake_sources.iter().enumerate() {
3619            let Some(slot) = slot else { continue };
3620            // SAFETY: the callback and ctx were supplied together by the port
3621            // through `register_wake_source` and outlive the executor by its
3622            // contract; the call takes no ownership.
3623            let due = unsafe { (slot.next)(slot.ctx) };
3624            // `u64::MAX` is "nothing pending" — an async source spells itself
3625            // this way and breaks the park by signalling instead.
3626            if due != u64::MAX && due < best.0 {
3627                best = (due, WakeSourceId::Platform(i as u8));
3628            }
3629        }
3630
3631        best
3632    }
3633
3634    /// phase-436 — the bound the last park used, and which source won it.
3635    pub fn last_park(&self) -> (u64, WakeSourceId) {
3636        (self.last_park_bound_us, self.last_park_source)
3637    }
3638
3639    /// phase-436 W2 — the park the platform was actually asked for, after
3640    /// rounding the request up to an expressible value.
3641    ///
3642    /// Differs from `last_park().0` exactly when the request did not land on
3643    /// the platform's grid. Both are reported because a cadence that cannot be
3644    /// waited in must not be presented as if it were met.
3645    pub fn last_park_achieved_us(&self) -> u64 {
3646        self.last_park_achieved_us
3647    }
3648
3649    /// phase-436 W3 (issue 1194) — the granularity the release-jitter
3650    /// measurement was actually made at, in microseconds.
3651    ///
3652    /// The jitter statistic is reported in microseconds, but the mechanism
3653    /// that PACES the loop decides how fine it can honestly be, and there are
3654    /// two mechanisms:
3655    ///
3656    /// * **No declared cadence** — the loop is paced by the blocking wait
3657    ///   itself, so it cannot come round more precisely than one park granule.
3658    ///   Reporting µs there is precision the wait cannot deliver.
3659    /// * **A declared cadence** (`set_spin_nominal_us`) — the caller paces
3660    ///   itself, as nros-cpp's tiers do with `platform_sleep_us` between
3661    ///   spins. That sleep is finer than the wait, so the measurement really
3662    ///   is good to a microsecond and coarsening it would understate it.
3663    ///
3664    /// Reported rather than applied: this does not change what the monitor
3665    /// judges, because silently coarsening a bound could mask real lateness.
3666    /// It says what the number is worth.
3667    pub fn release_jitter_granularity_us(&self) -> u64 {
3668        if self.spin_nominal_self_paced() {
3669            1
3670        } else {
3671            self.park_granularity_us()
3672        }
3673    }
3674
3675    /// The finest park this build can actually express, in microseconds.
3676    ///
3677    /// Comes from the INSTALLED primitive, because only the port knows. The
3678    /// exported ABI signature is not the limit in either direction (issue
3679    /// 1242):
3680    ///
3681    /// * an RTOS tick is a COARSER limit — ThreadX pins
3682    ///   `TX_TIMER_TICKS_PER_SECOND = 100`, a 10 ms tick, so a 1 ms park is not
3683    ///   achievable there however the ABI is spelled;
3684    /// * a timespec primitive is a FINER one — POSIX's `sem_timedwait` and
3685    ///   `pthread_cond_timedwait` are nanosecond-native, and only the
3686    ///   `uint32_t timeout_ms` signature floors them at a millisecond.
3687    ///
3688    /// This was a hardcoded `1_000` justified by "every `nros_platform_wake_*`
3689    /// port takes `uint32_t timeout_ms`". That was right for FreeRTOS by
3690    /// coincidence and wrong for the other two, and it made the W3 jitter
3691    /// report over-claim on ThreadX — the failure W3 exists to prevent,
3692    /// reproduced one layer below W3 in its own dependency.
3693    ///
3694    /// With nothing installed the fallback is 1 ms, which IS the honest floor
3695    /// of the `wake_wait_ms` ABI every port exports today.
3696    ///
3697    /// The declared granularity counts only while the park is USABLE. An
3698    /// executor whose backend has no live wake (XRCE, or a session whose
3699    /// callback is gone) never parks: `spin_once` waits in the transport's
3700    /// own receive instead, whose timeout is whole milliseconds. Claiming a
3701    /// POSIX park's 1 us there over-claimed the jitter report, and worse,
3702    /// rounded the bound to microseconds that the millisecond wait then
3703    /// truncated, so a sub-millisecond deadline became a non-blocking receive
3704    /// and the loop spun hot until it passed.
3705    pub fn park_granularity_us(&self) -> u64 {
3706        if self.park_granularity_declared_us != 0
3707            && self.park_primitive.is_some()
3708            && self.park_can_be_woken()
3709        {
3710            self.park_granularity_declared_us
3711        } else {
3712            1_000
3713        }
3714    }
3715
3716    /// phase-436 — whether a park could be ended early by data arriving.
3717    ///
3718    /// A park ends on its bound or on the wake object. With a poll-only
3719    /// backend (XRCE), or a session whose wake callback is gone, nothing
3720    /// signals that object, so a park would be a blind sleep and data
3721    /// arriving mid-park would wait out the whole bound — the Phase 127.C.4
3722    /// stall (a reliable stream whose ACK lands in a session nobody drives).
3723    /// Builds without `alloc` + `rmw-cffi` have no wake callback at all and
3724    /// park as they always did.
3725    pub(crate) fn park_can_be_woken(&self) -> bool {
3726        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3727        {
3728            self.async_wake_live()
3729        }
3730        #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
3731        {
3732            true
3733        }
3734    }
3735
3736    /// Drive the primary session for `timeout_ms`, the transport-side half of
3737    /// the wait.
3738    ///
3739    /// phase-436 (RFC-0106 D5) — on a poll-only backend this wait IS the
3740    /// executor's park, and it waits inside the backend's own `recv`, which a
3741    /// guard condition triggered from another thread cannot end: the trigger
3742    /// signals the wake object and nobody is waiting on it. Measured on XRCE
3743    /// before this: a trigger 20 ms into a 100 / 500 / 1000 ms spin was
3744    /// dispatched 79 / 480 / 979 ms later, i.e. it waited out the bound. So
3745    /// when such a waker exists and nothing can wake the park, the drive is
3746    /// cut into slices of [`POLL_ONLY_WAKE_SLICE_MS`] and stops at the first
3747    /// slice that ends with the wake flag set. The latency becomes one slice;
3748    /// an executor with no cross-thread waker keeps the single blocking drive.
3749    fn drive_primary(&mut self, timeout_ms: i32) -> Result<(), ()> {
3750        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3751        if self.has_cross_thread_waker
3752            && !self.async_wake_live()
3753            && timeout_ms > POLL_ONLY_WAKE_SLICE_MS
3754        {
3755            let mut remaining = timeout_ms;
3756            while remaining > 0 {
3757                let slice = remaining.min(POLL_ONLY_WAKE_SLICE_MS);
3758                self.session.drive_io(slice).map_err(|_| ())?;
3759                remaining -= slice;
3760                if self
3761                    .wake_flag
3762                    .swap(false, portable_atomic::Ordering::SeqCst)
3763                {
3764                    // Issue 1385 — consume the primitive too, or the next
3765                    // spin's wait returns at once on a signal already acted on.
3766                    if let Some(wake) = self.node_wake.as_ref() {
3767                        let _ = wake.wait_ms(0);
3768                    }
3769                    break;
3770                }
3771            }
3772            return Ok(());
3773        }
3774        self.session.drive_io(timeout_ms).map_err(|_| ())
3775    }
3776
3777    pub(crate) fn round_park_up_us(&self, requested_us: u64) -> u64 {
3778        if requested_us == 0 {
3779            return 0;
3780        }
3781        let g = self.park_granularity_us();
3782        if g <= 1 {
3783            return requested_us;
3784        }
3785        requested_us.div_ceil(g).saturating_mul(g)
3786    }
3787
3788    /// Microseconds until the soonest live timer is due, or `None` when no
3789    /// timer is pending.
3790    ///
3791    /// A timer that is cancelled, or a one-shot that already fired, owes
3792    /// nothing and must not hold the park short — otherwise the executor keeps
3793    /// waking on a deadline nobody is waiting for.
3794    ///
3795    /// So does a timer whose remainder is not WALL microseconds
3796    /// ([`TimerClockSource::remaining_is_wall_time`], issue 1321): the number
3797    /// this returns is handed to the port's park primitive, so a ROS-time
3798    /// timer's simulated remainder would be a units error — and on a paused
3799    /// `/clock` a frozen one, waking the image once per timer period forever.
3800    /// Its wake source is a `/clock` message, which reaches `drive_io` like
3801    /// any sample.
3802    pub(crate) fn next_timer_deadline_us(&self) -> Option<u64> {
3803        let arena_ptr = self.arena.as_ptr() as *const u8;
3804        let mut soonest: Option<u64> = None;
3805        for meta in self.entries.iter().flatten() {
3806            if !matches!(meta.kind, EntryKind::Timer) {
3807                continue;
3808            }
3809            // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`, whose
3810            // leading layout is `TimerHeader` — the same projection
3811            // `audit_spin_quantization` relies on.
3812            let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
3813            if header.cancelled || header.period_us == 0 {
3814                continue;
3815            }
3816            if header.oneshot && header.fired {
3817                continue;
3818            }
3819            // Issue 1321 — the park is WALL microseconds, so only a timer
3820            // whose own clock runs in them may bound it.
3821            if !header.clock_source.remaining_is_wall_time() {
3822                continue;
3823            }
3824            // Already due: the bound is zero, and no other timer can beat it.
3825            let remaining = header.period_us.saturating_sub(header.elapsed_us);
3826            soonest = Some(match soonest {
3827                Some(cur) => cur.min(remaining),
3828                None => remaining,
3829            });
3830            if remaining == 0 {
3831                break;
3832            }
3833        }
3834        soonest
3835    }
3836
3837    fn audit_spin_quantization(&mut self, spin_us: u64) {
3838        if spin_us == 0 {
3839            return;
3840        }
3841        let arena_ptr = self.arena.as_ptr() as *const u8;
3842        for i in 0..self.entries.len() {
3843            let Some(meta) = self.entries[i].as_ref() else {
3844                continue;
3845            };
3846            if !matches!(meta.kind, EntryKind::Timer) {
3847                continue;
3848            }
3849            // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
3850            // whose leading layout is `TimerHeader`.
3851            let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
3852            let period_us = header.period_us;
3853            let Some((early_us, late_us)) =
3854                spin_quantization_span(header.clock_source, period_us, spin_us)
3855            else {
3856                continue;
3857            };
3858            nros_log::log_warn!(
3859                nros_log::get_logger("nros"),
3860                "timer period {} us is not a multiple of the {} us spin period: activations will alternate between {} us and {} us (mean cadence preserved)",
3861                period_us,
3862                spin_us,
3863                early_us,
3864                late_us
3865            );
3866        }
3867    }
3868
3869    /// Issue #514 — whether the executor logs each violation as it is
3870    /// detected (the default).
3871    ///
3872    /// Turn this off in an application that reports violations its own
3873    /// way via [`Self::drain_violations`]; the ring is unaffected
3874    /// either way.
3875    pub fn set_report_violations(&mut self, enabled: bool) {
3876        self.violations.report = enabled;
3877    }
3878
3879    /// phase-474 I1 -- the drain-and-report hook: at the end of every spin,
3880    /// drain the ring and log each entry with its sequence number and the
3881    /// `total` / `dropped` counters (`contract violation #N: <rule> <fqn>
3882    /// measured=.. declared=.. (total=.. dropped=..)`).
3883    ///
3884    /// While on it REPLACES the log line at detection, so one verdict is one
3885    /// line, and an application that wanted to drain the ring itself sees an
3886    /// empty one. The generated C/C++ entries turn it on when the image sets
3887    /// `CONFIG_NROS_VIOLATION_DRAIN_REPORT` (off by default). The trace marker
3888    /// (`callback_trace::MARKER_VIOLATION`) and the SWD record do not depend
3889    /// on it: both are written at detection.
3890    pub fn set_violation_drain_report(&mut self, enabled: bool) {
3891        self.violations.drain_report = enabled;
3892    }
3893
3894    /// Issue #514 / phase-474 I1 -- violations evicted from the full ring
3895    /// before any drain took them.
3896    ///
3897    /// The ring keeps the LATEST `MAX_VIOLATIONS`, so non-zero means the
3898    /// OLDEST verdicts were lost, never the newest. (Before phase-474 a full
3899    /// ring refused the push, which kept the first verdicts since boot and
3900    /// lost every later one.)
3901    pub fn violations_dropped(&self) -> u32 {
3902        self.violations.counts.dropped
3903    }
3904
3905    /// phase-474 I1 -- contract violations this executor stored since boot
3906    /// (wrapping). The newest entry in the ring is number `violations_total()`;
3907    /// `total - dropped - pending` is how many drains have taken.
3908    pub fn violations_total(&self) -> u32 {
3909        self.violations.counts.total
3910    }
3911
3912    /// RFC-0052 W3b.4 -- drain pending contract violations, OLDEST first.
3913    /// Draining clears the ring. Call between spins, never from a callback.
3914    pub fn drain_violations(&mut self, mut f: impl FnMut(&super::monitor::Violation)) {
3915        self.violations.drain(|_, v| f(v));
3916    }
3917
3918    /// [`Self::drain_violations`] with each entry's sequence number (1-based,
3919    /// per executor; the same number the drain-and-report line and the trace
3920    /// marker carry), so a reader can tell a gap from a quiet interval.
3921    pub fn drain_violations_numbered(&mut self, f: impl FnMut(u32, &super::monitor::Violation)) {
3922        self.violations.drain(f);
3923    }
3924
3925    /// phase-474 I2 -- choose when this executor's contract monitors arm,
3926    /// overriding the build default (`NROS_MONITOR_ARM_ON_CALL`,
3927    /// `NROS_MONITOR_ARM_GRACE_MS`). Call before the first spin (an
3928    /// `arm_monitors` made before it is undone by this); after the first spin
3929    /// an executor that already armed stays armed.
3930    ///
3931    /// `on_call`: wait for [`Self::arm_monitors`] or
3932    /// [`super::monitor::request_monitor_arming`] (`rclc_monitors_arm()`).
3933    /// `grace_ms`: arm anyway that long after the first spin (0 = never by
3934    /// deadline; with `on_call == false`, 0 means "at the first spin").
3935    pub fn set_monitor_arming(&mut self, on_call: bool, grace_ms: u32) {
3936        let v = &mut self.violations.counts;
3937        v.arm_on_call = on_call;
3938        v.arm_grace_ms = grace_ms;
3939        if !v.spun {
3940            // Before the first spin, "armed" is just the policy's answer (no
3941            // rule has run yet); the first spin counts the arming once.
3942            v.armed = super::monitor::MonitorArming { on_call, grace_ms }.armed_at_first_spin();
3943        }
3944    }
3945
3946    /// phase-474 I2 -- the application says its start-up is over: this
3947    /// executor's monitors start judging now. Idempotent.
3948    ///
3949    /// Judgement restarts at arming: every rate/age/silence window reopens,
3950    /// a latency or age measured before arming is discarded, the release-
3951    /// jitter statistics are cleared (so the figure after RUN is RUN's), and a
3952    /// stack low-water mark already past the declared minimum is reported
3953    /// again, because stack depth reached at start-up is a real fault, not
3954    /// start-up noise.
3955    pub fn arm_monitors(&mut self) {
3956        self.arm_now("the application armed them");
3957    }
3958
3959    /// phase-474 I2 -- whether this executor's monitors are armed.
3960    pub fn monitors_armed(&self) -> bool {
3961        self.violations.counts.armed
3962    }
3963
3964    /// phase-474 I2 -- verdicts reached before the monitors armed: counted,
3965    /// never stored, logged, sunk or traced.
3966    pub fn violations_suppressed_before_arm(&self) -> u32 {
3967        self.violations.counts.suppressed_before_arm
3968    }
3969
3970    fn arm_now(&mut self, why: &str) {
3971        if self.violations.counts.armed {
3972            return;
3973        }
3974        self.violations.counts.armed = true;
3975        for st in self.monitor_states.iter_mut() {
3976            *st = super::monitor::MonitorState::default();
3977        }
3978        for st in self.age_states.iter_mut() {
3979            *st = super::monitor::AgeState::default();
3980        }
3981        for spec in self.monitor_table.iter() {
3982            spec.cell
3983                .max_latency_us
3984                .store(0, core::sync::atomic::Ordering::Relaxed);
3985        }
3986        // phase10-B3 -- a take waiting since start-up is not a route of RUN's.
3987        super::monitor::route_reset(self.monitor_table);
3988        for spec in self.age_table.iter() {
3989            spec.cell
3990                .max_age_ms
3991                .store(0, core::sync::atomic::Ordering::Relaxed);
3992            spec.cell
3993                .takes
3994                .store(0, core::sync::atomic::Ordering::Relaxed);
3995        }
3996        self.clear_release_jitter_stats();
3997        self.stack_headroom_reported = usize::MAX;
3998        self.stack_headroom_last_check_us = None;
3999        // Counted once per executor: here once it has spun, else by the first
4000        // spin (which counts an executor that is armed by then either way).
4001        #[cfg(nros_boot_report)]
4002        if self.violations.counts.spun {
4003            super::monitor::NROS_VIOLATION_RECORD
4004                .armed
4005                .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
4006        }
4007        nros_log::log_info!(
4008            nros_log::get_logger("nros"),
4009            "contract monitors armed ({}); {} verdict(s) before arming suppressed",
4010            why,
4011            self.violations.counts.suppressed_before_arm
4012        );
4013    }
4014
4015    /// phase-474 I2 -- once per spin, before any rule runs: note the first
4016    /// spin, and arm when the policy says so.
4017    fn update_monitor_arming(&mut self, now_us: Option<u64>) {
4018        // Milliseconds in a wrapping `u32`, like the silence rule's lease
4019        // (`AgeState::last_take_ms`): this state is inline in the `Executor`
4020        // value, whose size is budgeted (issue 0961).
4021        let now_ms = now_us.map(|us| (us / 1_000) as u32);
4022        let v = &mut self.violations.counts;
4023        if !v.spun {
4024            v.spun = true;
4025            v.first_spin_ms = now_ms.unwrap_or(0);
4026            if v.armed {
4027                // Armed from the start (the default), or by a call made before
4028                // the first spin: count it in the record so a reader sees how
4029                // many executors are judging.
4030                #[cfg(nros_boot_report)]
4031                super::monitor::NROS_VIOLATION_RECORD
4032                    .armed
4033                    .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
4034                return;
4035            }
4036            if (super::monitor::MonitorArming {
4037                on_call: v.arm_on_call,
4038                grace_ms: v.arm_grace_ms,
4039            })
4040            .armed_at_first_spin()
4041            {
4042                self.arm_now("at the first spin");
4043                return;
4044            }
4045        }
4046        if v.armed {
4047            return;
4048        }
4049        if super::monitor::arm_requests() != v.arm_requests_seen {
4050            self.arm_now("the application asked: rclc_monitors_arm");
4051            return;
4052        }
4053        let grace_ms = v.arm_grace_ms;
4054        if grace_ms == 0 {
4055            return;
4056        }
4057        match now_ms {
4058            Some(now) if now.wrapping_sub(v.first_spin_ms) >= grace_ms => {
4059                self.arm_now("the grace period after the first spin elapsed");
4060            }
4061            // No clock: a grace period cannot elapse, and waiting forever would
4062            // leave the image unmonitored. Arm rather than guess a duration.
4063            None if !v.arm_on_call => {
4064                self.arm_now("no clock to time the grace period");
4065            }
4066            _ => {}
4067        }
4068    }
4069
4070    /// Issue 1635 -- hand every stored violation to `sink`, beside the log line
4071    /// (issue 0514's floor) and the ring push.
4072    ///
4073    /// phase-474 I7 -- not at detection any more: the verdict is queued and
4074    /// leaves from `Executor::flush_violation_reports` at the end of a spin with
4075    /// slack, so the report never costs the dispatch it judges.
4076    ///
4077    /// Before this nothing in a generated image drained the ring, so the log
4078    /// line was the only output and `/diagnostics` stayed silent on every C
4079    /// and C++ road. A report point the RUNTIME owns reaches every road at
4080    /// once — every board's spin loop goes through the executor — where one
4081    /// per entry template would have to be rebuilt for each board runner. The
4082    /// ring is still filled, so an application (or a fixture) that drains it
4083    /// with [`Self::drain_violations`] sees exactly what it did before.
4084    /// `None` removes the sink.
4085    ///
4086    /// # Safety
4087    /// `ctx` must stay valid for every call of the sink until it is replaced
4088    /// or this executor is dropped (see [`super::monitor::ViolationSink`]).
4089    pub unsafe fn set_violation_sink(
4090        &mut self,
4091        sink: Option<(super::monitor::ViolationSink, *mut core::ffi::c_void)>,
4092    ) {
4093        self.violations.sink = sink.map(|(f, ctx)| (f, ctx as usize));
4094        if self.violations.sink.is_none() {
4095            // Nothing is left to send them to.
4096            self.violations.reports.pending.clear();
4097            self.violations.reports.since_us = None;
4098        }
4099    }
4100
4101    /// phase-474 I7 -- hand the queued verdicts to the sink, if this is a slot
4102    /// for it. Called at the end of every spin, after its dispatches and its
4103    /// rules, so the report never runs inside the tick being judged.
4104    ///
4105    /// A slot is a spin end whose next timer is due no sooner than the last
4106    /// report took (`ReportQueue::last_cost_us`; 0 before the first report, so
4107    /// the first goes at the first spin end with no timer already due), or
4108    /// any spin end once the oldest pending verdict has waited
4109    /// [`REPORT_DEFER_MAX_US`](super::monitor::REPORT_DEFER_MAX_US). Without a
4110    /// clock there is nothing to measure slack with, so every spin end is a
4111    /// slot. The sink takes as many as it coalesces into one message; the rest
4112    /// wait for the next slot.
4113    ///
4114    /// The time the sink takes is the reporter's own and is stated by
4115    /// [`Self::violation_report_cost_us`]; the release-jitter rule does not
4116    /// charge it to the spin's cadence (it is not a late release of any
4117    /// application work), which is what used to turn one verdict into a
4118    /// `release-jitter-runtime` verdict of its own on the safety island.
4119    pub(crate) fn flush_violation_reports(&mut self) {
4120        let Some((sink, ctx)) = self.violations.sink else {
4121            return;
4122        };
4123        if self.violations.reports.pending.is_empty() {
4124            return;
4125        }
4126        let now = self.now_us();
4127        if let Some(now) = now {
4128            let since = *self.violations.reports.since_us.get_or_insert(now);
4129            let overdue = now.saturating_sub(since) >= super::monitor::REPORT_DEFER_MAX_US;
4130            let cost = self.violations.reports.last_cost_us as u64;
4131            let slack = match self.next_timer_deadline_us() {
4132                None => true,
4133                Some(remaining) => remaining > 0 && remaining >= cost,
4134            };
4135            if !slack && !overdue {
4136                return;
4137            }
4138        }
4139        // SAFETY: `Executor::set_violation_sink`'s contract -- `ctx` is valid
4140        // for every call until the sink is replaced or the executor dropped.
4141        let taken = unsafe {
4142            sink(
4143                ctx as *mut core::ffi::c_void,
4144                &self.violations.reports.pending[..],
4145            )
4146        };
4147        let q = &mut self.violations.reports;
4148        if taken == 0 {
4149            // The sink counted its failure; drop the oldest so a report that
4150            // can never be sent does not stall the queue. It stays in the ring
4151            // and the record.
4152            q.pending.remove(0);
4153            q.failed = q.failed.saturating_add(1);
4154        } else {
4155            let n = taken.min(q.pending.len());
4156            for _ in 0..n {
4157                q.pending.remove(0);
4158            }
4159        }
4160        q.since_us = None;
4161        if let (Some(t0), Some(t1)) = (now, self.now_us()) {
4162            let q = &mut self.violations.reports;
4163            let cost = t1.saturating_sub(t0);
4164            q.last_cost_us = cost.min(u32::MAX as u64) as u32;
4165            q.max_cost_us = q.max_cost_us.max(q.last_cost_us);
4166            q.debt_us = q.debt_us.saturating_add(cost);
4167        }
4168    }
4169
4170    /// phase-474 I7 -- what handing verdicts to the violation sink has cost
4171    /// this executor: `(last_us, max_us, pending, overflowed)`. `overflowed`
4172    /// counts verdicts evicted from the report queue by newer ones before a
4173    /// slot came (they are still in the ring and the SWD record).
4174    pub fn violation_report_cost_us(&self) -> (u32, u32, usize, u32) {
4175        let q = &self.violations.reports;
4176        (q.last_cost_us, q.max_cost_us, q.pending.len(), q.overflowed)
4177    }
4178
4179    /// THE monotonic-µs read. phase-359 W4 — every consumer goes through here.
4180    ///
4181    /// Before this there were FIVE spellings of "what time is it": this one,
4182    /// `last_spin_end: Instant`, and two ad-hoc `static EPOCH: OnceLock<Instant>`
4183    /// blocks — plus `PlatformClock`, which nothing called because `Executor` is
4184    /// deliberately non-generic and the trait's methods are associated fns.
4185    ///
4186    /// `None` means no clock is available (no_std with no injected hook).
4187    /// Callers must degrade, not guess: a missing clock is why the sporadic
4188    /// refill and the major-frame phase behaved differently on no_std.
4189    fn now_us(&mut self) -> Option<u64> {
4190        self.clock_us_fn.map(|clock| clock())
4191    }
4192
4193    /// Run the rate/latency/age checks over the baked tables (single
4194    /// branch each when empty).
4195    fn run_contract_monitors(&mut self) {
4196        if !self.monitor_table.is_empty()
4197            && let Some(now_us) = self.now_us()
4198        {
4199            {
4200                for (i, spec) in self
4201                    .monitor_table
4202                    .iter()
4203                    .take(super::monitor::MAX_MONITORS)
4204                    .enumerate()
4205                {
4206                    if let Some(v) =
4207                        super::monitor::check_rate(spec, &mut self.monitor_states[i], now_us)
4208                    {
4209                        self.violations.record(v);
4210                    }
4211                    if let Some(v) =
4212                        super::monitor::check_latency(spec, &mut self.monitor_states[i])
4213                    {
4214                        self.violations.record(v);
4215                    }
4216                }
4217            }
4218        }
4219        if !self.age_table.is_empty() {
4220            // phase-462 W2 -- the age loop needs the clock now: `silence-runtime`
4221            // judges how long an endpoint has taken NOTHING, and `None` (a
4222            // build with no clock) makes that rule abstain rather than guess.
4223            // Read before the loop because the borrow of `age_states` below
4224            // outlives the `&mut self` the reader wants.
4225            let now_us = self.now_us();
4226            for (i, spec) in self
4227                .age_table
4228                .iter()
4229                .take(super::monitor::MAX_AGE_MONITORS)
4230                .enumerate()
4231            {
4232                if let Some(v) = super::monitor::check_age(spec, &mut self.age_states[i], now_us) {
4233                    self.violations.record(v);
4234                }
4235            }
4236        }
4237        // phase-462 W3b -- the hazard observer reads the verdict STATE the rows
4238        // above just updated, so it runs after them and on the same tick.
4239        if !self.hazard_table.is_empty()
4240            && let Some(now_us) = self.now_us()
4241        {
4242            let now_ms = (now_us / 1_000) as u32;
4243            let violations = &mut self.violations;
4244            for spec in self.hazard_table {
4245                super::monitor::check_hazard(
4246                    spec,
4247                    self.monitor_table,
4248                    &self.monitor_states,
4249                    &self.age_states,
4250                    now_ms,
4251                    |v| violations.record(v),
4252                );
4253            }
4254        }
4255        self.check_route_deadlines();
4256    }
4257
4258    /// phase-503 — `route-deadline-runtime`: every pending supervised route
4259    /// past `take + route_deadline_ms` is reported (once until a route is
4260    /// within budget again) and its reaction requested through the hook.
4261    /// Runs right after the park, whose bound includes the earliest due time
4262    /// ([`WakeSourceId::RouteDeadline`]), so a route whose output never comes
4263    /// is judged within one park granule of its deadline. A callback that does
4264    /// not return holds this check too: that is `MECHANISM_TIMER`'s case.
4265    fn check_route_deadlines(&mut self) {
4266        let table = self.monitor_table;
4267        if table.is_empty() || !super::monitor::route_deadline_active(table) {
4268            return;
4269        }
4270        let Some(now_us) = self.now_us() else {
4271            return;
4272        };
4273        // Start-up is not RUN (phase-474 I2): before arming a verdict is only
4274        // counted, and no reaction is requested for it either.
4275        let hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
4276        let violations = &mut self.violations;
4277        super::monitor::route_overdue(table, now_us, |row, v, first| {
4278            call_route_reaction(hook, table, row, &v);
4279            if first {
4280                violations.record(v);
4281            }
4282        });
4283    }
4284
4285    /// Issue #505 — report activations dropped by
4286    /// [`TimerOverrunPolicy::Skip`](super::arena::TimerOverrunPolicy)
4287    /// since the last check.
4288    ///
4289    /// Unlike the rate/age/latency rules this needs no baked spec table:
4290    /// every periodic timer counts its own overruns, and a dropped
4291    /// activation is a contract failure for any declared period. It runs
4292    /// on the same tick so violations land in the same ring the entry
4293    /// glue drains.
4294    /// Declare the minimum stack headroom this executor's thread must keep,
4295    /// in bytes. `0` (the default) disables the `stack-headroom-runtime`
4296    /// rule.
4297    ///
4298    /// Set by the entry that spawned the thread, because it is the only
4299    /// party that knows what stack it handed over: the executor never sees
4300    /// `stack_bytes`, and no portable query returns a task's total stack, so
4301    /// neither an absolute floor nor a percentage can be inferred here.
4302    /// Declare how often this executor's loop intends to come round, in
4303    /// microseconds. `0` (the default) falls back to the `spin_once` timeout.
4304    ///
4305    /// Needed because a driver's pacing and its blocking bound are separate
4306    /// numbers. nros-cpp's tier loops sleep `spin_period_us` between spins but
4307    /// pass a fixed 10 ms timeout, so the release-jitter rule judged every
4308    /// tier against 10 ms regardless of what the contract declared -- too lax
4309    /// for a 1 ms tier and far too strict for a 30 ms one.
4310    ///
4311    /// This also says the CALLER paces the loop (a sleep finer than the
4312    /// park), so the measurement is reported at 1 us. A loop paced by the
4313    /// `spin_once` wait itself declares with
4314    /// [`Self::set_spin_nominal_wait_paced_us`] instead.
4315    pub fn set_spin_nominal_us(&mut self, us: u64) {
4316        self.spin_nominal_declared_us = us;
4317        self.spin_nominal_wait_paced = false;
4318    }
4319
4320    /// phase-436 A2b -- declare the loop's cadence, in microseconds, for a
4321    /// loop paced by the `spin_once` wait itself. `0` withdraws it.
4322    ///
4323    /// [`Self::set_spin_nominal_us`] couples two facts: the period to judge
4324    /// against, and that the caller paces the loop with its own sleep. A loop
4325    /// like `for (;;) spin_once(period_ms)` has the first without the second.
4326    /// Declaring through the coupled setter would report its jitter at 1 us
4327    /// when the park it waits on is tick-granular (issue 1194's failure); not
4328    /// declaring at all makes the timeout the period, which is wrong whenever
4329    /// the timeout is not the period (a cadence that is not whole
4330    /// milliseconds, or one raised to a floor).
4331    ///
4332    /// Here the cadence is the period of the release grid that a
4333    /// `spin_once(timeout)` loop already runs on, and the timeout stays a
4334    /// bound on each wait. The jitter is judged at grid releases and reported
4335    /// at the park granularity.
4336    pub fn set_spin_nominal_wait_paced_us(&mut self, us: u64) {
4337        self.spin_nominal_declared_us = us;
4338        self.spin_nominal_wait_paced = us != 0;
4339    }
4340
4341    pub fn set_min_stack_headroom_bytes(&mut self, bytes: usize) {
4342        self.min_stack_headroom_bytes = bytes;
4343        // phase-436 E4 — a new bound is checked on the next spin, not up to an
4344        // interval later.
4345        self.stack_headroom_last_check_us = None;
4346        // phase-436 E3 — `check_stack_headroom_rule` treats a 0 from the port as
4347        // "not instrumented" and skips, which is right for the rule and wrong
4348        // for the reader: an armed rule that can never judge reports nothing,
4349        // exactly like one that judged and found the stack healthy. Ask the
4350        // port once, here. Entries set the bound at boot on the thread it
4351        // describes, so the answer is about the right stack and the warning
4352        // lands once per tier, next to the tier's other boot lines.
4353        // phase-436 E5 -- "does the port answer at all?" needs one byte, not
4354        // the whole unused region the full query would scan.
4355        self.stack_headroom_blind =
4356            bytes != 0 && nros_platform_api::stack_unused_bytes_upto(1) == 0;
4357        if self.stack_headroom_blind {
4358            nros_log::log_warn!(
4359                nros_log::get_logger("nros"),
4360                "stack-headroom-runtime: a {} B minimum is set but this port reports no stack \
4361                 data, so the rule will never check (Zephyr: enable CONFIG_INIT_STACKS)",
4362                bytes
4363            );
4364        }
4365    }
4366
4367    /// phase-436 E3 — whether `stack-headroom-runtime` is armed on a port that
4368    /// cannot measure, and so will never report a violation.
4369    ///
4370    /// `true` means the rule's silence is not evidence of headroom. Zephyr
4371    /// answers only with `CONFIG_INIT_STACKS && CONFIG_THREAD_STACK_INFO`;
4372    /// POSIX never does.
4373    pub fn stack_headroom_blind(&self) -> bool {
4374        self.stack_headroom_blind
4375    }
4376
4377    /// Report a spin thread that has come closer to the end of its stack
4378    /// than `set_min_stack_headroom_bytes` allows.
4379    ///
4380    /// Runs on the same tick as the other rules and feeds the same ring.
4381    /// Queries the port at most once per `STACK_HEADROOM_CHECK_INTERVAL_US`
4382    /// (phase-436 E4): on a painted Zephyr stack the full query scans every
4383    /// unused byte, so an every-spin query put that scan inside the control
4384    /// loop. Since E5 the query is capped at the declared minimum, so a scan
4385    /// reads at most that many bytes.
4386    /// Nothing at all when no minimum was declared.
4387    fn check_stack_headroom_rule(&mut self) {
4388        if self.min_stack_headroom_bytes == 0 {
4389            return;
4390        }
4391        self.stack_headroom_spins_since_check =
4392            self.stack_headroom_spins_since_check.saturating_add(1);
4393        let now_us = self.now_us();
4394        if !super::monitor::stack_headroom_check_due(
4395            now_us,
4396            self.stack_headroom_last_check_us,
4397            self.stack_headroom_spins_since_check,
4398        ) {
4399            return;
4400        }
4401        self.stack_headroom_last_check_us = Some(now_us.unwrap_or(0));
4402        self.stack_headroom_spins_since_check = 0;
4403        self.stack_headroom_checks = self.stack_headroom_checks.saturating_add(1);
4404        // phase-436 E5 -- the CAPPED query. The rule only asks whether
4405        // `min_stack_headroom_bytes` are unused, and a port that scans for
4406        // paint answers that by reading those bytes alone. Below the cap the
4407        // answer is the exact headroom, which is what a breach reports, so
4408        // no second, full scan is needed. E4's throttle still applies.
4409        let unused = nros_platform_api::stack_unused_bytes_upto(self.min_stack_headroom_bytes);
4410        // 0 means the port does not instrument stacks, not that the stack is
4411        // full. Reporting a violation there would be a fault invented from an
4412        // absence of data.
4413        if unused == 0 {
4414            return;
4415        }
4416        if let Some(v) = super::monitor::check_stack_headroom(
4417            unused,
4418            self.min_stack_headroom_bytes,
4419            &mut self.stack_headroom_reported,
4420        ) {
4421            self.violations.record(v);
4422        }
4423    }
4424
4425    /// AUTOSAR-style ALIVE supervision: report a SchedContext that produced
4426    /// no dispatch at all over a full window while declaring a period.
4427    ///
4428    /// Every other rule here fires when something happens and is wrong. This
4429    /// one fires when nothing happens, which is the failure a watchdog
4430    /// exists for and the one `rate-hierarchy-runtime` cannot see: that rule
4431    /// counts publishes on an endpoint, so a callback that stops firing while
4432    /// its topic is published from elsewhere leaves it satisfied.
4433    fn check_alive_supervision(&mut self) {
4434        let Some(now_us) = self.now_us() else {
4435            // No clock, no windows. Silence here is honest: the rule cannot
4436            // tell a stalled context from a fast one without time.
4437            return;
4438        };
4439        let n = self.alive_slots.len().min(self.sched_contexts.len());
4440        for i in 0..n {
4441            let period_us = match self.sched_contexts[i].as_ref() {
4442                Some(sc) => sc.period_us.get().map(|nz| nz.get() as u64).unwrap_or(0),
4443                None => continue,
4444            };
4445            let slot = &mut self.alive_slots[i];
4446            if let Some(v) =
4447                super::monitor::check_alive(period_us, slot.dispatches, &mut slot.state, now_us)
4448            {
4449                self.violations.record(v);
4450            }
4451        }
4452    }
4453
4454    /// Issue #515 — report a spin wake a whole period late.
4455    ///
4456    /// Runs on the same tick as `check_timer_overruns` and feeds the same
4457    /// ring, so the entry glue drains it with the other rules and no caller
4458    /// needs to know this one exists. Like that rule it needs no spec table:
4459    /// the bound is the spin period the caller already passes in.
4460    fn check_release_jitter_rule(&mut self) {
4461        let (max_us, _late, _total) = self.release_jitter();
4462        let period_us = self.spin_nominal_us;
4463        if let Some(v) =
4464            super::monitor::check_release_jitter(max_us, &mut self.jitter_reported_us, period_us)
4465        {
4466            self.violations.record(v);
4467        }
4468    }
4469
4470    fn check_timer_overruns(&mut self) {
4471        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4472        for i in 0..self.entries.len() {
4473            let Some(meta) = self.entries[i].as_ref() else {
4474                continue;
4475            };
4476            if !matches!(meta.kind, EntryKind::Timer) {
4477                continue;
4478            }
4479            // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
4480            // which shares its leading layout with `TimerHeader`. The
4481            // baseline lives in the header too, so this needs no state
4482            // parallel to `entries` (whose capacity is a runtime slice
4483            // length, not a const).
4484            let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
4485            if let Some(v) = super::monitor::check_timer_overrun(
4486                header.overruns,
4487                &mut header.overruns_reported,
4488                0,
4489            ) {
4490                self.violations.record(v);
4491            }
4492        }
4493    }
4494
4495    /// RFC-0052 / phase-296 W3a — replace the DEFAULT scheduling context
4496    /// (slot 0, the SC every unbound callback dispatches through).
4497    ///
4498    /// The run_tiers model runs one Executor per tier, so a tier-wide
4499    /// scheduling policy (`[tiers.<t>] class/budget_us/period_us` and the
4500    /// TT window) is exactly "this executor's default SC". Boards call
4501    /// this once, before entity creation; explicit per-handle/per-group
4502    /// bindings still take precedence (they never resolve to slot 0).
4503    ///
4504    /// Sporadic-class SCs get the same sibling `SporadicState` the
4505    /// `create_sched_context` path builds, so budget refill/exhaustion
4506    /// applies to the default queue too.
4507    pub fn set_default_sched_context(&mut self, sc: super::sched_context::SchedContext) {
4508        self.sched_contexts[0] = Some(sc);
4509        if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
4510            let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
4511            let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
4512            self.sporadic_states[0] =
4513                Some(super::sched_context::SporadicState::new(budget, period));
4514        } else {
4515            self.sporadic_states[0] = None;
4516        }
4517    }
4518
4519    /// Bind a registered callback to a scheduling context. The next
4520    /// `spin_once` cycle dispatches the callback through that SC's
4521    /// queue (FIFO bitmap or EDF heap). Phase 110.B.
4522    pub fn bind_handle_to_sched_context(
4523        &mut self,
4524        handle: HandleId,
4525        sc_id: super::sched_context::SchedContextId,
4526    ) -> Result<(), NodeError> {
4527        let i = self.live_slot(handle);
4528        if i >= self.entries.len() {
4529            return Err(NodeError::InvalidSchedContextBinding);
4530        }
4531        if self.entries[i].is_none() {
4532            return Err(NodeError::InvalidSchedContextBinding);
4533        }
4534        let sc_idx = sc_id.0 as usize;
4535        if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
4536            return Err(NodeError::InvalidSchedContextBinding);
4537        }
4538        self.sched_context_bindings[i] = sc_id;
4539        Ok(())
4540    }
4541
4542    /// Phase 110.F — opt in to per-callback OS-priority dispatch.
4543    /// Once registered, every `spin_once` cycle routes ready entries
4544    /// whose bound SC has `os_pri > 0` onto a worker thread the OS
4545    /// scheduler has elevated to that numeric priority. Workers are
4546    /// spawned lazily on first use and self-halt when the Executor
4547    /// drops.
4548    ///
4549    /// `apply_policy` is the same `fn(SchedPolicy) -> Result<(),
4550    /// SchedError>` shape `open_threaded` takes — keeps the
4551    /// Executor non-generic over Platform.
4552    ///
4553    /// Calling this with `apply_policy = noop` is fine for testing
4554    /// (workers spawn but don't actually elevate priority); real
4555    /// hard-RT use needs `CAP_SYS_NICE` on Linux or the equivalent
4556    /// kernel config on RTOSes.
4557    #[cfg(all(feature = "alloc", feature = "scheduler-os-priority"))]
4558    pub fn register_os_priority_dispatcher(
4559        &mut self,
4560        apply_policy: fn(
4561            nros_platform_api::SchedPolicy,
4562        ) -> Result<(), nros_platform_api::SchedError>,
4563    ) {
4564        self.os_priority_apply_policy = Some(apply_policy);
4565    }
4566
4567    /// Phase 110.G — enable time-triggered dispatch by setting the
4568    /// executor's major-frame length. Once set, every `spin_once`
4569    /// cycle gates dispatch through each entry's bound SC's
4570    /// `tt_window_offset_us` / `tt_window_duration_us` fields:
4571    /// dispatch only fires when the current monotonic time falls
4572    /// inside the window `[off, off + duration) mod major_frame`.
4573    ///
4574    /// `major_frame_us = 0` disables the TT gate (default state).
4575    /// Setting a non-zero major frame after callbacks are already
4576    /// registered is allowed — TT gates take effect on the next
4577    /// `spin_once` cycle.
4578    pub fn register_time_triggered_dispatcher(&mut self, major_frame_us: u32) {
4579        self.major_frame_us = major_frame_us;
4580    }
4581
4582    /// Phase 110.G — apply a declarative cyclic schedule.
4583    ///
4584    /// One-shot helper that wraps the underlying primitives:
4585    /// validates the schedule (`major_frame > 0`, no overlapping
4586    /// windows, every window fits inside the major frame), sets the
4587    /// executor's major-frame length, then materialises one
4588    /// `SchedContext` per window with `class = TimeTriggered` +
4589    /// the window's offset / duration. Returns the per-window
4590    /// [`SchedContextId`](crate::executor::sched_context::SchedContextId) array so callers can immediately
4591    /// `bind_handle_to_sched_context(handle, sc_id)` for their
4592    /// subscription / timer handles.
4593    ///
4594    /// `N` is the schedule's *declared* maximum window count;
4595    /// `schedule.window_count` gates how many SCs are actually
4596    /// created. Unused trailing slots return
4597    /// `SchedContextId::default()` (sentinel — callers must respect
4598    /// `window_count`).
4599    pub fn apply_time_triggered_schedule<const N: usize>(
4600        &mut self,
4601        schedule: &super::sched_context::TimeTriggeredSchedule<N>,
4602    ) -> Result<
4603        [super::sched_context::SchedContextId; N],
4604        super::sched_context::TimeTriggeredScheduleError,
4605    > {
4606        schedule.validate()?;
4607        self.major_frame_us = schedule.major_frame_us;
4608        // SC slot 0 is the auto-created default; reusing it as a
4609        // sentinel for unused trailing slots is safe because the
4610        // caller respects `schedule.window_count`.
4611        let mut ids: [super::sched_context::SchedContextId; N] =
4612            [super::sched_context::SchedContextId(0); N];
4613        for (i, window) in schedule.windows[..schedule.window_count].iter().enumerate() {
4614            // TT is not a scheduling class (phase-482 W6 deleted the
4615            // `SchedClass::TimeTriggered` variant): it is a per-SC *window gate* on top of
4616            // the existing class-based dispatch (Fifo here keeps
4617            // the EDF / Sporadic budgets out of the picture for
4618            // pure cyclic schedules). The window-gate fields set
4619            // below are what `spin_once`'s 110.G runtime gate
4620            // actually reads.
4621            let sc = super::sched_context::SchedContext {
4622                tt_window_offset_us: super::sched_context::OptUs::from_us(window.offset_us),
4623                tt_window_duration_us: super::sched_context::OptUs::from_us(window.duration_us),
4624                ..super::sched_context::SchedContext::new_fifo()
4625            };
4626            ids[i] = self.create_sched_context(sc).map_err(|_| {
4627                super::sched_context::TimeTriggeredScheduleError::WindowCountOverflow
4628            })?;
4629        }
4630        Ok(ids)
4631    }
4632
4633    /// Phase 110.E.b — register an ISR-driven refill timer for an
4634    /// already-created Sporadic SC. The caller invokes their
4635    /// platform's `PlatformTimer::create_periodic` with the returned
4636    /// `Arc<AtomicSporadicState>` as `user_data` and the
4637    /// `atomic_sporadic_refill_thunk` as the callback, then hands
4638    /// the resulting platform handle to this method via
4639    /// `OpaqueTimerHandle::new(handle, destroy_fn)`.
4640    ///
4641    /// The Executor stores both the Arc and the handle so Drop can
4642    /// clean them up. Calling this on a non-Sporadic SC returns
4643    /// `Err(InvalidSchedContextBinding)`.
4644    #[cfg(feature = "alloc")]
4645    pub fn register_sporadic_timer(
4646        &mut self,
4647        sc_id: super::sched_context::SchedContextId,
4648        timer: OpaqueTimerHandle,
4649    ) -> Result<portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>, NodeError>
4650    {
4651        let i = sc_id.0 as usize;
4652        if i >= self.sched_contexts.len() {
4653            return Err(NodeError::InvalidSchedContextBinding);
4654        }
4655        let sc = self.sched_contexts[i]
4656            .as_ref()
4657            .ok_or(NodeError::InvalidSchedContextBinding)?;
4658        if !matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
4659            return Err(NodeError::InvalidSchedContextBinding);
4660        }
4661        let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
4662        let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
4663        let state = portable_atomic_util::Arc::new(super::sched_context::AtomicSporadicState::new(
4664            budget, period,
4665        ));
4666        self.sporadic_atomic_states[i] = Some((portable_atomic_util::Arc::clone(&state), timer));
4667        Ok(state)
4668    }
4669
4670    /// Inspect a registered scheduling context. Phase 110.B.
4671    pub fn sched_context(
4672        &self,
4673        sc_id: super::sched_context::SchedContextId,
4674    ) -> Option<&super::sched_context::SchedContext> {
4675        self.sched_contexts.get(sc_id.0 as usize)?.as_ref()
4676    }
4677
4678    /// Phase 104.C.2 — start a rclcpp-style Node builder for this
4679    /// Executor. The returned [`NodeBuilder`](super::node_record::NodeBuilder)
4680    /// is chainable:
4681    ///
4682    /// ```ignore
4683    /// let id = exec.node_builder("ingress")
4684    ///     .rmw("zenoh")
4685    ///     .locator("tcp/127.0.0.1:7447")
4686    ///     .sched(my_sc_id)
4687    ///     .build()?;
4688    /// ```
4689    ///
4690    /// In Phase 104.C.2 the Node table is storage-only — all
4691    /// registered Nodes share the Executor's primary session. Per-
4692    /// Node session binding (the bridge feature) lands in Phase
4693    /// 104.C.3 when the session cache is wired.
4694    pub fn node_builder<'a, 'cfg>(
4695        &'a mut self,
4696        name: &'cfg str,
4697    ) -> super::node_record::NodeBuilder<'a, 'cfg, 's> {
4698        super::node_record::NodeBuilder {
4699            executor: self,
4700            name,
4701            namespace: None,
4702            rmw_name: None,
4703            locator: None,
4704            domain_id: None,
4705            sched: None,
4706            session_idx: None,
4707        }
4708    }
4709
4710    /// Return the Node table — Phase 104.C.2 read accessor.
4711    pub fn nodes(&self) -> &[super::node_record::NodeRecord] {
4712        &self.nodes
4713    }
4714
4715    /// Borrow a Node's metadata by id, returning `None` if the id
4716    /// is out of range.
4717    pub fn node(&self, id: super::node_record::NodeId) -> Option<&super::node_record::NodeRecord> {
4718        self.nodes.get(id.index())
4719    }
4720
4721    /// Phase 189.M1 — an executor-borrowing node handle for the entity builders
4722    /// (`exec.node_mut(id).subscription(t)...` / `.create_subscription(...)`).
4723    /// A short-lived `&mut Executor` borrow — use one at a time; entity handles
4724    /// are owned and outlive it (see `Node`).
4725    /// phase-483 W3 — the component registration frame, for the `nros`
4726    /// facade's declarative constructors. Not a user API.
4727    #[doc(hidden)]
4728    pub fn __component_frame(&self) -> Option<core::ptr::NonNull<()>> {
4729        self.component_frame
4730    }
4731
4732    /// phase-483 W3 — install or clear the component registration frame.
4733    /// The caller guarantees the pointee outlives the installation.
4734    #[doc(hidden)]
4735    pub fn __set_component_frame(&mut self, frame: Option<core::ptr::NonNull<()>>) {
4736        self.component_frame = frame;
4737    }
4738
4739    pub fn node_mut(&mut self, id: super::node_record::NodeId) -> super::node::Node<'_, 's> {
4740        super::node::Node::new(self, id)
4741    }
4742
4743    /// Issue #52 — install the baked QoS-override table on one node. Every
4744    /// entity created on it AFTERWARDS folds the matching `(topic, role)`
4745    /// entries into its QoS, before the backend-compat check — so an override
4746    /// the active RMW cannot honour still errors loudly rather than silently
4747    /// downgrading.
4748    ///
4749    /// Called by the generated entry (`nros::main!` → the register seam) right
4750    /// after the node is created and before the component declares entities.
4751    /// Unknown `node_id` is a no-op.
4752    pub fn set_node_qos_overrides(
4753        &mut self,
4754        node_id: super::node_record::NodeId,
4755        overrides: &'static [super::node_record::QoSOverrideCode],
4756    ) {
4757        if let Some(r) = self.nodes.get_mut(node_id.index()) {
4758            r.qos_overrides = overrides;
4759        }
4760    }
4761
4762    /// Phase 104.C.3 — resolve a session-slot index to a mutable
4763    /// session reference. Slot 0 = the Executor's primary session;
4764    /// slots 1..=N = the `extra_sessions` vec opened by
4765    /// `node_builder.rmw(name)` calls that named a backend
4766    /// different from the primary.
4767    pub(crate) fn session_at_mut(&mut self, idx: u8) -> Option<&mut session::ConcreteSession> {
4768        if idx == 0 {
4769            Some(&mut *self.session)
4770        } else {
4771            self.extra_sessions.get_mut((idx - 1) as usize)
4772        }
4773    }
4774
4775    /// [`Self::session_at_mut`] plus a shared borrow of the remap table, for
4776    /// building a [`NodeHandle`] — which needs both at once, and gets them
4777    /// here as two disjoint field borrows rather than through two `&mut self`
4778    /// calls the borrow checker could not split.
4779    pub(crate) fn session_and_remaps_at_mut(
4780        &mut self,
4781        idx: u8,
4782    ) -> Option<(&mut session::ConcreteSession, &[Option<RemapRule>])> {
4783        let remaps = &self.remap_table[..self.remap_len];
4784        let session = if idx == 0 {
4785            Some(&mut *self.session)
4786        } else {
4787            self.extra_sessions.get_mut((idx - 1) as usize)
4788        };
4789        session.map(|s| (s, remaps))
4790    }
4791
4792    /// Phase 104.C.9.b — resolve the per-Node session for direct
4793    /// entity creation paths (C++ FFI publisher / subscription /
4794    /// service that bypass the `register_*_on` arena dispatch).
4795    /// Returns `None` when `node_id` is out of range or the Node's
4796    /// `session_idx` lands outside the executor's session table.
4797    pub fn node_session_mut(
4798        &mut self,
4799        node_id: super::node_record::NodeId,
4800    ) -> Option<&mut session::ConcreteSession> {
4801        let session_idx = self.nodes.get(node_id.index())?.session_idx;
4802        self.session_at_mut(session_idx)
4803    }
4804
4805    /// Phase 189.M1 — create a typed publisher bound to a node's session.
4806    /// Backs `node.publisher(t).typed::<M>().build()` on the
4807    /// executor-borrowing [`Node`](super::node::Node); the returned
4808    /// handle is owned and outlives the `Node`.
4809    pub fn create_publisher_on<M: crate::rmw_type_registry::MessageForRmw>(
4810        &mut self,
4811        node_id: super::node_record::NodeId,
4812        topic_name: &str,
4813        qos: QoSProfile,
4814    ) -> Result<crate::executor::handles::EmbeddedPublisher<M>, NodeError> {
4815        // RFC-0088 / phase-421 W1 — the message's declared format must be the
4816        // one the linked backend speaks. Universal: the const lives on
4817        // `RosMessage`, which `MessageForRmw` requires under every backend.
4818        crate::format_check::assert_message_format::<M>();
4819        // Phase 212.K.7.6.b — register `M`'s cyclonedds descriptor before
4820        // creating the underlying publisher handle. No-op for other RMWs.
4821        crate::rmw_type_registry::register_type::<M>()?;
4822        let handle = self.create_raw_publisher_handle_on(
4823            node_id,
4824            topic_name,
4825            <M as RosMessage>::TYPE_NAME,
4826            <M as RosMessage>::TYPE_HASH,
4827            qos,
4828        )?;
4829        // RFC-0052 W3b.4 — attach the contracted endpoint's counter cell.
4830        let monitor = self
4831            .monitor_table
4832            .iter()
4833            .find(|m| m.topic == topic_name)
4834            .map(|m| m.cell);
4835        Ok(crate::executor::handles::EmbeddedPublisher {
4836            handle,
4837            event_regs: crate::executor::handles::empty_event_regs(),
4838            monitor,
4839            epoch: self.epoch_us_fn,
4840            _phantom: PhantomData,
4841        })
4842    }
4843
4844    /// Phase 189.M1 — create a generic (type-erased) publisher bound to a
4845    /// node's session. Backs `node.publisher(t).generic(ty, hash).build()`;
4846    /// the bridge re-publishes through this handle on the dest session.
4847    pub fn create_publisher_raw_on(
4848        &mut self,
4849        node_id: super::node_record::NodeId,
4850        topic_name: &str,
4851        type_name: &str,
4852        type_hash: &str,
4853        qos: QoSProfile,
4854    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
4855        let handle =
4856            self.create_raw_publisher_handle_on(node_id, topic_name, type_name, type_hash, qos)?;
4857        Ok(crate::executor::handles::EmbeddedRawPublisher {
4858            handle,
4859            arena: crate::executor::handles::TxArena::new(),
4860            event_regs: crate::executor::handles::empty_event_regs(),
4861            // Issue 1676 — attach the contracted endpoint's counter cell by
4862            // exact topic match against the installed table, the rule
4863            // `NodeHandle::create_publisher` applies. This is the publisher
4864            // the Rust component road creates, so without it no contracted
4865            // Rust publisher was ever counted.
4866            monitor: self.publisher_monitor_cell(topic_name),
4867        })
4868    }
4869
4870    /// Shared prelude for the publisher-on-node paths: resolve the node's
4871    /// identity + session slot, build the [`TopicInfo`], validate QoS, and
4872    /// create the backend publisher handle. Mirrors
4873    /// `register_subscription_buffered_raw_on`'s session resolution so a
4874    /// bridge's source sub + dest pub agree on topic construction.
4875    fn create_raw_publisher_handle_on(
4876        &mut self,
4877        node_id: super::node_record::NodeId,
4878        topic_name: &str,
4879        type_name: &str,
4880        type_hash: &str,
4881        qos: QoSProfile,
4882    ) -> Result<session::RmwPublisher, NodeError> {
4883        let (node_name, ns, session_idx, overrides) = {
4884            let r = self
4885                .nodes
4886                .get(node_id.index())
4887                .ok_or(NodeError::InvalidSchedContextBinding)?;
4888            (
4889                r.name.clone(),
4890                r.namespace.clone(),
4891                r.session_idx,
4892                r.qos_overrides,
4893            )
4894        };
4895        // Issue #52 — fold the node's baked overrides for this topic BEFORE
4896        // `validate_against`, so an override the backend cannot honour errors
4897        // loudly instead of being silently dropped.
4898        let qos = super::node_record::apply_qos_override_codes(
4899            qos,
4900            topic_name,
4901            nros_rmw::QoSOverrideRole::Publisher,
4902            overrides,
4903        );
4904        let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
4905            .with_domain(self.domain_id)
4906            .with_namespace(&ns);
4907        if !node_name.is_empty() {
4908            topic = topic.with_node_name(&node_name);
4909        }
4910        // issue 1608 -- the publisher half of the declared-QoS check, before
4911        // validation so a policy the contract raises this publisher to is one
4912        // the backend is asked about. See `declared_qos::honour_publisher`.
4913        let qos = crate::declared_qos::honour_publisher(topic.type_name, topic.name, qos)?;
4914        let session = self
4915            .session_at_mut(session_idx)
4916            .ok_or(NodeError::BackendMismatch)?;
4917        super::node::validate_qos_or_report(
4918            &qos,
4919            Session::supported_qos_policies(session),
4920            "publisher",
4921            topic_name,
4922        )?;
4923        session
4924            .create_publisher(&topic, qos)
4925            .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))
4926    }
4927
4928    /// Phase 124.B.1 — install the executor's wake callback onto the
4929    /// primary session. Best-effort: backends that don't override
4930    /// `Session::set_wake_callback` (poll-only XRCE, bare-metal)
4931    /// ignore the call and continue to be drained on the executor's
4932    /// deadline-bound cv-wait boundary.
4933    ///
4934    /// `pub` for issue 1385, and for the same reason
4935    /// [`set_primary_identity`](Self::set_primary_identity) is: the three
4936    /// `Executor::open*` paths call this automatically, and a binding that
4937    /// builds its executor over a session it BORROWS
4938    /// ([`from_session_ptr_in`](Self::from_session_ptr_in)) reaches none of
4939    /// them, so it has to call it itself. `rclc_executor_init` is that
4940    /// binding, and until 1385 it did not — leaving `has_async_wake` false
4941    /// for the life of every C executor on every backend.
4942    ///
4943    /// Pairs with `clear_wake_signal`, which the
4944    /// executor runs from `close()` and from `drop`. A caller that installs
4945    /// owes nothing further: the teardown is the executor's.
4946    ///
4947    /// phase-436 — the backend's slot holds a `WakeFanout`
4948    /// and this executor JOINS it, so every executor over one session is woken
4949    /// by an arrival: the boot executor and each spawned tier
4950    /// (`open_with_session*` now installs too), or several C executors over
4951    /// one support context. Idempotent. If the fan-out pool is exhausted the
4952    /// executor installs its own context directly, as before; if the entry's
4953    /// member table is full it joins nothing and reads as having no async
4954    /// wake, so it never parks on a wake nobody will signal.
4955    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4956    pub fn install_wake_signal_on_primary(&mut self) {
4957        use nros_rmw::Session as _;
4958        if self.wake_membership.is_some() {
4959            return;
4960        }
4961        let ctx = self.wake_ctx_ptr();
4962        let key = self.session_ptr() as *mut core::ffi::c_void;
4963        let supports = self.session.supports_wake_callback();
4964        let fanout = match super::wake_fanout::find(key) {
4965            Some(f) => Some(f),
4966            None => super::wake_fanout::claim(key).inspect(|f| {
4967                f.set_live(supports);
4968                // SAFETY: the context is a `static` pool entry, valid for the
4969                // program's life; `clear_wake_signal` takes the callback off
4970                // before returning the entry to the pool.
4971                unsafe {
4972                    self.session
4973                        .set_wake_callback(Some(nros_rmw_fanout_wake_cb), f.as_ctx());
4974                }
4975            }),
4976        };
4977        match fanout {
4978            Some(f) => {
4979                if let Some(m) = f.join(ctx) {
4980                    self.wake_membership = Some(m);
4981                    // Issue 1385 — this executor now owes a teardown: leaving
4982                    // the fan-out (and, for the session's owner or its last
4983                    // member, taking the callback off the backend).
4984                    self.wake_cb_installed = true;
4985                }
4986            }
4987            None => {
4988                // SAFETY: `ctx` points at executor-owned wake state that
4989                // outlives the installation and is cleared by
4990                // `clear_wake_signal` on `close()`/drop.
4991                unsafe {
4992                    self.session
4993                        .set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
4994                }
4995                self.wake_cb_installed = true;
4996                if supports {
4997                    self.has_async_wake = true;
4998                }
4999            }
5000        }
5001    }
5002
5003    /// phase-436 — does an arrival on any of this executor's sessions signal
5004    /// its wake object? Through the primary's fan-out (live until the
5005    /// session's owner takes the callback down), or a direct install on the
5006    /// primary or an extra session.
5007    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5008    pub(crate) fn async_wake_live(&self) -> bool {
5009        self.has_async_wake || self.wake_membership.is_some_and(|m| m.is_live())
5010    }
5011
5012    /// Phase 124.B.1 — install the wake callback onto an extra
5013    /// session opened by `node_builder.rmw(...)`. Called from
5014    /// `NodeBuilder::build()` right after `extra_sessions.push(...)`.
5015    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5016    pub(crate) fn install_wake_signal_on_extra(&mut self, idx: usize) {
5017        use nros_rmw::Session as _;
5018        let ctx = self.wake_ctx_ptr();
5019        if let Some(s) = self.extra_sessions.get_mut(idx) {
5020            // SAFETY: same executor-owned wake state as the primary session;
5021            // the extra session is owned by this executor.
5022            unsafe {
5023                s.set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
5024            }
5025            // Issue 1385 — as on the primary.
5026            self.wake_cb_installed = true;
5027            if s.supports_wake_callback() {
5028                self.has_async_wake = true;
5029            }
5030        }
5031    }
5032
5033    /// Issue 1385 — the teardown half of `install_wake_signal_on_*`, and the
5034    /// obligation `nros_rmw_runtime_wake_cb`'s own SAFETY comment has asserted
5035    /// since phase 124: *"Executor::drop must clear the callback via
5036    /// `set_wake_callback(None, _)` on all sessions before dropping
5037    /// wake_ctx"*. Nothing did. The string `set_wake_callback(None` occurred
5038    /// exactly once in the tree — in that comment.
5039    ///
5040    /// The context the backend holds is `Arc::as_ptr(&self.wake_ctx)`, freed
5041    /// with this executor. Leaving it installed is a dangling callback the
5042    /// backend may invoke from a worker thread or an ISR. On the Rust `open*`
5043    /// paths the session is `SessionStore::Owned` and dies in the same drop,
5044    /// so the window is narrow; on the C path it is arbitrary —
5045    /// `rclc_executor_fini` drops the executor and zero-fills `_opaque` while
5046    /// the session lives on in `rclc_support_t` until `rclc_support_fini`.
5047    ///
5048    /// Clears the primary and every extra session, then latches off, so
5049    /// `close()` followed by drop clears once.
5050    ///
5051    /// Only fires when THIS executor installed (see
5052    /// `install_wake_signal_on_primary`). Executors sharing one session were
5053    /// last-writer-wins on the backend's single slot until phase-436: the first
5054    /// to tear down cleared the other's wake. The slot now holds a wake
5055    /// fan-out, so a member's teardown is leaving it, and the callback comes
5056    /// off the backend only with the session's owner or its last member.
5057    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5058    pub(crate) fn clear_wake_signal(&mut self) {
5059        use nros_rmw::Session as _;
5060        if !self.wake_cb_installed {
5061            return;
5062        }
5063        self.wake_cb_installed = false;
5064        // phase-436 — a fan-out member leaves first; the callback comes off
5065        // the backend only when the session's OWNER leaves (the session dies
5066        // with it) or the last member does. A membership from an entry the
5067        // owner already released is a no-op, and must not touch the entry's
5068        // next tenant.
5069        let take_off_primary = match self.wake_membership.take() {
5070            Some(m) => {
5071                let fanout = m.fanout();
5072                let current = m.is_current();
5073                m.leave(nros_platform_api::yield_now);
5074                let owned = matches!(self.session, SessionStore::Owned(_));
5075                if current && (owned || fanout.member_count() == 0) {
5076                    // SAFETY: as below; the entry is released only after the
5077                    // backend can no longer start a callback on it.
5078                    unsafe { self.session.set_wake_callback(None, core::ptr::null_mut()) };
5079                    fanout.release(nros_platform_api::yield_now);
5080                }
5081                false
5082            }
5083            None => true,
5084        };
5085        // SAFETY: clearing takes no context, and both sessions are still live
5086        // here — this runs BEFORE `Session::close` in `close()` and before the
5087        // session field is dropped in `Drop`.
5088        unsafe {
5089            if take_off_primary {
5090                self.session.set_wake_callback(None, core::ptr::null_mut());
5091            }
5092            for s in self.extra_sessions.iter_mut() {
5093                s.set_wake_callback(None, core::ptr::null_mut());
5094            }
5095        }
5096        self.has_async_wake = false;
5097    }
5098
5099    /// phase-467 Row 8 — hand the PRIMARY session a graph-change target it may
5100    /// trigger from a worker thread, and take ownership of that target.
5101    ///
5102    /// The install half of the pair; [`clear_graph_change_signal`] is the
5103    /// other and the executor owes it. `guard` is CLONED, so the caller keeps
5104    /// its own handle to the same arena flag — that is the whole point: the
5105    /// pointer the backend receives is the executor's box, never the caller's
5106    /// storage. Issue 1385 is the same hazard on the wake slot, found after
5107    /// the fact; this is the shape that cannot reach it.
5108    ///
5109    /// PRIMARY only, deliberately. A graph is a property of a session, and the
5110    /// extra sessions of `node_builder.rmw(...)` each have their own — feeding
5111    /// several into one guard would make "the graph changed" unattributable
5112    /// while costing the caller the ability to ask which. A caller wanting a
5113    /// second backend's graph asks that session for its own.
5114    ///
5115    /// Returns `Err(Unsupported)` from a backend with no graph. A previously
5116    /// installed target is retired first, so an executor never has two.
5117    ///
5118    /// [`clear_graph_change_signal`]: Self::clear_graph_change_signal
5119    ///
5120    /// `pub(crate)`, not `pub`: the public verb is
5121    /// [`register_graph_change_guard_on`], which does the probe, the
5122    /// registration and this in the one order that cannot hand back a guard
5123    /// nothing fires. Exposing the install alone would let a caller reach the
5124    /// second step without the first.
5125    ///
5126    /// [`register_graph_change_guard_on`]: Self::register_graph_change_guard_on
5127    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5128    pub(crate) fn install_graph_change_guard(
5129        &mut self,
5130        guard: &GuardCondition,
5131    ) -> Result<(), NodeError> {
5132        use nros_rmw::Session as _;
5133        self.clear_graph_change_signal();
5134        let boxed = alloc::boxed::Box::new(guard.clone());
5135        let ptr = (&*boxed) as *const GuardCondition as *const core::ffi::c_void;
5136        // SAFETY: `ptr` names heap storage this executor owns and which
5137        // `clear_graph_change_signal` retires from the backend before it is
5138        // freed. `GuardCondition` is `Send + Sync` and its `trigger()` is a
5139        // relaxed store plus the runtime wake hook, which is what a foreign
5140        // thread is allowed to do here.
5141        let rc = unsafe {
5142            self.session
5143                .set_graph_change_callback(Some(nros_rmw_runtime_graph_change_cb), ptr)
5144        };
5145        match rc {
5146            Ok(()) => {
5147                self.graph_change_target = Some(boxed);
5148                Ok(())
5149            }
5150            // `boxed` drops here, and nothing holds its pointer: the backend
5151            // either refused or failed, so there is no installed callback to
5152            // retire first.
5153            Err(e) => Err(NodeError::Transport(e)),
5154        }
5155    }
5156
5157    /// phase-467 Row 8 — can the primary session deliver a graph-change edge?
5158    ///
5159    /// Asked by CLEARING, which is the one probe that costs nothing and cannot
5160    /// lie: a backend with no graph answers `Unsupported` to a `None` install
5161    /// just as it does to a real one, and an idempotent clear on a backend
5162    /// that HAS a graph is a no-op. Nullity of the vtable slot cannot answer
5163    /// it — `rust_adapter.rs` fills the slot for every Rust backend whether or
5164    /// not that backend overrides the trait method, which
5165    /// `check-rmw-slot-producers`'s own report says in as many words.
5166    ///
5167    /// The caller is `register_graph_change_guard_on`, which asks BEFORE it
5168    /// consumes an executor handle slot, so an unsupported backend costs
5169    /// nothing and the caller is never handed a guard nothing fires.
5170    /// `pub(crate)` for the same reason the install is: asking costs a CLEAR,
5171    /// so a caller that asks and then does nothing has silently retired a live
5172    /// target. The public verb asks once, on the caller's behalf.
5173    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5174    pub(crate) fn supports_graph_change_callback(&mut self) -> bool {
5175        use nros_rmw::Session as _;
5176        // Retire whatever is installed first — the probe IS a clear, so doing
5177        // it without this would silently drop a live target.
5178        self.clear_graph_change_signal();
5179        // SAFETY: a `None` install takes no context and installs nothing.
5180        unsafe {
5181            self.session
5182                .set_graph_change_callback(None, core::ptr::null())
5183                .is_ok()
5184        }
5185    }
5186
5187    /// phase-467 Row 8 — the teardown half of
5188    /// [`install_graph_change_guard`](Self::install_graph_change_guard), and
5189    /// the reason that install cannot recreate issue 1385.
5190    ///
5191    /// Order is the whole content: tell the backend to stop FIRST, then drop
5192    /// the box it was pointing at. Reversed, the window between the free and
5193    /// the clear is exactly 1385's dangling callback — and on the C path that
5194    /// window is arbitrary, because the session outlives the executor.
5195    ///
5196    /// Latched on `graph_change_target.is_some()`, not on a separate flag: the
5197    /// box IS the record of having installed, so `close()` followed by drop
5198    /// clears once and an executor that never installed clears nothing. That
5199    /// is the same predicate `clear_wake_signal` needs a bool for only because
5200    /// its context is shared with `signal_fd()` and every guard condition.
5201    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5202    pub(crate) fn clear_graph_change_signal(&mut self) {
5203        use nros_rmw::Session as _;
5204        if self.graph_change_target.is_none() {
5205            return;
5206        }
5207        // SAFETY: clearing takes no context, and the session is still live
5208        // here — this runs BEFORE `Session::close` in `close()` and before the
5209        // session field is dropped in `Drop`.
5210        unsafe {
5211            let _ = self
5212                .session
5213                .set_graph_change_callback(None, core::ptr::null());
5214        }
5215        // Only now. The backend has been told, so nothing can be holding this
5216        // pointer when it is freed.
5217        self.graph_change_target = None;
5218    }
5219
5220    /// Phase 124.B.2 — opaque context pointer the runtime wake
5221    /// callback receives. Encodes `(flag, mu, cv)` as a borrowed
5222    /// `&WakeCtx` reference; the callback decodes via
5223    /// `*const WakeCtx`.
5224    ///
5225    /// Lifetime: tied to the Executor instance. WakeCtx storage
5226    /// lives inside Executor (lazy-allocated on first install), so
5227    /// the pointer stays valid as long as the Executor is.
5228    /// Phase 124.B.7.c — POSIX signal-handler-safe wake fd.
5229    ///
5230    /// Returns a Linux `eventfd` that callers (typically POSIX
5231    /// signal handlers) can `write(fd, &1u64, 8)` to from any
5232    /// context, including signal handlers. A runtime-owned worker
5233    /// thread reads the fd and signals `wake_cv`, unblocking
5234    /// `spin_once`.
5235    ///
5236    /// The worker thread is spawned lazily on first call and
5237    /// joined on Executor drop. Linux-only and gated behind
5238    /// `feature = "signal-fd-wake"`; binaries that don't install
5239    /// signal handlers shouldn't enable it.
5240    ///
5241    /// Returns the raw fd. The Executor retains ownership; do not
5242    /// `close()` it from the caller.
5243    #[cfg(all(feature = "signal-fd-wake", feature = "rmw-cffi", target_os = "linux"))]
5244    /// phase-359 W10 — was `std::io::Result`. Same values, an error type that
5245    /// does not require `std`.
5246    pub fn signal_fd(&mut self) -> Result<core::ffi::c_int, NodeError> {
5247        let ctx_ptr = self.wake_ctx_ptr() as *const WakeCtx;
5248        if self.signal_fd.is_none() {
5249            self.signal_fd = Some(WakeSignalFd::new(ctx_ptr)?);
5250        }
5251        Ok(self.signal_fd.as_ref().expect("just set").fd())
5252    }
5253
5254    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5255    fn wake_ctx_ptr(&mut self) -> *mut core::ffi::c_void {
5256        if self.wake_ctx.is_none() {
5257            self.wake_ctx = Some(portable_atomic_util::Arc::new(WakeCtx {
5258                flag: self.wake_flag.clone(),
5259                node_wake: self.node_wake.clone(),
5260            }));
5261        }
5262        let arc = self.wake_ctx.as_ref().expect("just set");
5263        portable_atomic_util::Arc::as_ptr(arc) as *mut core::ffi::c_void
5264    }
5265
5266    /// Phase 104.C.4 — apply a Node's default SchedContext to a
5267    /// freshly-registered handle. Called from every `_inner`
5268    /// register variant after the entry slot is committed. No-op
5269    /// when `node_id` is None (legacy path), when the Node is
5270    /// out of range, or when the Node's `default_sched` is the
5271    /// auto-created Fifo slot (0) which matches the executor's
5272    /// default binding already.
5273    ///
5274    /// Phase 273 (RFC-0047) — extends with an optional `group` name.
5275    /// Precedence: **group table > node default > no binding** (SC 0).
5276    /// When `group` is `Some(g)`, consults `group_sched_table` first;
5277    /// if no entry exists for `(name, namespace, g)` falls back to the
5278    /// node's `default_sched`. When `group` is `None` the group table
5279    /// is not consulted (unchanged phase-272 path).
5280    ///
5281    /// Handles can still override per-call via
5282    /// `bind_handle_to_sched_context(handle, sc_id)` post-register.
5283    pub(crate) fn apply_node_default_sched(
5284        &mut self,
5285        slot: usize,
5286        node_id: Option<super::node_record::NodeId>,
5287        group: Option<&str>,
5288    ) {
5289        let Some(id) = node_id else { return };
5290        // Copy name, namespace, and default_sched out so the borrow on
5291        // `self.nodes` is released before the immutable `lookup_group_sched`
5292        // borrow and the mutable `sched_context_bindings` write below.
5293        let (name, namespace, node_sc) = {
5294            let Some(rec) = self.nodes.get(id.index()) else {
5295                return;
5296            };
5297            (rec.name.clone(), rec.namespace.clone(), rec.default_sched)
5298        };
5299        // Phase 273: group table > node default.
5300        let sc = match group {
5301            Some(g) => self
5302                .lookup_group_sched(name.as_str(), namespace.as_str(), g)
5303                .unwrap_or(node_sc),
5304            None => node_sc,
5305        };
5306        if sc.0 == 0 {
5307            return;
5308        }
5309        if slot >= self.entries.len() {
5310            return;
5311        }
5312        let sc_idx = sc.0 as usize;
5313        if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
5314            return;
5315        }
5316        self.sched_context_bindings[slot] = sc;
5317    }
5318
5319    /// Phase 104.C.3.2 — scoped Node-handle access. The closure
5320    /// receives a [`NodeHandle`](crate::executor::NodeHandle) bound to the
5321    /// requested [`NodeId`](crate::executor::NodeId)'s
5322    /// session + identity. Use the standard `Node::create_publisher`,
5323    /// `create_subscription`, etc. APIs inside.
5324    ///
5325    /// rclcpp-aligned bridge pattern:
5326    ///
5327    /// ```ignore
5328    /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
5329    /// let node_out = exec.node_builder("egress").rmw("xrce").build()?;
5330    ///
5331    /// let pub_out = exec.with_node(node_out, |n| {
5332    ///     n.create_publisher::<Int32>("/fwd")
5333    /// })??;
5334    ///
5335    /// exec.with_node(node_in, |n| {
5336    ///     n.create_subscription_buffered::<Int32, _, 1024>(
5337    ///         "/src", qos(), move |m| { let _ = pub_out.publish(m); }
5338    ///     )
5339    /// })??;
5340    /// ```
5341    ///
5342    /// The closure can return any type; double-`?` unwraps the
5343    /// outer `Result<R, NodeError>` from `with_node` and the inner
5344    /// result returned by the closure.
5345    /// Phase 104.C.3.3.d — flat-Result variant of
5346    /// [`with_node`](Self::with_node). When the closure already
5347    /// returns `Result<R, NodeError>`, this avoids the double-`?`:
5348    ///
5349    /// ```ignore
5350    /// // Without `with_node_try`:
5351    /// let pub_ = exec.with_node(id, |n| n.create_publisher(...))??;
5352    ///
5353    /// // With `with_node_try`:
5354    /// let pub_ = exec.with_node_try(id, |n| n.create_publisher(...))?;
5355    /// ```
5356    pub fn with_node_try<R>(
5357        &mut self,
5358        id: super::node_record::NodeId,
5359        f: impl FnOnce(&mut NodeHandle<'_>) -> Result<R, NodeError>,
5360    ) -> Result<R, NodeError> {
5361        self.with_node(id, f)?
5362    }
5363
5364    pub fn with_node<R>(
5365        &mut self,
5366        id: super::node_record::NodeId,
5367        f: impl FnOnce(&mut NodeHandle<'_>) -> R,
5368    ) -> Result<R, NodeError> {
5369        let (name, ns, session_idx) = {
5370            let r = self
5371                .nodes
5372                .get(id.index())
5373                .ok_or(NodeError::InvalidSchedContextBinding)?;
5374            (r.name.clone(), r.namespace.clone(), r.session_idx)
5375        };
5376        let monitors = self.monitor_table;
5377        let age_monitors = self.age_table;
5378        let epoch = self.epoch_us_fn;
5379        let domain_id = self.domain_id;
5380        let (session, remaps) = self
5381            .session_and_remaps_at_mut(session_idx)
5382            .ok_or(NodeError::BackendMismatch)?;
5383        // SAFETY: short-lived scoped reference. `Node::new` takes
5384        // `&mut ConcreteSession`; lifetime is bound to this fn's
5385        // body via the closure's borrow of `node`.
5386        // issue 0801 (second half) — the executor's domain, NOT a literal 0.
5387        // 429d5a581 fixed the ELEVEN arena `TopicInfo`s and left the THREE
5388        // `NodeHandle::new` sites, so an entity created through a node HANDLE
5389        // (`with_node`, `node`, `node_on`) still declared on domain 0 while the
5390        // arena path declared on the configured one. Same split the issue is
5391        // about, one constructor over: `loan_e2e` publishes through a handle and
5392        // subscribes through the arena, so it delivered on domain 0 and on
5393        // nothing else.
5394        let mut node = NodeHandle::new(name, ns, session, remaps, domain_id);
5395        // RFC-0052 W3b.4/.5 — seed the baked monitor tables so contracted
5396        // publishers/subscribers attach their cells without entry glue.
5397        node.set_monitors(monitors);
5398        node.set_age_monitors(age_monitors, epoch);
5399        Ok(f(&mut node))
5400    }
5401
5402    /// Find a registered executor node by final name and namespace.
5403    pub fn node_id_by_name(
5404        &self,
5405        name: &str,
5406        namespace: &str,
5407    ) -> Option<super::node_record::NodeId> {
5408        self.nodes
5409            .iter()
5410            .enumerate()
5411            .find(|(_, node)| node.name.as_str() == name && node.namespace.as_str() == namespace)
5412            .map(|(index, _)| super::node_record::NodeId::from_raw(index as u8))
5413    }
5414
5415    /// Create a node on this executor.
5416    ///
5417    /// Registers the node in the executor's table, deduplicating on
5418    /// `(name, namespace)` — phase-376 W5/B1.
5419    ///
5420    /// # This is `rclrs::Executor::create_node`
5421    ///
5422    /// phase-427 W10 (RFC-0089 "Context and `init`, settled"). Same name, same
5423    /// place in the sequence — `Context` → executor → node — so a ported rclrs
5424    /// `main` lands here unchanged:
5425    ///
5426    /// ```ignore
5427    /// let context = nros::Context::default_from_env()?;
5428    /// let mut executor = context.create_executor()?;
5429    /// let mut node = executor.create_node("talker")?;
5430    /// ```
5431    ///
5432    /// Two differences from upstream, both structural and both named in the
5433    /// ledger row (`rust:Executor::create_node`): ours takes `&mut self`,
5434    /// because the node is registered in the executor's compile-time-sized
5435    /// table rather than allocated, and it hands back a borrowing
5436    /// [`NodeHandle`] rather than an `Arc<Node>`, because there is no
5437    /// allocator. Neither is silent — the compiler names both.
5438    ///
5439    /// The name comes from HERE, not from
5440    /// [`ExecutorConfig::node_name`](crate::ExecutorConfig::node_name), whose
5441    /// builder spelling is deprecated for exactly this reason: one executor
5442    /// holds several named nodes (RFC-0047), so a name on the session config
5443    /// can only ever describe the first of them.
5444    ///
5445    /// Until 2026-08-24 this path registered NOTHING. It built a `NodeHandle`
5446    /// and returned it, so `node_id_by_name` could not find a node the caller
5447    /// had just created, and two calls with one name handed out two handles the
5448    /// executor had never heard of. `create_node_on_with_domain` had the dedup
5449    /// (phase-267 added it there when N bridge endpoints overflowed the table);
5450    /// the plain path never got it.
5451    ///
5452    /// That is a prerequisite for the `create_node` vtable slot, whose contract
5453    /// is that the runtime calls it ONCE per distinct `(name, namespace)`:
5454    /// without a registry to check, the runtime would call it once per
5455    /// `create_node` and every backend would need its own dedup — which is the
5456    /// registry the slot exists to delete (zenoh's `ensure_node_liveliness`
5457    /// linear-scans `per_node_liveliness` for exactly this reason).
5458    ///
5459    /// The table is bounded by `MAX_NODES` (`NROS_EXECUTOR_MAX_NODES`, default
5460    /// 4), so a caller creating a fifth DISTINCT node now gets
5461    /// `NodeError::NodeTableFull` where it previously got a handle. That is the
5462    /// bound doing its job: a node the executor does not know about cannot
5463    /// carry a sched context, a QoS override, or a graph identity. Repeated
5464    /// calls with the SAME name are free.
5465    pub fn create_node(&mut self, name: &str) -> Result<super::node::Node<'_, 's>, NodeError> {
5466        if name.len() > 64 {
5467            return Err(NodeError::NameTooLong);
5468        }
5469        // Dedup against the executor's own namespace, which is the namespace
5470        // `node_builder` gives a node when none is named. phase-483 W2: this
5471        // returns THE node type (`Node`, re-exported as `nros::Node`), the
5472        // same handle `node_mut` gives a component, where it used to return a
5473        // session-borrowing `NodeHandle` with a different method set.
5474        let id = match self.node_id_by_name(name, self.namespace.clone().as_str()) {
5475            Some(id) => id,
5476            None => self.node_builder(name).build()?,
5477        };
5478        Ok(super::node::Node::new(self, id))
5479    }
5480
5481    /// Phase 128.F.2 — bridge-mode node factory. Registers a Node
5482    /// bound to the named RMW backend by opening (or reusing) an
5483    /// extra session via `node_builder().rmw(rmw).build()`, then
5484    /// returns a [`NodeHandle`](crate::executor::NodeHandle) borrowing that
5485    /// session. Use when the
5486    /// binary intentionally links more than one backend and a Node
5487    /// must speak a specific one.
5488    ///
5489    /// The single-backend common case should keep using
5490    /// [`create_node`](Self::create_node) — this entry costs an
5491    /// extra session lookup and serves no purpose when only one
5492    /// backend is registered.
5493    #[cfg(feature = "rmw-cffi")]
5494    pub fn create_node_on(
5495        &mut self,
5496        name: &str,
5497        rmw: &str,
5498    ) -> Result<super::node::Node<'_, 's>, NodeError> {
5499        self.create_node_on_with_domain(name, rmw, None, None)
5500    }
5501
5502    /// Like [`create_node_on`](Self::create_node_on) but pins the extra
5503    /// session's domain id. Required for a multi-domain config-driven bridge:
5504    /// an extra RMW session's participant domain follows the **node builder's**
5505    /// `domain_id` (`resolve_session_slot` → `domain_id.unwrap_or(0)`), NOT the
5506    /// `SessionSpec`'s — so without this an egress on a non-zero domain silently
5507    /// opens on domain 0 and never matches its receiver (phase-267 issue 0109).
5508    /// `None` domain preserves the legacy domain-0 default. `locator` pins the
5509    /// extra session's address — REQUIRED for an agent-based backend (xrce: the
5510    /// Micro-XRCE-DDS Agent addr) whose session can't be opened locator-less;
5511    /// `None` keeps the rmw-default (cyclonedds is domain-discovered, no locator).
5512    pub fn create_node_on_with_domain(
5513        &mut self,
5514        name: &str,
5515        rmw: &str,
5516        domain_id: Option<u32>,
5517        locator: Option<&str>,
5518    ) -> Result<super::node::Node<'_, 's>, NodeError> {
5519        if name.len() > 64 {
5520            return Err(NodeError::NameTooLong);
5521        }
5522        // Reuse an existing Node of this name rather than growing the node table
5523        // (phase-267 non-flat): a config-driven bridge calls this once per bridge
5524        // ENDPOINT, and the same session node (`s0`/`s1`) recurs across every
5525        // `[[bridge]]`. Without dedup, N bridges push 2N records and overflow
5526        // `MAX_NODES`. Names are unique per session in a generated bridge config,
5527        // so matching by name is unambiguous.
5528        let id = if let Some(i) = self.nodes.iter().position(|n| n.name.as_str() == name) {
5529            super::node_record::NodeId::from_raw(i as u8)
5530        } else {
5531            let mut builder = self.node_builder(name).rmw(rmw);
5532            if let Some(d) = domain_id {
5533                builder = builder.domain_id(d);
5534            }
5535            if let Some(loc) = locator {
5536                builder = builder.locator(loc);
5537            }
5538            builder.build()?
5539        };
5540        Ok(super::node::Node::new(self, id))
5541    }
5542
5543    /// Drive transport I/O (poll network, dispatch callbacks).
5544    #[allow(dead_code)]
5545    pub(crate) fn drive_io(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
5546        self.session
5547            .drive_io(timeout_ms)
5548            .map_err(|_| NodeError::Transport(TransportError::PollFailed))
5549    }
5550
5551    /// Close the underlying session, running the shutdown hooks around it.
5552    ///
5553    /// Issue 0790. The order is the feature:
5554    ///
5555    /// 1. every registered PRE-shutdown hook, while the session is still open
5556    ///    and every entity still works — this is where a node publishes a final
5557    ///    state, answers a last request, parks an actuator or releases a bus;
5558    /// 2. the session close;
5559    /// 3. every registered ON-shutdown hook.
5560    ///
5561    /// A hook runs EXACTLY ONCE: each phase table is emptied before its first
5562    /// hook is invoked, so a second `close()` — or the [`Drop`] sweep after one
5563    /// — finds nothing left to run. Step 2 runs even
5564    /// if a pre-shutdown hook was registered and step 3 even if the close
5565    /// failed — a hook cannot strand the session, and a dead session must not
5566    /// strand the hooks.
5567    ///
5568    /// # This is a CLEAN-STOP facility and nothing more
5569    ///
5570    /// A watchdog reset, a hard fault or a panic does not come through here, so
5571    /// these hooks do not run then. Nothing in a fixed static table can promise
5572    /// otherwise, and an API that implied it would be worse than none: hardware
5573    /// that must be safe across an abnormal stop needs a hardware answer (a
5574    /// pull-down, a watchdog-driven output disable), not a callback.
5575    pub fn close(&mut self) -> Result<(), NodeError> {
5576        self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
5577        // Issue 1385 — hand the callback back BEFORE closing the session that
5578        // holds it. `close()` leaves the executor alive, so without this a
5579        // later `set_wake_callback` from the drop sweep would reach a session
5580        // the backend has already torn down.
5581        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5582        self.clear_wake_signal();
5583        // phase-467 Row 8 — the same obligation on the graph-change slot, and
5584        // it has to be discharged in the same place for the same reason.
5585        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5586        self.clear_graph_change_signal();
5587        let result = self
5588            .session
5589            .close()
5590            .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed));
5591        self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
5592        result
5593    }
5594
5595    /// Register a hook to run BEFORE the session is closed (issue 0790).
5596    ///
5597    /// rclcpp's `Context::add_pre_shutdown_callback`. Returns the handle
5598    /// [`Self::remove_pre_shutdown_callback`] takes, or
5599    /// [`NodeError::ShutdownCallbacksFull`] when the phase table is full —
5600    /// raise `NROS_EXECUTOR_MAX_SHUTDOWN_CBS` (default 2) at build time.
5601    ///
5602    /// # Safety
5603    /// `callback` must be safe to invoke exactly once with `context`, and
5604    /// `context` must stay valid until the hook runs or is removed. The hook
5605    /// runs on whichever task calls [`Self::close`] (or drops the executor),
5606    /// which is not necessarily the task that registered it.
5607    pub unsafe fn add_pre_shutdown_callback(
5608        &mut self,
5609        callback: super::types::ShutdownCallbackFn,
5610        context: *mut core::ffi::c_void,
5611    ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
5612        Self::claim_shutdown_slot(
5613            &mut self.pre_shutdown_hooks,
5614            super::types::ShutdownPhase::Pre,
5615            callback,
5616            context,
5617        )
5618    }
5619
5620    /// Register a hook to run AFTER the session is closed (issue 0790).
5621    ///
5622    /// rclcpp's `Context::add_on_shutdown_callback` / `rclcpp::on_shutdown`.
5623    /// Entities are gone by the time it runs, so anything that needs the wire
5624    /// belongs in [`Self::add_pre_shutdown_callback`] instead.
5625    ///
5626    /// # Safety
5627    /// Same contract as [`Self::add_pre_shutdown_callback`].
5628    pub unsafe fn add_on_shutdown_callback(
5629        &mut self,
5630        callback: super::types::ShutdownCallbackFn,
5631        context: *mut core::ffi::c_void,
5632    ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
5633        Self::claim_shutdown_slot(
5634            &mut self.on_shutdown_hooks,
5635            super::types::ShutdownPhase::Post,
5636            callback,
5637            context,
5638        )
5639    }
5640
5641    /// Remove a pre-shutdown hook. `true` if `handle` named a live one.
5642    ///
5643    /// rclcpp's `Context::remove_pre_shutdown_callback`, and `bool` for the
5644    /// same reason: "it was not there" is an ordinary answer (the hook may
5645    /// already have run), not an error. A handle issued for the OTHER phase
5646    /// returns `false` and removes nothing — that is what the phase tag in
5647    /// [`ShutdownCallbackHandle`] buys.
5648    ///
5649    /// [`ShutdownCallbackHandle`]: super::types::ShutdownCallbackHandle
5650    pub fn remove_pre_shutdown_callback(
5651        &mut self,
5652        handle: super::types::ShutdownCallbackHandle,
5653    ) -> bool {
5654        Self::release_shutdown_slot(
5655            &mut self.pre_shutdown_hooks,
5656            super::types::ShutdownPhase::Pre,
5657            handle,
5658        )
5659    }
5660
5661    /// Remove an on-shutdown hook. See [`Self::remove_pre_shutdown_callback`].
5662    pub fn remove_on_shutdown_callback(
5663        &mut self,
5664        handle: super::types::ShutdownCallbackHandle,
5665    ) -> bool {
5666        Self::release_shutdown_slot(
5667            &mut self.on_shutdown_hooks,
5668            super::types::ShutdownPhase::Post,
5669            handle,
5670        )
5671    }
5672
5673    /// How many hooks are currently registered for `phase`. Diagnostic /
5674    /// test surface, and what a "did my registration land?" assertion reads.
5675    pub fn shutdown_callback_count(&self, phase: super::types::ShutdownPhase) -> usize {
5676        let table = match phase {
5677            super::types::ShutdownPhase::Pre => &self.pre_shutdown_hooks,
5678            super::types::ShutdownPhase::Post => &self.on_shutdown_hooks,
5679        };
5680        table.iter().flatten().count()
5681    }
5682
5683    /// Claim the first free slot of a phase table.
5684    fn claim_shutdown_slot(
5685        table: &mut [Option<super::types::ShutdownHook>],
5686        phase: super::types::ShutdownPhase,
5687        callback: super::types::ShutdownCallbackFn,
5688        context: *mut core::ffi::c_void,
5689    ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
5690        for (index, slot) in table.iter_mut().enumerate() {
5691            if slot.is_some() {
5692                continue;
5693            }
5694            let handle = super::types::ShutdownCallbackHandle::new(phase, index)
5695                .ok_or(NodeError::ShutdownCallbacksFull)?;
5696            *slot = Some(super::types::ShutdownHook { callback, context });
5697            return Ok(handle);
5698        }
5699        Err(NodeError::ShutdownCallbacksFull)
5700    }
5701
5702    /// Clear a slot of a phase table, rejecting a handle from the other phase.
5703    fn release_shutdown_slot(
5704        table: &mut [Option<super::types::ShutdownHook>],
5705        phase: super::types::ShutdownPhase,
5706        handle: super::types::ShutdownCallbackHandle,
5707    ) -> bool {
5708        if handle.phase() != Some(phase) {
5709            return false;
5710        }
5711        match table.get_mut(handle.index()) {
5712            Some(slot) => slot.take().is_some(),
5713            None => false,
5714        }
5715    }
5716
5717    /// Run — and consume — every hook registered for `phase`, in registration
5718    /// order.
5719    ///
5720    /// The table is EMPTIED BEFORE the first callback runs, not slot by slot as
5721    /// the loop walks it, and both halves of that matter:
5722    ///
5723    /// * "exactly once" becomes a property of the table rather than of the
5724    ///   caller — a second `close()`, or the `Drop` sweep after one, finds
5725    ///   nothing left to run;
5726    /// * the `&mut self` borrow ENDS before any foreign code is invoked. A hook
5727    ///   is an `extern "C" fn` that may hold a raw pointer back to this
5728    ///   executor (the C and C++ shims hand out exactly that), so a hook that
5729    ///   registers or removes another one must not be running inside a live
5730    ///   `&mut` into the table it is touching.
5731    pub(crate) fn run_shutdown_hooks(&mut self, phase: super::types::ShutdownPhase) {
5732        let table = match phase {
5733            super::types::ShutdownPhase::Pre => &mut self.pre_shutdown_hooks,
5734            super::types::ShutdownPhase::Post => &mut self.on_shutdown_hooks,
5735        };
5736        // `ShutdownHook` is `Copy` (a fn pointer and a raw pointer), so this is
5737        // a register-width move of a table whose default size is two slots —
5738        // not a reason to keep the borrow open across the calls.
5739        let hooks = *table;
5740        *table = [None; crate::config::MAX_SHUTDOWN_CBS];
5741        for hook in hooks.iter().flatten() {
5742            // SAFETY: the `add_*_shutdown_callback` caller promised `callback`
5743            // is safe to invoke once with `context`, and that `context` stays
5744            // valid until the hook runs or is removed. This is that one call,
5745            // and the table is already cleared so it cannot happen again.
5746            unsafe {
5747                (hook.callback)(hook.context);
5748            }
5749        }
5750    }
5751
5752    /// Phase 216 follow-up — register a per-Node dispatch trampoline.
5753    ///
5754    /// The board-side Entry pkg (or the macro-emitted
5755    /// `register_dispatch(executor)` wrapper, once wired) calls this
5756    /// once per deployed Node pkg, handing in the
5757    /// `__nros_node_<pkg>_on_callback` symbol + the Node's per-pkg
5758    /// `state` blob. [`Executor::dispatch_callback`] then linear-scans
5759    /// the registered slots when the dispatch task hands off a
5760    /// `SignaledCallback`.
5761    ///
5762    /// Returns `Err(())` when the registry is full (`MAX_NODES`
5763    /// entries — raise via `NROS_EXECUTOR_MAX_NODES` at build time).
5764    ///
5765    /// # Safety
5766    ///
5767    /// `state` must outlive the executor (the typical shape is a
5768    /// `*mut State` produced by
5769    /// `nros::__private_node_state_into_raw` from the
5770    /// macro-emitted `i()`; that pointer's lifetime IS the
5771    /// `Executor`'s by construction). `on_callback` must be safe to
5772    /// invoke with `(state, cb_id_ptr, cb_id_len, ctx)` matching the
5773    /// per-Node `__nros_node_<pkg>_on_callback` ABI emitted by the
5774    /// `nros::node!()` macro (Phase 216.A.5).
5775    #[allow(clippy::result_unit_err)]
5776    pub fn register_dispatch_slot(
5777        &mut self,
5778        state: *mut core::ffi::c_void,
5779        on_callback: unsafe extern "C" fn(
5780            *mut core::ffi::c_void,
5781            *const u8,
5782            usize,
5783            *mut core::ffi::c_void,
5784        ),
5785    ) -> Result<(), ()> {
5786        self.dispatch_slots
5787            .push(DispatchSlot { state, on_callback })
5788            .map_err(|_| ())
5789    }
5790
5791    /// Phase 216 follow-up — current registered dispatch-slot count.
5792    /// Diagnostic / test surface.
5793    pub fn dispatch_slot_count(&self) -> usize {
5794        self.dispatch_slots.len()
5795    }
5796
5797    /// Phase 258 (Track 2, 2a) — enroll a component into the executor-owned
5798    /// tick registry. Called by `nros`'s `install`/`register_node_borrowed`
5799    /// after it builds the `Arc<ComponentCell>`: `state` is the leaked
5800    /// `Arc<ComponentCell>` (the slot takes ownership), `tick`/`drop` are the
5801    /// `nros`-side trampolines (see `ComponentSlot`). The slot's `tick`
5802    /// runs at the tail of every [`spin_once`](Self::spin_once); its `drop`
5803    /// runs once on `Executor::drop`.
5804    ///
5805    /// Returns `Err(())` when the registry is full (`MAX_NODES` — raise via
5806    /// `NROS_EXECUTOR_MAX_NODES` at build time). On error the caller still
5807    /// owns `state` (the slot was not stored) and must drop it.
5808    ///
5809    /// # Safety
5810    /// `state` must be a `*mut` produced by leaking the component cell the
5811    /// `tick`/`drop` trampolines expect (an `Arc<ComponentCell>` via
5812    /// `Arc::into_raw` in the canonical `nros` caller), and must remain valid
5813    /// until the matching `drop` runs. `tick` must be safe to invoke with
5814    /// `(state, exec_ctx = *mut Executor)` each spin; `drop` must be safe to
5815    /// invoke exactly once with `state`.
5816    #[allow(clippy::result_unit_err)]
5817    pub unsafe fn enroll_component(
5818        &mut self,
5819        state: *mut core::ffi::c_void,
5820        tick: unsafe extern "C" fn(*mut core::ffi::c_void, *mut core::ffi::c_void),
5821        drop: unsafe extern "C" fn(*mut core::ffi::c_void),
5822    ) -> Result<(), ()> {
5823        self.component_slots
5824            .push(ComponentSlot { state, tick, drop })
5825            .map_err(|_| ())
5826    }
5827
5828    /// Phase 258 (Track 2, 2a) — current enrolled component-slot count.
5829    /// Diagnostic / test surface.
5830    pub fn component_slot_count(&self) -> usize {
5831        self.component_slots.len()
5832    }
5833
5834    /// issue #140 — the enrolled components' opaque `state` pointers, in enroll
5835    /// order. Each is the *leaked* `Arc<ComponentCell>` `enroll_component` was
5836    /// handed (see `ComponentSlot::state`); the `nros` layer re-borrows them
5837    /// to fold per-component dispatch counters into
5838    /// `observed_callback_counts` — install-seam components
5839    /// (`register_node_borrowed`) live ONLY here, not in
5840    /// `ExecutorNodeRuntime::components`, so the hosted-spin counts read zero
5841    /// without this surface.
5842    pub fn enrolled_component_states(&self) -> impl Iterator<Item = *mut core::ffi::c_void> + '_ {
5843        self.component_slots.iter().map(|slot| slot.state)
5844    }
5845
5846    /// Phase 216 final dispatch hook — stable entry point the
5847    /// framework's dispatch task (RTIC `__nros_run` /
5848    /// Embassy `__nros_run_task`) calls for each `SignaledCallback`
5849    /// envelope it dequeues from the board-side SPSC / Embassy
5850    /// channel.
5851    ///
5852    /// ## Signature shape
5853    ///
5854    /// `nros-node` sits below `nros` in the dep graph, so the typed
5855    /// `nros::CallbackId<'_>` / `nros::CallbackCtx<'_>` types
5856    /// referenced in the Phase 216 design notes cannot appear in the
5857    /// signature here. The macro emit translates the dequeued
5858    /// envelope to the layer-clean `(cb_id: &str, ctx: *mut c_void)`
5859    /// pair before calling this method; the per-Node `on_callback`
5860    /// trampoline ABI (Phase 216.A.5,
5861    /// `__nros_node_<pkg>_on_callback(state, cb_id_ptr, cb_id_len,
5862    /// ctx)`) uses the same untyped shape on the other side of the
5863    /// fence, so the round-trip stays type-consistent.
5864    ///
5865    /// ## Body — linear scan of the dispatch registry
5866    ///
5867    /// Each registered `DispatchSlot` holds an
5868    /// `__nros_node_<pkg>_on_callback` fn pointer + the owning Node's
5869    /// `state` blob. The macro-emitted trampoline body
5870    /// `match`es on `CallbackId` tags the Node declared and is a
5871    /// no-op for non-matching `cb_id`s — at most one Node per
5872    /// `cb_id` actually acts, the rest are cheap string-compare
5873    /// no-ops. This mirrors the strategy
5874    /// `ExecutorNodeRuntime::dispatch_callback` uses in
5875    /// `packages/api/nros/src/node_runtime.rs:470`.
5876    ///
5877    /// ## What's NOT auto-wired today
5878    ///
5879    /// The `nros::node!()` macro doesn't yet emit a
5880    /// `register_dispatch(executor)` wrapper that pushes the per-pkg
5881    /// `(state, on_callback)` into this registry. Until that wiring
5882    /// lands (Phase 216 follow-up — see commit msg), downstream
5883    /// consumers (board's `init_hardware`, or the codegen-emitted
5884    /// `run_plan`) must call
5885    /// [`Executor::register_dispatch_slot`] explicitly with the
5886    /// `__nros_node_<pkg>_on_callback` symbol + a `state` blob from
5887    /// the macro-emitted `i()`.
5888    //
5889    // `ctx` is an opaque FFI cookie forwarded verbatim to each slot's
5890    // `on_callback`; this fn never dereferences it (the registered callback
5891    // does, under the `register_dispatch_slot` safety contract), so it is sound
5892    // to call from safe code.
5893    #[allow(clippy::not_unsafe_ptr_arg_deref)]
5894    pub fn dispatch_callback(&mut self, cb_id: &str, ctx: *mut core::ffi::c_void) {
5895        let cb_id_ptr = cb_id.as_ptr();
5896        let cb_id_len = cb_id.len();
5897        // Snapshot pointer + length to avoid an outstanding borrow
5898        // across the unsafe fn calls below; each `DispatchSlot` is
5899        // `Copy`, so iterating by value sidesteps any aliasing
5900        // worry the borrow checker would flag if a slot's
5901        // `on_callback` re-entered the executor.
5902        for slot in self.dispatch_slots.iter().copied() {
5903            // SAFETY: caller of `register_dispatch_slot` guaranteed
5904            // `state` outlives the executor + `on_callback` matches
5905            // the per-Node `__nros_node_<pkg>_on_callback` ABI;
5906            // `cb_id_ptr`/`cb_id_len` describe the live `&str` the
5907            // caller passed in.
5908            unsafe {
5909                (slot.on_callback)(slot.state, cb_id_ptr, cb_id_len, ctx);
5910            }
5911        }
5912    }
5913
5914    /// Get a reference to the underlying session.
5915    pub fn session(&self) -> &session::ConcreteSession {
5916        &self.session
5917    }
5918
5919    /// Get a mutable reference to the underlying session.
5920    pub fn session_mut(&mut self) -> &mut session::ConcreteSession {
5921        &mut self.session
5922    }
5923
5924    /// Phase 124.F.3 — session-level connectivity probe. Wire-level
5925    /// round-trip "is the peer / agent / router still reachable?"
5926    /// — cheaper than the service-availability probe (no discovery
5927    /// state required).
5928    ///
5929    /// Returns `Ok(())` on reply within `timeout_ms`,
5930    /// `Err(NodeError::Transport(Timeout))` on no reply,
5931    /// `Err(NodeError::Transport(Unsupported))` when the active
5932    /// backend can't probe.
5933    ///
5934    /// Mirrors micro-ROS's `rmw_uros_ping_agent`. Useful for
5935    /// reconnect-on-link-loss patterns: bare-metal code can call
5936    /// `ping(100)` periodically and tear down / re-open the session
5937    /// on timeout.
5938    pub fn ping(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
5939        use nros_rmw::Session;
5940        self.session
5941            .ping_session(timeout_ms)
5942            .map_err(NodeError::Transport)
5943    }
5944
5945    /// phase-381 W4 — every node on the graph, with its namespace.
5946    ///
5947    /// `visit(name, namespace, enclave)` is called once per node and returns
5948    /// `false` to stop early. A VISITOR rather than a returned collection
5949    /// because there is no allocator at this layer and the graph has no bound
5950    /// the caller can know; peak extra memory is one entry. Every string is
5951    /// BORROWED for the duration of the call.
5952    ///
5953    /// `enclave` is `None` where the backend does not track one, which is what
5954    /// lets one call answer both `rmw_get_node_names` and
5955    /// `rmw_get_node_names_with_enclaves`.
5956    ///
5957    /// **This reports what has already been DISCOVERED, and never blocks.** The
5958    /// first call after startup legitimately sees a partial graph — the backend
5959    /// keeps a standing query fed by `spin`, so the view fills in over
5960    /// successive calls. Code that waits for a peer should poll, not call once
5961    /// and conclude. An empty result is "nobody seen yet", never "nobody
5962    /// exists".
5963    ///
5964    /// `Err(Transport(Unsupported))` from a backend with no graph — distinct
5965    /// from an empty graph, deliberately.
5966    pub fn get_node_names(
5967        &mut self,
5968        visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
5969    ) -> Result<(), NodeError> {
5970        use nros_rmw::Session;
5971        self.session
5972            .get_node_names(visit)
5973            .map_err(NodeError::Transport)
5974    }
5975
5976    /// phase-381 W4 — every topic on the graph, with the types on it.
5977    ///
5978    /// `visit(topic_name, types)` is called once per distinct TOPIC — a topic
5979    /// carrying two types is one call with two entries, not two calls. `types`
5980    /// may legitimately be empty on a partially discovered graph: reporting the
5981    /// name without a type beats dropping it.
5982    ///
5983    /// Same discovery caveat as [`Self::get_node_names`].
5984    pub fn get_topic_names_and_types(
5985        &mut self,
5986        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
5987    ) -> Result<(), NodeError> {
5988        use nros_rmw::Session;
5989        self.session
5990            .get_topic_names_and_types(visit)
5991            .map_err(NodeError::Transport)
5992    }
5993
5994    /// phase-381 W4 — every service on the graph, with its types.
5995    /// As [`Self::get_topic_names_and_types`], over servers and clients.
5996    pub fn get_service_names_and_types(
5997        &mut self,
5998        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
5999    ) -> Result<(), NodeError> {
6000        use nros_rmw::Session;
6001        self.session
6002            .get_service_names_and_types(visit)
6003            .map_err(NodeError::Transport)
6004    }
6005
6006    /// phase-381 W4 — how many publishers are visible on `topic_name`.
6007    ///
6008    /// `topic_name` is a ROS name (`"/chatter"`). A count reflects what has
6009    /// been DISCOVERED, so it can be low right after startup and is never a
6010    /// proof of absence — see [`Self::get_node_names`].
6011    pub fn count_publishers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
6012        use nros_rmw::Session;
6013        self.session
6014            .count_publishers(topic_name)
6015            .map_err(NodeError::Transport)
6016    }
6017
6018    /// phase-381 W4 — how many subscribers are visible on `topic_name`.
6019    /// See [`Self::count_publishers`] for the caveats.
6020    pub fn count_subscribers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
6021        use nros_rmw::Session;
6022        self.session
6023            .count_subscribers(topic_name)
6024            .map_err(NodeError::Transport)
6025    }
6026
6027    /// phase-381 W4 — what one named node PUBLISHES, with the types.
6028    ///
6029    /// `visit(topic_name, types)` per distinct topic. A node the graph has not
6030    /// discovered yields no visits, which is not an error — see
6031    /// [`Self::get_node_names`] for why an empty answer means "not seen yet".
6032    pub fn get_publisher_names_and_types_by_node(
6033        &mut self,
6034        node_name: &str,
6035        node_namespace: &str,
6036        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6037    ) -> Result<(), NodeError> {
6038        use nros_rmw::{GraphEntityKind, Session};
6039        self.session
6040            .get_names_and_types_by_node(
6041                GraphEntityKind::Publisher,
6042                node_name,
6043                node_namespace,
6044                visit,
6045            )
6046            .map_err(NodeError::Transport)
6047    }
6048
6049    /// phase-381 W4 — what one named node SUBSCRIBES to, with the types.
6050    ///
6051    /// **`subscription`, not `subscriber`** — this is rclrs's spelling
6052    /// (`get_subscription_names_and_types_by_node`), and the Rust surface takes
6053    /// its vocabulary from rclrs so a user porting Rust ROS 2 code types what
6054    /// they already know. The C surface says `subscriber` because rcl does, and
6055    /// the vtable slot says `subscriber` because upstream rmw does. Three
6056    /// layers, three upstreams, one word each — not drift. Issue 0788 owns the
6057    /// wider verb sweep.
6058    pub fn get_subscription_names_and_types_by_node(
6059        &mut self,
6060        node_name: &str,
6061        node_namespace: &str,
6062        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6063    ) -> Result<(), NodeError> {
6064        use nros_rmw::{GraphEntityKind, Session};
6065        self.session
6066            .get_names_and_types_by_node(
6067                GraphEntityKind::Subscriber,
6068                node_name,
6069                node_namespace,
6070                visit,
6071            )
6072            .map_err(NodeError::Transport)
6073    }
6074
6075    /// phase-381 W4 — what services one named node SERVES, with the types.
6076    pub fn get_service_names_and_types_by_node(
6077        &mut self,
6078        node_name: &str,
6079        node_namespace: &str,
6080        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6081    ) -> Result<(), NodeError> {
6082        use nros_rmw::{GraphEntityKind, Session};
6083        self.session
6084            .get_names_and_types_by_node(GraphEntityKind::Service, node_name, node_namespace, visit)
6085            .map_err(NodeError::Transport)
6086    }
6087
6088    /// phase-381 W4 — what services one named node CALLS, with the types.
6089    pub fn get_client_names_and_types_by_node(
6090        &mut self,
6091        node_name: &str,
6092        node_namespace: &str,
6093        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6094    ) -> Result<(), NodeError> {
6095        use nros_rmw::{GraphEntityKind, Session};
6096        self.session
6097            .get_names_and_types_by_node(GraphEntityKind::Client, node_name, node_namespace, visit)
6098            .map_err(NodeError::Transport)
6099    }
6100
6101    /// phase-381 W4 — the publishers on `topic_name`, one visit each.
6102    ///
6103    /// Each `GraphEndpointInfo` BORROWS its strings for the duration of the
6104    /// visit; copy anything kept.
6105    ///
6106    /// It carries no QoS. The granted profile is what would answer "why is
6107    /// nothing arriving", and no backend can read one back yet — reporting the
6108    /// remote's DECLARED profile instead would be a confident wrong answer, so
6109    /// the field is absent rather than misleading.
6110    pub fn get_publishers_info_by_topic(
6111        &mut self,
6112        topic_name: &str,
6113        visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
6114    ) -> Result<(), NodeError> {
6115        use nros_rmw::Session;
6116        self.session
6117            .get_endpoint_info_by_topic(true, topic_name, visit)
6118            .map_err(NodeError::Transport)
6119    }
6120
6121    /// phase-381 W4 — the subscriptions on `topic_name`, one visit each.
6122    /// See [`Self::get_publishers_info_by_topic`].
6123    pub fn get_subscriptions_info_by_topic(
6124        &mut self,
6125        topic_name: &str,
6126        visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
6127    ) -> Result<(), NodeError> {
6128        use nros_rmw::Session;
6129        self.session
6130            .get_endpoint_info_by_topic(false, topic_name, visit)
6131            .map_err(NodeError::Transport)
6132    }
6133
6134    /// phase-444 — block until at least `count` publishers are visible on
6135    /// `topic_name`, or `timeout` elapses. Upstream's `rcl_wait_for_publishers`.
6136    ///
6137    /// Returns `Ok(true)` when the graph showed at least `count` inside the
6138    /// budget, `Ok(false)` on timeout. The startup-ordering primitive one
6139    /// entity kind over from [`EmbeddedServiceClient::wait_for_service`](super::handles::EmbeddedServiceClient::wait_for_service),
6140    /// and built the same way: a spin on a discovery read, so other
6141    /// subscriptions and timers keep making progress while it waits.
6142    ///
6143    /// **This is the one blocking member of the graph family, and it does not
6144    /// weaken the family's contract — it composes it.** The query underneath
6145    /// ([`count_publishers`](Self::count_publishers)) still reports only what
6146    /// has been DISCOVERED and still never blocks; what waits is this loop,
6147    /// and it waits by POLLING that query. RFC-0021 is the rule that governs
6148    /// it, and that rule does not forbid blocking: it requires a blocking
6149    /// helper to take an executor and drive it, which is what makes the
6150    /// timeout reliable on a single-threaded transport and what makes the
6151    /// reentrancy visible at the call site.
6152    ///
6153    /// **`Unsupported` is propagated IMMEDIATELY, not waited out** — and this
6154    /// is the one place it deliberately differs from `wait_for_service`, which
6155    /// treats a backend that cannot answer as "keep waiting" and reports
6156    /// `Ok(false)` at the deadline (issue 1087). That is right for a service
6157    /// probe, where not-yet-answerable is a transient. It is wrong here: a
6158    /// backend with no graph (XRCE) can never answer, so spending the budget
6159    /// to return `Ok(false)` would report "no publishers appeared" for a
6160    /// backend that cannot see publishers at all — collapsing "cannot tell
6161    /// you" into "not there", which is exactly the distinction RFC-0036 exists
6162    /// to keep and which every other method in this family promises to keep.
6163    ///
6164    /// `count == 0` is satisfied immediately and without asking the backend —
6165    /// including on a backend with no graph, because "at least zero" is true
6166    /// of every graph and needs no discovery to establish.
6167    pub fn wait_for_publishers(
6168        &mut self,
6169        topic_name: &str,
6170        count: usize,
6171        timeout: core::time::Duration,
6172    ) -> Result<bool, NodeError> {
6173        self.wait_for_endpoints(true, topic_name, count, timeout)
6174    }
6175
6176    /// phase-444 — block until at least `count` subscribers are visible on
6177    /// `topic_name`. Upstream's `rcl_wait_for_subscribers`. See
6178    /// [`wait_for_publishers`](Self::wait_for_publishers) for the contract,
6179    /// and for why `Unsupported` returns rather than waits.
6180    pub fn wait_for_subscribers(
6181        &mut self,
6182        topic_name: &str,
6183        count: usize,
6184        timeout: core::time::Duration,
6185    ) -> Result<bool, NodeError> {
6186        self.wait_for_endpoints(false, topic_name, count, timeout)
6187    }
6188
6189    /// The one loop both `wait_for_*` verbs run — a shared helper rather than
6190    /// a second spelling, because the pair differs only in which counter it
6191    /// polls and a copied loop is how the two drift.
6192    fn wait_for_endpoints(
6193        &mut self,
6194        publishers: bool,
6195        topic_name: &str,
6196        count: usize,
6197        timeout: core::time::Duration,
6198    ) -> Result<bool, NodeError> {
6199        if count == 0 {
6200            return Ok(true);
6201        }
6202        // `?`, not a swallow: a backend that cannot see the graph says so now.
6203        if self.count_endpoints(publishers, topic_name)? >= count {
6204            return Ok(true);
6205        }
6206        let spin_interval =
6207            core::time::Duration::from_millis(super::handles::DEFAULT_SPIN_INTERVAL_MS);
6208        let max_spins =
6209            (timeout.as_millis() as u64 / super::handles::DEFAULT_SPIN_INTERVAL_MS).max(1);
6210        let mut budget = super::handles::WaitBudget::new(max_spins, timeout);
6211        loop {
6212            self.spin_once(budget.next_spin_interval(spin_interval));
6213            if self.count_endpoints(publishers, topic_name)? >= count {
6214                return Ok(true);
6215            }
6216            if !budget.tick() {
6217                return Ok(false);
6218            }
6219        }
6220    }
6221
6222    /// Which counter a `wait_for_*` polls. Keeps the boolean's meaning in ONE
6223    /// place instead of at both call sites.
6224    fn count_endpoints(&mut self, publishers: bool, topic_name: &str) -> Result<usize, NodeError> {
6225        if publishers {
6226            self.count_publishers(topic_name)
6227        } else {
6228            self.count_subscribers(topic_name)
6229        }
6230    }
6231
6232    /// Get a mutable reference to an action client core in the arena by entry index.
6233    ///
6234    /// # Safety
6235    /// The caller must ensure that `entry_index` refers to an `ActionClientRawArenaEntry`.
6236    pub unsafe fn action_client_core_mut(
6237        &mut self,
6238        entry_index: usize,
6239    ) -> Option<&mut super::action_core::ActionClientCore> {
6240        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6241        if !matches!(meta.kind, EntryKind::ActionClient) {
6242            return None;
6243        }
6244        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
6245        unsafe {
6246            let entry_ptr = arena_ptr.add(meta.offset)
6247                as *mut super::arena::ActionClientRawArenaEntry<
6248                    { crate::config::DEFAULT_RX_BUF_SIZE },
6249                    { crate::config::DEFAULT_RX_BUF_SIZE },
6250                    { crate::config::DEFAULT_RX_BUF_SIZE },
6251                >;
6252            Some(&mut (*entry_ptr).core)
6253        }
6254    }
6255
6256    /// Get a mutable reference to a service-client arena entry (Phase 82).
6257    ///
6258    /// Returns `None` if `entry_index` doesn't refer to a service client
6259    /// entry. The default reply buffer size is assumed because the C API
6260    /// always uses the default — the entry was registered via
6261    /// `register_service_client_raw_sized::<DEFAULT_RX_BUF_SIZE>`.
6262    ///
6263    /// # Safety
6264    /// `entry_index` must refer to a `ServiceClientRawArenaEntry`.
6265    pub unsafe fn service_client_entry_mut(
6266        &mut self,
6267        entry_index: usize,
6268    ) -> Option<&mut super::arena::ServiceClientRawArenaEntry<{ crate::config::DEFAULT_RX_BUF_SIZE }>>
6269    {
6270        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6271        if !matches!(meta.kind, EntryKind::ServiceClient) {
6272            return None;
6273        }
6274        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
6275        unsafe {
6276            let entry_ptr = arena_ptr.add(meta.offset)
6277                as *mut super::arena::ServiceClientRawArenaEntry<
6278                    { crate::config::DEFAULT_RX_BUF_SIZE },
6279                >;
6280            Some(&mut *entry_ptr)
6281        }
6282    }
6283
6284    // ------------------------------------------------------------------
6285    // issue 1437 / phase-444 — the ARENA half of the granted-QoS read-back.
6286    //
6287    // An entity registered with a callback lives in the executor arena, and
6288    // the C / C++ handle the user holds is then bookkeeping: its inline
6289    // storage is untouched and the only identity it carries is the entry
6290    // index. So an accessor that reads the user's own storage answers for
6291    // the POLL road and returns an absence on the CALLBACK road — which is
6292    // the road a ported `rclcpp` node takes, i.e. the common one. These
6293    // three close that reach.
6294    //
6295    // Each reads the entry's HANDLE and nothing else, which is why one
6296    // function per kind serves every entry SHAPE of that kind: every arena
6297    // entry is `#[repr(C)]` with its handle as the first field (12
6298    // subscription shapes, 2 service-server, 2 service-client), so
6299    // `arena_base + offset` IS the handle pointer whatever buffer sizes and
6300    // callback types the shape was monomorphised with.
6301    // `service_client_entry_mut` above has to name ONE shape because it
6302    // reaches past the handle; these do not, and that is the whole
6303    // difference. `arena::tests::every_arena_entry_begins_with_its_handle`
6304    // pins the invariant.
6305
6306    /// The `RmwSubscriber` of an arena-registered subscription, by entry index.
6307    ///
6308    /// `None` when `entry_index` names no entry, or names one that is not a
6309    /// subscription.
6310    ///
6311    /// # Safety
6312    /// `entry_index` must be an index this executor handed out. The borrow is
6313    /// valid only while the arena is not re-entered.
6314    pub unsafe fn subscription_handle(
6315        &self,
6316        entry_index: usize,
6317    ) -> Option<&session::RmwSubscriber> {
6318        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6319        if !matches!(meta.kind, EntryKind::Subscription) {
6320            return None;
6321        }
6322        let arena_ptr = self.arena.as_ptr() as *const u8;
6323        // SAFETY: `meta.offset` is inside the arena by construction, and every
6324        // subscription entry shape begins with its `RmwSubscriber`.
6325        Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwSubscriber) })
6326    }
6327
6328    /// The `RmwServiceServer` of an arena-registered service, by entry index.
6329    ///
6330    /// # Safety
6331    /// See [`Executor::subscription_handle`].
6332    pub unsafe fn service_server_handle(
6333        &self,
6334        entry_index: usize,
6335    ) -> Option<&session::RmwServiceServer> {
6336        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6337        if !matches!(meta.kind, EntryKind::Service) {
6338            return None;
6339        }
6340        let arena_ptr = self.arena.as_ptr() as *const u8;
6341        // SAFETY: as above; every service-server entry shape begins with its
6342        // `RmwServiceServer`.
6343        Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwServiceServer) })
6344    }
6345
6346    /// The `RmwServiceClient` of an arena-registered client, by entry index.
6347    ///
6348    /// # Safety
6349    /// See [`Executor::subscription_handle`].
6350    pub unsafe fn service_client_handle(
6351        &self,
6352        entry_index: usize,
6353    ) -> Option<&session::RmwServiceClient> {
6354        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6355        if !matches!(meta.kind, EntryKind::ServiceClient) {
6356            return None;
6357        }
6358        let arena_ptr = self.arena.as_ptr() as *const u8;
6359        // SAFETY: as above; both service-client entry shapes begin with their
6360        // `RmwServiceClient` — the typed one through `ServiceClientSendHeader`,
6361        // itself `#[repr(C)]` and handle-first.
6362        Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwServiceClient) })
6363    }
6364
6365    /// Set the executor-level trigger condition.
6366    ///
6367    /// Controls which handles must be ready before `spin_once` dispatches
6368    /// callbacks. Defaults to [`Trigger::AnyReady`](crate::Trigger).
6369    pub fn set_trigger(&mut self, trigger: Trigger) {
6370        self.trigger = trigger;
6371    }
6372
6373    /// Set the executor data communication semantics.
6374    ///
6375    /// Choose between `Direct` (process in place) and `LET`
6376    /// (snapshot-then-process) semantics. See [`ExecutorSemantics`].
6377    pub fn set_semantics(&mut self, semantics: ExecutorSemantics) {
6378        self.semantics = semantics;
6379    }
6380
6381    /// Set the invocation mode for a specific handle.
6382    ///
6383    /// Controls whether the callback fires on every spin
6384    /// ([`Always`](InvocationMode::Always)) or only when new data
6385    /// arrives ([`OnNewData`](InvocationMode::OnNewData), the default).
6386    pub fn set_invocation(&mut self, id: HandleId, mode: InvocationMode) {
6387        let slot = self.live_slot(id);
6388        if let Some(Some(meta)) = self.entries.get_mut(slot) {
6389            meta.invocation = mode;
6390        }
6391    }
6392
6393    // ========================================================================
6394    // Arena-based callback registration
6395    // ========================================================================
6396
6397    /// Arena bytes claimed by registered entities so far.
6398    ///
6399    /// EXACT, not a worst case: the arena is a bump allocator and every
6400    /// `arena_alloc` charges `size_of::<T>()`, so nothing is reserved per slot.
6401    /// `ARENA_SIZE` itself is derived the other way — every slot budgeted at
6402    /// the ActionClient worst case — which is issue 0900, and this accessor is
6403    /// how an image can find out what it actually needs.
6404    pub fn arena_used(&self) -> usize {
6405        self.arena_used
6406    }
6407
6408    /// Total arena bytes this executor was given.
6409    ///
6410    /// The arena is a borrowed slice; where its storage lives is the caller's
6411    /// choice, not a property of this type. Three answers are real — a `.bss`
6412    /// static (the C/C++ entries, and the `alloc` constructors since phase-392
6413    /// W6), the heap (those constructors' fallback), and a stack-declared
6414    /// `rclc_executor_t`. See `super::backing` and `report_arena_headroom`.
6415    pub fn arena_capacity(&self) -> usize {
6416        self.arena.len()
6417    }
6418
6419    /// Bump-allocate space for `T` in the arena. Returns the byte offset.
6420    pub(crate) fn arena_alloc<T>(&mut self) -> Result<usize, NodeError> {
6421        // Issue 1667 — every region is grain-aligned and at least one free-list
6422        // node long, so it can be listed when it is released.
6423        let align = core::mem::align_of::<T>().max(ARENA_GRAIN);
6424        let size = Self::arena_region_len(core::mem::size_of::<T>());
6425        if let Some((offset, granted)) = self.arena_take_freed(size, align) {
6426            crate::boot_report::note_alloc(size, self.arena_used);
6427            self.note_entry_region(offset, granted);
6428            return Ok(offset);
6429        }
6430        let aligned_offset = (self.arena_used + align - 1) & !(align - 1);
6431        let new_used = aligned_offset + size;
6432        if new_used > self.arena.len() {
6433            // issue 0900 — `BufferTooSmall` is returned by a dozen other paths,
6434            // so on a target where a return code is all you get, arena
6435            // exhaustion is indistinguishable from a message that did not fit.
6436            return Err(self.arena_exhausted(
6437                core::any::type_name::<T>(),
6438                size,
6439                new_used - self.arena.len(),
6440            ));
6441        }
6442        self.arena_used = new_used;
6443        crate::boot_report::note_alloc(size, new_used);
6444        self.note_entry_region(aligned_offset, size);
6445        Ok(aligned_offset)
6446    }
6447
6448    /// Issue 1631 — remember the region an ENTRY allocation handed out, for
6449    /// `emplace_entry` to record. Only the two entry allocators call this; a
6450    /// callback capture rides inside the entry's trailing region (phase-476
6451    /// W0), so it is part of what they record.
6452    fn note_entry_region(&mut self, offset: usize, len: usize) {
6453        self.pending_region = Some(FreedRegion { offset, len });
6454    }
6455
6456    /// Issue 1667 — the length a region of `len` requested bytes occupies:
6457    /// rounded up to the grain, and never shorter than a free-list node.
6458    const fn arena_region_len(len: usize) -> usize {
6459        let len = len.next_multiple_of(ARENA_GRAIN);
6460        if len < ARENA_MIN_REGION {
6461            ARENA_MIN_REGION
6462        } else {
6463            len
6464        }
6465    }
6466
6467    /// Bytes a callback CAPTURE takes in an entry's trailing region: its length
6468    /// rounded up to the arena's `u64` grain. 0 for no capture.
6469    ///
6470    /// phase-476 W0 — a capture used to be a SEPARATE arena allocation
6471    /// (`stow_capture` over `arena_alloc_bytes`), which `release_entry` never
6472    /// gave back: it frees exactly the region `emplace_entry` recorded, and that
6473    /// was the entry's. Placing the capture at the END of the entry's own
6474    /// trailing region puts it inside the recorded region, so releasing the
6475    /// entry releases its capture.
6476    pub(crate) fn capture_trailing_len(capture: Option<&[u8]>) -> usize {
6477        match capture {
6478            Some(b) if !b.is_empty() => b.len().next_multiple_of(core::mem::align_of::<u64>()),
6479            _ => 0,
6480        }
6481    }
6482
6483    /// Copy a caller's callback CAPTURE to arena offset `at` and return the
6484    /// context pointer the entry should dispatch with — phase-456 W1, made a
6485    /// runtime length by W8, made part of the entry's region by phase-476 W0.
6486    ///
6487    /// `context` is one pointer. That carries a C++ `[this]` capture and not a
6488    /// `[this, state]` (two) or a `[obj, method]` (three, because a
6489    /// pointer-to-member-function is two words on the Itanium ABI) — the
6490    /// distribution phase-442 W0 measured over this tree and the porting corpus.
6491    /// So the capture lives in the arena beside the registration that uses it:
6492    /// arena bytes never move once allocated, and the returned pointer is into
6493    /// them rather than into anything of the caller's, so the C++ side keeps
6494    /// nothing.
6495    ///
6496    /// `at` must start [`capture_trailing_len`](Self::capture_trailing_len)
6497    /// bytes the caller's entry allocation reserved for it. `None` and an empty
6498    /// slice both mean "no capture", and both give the caller's own `context`
6499    /// straight back, which is every pre-W1 caller.
6500    pub(crate) fn place_capture(
6501        &mut self,
6502        at: usize,
6503        capture: Option<&[u8]>,
6504        context: *mut core::ffi::c_void,
6505    ) -> *mut core::ffi::c_void {
6506        let bytes = match capture {
6507            Some(b) if !b.is_empty() => b,
6508            _ => return context,
6509        };
6510        debug_assert!(at + bytes.len() <= self.arena.len());
6511        // SAFETY: the caller's entry allocation reserved
6512        // `capture_trailing_len(capture)` bytes at `at` for exactly this, and
6513        // `bytes` is a caller slice that cannot alias the arena (the arena is
6514        // borrowed mutably by `self` for the whole call).
6515        unsafe {
6516            let dst = (self.arena.as_mut_ptr() as *mut u8).add(at);
6517            core::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len());
6518            dst as *mut core::ffi::c_void
6519        }
6520    }
6521
6522    /// Bump-allocate space for `T` plus `trailing_bytes` extra bytes.
6523    ///
6524    /// Returns `(entry_offset, trailing_offset)`. The trailing region starts
6525    /// immediately after `T` (aligned to 8 bytes).
6526    pub(crate) fn arena_alloc_with_trailing<T>(
6527        &mut self,
6528        trailing_bytes: usize,
6529    ) -> Result<(usize, usize), NodeError> {
6530        let align = core::mem::align_of::<T>().max(ARENA_GRAIN);
6531        let entry_size = core::mem::size_of::<T>();
6532        // Issue 1496 — a released region that holds the entry AND its trailing
6533        // region is reused whole. The trailing region keeps its rule: it starts
6534        // on a `u64` boundary after the entry, so the request is sized by that.
6535        let u64_align = core::mem::align_of::<u64>();
6536        let whole = Self::arena_region_len(entry_size.next_multiple_of(u64_align) + trailing_bytes);
6537        if let Some((entry_offset, granted)) = self.arena_take_freed(whole, align) {
6538            let trailing_offset = (entry_offset + entry_size).next_multiple_of(u64_align);
6539            crate::boot_report::note_alloc(whole, self.arena_used);
6540            self.note_entry_region(entry_offset, granted);
6541            return Ok((entry_offset, trailing_offset));
6542        }
6543        let entry_offset = self.arena_used.next_multiple_of(align);
6544        // Trailing region starts on an 8-byte (u64) boundary after the entry.
6545        let trailing_offset =
6546            (entry_offset + entry_size).next_multiple_of(core::mem::align_of::<u64>());
6547        // Issue 1667 — the region ends on the grain and is at least a node.
6548        let new_used = entry_offset + whole;
6549        debug_assert!(new_used >= trailing_offset + trailing_bytes);
6550        if new_used > self.arena.len() {
6551            // This path reported NOTHING until phase-412's self-report went in,
6552            // while its sibling `arena_alloc` has named the knob since issue
6553            // 0900. Half of arena exhaustion was therefore silent -- and it is
6554            // the half carrying buffered subscriptions and action entries,
6555            // which is what an island image actually allocates.
6556            return Err(self.arena_exhausted(
6557                core::any::type_name::<T>(),
6558                new_used - entry_offset,
6559                new_used - self.arena.len(),
6560            ));
6561        }
6562        self.arena_used = new_used;
6563        crate::boot_report::note_alloc(new_used - entry_offset, new_used);
6564        self.note_entry_region(entry_offset, new_used - entry_offset);
6565        Ok((entry_offset, trailing_offset))
6566    }
6567
6568    /// The ONE exhaustion path every arena allocator goes through — issues
6569    /// 0900, 1036, 1496.
6570    ///
6571    /// It names the knob (`report_arena_exhausted`), writes the first failure
6572    /// and its shortfall to the boot record (the only channel a console-less
6573    /// board has), says whether the failure is a SHAPE problem rather than a
6574    /// size one (released regions that together hold the request, none of
6575    /// which alone does), and — where the image asked for it — halts through
6576    /// the platform's fatal hook, so the record survives instead of the board
6577    /// running on with an entity missing. Three spellings of this existed, and
6578    /// the `_with_trailing` one reported nothing until phase-412.
6579    fn arena_exhausted(&self, entity: &'static str, request: usize, shortfall: usize) -> NodeError {
6580        super::arena::report_arena_exhausted(
6581            entity,
6582            shortfall,
6583            self.arena_used,
6584            self.arena.len(),
6585            self.arena_released(),
6586            request,
6587        );
6588        crate::boot_report::note_alloc_failed(request, shortfall);
6589        super::arena::arena_exhaustion_is_fatal();
6590        NodeError::BufferTooSmall
6591    }
6592
6593    /// Issue 1667 — read the free-list node at `offset`: `(next, len)`.
6594    fn arena_hole(&self, offset: usize) -> (usize, usize) {
6595        // SAFETY: `offset` is a listed free region: in bounds, grain-aligned and
6596        // at least `ARENA_MIN_REGION` long, written by `arena_set_hole`.
6597        unsafe {
6598            let p = (self.arena.as_ptr() as *const u8).add(offset) as *const usize;
6599            (p.read_unaligned(), p.add(1).read_unaligned())
6600        }
6601    }
6602
6603    /// Issue 1667 — write the free-list node at `offset`.
6604    fn arena_set_hole(&mut self, offset: usize, next: usize, len: usize) {
6605        debug_assert!(len >= ARENA_MIN_REGION && offset + len <= self.arena.len());
6606        // SAFETY: as `arena_hole`; the region is free, so nothing else reads it.
6607        unsafe {
6608            let p = (self.arena.as_mut_ptr() as *mut u8).add(offset) as *mut usize;
6609            p.write_unaligned(next);
6610            p.add(1).write_unaligned(len);
6611        }
6612    }
6613
6614    /// Point `prev`'s node (or the head, for `ARENA_FREE_NONE`) at `next`.
6615    fn arena_link(&mut self, prev: usize, next: usize) {
6616        if prev == ARENA_FREE_NONE {
6617            self.arena_free_head = next;
6618        } else {
6619            let (_, len) = self.arena_hole(prev);
6620            self.arena_set_hole(prev, next, len);
6621        }
6622    }
6623
6624    /// Issue 1496 / 1667 — first-fit over the free list. Returns the offset and
6625    /// the length GRANTED, which is the request plus any tail too short to list
6626    /// on its own: that tail belongs to the allocation, so its release gives it
6627    /// back. A hole whose alignment slack would be too short to list is skipped
6628    /// rather than split, for the same reason — no byte is ever orphaned.
6629    fn arena_take_freed(&mut self, size: usize, align: usize) -> Option<(usize, usize)> {
6630        let mut prev = ARENA_FREE_NONE;
6631        let mut cur = self.arena_free_head;
6632        while cur != ARENA_FREE_NONE {
6633            let (next, len) = self.arena_hole(cur);
6634            let start = cur.next_multiple_of(align);
6635            let head = start - cur;
6636            let fits = start + size <= cur + len;
6637            if fits && (head == 0 || head >= ARENA_MIN_REGION) {
6638                let tail = cur + len - (start + size);
6639                let granted = if tail < ARENA_MIN_REGION {
6640                    size + tail
6641                } else {
6642                    size
6643                };
6644                // What stays listed: the head slack, the tail, both, or neither.
6645                let mut after = next;
6646                if tail >= ARENA_MIN_REGION {
6647                    let t = start + size;
6648                    self.arena_set_hole(t, after, tail);
6649                    after = t;
6650                }
6651                if head > 0 {
6652                    self.arena_set_hole(cur, after, head);
6653                } else {
6654                    self.arena_link(prev, after);
6655                }
6656                return Some((start, granted));
6657            }
6658            prev = cur;
6659            cur = next;
6660        }
6661        None
6662    }
6663
6664    /// Issue 1496 / 1667 — give a region back. It is inserted in address order
6665    /// and coalesced with both neighbours, so a loop that frees what it
6666    /// allocated returns the arena to the shape it had; a hole that then ends
6667    /// at `arena_used` is handed back to the bump pointer.
6668    pub(crate) fn arena_release(&mut self, offset: usize, len: usize) {
6669        debug_assert!(len >= ARENA_MIN_REGION && len.is_multiple_of(ARENA_GRAIN));
6670        // `pprev` -> `prev` -> [offset] -> `cur`, in address order.
6671        let mut pprev = ARENA_FREE_NONE;
6672        let mut prev = ARENA_FREE_NONE;
6673        let mut cur = self.arena_free_head;
6674        while cur != ARENA_FREE_NONE && cur < offset {
6675            pprev = prev;
6676            prev = cur;
6677            cur = self.arena_hole(cur).0;
6678        }
6679        let (mut start, mut size) = (offset, len);
6680        let mut next = cur;
6681        if next != ARENA_FREE_NONE && start + size == next {
6682            let (after, next_len) = self.arena_hole(next);
6683            size += next_len;
6684            next = after;
6685        }
6686        if prev != ARENA_FREE_NONE {
6687            let (_, prev_len) = self.arena_hole(prev);
6688            if prev + prev_len == start {
6689                start = prev;
6690                size += prev_len;
6691                // `prev` is absorbed: link from ITS predecessor.
6692                prev = pprev;
6693            }
6694        }
6695        if start + size == self.arena_used {
6696            // The top of the arena: give it back to the bump pointer.
6697            self.arena_used = start;
6698            self.arena_link(prev, next);
6699            return;
6700        }
6701        self.arena_set_hole(start, next, size);
6702        self.arena_link(prev, start);
6703    }
6704
6705    /// Issue 1496 — bytes released back to the arena and not yet reused.
6706    /// Bytes handed back to the bump pointer are not counted: `arena_used`
6707    /// already went down by them.
6708    pub fn arena_released(&self) -> usize {
6709        let mut total = 0;
6710        let mut cur = self.arena_free_head;
6711        while cur != ARENA_FREE_NONE {
6712            let (next, len) = self.arena_hole(cur);
6713            total += len;
6714            cur = next;
6715        }
6716        total
6717    }
6718
6719    /// Issue 1667 — how many separate holes the free list holds. For tests and
6720    /// diagnostics.
6721    pub fn arena_holes(&self) -> usize {
6722        let mut count = 0;
6723        let mut cur = self.arena_free_head;
6724        while cur != ARENA_FREE_NONE {
6725            count += 1;
6726            cur = self.arena_hole(cur).0;
6727        }
6728        count
6729    }
6730
6731    /// Issue 1496 — RELEASE one callback entry: drop the entry in place (its
6732    /// RMW handles' `Drop`s destroy the backend entities, so nothing stays
6733    /// advertised), free its callback slot, and give its arena bytes back for
6734    /// reuse. `false`, touching nothing, when `index` is not a live entry of
6735    /// `kind`.
6736    ///
6737    /// Issue 1631 — the bytes released are the ones `emplace_entry` RECORDED
6738    /// from the allocator (`CallbackMeta::arena_len`), entry and trailing
6739    /// region together. 1496's form took the size from the caller, which had
6740    /// to restate the concrete entry type — impossible for a buffered
6741    /// subscription (its trailing region is sized at runtime) or a timer (its
6742    /// closure type is the registering crate's).
6743    ///
6744    /// # Safety
6745    /// No handle naming `index` may be used afterwards: the slot and its bytes
6746    /// go to the next registration.
6747    pub(crate) unsafe fn release_entry(&mut self, index: usize, kind: EntryKind) -> bool {
6748        let live = self
6749            .entries
6750            .get(index)
6751            .and_then(|e| e.as_ref())
6752            .is_some_and(|m| m.kind == kind);
6753        if !live {
6754            return false;
6755        }
6756        // Issue 1667 — the entry's own callback is running (it, or something it
6757        // called, asked for this release). Mark it and let `finish_dispatch` do
6758        // the release when the callback returns. The handle stops resolving
6759        // now, so a second request answers `false` and nothing dispatches it.
6760        //
6761        // Issue 1705 — the same for an entry an OS-priority WORKER holds
6762        // (`WORKER_QUEUED`): its callback may be running on the worker task, or
6763        // its item may still be queued. The worker skips an item whose release
6764        // is pending, and `reap_deferred_releases` completes the release once
6765        // the worker has let go. This is also the whole of what a release
6766        // requested FROM the worker thread does: one atomic flag, nothing the
6767        // spin thread is touching.
6768        if let Some(tag) = self.slot_tags.get(index)
6769            && tag.flags() & super::types::SlotTag::IN_FLIGHT != 0
6770        {
6771            let was = tag.set(super::types::SlotTag::RELEASE_PENDING);
6772            return was & super::types::SlotTag::RELEASE_PENDING == 0;
6773        }
6774        let Some(meta) = self.entries[index].take() else {
6775            return false;
6776        };
6777        // SAFETY: the entry was written by its registration and not yet
6778        // dropped; `drop_fn` matches its concrete type.
6779        unsafe {
6780            let data_ptr = (self.arena.as_mut_ptr() as *mut u8).add(meta.offset);
6781            (meta.drop_fn)(data_ptr);
6782        }
6783        self.sched_context_bindings[index] = super::sched_context::SchedContextId(0);
6784        if let Some(st) = self.sporadic_states.get_mut(index) {
6785            *st = None;
6786        }
6787        // phase10-B3 -- a released trigger subscription stops starting routes.
6788        if matches!(kind, EntryKind::Subscription) {
6789            super::monitor::route_unbind(self.monitor_table, index);
6790        }
6791        if let Some(len) = meta.arena_len.get() {
6792            self.arena_release(meta.offset, len);
6793        }
6794        if let Some(tag) = self.slot_tags.get_mut(index) {
6795            tag.owner = 0;
6796            tag.reset_flags();
6797        }
6798        true
6799    }
6800
6801    /// Issue 1667 — mark `slot`'s callback as running, so a release it requests
6802    /// is deferred rather than dropping the entry under it.
6803    #[inline]
6804    fn begin_dispatch(slot_tags: &[super::types::SlotTag], slot: usize) {
6805        if let Some(tag) = slot_tags.get(slot) {
6806            tag.set(super::types::SlotTag::IN_DISPATCH);
6807        }
6808    }
6809
6810    /// Issue 1705 — `slot`'s entry is not to be touched by the spin thread
6811    /// this pass: an OS-priority worker holds it (`WORKER_QUEUED` — its item
6812    /// is queued or its callback is running, so even READING its state for
6813    /// readiness races the worker), or its release is pending (it is never
6814    /// dispatched again).
6815    #[inline]
6816    fn slot_held(slot_tags: &[super::types::SlotTag], slot: usize) -> bool {
6817        slot_tags.get(slot).is_some_and(|t| {
6818            t.flags()
6819                & (super::types::SlotTag::WORKER_QUEUED | super::types::SlotTag::RELEASE_PENDING)
6820                != 0
6821        })
6822    }
6823
6824    /// Issue 1667 — `slot`'s callback has returned. Complete a release it
6825    /// requested while running.
6826    fn finish_dispatch(&mut self, slot: usize) {
6827        let Some(tag) = self.slot_tags.get(slot) else {
6828            return;
6829        };
6830        let was = tag.clear(super::types::SlotTag::IN_DISPATCH);
6831        if was & super::types::SlotTag::RELEASE_PENDING != 0 {
6832            self.complete_deferred_release(slot);
6833        }
6834    }
6835
6836    /// Issue 1667 / 1705 — finish a release that was deferred while a dispatch
6837    /// of the entry was in flight, if none is any more. A no-op while one still
6838    /// is (a worker holding the entry): `reap_deferred_releases` retries.
6839    fn complete_deferred_release(&mut self, slot: usize) {
6840        let Some(tag) = self.slot_tags.get(slot) else {
6841            return;
6842        };
6843        let f = tag.flags();
6844        if f & super::types::SlotTag::RELEASE_PENDING == 0
6845            || f & super::types::SlotTag::IN_FLIGHT != 0
6846        {
6847            return;
6848        }
6849        if let Some(kind) = self
6850            .entries
6851            .get(slot)
6852            .and_then(|e| e.as_ref())
6853            .map(|m| m.kind)
6854        {
6855            // SAFETY: the release was requested through a generation-checked
6856            // handle, and `resolve_handle` has refused the slot since; no
6857            // dispatch holds the entry (`IN_FLIGHT` is clear, read Acquire, so
6858            // a worker's writes before it let go are visible).
6859            let _ = unsafe { self.release_entry(slot, kind) };
6860        }
6861    }
6862
6863    /// Issue 1705 — complete every deferred release whose last dispatch has
6864    /// finished. A release deferred behind an OS-priority WORKER cannot be
6865    /// completed by the worker (it may not touch the executor), so the spin
6866    /// thread picks it up here, at the top of every `spin_once`.
6867    pub(crate) fn reap_deferred_releases(&mut self) {
6868        for slot in 0..self.slot_tags.len() {
6869            if self.slot_tags[slot].flags() & super::types::SlotTag::RELEASE_PENDING != 0 {
6870                self.complete_deferred_release(slot);
6871            }
6872        }
6873    }
6874
6875    /// Issue 1631 — release a SUBSCRIPTION entry, whatever registration shape
6876    /// made it (typed, raw, in-place, buffered, with-info). See
6877    /// `release_entry` (crate-private): the entry is dropped, so its
6878    /// subscriber leaves the graph; its slot and arena bytes are reused by the
6879    /// next registration. `false`, touching nothing, when `handle` is not a
6880    /// live subscription.
6881    ///
6882    /// # Safety
6883    /// No copy of `handle` may be used afterwards — the slot is handed to the
6884    /// next registration, which may be of any kind.
6885    pub unsafe fn release_subscription(&mut self, handle: HandleId) -> bool {
6886        match self.resolve_handle(handle) {
6887            Some(slot) => unsafe { self.release_entry(slot, EntryKind::Subscription) },
6888            None => false,
6889        }
6890    }
6891
6892    /// Issue 1631 — release a TIMER entry; the timer's callback (and anything
6893    /// its closure captured) is dropped and never fires again. As
6894    /// [`release_subscription`](Self::release_subscription).
6895    ///
6896    /// # Safety
6897    /// As [`release_subscription`](Self::release_subscription).
6898    pub unsafe fn release_timer(&mut self, handle: HandleId) -> bool {
6899        match self.resolve_handle(handle) {
6900            Some(slot) => unsafe { self.release_entry(slot, EntryKind::Timer) },
6901            None => false,
6902        }
6903    }
6904
6905    /// Issue 1631 — release a SERVICE SERVER entry; the server leaves the
6906    /// graph. As [`release_subscription`](Self::release_subscription).
6907    ///
6908    /// # Safety
6909    /// As [`release_subscription`](Self::release_subscription).
6910    pub unsafe fn release_service(&mut self, handle: HandleId) -> bool {
6911        match self.resolve_handle(handle) {
6912            Some(slot) => unsafe { self.release_entry(slot, EntryKind::Service) },
6913            None => false,
6914        }
6915    }
6916
6917    /// Issue 1631 — release a SERVICE CLIENT entry; the client leaves the
6918    /// graph and any reply it was waiting for is abandoned with it. As
6919    /// [`release_subscription`](Self::release_subscription).
6920    ///
6921    /// # Safety
6922    /// As [`release_subscription`](Self::release_subscription).
6923    pub unsafe fn release_service_client(&mut self, handle: HandleId) -> bool {
6924        match self.resolve_handle(handle) {
6925            Some(slot) => unsafe { self.release_entry(slot, EntryKind::ServiceClient) },
6926            None => false,
6927        }
6928    }
6929
6930    /// Issue 1667 — release a GUARD CONDITION entry: its callback is dropped and
6931    /// `spin_once` stops polling it; the slot and its arena bytes go to the next
6932    /// registration.
6933    ///
6934    /// Unlike the other release verbs this one has a contract a generation
6935    /// cannot discharge. The [`GuardCondition`] trigger handle holds a pointer
6936    /// to the flag INSIDE the entry, and it is `Clone` + `Send`, so a clone on
6937    /// another thread (an ISR, a worker) would write into whatever reuses those
6938    /// bytes. The C++ `GuardCondition` is non-copyable and the one holder of its
6939    /// trigger handle, which is why its destructor can call this.
6940    ///
6941    /// # Safety
6942    /// No [`GuardCondition`] trigger handle for this entry — the one
6943    /// registration returned, or any clone of it — may be used afterwards.
6944    pub unsafe fn release_guard_condition(&mut self, handle: HandleId) -> bool {
6945        match self.resolve_handle(handle) {
6946            Some(slot) => unsafe { self.release_entry(slot, EntryKind::GuardCondition) },
6947            None => false,
6948        }
6949    }
6950
6951    /// Find the next free entry slot index.
6952    pub(crate) fn next_entry_slot(&self) -> Result<usize, NodeError> {
6953        self.entries
6954            .iter()
6955            .position(|e| e.is_none())
6956            // Issue 0095 — the callback-entry table (`NROS_EXECUTOR_MAX_CBS`,
6957            // default 4) is full. Distinct from `BufferTooSmall` so the register
6958            // seam can tell the user to raise the knob.
6959            .ok_or(NodeError::ExecutorFull)
6960    }
6961
6962    /// Install a finished [`CallbackMeta`] into its slot.
6963    ///
6964    /// phase-8 (`docs/design/callback_tracing.rst`) — the ONE choke point
6965    /// every registration site funnels through, so the
6966    /// `nros_callback_register(handle, kind, name)` event is emitted once,
6967    /// here, rather than at the 25 sites that build a `CallbackMeta`. The
6968    /// design's registration half is deliberately EXHAUSTIVE across every
6969    /// `EntryKind` even though the leaf hooks are staged: a callback that was
6970    /// registered but never observed then prints as "registered, not
6971    /// instrumented" instead of being silently absent, so an incomplete hook
6972    /// set announces itself in the output rather than looking like a
6973    /// measurement.
6974    ///
6975    /// It is the assignment — not [`next_entry_slot`](Self::next_entry_slot)
6976    /// — because a slot is claimed BEFORE the fallible work (session lookup,
6977    /// arena allocation, handle creation) that can still return `Err`.
6978    /// Emitting at slot-claim time would announce callbacks that do not
6979    /// exist.
6980    pub(crate) fn emplace_entry(
6981        &mut self,
6982        slot: usize,
6983        mut meta: CallbackMeta,
6984        name: TraceName<'_>,
6985    ) {
6986        // Issue 1631 — record the region the entry's allocation handed out,
6987        // so `release_entry` gives back exactly that. Matched on the OFFSET so
6988        // a stale region (a registration that failed after allocating) is
6989        // never attributed to a different entry.
6990        meta.arena_len = match self.pending_region.take() {
6991            Some(r) if r.offset == meta.offset => super::arena::ArenaLen::new(r.len),
6992            _ => super::arena::ArenaLen::UNRECORDED,
6993        };
6994        trace_register(slot, meta.kind, name);
6995        // phase10-B3 -- a subscription on a route's trigger topic is the
6996        // route's trigger. The table is installed before any node exists.
6997        if let (EntryKind::Subscription, TraceName::Text(topic)) = (meta.kind, &name) {
6998            super::monitor::route_bind(self.monitor_table, topic, slot);
6999        }
7000        // phase-463 W7 — the slot's identity for the host profile, keyed on the
7001        // arena base the dispatch sees (stable for the executor's life).
7002        #[cfg(feature = "profile-mode")]
7003        super::profile::on_register(self.arena.as_ptr() as usize, slot, meta.kind, name);
7004        self.entries[slot] = Some(meta);
7005        // phase-476 W0 — a new occupant gets a new generation, so every handle
7006        // issued for the slot's previous occupant stops resolving. Owner is
7007        // cleared; a registration made for a node sets it after this
7008        // (`set_entry_owner`).
7009        if let Some(tag) = self.slot_tags.get_mut(slot) {
7010            tag.generation = tag.next_generation();
7011            tag.owner = 0;
7012            tag.reset_flags();
7013        }
7014    }
7015
7016    /// phase-476 W0 — the handle for the CURRENT occupant of `slot`. Every
7017    /// registration returns this, after `emplace_entry` has stamped the slot.
7018    pub(crate) fn handle_for(&self, slot: usize) -> HandleId {
7019        let generation = self.slot_tags.get(slot).map_or(0, |t| t.generation);
7020        HandleId::new(slot, generation)
7021    }
7022
7023    /// phase-476 W0 (issue 1667) — the slot `id` names, if and only if `id` was
7024    /// issued for the entry that occupies it NOW. A handle whose entry was
7025    /// released, or whose slot has since been taken by another registration of
7026    /// any kind, answers `None`, never the next occupant.
7027    pub fn resolve_handle(&self, id: HandleId) -> Option<usize> {
7028        let slot = id.slot();
7029        let live = self.entries.get(slot).is_some_and(|e| e.is_some())
7030            && self
7031                .slot_tags
7032                .get(slot)
7033                .is_none_or(|t| t.flags() & super::types::SlotTag::RELEASE_PENDING == 0);
7034        // Generation 0 is `HandleId::for_owned_slot`: a uniquely-owned holder
7035        // naming its own live slot. Every ISSUED handle carries >= 1.
7036        let current = id.generation() == 0
7037            || self
7038                .slot_tags
7039                .get(slot)
7040                .is_some_and(|t| t.generation == id.generation());
7041        (live && current).then_some(slot)
7042    }
7043
7044    /// Issue 1703 — the slot behind a by-index accessor's `entry_index`
7045    /// argument, which callers hand in in TWO spellings: a bare slot (nros-c's
7046    /// stored `arena_entry_index`, `node_runtime`'s resolved slot, an action's
7047    /// owned slot) or the PACKED handle an FFI issued (`HandleId::to_raw`,
7048    /// what nros-cpp's `handle_id_` and nros-c's `subscription.handle_id`
7049    /// hold since phase-476 W0). A bare slot has generation 0, so both decode
7050    /// through [`HandleId::from_raw`] and resolve here: the slot for a live
7051    /// entry, generation-checked when one was issued, `None` for a stale or
7052    /// foreign handle.
7053    ///
7054    /// The five accessors indexed `entries` with the argument as given. W0 made
7055    /// the FFI value a packed `(generation << 16) | slot`, so every packed
7056    /// caller read past the table and got `None` — `async_send_request` on a
7057    /// C++ callback client returned `INVALID_ARGUMENT` on every call, and the
7058    /// granted-QoS read-backs answered for no entity.
7059    fn slot_of_raw(&self, entry_index: usize) -> Option<usize> {
7060        self.resolve_handle(HandleId::from_raw(entry_index))
7061    }
7062
7063    /// phase-476 W0 — [`resolve_handle`](Self::resolve_handle) as an index that
7064    /// is OUT OF RANGE for a stale handle, so an existing `entries.get(..)`
7065    /// chain answers `None` for it with no further change.
7066    pub(crate) fn live_slot(&self, id: HandleId) -> usize {
7067        self.resolve_handle(id).unwrap_or(usize::MAX)
7068    }
7069
7070    /// phase-476 W0 — record that `node` registered the entry `id` names, so
7071    /// [`release_node`](Self::release_node) can find it. `false` when `id` is
7072    /// stale.
7073    pub fn set_entry_owner(&mut self, id: HandleId, node: crate::executor::NodeId) -> bool {
7074        let Some(slot) = self.resolve_handle(id) else {
7075            return false;
7076        };
7077        let Ok(biased) = u8::try_from(node.index() + 1) else {
7078            return false;
7079        };
7080        self.slot_tags[slot].owner = biased;
7081        true
7082    }
7083
7084    /// phase-476 W0 — release every entry `node` registered, whatever its kind.
7085    /// Returns how many were released.
7086    ///
7087    /// This is what a node's destruction calls. A C++ callback's capture is
7088    /// copied into the arena and commonly holds the node's `this`, so an entry
7089    /// that outlived its node would dispatch into freed memory.
7090    ///
7091    /// # Safety
7092    /// No handle to any of those entries may be used afterwards except through
7093    /// a lookup that checks the generation ([`resolve_handle`](Self::resolve_handle)),
7094    /// which every public lookup on this type does.
7095    pub unsafe fn release_node(&mut self, node: crate::executor::NodeId) -> usize {
7096        let Ok(biased) = u8::try_from(node.index() + 1) else {
7097            return 0;
7098        };
7099        let mut released = 0;
7100        for slot in 0..self.entries.len() {
7101            if self.slot_tags.get(slot).is_some_and(|t| t.owner == biased) {
7102                let kind = match self.entries[slot].as_ref() {
7103                    Some(m) => m.kind,
7104                    None => continue,
7105                };
7106                // SAFETY: forwarded from this function's contract.
7107                if unsafe { self.release_entry(slot, kind) } {
7108                    released += 1;
7109                }
7110            }
7111        }
7112        released
7113    }
7114
7115    /// Open a subscription: claim a slot, resolve the node's identity, create
7116    /// the backend subscriber, and answer the in-place question ONCE.
7117    ///
7118    /// **phase-456 W8 — the one prologue.** Every registration that creates its
7119    /// own subscriber goes through here, in every language. The two callers
7120    /// that do not are the two that cannot: `add_arena_subscription_callback`
7121    /// is handed a subscriber someone else built (uORB needs a
7122    /// `&'static orb_metadata`, which no `TopicInfo` can express), so there is
7123    /// no topic to resolve and no session to reach — a fourth axis under issue
7124    /// 1319's rows that W8's three-axis decomposition did not predict: WHO
7125    /// CREATED THE SUBSCRIBER. It is a real axis, it is orthogonal to the other
7126    /// three, and it has exactly one member.
7127    ///
7128    /// Everything downstream of this function differs per delivery shape: the
7129    /// arena entry's type, its `try_process`, and whether it has a receive
7130    /// region at all. That part is irreducible and stays at the call site.
7131    pub(crate) fn open_subscription(
7132        &mut self,
7133        req: &SubscriptionRequest<'_>,
7134    ) -> Result<SubscriptionOpen, NodeError> {
7135        let slot = self.next_entry_slot()?;
7136        let (node_name, ns, session_idx) = match req.node_id {
7137            Some(id) => {
7138                let r = self
7139                    .nodes
7140                    .get(id.index())
7141                    .ok_or(NodeError::InvalidSchedContextBinding)?;
7142                (r.name.clone(), r.namespace.clone(), r.session_idx)
7143            }
7144            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
7145        };
7146        let mut topic = TopicInfo::new(req.topic_name, req.type_name, req.type_hash)
7147            .with_domain(self.domain_id)
7148            .with_namespace(&ns);
7149        if !node_name.is_empty() {
7150            topic = topic.with_node_name(&node_name);
7151        }
7152        // Phase 231 (RFC-0038) / phase-402 W2 — hand the backend a
7153        // receive-buffer size so it can size-class its receive storage
7154        // (zenoh-pico: small vs large). Only when the caller actually stated
7155        // one: `with_rx_buffer_hint(0)` would be a claim of "zero bytes", not
7156        // "no opinion".
7157        if let Some(hint) = req.backend_hint {
7158            if hint != 0 {
7159                topic = topic.with_rx_buffer_hint(hint);
7160            }
7161        }
7162        // phase-454 W10/W13 — the depth this registration asks for against the
7163        // depth this system DECLARED for the topic. Before the backend call AND
7164        // before the arena claim, so the region this registration takes and the
7165        // one the build reserved are the same number; unchanged when nothing
7166        // was declared, which is every image with no contract sidecar. See
7167        // `declared_qos::honour` for why Rust TAKES the declared depth where C
7168        // and C++ assert it at the call site.
7169        //
7170        // phase-456 W8 — this used to be `declared_qos::check` here and
7171        // `honour` in each of the twelve hand-rolled prologues this function
7172        // replaced. Collapsing them onto `check` would have silently reverted
7173        // W13 for every Rust registration: `check` validates and returns, while
7174        // `honour` REWRITES the depth the backend is then given. One consulting
7175        // site is the point of this function, and it has to be the honouring
7176        // one.
7177        let qos = crate::declared_qos::honour(topic.type_name, topic.name, req.qos)?;
7178        let handle = {
7179            let session = self
7180                .session_at_mut(session_idx)
7181                .ok_or(NodeError::BackendMismatch)?;
7182            session
7183                .create_subscription(&topic, qos)
7184                .map_err(NodeError::Transport)?
7185        };
7186        // Phase 231 Wave 0.2 (RFC-0038), phase-456 W8 — THE consulting site.
7187        // Deliberately `&&`-guarded by the delivery shape rather than by the
7188        // language: a shape that must hand the callback more than borrowed
7189        // bytes cannot use the capability however capable the backend is.
7190        let in_place = req.in_place_capable && {
7191            use nros_rmw::Subscription as _;
7192            handle.supports_process_in_place()
7193        };
7194        // phase-457 W3 — tell a metadata probe what this CALL SITE answered.
7195        //
7196        // `req.in_place_capable` and NOT `in_place`: the conjunction above
7197        // includes the backend a probe links, which is the RECORDING backend and
7198        // not the one the image ships. The sizing descriptor knows the shipped
7199        // backend from the image's `rmw` name and composes the two halves
7200        // itself; what it cannot see is this half, which is why nine of the
7201        // eleven registration entry points were previously credited with a
7202        // capability they do not have (issue 1340).
7203        //
7204        // AFTER the backend call, so a registration that failed reports
7205        // nothing — a row for a subscription that does not exist would raise the
7206        // probe's count, and an over-count of subscriptions is a real cost in
7207        // every pool that derives from it.
7208        #[cfg(feature = "registration-observer")]
7209        super::registration_observer::observe(super::registration_observer::ObservedRegistration {
7210            topic: topic.name,
7211            type_name: topic.type_name,
7212            in_place_capable: req.in_place_capable,
7213            // Issue 1648 -- the slot this registration is about to claim, read
7214            // off the request rather than inferred: `DEFAULT_RX_BUF_SIZE`
7215            // (`RX_BUF`) is the closure buffer (`unbounded`), anything else a
7216            // stated bound.
7217            claims_closure_buffer: req.slot_bytes == crate::config::DEFAULT_RX_BUF_SIZE,
7218        });
7219        Ok(SubscriptionOpen {
7220            slot,
7221            handle,
7222            qos,
7223            in_place,
7224            slot_bytes: req.slot_bytes,
7225        })
7226    }
7227
7228    /// Typed buffered subscription core (the `node_mut(id).subscription(t)
7229    /// .typed::<M>()` builder lowers here). Routes the typed subscription
7230    /// through the [`NodeId`]'s session + identity (rclcpp `add_node` pattern).
7231    ///
7232    /// Phase 403 W2 -- `rx_bytes` says how many bytes each buffer slot claims
7233    /// from the arena.
7234    ///
7235    /// phase-392 W3c -- `Some(n)` is verbatim; **`None` now means DERIVE from
7236    /// `M`**, falling back to `RX_BUF` only where no bound is reachable (an
7237    /// unbounded type, or a backend whose `MessageForRmw` carries no schema).
7238    /// It used to mean `RX_BUF` unconditionally, so a subscription that named no
7239    /// number was charged the global for every slot and a consumer who did not
7240    /// know `.rx_buffer_from_type()` existed paid it silently. The opt-out is
7241    /// now the explicit one: `.rx_buffer::<N>()` passes `Some(N)`.
7242    ///
7243    /// It is a RUNTIME `usize` and not a second const generic because on THIS
7244    /// path `RX_BUF` was never an array length -- it reaches
7245    /// `buffered_region_size`, `TripleBuffer::init` and `SpscRing::init`, all of
7246    /// which take a plain `usize`. That is the only reason W2's per-type sizing
7247    /// is expressible here at all: `Sub<{ M::BOUND }>` is
7248    /// `error: generic parameters may not be used in const operations` on stable
7249    /// (rustc 1.97.1), so the sibling `register_subscription_with_info_sized_inner`
7250    /// / `..._with_safety_sized_inner`, whose entries really do hold
7251    /// `[u8; RX_BUF]`, still need `nros::rx_buffer_for!` at the call site.
7252    pub(crate) fn register_subscription_buffered_on<M, F, const RX_BUF: usize>(
7253        &mut self,
7254        node_id: super::node_record::NodeId,
7255        topic_name: &str,
7256        qos: QoSProfile,
7257        callback: F,
7258        group: Option<&str>,
7259        rx_bytes: Option<usize>,
7260    ) -> Result<HandleId, NodeError>
7261    where
7262        M: crate::rmw_type_registry::MessageForRmw + 'static,
7263        F: FnMut(&M) + 'static,
7264    {
7265        type Entry<M, F> = SubBufferedEntry<M, F>;
7266
7267        // RFC-0088 / phase-421 W1 — the message's declared format must be the
7268        // one the linked backend speaks. Universal: the const lives on
7269        // `RosMessage`, which `MessageForRmw` requires under every backend.
7270        crate::format_check::assert_message_format::<M>();
7271        // Phase 212.K.7.6.b — see `create_publisher_on`.
7272        crate::rmw_type_registry::register_type::<M>()?;
7273
7274        // W3b.5 — contracted-endpoint age hook (None = free).
7275        let age_mon = self.age_lookup::<M>(topic_name);
7276
7277        // Phase 403 W2 -- one number for the whole allocation. The region size,
7278        // the strategy's slot size and the pointer arithmetic must agree, so
7279        // they read the same local rather than each spelling `RX_BUF`.
7280        //
7281        // phase-392 W3c -- and `None` no longer means `RX_BUF`, it means DERIVE.
7282        // This is the one choke point every default registration funnels
7283        // through, so putting the derivation here is what makes it the default
7284        // at all four of them (`create_subscription`, `create_subscription_in_group`,
7285        // the builder's `build()`, and the `/clock` time source) instead of at
7286        // whichever one a wave happened to touch. An explicit `Some(n)` -- what
7287        // `.rx_buffer::<N>()` and `.rx_buffer_from_type()` pass -- is still
7288        // verbatim, so the opt-out and the opt-in both bypass this line.
7289        let slot_bytes = crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF);
7290
7291        let open = self.open_subscription(&SubscriptionRequest {
7292            node_id: Some(node_id),
7293            topic_name,
7294            type_name: <M as RosMessage>::TYPE_NAME,
7295            type_hash: <M as RosMessage>::TYPE_HASH,
7296            qos,
7297            // Phase 392 W3a — the TYPE's bound, not `RX_BUF`. The arena slot
7298            // size says nothing about the message: a 64-byte type and a 4 KiB
7299            // type both hinted the same number, so the class was chosen from a
7300            // value unrelated to what arrives. Falls back to `RX_BUF` for an
7301            // unbounded type, where no bound exists to state (phase 380).
7302            backend_hint: Some(crate::rmw_type_registry::subscription_rx_hint::<M>(RX_BUF)),
7303            slot_bytes,
7304            // A typed Rust callback takes `&M` and nothing else, so borrowed
7305            // bytes are enough: deserialize straight from the backend's slot.
7306            in_place_capable: true,
7307        })?;
7308        let SubscriptionOpen {
7309            slot,
7310            handle,
7311            qos,
7312            in_place,
7313            slot_bytes,
7314        } = open;
7315
7316        // Phase 231 Wave 0.2 (RFC-0038) — in-place dispatch when the backend
7317        // advertises it: deserialize straight from the borrowed receive slot,
7318        // no arena buffer (copy #1 removed). Else the buffered path below.
7319        if in_place {
7320            let entry_offset = self.arena_alloc::<SubInplaceEntry<M, F>>()?;
7321            unsafe {
7322                let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7323                let entry_ptr = arena_ptr.add(entry_offset) as *mut SubInplaceEntry<M, F>;
7324                core::ptr::write(
7325                    entry_ptr,
7326                    SubInplaceEntry {
7327                        handle,
7328                        callback,
7329                        age_mon,
7330                        _phantom: PhantomData,
7331                    },
7332                );
7333            }
7334            let meta = CallbackMeta {
7335                offset: entry_offset,
7336                kind: EntryKind::Subscription,
7337                arena_len: super::arena::ArenaLen::UNRECORDED,
7338                try_process: sub_inplace_try_process::<M, F>,
7339                has_data: sub_inplace_has_data::<M, F>,
7340                pre_sample: no_pre_sample,
7341                invocation: InvocationMode::OnNewData,
7342                drop_fn: drop_entry::<SubInplaceEntry<M, F>>,
7343            };
7344            self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7345            self.apply_node_default_sched(slot, Some(node_id), group);
7346            return Ok(self.handle_for(slot));
7347        }
7348
7349        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
7350
7351        let (entry_offset, trailing_offset) =
7352            self.arena_alloc_with_trailing::<Entry<M, F>>(trailing_bytes)?;
7353
7354        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
7355
7356        let buffer = if qos.depth <= 1 {
7357            BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
7358        } else {
7359            BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
7360        };
7361
7362        unsafe {
7363            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7364            let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<M, F>;
7365            core::ptr::write(
7366                entry_ptr,
7367                Entry {
7368                    handle,
7369                    buffer,
7370                    callback,
7371                    age_mon,
7372                    _phantom: PhantomData,
7373                },
7374            );
7375        }
7376
7377        let meta = CallbackMeta {
7378            offset: entry_offset,
7379            kind: EntryKind::Subscription,
7380            arena_len: super::arena::ArenaLen::UNRECORDED,
7381            try_process: sub_buffered_try_process::<M, F>,
7382            has_data: sub_buffered_has_data::<M, F>,
7383            pre_sample: no_pre_sample,
7384            invocation: InvocationMode::OnNewData,
7385            drop_fn: drop_entry::<Entry<M, F>>,
7386        };
7387        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7388        // Phase 104.C.4 — apply Node's default SchedContext.
7389        self.apply_node_default_sched(slot, Some(node_id), group);
7390        Ok(self.handle_for(slot))
7391    }
7392
7393    /// Generic (type-erased) buffered subscription core (the
7394    /// `node_mut(id).subscription(t).generic(ty, hash)` builder lowers here).
7395    /// Routes the subscriber creation through the [`NodeId`]'s
7396    /// session + identity (rclcpp `add_node` pattern).
7397    ///
7398    /// Use this in bridge code where two Nodes bind to different RMW
7399    /// backends:
7400    ///
7401    /// ```ignore
7402    /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
7403    /// let pub_out = exec.with_node(node_out, |n| {
7404    ///     n.create_publisher_raw("/fwd", TYPE, HASH)
7405    /// })??;
7406    /// exec.register_subscription_buffered_raw_on::<_, 1024>(
7407    ///     node_in, "/src", TYPE, HASH, qos(),
7408    ///     move |bytes: &[u8]| { let _ = pub_out.publish_raw(bytes); },
7409    /// )?;
7410    /// ```
7411    pub(crate) fn register_subscription_buffered_raw_on<F, const RX_BUF: usize>(
7412        &mut self,
7413        node_id: super::node_record::NodeId,
7414        topic_name: &str,
7415        type_name: &str,
7416        type_hash: &str,
7417        qos: QoSProfile,
7418        callback: F,
7419    ) -> Result<HandleId, NodeError>
7420    where
7421        F: FnMut(&[u8]) + 'static,
7422    {
7423        // Issue #52 — fold the node's baked overrides for this topic before the
7424        // backend-compat check runs inside `create_subscription`.
7425        //
7426        // **This is the one registration that does it**, which phase-456 W8
7427        // noticed while collapsing the twelve prologues into one and did NOT
7428        // change: the other eleven reach `create_subscription` with the QoS
7429        // their caller passed. Whether that is a defect or a deliberate limit
7430        // of the generic bridge path is a question with a measurable answer and
7431        // no answer here, so the asymmetry stays where it was, stated.
7432        let overrides = self
7433            .nodes
7434            .get(node_id.index())
7435            .ok_or(NodeError::InvalidSchedContextBinding)?
7436            .qos_overrides;
7437        let qos = super::node_record::apply_qos_override_codes(
7438            qos,
7439            topic_name,
7440            nros_rmw::QoSOverrideRole::Subscription,
7441            overrides,
7442        );
7443
7444        let SubscriptionOpen {
7445            slot,
7446            handle,
7447            qos,
7448            in_place,
7449            slot_bytes,
7450        } = self.open_subscription(&SubscriptionRequest {
7451            node_id: Some(node_id),
7452            topic_name,
7453            type_name,
7454            type_hash,
7455            qos,
7456            backend_hint: None,
7457            slot_bytes: RX_BUF,
7458            // Issue 1340 — `FnMut(&[u8])` IS a borrowed-bytes callback, which is
7459            // exactly what `process_raw_in_place` hands over, so this shape takes
7460            // the in-place row the way the C raw path does.
7461            //
7462            // This used to be `false` because the buffered entry is shared with
7463            // `Self::add_arena_subscription_callback` and "an in-place twin would
7464            // have to serve both". It does not: that caller supplies its own
7465            // subscriber and never reaches `open_subscription`, so no in-place
7466            // report can arrive there. The branch below is local to this entry
7467            // point, and the shared buffered emplace is untouched.
7468            //
7469            // phase-457 W5 (issue 1522) — spelled as the SHAPE rather than as a
7470            // literal, because the declarative registrar lowers here and the
7471            // metadata probe must state the same bool without registering. One
7472            // definition (`DeclaredSubscriptionShape::in_place_capable`), two
7473            // readers, so the row the build prices and the claim made here
7474            // moved together when it flipped.
7475            in_place_capable: super::declared_shape::DeclaredSubscriptionShape::BufferedRaw
7476                .in_place_capable(),
7477        })?;
7478
7479        if in_place {
7480            // No receive region: the backend's slot is the buffer for the
7481            // duration of the callback. This is the whole of issue 1340's saving
7482            // on the generic path — `buffered_region_size(depth, RX_BUF)` is
7483            // never reserved.
7484            type Entry<F> = super::arena::SubInplaceRawEntry<F>;
7485            let entry_offset = self.arena_alloc::<Entry<F>>()?;
7486            unsafe {
7487                let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7488                let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<F>;
7489                core::ptr::write(entry_ptr, Entry { handle, callback });
7490            }
7491            let meta = CallbackMeta {
7492                offset: entry_offset,
7493                kind: EntryKind::Subscription,
7494                arena_len: super::arena::ArenaLen::UNRECORDED,
7495                try_process: super::arena::sub_inplace_raw_try_process::<F>,
7496                has_data: super::arena::sub_inplace_raw_has_data::<F>,
7497                pre_sample: no_pre_sample,
7498                invocation: InvocationMode::OnNewData,
7499                drop_fn: drop_entry::<Entry<F>>,
7500            };
7501            // Same trace name as the buffered arm, so a trace reads the same
7502            // whichever dispatch the backend chose.
7503            self.emplace_entry(slot, meta, TraceName::Slot("sub", slot));
7504        } else {
7505            self.emplace_raw_buffered_subscription(slot, handle, qos, slot_bytes, callback)?;
7506        }
7507        // Phase 104.C.4 — apply Node's default SchedContext.
7508        self.apply_node_default_sched(slot, Some(node_id), None);
7509        Ok(self.handle_for(slot))
7510    }
7511
7512    /// Register a borrowed (zero-copy) buffered subscription (Phase 229.6,
7513    /// issue 0007 / RFC-0033 `borrowed` mode).
7514    ///
7515    /// `B` is the code-generated borrowed-message marker (e.g. `ImageViewable`)
7516    /// implementing [`ViewableMessage`](nros_core::ViewableMessage); the
7517    /// callback receives `&B::View<'a>` — a lifetime-carrying message whose
7518    /// unbounded sequence/string fields borrow directly from the receive buffer
7519    /// (no `heapless::Vec` copy). The view is valid only for the callback's
7520    /// duration.
7521    ///
7522    /// **Triple-buffer only.** A borrowed view must reference exactly one
7523    /// well-defined buffer slot for the callback's duration; an SPSC ring
7524    /// (`qos.depth > 1`) keeps several samples in flight with no single such
7525    /// slot. `qos.depth > 1` is therefore rejected with
7526    /// [`TransportError::Unsupported`].
7527    pub(crate) fn register_subscription_buffered_borrowed_on<B, F, const RX_BUF: usize>(
7528        &mut self,
7529        node_id: super::node_record::NodeId,
7530        topic_name: &str,
7531        qos: QoSProfile,
7532        callback: F,
7533    ) -> Result<HandleId, NodeError>
7534    where
7535        B: nros_core::ViewableMessage + 'static,
7536        F: for<'a> FnMut(&B::View<'a>) + 'static,
7537    {
7538        type Entry<B, F> = SubBufferedViewEntry<B, F>;
7539
7540        // Borrowed views require a single well-defined slot (triple buffer).
7541        if qos.depth > 1 {
7542            return Err(NodeError::Transport(TransportError::Unsupported));
7543        }
7544
7545        let SubscriptionOpen { slot, handle, .. } =
7546            self.open_subscription(&SubscriptionRequest {
7547                node_id: Some(node_id),
7548                topic_name,
7549                type_name: <B as ViewableMessage>::TYPE_NAME,
7550                type_hash: <B as ViewableMessage>::TYPE_HASH,
7551                qos,
7552                backend_hint: None,
7553                slot_bytes: RX_BUF,
7554                // The view borrows the arena's own triple-buffer slot for the
7555                // callback's duration and `ViewableMessage::deserialize_view` is
7556                // what produces it, so there is no `FnMut(&[u8])` here to hand a
7557                // backend slot to. Making this shape in-place is a real option and
7558                // a separate change: it would move the borrow from the arena to the
7559                // backend's ring, which is a lifetime argument this item did not
7560                // make.
7561                in_place_capable: false,
7562            })?;
7563
7564        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, RX_BUF);
7565        let (entry_offset, trailing_offset) =
7566            self.arena_alloc_with_trailing::<Entry<B, F>>(trailing_bytes)?;
7567        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
7568
7569        // depth <= 1 guaranteed above → always triple buffer.
7570        let buffer = BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, RX_BUF) });
7571
7572        unsafe {
7573            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7574            let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<B, F>;
7575            core::ptr::write(
7576                entry_ptr,
7577                Entry {
7578                    handle,
7579                    buffer,
7580                    callback,
7581                    _phantom: PhantomData,
7582                },
7583            );
7584        }
7585
7586        let meta = CallbackMeta {
7587            offset: entry_offset,
7588            kind: EntryKind::Subscription,
7589            arena_len: super::arena::ArenaLen::UNRECORDED,
7590            try_process: sub_buffered_view_try_process::<B, F>,
7591            has_data: sub_buffered_view_has_data::<B, F>,
7592            pre_sample: no_pre_sample,
7593            invocation: InvocationMode::OnNewData,
7594            drop_fn: drop_entry::<Entry<B, F>>,
7595        };
7596        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7597        self.apply_node_default_sched(slot, Some(node_id), None);
7598        Ok(self.handle_for(slot))
7599    }
7600
7601    /// Register a raw (type-erased) buffered subscription whose callback
7602    /// also receives a [`RawMessageInfo`](nros_core::RawMessageInfo)
7603    /// carrying the sample's wire **attachment** (Phase 189.M1).
7604    ///
7605    /// Backs the `node.subscription(t).generic(..).message_info().build(cb)`
7606    /// builder — the cross-RMW bridge reads the `bridge_origin` tag from
7607    /// `info.attachment()` for echo suppression. One sample per
7608    /// `spin_once`; the attachment is staged in a flat per-entry buffer
7609    /// (cap `RAW_INFO_ATT_CAP`).
7610    pub fn register_subscription_buffered_raw_info_on<F, const RX_BUF: usize>(
7611        &mut self,
7612        node_id: super::node_record::NodeId,
7613        topic_name: &str,
7614        type_name: &str,
7615        type_hash: &str,
7616        qos: QoSProfile,
7617        callback: F,
7618    ) -> Result<HandleId, NodeError>
7619    where
7620        F: FnMut(&[u8], &nros_core::RawMessageInfo) + 'static,
7621    {
7622        type Entry<F> = SubBufferedRawInfoEntry<F>;
7623
7624        let SubscriptionOpen {
7625            slot,
7626            handle,
7627            slot_bytes,
7628            ..
7629        } = self.open_subscription(&SubscriptionRequest {
7630            node_id: Some(node_id),
7631            topic_name,
7632            type_name,
7633            type_hash,
7634            qos,
7635            backend_hint: None,
7636            slot_bytes: RX_BUF,
7637            // The callback also receives the sample's wire ATTACHMENT, which
7638            // `process_raw_in_place` does not carry: it hands over borrowed
7639            // payload bytes and nothing else. Not a limitation of the backend.
7640            in_place_capable: false,
7641        })?;
7642
7643        // Issue 1860 -- in the trailing region, as the C twin. A raw subscription
7644        // has no type to derive from, so the size is still `RX_BUF`.
7645        let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<F>>(slot_bytes)?;
7646        unsafe {
7647            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7648            let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
7649            let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
7650            core::ptr::write(
7651                entry_ptr,
7652                Entry {
7653                    handle,
7654                    buffer,
7655                    att: [0u8; super::arena::RAW_INFO_ATT_CAP],
7656                    callback,
7657                },
7658            );
7659        }
7660
7661        let meta = CallbackMeta {
7662            offset,
7663            kind: EntryKind::Subscription,
7664            arena_len: super::arena::ArenaLen::UNRECORDED,
7665            try_process: sub_buffered_raw_info_try_process::<F>,
7666            has_data: sub_buffered_raw_info_has_data::<F>,
7667            pre_sample: no_pre_sample,
7668            invocation: InvocationMode::OnNewData,
7669            drop_fn: drop_entry::<Entry<F>>,
7670        };
7671        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7672        self.apply_node_default_sched(slot, Some(node_id), None);
7673        Ok(self.handle_for(slot))
7674    }
7675
7676    /// Phase 250 (Wave 2) — register a generic (type-erased) raw subscription
7677    /// that surfaces E2E [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC +
7678    /// sequence gap/dup) alongside the raw CDR bytes
7679    /// (`FnMut(&[u8], &IntegrityStatus)`). The type-erased analog of
7680    /// `register_subscription_with_safety_sized_inner`: the validator lives in
7681    /// the `RmwSubscriber` (`take_validated`), so the subscriber is created
7682    /// plainly and no `register_type::<M>()` is needed (the declarative `Node`
7683    /// path is generic). Used by the declarative runtime's `.safety()` opt-in.
7684    #[cfg(feature = "safety-e2e")]
7685    pub fn register_subscription_buffered_raw_safety_on<F, const RX_BUF: usize>(
7686        &mut self,
7687        node_id: super::node_record::NodeId,
7688        topic_name: &str,
7689        type_name: &str,
7690        type_hash: &str,
7691        qos: QoSProfile,
7692        callback: F,
7693    ) -> Result<HandleId, NodeError>
7694    where
7695        F: FnMut(&[u8], &nros_rmw::IntegrityStatus) + 'static,
7696    {
7697        use super::arena::{
7698            SubBufferedRawSafetyEntry, sub_buffered_raw_safety_has_data,
7699            sub_buffered_raw_safety_try_process,
7700        };
7701        type Entry<F> = SubBufferedRawSafetyEntry<F>;
7702
7703        let SubscriptionOpen {
7704            slot,
7705            handle,
7706            slot_bytes,
7707            ..
7708        } = self.open_subscription(&SubscriptionRequest {
7709            node_id: Some(node_id),
7710            topic_name,
7711            type_name,
7712            type_hash,
7713            qos,
7714            backend_hint: None,
7715            slot_bytes: RX_BUF,
7716            // The sample is taken through `take_validated`, which computes the
7717            // integrity status as it copies. `process_raw_in_place` has no
7718            // validating form, so this shape cannot use it.
7719            //
7720            // phase-457 W5 (issue 1522) — as the SHAPE, for the reason its
7721            // sibling in `register_subscription_buffered_raw_on` gives.
7722            in_place_capable: super::declared_shape::DeclaredSubscriptionShape::BufferedRawSafety
7723                .in_place_capable(),
7724        })?;
7725
7726        // Issue 1860 -- in the trailing region; untyped, so still `RX_BUF` bytes.
7727        let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<F>>(slot_bytes)?;
7728        unsafe {
7729            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7730            let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
7731            let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
7732            core::ptr::write(
7733                entry_ptr,
7734                Entry {
7735                    handle,
7736                    buffer,
7737                    callback,
7738                },
7739            );
7740        }
7741
7742        let meta = CallbackMeta {
7743            offset,
7744            kind: EntryKind::Subscription,
7745            arena_len: super::arena::ArenaLen::UNRECORDED,
7746            try_process: sub_buffered_raw_safety_try_process::<F>,
7747            has_data: sub_buffered_raw_safety_has_data::<F>,
7748            pre_sample: no_pre_sample,
7749            invocation: InvocationMode::OnNewData,
7750            drop_fn: drop_entry::<Entry<F>>,
7751        };
7752        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7753        self.apply_node_default_sched(slot, Some(node_id), None);
7754        Ok(self.handle_for(slot))
7755    }
7756
7757    /// Register a raw byte-shaped callback against a pre-built
7758    /// `RmwSubscriber` handle.
7759    ///
7760    /// Backend-agnostic primitive — the caller is responsible for
7761    /// obtaining the handle by whatever route the active backend
7762    /// supports:
7763    ///
7764    /// - **Generic ROS-typed flow**: call `Session::create_subscription`
7765    ///   on `self.session_mut()` with a [`TopicInfo`]. The
7766    ///   `node_mut(id).subscription(t).generic(ty, hash)` builder is the
7767    ///   convenience wrapper for this path.
7768    /// - **Backend-specific flow** (e.g. uORB needs `&'static orb_metadata`):
7769    ///   reach into the concrete session via [`Self::session_mut`] and
7770    ///   call its backend-specific create method, then hand the handle
7771    ///   here. `nros-px4::uorb::create_subscription_with_callback` is
7772    ///   the example.
7773    ///
7774    /// The arena-store + vtable wiring is identical to
7775    /// `register_subscription_buffered_raw`; the only thing that varies is
7776    /// where the handle came from. Callback fires on every message
7777    /// delivery during [`spin_once`](Self::spin_once); bytes are
7778    /// passed as `&[u8]`.
7779    pub fn add_arena_subscription_callback<F, const RX_BUF: usize>(
7780        &mut self,
7781        handle: session::RmwSubscriber,
7782        qos: QoSProfile,
7783        callback: F,
7784    ) -> Result<HandleId, NodeError>
7785    where
7786        F: FnMut(&[u8]) + 'static,
7787    {
7788        let slot = self.next_entry_slot()?;
7789        self.emplace_raw_buffered_subscription(slot, handle, qos, RX_BUF, callback)?;
7790        Ok(self.handle_for(slot))
7791    }
7792
7793    /// Write a raw buffered subscription entry into an already-claimed slot —
7794    /// phase-456 W8.
7795    ///
7796    /// The shared tail of [`Self::add_arena_subscription_callback`] and
7797    /// `register_subscription_buffered_raw_on`, which differ only in where the
7798    /// subscriber came from: one is handed a live `RmwSubscriber`, the other
7799    /// resolves a topic through [`Self::open_subscription`] and gets one back.
7800    /// Both then build exactly this entry, and they used to build it twice —
7801    /// the bridge path by CALLING the other, which meant it claimed a slot,
7802    /// resolved a topic, and then let the callee claim a second slot it did not
7803    /// need.
7804    fn emplace_raw_buffered_subscription<F>(
7805        &mut self,
7806        slot: usize,
7807        handle: session::RmwSubscriber,
7808        qos: QoSProfile,
7809        slot_bytes: usize,
7810        callback: F,
7811    ) -> Result<(), NodeError>
7812    where
7813        F: FnMut(&[u8]) + 'static,
7814    {
7815        type Entry<F> = SubBufferedRawEntry<F>;
7816
7817        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
7818
7819        let (entry_offset, trailing_offset) =
7820            self.arena_alloc_with_trailing::<Entry<F>>(trailing_bytes)?;
7821
7822        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
7823
7824        let buffer = if qos.depth <= 1 {
7825            BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
7826        } else {
7827            BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
7828        };
7829
7830        unsafe {
7831            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7832            let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<F>;
7833            core::ptr::write(
7834                entry_ptr,
7835                Entry {
7836                    handle,
7837                    buffer,
7838                    callback,
7839                },
7840            );
7841        }
7842
7843        let meta = CallbackMeta {
7844            offset: entry_offset,
7845            kind: EntryKind::Subscription,
7846            arena_len: super::arena::ArenaLen::UNRECORDED,
7847            try_process: sub_buffered_raw_try_process::<F>,
7848            has_data: sub_buffered_raw_has_data::<F>,
7849            pre_sample: no_pre_sample,
7850            invocation: InvocationMode::OnNewData,
7851            drop_fn: drop_entry::<Entry<F>>,
7852        };
7853        self.emplace_entry(slot, meta, TraceName::Slot("sub", slot));
7854        Ok(())
7855    }
7856
7857    pub(crate) fn register_subscription_with_info_sized_inner<M, F, const RX_BUF: usize>(
7858        &mut self,
7859        node_id: Option<super::node_record::NodeId>,
7860        topic_name: &str,
7861        qos: QoSProfile,
7862        callback: F,
7863        // Issue 1860 -- `Some(n)` is a size the caller NAMED (`.rx_buffer::<N>()`),
7864        // used verbatim; `None` derives it from `M` exactly as the buffered path
7865        // does, falling back to `RX_BUF` only where `M` states no bound.
7866        rx_bytes: Option<usize>,
7867    ) -> Result<HandleId, NodeError>
7868    where
7869        M: crate::rmw_type_registry::MessageForRmw + 'static,
7870        F: FnMut(&M, Option<&nros_core::MessageInfo>) + 'static,
7871    {
7872        type Entry<M, F> = SubInfoEntry<M, F>;
7873
7874        // Phase 212.K.7.6.b — see `create_publisher_on`.
7875        crate::rmw_type_registry::register_type::<M>()?;
7876
7877        let SubscriptionOpen {
7878            slot,
7879            handle,
7880            slot_bytes,
7881            ..
7882        } = self.open_subscription(&SubscriptionRequest {
7883            node_id,
7884            topic_name,
7885            type_name: <M as RosMessage>::TYPE_NAME,
7886            type_hash: <M as RosMessage>::TYPE_HASH,
7887            qos,
7888            backend_hint: None,
7889            slot_bytes: crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF),
7890            // The entry's `pre_sample` hook stages a `MessageInfo` beside the
7891            // payload before the callback runs; an in-place drain has no
7892            // pre-sample step to hang it on.
7893            in_place_capable: false,
7894        })?;
7895
7896        let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<M, F>>(slot_bytes)?;
7897
7898        unsafe {
7899            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7900            let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
7901            let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F>;
7902            core::ptr::write(
7903                entry_ptr,
7904                Entry {
7905                    handle,
7906                    buffer,
7907                    sampled_len: 0,
7908                    callback,
7909                    _phantom: PhantomData,
7910                },
7911            );
7912        }
7913
7914        let meta = CallbackMeta {
7915            offset,
7916            kind: EntryKind::Subscription,
7917            arena_len: super::arena::ArenaLen::UNRECORDED,
7918            try_process: sub_info_try_process::<M, F>,
7919            has_data: sub_info_has_data::<M, F>,
7920            pre_sample: sub_info_pre_sample::<M, F>,
7921            invocation: InvocationMode::OnNewData,
7922            drop_fn: drop_entry::<Entry<M, F>>,
7923        };
7924        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7925        self.apply_node_default_sched(slot, node_id, None);
7926        Ok(self.handle_for(slot))
7927    }
7928
7929    #[cfg(feature = "safety-e2e")]
7930    pub(crate) fn register_subscription_with_safety_sized_inner<M, F, const RX_BUF: usize>(
7931        &mut self,
7932        node_id: Option<super::node_record::NodeId>,
7933        topic_name: &str,
7934        qos: QoSProfile,
7935        callback: F,
7936        // Issue 1860 -- as `register_subscription_with_info_sized_inner`.
7937        rx_bytes: Option<usize>,
7938    ) -> Result<HandleId, NodeError>
7939    where
7940        M: crate::rmw_type_registry::MessageForRmw + 'static,
7941        F: FnMut(&M, &nros_rmw::IntegrityStatus) + 'static,
7942    {
7943        type Entry<M, F> = SubSafetyEntry<M, F>;
7944
7945        // Phase 212.K.7.6.b — see `create_publisher_on`.
7946        crate::rmw_type_registry::register_type::<M>()?;
7947
7948        let SubscriptionOpen {
7949            slot,
7950            handle,
7951            slot_bytes,
7952            ..
7953        } = self.open_subscription(&SubscriptionRequest {
7954            node_id,
7955            topic_name,
7956            type_name: <M as RosMessage>::TYPE_NAME,
7957            type_hash: <M as RosMessage>::TYPE_HASH,
7958            qos,
7959            backend_hint: None,
7960            slot_bytes: crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF),
7961            // Same as the raw safety sibling: the status comes out of
7962            // `take_validated`, which is a copying take.
7963            in_place_capable: false,
7964        })?;
7965
7966        let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<M, F>>(slot_bytes)?;
7967
7968        unsafe {
7969            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7970            let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
7971            let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F>;
7972            core::ptr::write(
7973                entry_ptr,
7974                Entry {
7975                    handle,
7976                    buffer,
7977                    sampled_len: 0,
7978                    callback,
7979                    _phantom: PhantomData,
7980                },
7981            );
7982        }
7983
7984        let meta = CallbackMeta {
7985            offset,
7986            kind: EntryKind::Subscription,
7987            arena_len: super::arena::ArenaLen::UNRECORDED,
7988            try_process: sub_safety_try_process::<M, F>,
7989            has_data: sub_safety_has_data::<M, F>,
7990            pre_sample: sub_safety_pre_sample::<M, F>,
7991            invocation: InvocationMode::OnNewData,
7992            drop_fn: drop_entry::<Entry<M, F>>,
7993        };
7994        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7995        self.apply_node_default_sched(slot, node_id, None);
7996        Ok(self.handle_for(slot))
7997    }
7998
7999    /// Register a service callback with the default buffer size.
8000    ///
8001    /// The callback is stored in the arena and invoked during [`spin_once()`](Self::spin_once).
8002    pub fn register_service<Svc, F>(
8003        &mut self,
8004        service_name: &str,
8005        callback: F,
8006    ) -> Result<HandleId, NodeError>
8007    where
8008        Svc: RosService + 'static,
8009        Svc::Request: crate::rmw_type_registry::MessageForRmw,
8010        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
8011        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
8012    {
8013        self.register_service_sized::<Svc, F, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, callback)
8014    }
8015
8016    /// Register a service callback with custom request/reply buffer sizes.
8017    pub fn register_service_sized<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
8018        &mut self,
8019        service_name: &str,
8020        callback: F,
8021    ) -> Result<HandleId, NodeError>
8022    where
8023        Svc: RosService + 'static,
8024        Svc::Request: crate::rmw_type_registry::MessageForRmw,
8025        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
8026        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
8027    {
8028        type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
8029
8030        // Phase 212.K.7.7.b — register both halves of the service round-trip
8031        // under cyclonedds. No-op for other RMWs. Mirrors the K.7.6.b hook
8032        // on `Node::create_service_sized`.
8033        crate::rmw_type_registry::register_type::<Svc::Request>()?;
8034        crate::rmw_type_registry::register_type::<Svc::Reply>()?;
8035
8036        let slot = self.next_entry_slot()?;
8037        let node_name: heapless::String<64> = self.node_name.clone();
8038        let ns: heapless::String<64> = self.namespace.clone();
8039        let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
8040            .with_domain(self.domain_id)
8041            .with_namespace(&ns);
8042        if !node_name.is_empty() {
8043            info = info.with_node_name(&node_name);
8044        }
8045        let handle = self
8046            .session
8047            .create_service(&info, QoSProfile::services_default())
8048            .map_err(NodeError::Transport)?;
8049
8050        let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
8051
8052        // SAFETY: same guarantees as register_subscription_sized.
8053        unsafe {
8054            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8055            let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
8056            core::ptr::write(
8057                entry_ptr,
8058                Entry {
8059                    handle,
8060                    req_buffer: [0u8; REQ_BUF],
8061                    reply_buffer: [0u8; REPLY_BUF],
8062                    callback,
8063                    _phantom: PhantomData,
8064                },
8065            );
8066        }
8067
8068        let meta = CallbackMeta {
8069            offset,
8070            kind: EntryKind::Service,
8071            arena_len: super::arena::ArenaLen::UNRECORDED,
8072            try_process: srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
8073            has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
8074            pre_sample: no_pre_sample,
8075            invocation: InvocationMode::OnNewData,
8076            drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
8077        };
8078        self.emplace_entry(slot, meta, TraceName::Text(service_name));
8079        Ok(self.handle_for(slot))
8080    }
8081
8082    /// Phase 104.C.3.3.a — Node-aware variant of
8083    /// [`register_service_sized`](Self::register_service_sized).
8084    pub fn register_service_sized_on<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
8085        &mut self,
8086        node_id: super::node_record::NodeId,
8087        service_name: &str,
8088        qos: QoSProfile,
8089        callback: F,
8090    ) -> Result<HandleId, NodeError>
8091    where
8092        Svc: RosService + 'static,
8093        Svc::Request: crate::rmw_type_registry::MessageForRmw,
8094        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
8095        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
8096    {
8097        type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
8098
8099        // Phase 212.K.7.7.b — see `register_service_sized`.
8100        crate::rmw_type_registry::register_type::<Svc::Request>()?;
8101        crate::rmw_type_registry::register_type::<Svc::Reply>()?;
8102
8103        let slot = self.next_entry_slot()?;
8104        let (node_name, ns, session_idx) = {
8105            let r = self
8106                .nodes
8107                .get(node_id.index())
8108                .ok_or(NodeError::InvalidSchedContextBinding)?;
8109            (r.name.clone(), r.namespace.clone(), r.session_idx)
8110        };
8111        let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
8112            .with_domain(self.domain_id)
8113            .with_namespace(&ns);
8114        if !node_name.is_empty() {
8115            info = info.with_node_name(&node_name);
8116        }
8117        let handle = {
8118            let session = self
8119                .session_at_mut(session_idx)
8120                .ok_or(NodeError::BackendMismatch)?;
8121            // Phase 193.5 — validate against the backend's supported policies
8122            // (no silent downgrade); request/reply effectively requires RELIABLE.
8123            super::node::validate_qos_or_report(
8124                &qos,
8125                session.supported_qos_policies(),
8126                "service",
8127                service_name,
8128            )?;
8129            session
8130                .create_service(&info, qos)
8131                .map_err(NodeError::Transport)?
8132        };
8133
8134        let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
8135        unsafe {
8136            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8137            let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
8138            core::ptr::write(
8139                entry_ptr,
8140                Entry {
8141                    handle,
8142                    req_buffer: [0u8; REQ_BUF],
8143                    reply_buffer: [0u8; REPLY_BUF],
8144                    callback,
8145                    _phantom: PhantomData,
8146                },
8147            );
8148        }
8149
8150        let meta = CallbackMeta {
8151            offset,
8152            kind: EntryKind::Service,
8153            arena_len: super::arena::ArenaLen::UNRECORDED,
8154            try_process: srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
8155            has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
8156            pre_sample: no_pre_sample,
8157            invocation: InvocationMode::OnNewData,
8158            drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
8159        };
8160        self.emplace_entry(slot, meta, TraceName::Text(service_name));
8161        self.apply_node_default_sched(slot, Some(node_id), None);
8162        Ok(self.handle_for(slot))
8163    }
8164
8165    /// Phase 104.C.3.3.a — Node-aware variant of
8166    /// [`register_service`](Self::register_service).
8167    pub fn register_service_on<Svc, F>(
8168        &mut self,
8169        node_id: super::node_record::NodeId,
8170        service_name: &str,
8171        callback: F,
8172    ) -> Result<HandleId, NodeError>
8173    where
8174        Svc: RosService + 'static,
8175        Svc::Request: crate::rmw_type_registry::MessageForRmw,
8176        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
8177        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
8178    {
8179        self.register_service_sized_on::<
8180            Svc,
8181            F,
8182            { crate::config::DEFAULT_RX_BUF_SIZE },
8183            { crate::config::DEFAULT_RX_BUF_SIZE },
8184        >(node_id, service_name, QoSProfile::services_default(), callback)
8185    }
8186
8187    // ========================================================================
8188    // Timer registration
8189    // ========================================================================
8190
8191    /// The ONE timer registration in the tree (phase-430 W4).
8192    ///
8193    /// Every timer verb — the four `Executor::register_timer*` entry points
8194    /// below, `Node::create_timer_in_group` / `create_timer_on_clock*`, the
8195    /// declarative `nros::Node` timer, and the C/C++ FFI that lower to them —
8196    /// constructs its arena entry HERE. Until W4 there were four copies of this
8197    /// body differing only in `oneshot`, `clock_source` and whether the sched
8198    /// binding was applied, and phase-425's clock axis reached exactly one of
8199    /// them; that is how a `nros::main!` component could declare a timer but
8200    /// not a ROS-time one.
8201    ///
8202    /// `node_id` / `group` feed `apply_node_default_sched` (phase 273: group
8203    /// table > node default > SC 0). `node_id == None` is the legacy unscoped
8204    /// path and makes that call a no-op.
8205    pub(crate) fn register_timer_entry<F>(
8206        &mut self,
8207        node_id: Option<super::node_record::NodeId>,
8208        period: TimerDuration,
8209        source: TimerClockSource,
8210        oneshot: bool,
8211        group: Option<&str>,
8212        callback: F,
8213    ) -> Result<HandleId, NodeError>
8214    where
8215        F: FnMut() + 'static,
8216    {
8217        self.register_timer_entry_with(
8218            node_id,
8219            period,
8220            source,
8221            oneshot,
8222            group,
8223            None,
8224            core::ptr::null_mut(),
8225            move |_| callback,
8226        )
8227    }
8228
8229    /// [`register_timer_entry`](Self::register_timer_entry), with a CAPTURE
8230    /// the arena holds — phase-476 W2.
8231    ///
8232    /// The callback is BUILT after the allocation, by `make`, from the context
8233    /// pointer the entry should dispatch with: `place_capture`'s copy of
8234    /// `capture` when there is one, otherwise `context` unchanged. That is what
8235    /// lets the capture ride in the timer entry's own trailing region (so a
8236    /// release frees it) while the closure that reads it is still a plain
8237    /// `TimerEntry<F>`.
8238    #[allow(clippy::too_many_arguments)]
8239    pub(crate) fn register_timer_entry_with<F, M>(
8240        &mut self,
8241        node_id: Option<super::node_record::NodeId>,
8242        period: TimerDuration,
8243        source: TimerClockSource,
8244        oneshot: bool,
8245        group: Option<&str>,
8246        capture: Option<&[u8]>,
8247        context: *mut core::ffi::c_void,
8248        make: M,
8249    ) -> Result<HandleId, NodeError>
8250    where
8251        F: FnMut() + 'static,
8252        M: FnOnce(*mut core::ffi::c_void) -> F,
8253    {
8254        let slot = self.next_entry_slot()?;
8255        let capture_len = Self::capture_trailing_len(capture);
8256        // No capture keeps the exact pre-W2 allocation, so a timer that captures
8257        // nothing costs what it always did.
8258        let (offset, context) = if capture_len == 0 {
8259            (self.arena_alloc::<TimerEntry<F>>()?, context)
8260        } else {
8261            let (offset, at) = self.arena_alloc_with_trailing::<TimerEntry<F>>(capture_len)?;
8262            (offset, self.place_capture(at, capture, context))
8263        };
8264        let callback = make(context);
8265
8266        unsafe {
8267            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8268            let entry_ptr = arena_ptr.add(offset) as *mut TimerEntry<F>;
8269            core::ptr::write(
8270                entry_ptr,
8271                TimerEntry {
8272                    period_us: period.as_micros(),
8273                    elapsed_us: 0,
8274                    overruns: 0,
8275                    overruns_reported: 0,
8276                    oneshot,
8277                    fired: false,
8278                    cancelled: false,
8279                    overrun_policy: TimerOverrunPolicy::default(),
8280                    clock_source: source,
8281                    // Seeded HERE rather than on the first poll: a zero would
8282                    // make the first delta the whole epoch, which `Skip` would
8283                    // then coalesce into one immediate activation and `CatchUp`
8284                    // into a replay burst of ~10^9 periods. `Steady` reads no
8285                    // clock at all, so its seed is 0 and is never read.
8286                    last_clock_ns: source.now_ns(),
8287                    callback,
8288                },
8289            );
8290        }
8291
8292        let meta = CallbackMeta {
8293            offset,
8294            kind: EntryKind::Timer,
8295            arena_len: super::arena::ArenaLen::UNRECORDED,
8296            try_process: timer_try_process::<F>,
8297            has_data: always_ready,
8298            pre_sample: no_pre_sample,
8299            invocation: InvocationMode::Always,
8300            drop_fn: drop_entry::<TimerEntry<F>>,
8301        };
8302        self.emplace_entry(slot, meta, TraceName::TimerPeriod(period.as_micros()));
8303        // Phase 273 — apply group sched binding (group > node default > SC 0).
8304        self.apply_node_default_sched(slot, node_id, group);
8305        Ok(self.handle_for(slot))
8306    }
8307
8308    /// Register a repeating timer callback.
8309    ///
8310    /// The callback fires every `period` milliseconds during [`spin_once()`](Self::spin_once).
8311    /// The timer delta is approximated by the `timeout_ms` argument to `spin_once`.
8312    pub fn register_timer<F>(
8313        &mut self,
8314        period: TimerDuration,
8315        callback: F,
8316    ) -> Result<HandleId, NodeError>
8317    where
8318        F: FnMut() + 'static,
8319    {
8320        self.register_timer_entry(
8321            None,
8322            period,
8323            TimerClockSource::Steady,
8324            false,
8325            None,
8326            callback,
8327        )
8328    }
8329
8330    /// Register a repeating timer driven by a CLOCK rather than by the spin
8331    /// delta — phase-425 W4, the shape rclcpp spells
8332    /// `create_timer(node, clock, period, cb)`.
8333    ///
8334    /// [`register_timer`](Self::register_timer) is the wall timer: it consumes
8335    /// the executor's monotonic spin delta and no simulator can slow it down.
8336    /// This one reads `source` on every poll and advances by the difference, so
8337    /// a [`TimerClockSource::Ros`] timer follows `/clock` — it stops while the
8338    /// simulator is paused, halves with a bag replayed at 0.5x, and restarts
8339    /// its period on a backwards jump instead of stalling for the length of it.
8340    ///
8341    /// With no `/clock` source installed, a `Ros` timer reads system time and
8342    /// behaves like a wall timer with NTP steps, which is the same fallback
8343    /// `rclcpp::Clock` has: a node written for simulation still runs standalone.
8344    ///
8345    /// The node-level spellings of this are `Node::create_timer_on_clock`
8346    /// and the declarative `DeclaredNode::create_timer_on_clock` (phase-430 W4);
8347    /// all of them reach `register_timer_entry`, which is the only place a
8348    /// timer entry is built.
8349    pub fn register_timer_on_clock<F>(
8350        &mut self,
8351        period: TimerDuration,
8352        source: TimerClockSource,
8353        callback: F,
8354    ) -> Result<HandleId, NodeError>
8355    where
8356        F: FnMut() + 'static,
8357    {
8358        self.register_timer_entry(None, period, source, false, None, callback)
8359    }
8360
8361    /// Register a one-shot timer callback.
8362    ///
8363    /// The callback fires once after `delay` milliseconds, then becomes inert.
8364    pub fn register_timer_oneshot<F>(
8365        &mut self,
8366        delay: TimerDuration,
8367        callback: F,
8368    ) -> Result<HandleId, NodeError>
8369    where
8370        F: FnMut() + 'static,
8371    {
8372        self.register_timer_entry(None, delay, TimerClockSource::Steady, true, None, callback)
8373    }
8374
8375    /// phase-476 W2 — register a timer whose callback is a C function pointer
8376    /// and whose CAPTURE the arena holds: the `nros-cpp` road for a C++
8377    /// lambda, the timer twin of
8378    /// [`add_arena_subscription_c_callback_with_capture`](Self::add_arena_subscription_c_callback_with_capture).
8379    ///
8380    /// `capture`'s bytes are copied into the timer entry's trailing region and
8381    /// `callback` is called with a pointer to that copy, so the caller keeps
8382    /// nothing — which is what lets the C++ side hand back a two-word handle
8383    /// instead of a heap cell the node had to keep alive. Releasing the timer
8384    /// releases the capture with it.
8385    ///
8386    /// `source` picks the clock: [`TimerClockSource::Steady`] is the wall timer
8387    /// that advances with the spin delta.
8388    ///
8389    /// `capture_drop`, when given, is called with the same pointer when the
8390    /// entry is dropped (released, or the executor torn down), BEFORE its bytes
8391    /// go back to the arena. A C++ capture is a callable that may own something
8392    /// (a `std::shared_ptr`, a `std::string`), and its bytes were MOVED here by
8393    /// copy, so this is the only place its destructor can run.
8394    pub fn register_timer_c_capturing(
8395        &mut self,
8396        node_id: Option<super::node_record::NodeId>,
8397        period: TimerDuration,
8398        source: TimerClockSource,
8399        callback: unsafe extern "C" fn(*mut core::ffi::c_void),
8400        capture: Option<&[u8]>,
8401        capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
8402    ) -> Result<HandleId, NodeError> {
8403        self.register_timer_entry_with(
8404            node_id,
8405            period,
8406            source,
8407            false,
8408            None,
8409            capture,
8410            core::ptr::null_mut(),
8411            move |ctx| {
8412                let held = super::arena::CapturedCallback {
8413                    callback,
8414                    guard: super::arena::CaptureGuard {
8415                        ctx,
8416                        drop: capture_drop,
8417                    },
8418                };
8419                move || held.call()
8420            },
8421        )
8422    }
8423
8424    /// Phase 273 (RFC-0047) — register a repeating timer callback bound to a
8425    /// specific node and optional callback group. The group name is threaded to
8426    /// `apply_node_default_sched` so the seeded `group_sched_table` assigns
8427    /// the timer's callback to the group's `SchedContext`. When `group` is
8428    /// `None` the node's `default_sched` applies (phase-272 behavior).
8429    ///
8430    /// This is the executor-level primitive called by the Rust `_in_group` API
8431    /// (`Node::create_timer_in_group`) and the C/C++ group-aware timer FFI.
8432    pub fn register_timer_on<F>(
8433        &mut self,
8434        node_id: Option<super::node_record::NodeId>,
8435        period: TimerDuration,
8436        callback: F,
8437        group: Option<&str>,
8438    ) -> Result<HandleId, NodeError>
8439    where
8440        F: FnMut() + 'static,
8441    {
8442        self.register_timer_entry(
8443            node_id,
8444            period,
8445            TimerClockSource::Steady,
8446            false,
8447            group,
8448            callback,
8449        )
8450    }
8451
8452    // ========================================================================
8453    // Raw callback registration (for C API)
8454    // ========================================================================
8455
8456    /// The kept C-FFI subscription core (Phase 189.M2.b): registers a
8457    /// raw `RawSubscriptionCallback` fn-ptr + `context` against an
8458    /// optional node's session. The Rust ergonomic surface is the
8459    /// `node.subscription(t)` builder (closures); this is the single
8460    /// primitive the `nros-c` thin wrapper lowers to. `node_id == None`
8461    /// is the legacy single-node path.
8462    #[allow(clippy::too_many_arguments)]
8463    pub fn add_arena_subscription_c_callback<const RX_BUF: usize>(
8464        &mut self,
8465        node_id: Option<super::node_record::NodeId>,
8466        topic_name: &str,
8467        type_name: &str,
8468        type_hash: &str,
8469        qos: QoSProfile,
8470        callback: RawSubscriptionCallback,
8471        context: *mut core::ffi::c_void,
8472        group: Option<&str>,
8473        rx_buffer_hint: usize,
8474    ) -> Result<HandleId, NodeError> {
8475        self.add_arena_subscription_c_callback_with_capture::<RX_BUF>(
8476            node_id,
8477            topic_name,
8478            type_name,
8479            type_hash,
8480            qos,
8481            callback,
8482            context,
8483            group,
8484            rx_buffer_hint,
8485            None,
8486            None,
8487        )
8488    }
8489
8490    /// The same registration, with the caller's CAPTURE held by the arena —
8491    /// phase-456 W1.
8492    ///
8493    /// `context` is one pointer, which carries a C++ `[this]` capture and not a
8494    /// `[this, state]` or a `[obj, method]`. When `capture` is `Some`, its
8495    /// bytes are copied into the arena and `context` is repointed at that copy,
8496    /// so the caller keeps nothing and nothing the caller owns is referenced
8497    /// after this returns. When it is `None` the behaviour is exactly the
8498    /// pre-existing one, which is why the plain entry point above is a wrapper
8499    /// rather than a second implementation.
8500    ///
8501    /// **phase-456 W8 changed two things here.** The capture is a RUNTIME
8502    /// length — see `Self::place_capture` — so there is no constant the C++
8503    /// side has to match and no `BufferTooSmall` reachable only by drifting
8504    /// from it. And this path now reaches the in-place dispatch that the Rust
8505    /// typed path has had since phase-231: `RawSubscriptionCallback` is
8506    /// `(const uint8_t*, size_t, void*)`, so the backend's borrowed receive
8507    /// slot IS the argument, and a C or C++ subscription on zenoh or XRCE
8508    /// claims no receive region at all.
8509    #[allow(clippy::too_many_arguments)]
8510    pub fn add_arena_subscription_c_callback_with_capture<const RX_BUF: usize>(
8511        &mut self,
8512        node_id: Option<super::node_record::NodeId>,
8513        topic_name: &str,
8514        type_name: &str,
8515        type_hash: &str,
8516        qos: QoSProfile,
8517        callback: RawSubscriptionCallback,
8518        context: *mut core::ffi::c_void,
8519        group: Option<&str>,
8520        // phase-402 W2 / issue 0896 — bytes the caller expects to receive; 0 =
8521        // no opinion. A size-classing backend (zenoh-pico) routes on this, and
8522        // a subscription that states nothing takes the SMALL class whatever its
8523        // message type. The C path had no way to say it until the options
8524        // struct existed.
8525        rx_buffer_hint: usize,
8526        capture: Option<&[u8]>,
8527        // phase-476 W2 — called on the arena's copy of `capture` when the entry
8528        // is dropped; see `CaptureGuard`. `None` when the capture owns nothing.
8529        capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
8530    ) -> Result<HandleId, NodeError> {
8531        // phase-403 W3/W5 -- size the arena slot from the TYPE, not from the
8532        // image-wide default. `rx_buffer_hint` already arrives here (phase-402
8533        // routed it to the backend's payload class and stopped); spending it on
8534        // the allocation as well is what makes the buffer per-type.
8535        //
8536        // This is the whole saving on the raw path. `RX_BUF` is
8537        // DEFAULT_RX_BUF_SIZE, so every slot -- publishers and timers included --
8538        // was charged the largest subscription's buffer. Measured on
8539        // mr-canhubk344: 36 handles at the correct 2052-byte bound wanted a
8540        // 242096-byte arena of a 77968-byte region; only 13 of those handles
8541        // receive anything.
8542        //
8543        // 0 keeps the old behaviour, and it means "this CALLER stated nothing"
8544        // rather than "this type is unbounded" -- an unbounded type is a build
8545        // error now, so a zero here is a caller that did not ask, never a type
8546        // that could not answer.
8547        let slot_bytes = if rx_buffer_hint != 0 {
8548            rx_buffer_hint
8549        } else {
8550            RX_BUF
8551        };
8552
8553        let SubscriptionOpen {
8554            slot,
8555            handle,
8556            qos,
8557            in_place,
8558            slot_bytes,
8559        } = self.open_subscription(&SubscriptionRequest {
8560            node_id,
8561            topic_name,
8562            type_name,
8563            type_hash,
8564            qos,
8565            backend_hint: Some(rx_buffer_hint),
8566            slot_bytes,
8567            // phase-456 W8 — `RawSubscriptionCallback` takes borrowed bytes and
8568            // a context, which is exactly what `process_raw_in_place` hands it.
8569            in_place_capable: true,
8570        })?;
8571
8572        // The capture is placed AFTER the backend call and BEFORE either entry
8573        // is written, so both dispatch shapes get the same context and a failed
8574        // `create_subscription` costs no arena bytes. phase-476 W0 — it rides
8575        // at the end of the entry's own trailing region, so releasing the entry
8576        // gives it back.
8577        let capture_len = Self::capture_trailing_len(capture);
8578
8579        if in_place {
8580            let (entry_offset, capture_at) =
8581                self.arena_alloc_with_trailing::<super::arena::SubInplaceRawCEntry>(capture_len)?;
8582            let context = self.place_capture(capture_at, capture, context);
8583            unsafe {
8584                let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8585                let entry_ptr =
8586                    arena_ptr.add(entry_offset) as *mut super::arena::SubInplaceRawCEntry;
8587                core::ptr::write(
8588                    entry_ptr,
8589                    super::arena::SubInplaceRawCEntry {
8590                        handle,
8591                        callback,
8592                        context,
8593                        capture: super::arena::CaptureGuard {
8594                            ctx: context,
8595                            drop: capture_drop,
8596                        },
8597                    },
8598                );
8599            }
8600            let meta = CallbackMeta {
8601                offset: entry_offset,
8602                kind: EntryKind::Subscription,
8603                arena_len: super::arena::ArenaLen::UNRECORDED,
8604                try_process: super::arena::sub_inplace_raw_c_try_process,
8605                has_data: super::arena::sub_inplace_raw_c_has_data,
8606                pre_sample: no_pre_sample,
8607                invocation: InvocationMode::OnNewData,
8608                drop_fn: drop_entry::<super::arena::SubInplaceRawCEntry>,
8609            };
8610            self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8611            self.apply_node_default_sched(slot, node_id, group);
8612            return Ok(self.handle_for(slot));
8613        }
8614
8615        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
8616
8617        // The ring first, then the capture on the next `u64` boundary after it.
8618        let ring_len = trailing_bytes.next_multiple_of(core::mem::align_of::<u64>());
8619        let (entry_offset, trailing_offset) =
8620            self.arena_alloc_with_trailing::<SubBufferedRawCEntry>(ring_len + capture_len)?;
8621        let context = self.place_capture(trailing_offset + ring_len, capture, context);
8622
8623        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
8624
8625        let buffer = if qos.depth <= 1 {
8626            BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
8627        } else {
8628            BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
8629        };
8630
8631        unsafe {
8632            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8633            let entry_ptr = arena_ptr.add(entry_offset) as *mut SubBufferedRawCEntry;
8634            core::ptr::write(
8635                entry_ptr,
8636                SubBufferedRawCEntry {
8637                    handle,
8638                    buffer,
8639                    callback,
8640                    context,
8641                    capture: super::arena::CaptureGuard {
8642                        ctx: context,
8643                        drop: capture_drop,
8644                    },
8645                },
8646            );
8647        }
8648
8649        let meta = CallbackMeta {
8650            offset: entry_offset,
8651            kind: EntryKind::Subscription,
8652            arena_len: super::arena::ArenaLen::UNRECORDED,
8653            try_process: sub_buffered_raw_c_try_process,
8654            has_data: sub_buffered_raw_c_has_data,
8655            pre_sample: no_pre_sample,
8656            invocation: InvocationMode::OnNewData,
8657            drop_fn: drop_entry::<SubBufferedRawCEntry>,
8658        };
8659        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8660        self.apply_node_default_sched(slot, node_id, group);
8661        Ok(self.handle_for(slot))
8662    }
8663
8664    /// phase-417 W5.a (RFC-0089 stage 5) — the TYPED C-FFI subscription core:
8665    /// the same registration as [`Self::add_arena_subscription_c_callback`],
8666    /// plus caller-owned message storage and the erased deserialiser for its
8667    /// type, so the callback receives a DESERIALISED message instead of CDR
8668    /// bytes.
8669    ///
8670    /// This is the Rust half of rclc's
8671    /// `rclc_executor_add_subscription(executor, subscription, msg, callback,
8672    /// invocation)`. rclc has no allocator on that path either — it makes the
8673    /// CALLER own the storage, which is exactly what `msg` is here. The C
8674    /// ledger recorded our byte-oriented delivery as forced by "no allocator";
8675    /// the compared surface operates under the same constraint, so the reason
8676    /// was false and the divergence was avoidable (issue 1022 / W-C3).
8677    ///
8678    /// The raw path is untouched and stays: a byte-oriented subscriber is a
8679    /// legitimate thing to want, and this is additive.
8680    ///
8681    /// # Contract on failure
8682    /// If `deserialize` returns non-zero the callback is **not** invoked, the
8683    /// sample is dropped, `try_process` reports
8684    /// `TransportError::DeserializationError` (so it lands in
8685    /// `SpinOnceResult::subscription_errors`), and a rate-limited `nros_log`
8686    /// error names the length and the return code. A message that does not fit
8687    /// the caller's storage arrives here as exactly that failure: both the
8688    /// bounded-string and bounded-sequence decoders refuse rather than
8689    /// truncate, so there is no silent short read.
8690    ///
8691    /// # Safety
8692    /// `msg` must point to storage of the type `deserialize` writes, valid for
8693    /// as long as the subscription is registered. Nothing here drops it.
8694    #[allow(clippy::too_many_arguments)]
8695    pub unsafe fn add_arena_subscription_c_typed_callback<const RX_BUF: usize>(
8696        &mut self,
8697        node_id: Option<super::node_record::NodeId>,
8698        topic_name: &str,
8699        type_name: &str,
8700        type_hash: &str,
8701        qos: QoSProfile,
8702        msg: core::ptr::NonNull<core::ffi::c_void>,
8703        deserialize: RawMessageDeserializeFn,
8704        callback: TypedSubscriptionCallback,
8705        context: *mut core::ffi::c_void,
8706        group: Option<&str>,
8707        // Same meaning as on the raw path: bytes the caller expects to receive,
8708        // 0 = no opinion. A typed caller normally HAS an answer — the generated
8709        // `<Msg>_RX_MAX_SERIALIZED_SIZE` — which is why the C entry point takes
8710        // it rather than defaulting.
8711        rx_buffer_hint: usize,
8712    ) -> Result<HandleId, NodeError> {
8713        let SubscriptionOpen {
8714            slot,
8715            handle,
8716            qos,
8717            slot_bytes: rx_bytes,
8718            ..
8719        } = self.open_subscription(&SubscriptionRequest {
8720            node_id,
8721            topic_name,
8722            type_name,
8723            type_hash,
8724            qos,
8725            backend_hint: Some(rx_buffer_hint),
8726            // phase-403 W3/W5, unchanged: the hint sizes the arena slot, and 0
8727            // means "this caller stated nothing", never "this type is zero
8728            // bytes".
8729            slot_bytes: if rx_buffer_hint != 0 {
8730                rx_buffer_hint
8731            } else {
8732                RX_BUF
8733            },
8734            // The callback receives the caller's DESERIALISED storage, written
8735            // by `deserialize` from the sample's bytes, so borrowed bytes would
8736            // be enough and this shape could take the in-place row. It does not
8737            // yet: `SubBufferedTypedCEntry` also owns the `msg` pointer and the
8738            // deserialiser, so an in-place twin is a second entry type rather
8739            // than a flag, and phase-456 W8's acceptance is the RAW C path.
8740            // Stated here rather than left blank because a `false` nobody wrote
8741            // down is what this whole item is about.
8742            in_place_capable: false,
8743        })?;
8744
8745        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, rx_bytes);
8746
8747        let (entry_offset, trailing_offset) =
8748            self.arena_alloc_with_trailing::<SubBufferedTypedCEntry>(trailing_bytes)?;
8749
8750        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
8751
8752        let buffer = if qos.depth <= 1 {
8753            BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, rx_bytes) })
8754        } else {
8755            BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, rx_bytes, qos.depth as usize) })
8756        };
8757
8758        unsafe {
8759            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8760            let entry_ptr = arena_ptr.add(entry_offset) as *mut SubBufferedTypedCEntry;
8761            core::ptr::write(
8762                entry_ptr,
8763                SubBufferedTypedCEntry {
8764                    handle,
8765                    buffer,
8766                    msg: msg.as_ptr(),
8767                    deserialize,
8768                    callback,
8769                    context,
8770                },
8771            );
8772        }
8773
8774        let meta = CallbackMeta {
8775            offset: entry_offset,
8776            kind: EntryKind::Subscription,
8777            arena_len: super::arena::ArenaLen::UNRECORDED,
8778            try_process: sub_buffered_typed_c_try_process,
8779            has_data: sub_buffered_typed_c_has_data,
8780            pre_sample: no_pre_sample,
8781            invocation: InvocationMode::OnNewData,
8782            drop_fn: drop_entry::<SubBufferedTypedCEntry>,
8783        };
8784        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8785        self.apply_node_default_sched(slot, node_id, group);
8786        Ok(self.handle_for(slot))
8787    }
8788
8789    /// Phase 189.M3.4 — register a raw C-fn-ptr subscription whose callback
8790    /// also receives the sample's wire **attachment**
8791    /// ([`RawSubscriptionInfoCallback`]: `(data, len, attachment, att_len,
8792    /// context)`) — the C analog of the Rust
8793    /// `node.subscription(t).generic(..).message_info()` builder. Backs the C
8794    /// FFI `rclc_executor_add_subscription_raw_with_info`. Flat per-entry
8795    /// payload + attachment buffers (cap `RAW_INFO_ATT_CAP`);
8796    /// one sample per `spin_once`.
8797    #[allow(clippy::too_many_arguments)]
8798    pub fn add_arena_subscription_c_info_callback<const RX_BUF: usize>(
8799        &mut self,
8800        node_id: Option<super::node_record::NodeId>,
8801        topic_name: &str,
8802        type_name: &str,
8803        type_hash: &str,
8804        qos: QoSProfile,
8805        callback: RawSubscriptionInfoCallback,
8806        context: *mut core::ffi::c_void,
8807        // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
8808        // opinion. It now buys BOTH halves: the BACKEND's payload size-class
8809        // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
8810        // survives only as the fallback for a caller that states nothing, which
8811        // is exactly its role on the plain C path.
8812        rx_buffer_hint: usize,
8813    ) -> Result<HandleId, NodeError> {
8814        type Entry = SubBufferedRawInfoCEntry;
8815
8816        let SubscriptionOpen {
8817            slot,
8818            handle,
8819            slot_bytes: rx_bytes,
8820            ..
8821        } = self.open_subscription(&SubscriptionRequest {
8822            node_id,
8823            topic_name,
8824            type_name,
8825            type_hash,
8826            qos,
8827            backend_hint: Some(rx_buffer_hint),
8828            // phase-408 W5b — the hint sizes the ARENA too, not just the
8829            // backend's size class. 0 still means "this caller stated nothing",
8830            // so `RX_BUF` -- `DEFAULT_RX_BUF_SIZE` at every call site -- is what
8831            // it falls back to, and an unhinted registration claims the same
8832            // bytes it always did.
8833            slot_bytes: if rx_buffer_hint != 0 {
8834                rx_buffer_hint
8835            } else {
8836                RX_BUF
8837            },
8838            // Carries the wire attachment — see the Rust `_raw_info_on` twin.
8839            in_place_capable: false,
8840        })?;
8841
8842        let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
8843        unsafe {
8844            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8845            let buf = super::arena::TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
8846            let entry_ptr = arena_ptr.add(offset) as *mut Entry;
8847            core::ptr::write(
8848                entry_ptr,
8849                Entry {
8850                    handle,
8851                    buffer: buf,
8852                    att: [0u8; super::arena::RAW_INFO_ATT_CAP],
8853                    callback,
8854                    context,
8855                },
8856            );
8857        }
8858
8859        let meta = CallbackMeta {
8860            offset,
8861            kind: EntryKind::Subscription,
8862            arena_len: super::arena::ArenaLen::UNRECORDED,
8863            try_process: sub_buffered_raw_info_c_try_process,
8864            has_data: sub_buffered_raw_info_c_has_data,
8865            pre_sample: no_pre_sample,
8866            invocation: InvocationMode::OnNewData,
8867            drop_fn: drop_entry::<Entry>,
8868        };
8869        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8870        self.apply_node_default_sched(slot, node_id, None);
8871        Ok(self.handle_for(slot))
8872    }
8873
8874    /// Phase 269 W3 — register a raw C-fn-ptr subscription whose callback
8875    /// ALSO surfaces the sample's E2E integrity status (CRC + sequence gap/dup)
8876    /// alongside the CDR bytes — the C/C++ component-callback analog of Rust's
8877    /// `register_subscription_buffered_raw_safety_on` (`FnMut(&[u8], &IntegrityStatus)`).
8878    ///
8879    /// The executor validates the sample via `take_validated` and unpacks the
8880    /// [`nros_rmw::IntegrityStatus`] into three plain scalars (gap, duplicate,
8881    /// crc_valid) before calling `callback`. This avoids introducing a
8882    /// cbindgen-visible struct at the executor layer; the C/C++ headers pack them
8883    /// back into their local integrity-status typedef.
8884    ///
8885    /// Requires the `safety-e2e` feature. Backed by `SubBufferedRawSafetyCEntry`.
8886    #[cfg(feature = "safety-e2e")]
8887    #[allow(clippy::too_many_arguments)]
8888    pub fn add_arena_subscription_c_validated_callback<const RX_BUF: usize>(
8889        &mut self,
8890        node_id: Option<super::node_record::NodeId>,
8891        topic_name: &str,
8892        type_name: &str,
8893        type_hash: &str,
8894        qos: QoSProfile,
8895        callback: super::types::RawSubscriptionSafetyCallback,
8896        context: *mut core::ffi::c_void,
8897        // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
8898        // opinion. It now buys BOTH halves: the BACKEND's payload size-class
8899        // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
8900        // survives only as the fallback for a caller that states nothing, which
8901        // is exactly its role on the plain C path.
8902        rx_buffer_hint: usize,
8903    ) -> Result<HandleId, NodeError> {
8904        use super::arena::{
8905            SubBufferedRawSafetyCEntry, TrailingBuf, sub_buffered_raw_safety_c_has_data,
8906            sub_buffered_raw_safety_c_try_process,
8907        };
8908        type Entry = SubBufferedRawSafetyCEntry;
8909
8910        let SubscriptionOpen {
8911            slot,
8912            handle,
8913            slot_bytes: rx_bytes,
8914            ..
8915        } = self.open_subscription(&SubscriptionRequest {
8916            node_id,
8917            topic_name,
8918            type_name,
8919            type_hash,
8920            qos,
8921            backend_hint: Some(rx_buffer_hint),
8922            // phase-408 W5b — same as the info sibling: one flat slot in the
8923            // trailing region, sized from the hint, `RX_BUF` only as the
8924            // stated-nothing fallback.
8925            slot_bytes: if rx_buffer_hint != 0 {
8926                rx_buffer_hint
8927            } else {
8928                RX_BUF
8929            },
8930            // Carries the integrity status, which comes from `take_validated`.
8931            in_place_capable: false,
8932        })?;
8933
8934        let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
8935        unsafe {
8936            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8937            let buf = TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
8938            let entry_ptr = arena_ptr.add(offset) as *mut Entry;
8939            core::ptr::write(
8940                entry_ptr,
8941                Entry {
8942                    handle,
8943                    buffer: buf,
8944                    callback,
8945                    context,
8946                },
8947            );
8948        }
8949
8950        let meta = CallbackMeta {
8951            offset,
8952            kind: EntryKind::Subscription,
8953            arena_len: super::arena::ArenaLen::UNRECORDED,
8954            try_process: sub_buffered_raw_safety_c_try_process,
8955            has_data: sub_buffered_raw_safety_c_has_data,
8956            pre_sample: no_pre_sample,
8957            invocation: InvocationMode::OnNewData,
8958            drop_fn: drop_entry::<Entry>,
8959        };
8960        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8961        self.apply_node_default_sched(slot, node_id, None);
8962        Ok(self.handle_for(slot))
8963    }
8964
8965    /// Register a raw (untyped) service callback.
8966    ///
8967    /// Register a raw (untyped) service callback with the default buffer size.
8968    ///
8969    /// The callback receives and produces CDR bytes without typed
8970    /// deserialization/serialization. Used by the C API wrapper.
8971    pub fn register_service_raw(
8972        &mut self,
8973        service_name: &str,
8974        service_type: &str,
8975        service_hash: &str,
8976        callback: RawServiceCallback,
8977        context: *mut core::ffi::c_void,
8978    ) -> Result<HandleId, NodeError> {
8979        self.register_service_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(
8980            service_name,
8981            service_type,
8982            service_hash,
8983            QoSProfile::services_default(),
8984            callback,
8985            context,
8986        )
8987    }
8988
8989    /// Register a raw (untyped) service callback with custom buffer sizes + QoS.
8990    ///
8991    /// `REQ_BUF` and `REPLY_BUF` set the stack-allocated CDR buffers
8992    /// for the request and reply respectively. Increase for services
8993    /// with large payloads (e.g., parameter services). `qos` applies to both
8994    /// the request + reply endpoints (Phase 193.2c).
8995    #[allow(clippy::too_many_arguments)]
8996    pub fn register_service_raw_sized<const REQ_BUF: usize, const REPLY_BUF: usize>(
8997        &mut self,
8998        service_name: &str,
8999        service_type: &str,
9000        service_hash: &str,
9001        qos: QoSProfile,
9002        callback: RawServiceCallback,
9003        context: *mut core::ffi::c_void,
9004    ) -> Result<HandleId, NodeError> {
9005        self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF>(
9006            None,
9007            service_name,
9008            service_type,
9009            service_hash,
9010            qos,
9011            callback,
9012            context,
9013        )
9014    }
9015
9016    /// Phase 104.C.3.3.a — Node-aware variant of
9017    /// [`Self::register_service_raw_sized`]. C-FFI path.
9018    #[allow(clippy::too_many_arguments)]
9019    pub fn register_service_raw_sized_on<const REQ_BUF: usize, const REPLY_BUF: usize>(
9020        &mut self,
9021        node_id: super::node_record::NodeId,
9022        service_name: &str,
9023        service_type: &str,
9024        service_hash: &str,
9025        qos: QoSProfile,
9026        callback: RawServiceCallback,
9027        context: *mut core::ffi::c_void,
9028    ) -> Result<HandleId, NodeError> {
9029        self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF>(
9030            Some(node_id),
9031            service_name,
9032            service_type,
9033            service_hash,
9034            qos,
9035            callback,
9036            context,
9037        )
9038    }
9039
9040    #[allow(clippy::too_many_arguments)]
9041    fn register_service_raw_sized_inner<const REQ_BUF: usize, const REPLY_BUF: usize>(
9042        &mut self,
9043        node_id: Option<super::node_record::NodeId>,
9044        service_name: &str,
9045        service_type: &str,
9046        service_hash: &str,
9047        qos: QoSProfile,
9048        callback: RawServiceCallback,
9049        context: *mut core::ffi::c_void,
9050    ) -> Result<HandleId, NodeError> {
9051        let slot = self.next_entry_slot()?;
9052        let (node_name, ns, session_idx) = match node_id {
9053            Some(id) => {
9054                let r = self
9055                    .nodes
9056                    .get(id.index())
9057                    .ok_or(NodeError::InvalidSchedContextBinding)?;
9058                (r.name.clone(), r.namespace.clone(), r.session_idx)
9059            }
9060            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
9061        };
9062        let mut info = ServiceInfo::new(service_name, service_type, service_hash)
9063            .with_domain(self.domain_id)
9064            .with_namespace(&ns);
9065        if !node_name.is_empty() {
9066            info = info.with_node_name(&node_name);
9067        }
9068        let handle = {
9069            let session = self
9070                .session_at_mut(session_idx)
9071                .ok_or(NodeError::BackendMismatch)?;
9072            // Phase 193.5 — validate against the backend's supported policies
9073            // (no silent downgrade); request/reply effectively requires RELIABLE.
9074            super::node::validate_qos_or_report(
9075                &qos,
9076                session.supported_qos_policies(),
9077                "service",
9078                service_name,
9079            )?;
9080            session
9081                .create_service(&info, qos)
9082                .map_err(NodeError::Transport)?
9083        };
9084
9085        let offset = self.arena_alloc::<SrvRawEntry<REQ_BUF, REPLY_BUF>>()?;
9086
9087        unsafe {
9088            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9089            let entry_ptr = arena_ptr.add(offset) as *mut SrvRawEntry<REQ_BUF, REPLY_BUF>;
9090            core::ptr::write(
9091                entry_ptr,
9092                SrvRawEntry {
9093                    handle,
9094                    req_buffer: [0u8; REQ_BUF],
9095                    reply_buffer: [0u8; REPLY_BUF],
9096                    callback,
9097                    context,
9098                },
9099            );
9100        }
9101
9102        let meta = CallbackMeta {
9103            offset,
9104            kind: EntryKind::Service,
9105            arena_len: super::arena::ArenaLen::UNRECORDED,
9106            try_process: srv_raw_try_process::<REQ_BUF, REPLY_BUF>,
9107            has_data: srv_raw_has_data::<REQ_BUF, REPLY_BUF>,
9108            pre_sample: no_pre_sample,
9109            invocation: InvocationMode::OnNewData,
9110            drop_fn: drop_entry::<SrvRawEntry<REQ_BUF, REPLY_BUF>>,
9111        };
9112        self.emplace_entry(slot, meta, TraceName::Text(service_name));
9113        self.apply_node_default_sched(slot, node_id, None);
9114        Ok(self.handle_for(slot))
9115    }
9116
9117    // ========================================================================
9118    // Raw service client registration (Phase 82)
9119    // ========================================================================
9120
9121    /// Register a raw (untyped) service client with the default reply
9122    /// buffer size.
9123    ///
9124    /// The client is owned by the executor's arena. Each `spin_once`
9125    /// dispatch polls the in-flight reply slot via `take_response_raw`
9126    /// and fires the registered callback when the response arrives.
9127    /// Used by the C API thin wrapper — see Phase 82.
9128    pub fn register_service_client_raw(
9129        &mut self,
9130        service_name: &str,
9131        service_type: &str,
9132        service_hash: &str,
9133        callback: Option<RawResponseCallback>,
9134        context: *mut core::ffi::c_void,
9135    ) -> Result<HandleId, NodeError> {
9136        self.register_service_client_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }>(
9137            service_name,
9138            service_type,
9139            service_hash,
9140            QoSProfile::services_default(),
9141            callback,
9142            context,
9143        )
9144    }
9145
9146    /// Register a raw service client with a custom reply buffer size + QoS.
9147    ///
9148    /// `qos` applies to the client's request + reply endpoints (Phase 193.3b);
9149    /// defaults to [`QoSProfile::services_default`] via the convenience
9150    /// wrapper.
9151    #[allow(clippy::too_many_arguments)]
9152    pub fn register_service_client_raw_sized<const REPLY_BUF: usize>(
9153        &mut self,
9154        service_name: &str,
9155        service_type: &str,
9156        service_hash: &str,
9157        qos: QoSProfile,
9158        callback: Option<RawResponseCallback>,
9159        context: *mut core::ffi::c_void,
9160    ) -> Result<HandleId, NodeError> {
9161        self.register_service_client_raw_sized_inner::<REPLY_BUF>(
9162            None,
9163            service_name,
9164            service_type,
9165            service_hash,
9166            qos,
9167            callback,
9168            context,
9169        )
9170    }
9171
9172    /// Phase 104.C.3.3.a — Node-aware variant of
9173    /// [`Self::register_service_client_raw_sized`]. Routes the client
9174    /// creation through the named Node's session.
9175    #[allow(clippy::too_many_arguments)]
9176    pub fn register_service_client_raw_sized_on<const REPLY_BUF: usize>(
9177        &mut self,
9178        node_id: super::node_record::NodeId,
9179        service_name: &str,
9180        service_type: &str,
9181        service_hash: &str,
9182        qos: QoSProfile,
9183        callback: Option<RawResponseCallback>,
9184        context: *mut core::ffi::c_void,
9185    ) -> Result<HandleId, NodeError> {
9186        self.register_service_client_raw_sized_inner::<REPLY_BUF>(
9187            Some(node_id),
9188            service_name,
9189            service_type,
9190            service_hash,
9191            qos,
9192            callback,
9193            context,
9194        )
9195    }
9196
9197    #[allow(clippy::too_many_arguments)]
9198    fn register_service_client_raw_sized_inner<const REPLY_BUF: usize>(
9199        &mut self,
9200        node_id: Option<super::node_record::NodeId>,
9201        service_name: &str,
9202        service_type: &str,
9203        service_hash: &str,
9204        qos: QoSProfile,
9205        callback: Option<RawResponseCallback>,
9206        context: *mut core::ffi::c_void,
9207    ) -> Result<HandleId, NodeError> {
9208        let slot = self.next_entry_slot()?;
9209        let (node_name, ns, session_idx) = match node_id {
9210            Some(id) => {
9211                let r = self
9212                    .nodes
9213                    .get(id.index())
9214                    .ok_or(NodeError::InvalidSchedContextBinding)?;
9215                (r.name.clone(), r.namespace.clone(), r.session_idx)
9216            }
9217            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
9218        };
9219        let mut info = ServiceInfo::new(service_name, service_type, service_hash)
9220            .with_domain(self.domain_id)
9221            .with_namespace(&ns);
9222        if !node_name.is_empty() {
9223            info = info.with_node_name(&node_name);
9224        }
9225        let handle = {
9226            let session = self
9227                .session_at_mut(session_idx)
9228                .ok_or(NodeError::BackendMismatch)?;
9229            // Phase 193.5 — validate against the backend's supported policies
9230            // (no silent downgrade); request/reply effectively requires RELIABLE.
9231            super::node::validate_qos_or_report(
9232                &qos,
9233                session.supported_qos_policies(),
9234                "client",
9235                service_name,
9236            )?;
9237            session
9238                .create_client(&info, qos)
9239                .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
9240        };
9241
9242        let offset = self.arena_alloc::<ServiceClientRawArenaEntry<REPLY_BUF>>()?;
9243        unsafe {
9244            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9245            let entry_ptr = arena_ptr.add(offset) as *mut ServiceClientRawArenaEntry<REPLY_BUF>;
9246            core::ptr::write(
9247                entry_ptr,
9248                ServiceClientRawArenaEntry {
9249                    handle,
9250                    reply_buffer: [0u8; REPLY_BUF],
9251                    pending: false,
9252                    reply_ready: core::sync::atomic::AtomicBool::new(false),
9253                    callback,
9254                    context,
9255                },
9256            );
9257        }
9258
9259        let meta = CallbackMeta {
9260            offset,
9261            kind: EntryKind::ServiceClient,
9262            arena_len: super::arena::ArenaLen::UNRECORDED,
9263            try_process: service_client_raw_try_process::<REPLY_BUF>,
9264            has_data: always_ready,
9265            pre_sample: no_pre_sample,
9266            invocation: InvocationMode::Always,
9267            drop_fn: drop_entry::<ServiceClientRawArenaEntry<REPLY_BUF>>,
9268        };
9269        self.emplace_entry(slot, meta, TraceName::Text(service_name));
9270        self.apply_node_default_sched(slot, node_id, None);
9271        Ok(self.handle_for(slot))
9272    }
9273
9274    /// RFC-0041 / Phase 239.1 — register a **typed callback** service client.
9275    /// The reply is eager-drained at `spin_once` and dispatched to `callback` as
9276    /// a deserialized `Svc::Reply`. Returns the scheduling [`HandleId`] and a
9277    /// `*mut` to the arena entry's send header (used to build the typed
9278    /// [`ServiceClientCallback`](super::handles::ServiceClientCallback)).
9279    #[allow(clippy::too_many_arguments)]
9280    pub(crate) fn register_service_client_callback<Svc, F, const REPLY_BUF: usize>(
9281        &mut self,
9282        node_id: Option<super::node_record::NodeId>,
9283        service_name: &str,
9284        service_type: &str,
9285        service_hash: &str,
9286        qos: QoSProfile,
9287        callback: F,
9288    ) -> Result<(HandleId, *mut ServiceClientSendHeader<REPLY_BUF>), NodeError>
9289    where
9290        Svc: nros_core::RosService + 'static,
9291        F: FnMut(&Svc::Reply) + 'static,
9292    {
9293        let slot = self.next_entry_slot()?;
9294        let (node_name, ns, session_idx) = match node_id {
9295            Some(id) => {
9296                let r = self
9297                    .nodes
9298                    .get(id.index())
9299                    .ok_or(NodeError::InvalidSchedContextBinding)?;
9300                (r.name.clone(), r.namespace.clone(), r.session_idx)
9301            }
9302            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
9303        };
9304        let mut info = ServiceInfo::new(service_name, service_type, service_hash)
9305            .with_domain(self.domain_id)
9306            .with_namespace(&ns);
9307        if !node_name.is_empty() {
9308            info = info.with_node_name(&node_name);
9309        }
9310        let handle = {
9311            let session = self
9312                .session_at_mut(session_idx)
9313                .ok_or(NodeError::BackendMismatch)?;
9314            super::node::validate_qos_or_report(
9315                &qos,
9316                session.supported_qos_policies(),
9317                "client",
9318                service_name,
9319            )?;
9320            session
9321                .create_client(&info, qos)
9322                .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
9323        };
9324
9325        let offset = self.arena_alloc::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>()?;
9326        let hdr_ptr = unsafe {
9327            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9328            let entry_ptr =
9329                arena_ptr.add(offset) as *mut ServiceClientCallbackEntry<Svc, F, REPLY_BUF>;
9330            core::ptr::write(
9331                entry_ptr,
9332                ServiceClientCallbackEntry {
9333                    hdr: ServiceClientSendHeader {
9334                        handle,
9335                        reply_buffer: [0u8; REPLY_BUF],
9336                        pending: false,
9337                        reply_ready: core::sync::atomic::AtomicBool::new(false),
9338                    },
9339                    callback,
9340                    _phantom: core::marker::PhantomData,
9341                },
9342            );
9343            &mut (*entry_ptr).hdr as *mut ServiceClientSendHeader<REPLY_BUF>
9344        };
9345
9346        let meta = CallbackMeta {
9347            offset,
9348            kind: EntryKind::ServiceClient,
9349            arena_len: super::arena::ArenaLen::UNRECORDED,
9350            try_process: service_client_callback_try_process::<Svc, F, REPLY_BUF>,
9351            has_data: always_ready,
9352            pre_sample: no_pre_sample,
9353            invocation: InvocationMode::Always,
9354            drop_fn: drop_entry::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>,
9355        };
9356        self.emplace_entry(slot, meta, TraceName::Text(service_name));
9357        self.apply_node_default_sched(slot, node_id, None);
9358        Ok((self.handle_for(slot), hdr_ptr))
9359    }
9360
9361    // ========================================================================
9362    // Guard condition registration
9363    // ========================================================================
9364
9365    /// Register a guard condition with a callback, on no particular node.
9366    ///
9367    /// Returns both the [`HandleId`] for trigger configuration and a
9368    /// [`GuardCondition`] for triggering from other threads.
9369    ///
9370    /// **This is the ours-only PRIMITIVE, not the verb a user reaches for.**
9371    /// phase-417 W4.e settled that the NODE owns a guard condition in all three
9372    /// languages, so the spelling an application writes is
9373    /// [`Node::create_guard_condition`](crate::executor::Node::create_guard_condition)
9374    /// — which is this function with a node bound, so the guard inherits that
9375    /// node's `SchedContext` like every other entity the node creates. This one
9376    /// stays because the executor is what owns the arena the flag lives in, and
9377    /// because an image with no node at all (a bare `Executor::open` plus a
9378    /// wake source) still has to be able to make one.
9379    pub fn register_guard_condition<F>(
9380        &mut self,
9381        callback: F,
9382    ) -> Result<(HandleId, GuardCondition), NodeError>
9383    where
9384        F: FnMut() + 'static,
9385    {
9386        self.register_guard_condition_on(None, callback)
9387    }
9388
9389    /// The node-bound form of [`register_guard_condition`](Self::register_guard_condition)
9390    /// — phase-417 W4.e.
9391    ///
9392    /// `node_id == None` is the legacy nodeless path and behaves exactly as
9393    /// `register_guard_condition` always did. With a node, the registered slot
9394    /// takes that node's default `SchedContext`, which is what "the node owns
9395    /// it" has to mean for a callback that shares one executor with every other
9396    /// tier in the image (RFC-0047).
9397    pub fn register_guard_condition_on<F>(
9398        &mut self,
9399        node_id: Option<super::node_record::NodeId>,
9400        callback: F,
9401    ) -> Result<(HandleId, GuardCondition), NodeError>
9402    where
9403        F: FnMut() + 'static,
9404    {
9405        let slot = self.next_entry_slot()?;
9406        let offset = self.arena_alloc::<GuardConditionEntry<F>>()?;
9407
9408        unsafe {
9409            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9410            let entry_ptr = arena_ptr.add(offset) as *mut GuardConditionEntry<F>;
9411            core::ptr::write(
9412                entry_ptr,
9413                GuardConditionEntry {
9414                    flag: portable_atomic::AtomicBool::new(false),
9415                    callback,
9416                },
9417            );
9418
9419            // Create a handle pointing to the flag in the arena
9420            let flag_ptr = &(*entry_ptr).flag as *const portable_atomic::AtomicBool;
9421            #[allow(unused_mut)]
9422            let mut guard_handle = GuardCondition::new(flag_ptr);
9423            // Phase 124.B.5 — wire the wake callback so trigger()
9424            // also signals the executor's wake_cv.
9425            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
9426            {
9427                let ctx = self.wake_ctx_ptr();
9428                guard_handle.set_wake_cb(nros_rmw_runtime_wake_cb, ctx);
9429                self.has_cross_thread_waker = true;
9430            }
9431
9432            let meta = CallbackMeta {
9433                offset,
9434                kind: EntryKind::GuardCondition,
9435                arena_len: super::arena::ArenaLen::UNRECORDED,
9436                try_process: guard_try_process::<F>,
9437                has_data: guard_has_data::<F>,
9438                pre_sample: no_pre_sample,
9439                invocation: InvocationMode::OnNewData,
9440                drop_fn: drop_entry::<GuardConditionEntry<F>>,
9441            };
9442            self.emplace_entry(slot, meta, TraceName::Slot("guard", slot));
9443            self.apply_node_default_sched(slot, node_id, None);
9444
9445            Ok((self.handle_for(slot), guard_handle))
9446        }
9447    }
9448
9449    /// phase-467 Row 8 — a guard condition the BACKEND triggers when the ROS
9450    /// graph changes. Upstream's `rcl_node_get_graph_guard_condition`, under
9451    /// this project's guard-condition shape.
9452    ///
9453    /// **The verb is upstream's, the shape is ours, and the difference is
9454    /// stated rather than silent.** `rcl_node_get_graph_guard_condition`
9455    /// RETURNS a borrowed handle to a guard the node owns, which the caller
9456    /// then adds to a wait set. RFC-0018/0019 forbids returning an owned
9457    /// handle across the C seam, we decline wait sets (RFC-0002: one executor
9458    /// per RTOS task), and a guard condition here is created into CALLER
9459    /// storage with its callback bound at creation. So this CREATES one —
9460    /// exactly as [`register_guard_condition_on`] does — and differs from it
9461    /// only in who pulls the trigger.
9462    ///
9463    /// Order matters and is the reason this is one verb rather than two. The
9464    /// backend is asked whether it HAS a graph before a handle slot is
9465    /// consumed, so a caller on XRCE or uORB gets `Unsupported` and an
9466    /// unchanged executor, never a guard condition nothing will ever fire.
9467    ///
9468    /// [`register_guard_condition_on`]: Self::register_guard_condition_on
9469    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
9470    pub fn register_graph_change_guard_on<F>(
9471        &mut self,
9472        node_id: Option<super::node_record::NodeId>,
9473        callback: F,
9474    ) -> Result<(HandleId, GuardCondition), NodeError>
9475    where
9476        F: FnMut() + 'static,
9477    {
9478        // The probe FIRST — see the doc comment. `supports_*` asks by
9479        // clearing, which is idempotent and costs nothing.
9480        if !self.supports_graph_change_callback() {
9481            return Err(NodeError::Transport(TransportError::Unsupported));
9482        }
9483        let (handle_id, guard) = self.register_guard_condition_on(node_id, callback)?;
9484        self.install_graph_change_guard(&guard)?;
9485        Ok((handle_id, guard))
9486    }
9487
9488    /// The nodeless form of
9489    /// [`register_graph_change_guard_on`](Self::register_graph_change_guard_on),
9490    /// for an image with a session and no node — the same pairing
9491    /// [`register_guard_condition`](Self::register_guard_condition) has.
9492    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
9493    pub fn register_graph_change_guard<F>(
9494        &mut self,
9495        callback: F,
9496    ) -> Result<(HandleId, GuardCondition), NodeError>
9497    where
9498        F: FnMut() + 'static,
9499    {
9500        self.register_graph_change_guard_on(None, callback)
9501    }
9502
9503    /// The same two verbs on a build with no `rmw-cffi` backend seam, or no
9504    /// allocator — they REFUSE rather than disappear.
9505    ///
9506    /// The cfg'd-out spelling was the first shape and it was wrong in a way
9507    /// `just check fast` cannot see: `rclc_node_get_graph_guard_condition` is
9508    /// a C ABI symbol and calls this unconditionally, so a feature
9509    /// combination without the seam stopped `nros-c` from COMPILING
9510    /// (`check-build`'s `workspace-features`, which is the lane that unifies
9511    /// nothing and therefore sees each combination as a user would).
9512    ///
9513    /// Refusing is also the honest answer rather than a compile fix. The
9514    /// graph-change edge is installed on a SESSION through the backend seam;
9515    /// without that seam there is no backend to install it on, and the
9516    /// executor-owned target needs an allocator for the box the backend holds
9517    /// a pointer to. `Unsupported` is what a caller gets from a backend with
9518    /// no graph too, so the C entry point answers `RCL_RET_UNSUPPORTED` in
9519    /// both cases and a ported program sees one behaviour, not two.
9520    #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
9521    pub fn register_graph_change_guard_on<F>(
9522        &mut self,
9523        node_id: Option<super::node_record::NodeId>,
9524        callback: F,
9525    ) -> Result<(HandleId, GuardCondition), NodeError>
9526    where
9527        F: FnMut() + 'static,
9528    {
9529        let _ = (node_id, callback);
9530        Err(NodeError::Transport(TransportError::Unsupported))
9531    }
9532
9533    /// The nodeless form on a build with no backend seam — see
9534    /// [`register_graph_change_guard_on`](Self::register_graph_change_guard_on).
9535    #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
9536    pub fn register_graph_change_guard<F>(
9537        &mut self,
9538        callback: F,
9539    ) -> Result<(HandleId, GuardCondition), NodeError>
9540    where
9541        F: FnMut() + 'static,
9542    {
9543        self.register_graph_change_guard_on(None, callback)
9544    }
9545
9546    // ========================================================================
9547    // Timer control methods
9548    // ========================================================================
9549
9550    /// Cancel a timer. A cancelled timer will not fire but still accumulates
9551    /// elapsed time. The timer can be restarted with [`reset_timer()`](Self::reset_timer).
9552    pub fn cancel_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
9553        let meta = self
9554            .entries
9555            .get(self.live_slot(id))
9556            .and_then(|e| e.as_ref())
9557            .ok_or(NodeError::BufferTooSmall)?;
9558        if !matches!(meta.kind, EntryKind::Timer) {
9559            return Err(NodeError::BufferTooSmall);
9560        }
9561        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9562        // SAFETY: meta.offset points to a valid TimerEntry<F> which shares
9563        // layout with TimerHeader for its initial fields (both #[repr(C)]).
9564        let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
9565        header.cancelled = true;
9566        Ok(())
9567    }
9568
9569    /// Reset a timer. Clears the cancelled state and resets the elapsed time
9570    /// to zero, so the timer starts a fresh period.
9571    pub fn reset_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
9572        let meta = self
9573            .entries
9574            .get(self.live_slot(id))
9575            .and_then(|e| e.as_ref())
9576            .ok_or(NodeError::BufferTooSmall)?;
9577        if !matches!(meta.kind, EntryKind::Timer) {
9578            return Err(NodeError::BufferTooSmall);
9579        }
9580        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9581        let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
9582        header.cancelled = false;
9583        header.elapsed_us = 0;
9584        Ok(())
9585    }
9586
9587    /// Check if a timer is cancelled.
9588    pub fn timer_is_canceled(&self, id: HandleId) -> bool {
9589        let meta = match self
9590            .entries
9591            .get(self.live_slot(id))
9592            .and_then(|e| e.as_ref())
9593        {
9594            Some(m) if matches!(m.kind, EntryKind::Timer) => m,
9595            _ => return false,
9596        };
9597        let arena_ptr = self.arena.as_ptr() as *const u8;
9598        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9599        header.cancelled
9600    }
9601
9602    /// Get the period of a timer in milliseconds (truncated — see
9603    /// [`Self::timer_period_us`]), or `None` if the handle is not a
9604    /// valid timer.
9605    pub fn timer_period_ms(&self, id: HandleId) -> Option<u64> {
9606        let meta = self
9607            .entries
9608            .get(self.live_slot(id))
9609            .and_then(|e| e.as_ref())
9610            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9611        let arena_ptr = self.arena.as_ptr() as *const u8;
9612        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9613        Some(header.period_us / 1000)
9614    }
9615
9616    /// Get the period of a timer in microseconds, or `None` if the
9617    /// handle is not a valid timer.
9618    pub fn timer_period_us(&self, id: HandleId) -> Option<u64> {
9619        let meta = self
9620            .entries
9621            .get(self.live_slot(id))
9622            .and_then(|e| e.as_ref())
9623            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9624        let arena_ptr = self.arena.as_ptr() as *const u8;
9625        // SAFETY: same layout invariant as `timer_period_ms`.
9626        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9627        Some(header.period_us)
9628    }
9629
9630    /// Swap a registered timer's period, in microseconds, returning the one
9631    /// it had — or `None` if the handle is not a valid timer.
9632    ///
9633    /// `TimerHeader::period_us` is the number `arena::timer_try_process`
9634    /// compares `elapsed_us` against, so this writes the period the
9635    /// DISPATCHER honours rather than a second copy of it (RFC-0019). The C
9636    /// entry point `rcl_timer_exchange_period` also keeps
9637    /// `rcl_timer_t::period_ns` in step, because that field is the
9638    /// registration input a later `rclc_executor_add_timer` would re-read.
9639    ///
9640    /// `elapsed_us` is deliberately NOT rewound, which is rcl's own behaviour:
9641    /// `rcl_timer_exchange_period` swaps the period and touches nothing else,
9642    /// so a timer shortened below its accumulated elapsed time becomes ready
9643    /// at once and one lengthened past it simply waits longer. A caller that
9644    /// wants the new period to start fresh calls
9645    /// [`reset_timer`](Self::reset_timer) after it.
9646    ///
9647    /// phase-417 W5.c — the Rust half of `c:timer_exchange_period`, the last
9648    /// timer row the C ledger filed as `gap`. The answer lives here and only
9649    /// the spelling lives in the wrapper, the same split
9650    /// [`timer_elapsed_us`](Self::timer_elapsed_us) made.
9651    pub fn exchange_timer_period_us(&mut self, id: HandleId, new_period_us: u64) -> Option<u64> {
9652        let offset = self
9653            .entries
9654            .get(self.live_slot(id))
9655            .and_then(|e| e.as_ref())
9656            .filter(|m| matches!(m.kind, EntryKind::Timer))?
9657            .offset;
9658        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9659        // SAFETY: same layout invariant as `timer_period_us`.
9660        let header = unsafe { &mut *(arena_ptr.add(offset) as *mut TimerHeader) };
9661        let old_period_us = header.period_us;
9662        header.period_us = new_period_us;
9663        Some(old_period_us)
9664    }
9665
9666    /// Set a timer's overrun policy (issue #505). Timers default to
9667    /// [`TimerOverrunPolicy::Skip`]; switch to
9668    /// [`TimerOverrunPolicy::CatchUp`] for timers whose every activation
9669    /// is a unit of work that must not be lost.
9670    pub fn set_timer_overrun_policy(
9671        &mut self,
9672        id: HandleId,
9673        policy: TimerOverrunPolicy,
9674    ) -> Result<(), NodeError> {
9675        let meta = self
9676            .entries
9677            .get(self.live_slot(id))
9678            .and_then(|e| e.as_ref())
9679            .ok_or(NodeError::BufferTooSmall)?;
9680        if !matches!(meta.kind, EntryKind::Timer) {
9681            return Err(NodeError::BufferTooSmall);
9682        }
9683        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9684        // SAFETY: same layout invariant as `cancel_timer`.
9685        let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
9686        header.overrun_policy = policy;
9687        Ok(())
9688    }
9689
9690    /// Periods this timer missed and dropped under
9691    /// [`TimerOverrunPolicy::Skip`] (issue #505), or `None` if the handle
9692    /// is not a valid timer.
9693    ///
9694    /// Monotonic and saturating. A growing count is the on-target signal
9695    /// that a tier is not keeping up with its declared cadence — the
9696    /// symptom an external observer would otherwise have to infer by
9697    /// differencing timestamps.
9698    pub fn timer_overruns(&self, id: HandleId) -> Option<u32> {
9699        let meta = self
9700            .entries
9701            .get(self.live_slot(id))
9702            .and_then(|e| e.as_ref())
9703            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9704        let arena_ptr = self.arena.as_ptr() as *const u8;
9705        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9706        Some(header.overruns)
9707    }
9708
9709    /// Microseconds accumulated since this timer last fired, or `None` if the
9710    /// handle is not a valid timer.
9711    ///
9712    /// This is `TimerHeader::elapsed_us` — the SAME counter
9713    /// `arena::timer_try_process` compares against the period and rewinds on
9714    /// every activation, so it is the executor's own answer rather than a
9715    /// second one derived from a clock. `None` is not "zero elapsed": a caller
9716    /// that cannot tell them apart reports a timer that has just fired and a
9717    /// handle that is not a timer identically.
9718    ///
9719    /// phase-417 W5.c — added because the state was already here and nothing
9720    /// exposed it, which is why `c:timer_get_time_since_last_call` and
9721    /// `c:timer_is_ready` were both filed as gaps. The C forwarders
9722    /// (`rcl_timer_get_time_since_last_call`, `nros_timer_is_ready`) read
9723    /// this; RFC-0019 puts the answer in Rust and only the spelling in the
9724    /// wrapper.
9725    pub fn timer_elapsed_us(&self, id: HandleId) -> Option<u64> {
9726        let meta = self
9727            .entries
9728            .get(self.live_slot(id))
9729            .and_then(|e| e.as_ref())
9730            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9731        let arena_ptr = self.arena.as_ptr() as *const u8;
9732        // SAFETY: same layout invariant as `timer_period_us`.
9733        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9734        Some(header.elapsed_us)
9735    }
9736
9737    /// Would this timer fire on the next dispatch pass? `None` if the handle
9738    /// is not a valid timer.
9739    ///
9740    /// The predicate is `arena::timer_try_process`'s own guard, in the same
9741    /// order: a cancelled timer is never ready, a one-shot that already fired
9742    /// is never ready again, and otherwise readiness is `elapsed >= period`.
9743    /// Keeping the two in one shape is the point — a readiness answer that
9744    /// disagrees with the dispatcher is worse than no answer at all.
9745    pub fn timer_is_ready(&self, id: HandleId) -> Option<bool> {
9746        let meta = self
9747            .entries
9748            .get(self.live_slot(id))
9749            .and_then(|e| e.as_ref())
9750            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9751        let arena_ptr = self.arena.as_ptr() as *const u8;
9752        // SAFETY: same layout invariant as `timer_period_us`.
9753        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9754        if header.cancelled || (header.oneshot && header.fired) {
9755            return Some(false);
9756        }
9757        Some(header.elapsed_us >= header.period_us)
9758    }
9759
9760    /// Nanoseconds until this timer next fires — NEGATIVE when it is overdue.
9761    /// `None` if the handle is not a valid timer.
9762    ///
9763    /// phase-417 G6. THE computation, and it has to be: rcl's own pair are
9764    /// `next_call_time` and `next_call_time - now`, so upstream cannot have
9765    /// them disagree either. Before this it lived in `nros-c`
9766    /// (`remaining_period_ns` + `time_until_next_call_ns_of`), and the C++
9767    /// half of `cpp:Timer::time_until_trigger` would have been a second
9768    /// derivation of the same subtraction in a second crate — the shape
9769    /// CLAUDE.md's "fix the CLASS, add ONE shared helper rather than a second
9770    /// spelling" rule names. `nros-c` forwards here now; so does `nros-cpp`.
9771    ///
9772    /// **The unit is nanoseconds and the RESOLUTION is microseconds.** The
9773    /// arena's timer accounting is microsecond-based (issue #505), so this is a
9774    /// microsecond quantity scaled by 1000. The unit is rcl's; the resolution
9775    /// is ours.
9776    ///
9777    /// Saturating rather than wrapping: `period_us` and `elapsed_us` are both
9778    /// `u64`, so their difference in nanoseconds can leave `i64` in either
9779    /// direction, and a wrap would turn "overdue by a lot" into "due in a
9780    /// long time" — the sign is the whole answer here.
9781    pub fn timer_time_until_next_call_ns(&self, id: HandleId) -> Option<i64> {
9782        let period_us = self.timer_period_us(id)?;
9783        let elapsed_us = self.timer_elapsed_us(id)?;
9784        let delta_ns = (period_us as i128 - elapsed_us as i128).saturating_mul(1_000);
9785        Some(if delta_ns > i64::MAX as i128 {
9786            i64::MAX
9787        } else if delta_ns < i64::MIN as i128 {
9788            i64::MIN
9789        } else {
9790            delta_ns as i64
9791        })
9792    }
9793
9794    // ========================================================================
9795    // spin_once (three-phase: readiness -> trigger -> dispatch)
9796    // ========================================================================
9797
9798    /// Drive I/O and dispatch registered callbacks once.
9799    ///
9800    /// Three-phase execution:
9801    /// 1. **Readiness scan** — query each handle's `has_data()`.
9802    /// 2. **Trigger evaluation** — check if the executor-level trigger passes.
9803    /// 3. **Dispatch** — invoke callbacks according to their `InvocationMode`.
9804    ///
9805    /// Returns a [`SpinOnceResult`] with counts of processed items and errors.
9806    ///
9807    /// # Arguments
9808    /// * `timeout` — upper bound on the I/O wait. Saturated at
9809    ///   `i32::MAX` ms (~24 days) for the underlying transport call.
9810    ///
9811    /// Phase 84.D7: unified on `core::time::Duration`. The previous
9812    /// `timeout_ms: i32` signature had a latent footgun where
9813    /// `spin_once(-1)` silently froze timers while still polling I/O;
9814    /// `Duration` has no negative sentinel.
9815    /// Consecutive `drive_io` failures on the primary session (issue 0324).
9816    ///
9817    /// `0` means the last drive succeeded. A value that keeps climbing across
9818    /// spins is a session that is no longer doing I/O — router gone, lease
9819    /// expired, socket closed — which otherwise presents as a node that spins
9820    /// `Ok(())` forever while publishing nowhere and firing no callbacks.
9821    ///
9822    /// Transient failures happen, so a single non-zero reading is not a fault;
9823    /// a threshold (say, "more than a few consecutive spins") is the useful
9824    /// signal. Extra (bridge / multi-domain) sessions are best-effort and are
9825    /// deliberately NOT counted here.
9826    pub fn session_io_failures(&self) -> u32 {
9827        self.consecutive_io_failures
9828    }
9829
9830    /// Whether the primary session drove I/O successfully on the last spin.
9831    ///
9832    /// Convenience over [`Self::session_io_failures`] for the common
9833    /// "is my transport alive?" check.
9834    pub fn session_io_healthy(&self) -> bool {
9835        self.consecutive_io_failures == 0
9836    }
9837
9838    /// Drive I/O once, then dispatch EVERY callback that is ready — the tick
9839    /// primitive the rest of the family is built from.
9840    ///
9841    /// OURS-ONLY (RFC-0089): rclcpp's `spin_once` executes the NEXT ready item
9842    /// and has no timeout; rclpy's is `spin_once(node, timeout_sec)`. Ours is a
9843    /// blocking wait with a budget, which an RTOS task that must not spin-poll
9844    /// needs and which neither upstream has a verb for. What it DOES when work
9845    /// is ready is rclcpp's `spin_some` — the whole ready set, one pass
9846    /// (RFC-0002 §3) — so [`spin_some`](Self::spin_some) is the drain verb and
9847    /// this is the waiting one.
9848    ///
9849    /// `timeout` is the upper bound on the I/O wait. Since phase-436 W2 it is
9850    /// carried in MICROSECONDS all the way to the park (issue 1193 — an
9851    /// `as_millis()` here truncated `from_micros(500)` to `0`, and a zero
9852    /// timeout selects the non-blocking path, so a sub-millisecond `spin()`
9853    /// was a 100 % CPU loop with no warning); the conversion to whatever the
9854    /// platform primitive accepts happens once, at the park, and rounds UP.
9855    /// `Duration` has no negative sentinel, which is what the pre-phase-84
9856    /// `timeout_ms: i32` signature used to silently mean "freeze the timers
9857    /// and poll I/O forever".
9858    pub fn spin_once(&mut self, timeout: core::time::Duration) -> SpinOnceResult {
9859        let mut discarded = None;
9860        self.spin_once_capturing(timeout, &mut discarded)
9861    }
9862
9863    /// [`spin_once`](Self::spin_once), plus the FIRST failing callback's
9864    /// error.
9865    ///
9866    /// phase-427 — [`SpinOnceResult`] counts errors and drops their values,
9867    /// and `SpinOnceResult` is `Copy`, so widening it to carry a
9868    /// (non-`Copy`, `alloc`-variant-bearing) [`TransportError`] would change a
9869    /// public type every consumer copies. The value is needed in exactly one
9870    /// place — `SpinOptions::stop_on_first_error`, where `spin` has to return
9871    /// the first error rather than a count — so it is threaded out through an
9872    /// out-parameter that lives in the caller's frame. Nothing is added to
9873    /// `Executor`, so a no-alloc image pays nothing for it.
9874    ///
9875    /// `first_error` is written at most once: subsequent failures in the same
9876    /// pass are counted in the result and their values dropped, exactly as
9877    /// before.
9878    fn spin_once_capturing(
9879        &mut self,
9880        timeout: core::time::Duration,
9881        first_error: &mut Option<TransportError>,
9882    ) -> SpinOnceResult {
9883        // Issue 1705 — releases deferred behind a dispatch that has since
9884        // finished (an OS-priority worker's) are completed here, before this
9885        // pass looks at the entries.
9886        self.reap_deferred_releases();
9887        // phase-436 W2 (issue 1193) — the budget is carried in MICROSECONDS.
9888        // This was `as_millis()`, which truncates: `from_micros(500)` became
9889        // `0`, and a zero timeout selects the non-blocking path — so a
9890        // sub-millisecond `spin()` was a 100 % CPU loop with no warning. The
9891        // conversion to whatever the platform primitive accepts happens once,
9892        // at the park, and rounds UP. The budget itself is `grid_budget_us`
9893        // below (phase-474 I11).
9894
9895        // phase-425 W3b — bring the `/clock` subscription in line with
9896        // `use_sim_time`. One bool comparison in the settled case; the work only
9897        // happens on a transition, or while a request is waiting for its first
9898        // node to exist.
9899        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
9900        self.reconcile_ros_time_source();
9901
9902        // phase-426 W3 — attach the six parameter services to any node that
9903        // does not have them yet. Same reason as the reconcile above: the
9904        // request routinely arrives before there is a node to publish under
9905        // (`nros::main!` emits `apply_param_services` ahead of its per-node
9906        // `register` calls, by design). One length comparison once settled.
9907        //
9908        // Issue 1271 -- a failure is LOGGED, once, rather than discarded. On
9909        // Cyclone this fails on every spin with `Transport(Unsupported)`
9910        // (issue 1268), and with the result dropped "this node has no
9911        // parameter services" was a fact nothing on the image stated.
9912        #[cfg(feature = "param-services")]
9913        if self.parameter_services_pending()
9914            && let Err(e) = self.reconcile_parameter_services()
9915        {
9916            self.report_parameter_service_reconcile_failure(e);
9917        }
9918
9919        // Release jitter, measured HERE rather than in `spin_period`, because
9920        // this is the function every driver goes through.
9921        //
9922        // The first version instrumented `spin_period` only, and that was the
9923        // same mistake issue 0736 records one layer down: a measurement placed
9924        // on a path no shipped image takes. `nros-cpp`'s tier trampoline paces
9925        // itself with a `spin_once` loop at `spin_period_us` (see its
9926        // `run_components` docs, step 4), so `spin_period` is not what the C++
9927        // entry -- and therefore not what the Zephyr lane -- actually calls.
9928        // The field recorded zero forever while claiming to measure cadence.
9929        //
9930        // `timeout` is the caller's intended pacing quantum: `spin_period`
9931        // passes its period, and the generated entries pass 10 ms. phase-474
9932        // I11 -- with no declared cadence it is the period of an absolute
9933        // release grid, and the park budget is the time to the next grid
9934        // point (see `record_release_jitter`); the release is judged after the
9935        // park, against the grid (`record_grid_release`). A zero timeout means
9936        // "poll, no cadence claimed", so it is neither judged nor gridded.
9937        let grid_budget_us = self.record_release_jitter(timeout);
9938
9939        // issue 0900 — one-shot advisory when the arena is far larger than what
9940        // registered. First spin rather than "end of registration", because
9941        // there is no such point: an app may register lazily.
9942        super::arena::maybe_report_arena_headroom(self.arena_used, self.arena.len());
9943
9944        // phase-412 -- the same moment, recorded where a board with no log sink
9945        // can still be asked. Reaching this stage is the positive result the
9946        // advisory above cannot deliver on the island: registration completed.
9947        crate::boot_report::checkpoint(crate::boot_report::Stage::FirstSpin);
9948
9949        // Phase 110.0 — the backend's next internal-event deadline (lease
9950        // keepalive, heartbeat, ACK-NACK timeout, ...). Default backend impl
9951        // returns `None`, so this contributes nothing unless the active
9952        // backend opts in.
9953        //
9954        // phase-436 W6 — a CANDIDATE now, not a pre-cap. Folding it into the
9955        // budget here meant a park the session won was attributed to
9956        // `CallerBudget`, so the one number that explains a wake named the
9957        // wrong source.
9958        let session_us = self
9959            .session
9960            .next_deadline_ms()
9961            .map(|next| (next as u64).saturating_mul(1000));
9962
9963        // phase-436 W1 (issue 1192) — and by the next TIMER deadline, which is
9964        // the one class of deadline the executor itself owns. Before this the
9965        // park was bounded by the caller's budget narrowed only by the
9966        // BACKEND's next event, so `spin_default`'s 50 ms slept straight past
9967        // a registered 10 ms timer.
9968        //
9969        // Attribution is recorded here rather than derived later: this is the
9970        // only point where the losing candidates are still in hand.
9971        let (park_bound_us, park_source) =
9972            self.next_wake_bound_attributed_us(grid_budget_us, session_us);
9973        self.last_park_bound_us = park_bound_us;
9974        self.last_park_source = park_source;
9975        // phase-436 W2 (issue 1193) — round UP to what the primitive can
9976        // express. The old `as_millis()` truncated, so a sub-millisecond
9977        // request became a zero park: the non-blocking path, a busy loop.
9978        let park_achieved_us = self.round_park_up_us(park_bound_us);
9979        self.last_park_achieved_us = park_achieved_us;
9980
9981        // phase-436 W6 — a port that installed a park primitive does its
9982        // waiting HERE, on the bound every source agreed on, and the transport
9983        // is then drained non-blockingly.
9984        //
9985        // This is the path bare metal needs: `park_until(deadline)` can arm a
9986        // timer compare before `wfi`, which `sleep(duration)` cannot express —
9987        // and `nros-baremetal-common` notes that `wfi` with no pending
9988        // interrupt deadlocks, so arming first is not optional there.
9989        //
9990        // Additive by construction: with no primitive installed this is a
9991        // no-op and every existing port keeps the behaviour it has.
9992        // phase-436 — park only when something can END the park early. A
9993        // park ends on the bound or on the wake object; with a poll-only
9994        // backend (XRCE), or a session whose wake callback is gone, nothing
9995        // signals that object, so the park would be a blind sleep and data
9996        // arriving mid-park would wait out the whole bound — the Phase
9997        // 127.C.4 stall (a reliable stream whose ACK lands in a session nobody
9998        // drives). Those executors keep the receive-blocking wait below.
9999        let park_can_be_woken = self.park_can_be_woken();
10000        let parked = match self.park_primitive {
10001            Some((park, ctx)) if park_achieved_us > 0 && park_can_be_woken => {
10002                // SAFETY: `park` and `ctx` were supplied together by the port
10003                // through `set_park_primitive` and outlive the executor by its
10004                // contract.
10005                //
10006                // phase-436 — a negative answer is "cannot park": the ABI's
10007                // own contract, and what a Rust-implemented port's trait
10008                // default returns at once. Counting that as a park would
10009                // drop the drain below to non-blocking and spin hot, so the
10010                // spin falls back to the wait it would have had uninstalled.
10011                (unsafe { park(ctx, park_achieved_us) }) >= 0
10012            }
10013            _ => false,
10014        };
10015
10016        // Having already waited, the transport drain must not block again —
10017        // otherwise the park and the drive would sum, and the cadence would be
10018        // up to twice what was asked. Same trap as passing a period to
10019        // `spin_once` instead of declaring it (#648).
10020        let timeout_ms = if parked {
10021            0
10022        } else {
10023            (park_achieved_us / 1000).min(i32::MAX as u64) as i32
10024        };
10025
10026        // Wall-clock-accurate timer accumulation. Measure real time
10027        // since the previous `spin_once` exited (or, on the first call,
10028        // since `drive_io` started). Two failure modes the requested
10029        // `timeout_ms` doesn't capture:
10030        //  1. `drive_io` returns early — e.g. zenoh-pico's condvar wakes
10031        //     on data arrival, well under 1 ms.
10032        //  2. The caller spends time outside `spin_once` (explicit sleep,
10033        //     ROS-2 cooperative scheduling, etc.) and that time should
10034        //     still count toward timers.
10035        // Crediting the requested timeout to timers in either case ticks
10036        // them faster than wall-clock — observed as a 30 Hz control loop
10037        // overshooting to >200 Hz under sustained traffic. Carry the
10038        // sub-ms remainder across calls so precision is preserved.
10039        // phase-359 W4 — the spin-ENTRY read, one spelling. Kept distinct from
10040        // the post-IO read below because the first spin's delta is measured from
10041        // entry; collapsing them would make that delta 0 on the first call only.
10042        let spin_start_us = self.now_us();
10043
10044        // phase-474 I2 -- arm the monitors when the policy says start-up is over,
10045        // before any rule of this spin can judge.
10046        self.update_monitor_arming(spin_start_us);
10047
10048        // RFC-0052 W3b.4 — contract monitors tick once per spin (window
10049        // logic inside; single branch when the baked table is empty).
10050        self.run_contract_monitors();
10051
10052        // Phase 104.C.6 — shared executor wake. Swap-and-clear the
10053        // wake flag; if it was set before this `spin_once` entered,
10054        // skip the blocking wait on the primary session and poll
10055        // every session non-blockingly. Lets a wake signal from any
10056        // thread (or, post-104.C.6.b, any backend's vtable hook)
10057        // pre-empt whichever session the executor would otherwise
10058        // sleep on. Cost on the no-wake path is one atomic swap.
10059        // phase-359 W10 — ONE swap, at `alloc` scope.
10060        //
10061        // This was `#[cfg(feature = "std")]` while the alloc wait arm did its
10062        // own swap further down. Merging the two arms made that two swaps of
10063        // one flag per spin, the first consuming what the second tested, so the
10064        // arm would have read `was_woken == false` forever. Deleting the outer
10065        // one instead broke a different thing: `wake_flag` is `alloc`-gated and
10066        // "spin_once consumes the wake flag" is an invariant of every alloc
10067        // build, but the merged arm only runs with `rmw-cffi` — so a
10068        // `std`-without-`rmw-cffi` build stopped clearing it, which
10069        // `test_wake_cleared_each_spin` catches. It belongs here, at the scope
10070        // of the field it reads.
10071        #[cfg(feature = "alloc")]
10072        #[allow(unused_variables)]
10073        let was_woken = self
10074            .wake_flag
10075            .swap(false, portable_atomic::Ordering::SeqCst);
10076
10077        // Phase 124.B.4 — condvar-blocked wait.
10078        //
10079        // RT contract:
10080        //  * cv.wait_timeout_while: bounded by `timeout_ms`.
10081        //    Predicate is O(1) — one atomic swap + Instant::now.
10082        //    No allocation. PI-mutex consideration: wake_mu held
10083        //    only during predicate check (microseconds);
10084        //    contended worst-case = notify_all execution time
10085        //    (~10s of µs).
10086        //  * Backend's `set_wake_callback`-installed cb is called
10087        //    on async data arrival from its transport-notify path
10088        //    (worker thread, ISR-safe variant via 124.B.7). The
10089        //    runtime cb writes wake_flag + signals wake_cv,
10090        //    unblocking this loop sub-poll-period.
10091        //  * Poll-only backends (XRCE, bare-metal) leave the slot
10092        //    NULL; the cv wait still fires on its deadline, then
10093        //    drive_io(0) drains whatever the backend's internal
10094        //    poll has buffered. Equivalent to their pre-124
10095        //    behaviour minus the blocking wait inside drive_io.
10096        //
10097        // Lost-wakeup safe: SeqCst flag write happens-before
10098        // notify, and the waiter checks the flag under wake_mu in
10099        // the predicate. If wake fires between drain and cv.wait
10100        // entry, the predicate sees flag=true on first eval and
10101        // exits immediately.
10102        // Phase 130.4 — only sleep in the wake wait when a backend actually
10103        // installed `set_wake_callback`. Poll-only backends (XRCE) leave the
10104        // vtable slot NULL → `has_async_wake == false` →
10105        // drive_io for the caller's full timeout instead of sleeping in a
10106        // never-signaled wait that starves reliable retransmission (Phase
10107        // 127.C.4 root cause: the server's `send_response` flushes 100 ms once,
10108        // then a blind `wait_ms(100)` sleeps with zero session activity, so the
10109        // agent's ACK arrives into a stalled session and reliable redelivery
10110        // never fires).
10111        //
10112        // Phase 248 (C2) — which primitive is a RUNTIME choice from the
10113        // platform vtable's wake probe, not a compile-time per-RTOS `cfg`:
10114        // `node_wake.is_some()` means `nros_platform_wake_*` is linked and
10115        // `nros_rmw_runtime_wake_cb` signals it on transport arrival.
10116        // phase-359 W10 — ONE arm. This was a `std` arm and an `alloc` arm with
10117        // the same body: block on the platform wake primitive when a backend
10118        // installed the callback and nothing has woken us yet, else drive the
10119        // transport for the caller's full timeout. They stopped differing when
10120        // the condvar fallback was deleted — the std arm's `else` branch had
10121        // already become "drive for the full timeout", which is what the alloc
10122        // arm always did — so keeping two was keeping a fork that agreed.
10123        // phase-417 W4.e — the FIRST arm is what the swap-and-clear above says
10124        // it is for, and it was missing: a wake that landed BETWEEN two spins
10125        // set the flag, and this expression then took the `else` and drove the
10126        // transport for the caller's FULL timeout, so the pending work waited
10127        // out the very budget the wake exists to cut short. Measured with a
10128        // guard condition triggered from another thread just before a 400 ms
10129        // spin: 400 ms to dispatch, against 50 ms for the same trigger issued
10130        // while the spin was already parked in `wait_ms`. Being woken means
10131        // there IS work, so the right timeout is 0 — poll every session
10132        // non-blockingly and go dispatch it, which is what the comment at the
10133        // swap has claimed since Phase 104.C.6.
10134        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
10135        let primary_drive_timeout_ms = {
10136            if was_woken {
10137                // Issue 1385 — drain the wake PRIMITIVE too.
10138                //
10139                // `nros_rmw_runtime_wake_cb` sets both halves of one signal:
10140                // the flag, and the platform primitive. The swap above
10141                // consumed the flag and this arm returns without entering the
10142                // wait, so a primitive left posted makes the NEXT spin's
10143                // `wait_ms` return instantly on a signal already acted on —
10144                // that spin does not wait at all, and whatever it should have
10145                // waited for is dispatched a whole spin late.
10146                //
10147                // `wait_ms(0)` is the non-blocking take: every port's wait is a
10148                // deadline (`sem_timedwait`, `k_sem_take`, `xSemaphoreTake`),
10149                // so zero means "take it if it is there", never "wait
10150                // forever".
10151                //
10152                // Measured, not reasoned: with the C executor installing a
10153                // callback (the other half of 1385), an arrival taken by this
10154                // arm left the spin after it returning in 0 ms where it owed
10155                // 60 ms of waiting — which is what `node_guard_condition.c`'s
10156                // mid-spin case caught, and what `executor_backend_wake.c`'s
10157                // second lower bound now pins. Latent until then only because
10158                // no C spin had ever entered the wait arm.
10159                if let Some(wake) = self.node_wake.as_ref() {
10160                    let _ = wake.wait_ms(0);
10161                }
10162                0
10163            } else if self.async_wake_live()
10164                && let Some(wake) = self.node_wake.as_ref()
10165            {
10166                // phase-436 — after a park this wait has a zero timeout and
10167                // the park has already waited on this same object, so it is a
10168                // second syscall that can only time out. Measured on a native
10169                // tier: one extra futex wait per iteration, doubling the idle
10170                // context switches. A signal that lands between the park's
10171                // return and here stays posted and ends the next park at once,
10172                // which costs one spin and loses nothing.
10173                if !parked {
10174                    let _ = wake.wait_ms(timeout_ms as u32);
10175                }
10176                // Drain any flag the cb set while we were waiting.
10177                let _ = self
10178                    .wake_flag
10179                    .swap(false, portable_atomic::Ordering::SeqCst);
10180                0
10181            } else {
10182                // No wake primitive linked, or a poll-only backend: drive the
10183                // transport for the full timeout. It still BLOCKS — in the
10184                // transport's own recv — which is where every flavour ended up
10185                // anyway.
10186                timeout_ms
10187            }
10188        };
10189
10190        // std builds without rmw-cffi (mock-session tests, future alternative
10191        // backends) keep the original "drive_io is non-blocking" assumption.
10192        // `std` implies `alloc`, so this is the only std configuration the arm
10193        // above does not cover.
10194        #[cfg(all(feature = "std", not(feature = "rmw-cffi")))]
10195        let primary_drive_timeout_ms = 0;
10196
10197        // no_std without (alloc + rmw-cffi) keeps the legacy
10198        // full-timeout drive_io call.
10199        #[cfg(all(
10200            not(feature = "std"),
10201            not(all(feature = "alloc", feature = "rmw-cffi"))
10202        ))]
10203        let primary_drive_timeout_ms = timeout_ms;
10204
10205        // issue 0324 — the primary session's I/O result is TRACKED, not
10206        // discarded. See `consecutive_io_failures` for why this is a counter
10207        // rather than an early return.
10208        match self.drive_primary(primary_drive_timeout_ms) {
10209            Ok(()) => self.consecutive_io_failures = 0,
10210            Err(_) => self.consecutive_io_failures = self.consecutive_io_failures.saturating_add(1),
10211        }
10212        for extra in self.extra_sessions.iter_mut() {
10213            // Best-effort BY DESIGN: extra sessions are bridge / multi-domain
10214            // attachments, and one of them failing must not stall the primary
10215            // spin. The old code expressed this the same way it expressed the
10216            // primary's discarded error — identically — so the intent was
10217            // invisible. It is now the only `let _ =` of the two.
10218            let _ = extra.drive_io(0);
10219        }
10220
10221        // phase-359 W4 — ONE clock read per spin, shared by the delta below and
10222        // every consumer further down. Replaces the std/no_std delta pair AND
10223        // two ad-hoc `static EPOCH: OnceLock<Instant>` blocks that each kept
10224        // their own std-only epoch.
10225        let now_us_this_spin = self.now_us();
10226        // phase-474 I11 -- the release instant is the end of the park, so it
10227        // is judged on this read, before any dispatch of this spin.
10228        if let Some(now) = now_us_this_spin {
10229            self.record_grid_release(now);
10230        }
10231
10232        // Same rule on both flavours: measure elapsed when a clock exists, else
10233        // credit the REQUESTED timeout. That fallback was previously reachable
10234        // only on no_std; on std `Instant` always answered. It stays unreachable
10235        // on std for the same reason — `now_us()` is infallible there — so this
10236        // is one expression, not a behaviour change.
10237        let delta_us = match now_us_this_spin {
10238            Some(now) => {
10239                let prev = self
10240                    .last_spin_end_us
10241                    .unwrap_or_else(|| spin_start_us.unwrap_or(now));
10242                self.last_spin_end_us = Some(now);
10243                now.saturating_sub(prev)
10244            }
10245            None => (timeout_ms as u64).saturating_mul(1000),
10246        };
10247
10248        // phase-430 W1 — the silence watch is credited the SAME `delta_us` the
10249        // wall timers below are, which is the whole of its unit argument: the
10250        // diagnostic arrives after as much time as this image's own wall timers
10251        // believe has passed, on whatever clock it actually has.
10252        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
10253        self.note_sim_time_silence(delta_us);
10254
10255        if !self.spin_quantization_checked && timeout_ms > 0 {
10256            self.spin_quantization_checked = true;
10257            // The TIER's declared cadence, not the capped park bound. Since
10258            // phase-436 W1 the two differ whenever a timer is due sooner than
10259            // the budget, and auditing the cap would compare the timer period
10260            // against itself — an exact multiple every time, so issue 0515's
10261            // warning would go silent on precisely the images that have a
10262            // timer to quantize. `spin_nominal_us` is the declared cadence
10263            // (`set_spin_nominal_us`, else the caller's timeout), recorded at
10264            // the top of this spin before any capping.
10265            self.spin_quantization_us = self.spin_nominal_us;
10266            self.audit_spin_quantization(self.spin_quantization_us);
10267        }
10268
10269        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10270
10271        // Phase 1: Readiness scan (Phase 110.A.b — backed by FifoReadySet).
10272        //
10273        // `bits` carries data-readiness only (used by trigger eval +
10274        // by `InvocationMode::OnNewData`). `always_mask` carries the
10275        // `InvocationMode::Always` entries that fire regardless of
10276        // data presence. The dispatcher drains
10277        // `FifoReadySet(bits | always_mask)` after the trigger
10278        // passes; `pop_next` yields registration order (lowest bit
10279        // first) so behavior is bit-identical to the pre-refactor
10280        // `for (i, meta) in entries.iter().enumerate()` loop.
10281        let mut bits: u64 = 0;
10282        let mut count: usize = 0;
10283        let mut non_timer_mask: u64 = 0;
10284        let mut always_mask: u64 = 0;
10285
10286        for (i, meta) in self.entries.iter().enumerate() {
10287            if let Some(meta) = meta
10288                && !Self::slot_held(self.slot_tags, i)
10289            {
10290                let data_ptr = unsafe { arena_ptr.add(meta.offset) as *const u8 };
10291                if unsafe { (meta.has_data)(data_ptr) } {
10292                    bits |= 1u64 << i;
10293                }
10294                if !matches!(meta.kind, EntryKind::Timer | EntryKind::GuardCondition) {
10295                    non_timer_mask |= 1u64 << i;
10296                }
10297                if matches!(meta.invocation, InvocationMode::Always) {
10298                    always_mask |= 1u64 << i;
10299                }
10300                count += 1;
10301            }
10302        }
10303
10304        let snapshot = ReadinessSnapshot { bits, count };
10305
10306        // Phase 2: Trigger evaluation
10307        let trigger_passes = match &self.trigger {
10308            Trigger::Any => bits & non_timer_mask != 0 || non_timer_mask == 0,
10309            Trigger::All => bits & non_timer_mask == non_timer_mask,
10310            Trigger::One(id) => snapshot.is_ready(*id),
10311            Trigger::AllOf(set) => snapshot.all_ready(*set),
10312            Trigger::AnyOf(set) => snapshot.any_ready(*set),
10313            Trigger::Always => true,
10314            Trigger::Predicate(f) => f(&snapshot),
10315            Trigger::RawPredicate { callback, context } => {
10316                // Convert ReadinessSnapshot bitmask to a bool array for the C callback
10317                let mut ready_array = [false; 64];
10318                for (i, slot) in ready_array
10319                    .iter_mut()
10320                    .enumerate()
10321                    .take(snapshot.count.min(64))
10322                {
10323                    *slot = snapshot.bits & (1u64 << i) != 0;
10324                }
10325                // SAFETY: The callback and context are provided by the C API caller.
10326                // The ready_array is valid for snapshot.count elements.
10327                unsafe { callback(ready_array.as_ptr(), snapshot.count, *context) }
10328            }
10329        };
10330
10331        if !trigger_passes {
10332            // Timers still need delta accumulation even when trigger doesn't pass
10333            //
10334            // phase-8 — `.enumerate()` (rather than a bare `.flatten()`) because
10335            // `try_process` now carries the entry's slot index for the callback
10336            // trace hooks. This sweep FIRES timer callbacks, so it is a real
10337            // dispatch path and must attribute them like the drain below does.
10338            //
10339            // phase-474 I6 -- and it must TIME them like the drain does. This
10340            // is the path every timer takes on a node that also subscribes
10341            // (the default `Trigger::Any` fails whenever no subscription has
10342            // a sample), and it measured nothing, so `max-latency-runtime`
10343            // never judged a timer path: the safety island's 250 ms overrun of
10344            // a 206 ms path stored no verdict. One clock read before and one
10345            // after each timer's `try_process`, only when a latency contract
10346            // exists and a clock is injected; the publish-count snapshot is a
10347            // fixed array on the stack, nothing allocates. The measured span
10348            // is callback entry to exit, as in the drains; release jitter is
10349            // `release-jitter-runtime`'s.
10350            let mon_table = self.monitor_table;
10351            // phase10-B3 -- the route rule reads the same clock and snapshot:
10352            // a timer tick is where a sampled route's output is published.
10353            let lat_clock = self.clock_us_fn.filter(|_| {
10354                mon_table.iter().any(|m| m.max_latency_ms > 0)
10355                    || super::monitor::route_active(mon_table)
10356            });
10357            for i in 0..self.entries.len() {
10358                let Some(meta) = self.entries[i].as_ref() else {
10359                    continue;
10360                };
10361                if matches!(meta.kind, EntryKind::Timer) && !Self::slot_held(self.slot_tags, i) {
10362                    let data_ptr = unsafe { arena_ptr.add(meta.offset) };
10363                    let try_process = meta.try_process;
10364                    let counts_before = snapshot_pub_counts(mon_table, lat_clock.is_some());
10365                    let start_us = lat_clock.map(|c| c());
10366                    Self::begin_dispatch(self.slot_tags, i);
10367                    // phase-463 W7 -- the host profile times this sweep too:
10368                    // it is the path every timer of a node that also
10369                    // subscribes takes (see above), so missing it measured
10370                    // the talker's timer at zero invocations.
10371                    //
10372                    // The OFF arm is the original `let _ =`, untouched, and
10373                    // that is MEASURED rather than tidy: every binding tried
10374                    // in its place (`let _p`, `let p; drop(p)`) moved
10375                    // `spin_once_capturing` by 49-64 B in the Zephyr image
10376                    // (phase-463 W5 I3c). Hence the second `unsafe` block,
10377                    // recorded in the unsafe census.
10378                    #[cfg(not(feature = "profile-mode"))]
10379                    let _ = unsafe { try_process(data_ptr, delta_us, i as u8) };
10380                    #[cfg(feature = "profile-mode")]
10381                    {
10382                        super::profile::dispatch_begin(arena_ptr as usize, i);
10383                        let processed = unsafe { try_process(data_ptr, delta_us, i as u8) };
10384                        super::profile::dispatch_end(matches!(processed, Ok(true)));
10385                    }
10386                    if let (Some(clock), Some(t0)) = (lat_clock, start_us) {
10387                        let end_us = clock();
10388                        let elapsed_us = end_us.saturating_sub(t0).min(u32::MAX as u64) as u32;
10389                        attribute_latency(mon_table, true, &counts_before, elapsed_us);
10390                        let hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
10391                        let violations = &mut self.violations;
10392                        super::monitor::route_judge(
10393                            mon_table,
10394                            &counts_before,
10395                            end_us,
10396                            |row, v, record, react| {
10397                                if react {
10398                                    call_route_reaction(hook, mon_table, row, &v);
10399                                }
10400                                if record {
10401                                    violations.record(v)
10402                                }
10403                            },
10404                        );
10405                    }
10406                    self.finish_dispatch(i);
10407                }
10408            }
10409
10410            // Parameter services live outside the arena and must be processed
10411            // regardless of trigger state, otherwise ROS 2 param queries time out.
10412            #[cfg(feature = "param-store")]
10413            {
10414                #[allow(unused_mut)] // only the services add to it
10415                let mut handled = 0usize;
10416                #[cfg(feature = "param-services")]
10417                if let Some(params) = &mut self.params {
10418                    {
10419                        let crate::param_store::ParamState {
10420                            server,
10421                            services,
10422                            buffers,
10423                            staged,
10424                            ..
10425                        } = &mut **params;
10426                        // phase-426 W3 — one set per node; every set is pumped,
10427                        // because `ros2 param list` addresses each node's own
10428                        // six and a set nobody polls times out.
10429                        //
10430                        // Issue 1270 -- all of them through ONE buffer pair.
10431                        // Issue 1271 -- a request that got no reply is logged
10432                        // inside, once per service; there is no error to drop.
10433                        if let Some(buffers) = buffers {
10434                            for set in services.iter_mut() {
10435                                handled += set.process(server, staged, buffers);
10436                            }
10437                        }
10438                    }
10439                }
10440                // phase-425 W3b — a `ros2 param set … use_sim_time true` arrives
10441                // through this path.
10442                self.note_param_services_ran(handled);
10443            }
10444
10445            // Same treatment for lifecycle services — `ros2 lifecycle get`
10446            // must succeed even when no callbacks fired this tick.
10447            //
10448            // Issue 1587 — `process` also drains the state machine's
10449            // announcement queue onto `~/transition_event`, so a transition
10450            // triggered from application code between two spins (autostart is
10451            // the common one) reaches a watching supervisor here.
10452            // SAFETY: see the matching invariant on the later call site.
10453            #[cfg(feature = "lifecycle-services")]
10454            if let Some(lc) = &mut self.lifecycle {
10455                let _ = unsafe { lc.process() };
10456            }
10457
10458            // phase-474 I7 -- an idle spin is the natural slot for the queued
10459            // reports.
10460            self.flush_violation_reports();
10461
10462            return SpinOnceResult::new();
10463        }
10464
10465        // Phase 2.5: LET pre-sample (only when LogicalExecutionTime)
10466        //
10467        // Sample all subscription data into entry buffers BEFORE dispatching
10468        // any callbacks. This ensures all callbacks in this cycle see a
10469        // consistent snapshot of data from the same point in time.
10470        // Services are NOT pre-sampled (request-reply is sequential).
10471        if matches!(self.semantics, ExecutorSemantics::LogicalExecutionTime) {
10472            for (i, meta) in self.entries.iter().enumerate() {
10473                let Some(meta) = meta else { continue };
10474                if matches!(meta.kind, EntryKind::Subscription)
10475                    && !Self::slot_held(self.slot_tags, i)
10476                {
10477                    let data_ptr = unsafe { arena_ptr.add(meta.offset) };
10478                    unsafe { (meta.pre_sample)(data_ptr) };
10479                }
10480            }
10481        }
10482
10483        // Phase 3: Dispatch (Phase 110.C — bucketed by SC.priority).
10484        //
10485        // Two ready-set families, each split across `Priority::COUNT`
10486        // buckets (Critical / Normal / BestEffort). Per-entry SC
10487        // `class` selects FIFO bitmap vs EDF heap; SC `priority`
10488        // selects the bucket within. Drain order:
10489        //   for each bucket in priority order (Critical first):
10490        //     drain EDF heap (deadline-priority), then FIFO bitmap
10491        //     (registration-order)
10492        // Default workloads — every entry on the auto-default Fifo SC
10493        // (Normal priority) — populate only `fifo[Normal]`, so
10494        // dispatch order is bit-identical to 110.B.b for those.
10495        const NB: usize = super::sched_context::Priority::COUNT;
10496        let mut result = SpinOnceResult::new();
10497        let mut fifo: super::ready_set::BucketedFifoSet<NB, { MAX_CALLBACK_SLOTS }> =
10498            super::ready_set::BucketedFifoSet::new();
10499        let mut edf: super::ready_set::BucketedEdfSet<NB, { MAX_CALLBACK_SLOTS }> =
10500            super::ready_set::BucketedEdfSet::new();
10501        let active_mask = bits | always_mask;
10502
10503        // Phase 110.E — refill any Sporadic SC budgets at period
10504        // boundaries before deciding what to dispatch this cycle.
10505        // Refill is polled (not ISR-driven) — coarse but correct
10506        // upper-bound bandwidth limiter.
10507        // phase-359 W4 — was `#[cfg(feature = "std")]` with NO no_std arm, so
10508        // polled Sporadic budgets never refilled on embedded: they exhausted
10509        // once and stayed exhausted. That was a consequence of "no clock on
10510        // no_std", which is false whenever a `clock_us` hook is injected. Now it
10511        // runs on either flavour when a clock exists, and is skipped when none
10512        // does — identical to today's behaviour in that case.
10513        if let Some(now_us) = now_us_this_spin {
10514            let now_ms = now_us / 1000;
10515            // issue 0736 — REFILL only. This used to also charge the cycle's
10516            // `delta_us` as the per-SC consumption estimate ("worst-case
10517            // attribution", pending a higher-precision clock hook). That hook
10518            // landed, and the attribution was not merely coarse: `delta_us` is
10519            // the wall-clock gap between spins, not CPU the callbacks spent, so
10520            // wherever the gap exceeds the budget the SC is exhausted on every
10521            // spin regardless of what it ran. Consumption is charged from
10522            // measured callback runtime in `consume_dispatch_runtime_us`.
10523            for slot in self.sporadic_states.iter_mut().flatten() {
10524                let _ = slot.refill(now_ms);
10525            }
10526        }
10527
10528        for i in 0..self.entries.len() {
10529            if active_mask & (1u64 << i) == 0 {
10530                continue;
10531            }
10532            let sc_idx = self.sched_context_bindings[i].0 as usize;
10533            let sc_class_priority_deadline = self
10534                .sched_contexts
10535                .get(sc_idx)
10536                .and_then(|s| s.as_ref())
10537                .map(|sc| {
10538                    (
10539                        sc.class,
10540                        sc.priority.index(),
10541                        sc.deadline_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX),
10542                    )
10543                });
10544            let (sc_class, bucket, deadline_us) = sc_class_priority_deadline.unwrap_or((
10545                super::sched_context::SchedClass::Fifo,
10546                super::sched_context::Priority::Normal.index(),
10547                u32::MAX,
10548            ));
10549            // Phase 110.E — Sporadic SC dispatch is suppressed when
10550            // its budget is exhausted. Atomic path (110.E.b PlatformTimer
10551            // refill) takes precedence when registered; polled path
10552            // (cycle-level delta_us attribution) handles the unregistered
10553            // case. Either way, exhausted budget skips dispatch.
10554            if matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
10555                #[cfg(feature = "alloc")]
10556                let atomic_has_budget = self
10557                    .sporadic_atomic_states
10558                    .get(sc_idx)
10559                    .and_then(|s| s.as_ref())
10560                    .map(|(state, _)| state.has_budget());
10561                #[cfg(not(feature = "alloc"))]
10562                let atomic_has_budget: Option<bool> = None;
10563                let has_budget = match atomic_has_budget {
10564                    Some(b) => b,
10565                    None => self
10566                        .sporadic_states
10567                        .get(sc_idx)
10568                        .and_then(|s| s.as_ref())
10569                        .map(|s| s.budget_remaining_us > 0)
10570                        .unwrap_or(true),
10571                };
10572                if !has_budget {
10573                    // issue 0736 — keep the TIMER'S CLOCK honest even while the
10574                    // budget gates its dispatch.
10575                    //
10576                    // A timer's `elapsed_us` only advances inside
10577                    // `timer_try_process`, which is reached only when the entry
10578                    // is DISPATCHED, and each dispatch is handed just THIS
10579                    // cycle's `delta_us`. So every cycle skipped here was
10580                    // dropped from the timer's sense of time: it did not fire,
10581                    // it did not learn that its period had passed, and — because
10582                    // the overrun counter is driven by that same `elapsed_us` —
10583                    // it did not count the activation it missed.
10584                    //
10585                    // That is why the throttle was invisible. Measured on
10586                    // nuttx-arm/rust: ~238 activations lost against ~40
10587                    // reported, so five in six missed firings were unaccounted
10588                    // ANYWHERE. The tier ran at a quarter of its declared rate
10589                    // and the only honest signal was the delivery count itself.
10590                    //
10591                    // Advancing the clock here does not dispatch anything and
10592                    // does not defeat the budget. It makes the entry's own
10593                    // accounting independent of whether the executor CHOSE to
10594                    // run it, which is the property every "did this meet its
10595                    // declaration?" question needs, and it lets the existing
10596                    // `Skip` policy count the backlog and the existing
10597                    // `timer-overrun-runtime` rule report it.
10598                    if let Some(meta) = self.entries[i].as_ref()
10599                        && matches!(meta.kind, EntryKind::Timer)
10600                    {
10601                        // SAFETY: a Timer entry's arena slot holds a
10602                        // `TimerEntry<F>`, whose leading layout IS
10603                        // `TimerHeader` — the same cast the overrun reporter
10604                        // one screen up already relies on.
10605                        let header =
10606                            unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
10607                        // Issue 1321 — on the TIMER'S clock, not the
10608                        // executor's. This line read
10609                        // `header.elapsed_us += delta_us`, which is wall
10610                        // microseconds: for a `Ros` timer that advanced a
10611                        // simulated-time counter by wall time, so a paused
10612                        // `/clock` no longer paused the timer — the one
10613                        // property a ROS-time timer has. Unbounded, unlike
10614                        // 1321's park bound, because it can fire a callback
10615                        // that no clock step earned.
10616                        super::arena::timer_advance_without_dispatch(header, delta_us);
10617                    }
10618                    // issue 0736 — a budget skip used to be a bare `continue`,
10619                    // which is the silent-drop shape 0737 gated one layer out:
10620                    // the entry is simply not dispatched and nothing anywhere
10621                    // says so, so a permanently starved tier is
10622                    // indistinguishable from an idle one. It took a hand-run
10623                    // 45 s image and a per-executor probe to learn that this
10624                    // line was skipping 96 % of one tier's spins.
10625                    // The rolling window catches a budget that THROTTLES; the
10626                    // streak below catches one that starves outright. issue
10627                    // 0736 needed both: the measured case skipped ~3 spins in
10628                    // 4 without ever reaching a streak, so the tier ran at a
10629                    // quarter of its declared rate in silence.
10630                    if let Some((skips, total)) = self
10631                        .sporadic_states
10632                        .get_mut(sc_idx)
10633                        .and_then(|st| st.as_mut())
10634                        .and_then(|st| st.take_budget_window())
10635                    {
10636                        nros_log::log_warn!(
10637                            nros_log::get_logger("nros"),
10638                            "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.",
10639                            sc_idx,
10640                            skips,
10641                            total
10642                        );
10643                    }
10644                    if let Some(streak) = self
10645                        .sporadic_states
10646                        .get_mut(sc_idx)
10647                        .and_then(|st| st.as_mut())
10648                        .and_then(|st| st.note_budget_skip())
10649                    {
10650                        nros_log::log_warn!(
10651                            nros_log::get_logger("nros"),
10652                            "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.",
10653                            streak,
10654                            sc_idx
10655                        );
10656                    }
10657                    continue;
10658                }
10659                // Phase 110.E.b follow-up — per-callback runtime
10660                // accounting (replaces this cycle-level attribution)
10661                // is applied at dispatch time below via
10662                // `consume_dispatch_runtime_us`. We only update the
10663                // polled-path `SporadicState` (no_std fallback) here
10664                // because the atomic path now records actual
10665                // wall-clock per-callback runtime. The
10666                // `delta_us` over-attribution that previously hit the
10667                // atomic state was a worst-case bandwidth limiter;
10668                // per-callback measurement is strictly tighter.
10669                #[cfg(not(feature = "alloc"))]
10670                {
10671                    let _ = sc_idx; // polled-state path lives in
10672                    // `sporadic_states`; this branch is a no-op when
10673                    // the atomic path is enabled.
10674                }
10675            }
10676            // Phase 110.F — per-callback OS priority routing. Entries
10677            // bound to an SC with `os_pri > 0` dispatch onto a worker
10678            // thread the OS has elevated to that priority; the
10679            // cooperative path is skipped for those entries. Workers
10680            // are spawned lazily.
10681            #[cfg(all(
10682                feature = "alloc",
10683                feature = "rmw-cffi",
10684                feature = "scheduler-os-priority"
10685            ))]
10686            {
10687                let os_pri = self
10688                    .sched_contexts
10689                    .get(sc_idx)
10690                    .and_then(|s| s.as_ref())
10691                    .map(|sc| sc.os_pri)
10692                    .unwrap_or(0);
10693                if os_pri > 0
10694                    && let Some(apply_policy) = self.os_priority_apply_policy
10695                    && let Some(meta) = self.entries[i].as_ref()
10696                {
10697                    // Issue 1705 — a worker still holds this entry (its item is
10698                    // queued or its callback is running). Sending it again, or
10699                    // running it here, would put two threads in one
10700                    // `try_process`; it gets its next turn once the worker lets
10701                    // go.
10702                    let tag = &self.slot_tags[i];
10703                    if tag.set(super::types::SlotTag::WORKER_QUEUED)
10704                        & super::types::SlotTag::WORKER_QUEUED
10705                        != 0
10706                    {
10707                        continue;
10708                    }
10709                    let item = super::os_priority::WorkItem {
10710                        arena_base: arena_ptr as usize,
10711                        arena_offset: meta.offset,
10712                        try_process: meta.try_process,
10713                        delta_us,
10714                        // phase-8 — the worker runs the leaf on a DIFFERENT
10715                        // thread, so it has to carry the slot index with it;
10716                        // nothing on the far side can recover it from the
10717                        // arena address alone.
10718                        desc_idx: i as u8,
10719                        tag: tag as *const super::types::SlotTag as usize,
10720                    };
10721                    // phase-359 W10 — `try_dispatch` now reports whether the
10722                    // entry was actually handed to a worker. It can decline:
10723                    // the mailbox is bounded, the pool is capacity-limited, and
10724                    // a platform without a wake primitive hosts no workers at
10725                    // all. Previously the spawn was infallible (`.expect`) and
10726                    // an unbounded `mpsc` never refused, so this branch always
10727                    // `continue`d. Falling through to the cooperative path is
10728                    // the honest answer to a decline — the callback still runs,
10729                    // just without the OS priority guarantee, which is exactly
10730                    // what an entry got before this feature existed.
10731                    if self
10732                        .os_priority_pool
10733                        .try_dispatch(os_pri, apply_policy, item)
10734                    {
10735                        continue;
10736                    }
10737                    // Declined: no worker took it, so the bit set above is
10738                    // released and the entry runs cooperatively.
10739                    self.slot_tags[i].clear(super::types::SlotTag::WORKER_QUEUED);
10740                }
10741            }
10742            // Phase 110.G — TT window gate, orthogonal to class.
10743            // Skips dispatch when the SC has a TT window AND the
10744            // current monotonic time is outside it. Both gates apply
10745            // independently — a Sporadic SC with a TT window must
10746            // pass both.
10747            if self.major_frame_us > 0 {
10748                let sc_opt = self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref());
10749                if let Some(sc) = sc_opt {
10750                    let off = sc.tt_window_offset_us.get().map(|nz| nz.get()).unwrap_or(0);
10751                    let dur = sc
10752                        .tt_window_duration_us
10753                        .get()
10754                        .map(|nz| nz.get())
10755                        .unwrap_or(0);
10756                    if dur > 0 {
10757                        // Compute current phase within the major
10758                        // frame using the accumulated `delta_us` clock
10759                        // (std-only precise; no_std uses `delta_us`
10760                        // approximation from spin cadence).
10761                        // phase-359 W4 — the shared read. The no_std arm used
10762                        // to be `now_us = delta_us`, i.e. a per-spin INTERVAL
10763                        // used as an absolute phase clock, which the comment
10764                        // above called an approximation. It is now the real
10765                        // clock when one is injected, and falls back to the old
10766                        // approximation only when none is.
10767                        let now_us = now_us_this_spin.unwrap_or(delta_us);
10768                        let phase = (now_us % self.major_frame_us as u64) as u32;
10769                        let in_window = if off + dur <= self.major_frame_us {
10770                            phase >= off && phase < off + dur
10771                        } else {
10772                            // Window wraps the major frame boundary.
10773                            let end = (off as u64 + dur as u64) % self.major_frame_us as u64;
10774                            phase >= off || (phase as u64) < end
10775                        };
10776                        if !in_window {
10777                            continue;
10778                        }
10779                    }
10780                }
10781            }
10782            let is_edf = matches!(sc_class, super::sched_context::SchedClass::Edf);
10783            let job = super::types::ActiveJob {
10784                sort_key: if is_edf { deadline_us } else { i as u32 },
10785                desc_idx: i as super::types::DescIdx,
10786            };
10787            if is_edf {
10788                let _ = edf.insert_into(bucket, job);
10789            } else {
10790                let _ = fifo.insert_into(bucket, job);
10791            }
10792        }
10793
10794        // SAFETY: each `desc_idx` we pop was set above only when the
10795        // corresponding `entries[i]` slot was `Some`; no Executor
10796        // mutation happens between that scan and this dispatch.
10797        let mut dispatch_one = |meta: &CallbackMeta,
10798                                desc_idx: usize,
10799                                arena_ptr: *mut u8,
10800                                delta_us: u64,
10801                                result: &mut SpinOnceResult| {
10802            // Phase 141.B.2 — capture T1 at subscription dispatch
10803            // entry. Probe pairs it with the most recent T0 from
10804            // `nros_rmw_runtime_wake_cb` (std + alloc variants)
10805            // and pushes `T1 - T0` onto the ring buffer 141.C
10806            // drains. No-op when the probe feature is off or
10807            // no cycle reader is installed. Other entry kinds
10808            // (Service / Timer / GuardCondition) skip the probe
10809            // because the 141 acceptance is specifically
10810            // wake-to-subscription-dispatch latency.
10811            #[cfg(feature = "wake-latency-probe")]
10812            if matches!(meta.kind, EntryKind::Subscription) {
10813                super::wake_probe::on_dispatch();
10814            }
10815            let data_ptr = unsafe { arena_ptr.add(meta.offset) };
10816            // phase-8 — `desc_idx` is the entry slot index, threaded through so
10817            // the leaf hooks in `arena.rs` can name the callback they bracket.
10818            // `MAX_CALLBACK_SLOTS` is 64 (enforced by the `u64` ready-set
10819            // bitmask), so the `as u8` cannot truncate a live slot.
10820            // phase-463 W7 — time the dispatch and attribute its publishes
10821            // (host `profile-mode` only). ONE call site for both builds, so no
10822            // second `unsafe` block; the binding is the only change an RTOS
10823            // image sees, and its loadable bytes measured identical (phase-463
10824            // W5 I3c, Zephyr cpp/talker).
10825            #[cfg(feature = "profile-mode")]
10826            super::profile::dispatch_begin(arena_ptr as usize, desc_idx);
10827            let processed = unsafe { (meta.try_process)(data_ptr, delta_us, desc_idx as u8) };
10828            #[cfg(feature = "profile-mode")]
10829            super::profile::dispatch_end(matches!(processed, Ok(true)));
10830            match processed {
10831                Ok(true) => match meta.kind {
10832                    EntryKind::Subscription => result.subscriptions_processed += 1,
10833                    EntryKind::Service
10834                    | EntryKind::ServiceClient
10835                    | EntryKind::ActionServer
10836                    | EntryKind::ActionClient => result.services_handled += 1,
10837                    EntryKind::Timer => result.timers_fired += 1,
10838                    EntryKind::GuardCondition => {}
10839                },
10840                Ok(false) => {}
10841                Err(e) => {
10842                    // phase-427 — the first one is kept, the rest are counted.
10843                    // `stop_on_first_error` is what reads it; when nobody
10844                    // asked, the `Option` is a local the optimiser drops.
10845                    if first_error.is_none() {
10846                        *first_error = Some(e);
10847                    }
10848                    match meta.kind {
10849                        EntryKind::Subscription => result.subscription_errors += 1,
10850                        EntryKind::Service
10851                        | EntryKind::ServiceClient
10852                        | EntryKind::ActionServer
10853                        | EntryKind::ActionClient => result.service_errors += 1,
10854                        EntryKind::Timer | EntryKind::GuardCondition => {}
10855                    }
10856                }
10857            }
10858        };
10859
10860        // Phase 110.E.b follow-up — per-callback runtime accounting
10861        // for Sporadic SCs. Wall-clock-measure each dispatch and
10862        // consume the elapsed microseconds from the bound SC's
10863        // atomic budget. This replaces the cycle-level over-
10864        // attribution that previously charged the FULL `delta_us`
10865        // against every Sporadic SC regardless of which entries
10866        // actually fired — accurate per-callback measurement is the
10867        // shape the design doc's per-callback runtime acceptance
10868        // calls out.
10869        //
10870        // phase-359 W10 — this was `feature = "std"`-gated, because it "needs a
10871        // `core::time::Instant`-equivalent monotonic clock ... until a
10872        // board-side monotonic-microsecond accessor lands". That accessor
10873        // landed: `now_us()` reads the platform monotonic counter on every
10874        // flavour. So per-callback sporadic accounting is no longer something
10875        // only a hosted build gets, and the no_std fallback to polled
10876        // `SporadicState` cycle deltas is no longer the only option on target.
10877        let consume_dispatch_runtime_us =
10878            |desc_idx: usize,
10879             elapsed_us: u32,
10880             sched_context_bindings: &[super::sched_context::SchedContextId],
10881             sched_contexts: &[Option<super::sched_context::SchedContext>],
10882             sporadic_states: &mut [Option<super::sched_context::SporadicState>],
10883             alive_slots: &mut [super::monitor::AliveSlot],
10884             #[cfg(feature = "alloc")] sporadic_atomic_states: &[Option<(
10885                portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
10886                OpaqueTimerHandle,
10887            )>]| {
10888                let sc_idx = sched_context_bindings[desc_idx].0 as usize;
10889                // Alive supervision counts EVERY dispatch, before the class
10890                // gate below: a Fifo or time-triggered context can fall
10891                // silent exactly as a sporadic one can, and the rule that
10892                // notices must not be scoped to the class that happens to
10893                // carry a budget.
10894                if let Some(slot) = alive_slots.get_mut(sc_idx) {
10895                    slot.dispatches = slot.dispatches.wrapping_add(1);
10896                }
10897                let sc_class = sched_contexts
10898                    .get(sc_idx)
10899                    .and_then(|s| s.as_ref())
10900                    .map(|sc| sc.class)
10901                    .unwrap_or(super::sched_context::SchedClass::Fifo);
10902                if !matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
10903                    return;
10904                }
10905                #[cfg(feature = "alloc")]
10906                if let Some((state, _)) =
10907                    sporadic_atomic_states.get(sc_idx).and_then(|s| s.as_ref())
10908                {
10909                    state.consume(elapsed_us);
10910                    // Unconditional, unlike the overrun record below: the
10911                    // budget is sized FROM this number, so it has to exist
10912                    // before the budget is right rather than only after it
10913                    // is wrong.
10914                    state.record_exec(elapsed_us);
10915                    // Phase 110.E.b — overrun detection. Cooperative
10916                    // single-thread can't preempt a runaway callback,
10917                    // so post-dispatch wall-clock comparison delivers
10918                    // the same observable signal as the design's
10919                    // oneshot-IRQ-and-cancel pattern, without needing
10920                    // a separate timer per SC. `budget_capacity_us` is
10921                    // the per-period budget the SC was sized against;
10922                    // any callback exceeding that has run past its
10923                    // bandwidth allotment.
10924                    if elapsed_us > state.budget_capacity_us {
10925                        state.record_overrun(elapsed_us - state.budget_capacity_us);
10926                    }
10927                }
10928                // issue 0736 — the POLLED state is charged unconditionally,
10929                // and it is the one that matters: `has_budget` prefers the
10930                // atomic state only when a refill timer was registered, which
10931                // no board or entry does. Before this, the alloc arm charged a
10932                // state nothing populated and the no_std arm discarded the
10933                // measurement outright (`let _ = (sc_idx, elapsed_us)`), so no
10934                // shipped image recorded per-callback runtime anywhere — while
10935                // the comment above claimed it did.
10936                if let Some(state) = sporadic_states.get_mut(sc_idx).and_then(|s| s.as_mut()) {
10937                    state.consume(elapsed_us);
10938                }
10939            };
10940
10941        // W3b.5 — post-dispatch contract checks. `lat_active` gates the
10942        // per-dispatch publish-count snapshot (attribution of dispatch
10943        // elapsed time to monitored publishers whose counter advanced);
10944        // `dl_active` gates elapsed measurement for deadline actions. The third
10945        // term used to be `cfg!(feature = "std")` — "std measures anyway for
10946        // the sporadic path" — which made a COMPILE-TIME flavour stand in for
10947        // "is there a clock". It is now the runtime question it always was.
10948        let mon_table = self.monitor_table;
10949        let lat_active = mon_table.iter().any(|m| m.max_latency_ms > 0);
10950        let dl_active = self.sched_contexts.iter().flatten().any(|sc| {
10951            sc.deadline_us.is_some()
10952                && !matches!(
10953                    sc.deadline_action,
10954                    super::sched_context::DeadlineAction::Ignore
10955                )
10956        });
10957        let mon_clock = self.clock_us_fn;
10958        // phase10-B3 -- the route rule needs the clock and the publish-count
10959        // snapshot, and nothing else; without a clock it judges nothing.
10960        let route_on = mon_clock.is_some() && super::monitor::route_active(mon_table);
10961        // phase-503 -- a late supervised route reacts at completion too; not
10962        // before the monitors are armed (phase-474 I2), as after the park.
10963        let route_hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
10964        let counts_on = lat_active || route_on;
10965        // phase-359 W4 — one predicate, no cfg block. `cfg!` is an expression,
10966        // so the flavour difference stays a value instead of a branch: std
10967        // measures unconditionally because the sporadic runtime accounting
10968        // below (itself std-only) consumes the result, which is exactly what
10969        // the comment above described and what the four-arm sites encoded.
10970        let measure_us = counts_on || dl_active || mon_clock.is_some();
10971        // phase-359 W4 — ONE hoisted µs reader for the per-dispatch latency
10972        // measurement below. Hoisted because `now_us()` takes `&mut self` and
10973        // the dispatch loop already holds borrows; the std arm copies the epoch
10974        // (a `Copy` `Instant`) so reading inside the loop touches no `self`.
10975        // This is the last cfg pair in the timing path: the two call sites it
10976        // serves each had FOUR arms (start + elapsed, per flavour).
10977        let read_us = move || mon_clock.map(|c| c()).unwrap_or(0);
10978        // Deferred deadline-miss violations (the loop body holds an
10979        // immutable borrow of `self.entries`, so the ring is fed after).
10980        let mut deadline_misses: heapless::Vec<
10981            super::monitor::Violation,
10982            { super::monitor::MAX_VIOLATIONS },
10983        > = heapless::Vec::new();
10984        // SCs whose remaining callbacks this cycle are skipped
10985        // (`DeadlineAction::Skip`) — bitmask over SC slots.
10986        let mut skipped_scs: u64 = 0;
10987
10988        // For each priority bucket (Critical → Normal → BestEffort),
10989        // drain EDF first then FIFO so an EDF callback in this bucket
10990        // beats a FIFO peer at the same priority, but no lower-priority
10991        // entry runs while a higher-priority bucket has work pending.
10992        // Strict static priority across buckets; non-preemptive within
10993        // an in-flight callback (see Phase 110.D).
10994        for bucket in 0..NB {
10995            while let Some(job) = edf.pop_from(bucket) {
10996                let i = job.desc_idx as usize;
10997                let sc_idx = self.sched_context_bindings[i].0 as usize;
10998                if sc_idx < 64 && skipped_scs & (1u64 << sc_idx) != 0 {
10999                    continue; // W3b.5 DeadlineAction::Skip containment
11000                }
11001                if let Some(meta) = self.entries[i].as_ref()
11002                    && !Self::slot_held(self.slot_tags, i)
11003                {
11004                    let counts_before = snapshot_pub_counts(mon_table, counts_on);
11005                    // Measured only when a consumer wants it, matching the
11006                    // no_std arm this replaces — the std arm used to measure
11007                    // unconditionally.
11008                    let start_us = measure_us.then(read_us);
11009                    let took_before = result.subscriptions_processed;
11010                    Self::begin_dispatch(self.slot_tags, i);
11011                    dispatch_one(meta, i, arena_ptr, delta_us, &mut result);
11012                    let end_us = start_us.map(|_| read_us());
11013                    let elapsed_us: Option<u32> = start_us
11014                        .zip(end_us)
11015                        .map(|(t0, t1)| t1.saturating_sub(t0))
11016                        .map(|d| d.min(u32::MAX as u64) as u32);
11017                    // phase10-B3 -- a subscription dispatch takes its sample
11018                    // first, so the dispatch start is the take.
11019                    if route_on && let (Some(t0), Some(t1)) = (start_us, end_us) {
11020                        if result.subscriptions_processed != took_before {
11021                            super::monitor::route_take(mon_table, i, t0);
11022                        }
11023                        super::monitor::route_judge(
11024                            mon_table,
11025                            &counts_before,
11026                            t1,
11027                            |row, v, record, react| {
11028                                if react {
11029                                    call_route_reaction(route_hook, mon_table, row, &v);
11030                                }
11031                                if record {
11032                                    let _ = deadline_misses.push(v);
11033                                }
11034                            },
11035                        );
11036                    }
11037                    if let Some(elapsed_us) = elapsed_us {
11038                        consume_dispatch_runtime_us(
11039                            i,
11040                            elapsed_us,
11041                            &self.sched_context_bindings[..],
11042                            &self.sched_contexts[..],
11043                            &mut self.sporadic_states[..],
11044                            &mut self.alive_slots[..],
11045                            #[cfg(feature = "alloc")]
11046                            &self.sporadic_atomic_states[..],
11047                        );
11048                    }
11049                    if let Some(elapsed_us) = elapsed_us {
11050                        attribute_latency(mon_table, lat_active, &counts_before, elapsed_us);
11051                        check_deadline_miss(
11052                            self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref()),
11053                            sc_idx,
11054                            elapsed_us,
11055                            &mut deadline_misses,
11056                            &mut skipped_scs,
11057                            self.fault_fn,
11058                            self.reaction_hook,
11059                        );
11060                    }
11061                    self.finish_dispatch(i);
11062                }
11063            }
11064            while let Some(job) = fifo.pop_from(bucket) {
11065                let i = job.desc_idx as usize;
11066                let sc_idx = self.sched_context_bindings[i].0 as usize;
11067                if sc_idx < 64 && skipped_scs & (1u64 << sc_idx) != 0 {
11068                    continue; // W3b.5 DeadlineAction::Skip containment
11069                }
11070                if let Some(meta) = self.entries[i].as_ref()
11071                    && !Self::slot_held(self.slot_tags, i)
11072                {
11073                    let counts_before = snapshot_pub_counts(mon_table, counts_on);
11074                    // Measured only when a consumer wants it, matching the
11075                    // no_std arm this replaces — the std arm used to measure
11076                    // unconditionally.
11077                    let start_us = measure_us.then(read_us);
11078                    let took_before = result.subscriptions_processed;
11079                    Self::begin_dispatch(self.slot_tags, i);
11080                    dispatch_one(meta, i, arena_ptr, delta_us, &mut result);
11081                    let end_us = start_us.map(|_| read_us());
11082                    let elapsed_us: Option<u32> = start_us
11083                        .zip(end_us)
11084                        .map(|(t0, t1)| t1.saturating_sub(t0))
11085                        .map(|d| d.min(u32::MAX as u64) as u32);
11086                    // phase10-B3 -- a subscription dispatch takes its sample
11087                    // first, so the dispatch start is the take.
11088                    if route_on && let (Some(t0), Some(t1)) = (start_us, end_us) {
11089                        if result.subscriptions_processed != took_before {
11090                            super::monitor::route_take(mon_table, i, t0);
11091                        }
11092                        super::monitor::route_judge(
11093                            mon_table,
11094                            &counts_before,
11095                            t1,
11096                            |row, v, record, react| {
11097                                if react {
11098                                    call_route_reaction(route_hook, mon_table, row, &v);
11099                                }
11100                                if record {
11101                                    let _ = deadline_misses.push(v);
11102                                }
11103                            },
11104                        );
11105                    }
11106                    if let Some(elapsed_us) = elapsed_us {
11107                        consume_dispatch_runtime_us(
11108                            i,
11109                            elapsed_us,
11110                            &self.sched_context_bindings[..],
11111                            &self.sched_contexts[..],
11112                            &mut self.sporadic_states[..],
11113                            &mut self.alive_slots[..],
11114                            #[cfg(feature = "alloc")]
11115                            &self.sporadic_atomic_states[..],
11116                        );
11117                    }
11118                    if let Some(elapsed_us) = elapsed_us {
11119                        attribute_latency(mon_table, lat_active, &counts_before, elapsed_us);
11120                        check_deadline_miss(
11121                            self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref()),
11122                            sc_idx,
11123                            elapsed_us,
11124                            &mut deadline_misses,
11125                            &mut skipped_scs,
11126                            self.fault_fn,
11127                            self.reaction_hook,
11128                        );
11129                    }
11130                    self.finish_dispatch(i);
11131                }
11132            }
11133        }
11134
11135        // W3b.5 — feed deferred deadline misses into the violation ring.
11136        for v in deadline_misses {
11137            self.violations.record(v);
11138        }
11139
11140        // Issue #505 — same ring, same cycle. This runs AFTER dispatch,
11141        // unlike the windowed rate/age/latency rules at the top of the
11142        // spin: the activations it reports were dropped by the dispatch
11143        // that just happened, and a tier that is stalling should not have
11144        // to wait for the next spin to say so.
11145        self.check_timer_overruns();
11146        self.check_release_jitter_rule();
11147        self.check_stack_headroom_rule();
11148        self.check_alive_supervision();
11149        // phase-474 I7 -- the queued /diagnostics reports, after this spin's
11150        // dispatches and rules, when the next timer leaves room for them.
11151        self.flush_violation_reports();
11152        // phase-474 I1 -- the opt-in drain-and-report hook, after every rule of
11153        // this spin has had its say.
11154        self.violations.drain_and_report();
11155
11156        // Process parameter services (outside the arena)
11157        #[cfg(feature = "param-store")]
11158        #[allow(unused_mut)] // only the services add to it
11159        let mut handled = 0usize;
11160        #[cfg(feature = "param-services")]
11161        if let Some(params) = &mut self.params {
11162            {
11163                let crate::param_store::ParamState {
11164                    server,
11165                    services,
11166                    buffers,
11167                    staged,
11168                    ..
11169                } = &mut **params;
11170                // phase-426 W3 -- one set per node; see the sibling site above,
11171                // including why nothing here can discard an error (1270/1271).
11172                if let Some(buffers) = buffers {
11173                    for set in services.iter_mut() {
11174                        let n = set.process(server, staged, buffers);
11175                        result.services_handled += n;
11176                        handled += n;
11177                    }
11178                }
11179            }
11180        }
11181        #[cfg(feature = "param-store")]
11182        self.note_param_services_ran(handled);
11183
11184        // Process lifecycle services (outside the arena).
11185        //
11186        // SAFETY: `change_state` dispatches a user-supplied C callback through a
11187        // raw function pointer stored in `LifecyclePollingNodeCtx`. The caller
11188        // of `register_lifecycle_services` guarantees the callback/context pair
11189        // stays live for as long as the executor (see that method's docs).
11190        #[cfg(feature = "lifecycle-services")]
11191        if let Some(lc) = &mut self.lifecycle {
11192            if let Ok(n) = unsafe { lc.process() } {
11193                result.services_handled += n;
11194            }
11195        }
11196
11197        // Phase 258 (Track 2, 2a) — executor-owned component tick pass.
11198        // Mirrors `ExecutorNodeRuntime::run_ticks`: after the transport +
11199        // callbacks have been pumped, drive each enrolled component's `tick`
11200        // (service-client/action poll, etc.). `exec_ctx` hands the component
11201        // the whole executor as a raw `*mut Executor` so its tick can
11202        // reborrow it (the same disjoint-field raw-ptr pattern run_ticks
11203        // uses). Index-iterate over `Copy` slots so no borrow of
11204        // `self.component_slots` is held while `tick` runs (which aliases
11205        // `self` through `exec_ctx`).
11206        let exec_ctx = self as *mut Executor as *mut core::ffi::c_void;
11207        let slot_count = self.component_slots.len();
11208        for i in 0..slot_count {
11209            let slot = self.component_slots[i];
11210            // SAFETY: `slot.state` is the leaked cell the matching `tick`
11211            // expects (enrolled via `enroll_component`); `exec_ctx` is a live
11212            // `*mut Executor` for `self`. The slot was copied out, so no
11213            // borrow of `component_slots` is outstanding during the call.
11214            unsafe {
11215                (slot.tick)(slot.state, exec_ctx);
11216            }
11217        }
11218
11219        result
11220    }
11221
11222    /// Drive I/O and dispatch callbacks forever — the body of an RTOS task.
11223    ///
11224    /// EXTENSION (RFC-0089). Not a rename of upstream's `spin`: the `!` is the
11225    /// point. A bare-metal entry has no shutdown source to return TO, and
11226    /// declaring that lets the compiler drop the epilogue and lets the caller
11227    /// write it as the last expression of a `-> !` task body with no
11228    /// `unreachable!()`. [`spin`](Self::spin) is the verb that can end, and it
11229    /// is upstream's.
11230    ///
11231    /// phase-427 — was `spin(Duration) -> !`, which squatted on rclrs's name
11232    /// with a different contract (a ported `executor.spin(SpinOptions)` line
11233    /// had to be renamed by hand, the one edit on the tutorial's happy path
11234    /// that no real difference forced). `spin_default()` went with it: it named
11235    /// a 50 ms poll interval and had zero call sites, and the interval is now
11236    /// [`SpinOptions::poll_interval`], defaulting to
11237    /// [`DEFAULT_SPIN_POLL_INTERVAL`](super::types::DEFAULT_SPIN_POLL_INTERVAL).
11238    ///
11239    /// **Envelope.** Only `poll_interval` and `stop_on_first_error` can mean
11240    /// anything here; `timeout`, `only_next` and `max_callbacks` are ENDING
11241    /// conditions and this verb does not end. Passing one is a caller error and
11242    /// says so at `LOG_ERR` on entry rather than being quietly dropped — a
11243    /// discarded ending condition is precisely the "compiles and differs" case
11244    /// RFC-0089 exists to make loud, and a `-> !` signature has no other
11245    /// channel. `stop_on_first_error` likewise cannot RETURN the error; it
11246    /// logs it and keeps spinning, because a task body has nowhere to put it.
11247    ///
11248    /// ```ignore
11249    /// let mut executor = Executor::open(&config)?;
11250    /// executor.register_subscription::<Int32, _>("/topic", |msg| { /* ... */ })?;
11251    /// executor.spin_forever(SpinOptions::default()); // never returns
11252    /// ```
11253    pub fn spin_forever(&mut self, opts: SpinOptions) -> ! {
11254        if opts.timeout.is_some() || opts.only_next || opts.max_callbacks.is_some() {
11255            nros_log::log_error!(
11256                nros_log::get_logger("nros"),
11257                "spin_forever: an ending condition (timeout / only_next / max_callbacks) \
11258                 was supplied to a verb that never returns and is being IGNORED \
11259                 -- use `Executor::spin` for a loop that can end"
11260            );
11261        }
11262        let poll = opts.poll_interval;
11263        let report_errors = opts.stop_on_first_error;
11264        loop {
11265            let mut first_error = None;
11266            self.spin_once_capturing(poll, &mut first_error);
11267            if report_errors && let Some(e) = first_error {
11268                nros_log::log_error!(
11269                    nros_log::get_logger("nros"),
11270                    "spin_forever: a callback failed ({:?}); `stop_on_first_error` cannot \
11271                     end a `-> !` loop, so this is a report, not a stop",
11272                    e
11273                );
11274            }
11275        }
11276    }
11277
11278    /// Drive I/O and dispatch callbacks asynchronously.
11279    ///
11280    /// Runs forever, yielding between poll cycles so that other async tasks
11281    /// (e.g., [`Promise`](super::handles::Promise)) can make progress.
11282    ///
11283    /// Uses only `core::future` — no external async runtime dependency.
11284    ///
11285    /// ADOPT-BOUNDED against rclrs's
11286    /// `spin_async(SpinOptions) -> impl Future<Output = Vec<RclrsError>>`:
11287    ///
11288    /// * **It never returns and reports no errors** — the output is `!`, and it
11289    ///   takes no `SpinOptions`. To stop, `select` it against the thing you are
11290    ///   waiting for (pattern 1 below).
11291    /// * **It does not sleep on RMW I/O.** Each cycle is `spin_once(1 ms)` and then
11292    ///   a yield that wakes itself before returning `Pending`, so any runtime that
11293    ///   honours wakes polls it again at once. It is CPU-bound by construction;
11294    ///   the 1 ms `spin_once` budget is its only wait. Do not read `async` as idle.
11295    ///
11296    /// # Usage patterns
11297    ///
11298    /// ```ignore
11299    /// // Pattern 1: select with a promise (embassy-futures)
11300    /// use embassy_futures::select::{select, Either};
11301    /// let promise = client.call(&req)?;
11302    /// let Either::Second(reply) = select(executor.spin_async(), promise).await
11303    ///     else { unreachable!() };
11304    ///
11305    /// // Pattern 2: manual polling (no async runtime)
11306    /// let mut promise = client.call(&req)?;
11307    /// loop {
11308    ///     executor.spin_once(core::time::Duration::from_millis(10));
11309    ///     if let Ok(Some(r)) = promise.take() { break r; }
11310    /// }
11311    /// ```
11312    pub async fn spin_async(&mut self) -> ! {
11313        loop {
11314            self.spin_once(core::time::Duration::from_millis(1));
11315            core::future::poll_fn::<(), _>(|cx| {
11316                cx.waker().wake_by_ref();
11317                core::task::Poll::Pending
11318            })
11319            .await;
11320        }
11321    }
11322
11323    /// Execute the work that is ready NOW and return — rclcpp's
11324    /// `Executor::spin_some(max_duration)`.
11325    ///
11326    /// ADOPT-BOUNDED (RFC-0089). Upstream's drain verb: collect what is ready,
11327    /// execute it, come back. It never waits for new work — that is
11328    /// [`spin_once`](Self::spin_once), which takes a budget and blocks on the
11329    /// transport until it expires.
11330    ///
11331    /// **Envelope, two ways it is weaker than rclcpp's:**
11332    ///
11333    /// * rclcpp collects the ready set ONCE and executes it. One
11334    ///   `spin_once(0)` here is already that whole set (RFC-0002 §3), so this
11335    ///   would be a single call — except a callback may enqueue work for
11336    ///   another entity, which rclcpp's `spin_all` re-collects and its
11337    ///   `spin_some` leaves for the next call. Ours re-drains until a pass does
11338    ///   NO work, which is `spin_all`'s answer, because the alternative is
11339    ///   work sitting in the arena until someone spins again. Bounded by
11340    ///   `max`, so a callback that feeds itself cannot wedge the caller.
11341    /// * `max` is checked BETWEEN passes, never inside one, so it is a floor on
11342    ///   when this returns and not a ceiling: a single long callback overruns
11343    ///   it. There is no preemption here to make it a ceiling.
11344    ///
11345    /// `max` of [`Duration::ZERO`](core::time::Duration::ZERO) means no limit —
11346    /// rclcpp's own default — and is the spelling a build with no clock must
11347    /// use. A non-zero `max` on a clockless build is an ERROR rather than an
11348    /// unbounded drain (issue 0709, the same rule as `spin`'s `timeout`).
11349    ///
11350    /// The `Result` is about that PRECONDITION and nothing else. A failed
11351    /// callback is counted in each pass's `SpinOnceResult` and does not end the
11352    /// drain, matching `spin`'s default (`SpinOptions::stop_on_first_error` is
11353    /// `false`); rclcpp's `spin_some` returns `void` and reports callback
11354    /// failures nowhere at all.
11355    ///
11356    /// # Example
11357    ///
11358    /// ```ignore
11359    /// // Drain the ready set, then get on with the caller's own work.
11360    /// executor.spin_some(core::time::Duration::ZERO)?;
11361    /// ```
11362    pub fn spin_some(&mut self, max: core::time::Duration) -> Result<(), NodeError> {
11363        let bounded = !max.is_zero();
11364        let start_us = self.now_us();
11365        if bounded && start_us.is_none() {
11366            nros_log::log_error!(
11367                nros_log::get_logger("nros"),
11368                "spin_some: a max duration was requested but this build has no clock \
11369                 - install one with `ExecutorConfig::clock_us`, or pass \
11370                 `Duration::ZERO` for rclcpp's unbounded default (issue 0709)"
11371            );
11372            return Err(NodeError::NotInitialized);
11373        }
11374        let max_us = max.as_micros().min(u64::MAX as u128) as u64;
11375
11376        // No `enter_spin_loop` here, deliberately: `is_spinning` answers "a
11377        // blocking loop is running that a peer's `cancel()` has to reach", and
11378        // this verb returns on its own. Raising the flag for the length of a
11379        // drain would make a `cancel()` from another task look acted-upon when
11380        // nothing was waiting for it.
11381        loop {
11382            let result = self.spin_once(core::time::Duration::ZERO);
11383            if !result.any_work() {
11384                break;
11385            }
11386            if bounded
11387                && let (Some(start), Some(now)) = (start_us, self.now_us())
11388                && now.saturating_sub(start) >= max_us
11389            {
11390                break;
11391            }
11392        }
11393        Ok(())
11394    }
11395
11396    // ========================================================================
11397    // spin_one_period (no_std)
11398    // ========================================================================
11399
11400    /// Process one iteration and return remaining sleep time.
11401    ///
11402    /// This is `no_std` compatible — the caller is responsible for the actual
11403    /// delay using platform-specific sleep.
11404    ///
11405    /// # Arguments
11406    /// * `period_ms` - Target period in milliseconds
11407    /// * `elapsed_ms` - Time elapsed since last call (used for timer ticking)
11408    ///
11409    /// # Example
11410    ///
11411    /// ```ignore
11412    /// loop {
11413    ///     let r = executor.spin_one_period(10, elapsed_ms);
11414    ///     platform_sleep_ms(r.remaining_ms);
11415    /// }
11416    /// ```
11417    pub fn spin_one_period(&mut self, period_ms: u64, elapsed_ms: u64) -> SpinPeriodPollingResult {
11418        let result = self.spin_once(core::time::Duration::from_millis(elapsed_ms));
11419        SpinPeriodPollingResult {
11420            work: result,
11421            remaining_ms: period_ms.saturating_sub(elapsed_ms),
11422        }
11423    }
11424}
11425
11426// ============================================================================
11427// Parameter services (cfg param-services)
11428// ============================================================================
11429
11430#[cfg(feature = "param-services")]
11431impl<'s> Executor<'s> {
11432    /// Register the 6 ROS 2 parameter services for this node.
11433    ///
11434    /// Creates service servers for `get_parameters`, `set_parameters`,
11435    /// `set_parameters_atomically`, `list_parameters`, `describe_parameters`,
11436    /// and `get_parameter_types`.
11437    ///
11438    /// The service names follow the ROS 2 convention: `/{namespace}/{node_name}/{suffix}`.
11439    /// For the default namespace `/`, this becomes `/{node_name}/{suffix}` (e.g.
11440    /// `/sentinel/list_parameters`).
11441    ///
11442    /// Parameter services are stored outside the arena and don't consume
11443    /// callback slots.
11444    ///
11445    /// # Example
11446    ///
11447    /// ```ignore
11448    /// let config = ExecutorConfig::from_env().node_name("talker");
11449    /// let mut executor = Executor::open(&config)?;
11450    /// executor.register_parameter_services()?;
11451    /// executor.declare_parameter("start_value", ParameterValue::Integer(0));
11452    /// ```
11453    pub fn register_parameter_services(&mut self) -> Result<(), NodeError> {
11454        // Issue 1706 -- six services over a store that does not exist would
11455        // answer every request with "not declared"; say what went wrong
11456        // instead (`report_parameter_store_refused` has named the size).
11457        if !self.ensure_parameter_store() {
11458            return Err(NodeError::Transport(TransportError::BadAlloc));
11459        }
11460        if let Some(params) = &mut self.params {
11461            params.requested = true;
11462        }
11463        // phase-426 W3 — with no node yet, RECORD the request and stop. The
11464        // reconcile's no-node fallback publishes under the executor's own
11465        // identity, and taking it here would pin node 0 to that name before the
11466        // node that IS node 0 has been built — which is the pre-W3 behaviour
11467        // this work item exists to remove. Every generated entry arrives in
11468        // exactly this state (`apply_param_services` runs ahead of the per-node
11469        // `register` calls, by design), so the fallback belongs on the spin
11470        // path, where "no node" has stopped being merely "not yet".
11471        if self.nodes.is_empty() {
11472            return Ok(());
11473        }
11474        self.reconcile_parameter_services()
11475    }
11476
11477    /// phase-426 W3 — is there a node whose six services are not up yet?
11478    ///
11479    /// The cheap half of the reconcile, kept separate so `spin_once` can test
11480    /// it without carrying the reconcile's frame (see below).
11481    #[inline]
11482    pub(crate) fn parameter_services_pending(&self) -> bool {
11483        self.params.as_deref().is_some_and(|p| {
11484            p.requested
11485                && p.services.len() < self.nodes.len().max(1)
11486                // Issue 1268 — a failure that cannot change is not pending
11487                // work. This used to retry six `create_service` calls on EVERY
11488                // spin, for the life of an image that would never serve them,
11489                // and the result was discarded; 1271 made it say so once, which
11490                // left the retrying. An explicit
11491                // `register_parameter_services()` still tries, because that is
11492                // a caller asking rather than the spin guessing.
11493                && p.reconcile_failure.as_ref().is_none_or(|f| !f.permanent)
11494        })
11495    }
11496
11497    /// Issue 1271 -- say, once, that the parameter services could not be
11498    /// created, instead of discarding the result on every spin.
11499    ///
11500    /// The spin keeps retrying (a node registered later, or a transport that
11501    /// comes up late, may still succeed); this only decides whether anyone is
11502    /// told. Cold and out of line: it formats, and `spin_once`'s frame must
11503    /// not carry that.
11504    #[cold]
11505    #[inline(never)]
11506    fn report_parameter_service_reconcile_failure(&mut self, e: NodeError) {
11507        let Some(params) = self.params.as_deref_mut() else {
11508            return;
11509        };
11510        if params.reconcile_failure_reported {
11511            return;
11512        }
11513        params.reconcile_failure_reported = true;
11514        // Issue 1268 — name the node and the service. "could not be created
11515        // ({:?})" named neither, so a reader could not tell a name too long for
11516        // one node from a backend that will serve none of the six.
11517        let (node_fqn, service, permanent) = match params.reconcile_failure.as_ref() {
11518            Some(f) => (f.node_fqn.as_str(), f.service, f.permanent),
11519            None => ("<unknown node>", "<unknown service>", false),
11520        };
11521        nros_log::log_error!(
11522            nros_log::get_logger("nros"),
11523            "parameter services could not be created for node {} at service {}: {:?}. \
11524             `ros2 param` cannot reach {} of this image's {} node(s). {} (issues 1271, 1268).",
11525            node_fqn,
11526            service,
11527            e,
11528            self.nodes.len().max(1) - params.services.len(),
11529            self.nodes.len().max(1),
11530            if permanent {
11531                "Not retrying: this failure cannot change without a rebuild."
11532            } else {
11533                "Retrying on every spin; logged once."
11534            }
11535        );
11536    }
11537
11538    /// phase-426 W3 — publish one set of six under every node that does not
11539    /// have one yet.
11540    ///
11541    /// **Why a reconcile and not a registration.** `nros::main!` emits
11542    /// `apply_param_services` BEFORE its per-node `register` calls, by design:
11543    /// the store has to exist when each component cell captures it. So at the
11544    /// moment the request is made the node table is EMPTY and there is no FQN
11545    /// to publish under — registering there is how the six ended up pinned to
11546    /// the executor's own identity in the first place. The request is recorded
11547    /// and satisfied here, at the head of every spin, exactly as
11548    /// `reconcile_ros_time_source` does for `use_sim_time` and for the same
11549    /// reason.
11550    ///
11551    /// **An executor with no registered node still gets one set**, under its
11552    /// own `node_name`/`namespace` and keyed `NodeId::PRIMARY` — the identity
11553    /// [`declare_parameter`](Self::declare_parameter) writes to. That is the
11554    /// pre-W3 behaviour, preserved for the direct-API images that never call
11555    /// `node_builder` (`param-chatter-talker` is one), and it is keyed the same
11556    /// as the first node record, so a node registered later inherits the set
11557    /// rather than getting a second one under a different name.
11558    ///
11559    /// Cheap in the settled case: one length comparison — but the FRAME is not
11560    /// cheap, so it is `inline(never)` and the spin site tests
11561    /// [`parameter_services_pending`](Self::parameter_services_pending) first.
11562    /// Building one set used to materialise six `EmbeddedServiceServer`s by
11563    /// value (2 x `PARAM_SERVICE_BUFFER_SIZE` each, ~48 KiB at the 4096
11564    /// default) before they were boxed; inlined into `spin_once` that landed
11565    /// on every spin frame whether or not the branch was taken. Issue 1270
11566    /// took the buffers out of the set -- one pair per executor, allocated on
11567    /// the heap directly -- but the set still carries six backend handles and
11568    /// a name, so the frame stays out of `spin_once`. Issue 0756's rule one
11569    /// call up — a parameter-service frame does not fit an embedded thread
11570    /// stack, and `test_open_threaded_spawn_and_halt` died with a bare SIGSEGV
11571    /// the first time this was reachable from the spin path.
11572    #[inline(never)]
11573    pub(crate) fn reconcile_parameter_services(&mut self) -> Result<(), NodeError> {
11574        let Some(params) = self.params.as_deref() else {
11575            return Ok(());
11576        };
11577        if !params.requested {
11578            return Ok(());
11579        }
11580        // The nodes wanted, minus the ones already served. Collected first
11581        // because building a set borrows `self.session` mutably while
11582        // `self.nodes` / `self.params` are still borrowed by the scan.
11583        let want = self.nodes.len().max(1);
11584        if params.services.len() >= want {
11585            return Ok(());
11586        }
11587        let mut todo: heapless::Vec<
11588            (u8, heapless::String<64>, heapless::String<64>),
11589            { crate::param_sizing::MAX_SERVICE_SETS },
11590        > = heapless::Vec::new();
11591        for index in 0..want {
11592            let key = nros_params::NodeKey::new(index as u8);
11593            if params.services.iter().any(|set| set.param_node() == key) {
11594                continue;
11595            }
11596            let (name, namespace) = match self.nodes.get(index) {
11597                Some(record) => (record.name.clone(), record.namespace.clone()),
11598                // No node table entry: the executor's own implicit primary
11599                // identity, which is what `NodeId::PRIMARY` means.
11600                None => (self.node_name.clone(), self.namespace.clone()),
11601            };
11602            if todo.push((index as u8, name, namespace)).is_err() {
11603                return Err(NodeError::NodeTableFull);
11604            }
11605        }
11606        // Issue 1270 -- the ONE buffer pair every set is answered through,
11607        // allocated with the first set so an image that declares parameters
11608        // and serves none pays nothing for it. `params` is `Some` here: the
11609        // scan above returned early otherwise.
11610        // Issue 1812 -- a carved home hands its pair over with the state, so
11611        // this is reached only on the heap fallback.
11612        if let Some(params) = self.params.as_deref_mut()
11613            && params.buffers.is_none()
11614        {
11615            // Issue 1551 — an exhausted heap is an error this fn returns, not
11616            // an abort through libstd's OOM hook.
11617            #[cfg(feature = "alloc")]
11618            {
11619                params.buffers = Some(
11620                    crate::parameter_services::ParamServiceBuffers::try_with_capacity(
11621                        crate::parameter_services::param_service_buffer_bytes(),
11622                    )
11623                    .ok_or(NodeError::Transport(TransportError::BadAlloc))?,
11624                );
11625            }
11626            #[cfg(not(feature = "alloc"))]
11627            return Err(NodeError::Transport(TransportError::BadAlloc));
11628        }
11629        for (index, name, namespace) in todo {
11630            let servers = match self.build_parameter_service_set(
11631                nros_params::NodeKey::new(index),
11632                &name,
11633                &namespace,
11634            ) {
11635                Ok(servers) => servers,
11636                Err((e, service)) => {
11637                    // Issue 1268 — keep WHAT failed and WHERE, for the one log
11638                    // line, and decide once whether retrying could ever help.
11639                    // `Unsupported` from the transport is a property of the
11640                    // build (the backend has no descriptor for the
11641                    // `rcl_interfaces` types), so a later spin asks the same
11642                    // question and gets the same answer.
11643                    let permanent = matches!(e, NodeError::Transport(TransportError::Unsupported));
11644                    if let Some(params) = self.params.as_deref_mut() {
11645                        let mut node_fqn = heapless::String::<256>::new();
11646                        if let Ok(fqn) = crate::names::fully_qualified_name(&name, &namespace) {
11647                            let _ = node_fqn.push_str(fqn.as_str());
11648                        }
11649                        params.reconcile_failure =
11650                            Some(crate::parameter_services::ParamServiceReconcileFailure {
11651                                node_fqn,
11652                                service,
11653                                permanent,
11654                            });
11655                    }
11656                    return Err(e);
11657                }
11658            };
11659            // Issue 1812 -- the set lives INLINE in the (carved) state now, not
11660            // in a box of its own.
11661            // Issue 0745 — PRESERVE an already-initialized store: launch-param
11662            // seeding may have run before any node existed (services can't be
11663            // registered that early). Overwriting here would wipe the seeds.
11664            match &mut self.params {
11665                Some(params) => params
11666                    .services
11667                    .push(servers)
11668                    .map_err(|_| NodeError::NodeTableFull)?,
11669                None => {
11670                    // Issue 0756 — see new_param_state: never build a ParamState
11671                    // by value, it does not fit an embedded thread stack.
11672                    // Issue 1706 -- the store is fallible; the refusal is
11673                    // reported once by `ensure_parameter_store`'s spelling.
11674                    if self.param_store_refused {
11675                        return Err(NodeError::Transport(TransportError::BadAlloc));
11676                    }
11677                    let Some(mut state) = Self::new_param_state(
11678                        core::mem::take(&mut self.param_slots),
11679                        core::mem::take(&mut self.param_home),
11680                    ) else {
11681                        self.param_store_refused = true;
11682                        report_parameter_store_refused();
11683                        return Err(NodeError::Transport(TransportError::BadAlloc));
11684                    };
11685                    state.requested = true;
11686                    let _ = state.services.push(servers);
11687                    self.params = Some(state);
11688                }
11689            }
11690            // phase-430 W2 — "declare `use_sim_time` on EVERY node, as rclcpp
11691            // does", said where the nodes finally exist. This is the only place
11692            // that knows the full set: `ensure_parameter_store` runs before any
11693            // node record does, so it can only seed the implicit PRIMARY, and
11694            // the further nodes an image composes onto one executor arrive
11695            // later. Seeding as each set of six goes up keeps the two in step —
11696            // the parameter is declared on exactly the nodes a
11697            // `ros2 param set <node> use_sim_time true` can reach.
11698            //
11699            // Idempotent by construction: `declare` refuses a name the node
11700            // already holds and the placeholder flag is only raised on
11701            // acceptance, so re-seeding PRIMARY here neither duplicates the
11702            // entry nor re-marks an application's own declaration as ours.
11703            self.seed_use_sim_time_default(super::node_record::NodeId::from_raw(index));
11704        }
11705        // Issue 1271 -- every node is served now, so a later failure (a node
11706        // registered after this one) is news again and earns its own line.
11707        if let Some(params) = self.params.as_deref_mut() {
11708            params.reconcile_failure_reported = false;
11709            // Issue 1268 -- and drop the record with it, so a later failure is
11710            // reported against the node it actually happened on.
11711            params.reconcile_failure = None;
11712        }
11713        Ok(())
11714    }
11715
11716    /// phase-426 W3 — create the six service servers for ONE node.
11717    ///
11718    /// Split out of [`register_parameter_services`](Self::register_parameter_services)
11719    /// because there are now N of these per image rather than one, and because
11720    /// the six `create_param_srv` call sites are what
11721    /// `check-infra-queryable-counts` reads to hold `PARAM_SERVICE_QUERYABLES`
11722    /// to the number of queryables a set actually claims.
11723    #[inline(never)]
11724    fn build_parameter_service_set(
11725        &mut self,
11726        node: nros_params::NodeKey,
11727        node_name: &str,
11728        namespace: &str,
11729    ) -> Result<crate::parameter_services::ParameterServiceServers, (NodeError, &'static str)> {
11730        use crate::parameter_services::{
11731            DescribeParameters, GetParameterTypes, GetParameters, ListParameters,
11732            ParamServiceHandle as PSrv, ParameterServiceServers, SetParameters,
11733            SetParametersAtomically,
11734        };
11735        use nros_core::RosService;
11736
11737        // The node FQN, from the one implementation. This was the third
11738        // hand-rolled copy of the same join (`crate::names::fully_qualified_name`
11739        // is the first, `Node::fully_qualified_name` was the second) — and the
11740        // three did not agree: this one absolutised a bare namespace, that one
11741        // did not. A join with three spellings has no answer to "what does the
11742        // tree do with `my_ns`".
11743        let node_fqn = crate::names::fully_qualified_name(node_name, namespace)
11744            .map_err(|()| (NodeError::NameTooLong, "get_parameters"))?;
11745        // The service names below still take the two halves separately.
11746        let ns: &str = namespace;
11747        let nn: &str = node_name;
11748
11749        /// Build a service name like `{node_fqn}/{suffix}` and create the server handle.
11750        ///
11751        /// Issue 1268 — the error carries the SUFFIX, so a failure can name the
11752        /// service a tool would have called instead of "one of six".
11753        fn create_param_srv<Svc>(
11754            session: &mut session::ConcreteSession,
11755            domain_id: u32,
11756            node_fqn: &str,
11757            namespace: &str,
11758            node_name: &str,
11759            suffix: &'static str,
11760        ) -> Result<session::RmwServiceServer, (NodeError, &'static str)>
11761        where
11762            Svc: RosService,
11763            Svc::Request: crate::rmw_type_registry::MessageForRmw,
11764            Svc::Reply: crate::rmw_type_registry::MessageForRmw,
11765        {
11766            // Issue 1268 — REGISTER THE TYPES FIRST, exactly as a typed user
11767            // service does (`register_service_sized`). A backend that keys
11768            // topics on a registered type descriptor — Cyclone does — fails
11769            // `create_service` with `Unsupported` for a type it has never been
11770            // told about, and nothing else in the image registers the
11771            // `rcl_interfaces` service types: the generated typesupport of a
11772            // downstream covers ITS OWN message packages, and the backend bakes
11773            // in only `ParticipantEntitiesInfo` for the graph. So the six
11774            // parameter services failed on every spin while the image's own
11775            // services worked, which is what made the cause look like "no
11776            // service support" for two phases (the issue-0745 comment).
11777            //
11778            // A no-op on backends that need no descriptors (zenoh, xrce), which
11779            // is why zenoh served this same image all along.
11780            let register = |e: NodeError| (e, suffix);
11781            crate::rmw_type_registry::register_type::<Svc::Request>().map_err(register)?;
11782            crate::rmw_type_registry::register_type::<Svc::Reply>().map_err(register)?;
11783            let mut name = heapless::String::<256>::new();
11784            name.push_str(node_fqn)
11785                .map_err(|_| (NodeError::NameTooLong, suffix))?;
11786            name.push_str("/")
11787                .map_err(|_| (NodeError::NameTooLong, suffix))?;
11788            name.push_str(suffix)
11789                .map_err(|_| (NodeError::NameTooLong, suffix))?;
11790            let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
11791                // issue 0824 follow-up — `domain_id` is a PARAMETER, not `self.domain_id`:
11792                // this is a nested `fn`, not a method, so `self` is not in scope and
11793                // the original spelling was E0434. It only fails under feature sets
11794                // that compile this arm, which is why `--all-features` caught it and
11795                // the narrower lanes did not.
11796                .with_domain(domain_id)
11797                .with_namespace(namespace);
11798            if !node_name.is_empty() {
11799                info = info.with_node_name(node_name);
11800            }
11801            // issue 0793 — the parameter services get the PARAMETER profile
11802            // (`rmw_qos_profile_parameters`: KEEP_LAST(1000), reliable,
11803            // volatile), not the generic services one. `QOS_PROFILE_PARAMETERS`
11804            // existed with the right depth and had no caller, so every parameter
11805            // server ran on a depth-10 queue while ROS 2 gives them 1000 — which
11806            // matters exactly when a tool sets many parameters at once, the case
11807            // the deep queue is for.
11808            session
11809                .create_service(&info, QoSProfile::parameter_services_default())
11810                .map_err(|e| (NodeError::Transport(e), suffix))
11811        }
11812
11813        let get_handle = create_param_srv::<GetParameters>(
11814            &mut self.session,
11815            self.domain_id,
11816            &node_fqn,
11817            ns,
11818            nn,
11819            "get_parameters",
11820        )?;
11821        let set_handle = create_param_srv::<SetParameters>(
11822            &mut self.session,
11823            self.domain_id,
11824            &node_fqn,
11825            ns,
11826            nn,
11827            "set_parameters",
11828        )?;
11829        let set_atomic_handle = create_param_srv::<SetParametersAtomically>(
11830            &mut self.session,
11831            self.domain_id,
11832            &node_fqn,
11833            ns,
11834            nn,
11835            "set_parameters_atomically",
11836        )?;
11837        let list_handle = create_param_srv::<ListParameters>(
11838            &mut self.session,
11839            self.domain_id,
11840            &node_fqn,
11841            ns,
11842            nn,
11843            "list_parameters",
11844        )?;
11845        let desc_handle = create_param_srv::<DescribeParameters>(
11846            &mut self.session,
11847            self.domain_id,
11848            &node_fqn,
11849            ns,
11850            nn,
11851            "describe_parameters",
11852        )?;
11853        let types_handle = create_param_srv::<GetParameterTypes>(
11854            &mut self.session,
11855            self.domain_id,
11856            &node_fqn,
11857            ns,
11858            nn,
11859            "get_parameter_types",
11860        )?;
11861
11862        Ok(ParameterServiceServers::new(
11863            // phase-426 W3 — the set carries the node whose parameters it
11864            // serves, so a handler can never read the store without saying
11865            // whose parameters it means.
11866            node,
11867            // Issue 1271 -- carried so a dropped request is reported against
11868            // the service a tool actually called.
11869            node_fqn,
11870            // Issue 1270 -- handles only. The buffers are the executor's one
11871            // shared pair (`ParamState::buffers`), not six per node.
11872            PSrv::<GetParameters>::new(get_handle),
11873            PSrv::<SetParameters>::new(set_handle),
11874            PSrv::<SetParametersAtomically>::new(set_atomic_handle),
11875            PSrv::<ListParameters>::new(list_handle),
11876            PSrv::<DescribeParameters>::new(desc_handle),
11877            PSrv::<GetParameterTypes>::new(types_handle),
11878        ))
11879    }
11880
11881    /// phase-426 W3 — the node FQNs this executor publishes parameter services
11882    /// under, in registration order.
11883    ///
11884    /// The acceptance question for W3 ("does a two-node image expose both
11885    /// FQNs?") answered without a wire: the six services are created from
11886    /// exactly this list, so asserting it asserts what `ros2 param list` will
11887    /// enumerate. The wire assertion is W6.
11888    pub fn parameter_service_node_names(
11889        &self,
11890    ) -> heapless::Vec<crate::names::ResolvedName, { crate::param_sizing::MAX_SERVICE_SETS }> {
11891        let mut out = heapless::Vec::new();
11892        let Some(params) = self.params.as_deref() else {
11893            return out;
11894        };
11895        for set in params.services.iter() {
11896            let index = set.param_node().raw() as usize;
11897            let (name, namespace) = match self.nodes.get(index) {
11898                Some(record) => (record.name.as_str(), record.namespace.as_str()),
11899                None => (self.node_name.as_str(), self.namespace.as_str()),
11900            };
11901            if let Ok(fqn) = crate::names::fully_qualified_name(name, namespace) {
11902                let _ = out.push(fqn);
11903            }
11904        }
11905        out
11906    }
11907
11908    // phase-359 W10 / issue 0080 — `enable_parameter_persistence{,_with}` are
11909    // GONE with the seam they attached. Issue 0080 ruled on-device parameter
11910    // persistence a non-goal in July and listed both by name; nothing in the
11911    // tree ever called them, and the only backend that could be passed
11912    // (`FileParamStore`) is deleted. Runtime get/set/describe stay — it is the
11913    // PERSISTENCE half that was dropped.
11914}
11915
11916// ============================================================================
11917// Lifecycle services (cfg lifecycle-services)
11918// ============================================================================
11919
11920#[cfg(feature = "lifecycle-services")]
11921impl<'s> Executor<'s> {
11922    /// Register the five REP-2002 lifecycle services on this executor.
11923    ///
11924    /// After this call, `ros2 lifecycle set|get|list|nodes` can drive the
11925    /// stored [`LifecyclePollingNodeCtx`](crate::lifecycle::LifecyclePollingNodeCtx)
11926    /// through the node's lifecycle. The state machine is created fresh
11927    /// (starting in `Unconfigured`); callers register their transition
11928    /// callbacks via [`Executor::lifecycle_state_machine_mut`].
11929    ///
11930    /// # Safety
11931    /// Registered callbacks on the state machine are C FFI function pointers.
11932    /// The caller must keep the callback code and any context it captures
11933    /// valid for as long as the executor processes services.
11934    pub fn register_lifecycle_services(&mut self) -> Result<(), NodeError> {
11935        use crate::{
11936            lifecycle::LifecyclePollingNodeCtx,
11937            lifecycle_services::{
11938                ChangeState, GetAvailableStates, GetAvailableTransitions, GetState,
11939                LIFECYCLE_SERVICE_BUFFER_SIZE, LifecycleRuntimeState, LifecycleServiceServers,
11940            },
11941        };
11942        use nros_core::RosService;
11943
11944        type LcSrv<Svc> = super::handles::EmbeddedServiceServer<
11945            Svc,
11946            LIFECYCLE_SERVICE_BUFFER_SIZE,
11947            LIFECYCLE_SERVICE_BUFFER_SIZE,
11948        >;
11949
11950        // Build the node FQN from namespace + node_name (same convention as
11951        // register_parameter_services).
11952        let mut node_fqn = heapless::String::<256>::new();
11953        let ns: &str = &self.namespace;
11954        let nn: &str = &self.node_name;
11955        if ns.is_empty() || ns == "/" {
11956            node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
11957            node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
11958        } else {
11959            node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
11960            node_fqn
11961                .push_str(ns.trim_matches('/'))
11962                .map_err(|_| NodeError::NameTooLong)?;
11963            node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
11964            node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
11965        }
11966
11967        fn create_lc_srv<Svc>(
11968            session: &mut session::ConcreteSession,
11969            domain_id: u32,
11970            node_fqn: &str,
11971            namespace: &str,
11972            node_name: &str,
11973            suffix: &str,
11974        ) -> Result<session::RmwServiceServer, NodeError>
11975        where
11976            Svc: RosService,
11977            Svc::Request: crate::rmw_type_registry::MessageForRmw,
11978            Svc::Reply: crate::rmw_type_registry::MessageForRmw,
11979        {
11980            // Issue 1293 — register both halves first, as `create_param_srv`
11981            // does since issue 1268: Cyclone creates a topic only for a type it
11982            // holds a descriptor for. Three of the five request types are
11983            // EMPTY, and an empty schema had no descriptor until codegen gave
11984            // it rosidl's `structure_needs_at_least_one_member` byte. A no-op
11985            // on backends that need no descriptors (zenoh, xrce).
11986            crate::rmw_type_registry::register_type::<Svc::Request>()?;
11987            crate::rmw_type_registry::register_type::<Svc::Reply>()?;
11988            let mut name = heapless::String::<256>::new();
11989            name.push_str(node_fqn)
11990                .map_err(|_| NodeError::NameTooLong)?;
11991            name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
11992            name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
11993            let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
11994                .with_domain(domain_id)
11995                .with_namespace(namespace);
11996            if !node_name.is_empty() {
11997                info = info.with_node_name(node_name);
11998            }
11999            session
12000                .create_service(&info, QoSProfile::services_default())
12001                .map_err(NodeError::Transport)
12002        }
12003
12004        /// Issue 1587 — the ONE publisher the REP-2002 family claims,
12005        /// `~/transition_event`. Shaped like `create_lc_srv` beside it both
12006        /// because the naming rule is the same (node FQN + suffix) and
12007        /// because `check-infra-queryable-counts` counts the creation sites
12008        /// to hold `LIFECYCLE_SERVICE_PUBLISHERS` to them, exactly as it
12009        /// already holds `LIFECYCLE_SERVICE_QUERYABLES` to the five above.
12010        fn create_lc_pub<Msg: crate::rmw_type_registry::MessageForRmw>(
12011            session: &mut session::ConcreteSession,
12012            domain_id: u32,
12013            node_fqn: &str,
12014            namespace: &str,
12015            node_name: &str,
12016            suffix: &str,
12017            qos: QoSProfile,
12018        ) -> Result<session::RmwPublisher, NodeError> {
12019            // Issue 1293 — same rule as `create_lc_srv`: Cyclone needs the
12020            // `TransitionEvent` descriptor before it can create the topic.
12021            crate::rmw_type_registry::register_type::<Msg>()?;
12022            let mut name = heapless::String::<256>::new();
12023            name.push_str(node_fqn)
12024                .map_err(|_| NodeError::NameTooLong)?;
12025            name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
12026            name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
12027            let mut info = TopicInfo::new(
12028                &name,
12029                <Msg as nros_core::RosMessage>::TYPE_NAME,
12030                Msg::TYPE_HASH,
12031            )
12032            .with_domain(domain_id)
12033            .with_namespace(namespace);
12034            if !node_name.is_empty() {
12035                info = info.with_node_name(node_name);
12036            }
12037            session
12038                .create_publisher(&info, qos)
12039                .map_err(NodeError::Transport)
12040        }
12041
12042        let cs_handle = create_lc_srv::<ChangeState>(
12043            &mut self.session,
12044            self.domain_id,
12045            &node_fqn,
12046            ns,
12047            nn,
12048            "change_state",
12049        )?;
12050        let gs_handle = create_lc_srv::<GetState>(
12051            &mut self.session,
12052            self.domain_id,
12053            &node_fqn,
12054            ns,
12055            nn,
12056            "get_state",
12057        )?;
12058        let gas_handle = create_lc_srv::<GetAvailableStates>(
12059            &mut self.session,
12060            self.domain_id,
12061            &node_fqn,
12062            ns,
12063            nn,
12064            "get_available_states",
12065        )?;
12066        let gat_handle = create_lc_srv::<GetAvailableTransitions>(
12067            &mut self.session,
12068            self.domain_id,
12069            &node_fqn,
12070            ns,
12071            nn,
12072            "get_available_transitions",
12073        )?;
12074        let gtg_handle = create_lc_srv::<GetAvailableTransitions>(
12075            &mut self.session,
12076            self.domain_id,
12077            &node_fqn,
12078            ns,
12079            nn,
12080            "get_transition_graph",
12081        )?;
12082
12083        // Issue 1587 — REP-2002's communication interface is the five
12084        // services PLUS this. VOLATILE, so it costs a publisher slot and a
12085        // liveliness token and NO queryable; see
12086        // `LIFECYCLE_SERVICE_PUBLISHERS` for the measurement that settled it.
12087        let te_handle = create_lc_pub::<nros_lifecycle_msgs::msg::TransitionEvent>(
12088            &mut self.session,
12089            self.domain_id,
12090            &node_fqn,
12091            ns,
12092            nn,
12093            crate::lifecycle_services::TRANSITION_EVENT_SUFFIX,
12094            crate::lifecycle_services::transition_event_qos(),
12095        )?;
12096
12097        let servers = LifecycleServiceServers::new(
12098            LcSrv::<ChangeState> {
12099                handle: cs_handle,
12100                req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12101                reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12102                _phantom: core::marker::PhantomData,
12103            },
12104            LcSrv::<GetState> {
12105                handle: gs_handle,
12106                req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12107                reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12108                _phantom: core::marker::PhantomData,
12109            },
12110            LcSrv::<GetAvailableStates> {
12111                handle: gas_handle,
12112                req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12113                reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12114                _phantom: core::marker::PhantomData,
12115            },
12116            LcSrv::<GetAvailableTransitions> {
12117                handle: gat_handle,
12118                req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12119                reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12120                _phantom: core::marker::PhantomData,
12121            },
12122            LcSrv::<GetAvailableTransitions> {
12123                handle: gtg_handle,
12124                req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12125                reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12126                _phantom: core::marker::PhantomData,
12127            },
12128        );
12129
12130        self.lifecycle = Some(alloc::boxed::Box::new(LifecycleRuntimeState {
12131            state_machine: LifecyclePollingNodeCtx::new(),
12132            services: alloc::boxed::Box::new(servers),
12133            transition_event: te_handle,
12134        }));
12135
12136        Ok(())
12137    }
12138
12139    /// The `~/transition_event` publisher, for the tests that assert what
12140    /// reached the wire. Not a public capability: the executor announces, and
12141    /// a second publisher on the same topic would be a second source of truth.
12142    /// Its only callers are the mock-session tests, which a `rmw-cffi` build
12143    /// does not compile.
12144    #[cfg(all(test, not(feature = "rmw-cffi")))]
12145    pub(crate) fn lifecycle_transition_event_publisher(&self) -> Option<&session::RmwPublisher> {
12146        self.lifecycle.as_ref().map(|lc| &lc.transition_event)
12147    }
12148
12149    /// Mutable access to the lifecycle state machine, if registered.
12150    ///
12151    /// Used to register transition callbacks before spinning and to read the
12152    /// current state from application code.
12153    pub fn lifecycle_state_machine_mut(
12154        &mut self,
12155    ) -> Option<&mut crate::lifecycle::LifecyclePollingNodeCtx> {
12156        self.lifecycle.as_mut().map(|lc| &mut lc.state_machine)
12157    }
12158
12159    /// Immutable access to the lifecycle state machine, if registered.
12160    pub fn lifecycle_state_machine(&self) -> Option<&crate::lifecycle::LifecyclePollingNodeCtx> {
12161        self.lifecycle.as_ref().map(|lc| &lc.state_machine)
12162    }
12163
12164    /// Register a safe lifecycle node (issue 0335 / phase-317).
12165    ///
12166    /// Binds the five REP-2002 lifecycle services and wires each transition to
12167    /// the [`LifecycleCallbacks`](crate::lifecycle::LifecycleCallbacks) trait
12168    /// impl — the safe Rust counterpart to the C++ `nros::LifecycleNode`. No
12169    /// `unsafe` in user code; alloc-free (monomorphized trampolines).
12170    ///
12171    /// `node` must outlive this executor's lifecycle registration: the context
12172    /// pointer stored here is dereferenced on every transition until the
12173    /// executor is finalized. The executor spins single-threaded, so the
12174    /// `&mut node` reconstituted inside a callback is never aliased.
12175    pub fn register_lifecycle_node<T: crate::lifecycle::LifecycleCallbacks>(
12176        &mut self,
12177        node: &mut T,
12178    ) -> Result<(), NodeError> {
12179        use crate::lifecycle::{LifecycleCallbackSlot as Slot, trampolines};
12180
12181        self.register_lifecycle_services()?;
12182        let sm = self
12183            .lifecycle_state_machine_mut()
12184            .ok_or(NodeError::NotInitialized)?;
12185        sm.set_context(node as *mut T as *mut core::ffi::c_void);
12186        sm.register(Slot::Configure, Some(trampolines::on_configure::<T>));
12187        sm.register(Slot::Activate, Some(trampolines::on_activate::<T>));
12188        sm.register(Slot::Deactivate, Some(trampolines::on_deactivate::<T>));
12189        sm.register(Slot::Cleanup, Some(trampolines::on_cleanup::<T>));
12190        sm.register(Slot::Shutdown, Some(trampolines::on_shutdown::<T>));
12191        sm.register(Slot::Error, Some(trampolines::on_error::<T>));
12192        Ok(())
12193    }
12194}
12195
12196// ============================================================================
12197// phase-446 W6 -- the contract's declared parameters (cfg param-services)
12198// ============================================================================
12199
12200#[cfg(feature = "param-store")]
12201impl<'s> Executor<'s> {
12202    /// Hand the executor the parameters each node's contract declares.
12203    ///
12204    /// The entry calls this before any node registers. It only RECORDS the
12205    /// table: the check runs where an application declares a parameter (the
12206    /// `nros` node runtime), not in [`Self::declare_parameter_on`], because
12207    /// that funnel also carries the launch seed, whose value type is still
12208    /// inferred from text and was already held to the contract by the
12209    /// resolver.
12210    pub fn set_declared_params(&mut self, declared: nros_params::DeclaredParams) {
12211        self.declared_params = declared;
12212    }
12213
12214    /// The table [`Self::set_declared_params`] recorded (empty by default).
12215    pub fn declared_params(&self) -> nros_params::DeclaredParams {
12216        self.declared_params
12217    }
12218}
12219
12220// ============================================================================
12221// Parameter declaration API (cfg param-services)
12222// ============================================================================
12223
12224#[cfg(feature = "param-store")]
12225impl<'s> Executor<'s> {
12226    /// phase-461 W6 — build the parameter STORE and register no service.
12227    ///
12228    /// What an image declaring the `params` axis (and not `param_services`)
12229    /// calls ahead of its nodes: launch seeds and `declare_parameter` land in
12230    /// the store, and `ros2 param` reaches none of it, because no queryable
12231    /// was created. The primary node's `use_sim_time` placeholder is seeded
12232    /// as it is for a served store; nothing can SET it here, which is the
12233    /// axis' stated limit.
12234    ///
12235    /// Idempotent, and a later [`Self::register_parameter_services`] keeps
12236    /// this store and adds the six services on top of it.
12237    pub fn register_parameter_store(&mut self) -> Result<(), NodeError> {
12238        if self.ensure_parameter_store() {
12239            Ok(())
12240        } else {
12241            Err(NodeError::Transport(TransportError::BadAlloc))
12242        }
12243    }
12244
12245    /// Issue 0745 — lazily create the parameter STORE (no services yet).
12246    /// Launch-param seeding runs before any node is constructed; the six
12247    /// service servers attach later in `register_parameter_services`,
12248    /// which preserves this store.
12249    /// `true` when the store exists afterwards. `false` only when the platform
12250    /// heap refused its allocation (issue 1706), which is reported once, here,
12251    /// with the size it asked for.
12252    fn ensure_parameter_store(&mut self) -> bool {
12253        if self.params.is_none() {
12254            if self.param_store_refused {
12255                return false;
12256            }
12257            let Some(state) = Self::new_param_state(
12258                core::mem::take(&mut self.param_slots),
12259                core::mem::take(&mut self.param_home),
12260            ) else {
12261                self.param_store_refused = true;
12262                report_parameter_store_refused();
12263                return false;
12264            };
12265            self.params = Some(state);
12266            // phase-430 W2 — the executor's implicit PRIMARY node exists the
12267            // moment the store does, whether or not a node record has been
12268            // built yet, so it is seeded here. The other node keys are seeded
12269            // in `reconcile_parameter_services`, as each one's set of six goes
12270            // up.
12271            self.seed_use_sim_time_default(super::node_record::NodeId::PRIMARY);
12272        }
12273        true
12274    }
12275
12276    /// phase-430 W2 — declare `use_sim_time = false`, as rclcpp does on EVERY
12277    /// node.
12278    ///
12279    /// Upstream auto-declares the parameter in `rclcpp::Node`'s constructor, so
12280    /// `ros2 param list` shows it and `ros2 param set <node> use_sim_time true`
12281    /// reaches a node whose author never wrote a line about simulated time.
12282    /// Ours honoured the parameter only if the APP had declared it: the store
12283    /// refuses an undeclared set (`allow_undeclared` is false), so the runtime
12284    /// switch worked on exactly the nodes that least needed it.
12285    ///
12286    /// The seed is a PLACEHOLDER, tracked by `ParamState::sim_time_seeded`,
12287    /// because the store refuses a duplicate name: without that flag, seeding
12288    /// here would turn an application's own `declare_parameter("use_sim_time",
12289    /// Bool(true))` into a refusal and the auto-declared default would silently
12290    /// beat the app. `yield_seeded_use_sim_time` steps the placeholder aside
12291    /// for the first real declaration, whatever value or descriptor it carries.
12292    ///
12293    /// PER NODE, both here and in the flag. phase-426 keyed the store and the
12294    /// six services by node, so "every node" is now literally expressible and
12295    /// this is where it is said: `ensure_parameter_store` seeds the executor's
12296    /// implicit PRIMARY, and `reconcile_parameter_services` seeds each further
12297    /// node key as its set of six goes up — which is exactly the set of nodes a
12298    /// `ros2 param set <node> use_sim_time true` can reach.
12299    ///
12300    /// It deliberately does NOT go through `note_reserved_parameter`, so
12301    /// `sim_time_stated` stays false: an auto-declared default is not a
12302    /// STATEMENT by anyone. Recording it as one would make every executor with
12303    /// parameter services write `set_active(false)` onto a PROCESS-GLOBAL gate
12304    /// on its first spin — switching off a simulated clock another executor in
12305    /// the same image installed, which is exactly the collision
12306    /// `sim_time_stated` was added for (issue 1104). That property is per node
12307    /// too: seeding N nodes must still be N statements by nobody.
12308    ///
12309    /// Gated on `sim-time` as well as `param-services`: an image that cannot
12310    /// act on the parameter should not advertise it, because a settable knob
12311    /// that does nothing is worse than an absent one.
12312    fn seed_use_sim_time_default(&mut self, node: super::node_record::NodeId) {
12313        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
12314        if let Some(params) = self.params.as_deref_mut()
12315            && let Some(seeded) = params.sim_time_seeded.get_mut(node.index())
12316            && params.server.declare(
12317                node.into(),
12318                crate::time_source::USE_SIM_TIME_PARAM,
12319                nros_params::ParameterValue::Bool(false),
12320            )
12321        {
12322            *seeded = true;
12323        }
12324        let _ = node;
12325    }
12326
12327    /// phase-430 W2 — step the seeded `use_sim_time` aside for an application
12328    /// that declares it itself on `node`. Returns whether it did.
12329    ///
12330    /// The app's declaration must WIN — its own default, its own descriptor —
12331    /// and a declaration is refused when the name is already taken, so the
12332    /// placeholder has to leave before the real one arrives. Only ever the
12333    /// placeholder: once a real declaration has landed the flag is false and a
12334    /// second declaration is refused exactly as it was before W2.
12335    ///
12336    /// Only ever THIS node's placeholder, too: an app that names
12337    /// `use_sim_time` on one node of an image has said nothing about the
12338    /// others, and their seeds stay placeholders for whoever declares next.
12339    fn yield_seeded_use_sim_time(&mut self, node: super::node_record::NodeId, name: &str) -> bool {
12340        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
12341        if name == crate::time_source::USE_SIM_TIME_PARAM
12342            && let Some(params) = self.params.as_deref_mut()
12343            && let Some(seeded) = params.sim_time_seeded.get_mut(node.index())
12344            && core::mem::replace(seeded, false)
12345        {
12346            return params.server.remove(node.into(), name);
12347        }
12348        let _ = (node, name);
12349        false
12350    }
12351
12352    /// The HEAP home for the parameter slot table, used only when the
12353    /// executor's backing carved none.
12354    ///
12355    /// phase-382 W2' — `ParameterServer` no longer OWNS its slots; it borrows
12356    /// a caller-placed [`nros_params::ParameterTable`]. phase-382 W3' carves
12357    /// that table out of the caller's executor backing whenever the build
12358    /// KNOWS the image builds a store ([`ExecutorSizing::params`](super::storage::ExecutorSizing::params), from
12359    /// `nros_params::IMPLIED_STORE_SLOTS`); this function is what remains for a
12360    /// store the build could not see — one reached only from application
12361    /// code — and for an executor opened with a sizing that carves none. It
12362    /// makes a single heap allocation, leaked, one per such executor that
12363    /// touches parameters. The leak is what buys the `'static`
12364    /// the borrow needs without making `ParamState` self-referential (a
12365    /// `ParamState` that owned the storage AND a server borrowing it is not
12366    /// expressible), and it is bounded — `ensure_parameter_store` runs this
12367    /// at most once per executor.
12368    ///
12369    /// Issue 0756 — the placement dance is still here because the SIZE is
12370    /// still here; it only moved off `ParameterServer` and onto
12371    /// `ParameterStorage`. That storage is 285,184 bytes at the default
12372    /// `MAX_PARAMETERS=32` and 2,281,472 at 256 (`ParameterValue` is sized by
12373    /// its `StringArray` variant, so every slot costs ~8.5 KiB regardless of
12374    /// what it holds). `Box::new(ParameterStorage::new())` materialises all of
12375    /// that on the caller's stack before copying it into the allocation,
12376    /// because Rust has no placement-new. On the Zephyr lane that silently
12377    /// overran the thread stack: an image built with 256 slots boots to
12378    /// `dds_create_participant` and hangs with no fault and no output, while
12379    /// 32 — which fits the 512 KiB main stack the cyclonedds snippet asks for
12380    /// — runs clean. Initialising through the allocation bounds the largest
12381    /// stack temporary at one slot, so the knob no longer decides whether boot
12382    /// survives.
12383    ///
12384    /// Issue 1706 -- FALLIBLE. This is the one allocation every parameter
12385    /// declaration reaches, in every language and on every RTOS, and it is the
12386    /// largest single request most images make. `Box::new_uninit` sent a
12387    /// failure to the OOM handler: Zephyr printed `HEAP EXHAUSTED` and died in
12388    /// issue 0589's recursion, FreeRTOS heap_4 hung in `MALLOC FAILED`. Every
12389    /// caller already has a refusal to return (`declare_parameter` is a
12390    /// `bool`), so the failure is now one, named by
12391    /// [`report_parameter_store_refused`] with the size it asked for.
12392    ///
12393    /// Issue 1812 -- `alloc` only. Without a heap there is no fallback, and
12394    /// the store is the carved one or none (see `new_param_state`).
12395    #[cfg(feature = "alloc")]
12396    fn leak_parameter_storage() -> Option<nros_params::ParameterTable<'static>> {
12397        #[cfg(all(test, feature = "std"))]
12398        if RefuseParameterStore::armed() {
12399            return None;
12400        }
12401        let mut uninit = nros_rmw::fallible::try_box_uninit::<nros_params::ParameterStorage>()?;
12402        // Safety: `try_box_uninit` gives a correctly-sized, correctly-aligned
12403        // allocation for exactly this type, and `init_in_place` writes every
12404        // slot exactly once before `assume_init` observes it.
12405        unsafe {
12406            nros_params::ParameterStorage::init_in_place(uninit.as_mut_ptr());
12407            Some(alloc::boxed::Box::leak(uninit.assume_init()).as_table())
12408        }
12409    }
12410
12411    /// Build the executor's `ParamState`.
12412    ///
12413    /// phase-382 W3' — `carved` are the backing's store slots: non-empty, they
12414    /// ARE the store and the heap is not asked; empty, the store falls back to
12415    /// [`leak_parameter_storage`](Self::leak_parameter_storage).
12416    ///
12417    /// Issue 1812 — and `home` is the services' carved region, which the
12418    /// backing places on the same condition: non-empty, the state, its service
12419    /// sets, the buffer pair and the staging slot are built IN it and nothing
12420    /// here allocates. Otherwise the state is one heap allocation, initialised
12421    /// in place (`ParamState::try_boxed` — it is several KiB now, so never a
12422    /// by-value `Box::new`). Without `alloc` that fallback does not exist and
12423    /// an executor whose backing carved nothing gets `None`, which every caller
12424    /// reports as the named refusal (`report_parameter_store_refused`).
12425    fn new_param_state(
12426        carved: &'s mut [MaybeUninit<nros_params::ParameterSlot>],
12427        home: &'s mut [MaybeUninit<super::storage::ParamHome>],
12428    ) -> Option<crate::param_store::Placed<'s, crate::param_store::ParamState<'s>>> {
12429        let table = if carved.is_empty() {
12430            #[cfg(feature = "alloc")]
12431            {
12432                Self::leak_parameter_storage()?
12433            }
12434            #[cfg(not(feature = "alloc"))]
12435            {
12436                return None;
12437            }
12438        } else {
12439            // Said once per store, so a boot log answers "where did the store
12440            // come from" without a debugger: issue 1706's measurements were
12441            // all heap-peak arithmetic, and this line is the direct answer.
12442            nros_log::log_info!(
12443                nros_log::get_logger("nros"),
12444                "parameter store: {} slots ({} B) + {} ({} B) carved from the executor backing",
12445                carved.len(),
12446                core::mem::size_of_val(carved),
12447                // Issue 1897 -- the home is the store's state, plus the
12448                // services' sets and buffers only where they are compiled.
12449                if cfg!(feature = "param-services") {
12450                    "state and services"
12451                } else {
12452                    "state"
12453                },
12454                core::mem::size_of_val(home)
12455            );
12456            nros_params::ParameterTable::init_in(carved)
12457        };
12458        let server = nros_params::ParameterServer::new_in(table);
12459        match home.first_mut() {
12460            Some(home) => Some(crate::param_store::ParamHome::init_in(home, server)),
12461            #[cfg(feature = "alloc")]
12462            None => crate::param_store::ParamState::try_boxed(server),
12463            #[cfg(not(feature = "alloc"))]
12464            None => None,
12465        }
12466    }
12467
12468    /// Install `-p` / `--params-file` parameter overrides — phase-482 W5.
12469    ///
12470    /// They are applied when a parameter is DECLARED: the declaration's value
12471    /// is replaced by the override's, typed by it (`apply_argv_param_override`).
12472    /// So an override reaches a parameter on every road that declares through
12473    /// this executor — C, C++ and Rust — including the launch file's baked
12474    /// values, which the generated entry declares through the same seam.
12475    ///
12476    /// That makes argv WIN over a launch-baked parameter, the opposite of the
12477    /// remap tiers, deliberately: overriding a baked value at run time without
12478    /// a rebuild is what RFC-0015 §9 asks for ("a runtime override has
12479    /// identical effect to a `set_parameter` request after boot"), and upstream
12480    /// agrees — a launch file's parameters reach the node as command-line
12481    /// arguments themselves, where the later one wins.
12482    ///
12483    /// Overrides accumulate in argv order and the LAST one that matches a
12484    /// declaration wins, as rcl's do. A `node:` override matches the node of
12485    /// that NAME; one without matches every node.
12486    ///
12487    /// Fails only when the parameter store could not be allocated (issue 1706),
12488    /// in which case nothing is installed.
12489    #[cfg(feature = "alloc")]
12490    pub fn install_argv_params<'p, I>(&mut self, params: I) -> Result<usize, NodeError>
12491    where
12492        I: IntoIterator<Item = crate::ros_args::ParamArg<'p>>,
12493    {
12494        if !self.ensure_parameter_store() {
12495            return Err(NodeError::Transport(TransportError::BadAlloc));
12496        }
12497        let Some(state) = self.params.as_deref_mut() else {
12498            return Err(NodeError::Transport(TransportError::BadAlloc));
12499        };
12500        let mut n = 0;
12501        for p in params {
12502            state
12503                .argv_overrides
12504                .push(crate::param_store::ArgvParamOverride {
12505                    node: p.node.map(alloc::string::String::from),
12506                    name: alloc::string::String::from(p.name),
12507                    value: alloc::string::String::from(p.value),
12508                });
12509            n += 1;
12510        }
12511        Ok(n)
12512    }
12513
12514    /// The value a declaration of `name` on `node` should take — the last
12515    /// matching argv override, typed by `declared`, or `declared` itself — and
12516    /// whether an override was applied.
12517    ///
12518    /// An override whose text does not parse as the declared type is NOT
12519    /// applied: it is logged at error severity, naming the parameter, the text
12520    /// and the type, and the declared default stands (RFC-0015 §9.6 — "log +
12521    /// ignore, never panic"). An array-typed declaration takes no override yet.
12522    #[cfg(not(feature = "alloc"))]
12523    fn apply_argv_param_override(
12524        &self,
12525        _node: super::node_record::NodeId,
12526        _name: &str,
12527        declared: nros_params::ParameterValue,
12528    ) -> (nros_params::ParameterValue, bool) {
12529        // Issue 1812 -- argv overrides are a hosted-edge input installed
12530        // through `install_argv_params`, which is `alloc`-only.
12531        (declared, false)
12532    }
12533
12534    #[cfg(feature = "alloc")]
12535    fn apply_argv_param_override(
12536        &self,
12537        node: super::node_record::NodeId,
12538        name: &str,
12539        declared: nros_params::ParameterValue,
12540    ) -> (nros_params::ParameterValue, bool) {
12541        let Some(state) = self.params.as_deref() else {
12542            return (declared, false);
12543        };
12544        if state.argv_overrides.is_empty() {
12545            return (declared, false);
12546        }
12547        let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
12548        let Some(o) = state
12549            .argv_overrides
12550            .iter()
12551            .rev()
12552            .find(|o| o.name == name && o.node.as_deref().is_none_or(|n| n == node_name))
12553        else {
12554            return (declared, false);
12555        };
12556        match typed_param_override(&o.value, &declared) {
12557            Some(v) => (v, true),
12558            None => {
12559                nros_log::log_error!(
12560                    nros_log::get_logger("nros"),
12561                    "parameter override `{}:={}` is not a {:?}; the declared default stands",
12562                    name,
12563                    o.value,
12564                    declared.param_type()
12565                );
12566                (declared, false)
12567            }
12568        }
12569    }
12570
12571    /// Declare a parameter with a value on the executor's PRIMARY node.
12572    ///
12573    /// phase-426 W1 — the store is keyed by node, and this is the spelling for
12574    /// the executor's implicit primary node (`NodeId::PRIMARY`), which is the
12575    /// identity `register_parameter_services` publishes and the one every
12576    /// single-node entry means. Name a different node with
12577    /// [`declare_parameter_on`](Self::declare_parameter_on).
12578    ///
12579    /// phase-428 W6 — `#[must_use]`, for the reason `[[nodiscard]]` went on
12580    /// C++'s `Result`: the `else { false }` arm is silently discarded at a
12581    /// call site that drops the value, and `Node::logger` two files over
12582    /// already carries the attribute — so this was inconsistency, not house
12583    /// style. A declare that failed and was ignored is a parameter the program
12584    /// believes it has.
12585    #[must_use]
12586    pub fn declare_parameter(&mut self, name: &str, value: nros_params::ParameterValue) -> bool {
12587        self.declare_parameter_on(super::node_record::NodeId::PRIMARY, name, value)
12588    }
12589
12590    /// Declare a parameter with a value on a named node.
12591    ///
12592    /// `#[must_use]` for the reason [`declare_parameter`](Self::declare_parameter)
12593    /// carries it: issue 1257's two dropped results moved to THIS method when
12594    /// they were fixed, so the attribute on the wrapper alone guarded nothing
12595    /// a launch-seed site could still drop.
12596    #[must_use]
12597    pub fn declare_parameter_on(
12598        &mut self,
12599        node: super::node_record::NodeId,
12600        name: &str,
12601        value: nros_params::ParameterValue,
12602    ) -> bool {
12603        self.ensure_parameter_store();
12604        let (value, _) = self.apply_argv_param_override(node, name, value);
12605        // phase-430 W2 — an application declaring `use_sim_time` itself wins
12606        // over the auto-declared default, so the placeholder leaves first.
12607        let yielded = self.yield_seeded_use_sim_time(node, name);
12608        let accepted = match &mut self.params {
12609            Some(params) => params.server.declare(node.into(), name, value),
12610            None => false,
12611        };
12612        if yielded && !accepted {
12613            // The app's own declaration was refused on its own terms (a full
12614            // store, an ill-formed range). Put the rclcpp default back rather
12615            // than leaving the name undeclared — that is a state neither the
12616            // app nor upstream asked for, and it would silently un-do W2 for
12617            // this node.
12618            self.seed_use_sim_time_default(node);
12619        }
12620        // phase-425 W3b — `use_sim_time` is RESERVED, exactly as in ROS 2: its
12621        // value is not a value the app reads, it is the switch that attaches the
12622        // time source. This is the one seam every language funnels through
12623        // (`nros::main!`'s launch bakes via `apply_param_services`, nros-c's
12624        // the rclc parameter server's `declare`, nros-cpp's `params_shim`), so hooking it
12625        // here covers all of them instead of once per entry path.
12626        //
12627        // phase-430 W3 / issue 1202 — the hook runs AFTER the store's verdict
12628        // and only on acceptance. It used to run first and unconditionally, so
12629        // a re-declaration the store REFUSES (the name is already there) still
12630        // flipped the switch: the store kept `use_sim_time = true` while the
12631        // `/clock` source detached, leaving an executor with sim time recorded
12632        // as on and no source attached, which is neither of the two states the
12633        // parameter can name.
12634        if accepted {
12635            self.note_reserved_parameter(name);
12636        }
12637        accepted
12638    }
12639
12640    /// phase-425 W3b — record a reserved parameter's effect. Today that is
12641    /// `use_sim_time` and nothing else: it is the tree's only reserved name,
12642    /// and [`time_source::USE_SIM_TIME_PARAM`](crate::time_source::USE_SIM_TIME_PARAM)
12643    /// is the only such constant.
12644    ///
12645    /// Called only after the store ACCEPTED a declaration, and it reads the
12646    /// value back OUT of the store instead of taking the caller's: what the
12647    /// switch must follow is what the store holds, and the two are the same
12648    /// thing only while every write succeeds (issue 1202). The read is
12649    /// [`refresh_use_sim_time_from_store`](Self::refresh_use_sim_time_from_store),
12650    /// the same one the runtime `ros2 param set` path uses, so the declare path
12651    /// and the wire path cannot disagree about the value.
12652    ///
12653    /// A non-bool `use_sim_time` is IGNORED rather than rejected: parameter
12654    /// declaration has no channel to report a complaint on (it returns "did the
12655    /// store take it"), and refusing the declaration outright would fail a node
12656    /// for a parameter ROS 2 lets it declare. The time source simply does not
12657    /// attach, which is the same outcome as `false`.
12658    ///
12659    /// Not covered: [`Executor::parameter`](Self::parameter)'s
12660    /// [`ParameterBuilder`](nros_params::ParameterBuilder) borrows the server
12661    /// and declares through it, so the executor never sees the write and
12662    /// `use_sim_time` named that way attaches nothing — issue 1203.
12663    fn note_reserved_parameter(&mut self, name: &str) {
12664        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
12665        if name == crate::time_source::USE_SIM_TIME_PARAM {
12666            self.refresh_use_sim_time_from_store();
12667        }
12668        let _ = name;
12669    }
12670
12671    /// phase-425 W3b — a parameter service handled `n` requests this spin.
12672    ///
12673    /// The hook exists so `use_sim_time` can be re-read after a runtime
12674    /// `ros2 param set`, WITHOUT a per-spin scan of the store. It takes the
12675    /// count unconditionally and ignores it when `sim-time` is off, rather than
12676    /// the call sites carrying the feature test: `handled` was then assigned and
12677    /// never read in the `param-services`-without-`sim-time` combo, which is a
12678    /// `-D warnings` error `check-build` catches and the narrower per-crate
12679    /// clippy runs do not.
12680    #[inline]
12681    fn note_param_services_ran(&mut self, handled: usize) {
12682        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
12683        if handled > 0 {
12684            self.refresh_use_sim_time_from_store();
12685        }
12686        let _ = handled;
12687        self.report_truncated_descriptions();
12688        #[cfg(feature = "param-services")]
12689        self.flush_parameter_events();
12690    }
12691
12692    /// phase-446 F2 -- log each parameter whose description did not fit
12693    /// `NROS_MAX_PARAM_DESCRIPTION_LEN`, once, naming the knob.
12694    ///
12695    /// `nros-params` has no logger, so it truncates, records the truncation on
12696    /// the stored descriptor, and hands it over here. Asked on every spin
12697    /// because a declaration can arrive through builders this executor never
12698    /// sees the end of (`parameter::<T>()` returns the store's own builder);
12699    /// while nothing is pending the store answers from one flag, without
12700    /// walking its table.
12701    #[cold]
12702    fn report_truncated_descriptions(&mut self) {
12703        let Some(params) = self.params.as_deref_mut() else {
12704            return;
12705        };
12706        let nodes = &self.nodes;
12707        params.server.take_truncated_descriptions(|key, name| {
12708            let (ns, node) = nodes
12709                .get(key.index())
12710                .map_or(("", "?"), |r| (r.namespace.as_str(), r.name.as_str()));
12711            nros_log::log_warn!(
12712                nros_log::get_logger("nros"),
12713                "parameter `{}` on node {}/{}: its description is longer than \
12714                 NROS_MAX_PARAM_DESCRIPTION_LEN ({} bytes), so `ros2 param describe` \
12715                 answers it truncated at a character boundary. Raise \
12716                 NROS_MAX_PARAM_DESCRIPTION_LEN to keep it whole; 0 stores no \
12717                 descriptions. Logged once for this parameter (phase-446 F2).",
12718                name,
12719                ns.trim_end_matches('/'),
12720                node,
12721                nros_params::MAX_PARAM_DESCRIPTION_LEN
12722            );
12723        });
12724    }
12725
12726    /// Declare a parameter with a value and descriptor on the PRIMARY node.
12727    /// Returns `true` if successful.
12728    ///
12729    /// phase-428 W6 remainder — the sibling of [`Self::declare_parameter`],
12730    /// with the identical `else { false }` arm and no attribute. W6 marked the
12731    /// one method its audit named; this is the site three lines below it, which
12732    /// is the shape CLAUDE.md's "fix the CLASS, not the reported site" rule
12733    /// exists for.
12734    #[must_use]
12735    pub fn declare_parameter_with_descriptor(
12736        &mut self,
12737        name: &str,
12738        value: nros_params::ParameterValue,
12739        descriptor: nros_params::ParameterDescriptor,
12740    ) -> bool {
12741        self.declare_parameter_with_descriptor_on(
12742            super::node_record::NodeId::PRIMARY,
12743            name,
12744            value,
12745            descriptor,
12746        )
12747    }
12748
12749    /// Declare a parameter with a value and descriptor on a named node.
12750    pub fn declare_parameter_with_descriptor_on(
12751        &mut self,
12752        node: super::node_record::NodeId,
12753        name: &str,
12754        value: nros_params::ParameterValue,
12755        descriptor: nros_params::ParameterDescriptor,
12756    ) -> bool {
12757        self.ensure_parameter_store();
12758        // phase-430 W2 — same rule as the sibling above: the app's own
12759        // declaration, descriptor and all, replaces the auto-declared default.
12760        let yielded = self.yield_seeded_use_sim_time(node, name);
12761        // phase-482 W5 — an argv override that the DESCRIPTOR refuses (out of
12762        // range, or read-only with a different value) must not cost the
12763        // parameter its existence: log it, then declare the code's default.
12764        let (overridden, differs) = self.apply_argv_param_override(node, name, value.clone());
12765        let mut accepted = match &mut self.params {
12766            Some(params) => params.server.declare_with_descriptor(
12767                node.into(),
12768                name,
12769                overridden,
12770                Some(descriptor.clone()),
12771            ),
12772            None => false,
12773        };
12774        if !accepted && differs {
12775            nros_log::log_error!(
12776                nros_log::get_logger("nros"),
12777                "parameter override for `{}` was refused by its descriptor; the declared \
12778                 default stands",
12779                name
12780            );
12781            accepted = match &mut self.params {
12782                Some(params) => params.server.declare_with_descriptor(
12783                    node.into(),
12784                    name,
12785                    value,
12786                    Some(descriptor),
12787                ),
12788                None => false,
12789            };
12790        }
12791        if yielded && !accepted {
12792            self.seed_use_sim_time_default(node);
12793        }
12794        // phase-430 W3 / issue 1202 — the sibling declare path, which had NO
12795        // reserved hook at all: `declare_parameter_with_descriptor` stored
12796        // `use_sim_time` and attached nothing, so the same declaration meant
12797        // two different things depending on whether the app passed a
12798        // descriptor. Same seam, same verdict ordering, one shared hook.
12799        if accepted {
12800            self.note_reserved_parameter(name);
12801        }
12802        accepted
12803    }
12804
12805    /// THE writer — phase-426 W2.
12806    ///
12807    /// Set an ALREADY-DECLARED parameter on the executor's primary node, with
12808    /// the same rules a remote `ros2 param set` gets: read-only, type, range
12809    /// (min/max/step), and issue 1151's refusal of a name the node never
12810    /// declared unless that node opted in through
12811    /// [`allow_undeclared_parameters_on`](Self::allow_undeclared_parameters_on).
12812    ///
12813    /// It routes through [`ParameterServer::apply`](nros_params::ParameterServer::apply),
12814    /// which is what makes the C and C++ facades, the by-value oracle, the
12815    /// streaming service path and the atomic pre-check agree — a facade that
12816    /// wrote the slot directly would be a second answer to "may this set
12817    /// happen", which is the class RFC-0019/0020 forbids. An in-image caller
12818    /// that genuinely wants declare-on-write names
12819    /// `params_mut()`/`set_or_declare` and has opted in by naming it.
12820    ///
12821    /// Returns [`SetParameterResult::NotFound`] when the store does not exist
12822    /// yet (no parameter has been declared and no services registered).
12823    pub fn set_parameter(
12824        &mut self,
12825        name: &str,
12826        value: nros_params::ParameterValue,
12827    ) -> nros_params::SetParameterResult {
12828        self.set_parameter_on(super::node_record::NodeId::PRIMARY, name, value)
12829    }
12830
12831    /// [`set_parameter`](Self::set_parameter) on a named node.
12832    pub fn set_parameter_on(
12833        &mut self,
12834        node: super::node_record::NodeId,
12835        name: &str,
12836        value: nros_params::ParameterValue,
12837    ) -> nros_params::SetParameterResult {
12838        // Reserved parameters (`use_sim_time`) take effect on a set exactly as
12839        // they do on a declare — this is the seam every language funnels
12840        // through, so hooking it once here covers all of them.
12841        //
12842        // AFTER the store's verdict and only on `Success`, for the reason issue
12843        // 1202 gives one function up: the hook used to run first and off the
12844        // caller's value, so a write the store REFUSED still moved the switch
12845        // and left sim time recorded as on with no source attached. `apply` has
12846        // more ways to refuse than `declare` does — read-only, type, range,
12847        // undeclared-without-the-node-option — so a writer that hooked first
12848        // would reach that state from four directions instead of one.
12849        let result = match &mut self.params {
12850            Some(params) => params.server.apply(node.into(), name, value),
12851            None => nros_params::SetParameterResult::NotFound,
12852        };
12853        if result == nros_params::SetParameterResult::Success {
12854            self.note_reserved_parameter(name);
12855        }
12856        result
12857    }
12858
12859    /// phase-491 W2 (RFC-0105 D7) — rclc's LOCAL typed set on `node`:
12860    /// type, the on-set hooks with `(old, new)`, the write. Read-only and
12861    /// range are NOT checked, because rclc's owner of a parameter is not bound
12862    /// by them; remote writes keep [`set_parameter_on`](Self::set_parameter_on)'s
12863    /// rules. The reserved-parameter hook runs on success, as on every writer.
12864    pub fn set_parameter_local_on(
12865        &mut self,
12866        node: super::node_record::NodeId,
12867        name: &str,
12868        value: nros_params::ParameterValue,
12869    ) -> nros_params::SetParameterResult {
12870        let result = match &mut self.params {
12871            Some(params) => params.server.set_local(node.into(), name, value),
12872            None => nros_params::SetParameterResult::NotFound,
12873        };
12874        if result == nros_params::SetParameterResult::Success {
12875            self.note_reserved_parameter(name);
12876        }
12877        result
12878    }
12879
12880    #[cfg(feature = "param-services")]
12881    /// phase-491 W3 (RFC-0105 D8) — publish `node`'s parameter changes on
12882    /// `/parameter_events` (rclc's `notify_changed_over_dds`).
12883    ///
12884    /// Creates the executor's one `/parameter_events` publisher on the first
12885    /// call (see `ParamState::event_publisher`), typed
12886    /// `rcl_interfaces/msg/ParameterEvent`, RELIABLE + VOLATILE as upstream's
12887    /// `parameter_events` profile. Its depth is upstream's 1000 on a hosted
12888    /// build and `NROS_PARAM_EVENTS_DEPTH` (default 1) otherwise, because a
12889    /// reliable DDS writer's history is memory the RMW reserves; depth is not
12890    /// a matching criterion, so an upstream subscriber still matches.
12891    /// Idempotent.
12892    pub fn enable_parameter_events_on(
12893        &mut self,
12894        node: super::node_record::NodeId,
12895    ) -> Result<(), NodeError> {
12896        use nros_core::RosMessage;
12897        type Event = nros_rcl_interfaces::msg::ParameterEvent;
12898        if !self.ensure_parameter_store() {
12899            return Err(NodeError::Transport(TransportError::BadAlloc));
12900        }
12901        let idx = node.index();
12902        if idx >= crate::param_sizing::MAX_SERVICE_SETS {
12903            return Err(NodeError::NodeTableFull);
12904        }
12905        if self
12906            .params
12907            .as_ref()
12908            .is_some_and(|p| p.event_publisher.is_some())
12909        {
12910            if let Some(params) = self.params.as_mut() {
12911                params.server.set_parameter_events(node.into(), true);
12912            }
12913            return Ok(());
12914        }
12915        // The event is encoded into the services' reply buffer; on the heap
12916        // road that pair is allocated with the first service set, which may
12917        // not exist yet.
12918        #[cfg(feature = "alloc")]
12919        if let Some(params) = self.params.as_mut()
12920            && params.buffers.is_none()
12921        {
12922            params.buffers = Some(
12923                crate::parameter_services::ParamServiceBuffers::try_with_capacity(
12924                    crate::parameter_services::param_service_buffer_bytes(),
12925                )
12926                .ok_or(NodeError::Transport(TransportError::BadAlloc))?,
12927            );
12928        }
12929        crate::rmw_type_registry::register_type::<Event>()?;
12930        let qos = QoSProfile::parameter_events_default().depth(nros_params::PARAM_EVENTS_DEPTH);
12931        let handle = self.create_raw_publisher_handle_on(
12932            node,
12933            "/parameter_events",
12934            <Event as RosMessage>::TYPE_NAME,
12935            <Event as RosMessage>::TYPE_HASH,
12936            qos,
12937        )?;
12938        if let Some(params) = self.params.as_mut() {
12939            params.event_publisher = Some(handle);
12940            params.server.set_parameter_events(node.into(), true);
12941        }
12942        Ok(())
12943    }
12944
12945    #[cfg(feature = "param-services")]
12946    /// phase-491 W3 — publish every pending parameter change, one
12947    /// `ParameterEvent` each, oldest first. Runs after each spin's parameter
12948    /// services and after every C parameter call; nothing allocates.
12949    pub fn flush_parameter_events(&mut self) {
12950        let stamp_ns = self
12951            .epoch_us_fn
12952            .or(self.clock_us_fn)
12953            .map_or(0, |clock| clock().saturating_mul(1000));
12954        let mut lost: u32 = 0;
12955        loop {
12956            let Some(params) = self.params.as_mut() else {
12957                return;
12958            };
12959            let Some(event) = params.server.pop_parameter_event() else {
12960                break;
12961            };
12962            let idx = event.node.index();
12963            let mut fqn = heapless::String::<256>::new();
12964            match self.nodes.get(idx) {
12965                Some(r) => {
12966                    if let Ok(f) = crate::names::fully_qualified_name(&r.name, &r.namespace) {
12967                        let _ = fqn.push_str(f.as_str());
12968                    }
12969                }
12970                None => {
12971                    if let Ok(f) =
12972                        crate::names::fully_qualified_name(&self.node_name, &self.namespace)
12973                    {
12974                        let _ = fqn.push_str(f.as_str());
12975                    }
12976                }
12977            }
12978            let Some(params) = self.params.as_mut() else {
12979                return;
12980            };
12981            let crate::param_store::ParamState {
12982                server,
12983                buffers,
12984                event_publisher,
12985                ..
12986            } = &mut **params;
12987            let (Some(publisher), Some(buffers)) = (event_publisher.as_ref(), buffers.as_mut())
12988            else {
12989                continue;
12990            };
12991            let value = match event.kind {
12992                nros_params::ParameterEventKind::Deleted => None,
12993                _ => server.get(event.node, event.name.as_str()),
12994            };
12995            let encoded = crate::parameter_services::encode_parameter_event(
12996                buffers.reply_mut(),
12997                stamp_ns,
12998                fqn.as_str(),
12999                event.kind,
13000                event.name.as_str(),
13001                value,
13002            );
13003            match encoded {
13004                Some(n) => {
13005                    use nros_rmw::Publisher as _;
13006                    if publisher.publish_raw(&buffers.reply_mut()[..n]).is_err() {
13007                        lost = lost.saturating_add(1);
13008                    }
13009                }
13010                None => {
13011                    lost = lost.saturating_add(1);
13012                }
13013            }
13014        }
13015        let dropped = self
13016            .params
13017            .as_mut()
13018            .map_or(0, |p| p.server.take_parameter_events_dropped());
13019        let lost = lost.saturating_add(dropped);
13020        if lost > 0 {
13021            Self::report_parameter_events_lost(lost);
13022        }
13023    }
13024
13025    #[cfg(feature = "param-services")]
13026    #[cold]
13027    fn report_parameter_events_lost(lost: u32) {
13028        nros_log::log_warn!(
13029            nros_log::get_logger("nros"),
13030            "{} /parameter_events message(s) were not published: the change log \
13031             (PARAMETER_EVENT_LOG_LEN) filled between two flushes, a value did not fit the \
13032             parameter-service reply buffer (NROS_PARAM_SERVICE_BUFFER_SIZE), or the \
13033             publish failed. The parameters themselves changed (phase-491 W3).",
13034            lost
13035        );
13036    }
13037
13038    /// phase-491 W2 — a remote `NOT_SET` on `node` deletes the parameter
13039    /// (rclc) instead of being refused (rclcpp). See
13040    /// [`ParameterServer::set_delete_on_not_set`](nros_params::ParameterServer::set_delete_on_not_set).
13041    pub fn delete_parameters_on_not_set_on(&mut self, node: super::node_record::NodeId, on: bool) {
13042        self.ensure_parameter_store();
13043        if let Some(params) = &mut self.params {
13044            params.server.set_delete_on_not_set(node.into(), on);
13045        }
13046    }
13047
13048    /// May a remote set DECLARE a name this node never declared? Upstream's
13049    /// `allow_undeclared_parameters` node option (issue 1151), per node since
13050    /// phase-426 W1: switching it on for one node leaves its siblings
13051    /// refusing.
13052    pub fn allow_undeclared_parameters_on(
13053        &mut self,
13054        node: super::node_record::NodeId,
13055        allow: bool,
13056    ) {
13057        self.ensure_parameter_store();
13058        if let Some(params) = &mut self.params {
13059            params.server.set_allow_undeclared(node.into(), allow);
13060        }
13061    }
13062
13063    /// [`allow_undeclared_parameters_on`](Self::allow_undeclared_parameters_on)
13064    /// for the primary node.
13065    pub fn allow_undeclared_parameters(&mut self, allow: bool) {
13066        self.allow_undeclared_parameters_on(super::node_record::NodeId::PRIMARY, allow);
13067    }
13068
13069    /// Get a parameter value by name from the primary node.
13070    pub fn get_parameter(&self, name: &str) -> Option<&nros_params::ParameterValue> {
13071        self.get_parameter_on(super::node_record::NodeId::PRIMARY, name)
13072    }
13073
13074    /// Get a parameter value by name from a named node.
13075    pub fn get_parameter_on(
13076        &self,
13077        node: super::node_record::NodeId,
13078        name: &str,
13079    ) -> Option<&nros_params::ParameterValue> {
13080        self.params.as_deref()?.server.get(node.into(), name)
13081    }
13082
13083    /// Get an integer parameter value by name from the primary node (convenience).
13084    pub fn get_parameter_integer(&self, name: &str) -> Option<i64> {
13085        self.params
13086            .as_ref()?
13087            .server
13088            .get_integer(super::node_record::NodeId::PRIMARY.into(), name)
13089    }
13090
13091    /// Get a reference to the parameter server (if registered).
13092    pub fn params(&self) -> Option<&nros_params::ParameterServer<'s>> {
13093        self.params.as_deref().map(|p| &p.server)
13094    }
13095
13096    /// Get a mutable reference to the parameter server (if registered).
13097    pub fn params_mut(&mut self) -> Option<&mut nros_params::ParameterServer<'s>> {
13098        self.params.as_deref_mut().map(|p| &mut p.server)
13099    }
13100
13101    /// Create a typed parameter builder (rclrs-compatible API).
13102    ///
13103    /// Returns a [`ParameterBuilder`] for fluent parameter declaration with
13104    /// `.default()`, `.description()`, `.range()`, and terminal methods
13105    /// `.mandatory()`, `.optional()`, or `.read_only()`.
13106    ///
13107    /// Returns [`NodeError::NotInitialized`] if parameter services have
13108    /// not been registered yet — call [`register_parameter_services`]
13109    /// first.
13110    ///
13111    /// # Example
13112    ///
13113    /// ```ignore
13114    /// let max_speed = executor.parameter::<f64>("max_speed")?
13115    ///     .default(25.0)
13116    ///     .description("Maximum velocity (m/s)")
13117    ///     .read_only()?;
13118    /// ```
13119    ///
13120    /// [`ParameterBuilder`]: nros_params::ParameterBuilder
13121    /// [`register_parameter_services`]: Self::register_parameter_services
13122    pub fn parameter<'a, T: nros_params::ParameterVariant>(
13123        &'a mut self,
13124        name: &'a str,
13125    ) -> Result<nros_params::ParameterBuilder<'a, 's, T>, NodeError> {
13126        self.parameter_on(super::node_record::NodeId::PRIMARY, name)
13127    }
13128
13129    /// [`parameter`](Self::parameter) on a named node.
13130    pub fn parameter_on<'a, T: nros_params::ParameterVariant>(
13131        &'a mut self,
13132        node: super::node_record::NodeId,
13133        name: &'a str,
13134    ) -> Result<nros_params::ParameterBuilder<'a, 's, T>, NodeError> {
13135        let server = self
13136            .params
13137            .as_mut()
13138            .map(|p| &mut p.server)
13139            .ok_or(NodeError::NotInitialized)?;
13140        Ok(nros_params::ParameterBuilder::new(
13141            server,
13142            node.into(),
13143            name,
13144        ))
13145    }
13146
13147    // ========================================================================
13148    // phase-417 W4.a — the surface C and C++ forward to
13149    //
13150    // Every one of these is a forwarder onto `nros_params::ParameterServer`,
13151    // the ONE store. They exist on the executor because that is the object the
13152    // FFI holds: a C caller has an `rclc_executor_t*` and an `rcl_node_t*`,
13153    // and a C++ node has its `node_id`. Nothing here keeps state — a wrapper
13154    // that did would be the second store phase-426 removed.
13155    // ========================================================================
13156
13157    /// Attach a description and free-text constraints to a declared parameter
13158    /// on `node` — rclc's `rclc_add_parameter_description`.
13159    pub fn set_parameter_description_on(
13160        &mut self,
13161        node: super::node_record::NodeId,
13162        name: &str,
13163        description: &str,
13164        additional_constraints: &str,
13165    ) -> bool {
13166        match &mut self.params {
13167            Some(params) => params.server.set_parameter_description(
13168                node.into(),
13169                name,
13170                description,
13171                additional_constraints,
13172            ),
13173            None => false,
13174        }
13175    }
13176
13177    /// [`set_parameter_description_on`](Self::set_parameter_description_on) for
13178    /// the primary node.
13179    pub fn set_parameter_description(
13180        &mut self,
13181        name: &str,
13182        description: &str,
13183        additional_constraints: &str,
13184    ) -> bool {
13185        self.set_parameter_description_on(
13186            super::node_record::NodeId::PRIMARY,
13187            name,
13188            description,
13189            additional_constraints,
13190        )
13191    }
13192
13193    /// Mark a declared parameter read-only — rclc's
13194    /// `rclc_set_parameter_read_only`. Every later write, local or remote, is
13195    /// refused.
13196    pub fn set_parameter_read_only_on(
13197        &mut self,
13198        node: super::node_record::NodeId,
13199        name: &str,
13200        read_only: bool,
13201    ) -> bool {
13202        match &mut self.params {
13203            Some(params) => params
13204                .server
13205                .set_parameter_read_only(node.into(), name, read_only),
13206            None => false,
13207        }
13208    }
13209
13210    /// [`set_parameter_read_only_on`](Self::set_parameter_read_only_on) for the
13211    /// primary node.
13212    pub fn set_parameter_read_only(&mut self, name: &str, read_only: bool) -> bool {
13213        self.set_parameter_read_only_on(super::node_record::NodeId::PRIMARY, name, read_only)
13214    }
13215
13216    /// Attach an integer range — rclc's
13217    /// `rclc_add_parameter_constraint_integer`. Refuses an ill-formed range or
13218    /// one the current value does not satisfy.
13219    pub fn set_parameter_integer_range_on(
13220        &mut self,
13221        node: super::node_record::NodeId,
13222        name: &str,
13223        min: i64,
13224        max: i64,
13225        step: i64,
13226    ) -> bool {
13227        match &mut self.params {
13228            Some(params) => {
13229                params
13230                    .server
13231                    .set_parameter_integer_range(node.into(), name, min, max, step)
13232            }
13233            None => false,
13234        }
13235    }
13236
13237    /// [`set_parameter_integer_range_on`](Self::set_parameter_integer_range_on)
13238    /// for the primary node.
13239    pub fn set_parameter_integer_range(
13240        &mut self,
13241        name: &str,
13242        min: i64,
13243        max: i64,
13244        step: i64,
13245    ) -> bool {
13246        self.set_parameter_integer_range_on(
13247            super::node_record::NodeId::PRIMARY,
13248            name,
13249            min,
13250            max,
13251            step,
13252        )
13253    }
13254
13255    /// Attach a floating-point range — rclc's
13256    /// `rclc_add_parameter_constraint_double`.
13257    pub fn set_parameter_float_range_on(
13258        &mut self,
13259        node: super::node_record::NodeId,
13260        name: &str,
13261        min: f64,
13262        max: f64,
13263        step: f64,
13264    ) -> bool {
13265        match &mut self.params {
13266            Some(params) => {
13267                params
13268                    .server
13269                    .set_parameter_float_range(node.into(), name, min, max, step)
13270            }
13271            None => false,
13272        }
13273    }
13274
13275    /// [`set_parameter_float_range_on`](Self::set_parameter_float_range_on) for
13276    /// the primary node.
13277    pub fn set_parameter_float_range(&mut self, name: &str, min: f64, max: f64, step: f64) -> bool {
13278        self.set_parameter_float_range_on(super::node_record::NodeId::PRIMARY, name, min, max, step)
13279    }
13280
13281    /// The descriptor of a declared parameter — rclcpp's
13282    /// `describe_parameter`, rclrs's `ParameterBuilder` readback. `None` when
13283    /// the parameter is not declared on `node`, or was declared without one.
13284    pub fn describe_parameter_on(
13285        &self,
13286        node: super::node_record::NodeId,
13287        name: &str,
13288    ) -> Option<&nros_params::ParameterDescriptor> {
13289        self.params
13290            .as_ref()?
13291            .server
13292            .get_descriptor(node.into(), name)
13293    }
13294
13295    /// [`describe_parameter_on`](Self::describe_parameter_on) for the primary
13296    /// node.
13297    pub fn describe_parameter(&self, name: &str) -> Option<&nros_params::ParameterDescriptor> {
13298        self.describe_parameter_on(super::node_record::NodeId::PRIMARY, name)
13299    }
13300
13301    /// The declared TYPE of a parameter — rclcpp's `get_parameter_types`, and
13302    /// the local answer to the `~/get_parameter_types` service.
13303    pub fn get_parameter_type_on(
13304        &self,
13305        node: super::node_record::NodeId,
13306        name: &str,
13307    ) -> Option<nros_params::ParameterType> {
13308        self.params.as_deref()?.server.get_type(node.into(), name)
13309    }
13310
13311    /// [`get_parameter_type_on`](Self::get_parameter_type_on) for the primary
13312    /// node.
13313    pub fn get_parameter_type(&self, name: &str) -> Option<nros_params::ParameterType> {
13314        self.get_parameter_type_on(super::node_record::NodeId::PRIMARY, name)
13315    }
13316
13317    /// The names a node has declared, filtered by prefix — rclcpp's
13318    /// `list_parameters`, and the local answer to `~/list_parameters`. An
13319    /// empty prefix lists them all.
13320    pub fn list_parameters_on<'p>(
13321        &'p self,
13322        node: super::node_record::NodeId,
13323        prefix: &'p str,
13324    ) -> impl Iterator<Item = &'p str> {
13325        self.params
13326            .as_ref()
13327            .into_iter()
13328            .flat_map(move |p| p.server.list_with_prefix(node.into(), prefix))
13329    }
13330
13331    /// [`list_parameters_on`](Self::list_parameters_on) for the primary node.
13332    pub fn list_parameters<'p>(&'p self, prefix: &'p str) -> impl Iterator<Item = &'p str> {
13333        self.list_parameters_on(super::node_record::NodeId::PRIMARY, prefix)
13334    }
13335
13336    /// All-or-nothing multi-set on `node` — rclcpp's
13337    /// `set_parameters_atomically`.
13338    ///
13339    /// We SERVED `~/set_parameters_atomically` and offered it in no language,
13340    /// so the only way to reach our own atomic set was over the wire. This is
13341    /// the same call the service handler makes.
13342    ///
13343    /// `items` yields `None` for an element that could not be turned into a
13344    /// value at all; that refuses the batch like any failing check.
13345    pub fn set_parameters_atomically_on<'x, I>(
13346        &mut self,
13347        node: super::node_record::NodeId,
13348        items: I,
13349    ) -> nros_params::SetParameterResult
13350    where
13351        I: Iterator<Item = Option<(&'x str, nros_params::ParameterValue)>> + Clone,
13352    {
13353        match &mut self.params {
13354            Some(params) => params.server.apply_atomically(node.into(), items),
13355            None => nros_params::SetParameterResult::NotFound,
13356        }
13357    }
13358
13359    /// [`set_parameters_atomically_on`](Self::set_parameters_atomically_on) for
13360    /// the primary node.
13361    pub fn set_parameters_atomically<'x, I>(&mut self, items: I) -> nros_params::SetParameterResult
13362    where
13363        I: Iterator<Item = Option<(&'x str, nros_params::ParameterValue)>> + Clone,
13364    {
13365        self.set_parameters_atomically_on(super::node_record::NodeId::PRIMARY, items)
13366    }
13367
13368    /// Undeclare a parameter — rclcpp's `undeclare_parameter`, rclc's
13369    /// `rclc_delete_parameter`. The slot is freed for a later declaration.
13370    pub fn undeclare_parameter_on(&mut self, node: super::node_record::NodeId, name: &str) -> bool {
13371        match &mut self.params {
13372            Some(params) => params.server.remove(node.into(), name),
13373            None => false,
13374        }
13375    }
13376
13377    /// [`undeclare_parameter_on`](Self::undeclare_parameter_on) for the primary
13378    /// node.
13379    pub fn undeclare_parameter(&mut self, name: &str) -> bool {
13380        self.undeclare_parameter_on(super::node_record::NodeId::PRIMARY, name)
13381    }
13382
13383    /// Register an accept/reject hook for writes to `node` — rclcpp's
13384    /// `add_on_set_parameters_callback`.
13385    ///
13386    /// It runs on every write that reaches the store through
13387    /// [`set_parameter_on`](Self::set_parameter_on), which is the seam the six
13388    /// `rcl_interfaces/srv/*` servers and both wrapper languages share, so ONE
13389    /// registration covers a remote `ros2 param set` and a local one alike.
13390    ///
13391    /// `None` when all [`nros_params::MAX_ON_SET_CALLBACKS`] slots are taken.
13392    pub fn add_on_set_parameters_callback_on(
13393        &mut self,
13394        node: super::node_record::NodeId,
13395        callback: nros_params::OnSetParameterFn,
13396        ctx: nros_params::OnSetContext,
13397    ) -> Option<nros_params::OnSetParameterHandle> {
13398        self.ensure_parameter_store();
13399        self.params
13400            .as_mut()?
13401            .server
13402            .add_on_set_parameters_callback(node.into(), callback, ctx)
13403    }
13404
13405    /// [`add_on_set_parameters_callback_on`](Self::add_on_set_parameters_callback_on)
13406    /// for the primary node.
13407    pub fn add_on_set_parameters_callback(
13408        &mut self,
13409        callback: nros_params::OnSetParameterFn,
13410        ctx: nros_params::OnSetContext,
13411    ) -> Option<nros_params::OnSetParameterHandle> {
13412        self.add_on_set_parameters_callback_on(super::node_record::NodeId::PRIMARY, callback, ctx)
13413    }
13414
13415    /// Unregister a hook — rclcpp's `remove_on_set_parameters_callback`.
13416    pub fn remove_on_set_parameters_callback(
13417        &mut self,
13418        handle: nros_params::OnSetParameterHandle,
13419    ) -> bool {
13420        match &mut self.params {
13421            Some(params) => params.server.remove_on_set_parameters_callback(handle),
13422            None => false,
13423        }
13424    }
13425
13426    /// rclrs's `Node::use_undeclared_parameters` — a handle that reads and
13427    /// writes parameters this node never declared.
13428    ///
13429    /// The handle writes through `set_or_declare`, not through the wire-facing
13430    /// `apply`: `allow_undeclared_parameters` governs what a REMOTE set may
13431    /// create, and a caller who reached for this type has opted in by naming
13432    /// it. That is rclrs's split too.
13433    pub fn use_undeclared_parameters_on(
13434        &mut self,
13435        node: super::node_record::NodeId,
13436    ) -> Result<nros_params::UndeclaredParameters<'_, 's>, NodeError> {
13437        let server = self
13438            .params
13439            .as_mut()
13440            .map(|p| &mut p.server)
13441            .ok_or(NodeError::NotInitialized)?;
13442        Ok(nros_params::UndeclaredParameters::new(server, node.into()))
13443    }
13444
13445    /// [`use_undeclared_parameters_on`](Self::use_undeclared_parameters_on) for
13446    /// the primary node.
13447    pub fn use_undeclared_parameters(
13448        &mut self,
13449    ) -> Result<nros_params::UndeclaredParameters<'_, 's>, NodeError> {
13450        self.use_undeclared_parameters_on(super::node_record::NodeId::PRIMARY)
13451    }
13452}
13453
13454// ============================================================================
13455// Executor lifecycle — cancel / is_spinning (phase-417 W4.c)
13456// ============================================================================
13457
13458// Issue 1177 -- UN-GATED. This block was `#[cfg(feature = "alloc")]` because
13459// `halt_flag` and `spinning` were `alloc` fields, and neither needed to be: the
13460// allocator is required to SHARE the halt flag (`halt_flag()` hands out a
13461// refcounted handle), never to hold it.
13462//
13463// Gating it meant a no_std executor could not be stopped, and worse, that
13464// `nros-c`'s spin loop -- which calls `cancel`, `is_halted`, `is_spinning`,
13465// `enter_spin_loop` and `exit_spin_loop` with no cfg of its own -- did not
13466// COMPILE for an embedded image. The safety island found that; no
13467// merge-gating lane builds this crate without `alloc`.
13468impl<'s> Executor<'s> {
13469    /// Request the executor to stop spinning — `rclcpp::Executor::cancel`.
13470    ///
13471    /// # ADOPT-BOUNDED (RFC-0089)
13472    ///
13473    /// `cancel` sets a flag the spin loop observes at the NEXT POLL BOUNDARY,
13474    /// so it returns BEFORE spinning has actually stopped;
13475    /// [`is_spinning()`](Self::is_spinning) is the observable that tells you
13476    /// when it has. rclcpp makes the same promise in the same shape, but the
13477    /// boundary here is one `spin_once` timeout wide (a backend's `drive_io`
13478    /// blocks up to the poll interval), so "next boundary" is a duration a
13479    /// caller can measure rather than an implementation detail. The wake flag
13480    /// below shortens it; it does not remove it.
13481    ///
13482    /// Nothing here tears down the session: an executor that has been cancelled
13483    /// is still initialised, still owns its entities, and can be spun again.
13484    /// Teardown is a different verb (`fini` / `shutdown`), and collapsing the
13485    /// two costs a full discovery round on every mode change.
13486    ///
13487    /// Safe to call from another thread or a signal handler.
13488    ///
13489    /// Also raises the Phase 104.C.6 wake flag so a `spin_once` already blocked
13490    /// inside a backend's `drive_io` falls through to the cancel check on its
13491    /// next loop iteration instead of waiting out its full `timeout_ms` first.
13492    pub fn cancel(&self) {
13493        self.halt_flag
13494            .store(true, core::sync::atomic::Ordering::SeqCst);
13495        // The wake flag is the `Arc` half and still needs an allocator. A
13496        // core-only image simply waits out the poll interval, which is the
13497        // bounded behaviour the envelope above already promises.
13498        #[cfg(feature = "alloc")]
13499        self.wake_flag
13500            .store(true, core::sync::atomic::Ordering::SeqCst);
13501    }
13502
13503    /// Has a [`cancel()`](Self::cancel) been REQUESTED?
13504    ///
13505    /// Not the same question as [`is_spinning()`](Self::is_spinning), and both
13506    /// are kept because both have callers:
13507    ///
13508    /// * `is_halted()` — "someone asked this executor to stop." True the instant
13509    ///   `cancel()` returns, and it stays true until the next spin loop clears
13510    ///   it on entry. This is the flag a hand-rolled `while !exec.is_halted()`
13511    ///   loop polls, which is what a BSP that drives `spin_once` itself writes.
13512    /// * `is_spinning()` — "a spin loop is running right now." Answers the other
13513    ///   half of the envelope: whether the cancel has been ACTED on yet.
13514    ///
13515    /// The two are true together for as long as one poll takes, which is exactly
13516    /// the window the ADOPT-BOUNDED envelope on `cancel()` describes. Neither is
13517    /// derivable from the other: an executor that was never spun is neither
13518    /// halted nor spinning, and one cancelled mid-poll is both.
13519    ///
13520    /// The name is the pre-rename spelling and is deliberately NOT deprecated —
13521    /// `cancel`'s rclcpp pair is a `spinning` observable, not an
13522    /// `is_cancel_requested` one, so there is no ROS 2 name to adopt here and a
13523    /// third spelling would buy nothing.
13524    pub fn is_halted(&self) -> bool {
13525        self.halt_flag.load(core::sync::atomic::Ordering::SeqCst)
13526    }
13527
13528    /// Is a spin loop running on this executor right now?
13529    ///
13530    /// rclcpp spells this `Executor::is_spinning()`. True between the entry and
13531    /// the exit of [`spin`](Self::spin),
13532    /// [`spin_period`](Self::spin_period), the `open_threaded` worker loop, and
13533    /// the C / C++ API loops — every construct that owns its own iteration.
13534    ///
13535    /// A bare `spin_once` does NOT set it: one poll is not a spin, and a caller
13536    /// running its own `while` around `spin_once` is the one who knows it is
13537    /// looping. Such a caller can say so with
13538    /// [`enter_spin_loop`](Self::enter_spin_loop).
13539    ///
13540    /// See [`is_halted()`](Self::is_halted) for why this is a different question
13541    /// from "was cancel requested".
13542    pub fn is_spinning(&self) -> bool {
13543        self.spinning.load(core::sync::atomic::Ordering::SeqCst)
13544    }
13545
13546    /// Declare that the caller is entering a spin loop it drives itself.
13547    ///
13548    /// The seam the C and C++ APIs use so their loops answer
13549    /// [`is_spinning()`](Self::is_spinning) without keeping a flag of their own
13550    /// (RFC-0019: ergonomics may live in the wrapper, state may not). Also
13551    /// CLEARS any pending cancel, matching what `spin` does on entry:
13552    /// a cancel requested before a spin started is not a cancel of THIS spin.
13553    ///
13554    /// Pair it with [`exit_spin_loop`](Self::exit_spin_loop) on EVERY exit path,
13555    /// `break` and early `return` alike — a loop that leaves without it reports
13556    /// `is_spinning() == true` forever. It is a method pair rather than an RAII
13557    /// guard on purpose: a guard would have to borrow the executor for the whole
13558    /// loop, and every caller needs `&mut` inside the body to poll.
13559    ///
13560    /// Takes `&self` (the flags are atomics), so the borrow ends at the call.
13561    pub fn enter_spin_loop(&self) {
13562        self.halt_flag
13563            .store(false, core::sync::atomic::Ordering::SeqCst);
13564        self.spinning
13565            .store(true, core::sync::atomic::Ordering::SeqCst);
13566    }
13567
13568    /// End the scope opened by [`enter_spin_loop`](Self::enter_spin_loop).
13569    ///
13570    /// Idempotent. Leaves the cancel flag alone: "someone asked me to stop" is
13571    /// the caller's answer to keep or clear, and clearing it here would lose the
13572    /// reason the loop exited.
13573    pub fn exit_spin_loop(&self) {
13574        self.spinning
13575            .store(false, core::sync::atomic::Ordering::SeqCst);
13576    }
13577}
13578
13579// ============================================================================
13580// std-gated spin and halt methods
13581// ============================================================================
13582
13583// phase-359 W10 — this impl used to be ONE `#[cfg(feature = "std")]` block.
13584// Only the wall-clock spin loops in it need `std` (`Instant`, `thread::sleep`,
13585// `thread::spawn`); `halt` / `is_halted` / `wake` are plain atomic stores on
13586// flags the struct already owns, and `halt_flag` / `wake_handle` only need
13587// `Arc`. Gating the BLOCK rather than the items made a no_std image unable to
13588// stop its own executor — `ExecutorNodeRuntime::spin` carried a matching `std`
13589// gate for no reason but this one.
13590// phase-359 W10 — `alloc`, not `std`. The wall-clock spin loops were the
13591// reason this block was std-gated: `Instant` for the deadline and
13592// `thread::sleep` for the pacing. Both now go through the platform — the
13593// executor's own `now_us()` and `nros_platform_sleep_us` — so a no_std
13594// image can run the same blocking loops a hosted one does instead of
13595// hand-rolling them in its BSP.
13596#[cfg(feature = "alloc")]
13597impl<'s> Executor<'s> {
13598    /// Blocking spin loop with configurable exit conditions — rclrs's
13599    /// `Executor::spin(SpinOptions)`.
13600    ///
13601    /// ADOPT-BOUNDED (RFC-0089). Same name, same argument, same meaning: run
13602    /// until an exit condition in `opts` says stop. Two bounds, both visible in
13603    /// the signature:
13604    ///
13605    /// * **The error channel is one error, not all of them.** rclrs returns
13606    ///   `Vec<RclrsError>` and the caller writes `.first_error()?`; there is no
13607    ///   allocator here, so ours returns `Result<(), NodeError>` carrying the
13608    ///   FIRST failing callback's error and drops the rest (they are still
13609    ///   counted in each pass's [`SpinOnceResult`]). Ported line for line, the
13610    ///   edit is `.first_error()?` -> `?`, which the compiler points at.
13611    /// * **The loop is a poll loop**, so `opts.poll_interval` is the quantum at
13612    ///   which every exit condition is observed — a `cancel()` from another
13613    ///   task, the `timeout` expiring, `max_callbacks` being reached. Requesting
13614    ///   a `timeout` on a build with no clock is an ERROR, not a silent
13615    ///   never-ending loop (issue 0709).
13616    ///
13617    /// phase-427 — this method was `spin_blocking`. `spin` was taken by the
13618    /// `-> !` task body, which is now [`spin_forever`](Self::spin_forever).
13619    ///
13620    /// Runs until one of:
13621    /// - [`cancel()`](Self::cancel) is called (from
13622    ///   another thread or signal handler)
13623    /// - `opts.timeout` expires (if set)
13624    /// - `opts.max_callbacks` is reached (if set)
13625    /// - `opts.only_next` is true (single iteration)
13626    /// - a callback failed and `opts.stop_on_first_error` is set
13627    ///
13628    /// # Example
13629    ///
13630    /// ```ignore
13631    /// // Spin until cancelled
13632    /// executor.spin(SpinOptions::default())?;
13633    ///
13634    /// // Spin with 5-second timeout
13635    /// executor.spin(SpinOptions::new().timeout(core::time::Duration::from_secs(5)))?;
13636    ///
13637    /// // Single iteration
13638    /// executor.spin(SpinOptions::spin_once())?;
13639    /// ```
13640    pub fn spin(&mut self, opts: SpinOptions) -> Result<(), NodeError> {
13641        // phase-427 — was a private `POLL_INTERVAL` const; it is
13642        // `SpinOptions::poll_interval` now, defaulting to the same 10 ms, so
13643        // `spin` and `spin_forever` read one value instead of two literals.
13644        let poll_interval = opts.poll_interval;
13645
13646        // phase-359 W10 — the executor's own clock. `now_us()` reads the
13647        // PLATFORM's monotonic counter, and nothing else: the platform API is
13648        // the clock, so a build with a port has one and a build without has
13649        // none. There is no `std::time::Instant` third answer any more, which
13650        // is what used to make "what time is it" depend on whether some crate
13651        // in the graph happened to name `std`.
13652        //
13653        // `None` means this build has NO clock at all — and issue 0709 is what
13654        // the previous answer here cost. It read: "a timeout cannot be honoured
13655        // then, and pretending otherwise would exit immediately or never; the
13656        // honest reading is no deadline". It then picked NEVER. A caller that
13657        // asked for 50 ms got an infinite loop, silently, in the one API whose
13658        // whole contract is "returns after N ms" — ten hours of a CI lane
13659        // before anyone read it as stuck rather than slow.
13660        //
13661        // Neither of the two silent readings is honest. An unmet precondition
13662        // is an ERROR (repo rule, fail-loud): the caller supplied a time
13663        // quantity this build cannot measure, and only they can decide what to
13664        // do about it. An UNTIMED `spin` still runs until halt, which
13665        // is a promise this build can keep.
13666        let start_us = self.now_us();
13667        if opts.timeout.is_some() && start_us.is_none() {
13668            nros_log::log_error!(
13669                nros_log::get_logger("nros"),
13670                "spin: a timeout was requested but this build has no clock \
13671                 — install one with `ExecutorConfig::clock_us` (issue 0709)"
13672            );
13673            return Err(NodeError::NotInitialized);
13674        }
13675        // `as_micros()` rather than a hand-rolled `ms * 1_000`: the multiply
13676        // was unchecked, and the unit conversion is `Duration`'s job.
13677        let timeout_us = opts.timeout.map(|d| d.as_micros() as u64);
13678        let mut total_callbacks = 0usize;
13679
13680        // phase-417 W4.c — clears the cancel flag AND raises `is_spinning`. The
13681        // clear is the pre-existing behaviour (a cancel requested before this
13682        // spin is not a cancel of this spin); the raise is what lets a peer
13683        // thread see that its `cancel()` has not been acted on yet. Every exit
13684        // below is a `break` to the single tail, which is where the scope ends.
13685        self.enter_spin_loop();
13686
13687        loop {
13688            if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
13689                break;
13690            }
13691
13692            if let (Some(start), Some(limit)) = (start_us, timeout_us)
13693                && self
13694                    .now_us()
13695                    .is_some_and(|now| now.saturating_sub(start) >= limit)
13696            {
13697                break;
13698            }
13699
13700            let mut callback_error = None;
13701            let result = self.spin_once_capturing(poll_interval, &mut callback_error);
13702            total_callbacks += result.total();
13703
13704            // phase-427 — the first error, and only the first: rclrs's
13705            // `Vec<RclrsError>` needs an allocator we do not have. The scope is
13706            // closed before returning so a caller that reads `is_spinning()`
13707            // after an error sees `false`, exactly as after a clean exit.
13708            if opts.stop_on_first_error
13709                && let Some(e) = callback_error
13710            {
13711                self.exit_spin_loop();
13712                return Err(NodeError::Transport(e));
13713            }
13714
13715            if opts.max_callbacks.is_some_and(|max| total_callbacks >= max) {
13716                break;
13717            }
13718
13719            if opts.only_next {
13720                break;
13721            }
13722        }
13723
13724        self.exit_spin_loop();
13725        Ok(())
13726    }
13727
13728    /// Execute one period with wall-clock overrun detection.
13729    ///
13730    /// Calls [`spin_once()`](Self::spin_once), measures wall-clock time, sleeps
13731    /// for the remainder if under budget.
13732    ///
13733    /// # Example
13734    ///
13735    /// ```ignore
13736    /// let period = core::time::Duration::from_millis(10);
13737    /// let result = executor.spin_one_period_timed(period);
13738    /// if result.overrun {
13739    ///     log::warn!("Period overrun: {:?}", result.elapsed);
13740    /// }
13741    /// ```
13742    pub fn spin_one_period_timed(
13743        &mut self,
13744        period: core::time::Duration,
13745    ) -> super::types::SpinPeriodResult {
13746        let start_us = self.now_us();
13747        let result = self.spin_once(period);
13748        // With no clock there is nothing to measure and nothing to sleep off;
13749        // report zero elapsed and no overrun, which is what an unmeasured
13750        // period is.
13751        let elapsed = start_us
13752            .zip(self.now_us())
13753            .map(|(s, e)| core::time::Duration::from_micros(e.saturating_sub(s)))
13754            .unwrap_or_default();
13755        let overrun = elapsed > period;
13756        if !overrun && start_us.is_some() {
13757            platform_sleep(period - elapsed);
13758        }
13759        super::types::SpinPeriodResult {
13760            work: result,
13761            overrun,
13762            elapsed,
13763        }
13764    }
13765
13766    /// Spin at a fixed rate with drift compensation. Blocks until halted.
13767    ///
13768    /// Uses wall-clock time to maintain the target rate. The next invocation
13769    /// time is accumulated (not reset to `now + period`) to prevent cumulative
13770    /// drift.
13771    ///
13772    /// # Example
13773    ///
13774    /// ```ignore
13775    /// // 100Hz control loop — blocks until halt() is called
13776    /// executor.spin_period(core::time::Duration::from_millis(10))?;
13777    /// ```
13778    pub fn spin_period(&mut self, period: core::time::Duration) -> Result<(), NodeError> {
13779        let period_us = period.as_micros().min(u64::MAX as u128) as u64;
13780        // Absolute next-deadline in the executor's own clock. issue 0709 — the
13781        // sibling of `spin`'s guard above: with no clock there is no
13782        // pacing, and the loop would run as fast as `spin_once` returns while
13783        // the caller believes it is running at `period`. A requested period
13784        // this build cannot honour is a configuration error, not a silent
13785        // busy-loop.
13786        let Some(start_us) = self.now_us() else {
13787            nros_log::log_error!(
13788                nros_log::get_logger("nros"),
13789                "spin_period: a period was requested but this build has no clock \
13790                 — install one with `ExecutorConfig::clock_us` (issue 0709)"
13791            );
13792            return Err(NodeError::NotInitialized);
13793        };
13794        // phase-417 W4.c — after the clock guard, not before it: a call that
13795        // returns an error never spun, and must not report that it did. (This
13796        // also moved the cancel-flag clear past the guard, which is the same
13797        // correction.)
13798        self.enter_spin_loop();
13799        let mut next_us = Some(start_us + period_us);
13800
13801        loop {
13802            if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
13803                break;
13804            }
13805
13806            self.spin_once(period);
13807
13808            if let Some(next) = next_us {
13809                // Jitter is recorded in `spin_once` above, which this loop
13810                // calls every iteration -- counting it here as well would
13811                // double every wake on this one driver.
13812                if let Some(now) = self.now_us()
13813                    && now < next
13814                {
13815                    platform_sleep(core::time::Duration::from_micros(next - now));
13816                }
13817                // Accumulate to prevent drift (not = now + period)
13818                next_us = Some(next + period_us);
13819            }
13820        }
13821        self.exit_spin_loop();
13822        Ok(())
13823    }
13824}
13825
13826#[cfg(feature = "alloc")]
13827impl<'s> Executor<'s> {
13828    /// Phase 110.D.b — move this Executor onto a fresh OS thread,
13829    /// apply a per-thread scheduling policy via the caller-supplied
13830    /// `apply_policy` function, and run the spin loop until
13831    /// `ThreadHandle::halt` fires.
13832    ///
13833    /// The function-pointer indirection on `apply_policy` lets the
13834    /// caller pass any platform's `PlatformScheduler::set_current_thread_policy`
13835    /// without forcing `Executor` to be generic over the platform —
13836    /// keeps the existing `Executor` type stable.
13837    ///
13838    /// Multi-executor preemption (the actual hard-RT win) comes from
13839    /// the OS scheduler — call `open_threaded` once per criticality
13840    /// tier, each with its own policy / priority. The kernel handles
13841    /// preemption across executors; within a single executor,
13842    /// dispatch remains non-preemptive (110.A–C bucketed sets).
13843    ///
13844    /// # Safety
13845    ///
13846    /// Moves `self` across thread boundaries. `Executor` contains a
13847    /// raw `*mut session::ConcreteSession` when constructed via
13848    /// `from_session_ptr`; the caller must ensure that pointer's
13849    /// referent stays valid across the lifetime of the spawned thread
13850    /// and that no other thread mutates the session concurrently.
13851    /// `from_session` (Owned) is safer — `ConcreteSession` ownership
13852    /// transfers cleanly into the thread.
13853    #[cfg(feature = "alloc")]
13854    pub unsafe fn open_threaded(
13855        self,
13856        policy: nros_platform_api::SchedPolicy,
13857        apply_policy: fn(
13858            nros_platform_api::SchedPolicy,
13859        ) -> Result<(), nros_platform_api::SchedError>,
13860        spin_period: core::time::Duration,
13861    ) -> ThreadHandle
13862    where
13863        // phase-271 — the spawned thread owns `self` for an unbounded lifetime,
13864        // so its borrowed storage must be `'static` (a leaked/`static` backing —
13865        // the `alloc` convenience constructors or a program-lifetime region).
13866        's: 'static,
13867    {
13868        let halt = self.halt_flag.clone();
13869        // phase-359 W10 — a platform task, not `std::thread`. The two other
13870        // executor-owned workers moved earlier; this is the third and last, and
13871        // it is the one the ABI fits best: `open_threaded` exists to give a
13872        // spin loop an OS SCHEDULING POLICY, which is not something a build
13873        // without an OS was ever going to get from `std`.
13874        //
13875        // The closure becomes a heap context because the entry point crosses C
13876        // as `*mut c_void`. It is reclaimed by the trampoline on exit rather
13877        // than leaked: unlike a tier task, this loop RETURNS — that is what
13878        // `halt` is for.
13879        let ctx = alloc::boxed::Box::into_raw(alloc::boxed::Box::new(ThreadedCtx {
13880            executor: self,
13881            policy,
13882            apply_policy,
13883            spin_period,
13884        }));
13885        // SAFETY: `ctx` stays live until the trampoline reclaims it, which
13886        // happens on the spawned task and only after the loop exits.
13887        let task = unsafe {
13888            nros_platform_api::task::PlatformTask::spawn(
13889                threaded_spin_trampoline,
13890                ctx as *mut core::ffi::c_void,
13891                OPEN_THREADED_STACK_BYTES,
13892                c"nros-exec".as_ptr(),
13893            )
13894        };
13895        let Some(task) = task else {
13896            // The platform refused (or cannot size) a task. Reclaim the
13897            // context and hand back a handle that owns nothing — `halt` and
13898            // `join` stay callable, which keeps the caller's shape identical to
13899            // the success path.
13900            //
13901            // SAFETY: nothing was spawned, so this pointer has no other owner.
13902            drop(unsafe { alloc::boxed::Box::from_raw(ctx) });
13903            return ThreadHandle { task: None, halt };
13904        };
13905        ThreadHandle {
13906            task: Some(task),
13907            halt,
13908        }
13909    }
13910
13911    /// Phase 104.C.6 — wake the executor from another thread / ISR /
13912    /// signal handler.
13913    ///
13914    /// Sets the shared `wake_flag`. The next `spin_once` swap-clears the
13915    /// flag, skips the blocking wait on the primary session, and polls
13916    /// every session non-blockingly so whatever queued the wake is
13917    /// observed in a single iteration. Idempotent — multiple `wake()`
13918    /// calls collapse into one observed wake per `spin_once`.
13919    pub fn wake(&self) {
13920        self.wake_flag
13921            .store(true, core::sync::atomic::Ordering::SeqCst);
13922    }
13923}
13924
13925#[cfg(feature = "alloc")]
13926impl<'s> Executor<'s> {
13927    /// Get a clone of the halt flag for use in signal handlers or other threads.
13928    ///
13929    /// # Example
13930    ///
13931    /// ```ignore
13932    /// let halt = executor.halt_flag();
13933    /// std::thread::spawn(move || {
13934    ///     std::thread::sleep(Duration::from_secs(5));
13935    ///     halt.store(true, Ordering::SeqCst);
13936    /// });
13937    /// executor.spin(SpinOptions::default())?;
13938    /// ```
13939    pub fn halt_flag(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
13940        self.halt_flag.clone()
13941    }
13942
13943    /// Phase 104.C.6 — clone of the shared wake flag for cross-thread
13944    /// use (signal handlers, foreign threads, future per-backend vtable
13945    /// wake hooks).
13946    ///
13947    /// # Example
13948    ///
13949    /// ```ignore
13950    /// let wake = executor.wake_handle();
13951    /// std::thread::spawn(move || {
13952    ///     // ... compute something ...
13953    ///     // hand off to executor by setting the flag.
13954    ///     wake.store(true, Ordering::SeqCst);
13955    /// });
13956    /// loop { executor.spin_once(Duration::from_millis(100)); }
13957    /// ```
13958    pub fn wake_handle(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
13959        self.wake_flag.clone()
13960    }
13961}
13962
13963/// Phase 110.E.b — opaque per-platform timer handle. Stores the
13964/// raw platform handle (POSIX `timer_t` boxed via `PosixTimerHandle`,
13965/// FreeRTOS `TimerHandle_t`, etc.) plus a destroy thunk so the
13966/// Executor can clean up without being generic over the platform.
13967///
13968/// Caller of `register_sporadic_timer` builds this via
13969/// `OpaqueTimerHandle::new(handle, destroy_fn)` after their
13970/// `PlatformTimer::create_periodic` call returns.
13971#[cfg(feature = "alloc")]
13972pub struct OpaqueTimerHandle {
13973    handle: *mut core::ffi::c_void,
13974    destroy_fn: extern "C" fn(*mut core::ffi::c_void),
13975}
13976
13977#[cfg(feature = "alloc")]
13978unsafe impl Send for OpaqueTimerHandle {}
13979#[cfg(feature = "alloc")]
13980unsafe impl Sync for OpaqueTimerHandle {}
13981
13982#[cfg(feature = "alloc")]
13983impl OpaqueTimerHandle {
13984    /// # Safety
13985    /// `handle` must be a live platform-specific timer handle that
13986    /// `destroy_fn` knows how to drop. Caller surrenders ownership
13987    /// of the underlying handle to the Executor.
13988    pub unsafe fn new(
13989        handle: *mut core::ffi::c_void,
13990        destroy_fn: extern "C" fn(*mut core::ffi::c_void),
13991    ) -> Self {
13992        Self { handle, destroy_fn }
13993    }
13994}
13995
13996#[cfg(feature = "alloc")]
13997impl Drop for OpaqueTimerHandle {
13998    fn drop(&mut self) {
13999        if !self.handle.is_null() {
14000            (self.destroy_fn)(self.handle);
14001            self.handle = core::ptr::null_mut();
14002        }
14003    }
14004}
14005
14006/// Handle returned from [`Executor::open_threaded`]. Holds the
14007/// spawned thread's join handle and a clone of the executor's halt
14008/// flag. Drop runs `halt() + join()` so the thread can't outlive the
14009/// handle.
14010#[cfg(feature = "alloc")]
14011pub struct ThreadHandle {
14012    task: Option<nros_platform_api::task::PlatformTask>,
14013    halt: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
14014}
14015
14016/// Default stack for an `open_threaded` spin loop. `std::thread`'s default was
14017/// 2 MiB; the executor's own storage is caller-carved and lives elsewhere, so
14018/// this carries call frames only — the same reasoning (and size) as the NuttX
14019/// tier spawn, which measured this against a real RTOS default.
14020#[cfg(feature = "alloc")]
14021const OPEN_THREADED_STACK_BYTES: usize = 65536;
14022
14023/// What [`Executor::open_threaded`] hands its task, in place of a closure's
14024/// captures. Owned by the trampoline, which reclaims it when the loop exits.
14025#[cfg(feature = "alloc")]
14026struct ThreadedCtx {
14027    executor: Executor<'static>,
14028    policy: nros_platform_api::SchedPolicy,
14029    apply_policy: fn(nros_platform_api::SchedPolicy) -> Result<(), nros_platform_api::SchedError>,
14030    spin_period: core::time::Duration,
14031}
14032
14033/// The spawned spin loop.
14034///
14035/// # Safety
14036/// `arg` must be the `Box<ThreadedCtx>` raw pointer `open_threaded` created,
14037/// passed exactly once.
14038#[cfg(feature = "alloc")]
14039unsafe extern "C" fn threaded_spin_trampoline(
14040    arg: *mut core::ffi::c_void,
14041) -> *mut core::ffi::c_void {
14042    // SAFETY: the caller's contract — this is the pointer `open_threaded`
14043    // leaked, and the task is the only consumer of it.
14044    let mut ctx = unsafe { alloc::boxed::Box::from_raw(arg as *mut ThreadedCtx) };
14045    // Apply the requested OS scheduling policy to this fresh task. Failure is
14046    // reported but not propagated — a runtime that fails to lift to SCHED_FIFO
14047    // still spins correctly at SCHED_OTHER (just without RT guarantees).
14048    let _ = (ctx.apply_policy)(ctx.policy);
14049    // phase-417 W4.c — this loop owns its own iteration, so it is a spin and
14050    // must say so. NOT `enter_spin_loop()`: that clears the cancel flag, and
14051    // `open_threaded` hands the caller a `ThreadHandle` whose `halt()` may
14052    // already have fired before this task was scheduled. Raising `is_spinning`
14053    // without touching the cancel bit is the honest report here.
14054    ctx.executor
14055        .spinning
14056        .store(true, core::sync::atomic::Ordering::SeqCst);
14057    while !ctx.executor.is_halted() {
14058        ctx.executor.spin_once(ctx.spin_period);
14059    }
14060    ctx.executor.exit_spin_loop();
14061    core::ptr::null_mut()
14062}
14063
14064#[cfg(feature = "alloc")]
14065impl ThreadHandle {
14066    /// Signal the spawned executor thread to stop. The thread exits
14067    /// on its next `spin_once` iteration.
14068    pub fn halt(&self) {
14069        self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
14070    }
14071
14072    /// Wait for the spawned task to exit. After `join`, calling it again is a
14073    /// no-op.
14074    ///
14075    /// phase-359 W10 — was `std::thread::Result<()>`, which carried a panic
14076    /// payload this can no longer produce: a platform task has no unwinding
14077    /// join. Same two outcomes, an error type that does not require `std` —
14078    /// the trade `signal_fd` already made in this campaign. `NotInitialized`
14079    /// means the platform refused to host the task at spawn time — nothing was
14080    /// started, so there is nothing to wait for. (An existing variant rather
14081    /// than a new one: `NodeError` is mapped across the C and C++ FFI, so a new
14082    /// variant is a gate-checked ABI change and this needs no new meaning.)
14083    pub fn join(mut self) -> Result<(), NodeError> {
14084        self.halt();
14085        match self.task.take() {
14086            Some(t) => {
14087                t.join();
14088                Ok(())
14089            }
14090            None => Err(NodeError::NotInitialized),
14091        }
14092    }
14093}
14094
14095#[cfg(feature = "alloc")]
14096impl Drop for ThreadHandle {
14097    fn drop(&mut self) {
14098        self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
14099        if let Some(t) = self.task.take() {
14100            t.join();
14101        }
14102    }
14103}
14104
14105// SAFETY: Phase 110.D.b — `Executor` contains a raw `*mut
14106// session::ConcreteSession` only on the `from_session_ptr` (Borrowed)
14107// path; the `from_session` (Owned) path is plain Send-able. The
14108// `unsafe fn open_threaded` entry point documents the safety
14109// contract for Borrowed sessions; for Owned sessions the Send claim
14110// is unconditional.
14111// phase-359 W10 — `alloc`, not `std`: this assertion exists for
14112// `open_threaded`, which now hands the executor to a PLATFORM task. The
14113// crossing is the same one; only the thing doing the crossing changed.
14114#[cfg(feature = "alloc")]
14115unsafe impl<'s> Send for Executor<'s> {}
14116
14117// =============================================================================
14118// Phase 110.F — `OsPriorityWorker` + `WorkItem`
14119// =============================================================================
14120
14121// phase-359 W10 — `OsPriorityWorker` / `WorkItem` moved to
14122// `super::os_priority` and were rewritten off `std::thread` + `std::sync::mpsc`
14123// + `HashMap` onto the platform task ABI, a bounded `heapless` mailbox and a
14124// `NodeWake` doorbell. The capability is no longer std-only; see that module
14125// for what changed semantically (bounded mailbox, capacity-limited pool, and
14126// no pool on a platform without a wake primitive).
14127
14128impl<'s> Drop for Executor<'s> {
14129    fn drop(&mut self) {
14130        // Issue 0790 — a teardown that never went through `close()` still gets
14131        // its ordered shutdown hooks, and gets them in the SAME order. The C
14132        // API's `nros_executor_fini` is exactly that path: it drops the
14133        // executor in place and leaves the session to `nros_support_fini`, so
14134        // without this the whole facility would be silently inert for every C
14135        // entry. Entities are still live at the top of `drop` — the component
14136        // cells and the arena entries below are what tears them down — so this
14137        // is the last moment a pre-shutdown hook can do what it exists to do.
14138        //
14139        // After a normal `close()` both tables are already empty and these two
14140        // calls are a pair of `None` scans.
14141        self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
14142        // Phase 258 (Track 2, 2a) — release executor-owned component cells
14143        // first (before the arena entries), so a component's `drop`
14144        // trampoline can still touch its own (cell-owned) state. Each slot
14145        // owns a leaked `Arc<ComponentCell>`; its `drop` reconstitutes +
14146        // drops that Arc exactly once.
14147        for slot in self.component_slots.iter() {
14148            // SAFETY: `slot.state` is the leaked cell enrolled via
14149            // `enroll_component`; `slot.drop` is its matching trampoline, run
14150            // exactly once here (slots are not removed before Drop).
14151            unsafe {
14152                (slot.drop)(slot.state);
14153            }
14154        }
14155        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
14156        for meta in self.entries.iter().flatten() {
14157            // SAFETY: each entry was written by `ptr::write` in `add_*` and
14158            // has not been dropped yet. `drop_fn` matches the concrete type.
14159            unsafe {
14160                let data_ptr = arena_ptr.add(meta.offset);
14161                (meta.drop_fn)(data_ptr);
14162            }
14163        }
14164        // Issue 0790 — the post-teardown half, after the entities are gone.
14165        // Same "already empty after `close()`" note as the pre pass above.
14166        self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
14167        // Issue 1385 — LAST in the body, and that is the whole point: the
14168        // context the backend holds is `wake_ctx`, a field, so it is still
14169        // alive for every line above and is freed only after `drop` returns.
14170        // Clearing here is therefore the SHORTEST window in which a callback
14171        // can fire into live state, not the longest — entity teardown above
14172        // may still be talking to the backend.
14173        //
14174        // A no-op after `close()` (the latch), and a no-op for an executor
14175        // that never installed. For the C path this is the only clear there
14176        // is: `rclc_executor_fini` runs this `drop_in_place` and THEN
14177        // zero-fills the storage the callback pointed into.
14178        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
14179        self.clear_wake_signal();
14180        // phase-467 Row 8 — and the graph-change target, before the box it
14181        // names is freed with this executor.
14182        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
14183        self.clear_graph_change_signal();
14184    }
14185}
14186
14187#[cfg(all(test, feature = "std", not(feature = "rmw-cffi")))]
14188mod dispatch_registry_tests {
14189    //! Phase 216 follow-up — `Executor::register_dispatch_slot` +
14190    //! `Executor::dispatch_callback` round-trip.
14191    //!
14192    //! Uses `MockSession` (same pattern as
14193    //! `lifecycle_services::tests::mock_integration`) so the test
14194    //! doesn't need a live RMW backend. Gated `not(feature =
14195    //! "rmw-cffi")` because under `rmw-cffi` the `ConcreteSession`
14196    //! type alias resolves to the cffi session, which `MockSession`
14197    //! can't impersonate.
14198
14199    extern crate alloc;
14200
14201    use super::Executor;
14202    use crate::mock::MockSession;
14203    use std::sync::Mutex;
14204
14205    static CAPTURED: Mutex<alloc::vec::Vec<(usize, alloc::vec::Vec<u8>, usize)>> =
14206        Mutex::new(alloc::vec::Vec::new());
14207
14208    /// Test trampoline matching the per-Node
14209    /// `__nros_node_<pkg>_on_callback` ABI shape (Phase 216.A.5).
14210    unsafe extern "C" fn recording_on_callback(
14211        state: *mut core::ffi::c_void,
14212        cb_id_ptr: *const u8,
14213        cb_id_len: usize,
14214        ctx: *mut core::ffi::c_void,
14215    ) {
14216        // SAFETY: caller (test body below) holds storage live;
14217        // `cb_id_ptr..len` points into a `&str` literal.
14218        let cb_id_bytes = unsafe { core::slice::from_raw_parts(cb_id_ptr, cb_id_len).to_vec() };
14219        let mut guard = CAPTURED.lock().expect("CAPTURED poisoned");
14220        guard.push((state as usize, cb_id_bytes, ctx as usize));
14221    }
14222
14223    #[test]
14224    fn register_dispatch_slot_round_trip() {
14225        let session = MockSession::new();
14226        let mut executor: Executor = Executor::from_session(session);
14227
14228        // Pre-condition: empty registry.
14229        assert_eq!(executor.dispatch_slot_count(), 0);
14230
14231        // Two distinct "states" so we prove every slot gets called
14232        // with its OWN state.
14233        let mut state_blob_a: u32 = 0xABCD_0001;
14234        let mut state_blob_b: u32 = 0xABCD_0002;
14235        let state_a_ptr = &mut state_blob_a as *mut u32 as *mut core::ffi::c_void;
14236        let state_b_ptr = &mut state_blob_b as *mut u32 as *mut core::ffi::c_void;
14237
14238        executor
14239            .register_dispatch_slot(state_a_ptr, recording_on_callback)
14240            .expect("register slot A");
14241        executor
14242            .register_dispatch_slot(state_b_ptr, recording_on_callback)
14243            .expect("register slot B");
14244        assert_eq!(executor.dispatch_slot_count(), 2);
14245
14246        let mut ctx_blob: u32 = 0xFEED_BEEF;
14247        let ctx_ptr = &mut ctx_blob as *mut u32 as *mut core::ffi::c_void;
14248        let cb_id = "/talker/timer/publish";
14249
14250        CAPTURED.lock().expect("CAPTURED poisoned").clear();
14251        executor.dispatch_callback(cb_id, ctx_ptr);
14252
14253        let captured = CAPTURED.lock().expect("CAPTURED poisoned").clone();
14254        assert_eq!(
14255            captured.len(),
14256            2,
14257            "every registered slot must be invoked — linear scan, \
14258             no self-filter at the registry layer"
14259        );
14260        // The carved dispatch table iterates in insertion order.
14261        assert_eq!(captured[0].0, state_a_ptr as usize, "slot A's state");
14262        assert_eq!(captured[1].0, state_b_ptr as usize, "slot B's state");
14263        for (idx, capture) in captured.iter().enumerate() {
14264            assert_eq!(
14265                capture.1.as_slice(),
14266                cb_id.as_bytes(),
14267                "slot {idx} cb_id bytes round-trip"
14268            );
14269            assert_eq!(
14270                capture.2, ctx_ptr as usize,
14271                "slot {idx} ctx pointer round-trip"
14272            );
14273        }
14274    }
14275
14276    #[test]
14277    fn register_dispatch_slot_capacity_full() {
14278        let session = MockSession::new();
14279        let mut executor: Executor = Executor::from_session(session);
14280
14281        let mut state_blob: u32 = 0;
14282        let state_ptr = &mut state_blob as *mut u32 as *mut core::ffi::c_void;
14283
14284        // `MAX_NODES` slots fit; the next one must error.
14285        for _ in 0..crate::config::MAX_NODES {
14286            executor
14287                .register_dispatch_slot(state_ptr, recording_on_callback)
14288                .expect("under-capacity push must succeed");
14289        }
14290        assert_eq!(executor.dispatch_slot_count(), crate::config::MAX_NODES);
14291        let overflow = executor.register_dispatch_slot(state_ptr, recording_on_callback);
14292        assert!(
14293            overflow.is_err(),
14294            "over-capacity push must return Err(()) — raise \
14295             NROS_EXECUTOR_MAX_NODES at build time to grow the registry"
14296        );
14297    }
14298}
14299
14300/// Phase 274.W1 — borrowed-executor session sharing + active-groups gating.
14301///
14302/// Validates three primitives introduced for RFC-0015 Model 1:
14303/// - `session_handle` / `open_with_session_handle` (Borrowed session store —
14304///   the borrowed executor does not own or close the session on drop).
14305/// - `set_active_groups` + `group_active` (callback-group filter gating).
14306///
14307/// Uses `MockSession` (same pattern as `dispatch_registry_tests`); gated
14308/// `not(feature = "rmw-cffi")` for the same reason.
14309#[cfg(all(test, feature = "std", not(feature = "rmw-cffi")))]
14310mod p274_w1_tier_executor_tests {
14311    use super::Executor;
14312    use crate::mock::MockSession;
14313
14314    #[test]
14315    fn session_handle_borrowed_executor_shares_session_ptr() {
14316        // Open the primary executor (session owner).
14317        let session = MockSession::new();
14318        let mut primary = Executor::from_session(session);
14319
14320        // Record the primary's session pointer for later comparison.
14321        let primary_session_ptr = primary.session_ptr();
14322
14323        // Get the opaque session handle (into_raw for C FFI; here we keep it raw).
14324        let handle = primary.session_handle();
14325
14326        // Open a second executor over the SAME session (Borrowed — does not own it).
14327        // SAFETY: `primary` (the session owner) outlives `borrowed` in this scope.
14328        let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
14329
14330        // Both executors must expose the same session pointer.
14331        assert_eq!(
14332            primary.session_ptr(),
14333            borrowed.session_ptr(),
14334            "borrowed executor must share the primary's session pointer"
14335        );
14336        assert_eq!(
14337            borrowed.session_ptr(),
14338            primary_session_ptr,
14339            "session pointer must be stable across session_handle / open_with_session_handle"
14340        );
14341
14342        // Drop the borrowed executor — the primary's session must remain valid.
14343        drop(borrowed);
14344
14345        // Primary still exposes the same session pointer (session was NOT closed by drop).
14346        assert_eq!(
14347            primary.session_ptr(),
14348            primary_session_ptr,
14349            "primary session pointer must be unchanged after dropping the borrowed executor"
14350        );
14351    }
14352
14353    /// An oversized group name is REFUSED and fails CLOSED.
14354    ///
14355    /// issue 1172 — this used to be `if s.push_str(g).is_ok()`, which dropped
14356    /// the name and left the tier filtering on a set that silently lacked it.
14357    /// Two things are asserted because either alone would let the bug back:
14358    /// the call must REPORT the failure, and the resulting filter must accept
14359    /// NOTHING rather than fall back to the wildcard, which would be
14360    /// fail-open.
14361    #[test]
14362    fn an_oversized_group_name_is_refused_and_fails_closed() {
14363        let session = MockSession::new();
14364        let mut primary = Executor::from_session(session);
14365        let handle = primary.session_handle();
14366        // SAFETY: primary outlives borrowed.
14367        let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
14368
14369        // `GroupName` is `heapless::String<32>`.
14370        let too_long = "g".repeat(33);
14371        assert!(
14372            borrowed
14373                .set_active_groups(&[("n", "/", too_long.as_str())])
14374                .is_err(),
14375            "an oversized group name must be reported, not dropped"
14376        );
14377        assert!(
14378            !borrowed.group_active("n", "/", &too_long),
14379            "the name that did not fit must not be active"
14380        );
14381        assert!(
14382            !borrowed.group_active("n", "/", "anything-else"),
14383            "a refused filter must accept NOTHING — falling back to the \
14384             wildcard would run every callback on this tier"
14385        );
14386    }
14387
14388    #[test]
14389    fn set_active_groups_gates_group_active() {
14390        let session = MockSession::new();
14391        let mut primary = Executor::from_session(session);
14392        let handle = primary.session_handle();
14393
14394        // SAFETY: primary outlives borrowed.
14395        let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
14396
14397        // Before gating: wildcard — every group is accepted.
14398        assert!(
14399            borrowed.group_active("n", "/", "ctrl"),
14400            "default (wildcard) must accept every group"
14401        );
14402        assert!(
14403            borrowed.group_active("n", "/", "telem"),
14404            "default (wildcard) must accept every group"
14405        );
14406
14407        // Gate borrowed to only the "ctrl" group (one-tier filter).
14408        borrowed
14409            .set_active_groups(&[("n", "/", "ctrl")])
14410            .expect("fits");
14411
14412        assert!(
14413            borrowed.group_active("n", "/", "ctrl"),
14414            "\"ctrl\" must be active after set_active_groups([\"ctrl\"])"
14415        );
14416        assert!(
14417            !borrowed.group_active("n", "/", "telem"),
14418            "\"telem\" must NOT be active when only \"ctrl\" is gated"
14419        );
14420        assert!(
14421            !borrowed.group_active("n", "/", "planning"),
14422            "\"planning\" must NOT be active when only \"ctrl\" is gated"
14423        );
14424
14425        // Primary is unaffected (it still uses the wildcard).
14426        assert!(
14427            primary.group_active("n", "/", "telem"),
14428            "primary executor must remain unaffected (still wildcard)"
14429        );
14430
14431        // Clear the filter on borrowed — back to wildcard.
14432        borrowed
14433            .set_active_groups(&[])
14434            .expect("the wildcard always fits");
14435        assert!(
14436            borrowed.group_active("n", "/", "telem"),
14437            "after clearing, borrowed must accept all groups again"
14438        );
14439    }
14440
14441    #[test]
14442    fn session_handle_into_raw_from_raw_round_trip() {
14443        let session = MockSession::new();
14444        let mut primary = Executor::from_session(session);
14445        let session_ptr = primary.session_ptr();
14446
14447        let handle = primary.session_handle();
14448        let raw = handle.into_raw();
14449
14450        // into_raw must return a non-null pointer matching the session address.
14451        assert!(!raw.is_null());
14452        assert_eq!(raw as *mut _, session_ptr);
14453
14454        // from_raw must reconstruct a handle that opens the same borrowed executor.
14455        // SAFETY: primary still owns the session, raw is its address.
14456        let handle2 = unsafe { crate::executor::SessionHandle::from_raw(raw) };
14457        let mut borrowed = unsafe { Executor::open_with_session_handle(handle2) };
14458        assert_eq!(
14459            borrowed.session_ptr(),
14460            session_ptr,
14461            "from_raw reconstructed handle must open executor on the same session"
14462        );
14463    }
14464}
14465
14466/// W3b.5 — snapshot the monitored publishers' counters before a dispatch
14467/// (only when a latency contract exists; otherwise a zeroed array that
14468/// `attribute_latency` never reads).
14469fn snapshot_pub_counts(
14470    table: &'static [super::monitor::MonitorSpec],
14471    active: bool,
14472) -> [u32; super::monitor::MAX_MONITORS] {
14473    let mut counts = [0u32; super::monitor::MAX_MONITORS];
14474    if active {
14475        for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
14476            counts[k] = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
14477        }
14478    }
14479    counts
14480}
14481
14482/// W3b.5 — attribute one dispatch's elapsed time to every monitored
14483/// publisher whose counter advanced during it (an upper bound on the
14484/// node-path take → publish latency: the callback deserialized, ran, and
14485/// published within `elapsed_us`).
14486fn attribute_latency(
14487    table: &'static [super::monitor::MonitorSpec],
14488    active: bool,
14489    counts_before: &[u32; super::monitor::MAX_MONITORS],
14490    elapsed_us: u32,
14491) {
14492    if !active {
14493        return;
14494    }
14495    for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
14496        if spec.max_latency_ms == 0 {
14497            continue;
14498        }
14499        let now = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
14500        if now != counts_before[k] {
14501            spec.cell
14502                .max_latency_us
14503                .fetch_max(elapsed_us, core::sync::atomic::Ordering::Relaxed);
14504        }
14505    }
14506}
14507
14508/// W3b.5 — enforce the bound SC's deadline after a dispatch. A miss maps
14509/// through [`DeadlineAction`](super::sched_context::DeadlineAction):
14510/// `Warn`/`Skip`/`Fault` all report; `Skip` additionally masks the SC's
14511/// remaining callbacks for this cycle; `Fault` invokes the fault hook
14512/// (panic when none is registered).
14513/// phase-503 -- hand a route deadline verdict of `table[row]` to the reaction
14514/// hook, if one is installed (and the monitors are armed: the caller passes
14515/// `None` otherwise). Shared by the overdue check after the park and the
14516/// completion verdict of a late supervised route (`route_judge`).
14517fn call_route_reaction(
14518    hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
14519    table: &[super::monitor::MonitorSpec],
14520    row: usize,
14521    v: &super::monitor::Violation,
14522) {
14523    let Some((f, ctx)) = hook else { return };
14524    let spec = &table[row];
14525    let ev = super::monitor::ReactionEvent {
14526        reaction_id: spec.reaction_id as u32,
14527        row: row as u32,
14528        rule_code: super::monitor::rule_code(v.rule),
14529        measured_ms: v.measured,
14530        declared_ms: v.declared,
14531        mechanism: spec.mechanism,
14532    };
14533    // SAFETY: `f` and `ctx` were registered together through
14534    // `set_reaction_hook`, whose caller keeps `ctx` valid for the executor's
14535    // life; the event lives across the call.
14536    unsafe { f(&ev, ctx) };
14537}
14538
14539fn check_deadline_miss(
14540    sc: Option<&super::sched_context::SchedContext>,
14541    sc_idx: usize,
14542    elapsed_us: u32,
14543    misses: &mut heapless::Vec<super::monitor::Violation, { super::monitor::MAX_VIOLATIONS }>,
14544    skipped_scs: &mut u64,
14545    fault_fn: Option<fn(&super::monitor::Violation)>,
14546    reaction_hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
14547) {
14548    use super::sched_context::DeadlineAction;
14549    let Some(sc) = sc else { return };
14550    let Some(deadline_us) = sc.deadline_us.get().map(|nz| nz.get()) else {
14551        return;
14552    };
14553    if matches!(sc.deadline_action, DeadlineAction::Ignore) || elapsed_us <= deadline_us {
14554        return;
14555    }
14556    let v = super::monitor::Violation {
14557        rule: "deadline-miss-runtime",
14558        // Entries carry no name at this altitude; the SC slot stands in.
14559        fqn: "sched-context",
14560        measured: elapsed_us,
14561        declared: deadline_us,
14562    };
14563    match sc.deadline_action {
14564        DeadlineAction::Ignore => {}
14565        DeadlineAction::Warn => {
14566            let _ = misses.push(v);
14567        }
14568        DeadlineAction::Skip => {
14569            if sc_idx < 64 {
14570                *skipped_scs |= 1u64 << sc_idx;
14571            }
14572            let _ = misses.push(v);
14573        }
14574        DeadlineAction::Fault => {
14575            if let Some(f) = fault_fn {
14576                f(&v);
14577                let _ = misses.push(v);
14578            } else if let Some((f, ctx)) = reaction_hook {
14579                // phase-503 — the reaction hook is the fault path a C or C++
14580                // image can install; reaction id 0, the SC slot as the row.
14581                let ev = super::monitor::ReactionEvent {
14582                    reaction_id: super::monitor::REACTION_NONE as u32,
14583                    row: sc_idx as u32,
14584                    rule_code: super::monitor::rule_code(v.rule),
14585                    measured_ms: elapsed_us / 1_000,
14586                    declared_ms: deadline_us / 1_000,
14587                    mechanism: super::monitor::MECHANISM_EXECUTOR,
14588                };
14589                // SAFETY: as in `check_route_deadlines`.
14590                unsafe { f(&ev, ctx) };
14591                let _ = misses.push(v);
14592            } else {
14593                panic!(
14594                    "nros: deadline fault — dispatch ran {elapsed_us} us past a {deadline_us} us deadline"
14595                );
14596            }
14597        }
14598    }
14599}
14600
14601// Timer-accounting clock default (issue: no_std tiers with no `clock_us`
14602// credited each spin the REQUESTED timeout — spin.rs `delta_us` fallback —
14603// so shared-session wakes made low-rate timers fire early (a 100 ms timer
14604// at ~67 ms on Zephyr native_sim) and tick rounding made fast tiers fire
14605// late. Measured on that native_sim image; mechanism documented at the
14606// fallback site.)
14607//
14608// Every platform port exports `nros_platform_clock_ns` through the same
14609// linkage contract the wake primitives rely on (`nros_platform_export_clock!`
14610// in nros-platform-cffi), so an rmw-cffi no_std build can safely default the
14611// executor clock to it; `ExecutorConfig::clock_us` stays as an override.
14612#[cfg(feature = "rmw-cffi")]
14613unsafe extern "C" {
14614    fn nros_platform_clock_ns() -> u64;
14615}
14616
14617#[cfg(feature = "rmw-cffi")]
14618fn default_platform_clock_us() -> u64 {
14619    // SAFETY: bare query of the platform's monotonic ns counter; the symbol
14620    // is guaranteed by whichever platform port linked the binary (the same
14621    // contract `nros_platform_wake_*` already depends on).
14622    //
14623    // RFC-0073 made the ABI nanoseconds; the executor's own accounting is
14624    // still microseconds (`clock_us_fn`, `delta_us`), so the division lives
14625    // here rather than being pushed into every port.
14626    unsafe { nros_platform_clock_ns() / 1_000 }
14627}
14628
14629/// Sleep, through the platform ABI.
14630///
14631/// phase-359 W10 — the spin loops used `std::thread::sleep`. Every port already
14632/// exports `nros_platform_sleep_us` (the ABI's own pacing primitive, the one an
14633/// RTOS build has always used), so a hosted loop and an embedded one now pace
14634/// the same way. The µs entry point is used rather than `_ms` because
14635/// `spin_one_period_timed` sleeps off a remainder, which rounds badly at
14636/// millisecond granularity on short periods.
14637#[cfg(feature = "alloc")]
14638pub(crate) fn platform_sleep(d: core::time::Duration) {
14639    unsafe extern "C" {
14640        fn nros_platform_sleep_us(us: usize);
14641    }
14642    let us = d.as_micros().min(usize::MAX as u128) as usize;
14643    if us == 0 {
14644        return;
14645    }
14646    // SAFETY: a bare pacing call with no pointer arguments, guaranteed by
14647    // whichever port linked the binary — the same contract the clock and wake
14648    // symbols in this file already rely on.
14649    unsafe { nros_platform_sleep_us(us) }
14650}
14651
14652/// A monotonic µs reader, or `None` when this build has no clock at all.
14653///
14654/// phase-359 W10 — one provider, chosen by what is LINKED rather than by which
14655/// flavour the crate was built in. W4 unified the executor's clock ACCESSOR and
14656/// said the provider was the last piece; this is it.
14657///
14658/// `rmw-cffi` means a platform port is linked, and every port exports
14659/// `nros_platform_clock_ns` under the same contract the wake primitives use —
14660/// so a hosted build reads the same counter an embedded one does, instead of
14661/// `Instant` on one and the platform on the other. `Instant` survives only
14662/// where there is no port to ask: `std` without `rmw-cffi`.
14663///
14664/// phase-359 W10 follow-up — this comment claimed the metadata PROBE was that
14665/// configuration, and **it is not**. The probe's generated manifest deps
14666/// `nros-platform-cffi` with `posix-c-port` (issue 0288 layer 5, so it links),
14667/// and its `nros` resolves with `rmw-cffi` ON — feature-unified from the
14668/// component's board crate. Measured on a real probe tree: `alloc default
14669/// macros rmw-cffi ros-humble std`. It takes the PORT arm.
14670///
14671/// What is real: `nros-rmw-metadata` and `nros-tests` do resolve `nros` with
14672/// `std` and no `rmw-cffi`, and have no port crate in their graphs. But nothing
14673/// in either uses this arm — deleting it and building the workspace
14674/// `--all-targets` is clean, and there is no in-tree caller of
14675/// `nros::time::now` at all. So the arm serves an OUT-OF-TREE contract only: a
14676/// consumer with `std`, no `rmw-cffi`, and its own `Session`.
14677pub(crate) fn default_clock_us_fn() -> Option<fn() -> u64> {
14678    #[cfg(feature = "rmw-cffi")]
14679    {
14680        Some(default_platform_clock_us)
14681    }
14682    #[cfg(not(feature = "rmw-cffi"))]
14683    {
14684        None
14685    }
14686}
14687
14688// =============================================================================
14689// phase-425 W3b — the `/clock` time source's executor half.
14690//
14691// Its OWN impl block, gated on `sim-time`, because the methods were first
14692// written next to `declare_parameter` — which lives in a
14693// `#[cfg(feature = "param-store")]` block, so `reconcile_ros_time_source`
14694// silently disappeared in every combo without parameter services, including the
14695// one `spin_once` calls it from.
14696// =============================================================================
14697#[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14698impl<'s> Executor<'s> {
14699    /// phase-425 W3b — bring the `/clock` subscription in line with the last
14700    /// requested `use_sim_time`.
14701    ///
14702    /// Called at the head of every spin, and cheap when there is nothing to do:
14703    /// the common case is one bool comparison. It is a RECONCILE rather than an
14704    /// action at the request site because the request routinely arrives before
14705    /// there is a node to hang the subscription on — `nros::main!` emits
14706    /// `apply_param_services` before its per-node `register` calls, by design,
14707    /// so the store exists when each cell is created.
14708    ///
14709    /// Turning it off stops SAMPLES from being installed; the subscription
14710    /// itself stays, because the executor has no entity removal and inventing
14711    /// one for this would be a much larger change than the switch is worth. The
14712    /// gate is `time_source::set_active`, which the subscription callback reads.
14713    ///
14714    /// Turning it off also does NOT clear the override: a node that stops
14715    /// listening keeps the last simulated time rather than jumping back to the
14716    /// wall clock, which every ROS-time timer would otherwise absorb as a
14717    /// backwards jump.
14718    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14719    pub(crate) fn reconcile_ros_time_source(&mut self) {
14720        // An executor nobody told about `use_sim_time` has NO opinion, and the
14721        // gate is process-global, so writing its default here is not a no-op --
14722        // it is one executor overruling another. Say nothing until asked.
14723        if !self.sim_time_stated {
14724            return;
14725        }
14726        if self.sim_time_requested == crate::time_source::is_active()
14727            && (!self.sim_time_requested || self.sim_time_source.is_some())
14728        {
14729            return;
14730        }
14731        crate::time_source::set_active(self.sim_time_requested);
14732        if !self.sim_time_requested {
14733            // phase-430 W1 — the source is no longer wanted, so its silence is
14734            // no longer a symptom. Disarming here (rather than only re-arming
14735            // on attach) is what makes a later re-attach start a FRESH
14736            // interval: the watch that was counting is put down, not paused.
14737            self.sim_time_silence.disarm();
14738            return;
14739        }
14740        if self.sim_time_source.is_none() {
14741            // No node yet — stay pending and try again next spin. Not an error:
14742            // it is the ordinary order of a generated entry, which declares
14743            // parameters before it registers components.
14744            if self.nodes.is_empty() {
14745                return;
14746            }
14747            if let Ok(handle) = self.install_ros_time_source(
14748                super::node_record::NodeId::PRIMARY,
14749                crate::time_source::CLOCK_TOPIC,
14750            ) {
14751                self.sim_time_source = Some(handle);
14752                // phase-430 W1 — the interval starts HERE, at attach, and not
14753                // at construction: before the subscription exists there is
14754                // nothing to be silent about, so counting from boot would
14755                // accuse an image that spent the interval bringing a transport
14756                // up.
14757                self.sim_time_silence.arm();
14758            }
14759        } else {
14760            // Re-attached: `use_sim_time` went false and back to true while the
14761            // subscription (which the executor cannot remove) stayed. W1 asks
14762            // for the one-shot to reset in exactly this case.
14763            self.sim_time_silence.arm();
14764        }
14765    }
14766
14767    /// phase-430 W1 — credit one spin's elapsed time to the silence watch and
14768    /// emit the diagnostic if this is the spin that crosses the threshold.
14769    ///
14770    /// `delta_us` is the executor's OWN per-spin elapsed measure — the same one
14771    /// every wall timer on this executor accumulates, including its "no clock,
14772    /// credit the requested timeout" fallback. See
14773    /// [`SILENCE_WARN_US`](crate::time_source::SILENCE_WARN_US) for why that is
14774    /// the unit and not a spin count or an independent wall clock.
14775    ///
14776    /// Scope: the source the EXECUTOR installed for `use_sim_time`. An
14777    /// application that calls
14778    /// [`Node::install_ros_time_source`](super::node::Node::install_ros_time_source)
14779    /// directly is not watched, because the line this would print names
14780    /// `use_sim_time`, and that application never set it — the honest
14781    /// diagnostic for the explicit path would be a different sentence, and
14782    /// inventing one nobody asked for is how a warning becomes noise.
14783    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14784    fn note_sim_time_silence(&mut self, delta_us: u64) {
14785        if self.sim_time_silence.tick(
14786            delta_us,
14787            nros_core::clock::Clock::is_ros_time_override_active(),
14788        ) {
14789            crate::time_source::report_silence(crate::time_source::CLOCK_TOPIC);
14790        }
14791    }
14792
14793    /// phase-430 W1 — how many times this executor has reported that
14794    /// `use_sim_time` is on with no `/clock` sample.
14795    ///
14796    /// The count rather than a bool, and an accessor rather than a log grep: a
14797    /// diagnostic whose contract is "exactly once" needs an assertion that can
14798    /// tell one from two, and a log sink is not present on every target that
14799    /// runs the test. It is also the question an application supervising its
14800    /// own configuration would ask.
14801    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14802    pub fn sim_time_silence_warnings(&self) -> u32 {
14803        self.sim_time_silence.reports()
14804    }
14805
14806    /// phase-425 W3b — re-read `use_sim_time` from the parameter store.
14807    ///
14808    /// Called after a parameter service actually handled something, which is
14809    /// what makes a runtime `ros2 param set <node> use_sim_time true` work
14810    /// without a per-spin scan of the store.
14811    ///
14812    /// phase-430 W3 / issue 1202 — the declaration path calls it too, through
14813    /// `note_reserved_parameter`, once the store has accepted the write. It
14814    /// used to record the value directly from the caller's argument, which is
14815    /// the same thing only while the store never refuses. One read for both
14816    /// paths; a second spelling is the drift this function exists to prevent.
14817    /// `pub(crate)` for the same reason: a test that changes the store directly
14818    /// needs the entry point a parameter service uses, not a copy of it.
14819    #[cfg(all(feature = "sim-time", feature = "param-store", any(has_rmw, test)))]
14820    pub(crate) fn refresh_use_sim_time_from_store(&mut self) {
14821        // phase-426 W1 — the store is keyed by NODE, and the simulated clock is
14822        // not. This function is where those two facts meet, so the rule is
14823        // stated here rather than left to whichever call site got there first.
14824        //
14825        // `use_sim_time` is a per-node parameter in ROS 2, and rclcpp means it:
14826        // every `Node` builds its own `TimeSource` over its own `Clock`, so two
14827        // nodes composed into one process CAN be configured differently and
14828        // stamp messages from different clocks at the same instant. This tree
14829        // deliberately does not follow that (phase-425): "the override is
14830        // process-global — one simulated clock per image, the model
14831        // `nros_core::Clock` already documents — so there is nothing to attach
14832        // to and nothing to fan out." That is the right trade for an image with
14833        // a fixed RAM budget, and it makes "whose `use_sim_time`?" a question
14834        // rclcpp never has to answer and this executor now does.
14835        //
14836        // ANY node asking arms the one source. Two reasons, and the second is
14837        // the binding one:
14838        //
14839        //   * it is the only rule the CALLERS can implement. `note_param_
14840        //     services_ran` fires after a runtime `ros2 param set` and knows
14841        //     only that N requests were handled — not which node was written.
14842        //     A last-writer-wins rule would need a writer identity that does
14843        //     not reach here, so it would silently degrade to "whatever node 0
14844        //     says", which is the default-to-primary bug phase-426 W1 exists to
14845        //     remove.
14846        //   * one node still wanting simulated time is a reason to keep the
14847        //     source attached, and detaching is the destructive direction:
14848        //     phase-425 already records that turning it off "stops SAMPLES, not
14849        //     the subscription", precisely so a mid-run change does not read as
14850        //     a clock jump.
14851        //
14852        // The scan is over the executor's own node table, which is the same
14853        // `u8` index space `NodeKey` wraps, so there is one node identity here
14854        // and not two.
14855        let Some(params) = self.params.as_deref() else {
14856            return;
14857        };
14858        let mut stated = false;
14859        let mut enable = false;
14860        // A `NodeId` IS the index into this table (`NodeId::index`), so the
14861        // enumeration is the key space — no second derivation of the number.
14862        //
14863        // `.max(1)` because `NodeId::PRIMARY` is IMPLICIT: the executor's node
14864        // table starts empty and a parameter can be declared on the primary
14865        // node before any `create_node` puts a record in it. Scanning only the
14866        // table would then find nothing and leave `sim_time_stated` false, so a
14867        // `use_sim_time` declared before the first node would never arm the
14868        // source — which `use_sim_time_attaches_and_detaches_the_clock_source`
14869        // catches, since its whole first half runs while "there is still no
14870        // node". Slot 0 is always a legitimate key; at worst the lookup misses.
14871        for idx in 0..self.nodes.len().max(1) {
14872            let key = nros_params::NodeKey::new(idx as u8);
14873            if let Some(v) = params
14874                .server
14875                .get_bool(key, crate::time_source::USE_SIM_TIME_PARAM)
14876            {
14877                stated = true;
14878                enable |= v;
14879            }
14880        }
14881        if stated {
14882            self.sim_time_requested = enable;
14883            self.sim_time_stated = true;
14884        }
14885    }
14886
14887    /// phase-425 W3b — the installed `/clock` subscription, if any.
14888    ///
14889    /// The HANDLE rather than a bool, because "did we subscribe twice" is the
14890    /// question a reconciliation loop has to be able to answer: a second
14891    /// registration takes a new slot, so a stable handle across spins is the
14892    /// evidence that the loop is idempotent.
14893    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14894    pub fn ros_time_source_handle(&self) -> Option<HandleId> {
14895        self.sim_time_source
14896    }
14897
14898    /// phase-425 W3b — whether a `/clock` subscription is currently installed.
14899    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14900    pub fn ros_time_source_installed(&self) -> bool {
14901        self.sim_time_source.is_some()
14902    }
14903
14904    /// phase-425 W3 — subscribe `topic` and install every sample as this
14905    /// image's ROS time. The registration behind
14906    /// [`Node::install_ros_time_source`](super::node::Node::install_ros_time_source)
14907    /// and behind the `use_sim_time` reconciliation, so both spell the QoS and
14908    /// the conversion exactly once.
14909    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14910    pub(crate) fn install_ros_time_source(
14911        &mut self,
14912        node_id: super::node_record::NodeId,
14913        topic: &str,
14914    ) -> Result<HandleId, NodeError> {
14915        self.register_subscription_buffered_on::<
14916            nros_rosgraph_msgs::msg::Clock,
14917            _,
14918            { crate::config::DEFAULT_RX_BUF_SIZE },
14919        >(
14920            node_id,
14921            topic,
14922            QoSProfile::clock_default(),
14923            |msg: &nros_rosgraph_msgs::msg::Clock| {
14924                // The `use_sim_time` gate is read HERE rather than by removing
14925                // the subscription, because there is no entity removal. An
14926                // image that never touches `use_sim_time` and installs the
14927                // source explicitly is active by default.
14928                if !crate::time_source::is_active() {
14929                    return;
14930                }
14931                if let Some(nanos) =
14932                    crate::time_source::override_nanos(msg.clock.sec, msg.clock.nanosec)
14933                {
14934                    nros_core::clock::Clock::set_ros_time_override(nanos);
14935                }
14936            },
14937            None, // no group — node default
14938            None, // the configured default RX buffer, unchanged
14939        )
14940    }
14941}
14942
14943// ============================================================================
14944// phase-417 W4.c — cancel / is_spinning
14945// ============================================================================
14946//
14947// Same gate as `executor::tests`, exactly: `ConcreteSession` is `MockSession`
14948// only while no rmw-* feature is unified in (see `executor/mod.rs`), and
14949// `from_session_with` / `halt_flag` are `alloc`. No `std` gate — `cfg(test)`
14950// already means a hosted harness, which is why `lib.rs` brings `std` in under
14951// `test` as well. (It said "exactly" while missing `alloc`, and `std` was not in
14952// scope: 20 errors, hidden by feature unification under `--workspace`.)
14953#[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
14954mod cancel_tests {
14955    use super::Executor;
14956    use crate::{executor::types::ExecutorConfig, mock::MockSession};
14957
14958    /// The core has no default clock and says so (issue 0709), so a test that
14959    /// measures elapsed time brings its own — the same seam a board uses.
14960    fn test_clock_us() -> u64 {
14961        use std::{sync::OnceLock, time::Instant};
14962        static EPOCH: OnceLock<Instant> = OnceLock::new();
14963        EPOCH.get_or_init(Instant::now).elapsed().as_micros() as u64
14964    }
14965
14966    fn executor() -> Executor<'static> {
14967        let cfg = ExecutorConfig::default().clock_us(test_clock_us);
14968        Executor::from_session_with(MockSession::new(), &cfg)
14969    }
14970
14971    /// A fresh executor is neither cancelled nor spinning — the two questions
14972    /// are independent, and this is the state where both answers are `false`.
14973    #[test]
14974    fn a_fresh_executor_is_neither_cancelled_nor_spinning() {
14975        let exec = executor();
14976        assert!(!exec.is_halted(), "nothing has asked it to stop");
14977        assert!(!exec.is_spinning(), "no spin loop is running");
14978    }
14979
14980    /// `cancel()` raises the request bit without starting or stopping anything.
14981    #[test]
14982    fn cancel_requests_without_spinning() {
14983        let exec = executor();
14984        exec.cancel();
14985        assert!(exec.is_halted(), "cancel() must be observable immediately");
14986        assert!(
14987            !exec.is_spinning(),
14988            "cancel() must not make a non-spinning executor claim to spin"
14989        );
14990    }
14991
14992    /// The load-bearing one: `cancel()` from a peer thread TERMINATES a running
14993    /// `spin`, `is_spinning()` is true while the loop runs, and false
14994    /// once it has returned.
14995    ///
14996    /// The peer asserts `is_spinning()` from OUTSIDE the spinning thread, which
14997    /// is the whole point of the observable — it is what a signal handler or a
14998    /// supervisor sees.
14999    #[test]
15000    fn cancel_terminates_a_spin_and_is_spinning_tracks_it() {
15001        use std::{
15002            sync::{
15003                Arc,
15004                atomic::{AtomicBool, Ordering},
15005            },
15006            thread,
15007            time::{Duration, Instant},
15008        };
15009
15010        let mut exec = executor();
15011        assert!(!exec.is_spinning());
15012
15013        // `Executor` is not `Sync`, so the peer thread cannot hold a reference
15014        // to it. It observes and drives through the two flag handles instead —
15015        // which is the supported cross-thread surface (`halt_flag()`), and
15016        // keeps this test on the same seam a signal handler uses.
15017        let cancel_bit = exec.halt_flag();
15018        let saw_spinning = Arc::new(AtomicBool::new(false));
15019        let saw_spinning_peer = Arc::clone(&saw_spinning);
15020
15021        let peer = thread::spawn(move || {
15022            // Give the loop time to enter. Bounded, and the assertion below is
15023            // on what was OBSERVED, so a slow host cannot turn this green by
15024            // accident — it fails instead.
15025            thread::sleep(Duration::from_millis(50));
15026            saw_spinning_peer.store(true, Ordering::SeqCst);
15027            cancel_bit.store(true, Ordering::SeqCst);
15028        });
15029
15030        let started = Instant::now();
15031        exec.spin(crate::executor::types::SpinOptions::default())
15032            .expect("an untimed spin runs until cancelled");
15033        let elapsed = started.elapsed();
15034
15035        peer.join().expect("peer thread must not panic");
15036
15037        assert!(
15038            saw_spinning.load(Ordering::SeqCst),
15039            "the peer must have run before spin returned"
15040        );
15041        assert!(
15042            !exec.is_spinning(),
15043            "is_spinning() must be false once the loop has returned"
15044        );
15045        assert!(
15046            exec.is_halted(),
15047            "the cancel request survives the loop it ended"
15048        );
15049        // LOWER bound too: a spin that never waited would prove nothing about
15050        // termination. The peer sleeps 50 ms before cancelling.
15051        assert!(
15052            elapsed >= Duration::from_millis(40),
15053            "spin returned in {elapsed:?} — it cannot have spun"
15054        );
15055        // ...and an UPPER bound, because "terminated" is the claim: without the
15056        // cancel check this loop never returns and the test would hang rather
15057        // than fail. 10 s is far past any scheduling noise.
15058        assert!(
15059            elapsed < Duration::from_secs(10),
15060            "spin took {elapsed:?} to observe the cancel"
15061        );
15062    }
15063
15064    /// `is_spinning()` is true from INSIDE the loop. Read from a timer callback,
15065    /// which is the only place the executor is genuinely mid-spin, and asserted
15066    /// after the loop so a callback that never fired cannot pass silently.
15067    #[test]
15068    fn is_spinning_is_true_inside_the_loop() {
15069        use std::time::Duration;
15070
15071        let mut exec = executor();
15072        // `only_next` runs exactly one iteration, so this is a single poll
15073        // inside a spin scope — bounded, no thread, no clock dependency beyond
15074        // the one the fixture installs.
15075        assert!(!exec.is_spinning());
15076        exec.spin(crate::executor::types::SpinOptions::spin_once())
15077            .expect("a single-iteration spin must succeed");
15078        assert!(
15079            !exec.is_spinning(),
15080            "the scope must close when the loop returns"
15081        );
15082
15083        // The inside-the-loop reading needs a second thread to do it, because
15084        // the spinning thread is blocked in the loop and `Executor` is `!Sync`
15085        // so no `&Executor` may cross. What crosses is a pointer to the one
15086        // atomic, which is the same thing the C and C++ APIs hold: they observe
15087        // `is_spinning` through a raw executor handle, and that is the case this
15088        // flag exists to serve.
15089        struct FlagPtr(*const portable_atomic::AtomicBool);
15090        // SAFETY: the pointee is an `AtomicBool`, which is `Sync`; the only
15091        // operation performed through it is a relaxed-or-stronger load. The
15092        // executor outlives the thread — `peer.join()` below happens before
15093        // `exec` is dropped at the end of the test.
15094        unsafe impl Send for FlagPtr {}
15095
15096        let cancel_bit = exec.halt_flag();
15097        let observed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
15098        let spinning_bit_seen = std::sync::Arc::clone(&observed);
15099        let flag = FlagPtr(&exec.spinning as *const portable_atomic::AtomicBool);
15100        let peer = std::thread::spawn(move || {
15101            let flag = flag;
15102            for _ in 0..500 {
15103                // SAFETY: see the `unsafe impl Send` above.
15104                if unsafe { &*flag.0 }.load(core::sync::atomic::Ordering::SeqCst) {
15105                    spinning_bit_seen.store(true, std::sync::atomic::Ordering::SeqCst);
15106                    break;
15107                }
15108                std::thread::sleep(Duration::from_millis(2));
15109            }
15110            cancel_bit.store(true, std::sync::atomic::Ordering::SeqCst);
15111        });
15112
15113        exec.spin(crate::executor::types::SpinOptions::default())
15114            .expect("an untimed spin runs until cancelled");
15115        peer.join().expect("peer thread must not panic");
15116
15117        assert!(
15118            observed.load(std::sync::atomic::Ordering::SeqCst),
15119            "is_spinning() must read true while spin is running"
15120        );
15121    }
15122
15123    /// `spin_period` maintains the same scope, and a period this build cannot
15124    /// pace is an error that must NOT leave the executor claiming to spin.
15125    #[test]
15126    fn spin_period_with_no_clock_leaves_is_spinning_false() {
15127        let mut exec = executor();
15128        exec.clock_us_fn = None;
15129        let err = exec
15130            .spin_period(core::time::Duration::from_millis(10))
15131            .expect_err("a period with no clock must fail (issue 0709)");
15132        assert_eq!(err, crate::NodeError::NotInitialized);
15133        assert!(
15134            !exec.is_spinning(),
15135            "a spin that never started must not report that it did"
15136        );
15137    }
15138
15139    /// Entering a spin CLEARS a stale cancel: a cancel requested before this
15140    /// spin is not a cancel of this spin. Documents the ADOPT-BOUNDED envelope's
15141    /// one sharp edge.
15142    #[test]
15143    fn entering_a_spin_clears_a_stale_cancel() {
15144        let exec = executor();
15145        exec.cancel();
15146        assert!(exec.is_halted());
15147        exec.enter_spin_loop();
15148        assert!(
15149            !exec.is_halted(),
15150            "a cancel raised before the loop started must not end it instantly"
15151        );
15152        assert!(exec.is_spinning());
15153        exec.exit_spin_loop();
15154        assert!(!exec.is_spinning());
15155    }
15156}