Skip to main content

nros_node/executor/
spin.rs

1//! Executor struct and core spin methods.
2
3use core::{marker::PhantomData, mem::MaybeUninit};
4#[cfg(feature = "param-store")]
5use nros_params::ParameterServerOps as _; // phase-515 R10 stage 2
6
7use nros_core::{RosMessage, RosService, ViewableMessage};
8use nros_rmw::{QoSProfile, ServiceInfo, Session, TopicInfo, TransportError};
9
10use crate::{
11    session,
12    timer::{TimerCallInfo, TimerDuration},
13};
14
15#[cfg(feature = "safety-e2e")]
16use super::arena::{
17    SubSafetyEntry, sub_safety_has_data, sub_safety_pre_sample, sub_safety_try_process,
18};
19#[cfg(feature = "rmw-cffi")]
20use super::types::ExecutorConfig;
21use super::types::{ENTRY_IDLE_BUDGET, ENTRY_POLL_BUDGET};
22// phase-427 — unconditional: `spin_forever` takes `SpinOptions` and is not
23// `alloc`-gated (a bare-metal entry is exactly the caller that needs it).
24use super::{
25    arena::{
26        BufferStrategy, CallbackMeta, EntryKind, GuardConditionEntry, ServiceClientCallbackEntry,
27        ServiceClientRawArenaEntry, ServiceClientSendHeader, SrvEntry, SrvRawEntry,
28        SubBufferedEntry, SubBufferedRawCEntry, SubBufferedRawEntry, SubBufferedRawInfoCEntry,
29        SubBufferedRawInfoEntry, SubBufferedRawMsgInfoEntry, SubBufferedTypedCEntry,
30        SubBufferedViewEntry, SubInfoEntry, SubInplaceEntry, TimerClockSource, TimerEntry,
31        TimerHeader, TimerOverrunPolicy, TraceName, always_ready, buffered_region_size, drop_entry,
32        guard_has_data, guard_try_process, no_pre_sample, service_client_callback_try_process,
33        service_client_raw_try_process, srv_has_data, srv_raw_has_data, srv_raw_try_process,
34        srv_try_process, srv_try_process_with_info, sub_buffered_has_data,
35        sub_buffered_raw_c_has_data, sub_buffered_raw_c_try_process, sub_buffered_raw_has_data,
36        sub_buffered_raw_info_c_has_data, sub_buffered_raw_info_c_try_process,
37        sub_buffered_raw_info_has_data, sub_buffered_raw_info_try_process,
38        sub_buffered_raw_msg_info_has_data, sub_buffered_raw_msg_info_try_process,
39        sub_buffered_raw_try_process, sub_buffered_try_process, sub_buffered_typed_c_has_data,
40        sub_buffered_typed_c_try_process, sub_buffered_view_has_data,
41        sub_buffered_view_try_process, sub_info_has_data, sub_info_pre_sample,
42        sub_info_try_process, sub_inplace_has_data, sub_inplace_try_process, timer_try_process,
43    },
44    node::NodeHandle,
45    spsc_ring::SpscRing,
46    triple_buffer::TripleBuffer,
47    types::{
48        ExecutorSemantics, GuardCondition, HandleId, InvocationMode, NodeError,
49        RawMessageDeserializeFn, RawResponseCallback, RawServiceCallback,
50        RawServiceRequestIdCallback, RawSubscriptionCallback, RawSubscriptionInfoCallback,
51        ReadinessSnapshot, SpinOnceResult, SpinOptions, SpinPeriodPollingResult, Trigger,
52        TypedSubscriptionCallback,
53    },
54};
55
56// ============================================================================
57// phase-456 W8 — one subscription registration, stated as one value
58// ============================================================================
59
60/// What a subscription registration STATES — phase-456 W8.
61///
62/// The thirteen `register_subscription_*` / `add_arena_subscription_*` entry
63/// points differ in their DELIVERY SHAPE (typed, raw, borrowed, with-info,
64/// validated) and used to differ in their LANGUAGE, and each re-derived the
65/// same prologue from positional parameters: node identity, topic info, the
66/// declared-depth check, the backend call. Twelve copies of it, and exactly
67/// ONE of them asked the backend whether it dispatches in place.
68///
69/// This is that prologue's argument, as one value. A struct rather than nine
70/// parameters because the fields are independently optional and a caller must
71/// be able to say "I state nothing here" without reordering a call — the same
72/// reason `DescriptorInputs` is a struct one layer up.
73///
74/// Two of its fields are the axes phase-456 W8 found under issue 1319's five
75/// rows: [`backend_hint`](Self::backend_hint) is "does this site know the
76/// type's bound", and [`in_place_capable`](Self::in_place_capable) is what
77/// lets `Executor::open_subscription` ask the third axis once, for every
78/// caller, in every language.
79pub(crate) struct SubscriptionRequest<'a> {
80    /// `None` is the legacy single-node path: identity comes from the
81    /// executor's own name and namespace and the session is slot 0.
82    pub(crate) node_id: Option<super::node_record::NodeId>,
83    pub(crate) topic_name: &'a str,
84    pub(crate) type_name: &'a str,
85    pub(crate) type_hash: &'a str,
86    pub(crate) qos: QoSProfile,
87    /// Bytes the CALL SITE says it expects to receive, for the BACKEND's
88    /// payload size class (phase-231 / RFC-0038, phase-402 W2).
89    ///
90    /// `None` — and, for a C caller whose ABI spells it `0`, a zero — means
91    /// "this site stated nothing", which is a different claim from "zero
92    /// bytes": `with_rx_buffer_hint(0)` would be the second.
93    pub(crate) backend_hint: Option<usize>,
94    /// Bytes ONE receive slot claims from the arena, already derived by the
95    /// caller that has the type.
96    ///
97    /// Not the same number as [`Self::backend_hint`] — the typed Rust path
98    /// hints the type's bound and sizes the slot from
99    /// `default_subscription_rx_bytes`, which may differ — so both travel.
100    /// Ignored entirely when [`SubscriptionOpen::in_place`] comes back true.
101    pub(crate) slot_bytes: usize,
102    /// Can THIS delivery shape dispatch out of the backend's own receive slot?
103    ///
104    /// A property of the shape, not of the backend: `process_raw_in_place`
105    /// hands the callback borrowed BYTES and nothing else, so a shape that
106    /// must also deliver a wire attachment (`_info`), an integrity status
107    /// (`_validated` / `_safety`) or a `MessageInfo` cannot use it however
108    /// capable the backend is. Stated per call site so an omission is a line
109    /// someone wrote rather than a question nobody asked — which is exactly
110    /// what the C path was before W8.
111    pub(crate) in_place_capable: bool,
112}
113
114/// The result of `Executor::open_subscription`: a claimed slot, a live backend
115/// subscriber, and the ONE answer to the in-place question.
116pub(crate) struct SubscriptionOpen {
117    /// The claimed entry slot. Claimed BEFORE the fallible work, per
118    /// `Executor::emplace_entry`'s rule.
119    pub(crate) slot: usize,
120    pub(crate) handle: session::RmwSubscriber,
121    /// **The only place in this crate that
122    /// `Subscription::supports_process_in_place` is read.**
123    ///
124    /// Before phase-456 W8 the read lived inside
125    /// `register_subscription_buffered_on`, which is the Rust typed path and
126    /// only that: every C and C++ subscription on zenoh or XRCE therefore
127    /// allocated a receive region the backend does not need, at the same order
128    /// as issue 1340's ~9.7 KiB per subscription. The irony phase-456 W8
129    /// measured is that the C callback is the BETTER candidate —
130    /// `RawSubscriptionCallback` is already `(const uint8_t*, size_t, void*)`,
131    /// a borrowed-bytes signature, where the Rust typed path has to produce an
132    /// owned `&M` from somewhere.
133    pub(crate) in_place: bool,
134    /// [`SubscriptionRequest::slot_bytes`], carried through so a caller's arena
135    /// arithmetic reads ONE local rather than re-deriving it.
136    pub(crate) slot_bytes: usize,
137    /// The QoS the backend was actually given — phase-454 W13 through
138    /// phase-456 W8.
139    ///
140    /// NOT the caller's `req.qos`. `declared_qos::honour` REWRITES the depth
141    /// when the system declared one, and the twelve prologues this function
142    /// replaced each passed that rewritten value on to the entry they built.
143    /// Returning it is what keeps that true with one honouring site: a caller
144    /// that stored `req.qos` instead would size its entry from a depth the
145    /// backend never saw, and nothing would fail — the subscription would
146    /// simply hold a different number of samples than the build reserved for
147    /// it.
148    pub(crate) qos: QoSProfile,
149}
150
151// ============================================================================
152// Phase 8 — callback registration event (paired stubs)
153// ============================================================================
154//
155// `docs/design/callback_tracing.rst`. Paired stubs (the `entry_tiers.rs`
156// idiom) so `emplace_entry` reads identically whether or not the feature is
157// on, and the `#[cfg]` lives in exactly one place.
158
159/// Emit nros_callback_register(handle, kind, name) for a newly installed
160/// executor entry.
161#[cfg(feature = "trace-callbacks")]
162#[inline]
163fn trace_register(slot: usize, kind: EntryKind, name: TraceName<'_>) {
164    super::callback_trace::register(slot, kind, name);
165}
166
167#[cfg(not(feature = "trace-callbacks"))]
168#[inline]
169fn trace_register(_slot: usize, _kind: EntryKind, _name: TraceName<'_>) {}
170
171// ============================================================================
172// Executor::open() factory method
173// ============================================================================
174
175/// phase-271 / phase-392 W6 — the `'static` `u64` backing the `alloc`
176/// convenience constructors borrow, sized to `sizing`.
177///
178/// **The named static first, the heap second.** phase-271 made this a
179/// `Box::leak`, and a leak has no symbol: `mem-report` reads an ELF's symbol
180/// table, so the largest single RAM consumer of every Rust image was the one
181/// thing the memory instrument could not see (RFC-0002 § 4.4b). It is served
182/// from `executor::backing::EXECUTOR_BACKING` now — same bytes, same
183/// executor-lifetime reservation, in `.bss` where the linker can print them and
184/// out of the allocator arena that used to pay for them.
185///
186/// The heap arm is still reached, and both cases are legitimate rather than
187/// degraded: a SECOND executor opened through an `alloc` convenience
188/// constructor, and an entry sized past the build-time default. A tiered boot's
189/// spawned tiers are NOT that second executor any more: they open over the
190/// entry's `TierExecutorBacking` slots (`open_with_session_slot`, issue 1571). Neither is an error, so neither
191/// warns — `report_arena_headroom` is where an over-provisioned executor gets
192/// told about itself, and a duplicate line here would just be noise.
193///
194/// `alloc`-only — no_std-no-alloc entries supply their own `static`/stack
195/// backing via `open_in` / `from_session_in`.
196#[cfg(feature = "alloc")]
197fn default_backing(sizing: super::storage::ExecutorSizing) -> &'static mut [MaybeUninit<u64>] {
198    let words = sizing.u64_len();
199    match super::backing::take(words) {
200        Some(backing) => backing,
201        None => alloc::boxed::Box::leak(alloc::boxed::Box::new_uninit_slice(words)),
202    }
203}
204
205#[cfg(feature = "rmw-cffi")]
206impl<'s> Executor<'s> {
207    /// phase-271 — open a new executor session over caller-supplied `backing`,
208    /// sized by `sizing` (per-entry sizing). The core, non-generic sized entry
209    /// point: the `alloc` [`open`](Self::open) convenience leaks a default
210    /// backing and delegates here, and the `nros::main!` macro emits a backing
211    /// sized to the entry's own entity count.
212    ///
213    /// Phase 115.M.4 — auto-registers the cffi vtable for whichever
214    /// backend the build was configured for, mirroring the C++ side's
215    /// `#ifdef NROS_RMW_<NAME>` fan-out in `<rclcpp/node.hpp>`. The
216    /// runtime's atomic vtable slot is idempotent: a re-call of any
217    /// backend's `register()` is a no-op, so the fan-out below is safe
218    /// to invoke on every `Executor::open` (cheaper than a `Once` and
219    /// doesn't pull in `std::sync` for no_std targets).
220    ///
221    /// Connects to the middleware at the locator specified in `config`.
222    ///
223    /// # Safety
224    /// `backing` must be ≥ `sizing.u64_len()` words, live for `'s`, and be
225    /// otherwise untouched while the executor lives (see
226    /// [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in)).
227    pub unsafe fn open_in(
228        config: &ExecutorConfig<'_>,
229        backing: &'s mut [MaybeUninit<u64>],
230        sizing: super::storage::ExecutorSizing,
231    ) -> Result<Self, NodeError> {
232        use nros_rmw::Rmw;
233
234        // Phase 128.A.3 / 249 P4b.1 — manifest-driven backend selection.
235        //
236        // Every linked backend self-registered via its `.init_array`
237        // ctor before `main` (RFC-0042 §D3.3), so the registry is
238        // already populated — no runtime section walk.
239        //
240        // 1. Honour `config.rmw` — the caller's explicit backend selection,
241        //    mirroring ROS 2's `RMW_IMPLEMENTATION`. On a hosted build
242        //    `nros::ExecutorConfigEnvExt::from_env` / `nros::env::resolve_hosted`
243        //    fill it from `$NROS_RMW`; issue 0687 moved that read to the edge,
244        //    because reading a process environment is what kept `std` in this
245        //    crate and an RTOS image has no environment to read.
246        // 2. With no selector, pick the unique registered backend.
247        //    Zero registered → `NoBackend`; more than one →
248        //    `Ambiguous` (user must select one, or use
249        //    `Executor::open_multi`).
250        let selector = config.rmw;
251        match nros_rmw_cffi::resolve_backend(selector.map(str::as_bytes)) {
252            nros_rmw_cffi::BackendResolution::Single(_) => {}
253            // Issue 0436 — these are SELECTION outcomes, not transport failures,
254            // and calling them `ConnectionFailed` actively misleads: a PX4 bridge
255            // that registered two backends reported "connection failed", which
256            // reads as a router/network problem and was chased as one. Nothing is
257            // connected at this point — the resolver has not chosen a backend yet.
258            //
259            // The variant still collapses into `NodeError::Transport` (the enum has
260            // no selection arm, and adding one is an ABI change across the C/C++
261            // seams), but `InvalidConfig` at least says "your configuration is
262            // unresolvable", and the std-gated line below names WHICH outcome.
263            other => {
264                {
265                    let why: &str = match other {
266                        nros_rmw_cffi::BackendResolution::NoBackend => {
267                            "no RMW backend is registered"
268                        }
269                        nros_rmw_cffi::BackendResolution::Ambiguous => {
270                            "more than one RMW backend is registered and no \
271                             $NROS_RMW selector was set — name one (e.g. \
272                             NROS_RMW=uorb), or open per-backend sessions"
273                        }
274                        nros_rmw_cffi::BackendResolution::Unknown => {
275                            "$NROS_RMW names a backend that is not registered"
276                        }
277                        nros_rmw_cffi::BackendResolution::Single(_) => unreachable!(),
278                    };
279                    nros_log::log_error!(
280                        nros_log::get_logger("nros"),
281                        "cannot select an RMW backend — {why}"
282                    );
283                }
284                let _ = other;
285                return Err(NodeError::Transport(TransportError::InvalidConfig));
286            }
287        }
288
289        // RFC-0109 D8 — the image's baked session properties (its bringup's
290        // own `rmw/<backend>` file, lowered at build time).
291        let session = super::session_props::with_session_properties(&[], |properties| {
292            let rmw_config = nros_rmw::RmwConfig {
293                locator: config.locator,
294                mode: config.mode,
295                domain_id: config.domain_id,
296                node_name: config.node_name,
297                namespace: config.namespace,
298                properties,
299            };
300            if let Some(name) = selector {
301                // Selector path: route to the specific named backend so
302                // the env-var-disambiguated outcome matches what the
303                // resolver above identified.
304                nros_rmw_cffi::CffiRmw::open_with_rmw(name, &rmw_config)
305            } else {
306                nros_rmw_cffi::CffiRmw.open(&rmw_config)
307            }
308        })
309        .map_err(|e| {
310            // Issue 0465 — do not relabel. This used to discard the backend's
311            // error and report `ConnectionFailed` for every open failure, so an
312            // exhausted session pool (`InvalidConfig`) and a router that is not
313            // there produced the same sentence. Same lesson as the selection
314            // arm above: say which failure happened.
315            nros_log::log_error!(
316                nros_log::get_logger("nros"),
317                "RMW session open failed — {e:?}"
318            );
319            NodeError::Transport(e)
320        })?;
321        // SAFETY: forwarded from this fn's contract — `backing`/`sizing` sized
322        // + alive for `'s`.
323        let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
324        {
325            // `config.clock_us` overrides the constructor's platform default
326            // (issue: assigning `None` here clobbered it and re-enabled the
327            // credit-the-requested-timeout fallback).
328            if let Some(clock) = config.clock_us {
329                executor.clock_us_fn = Some(clock);
330                executor.last_spin_end_us = Some(clock());
331            }
332            // issue 0671 — the SAME rule as `clock_us` directly above, which is
333            // why that one is guarded: a config that does not SPECIFY an epoch
334            // must not be read as "this target HAS no epoch". Assigning `None`
335            // here clobbered the constructor's platform default
336            // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
337            // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
338            // so EVERY hosted node built that way silently lost its wall clock.
339            // With no epoch, `Node::subscription` never attaches the age cell
340            // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
341            // `max_age_ms` contract becomes a silently-dead monitor — the exact
342            // outcome RFC-0052 says must never happen. The rate monitor rides
343            // the GUARDED `clock_us_fn` and kept working, which is why only the
344            // age half went quiet.
345            if let Some(epoch) = config.epoch_us {
346                executor.epoch_us_fn = Some(epoch);
347            }
348        }
349        executor.set_node_identity(config.node_name, config.namespace);
350        // Issue 0656 — beside the identity, for the same reason: an entity needs
351        // both to be addressable, and this half used to be dropped here.
352        executor.domain_id = config.domain_id;
353        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
354        executor.install_wake_signal_on_primary();
355        // Phase 277 W2.c — readiness marker for E2E harnesses. This is the
356        // single call-through `open()`/`open_sized()` share, so it fires on
357        // every platform that reaches here (native, freertos, zephyr,
358        // threadx, …) regardless of which RMW backend or board owns the
359        // boot path. It replaces the per-example synthetic
360        // `log::info!("Publishing messages")` markers W4 removes — those
361        // only proved a callback had fired at least once; this line proves
362        // the session itself is up, before any node/callback exists.
363        //
364        // STABILITY CONTRACT: the leading `"nros: session open"` text is
365        // load-bearing — test harnesses grep for it verbatim. Keep it
366        // stable even if the trailing `(rmw=...)` detail changes.
367        #[cfg(feature = "log")]
368        {
369            if let Some(name) = selector {
370                log::info!("nros: session open (rmw={name})");
371            } else {
372                log::info!("nros: session open");
373            }
374        }
375        Ok(executor)
376    }
377}
378
379#[cfg(all(feature = "rmw-cffi", feature = "alloc"))]
380impl Executor<'static> {
381    /// Open a new executor session using the active RMW backend, at the
382    /// build-time default sizing. Convenience over
383    /// [`open_in`](Self::open_in): leaks a default-sized backing (executor-
384    /// lifetime) so existing callers keep the zero-storage-arg signature.
385    /// Per-entry sizing goes through `open_in` / the `nros::main!` macro.
386    ///
387    /// # Example
388    ///
389    /// ```ignore
390    /// let config = ExecutorConfig::from_env().node_name("my_node");
391    /// let mut executor = Executor::open(&config)?;
392    /// ```
393    pub fn open(config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
394        Self::open_sized(config, super::storage::ExecutorSizing::DEFAULT)
395    }
396
397    /// phase-271 — like [`open`](Self::open) but sized to a caller-supplied
398    /// `sizing` (its own declared topology) instead of the build-time default.
399    /// The `alloc` entry point the `nros::main!` macro's native board path uses
400    /// to size a fat entry (>default `MAX_CBS` callbacks) without a
401    /// workspace-global `NROS_EXECUTOR_MAX_CBS`. Leaks a `sizing`-sized backing
402    /// (executor-lifetime); no-alloc entries use `open_in` with their own static.
403    pub fn open_sized(
404        config: &ExecutorConfig<'_>,
405        sizing: super::storage::ExecutorSizing,
406    ) -> Result<Self, NodeError> {
407        // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`,
408        // uniquely owned by the returned executor.
409        unsafe { Self::open_in(config, default_backing(sizing), sizing) }
410    }
411
412    /// Phase 128.F.1 — explicit per-backend session declaration for
413    /// bridge mode. `specs[0]` becomes the primary session; `specs[1..]`
414    /// open as extras keyed by RMW name. After construction, every
415    /// `create_node_on(name, rmw)` call dispatches to whichever
416    /// session was opened under that RMW name (or, when the rmw name
417    /// matches the primary, the primary session itself).
418    ///
419    /// Single-backend callers should keep using
420    /// [`open`](Self::open) — this entry costs an extra
421    /// `open_with_rmw` per spec and adds no value when only one
422    /// backend is linked.
423    ///
424    /// `$NROS_RMW` env is ignored: bridge mode wants explicit names.
425    ///
426    /// Default-sized `alloc` convenience over
427    /// [`open_multi_in`](crate::executor::ffi::ExecutorFfi::open_multi_in) (leaks a default backing).
428    #[cfg(feature = "rmw-cffi")]
429    pub fn open_multi(specs: &[SessionSpec<'_>]) -> Result<Self, NodeError> {
430        let sizing = super::storage::ExecutorSizing::DEFAULT;
431        // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`.
432        unsafe { Self::open_multi_in(specs, default_backing(sizing), sizing) }
433    }
434
435    /// Phase 104.C.1 — open the Executor against a specific RMW
436    /// backend by name. Selects from the named registry (Phase
437    /// 104.B.2). `rmw_name` must match one of the names a backend
438    /// registered under (`"zenoh"`, `"cyclonedds"`, `"xrce"`, …).
439    ///
440    /// Equivalent to [`Executor::open`] when the registry has exactly
441    /// one backend (the default-backend fast path). Use this entry
442    /// point in multi-backend builds where `Executor::open` would
443    /// pick the first-registered slot.
444    ///
445    /// Single-Executor multi-Node multi-RMW (the long-term Design X
446    /// from `docs/roadmap/phase-104-multi-backend-bridges.md`) is
447    /// follow-up work — Phase 104.C.2 + C.3.
448    ///
449    /// Default-sized `alloc` convenience over
450    /// [`open_with_rmw_in`](Self::open_with_rmw_in) (leaks a default backing).
451    #[cfg(feature = "rmw-cffi")]
452    pub fn open_with_rmw(rmw_name: &str, config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
453        let sizing = super::storage::ExecutorSizing::DEFAULT;
454        // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`.
455        unsafe { Self::open_with_rmw_in(rmw_name, config, default_backing(sizing), sizing) }
456    }
457}
458
459// phase-271 — no-alloc sized cores for the bridge/named open paths. In
460// `impl<'s>` (not the `'static` alloc block) so they stay available in
461// `rmw-cffi`-without-`alloc` builds (e.g. the `nros-bridge` no_std default),
462// which is where `open_multi`/`open_with_rmw` lived before.
463#[cfg(feature = "rmw-cffi")]
464impl<'s> Executor<'s> {
465    /// Per-entry-sized [`open_multi`](Self::open_multi): carves `backing` for
466    /// the executor's tables instead of leaking a default one.
467    ///
468    /// # Safety
469    /// `backing`/`sizing` as in [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in).
470    pub(crate) unsafe fn open_multi_in(
471        specs: &[SessionSpec<'_>],
472        backing: &'s mut [MaybeUninit<u64>],
473        sizing: super::storage::ExecutorSizing,
474    ) -> Result<Self, NodeError> {
475        // Phase 249 P4b.1 — backends self-registered via their
476        // `.init_array` ctor before `main`; no runtime section walk.
477        let primary = specs
478            .first()
479            .ok_or(NodeError::Transport(TransportError::ConnectionFailed))?;
480        let primary_session =
481            nros_rmw_cffi::CffiRmw::open_with_rmw(primary.rmw, &primary.to_rmw_config())
482                .map_err(NodeError::Transport)?;
483        // SAFETY: forwarded from this fn's contract.
484        let mut executor = unsafe { Self::from_session_in(primary_session, backing, sizing) };
485        executor.set_node_identity("", "/");
486        // Phase 156 — see `Executor::open` for primary-identity
487        // recording rationale.
488        let _ = executor.primary_rmw_name.push_str(primary.rmw);
489        let _ = executor.primary_locator.push_str(primary.locator);
490        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
491        executor.install_wake_signal_on_primary();
492
493        for spec in specs.iter().skip(1) {
494            let session = nros_rmw_cffi::CffiRmw::open_with_rmw(spec.rmw, &spec.to_rmw_config())
495                .map_err(NodeError::Transport)?;
496            executor
497                .extra_sessions
498                .push(session)
499                .map_err(|_| NodeError::NodeTableFull)?;
500            // Issue 0436 — record WHICH backend/locator this extra is, so
501            // `NodeBuilder::rmw(name)` can find it instead of opening a second
502            // session against the same (singleton) backend.
503            {
504                let mut rmw_s = heapless::String::<32>::new();
505                let _ = rmw_s.push_str(spec.rmw);
506                let mut loc_s = heapless::String::<128>::new();
507                let _ = loc_s.push_str(spec.locator);
508                let _ = executor.extra_session_ids.push((rmw_s, loc_s));
509            }
510            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
511            {
512                let idx = executor.extra_sessions.len() - 1;
513                executor.install_wake_signal_on_extra(idx);
514            }
515        }
516
517        Ok(executor)
518    }
519
520    /// Per-entry-sized [`open_with_rmw`](Self::open_with_rmw): carves `backing`
521    /// for the executor's tables instead of leaking a default one.
522    ///
523    /// # Safety
524    /// `backing`/`sizing` as in [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in).
525    pub unsafe fn open_with_rmw_in(
526        rmw_name: &str,
527        config: &ExecutorConfig<'_>,
528        backing: &'s mut [MaybeUninit<u64>],
529        sizing: super::storage::ExecutorSizing,
530    ) -> Result<Self, NodeError> {
531        if !nros_rmw_cffi::backend_registered() {
532            return Err(NodeError::Transport(TransportError::ConnectionFailed));
533        }
534
535        let session = super::session_props::with_session_properties(&[], |properties| {
536            nros_rmw_cffi::CffiRmw::open_with_rmw(
537                rmw_name,
538                &nros_rmw::RmwConfig {
539                    locator: config.locator,
540                    mode: config.mode,
541                    domain_id: config.domain_id,
542                    node_name: config.node_name,
543                    namespace: config.namespace,
544                    properties,
545                },
546            )
547        })
548        .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed))?;
549        // SAFETY: forwarded from this fn's contract.
550        let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
551        {
552            // `config.clock_us` overrides the constructor's platform default
553            // (issue: assigning `None` here clobbered it and re-enabled the
554            // credit-the-requested-timeout fallback).
555            if let Some(clock) = config.clock_us {
556                executor.clock_us_fn = Some(clock);
557                executor.last_spin_end_us = Some(clock());
558            }
559            // issue 0671 — the SAME rule as `clock_us` directly above, which is
560            // why that one is guarded: a config that does not SPECIFY an epoch
561            // must not be read as "this target HAS no epoch". Assigning `None`
562            // here clobbered the constructor's platform default
563            // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
564            // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
565            // so EVERY hosted node built that way silently lost its wall clock.
566            // With no epoch, `Node::subscription` never attaches the age cell
567            // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
568            // `max_age_ms` contract becomes a silently-dead monitor — the exact
569            // outcome RFC-0052 says must never happen. The rate monitor rides
570            // the GUARDED `clock_us_fn` and kept working, which is why only the
571            // age half went quiet.
572            if let Some(epoch) = config.epoch_us {
573                executor.epoch_us_fn = Some(epoch);
574            }
575        }
576        executor.set_node_identity(config.node_name, config.namespace);
577        // Phase 156 — record primary identity for the session-
578        // cache hit path. See `Executor::open` for the rationale.
579        let _ = executor.primary_rmw_name.push_str(rmw_name);
580        let _ = executor.primary_locator.push_str(config.locator);
581        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
582        executor.install_wake_signal_on_primary();
583        Ok(executor)
584    }
585}
586
587/// Phase 128.F.1 — per-backend session declaration for
588/// [`Executor::open_multi`]. Each spec names an RMW backend (must
589/// match one a backend registered under via
590/// `nros_rmw_cffi_register_named`, which its self-registration calls) and
591/// the locator + domain id to open against it.
592#[cfg(feature = "rmw-cffi")]
593#[derive(Clone, Copy)]
594pub struct SessionSpec<'cfg> {
595    pub rmw: &'cfg str,
596    pub locator: &'cfg str,
597    pub domain_id: u32,
598    pub node_name: &'cfg str,
599    pub namespace: &'cfg str,
600    /// The session's backend-specific properties (`RmwConfig::properties`):
601    /// what its domain's RMW file lowers to (RFC-0109 D6, phase-514 W3b).
602    /// Empty: none — the image's baked table is the DEFAULT session's and is
603    /// never given to a spec.
604    pub properties: &'cfg [(&'cfg str, &'cfg str)],
605}
606
607#[cfg(feature = "rmw-cffi")]
608impl<'cfg> SessionSpec<'cfg> {
609    /// Minimal spec — just RMW name + locator. Domain id defaults to
610    /// 0; node name and namespace are empty.
611    pub const fn new(rmw: &'cfg str, locator: &'cfg str) -> Self {
612        Self {
613            rmw,
614            locator,
615            domain_id: 0,
616            node_name: "",
617            namespace: "",
618            properties: &[],
619        }
620    }
621
622    /// The session's backend-specific properties.
623    pub const fn properties(mut self, properties: &'cfg [(&'cfg str, &'cfg str)]) -> Self {
624        self.properties = properties;
625        self
626    }
627
628    pub const fn domain_id(mut self, domain_id: u32) -> Self {
629        self.domain_id = domain_id;
630        self
631    }
632
633    pub const fn node_name(mut self, name: &'cfg str) -> Self {
634        self.node_name = name;
635        self
636    }
637
638    pub const fn namespace(mut self, ns: &'cfg str) -> Self {
639        self.namespace = ns;
640        self
641    }
642
643    fn to_rmw_config(self) -> nros_rmw::RmwConfig<'cfg> {
644        nros_rmw::RmwConfig {
645            locator: self.locator,
646            mode: nros_rmw::SessionMode::Client,
647            domain_id: self.domain_id,
648            node_name: self.node_name,
649            namespace: self.namespace,
650            properties: self.properties,
651        }
652    }
653}
654
655// Phase 128.A.3 — selector for the single-backend resolution path.
656//
657// On hosted (`std`) builds, read `$NROS_RMW`; mirrors ROS 2's
658// `RMW_IMPLEMENTATION`. Returns the name as a byte vector so the
659// caller can pass it to `nros_rmw_cffi::resolve_backend` and (when
660// `Some`) to `CffiRmw::open_with_rmw`.
661//
662// On `no_std` / bare-metal builds, environment variables are not
663// available; resolution always falls through to the single-backend
664// or ambiguous path. Embedded users with multiple backends use the
665// bridge surface `Executor::open_multi` instead.
666// phase-359 W10 / issue 0687 — the private reader that stood here is gone, and
667// so is the shared one that briefly replaced it. `ExecutorConfig::rmw` carries
668// the selection instead: `nros::rmw_selector` reads the variable ONCE, at the
669// hosted edge, and every consumer takes the value.
670
671// ============================================================================
672// SessionStore — owned or borrowed session
673// ============================================================================
674
675/// Session storage: owned or borrowed via raw pointer.
676///
677/// The C API creates a session in `rclc_support_init()` before the
678/// executor. `Borrowed` lets the executor use that session without owning it.
679#[allow(clippy::large_enum_variant)]
680pub(crate) enum SessionStore {
681    Owned(session::ConcreteSession),
682    Borrowed(*mut session::ConcreteSession),
683}
684
685impl core::ops::Deref for SessionStore {
686    type Target = session::ConcreteSession;
687    fn deref(&self) -> &session::ConcreteSession {
688        match self {
689            SessionStore::Owned(s) => s,
690            SessionStore::Borrowed(ptr) => unsafe { &**ptr },
691        }
692    }
693}
694
695impl core::ops::DerefMut for SessionStore {
696    fn deref_mut(&mut self) -> &mut session::ConcreteSession {
697        match self {
698            SessionStore::Owned(s) => s,
699            SessionStore::Borrowed(ptr) => unsafe { &mut **ptr },
700        }
701    }
702}
703
704/// Phase 228.E — an opaque, `Send` handle to an [`Executor`]'s RMW session.
705///
706/// In the per-tier model the boot executor opens the one session and hands each
707/// spawned tier task a handle (not a borrow) so the task opens its own
708/// [`Executor`] over that *same* session across the RTOS task boundary. Wrapping
709/// the `pub(crate)` session pointer lets board crates (`nros-board-linux`,
710/// `nros-board-freertos`, …) name + move the handle without naming the session
711/// type. Obtain via [`Executor::session_handle`]; consume via
712/// [`Executor::open_with_session_handle`].
713#[cfg(any(has_rmw, test))]
714pub struct SessionHandle(*mut session::ConcreteSession);
715
716// SAFETY: the per-tier model deliberately shares one session across RTOS tasks;
717// concurrent access is serialized by the RMW backend's internal locks (the RTOS
718// targets build zenoh-pico `Z_FEATURE_MULTI_THREAD=1` — RFC-0032 §5.0). The
719// boot executor owns the session and outlives every tier task.
720#[cfg(any(has_rmw, test))]
721unsafe impl Send for SessionHandle {}
722
723#[cfg(any(has_rmw, test))]
724impl SessionHandle {
725    /// Phase 274.W1 — convert to an opaque `*mut c_void` for C/C++ FFI.
726    ///
727    /// The returned pointer encodes the session address and is valid as long as
728    /// the owning executor lives. Reconstruct via [`Self::from_raw`].
729    pub fn into_raw(self) -> *mut core::ffi::c_void {
730        self.0 as *mut core::ffi::c_void
731    }
732
733    /// Phase 274.W1 — reconstruct a `SessionHandle` from an opaque pointer
734    /// returned by [`Self::into_raw`].
735    ///
736    /// # Safety
737    /// `ptr` must be a pointer obtained from `into_raw()` on a `SessionHandle`
738    /// whose underlying session is still live and owned by its original executor.
739    pub unsafe fn from_raw(ptr: *mut core::ffi::c_void) -> Self {
740        Self(ptr as *mut session::ConcreteSession)
741    }
742}
743
744/// Phase 228.C — pure callback-group filter decision. `None` = wildcard (accept
745/// every group); `Some` = accept only listed groups. Backs
746/// [`Executor::group_active`]; split out so the logic is unit-testable without a
747/// live session.
748pub(crate) fn group_filter_accepts(
749    active: Option<&[super::storage::ActiveGroup]>,
750    node: u64,
751    group: &str,
752) -> bool {
753    match active {
754        None => true,
755        Some(v) => v.iter().any(|(n, g)| *n == node && g.as_str() == group),
756    }
757}
758
759/// Bytes the parameter store asks the platform heap for, in ONE allocation.
760///
761/// Issue 1706 -- the size of THIS build's store, which folds every knob that
762/// shapes it (`MAX_PARAMETERS`, which a contract's `params:` derives, and the
763/// board's string/array capacities). No formula and no measured constant: the
764/// compiler's own answer, so the number reported below cannot drift from the
765/// allocation it describes.
766#[cfg(all(feature = "param-store", feature = "alloc"))]
767pub const PARAMETER_STORE_BYTES: usize = core::mem::size_of::<nros_params::ParameterStorage>();
768
769/// Issue 1706 -- a TEST seam standing in for a platform heap that returns NULL
770/// for the store's request, on the current thread only (tests run in
771/// parallel). Not a test `#[global_allocator]`: `check-feature-contract`
772/// clause (e) allows exactly one, `nros-platform`'s.
773#[cfg(all(test, feature = "alloc", feature = "param-store"))]
774pub(crate) struct RefuseParameterStore;
775
776#[cfg(all(test, feature = "alloc", feature = "param-store"))]
777std::thread_local! {
778    static REFUSE_PARAMETER_STORE: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
779}
780
781#[cfg(all(test, feature = "alloc", feature = "param-store"))]
782impl RefuseParameterStore {
783    // Only `executor::tests` arms it, and that module is mock-session only.
784    #[cfg(not(feature = "rmw-cffi"))]
785    pub(crate) fn arm() -> Self {
786        REFUSE_PARAMETER_STORE.with(|c| c.set(true));
787        Self
788    }
789
790    fn armed() -> bool {
791        REFUSE_PARAMETER_STORE.with(core::cell::Cell::get)
792    }
793}
794
795#[cfg(all(test, feature = "alloc", feature = "param-store"))]
796impl Drop for RefuseParameterStore {
797    fn drop(&mut self) {
798        REFUSE_PARAMETER_STORE.with(|c| c.set(false));
799    }
800}
801
802/// Issue 2023 — create the executor's ONE `/parameter_events` publisher,
803/// `rcl_interfaces/msg/ParameterEvent`, announced under `node`.
804///
805/// The ONE creation site `check-infra-queryable-counts` holds
806/// [`PARAM_EVENT_PUBLISHERS`](crate::parameter_services::PARAM_EVENT_PUBLISHERS)
807/// to. A node with a record goes through the node's own publisher path (its
808/// session, QoS overrides and the declared-QoS check); an executor with no
809/// registered node — a direct-API image whose parameter services the
810/// reconcile serves under the executor's own identity — publishes under that
811/// same identity on the primary session, so the events and the six services
812/// name one node.
813#[cfg(feature = "param-services")]
814fn create_param_event_pub(
815    exec: &mut Executor<'_>,
816    node: super::node_record::NodeId,
817    qos: QoSProfile,
818) -> Result<session::RmwPublisher, NodeError> {
819    type Event = nros_rcl_interfaces::msg::ParameterEvent;
820    const TOPIC: &str = "/parameter_events";
821    if exec.nodes.get(node.index()).is_some() {
822        return exec.create_raw_publisher_handle_on(
823            node,
824            TOPIC,
825            <Event as RosMessage>::TYPE_NAME,
826            <Event as RosMessage>::TYPE_HASH,
827            qos,
828        );
829    }
830    let node_name = exec.node_name.clone();
831    let namespace = exec.namespace.clone();
832    let mut topic = TopicInfo::new(
833        TOPIC,
834        <Event as RosMessage>::TYPE_NAME,
835        <Event as RosMessage>::TYPE_HASH,
836    )
837    .with_domain(exec.domain_id)
838    .with_namespace(&namespace);
839    if !node_name.is_empty() {
840        topic = topic.with_node_name(&node_name);
841    }
842    let session = &mut *exec.session;
843    super::node::validate_qos_or_report(
844        &qos,
845        Session::supported_qos_policies(session),
846        "publisher",
847        TOPIC,
848    )?;
849    session
850        .create_publisher(&topic, qos)
851        .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))
852}
853
854/// Issue 2023 — say that `/parameter_events` could not be created for a node
855/// whose parameter services did come up. Cold: it formats.
856#[cfg(feature = "param-services")]
857#[cold]
858#[inline(never)]
859fn report_parameter_events_refused(e: NodeError) {
860    nros_log::log_error!(
861        nros_log::get_logger("nros"),
862        "/parameter_events could not be created ({:?}); the parameter services are up, \
863         but no parameter change is published. A publisher slot the image did not size \
864         for is the usual cause (issue 2023).",
865        e
866    );
867}
868
869/// Issue 1706 -- say, once, that the parameter store did not fit.
870///
871/// The RFC-0100 D11 shape, one layer up from Cyclone's boot check: the
872/// failure is named where it is decided, with the number a reader needs to
873/// size the heap, instead of surfacing as an anonymous halt in the allocator.
874/// What it can name is the REQUEST; the heap's own report (Zephyr's `HEAP
875/// EXHAUSTED` line, the board's `nros: heap peak` line) carries the capacity.
876#[cfg(feature = "param-store")]
877#[cold]
878fn report_parameter_store_refused() {
879    // Issue 1812 -- with no heap there was no allocation to refuse: the
880    // backing carved no store, and that is the whole story.
881    #[cfg(not(feature = "alloc"))]
882    {
883        let logger = nros_log::get_logger("nros");
884        nros_log::log_error!(
885            logger,
886            "parameter store refused: this executor's backing carves none and the build has no heap"
887        );
888        nros_log::log_error!(
889            logger,
890            "every parameter declaration on this executor is refused (issue 1812)"
891        );
892        nros_log::log_error!(
893            logger,
894            "open it with ExecutorSizing::params > 0 (NROS_PARAM_STORE=1, NROS_MAX_PARAMETERS)"
895        );
896    }
897    #[cfg(feature = "alloc")]
898    report_parameter_heap_store_refused();
899}
900
901/// The `alloc` half of [`report_parameter_store_refused`]: the heap refused
902/// the store's one allocation (issue 1706).
903#[cfg(all(feature = "param-store", feature = "alloc"))]
904#[cold]
905fn report_parameter_heap_store_refused() {
906    let slots = nros_params::MAX_PARAMETERS;
907    // THREE short lines, not one sentence: a log backend truncates a long
908    // line (Zephyr's cut the first draft at its slot count), and the number
909    // is the one part that must survive.
910    let logger = nros_log::get_logger("nros");
911    nros_log::log_error!(
912        logger,
913        "parameter store refused: needs {} bytes in one allocation ({} slots x {})",
914        PARAMETER_STORE_BYTES,
915        slots,
916        PARAMETER_STORE_BYTES / if slots == 0 { 1 } else { slots }
917    );
918    nros_log::log_error!(
919        logger,
920        "every parameter declaration on this executor is refused (issue 1706)"
921    );
922    // phase-382 W3' -- the remedy that takes the store OUT of the heap comes
923    // first: a build that knows the image builds a store carves it from the
924    // executor backing instead (`nros_params::IMPLIED_STORE_SLOTS`).
925    nros_log::log_error!(
926        logger,
927        "declare the parameters in the contract (params:) or state NROS_PARAM_STORE=1"
928    );
929    nros_log::log_error!(
930        logger,
931        "and the store is carved from the executor backing; or raise the image heap"
932    );
933}
934
935/// `text` typed as `declared`'s type — phase-482 W5. `None` when it does not
936/// parse as that type, or the type takes no override yet (arrays).
937///
938/// A `NotSet` declaration has no type to follow, so the text decides: `true` /
939/// `false`, then an integer, then a double, then a string — the order rcl's
940/// YAML reading gives an unquoted scalar.
941#[cfg(all(feature = "param-store", feature = "alloc"))]
942fn typed_param_override(
943    text: &str,
944    declared: &nros_params::ParameterValue,
945) -> Option<nros_params::ParameterValue> {
946    let mut value = nros_params::ParameterValue::NotSet;
947    typed_param_override_into(text, declared, &mut value).then_some(value)
948}
949
950/// [`typed_param_override`], written into `out` IN PLACE — issue 2118.
951/// `false`, with `out` unspecified, where that returns `None`.
952///
953/// Returning the value cost every caller two: the `Option` the callee builds
954/// and the binding the caller moves it into (17 KiB on Cortex-M3 for one
955/// declaration). A caller that fills a slot it holds keeps one.
956#[cfg(all(feature = "param-store", feature = "alloc"))]
957fn typed_param_override_into(
958    text: &str,
959    declared: &nros_params::ParameterValue,
960    out: &mut nros_params::ParameterValue,
961) -> bool {
962    use nros_params::ParameterValue as V;
963    let boolean = |t: &str| match t {
964        "true" | "True" | "TRUE" => Some(true),
965        "false" | "False" | "FALSE" => Some(false),
966        _ => None,
967    };
968    // Issue 2104 -- the TYPE is decided first and the value built once. A
969    // chain of `Option<ParameterValue>`s (`.map(..).or_else(..)`) is one
970    // ~8 KiB temporary per link, in every caller this inlines into.
971    enum Typed {
972        Bool(bool),
973        Integer(i64),
974        Double(f64),
975        String,
976    }
977    let typed = match declared {
978        V::Bool(_) => match boolean(text) {
979            Some(b) => Typed::Bool(b),
980            None => return false,
981        },
982        V::Integer(_) => match text.parse::<i64>() {
983            Ok(i) => Typed::Integer(i),
984            Err(_) => return false,
985        },
986        V::Double(_) => match text.parse::<f64>() {
987            Ok(d) => Typed::Double(d),
988            Err(_) => return false,
989        },
990        V::String(_) => Typed::String,
991        V::NotSet => {
992            if let Some(b) = boolean(text) {
993                Typed::Bool(b)
994            } else if let Ok(i) = text.parse::<i64>() {
995                Typed::Integer(i)
996            } else if let Ok(d) = text.parse::<f64>() {
997                Typed::Double(d)
998            } else {
999                Typed::String
1000            }
1001        }
1002        _ => return false,
1003    };
1004    match typed {
1005        Typed::Bool(b) => *out = V::Bool(b),
1006        Typed::Integer(i) => *out = V::Integer(i),
1007        Typed::Double(d) => *out = V::Double(d),
1008        Typed::String => return out.assign_string(text),
1009    }
1010    true
1011}
1012
1013#[cfg(test)]
1014mod group_filter_tests {
1015    use super::group_filter_accepts;
1016    use crate::executor::storage::{ActiveGroup, node_identity_hash};
1017
1018    type Group = heapless::String<32>;
1019
1020    fn entry(node: &str, ns: &str, g: &str) -> ActiveGroup {
1021        let mut s = Group::new();
1022        s.push_str(g).unwrap();
1023        (node_identity_hash(node, ns), s)
1024    }
1025
1026    #[test]
1027    fn wildcard_accepts_all() {
1028        assert!(group_filter_accepts(
1029            None,
1030            node_identity_hash("n", "/"),
1031            "anything"
1032        ));
1033    }
1034
1035    #[test]
1036    fn set_accepts_only_listed_groups() {
1037        let active = [entry("n", "/", "ctrl")];
1038        let n = node_identity_hash("n", "/");
1039        assert!(group_filter_accepts(Some(&active[..]), n, "ctrl"));
1040        assert!(!group_filter_accepts(Some(&active[..]), n, "telem"));
1041    }
1042
1043    /// issue 1172 — the SAME group id on a DIFFERENT node is not this tier's.
1044    ///
1045    /// This is the case the whole change exists for: two nodes may legitimately
1046    /// declare `ctrl`, pinned to different tiers, and the old filter — keyed on
1047    /// the group name alone — admitted both onto whichever tier asked first.
1048    #[test]
1049    fn the_same_group_on_another_node_is_not_accepted() {
1050        let active = [entry("talker", "/", "ctrl")];
1051        assert!(group_filter_accepts(
1052            Some(&active[..]),
1053            node_identity_hash("talker", "/"),
1054            "ctrl"
1055        ));
1056        assert!(
1057            !group_filter_accepts(
1058                Some(&active[..]),
1059                node_identity_hash("listener", "/"),
1060                "ctrl"
1061            ),
1062            "another node's identically-named group must NOT be admitted"
1063        );
1064    }
1065
1066    /// The namespace is part of the identity, not decoration: two nodes may
1067    /// share a bare name under different namespaces, which is legal in ROS.
1068    #[test]
1069    fn the_same_name_under_another_namespace_is_a_different_node() {
1070        let active = [entry("talker", "/left", "ctrl")];
1071        assert!(!group_filter_accepts(
1072            Some(&active[..]),
1073            node_identity_hash("talker", "/right"),
1074            "ctrl"
1075        ));
1076    }
1077
1078    /// An empty namespace and `/` are the SAME node. Both ends normalise, and
1079    /// if only one did, a filter would never match what it named.
1080    #[test]
1081    fn an_empty_namespace_normalises_to_root() {
1082        assert_eq!(
1083            node_identity_hash("talker", ""),
1084            node_identity_hash("talker", "/")
1085        );
1086    }
1087
1088    /// The separator is load-bearing: without it `("ab","c")` and `("a","bc")`
1089    /// would hash alike, and a node name may contain any namespace character.
1090    #[test]
1091    fn the_name_namespace_split_cannot_be_forged() {
1092        assert_ne!(
1093            node_identity_hash("ab", "/c"),
1094            node_identity_hash("a", "b/c")
1095        );
1096    }
1097
1098    /// phase-409 — the wildcard and an EMPTY filter are different answers, and
1099    /// the carved table cannot tell them apart on its own (the old
1100    /// `Option<Vec>` could). `Executor::active_groups_filtering` is what keeps
1101    /// them apart; this pins the distinction at the decision itself.
1102    #[test]
1103    fn an_empty_filter_is_not_the_wildcard() {
1104        let empty: [ActiveGroup; 0] = [];
1105        let n = node_identity_hash("n", "/");
1106        assert!(!group_filter_accepts(Some(&empty[..]), n, "anything"));
1107        assert!(group_filter_accepts(None, n, "anything"));
1108    }
1109}
1110
1111// ============================================================================
1112// Executor
1113// ============================================================================
1114
1115/// Backend-agnostic executor that owns a session.
1116///
1117/// Provides `create_node()` for entity creation and `drive_io()` for polling.
1118///
1119/// # Callback Mode
1120///
1121/// The executor supports arena-based callback registration via the
1122/// `node_mut(id).subscription(t)` builder and
1123/// [`register_service()`](Self::register_service), with dispatch via
1124/// [`spin_once()`](Self::spin_once). No heap allocation is needed.
1125///
1126/// The sizes are set via `NROS_EXECUTOR_MAX_CBS` (default 4) and
1127/// `NROS_EXECUTOR_ARENA_SIZE` (default 4096) environment variables at build time.
1128///
1129/// Phase 124.B.2 — opaque context handed to the runtime wake
1130/// callback. Backends store the raw pointer + invoke the callback;
1131/// the callback decodes back to `&WakeCtx`.
1132///
1133/// `repr(align(64))` because an entity waker carries its slot in the low six
1134/// bits of a pointer to this struct (phase-397 W2, `entity_wake`).
1135#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1136#[repr(align(64))]
1137pub(crate) struct WakeCtx {
1138    pub(crate) flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
1139    /// The wake primitive. Phase 130.3 added it beside a `std::sync::Condvar`
1140    /// pair and noted that "a future migration to a single primitive flips one
1141    /// branch instead of two"; phase-359 W10 is that migration, and this is the
1142    /// one that survived.
1143    pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
1144    /// phase-397 W2 — one bit per entry slot an attributed arrival has marked
1145    /// since the executor last took the word. Set by an entity waker
1146    /// (`entity_wake`), never by the session-scoped callback, which cannot
1147    /// name an entry. W3 reads it instead of scanning.
1148    /// A `usize`, not a `u64`: 32-bit targets without 64-bit atomics have no
1149    /// `AtomicU64`, so the word names `usize::BITS` slots (`entity_wake::SLOTS`)
1150    /// and an entry above that keeps the session wake.
1151    pub(crate) ready: portable_atomic::AtomicUsize,
1152    /// phase-509 P9b item 3 (RFC-0108 D7) -- the band workers' wake objects
1153    /// (`Executor::attach_band_worker`), a list of `'static` waiters that is
1154    /// only ever pushed to. An attributed arrival wakes the worker whose band
1155    /// holds its slot; an unattributed one wakes every worker.
1156    pub(crate) band_waiters: portable_atomic::AtomicPtr<super::scheduler::BandWaiter>,
1157}
1158
1159#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1160impl WakeCtx {
1161    pub(crate) fn new(
1162        flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
1163        node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
1164    ) -> Self {
1165        Self {
1166            flag,
1167            node_wake,
1168            ready: portable_atomic::AtomicUsize::new(0),
1169            band_waiters: portable_atomic::AtomicPtr::new(core::ptr::null_mut()),
1170        }
1171    }
1172
1173    /// phase-509 P9b item 3 -- the wake for an arrival nothing attributes:
1174    /// the executor's own worker, and every band worker, since any of them
1175    /// may own the slot it is for.
1176    pub(crate) fn signal(&self) {
1177        self.signal_own();
1178        let mut w = self.band_waiters.load(portable_atomic::Ordering::Acquire);
1179        while !w.is_null() {
1180            // SAFETY: only `'static` waiters are linked, never unlinked.
1181            let waiter = unsafe { &*w };
1182            waiter.signal();
1183            w = waiter.next.load(portable_atomic::Ordering::Acquire);
1184        }
1185    }
1186
1187    /// The executor's own worker's wake: flag, then the primitive. Shared by
1188    /// the session callback and the entity waker so the two cannot drift.
1189    pub(crate) fn signal_own(&self) {
1190        // Phase 141.B.2 — capture T0 at cb entry. No-op when the
1191        // probe feature is off or no cycle reader is installed.
1192        #[cfg(feature = "wake-latency-probe")]
1193        super::wake_probe::on_wake();
1194        // Lock-free: the `flag` store is SeqCst and therefore happens-before any
1195        // subsequent acquire in the waiter, so a wake cannot be missed even though
1196        // nothing is held here.
1197        self.flag.store(true, core::sync::atomic::Ordering::SeqCst);
1198        // phase-359 W10 — ONE primitive. This used to signal both a
1199        // `std::sync::Condvar` and (since phase 130.3) the `NodeWake`, "so the cb
1200        // keeps working whichever wait primitive spin_once is using". `spin_once`
1201        // now has only one, so this has only one to signal.
1202        if let Some(nw) = self.node_wake.as_ref() {
1203            nw.signal();
1204        }
1205    }
1206
1207    /// phase-397 W2 — an attributed arrival on entry `slot`: its bit, then the
1208    /// wake. The bit is set FIRST, so a waiter that sees the flag sees the bit.
1209    pub(crate) fn mark_ready(&self, slot: usize) {
1210        self.ready
1211            .fetch_or(1usize << slot, core::sync::atomic::Ordering::SeqCst);
1212        // phase-509 P9b item 3 (RFC-0108 D7) -- only the worker whose band
1213        // holds the slot; the executor's own worker when no band does.
1214        let bit = 1usize << slot;
1215        let mut w = self.band_waiters.load(portable_atomic::Ordering::Acquire);
1216        while !w.is_null() {
1217            // SAFETY: only `'static` waiters are linked, never unlinked.
1218            let waiter = unsafe { &*w };
1219            if waiter.slots.load(portable_atomic::Ordering::SeqCst) & bit != 0 {
1220                waiter.signal();
1221                return;
1222            }
1223            w = waiter.next.load(portable_atomic::Ordering::Acquire);
1224        }
1225        self.signal_own();
1226    }
1227}
1228
1229/// Phase 124.B.2 — runtime wake callback.
1230///
1231/// RT-context contract:
1232///
1233/// * **Thread-safe**: callable from any thread. The cb is lock-free
1234///   — it sets `wake_flag` and signals the executor's `NodeWake`, a
1235///   binary semaphore, which holds a signal sent before the waiter
1236///   blocks, so no wakeup is lost. (A condvar pair guarded it before
1237///   phase-359 W10: the waiter checked `wake_flag` under ~~`wake_mu`~~.)
1238/// * **NOT async-signal-safe on POSIX**: `pthread_cond_signal`
1239///   isn't on the POSIX async-signal-safe function list. For POSIX
1240///   signal handler wake, use a `signalfd` + select pattern in a
1241///   thread that owns the wake duty — that pattern is Linux-only
1242///   (`signalfd`/`eventfd` are Linux syscalls, not POSIX), which is
1243///   why the `WakeSignalFd` worker is gated `target_os = "linux"`.
1244/// * **RTOS ISR**: per-RTOS platform layer wraps the cv with an
1245///   ISR-safe primitive (`xSemaphoreGiveFromISR`,
1246///   `tx_event_flags_set` from ISR, `k_sem_give` from ISR on
1247///   Zephyr). Backend's ISR caller routes through the platform's
1248///   `signal_from_isr` API instead of this cb directly.
1249/// * **Bounded execution time**: O(1) — atomic store + cv notify.
1250///   No allocation, no contended lock.
1251///
1252/// The cb is the symbol backends invoke from their async wake path
1253/// (datagram arrival, worker-thread enqueue, etc.). It does
1254/// flag-write + condvar-signal in that order, lock-free.
1255#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1256pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb(ctx: *mut core::ffi::c_void) {
1257    if ctx.is_null() {
1258        return;
1259    }
1260    // SAFETY: ctx points at a `WakeCtx` owned by an Executor still
1261    // alive at the time of the call. `Executor::clear_wake_signal`
1262    // discharges that obligation — `set_wake_callback(None, _)` on the
1263    // primary and every extra session — from `Executor::close` (before
1264    // the session closes) and from the end of `Executor::drop` (before
1265    // `wake_ctx` is dropped). Issue 1385: until then this comment named
1266    // an "`install_wake_*` teardown path" that did not exist, and
1267    // `set_wake_callback(None` occurred nowhere in the tree but here.
1268    let wake = unsafe { &*(ctx as *const WakeCtx) };
1269    wake.signal();
1270}
1271
1272/// phase-436 — the callback a backend holds when several executors share its
1273/// session: signal every member of the [`WakeFanout`](super::wake_fanout)
1274/// whose address is `ctx`. The entry is a `static`, so `ctx` never dangles.
1275#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1276pub(crate) unsafe extern "C" fn nros_rmw_fanout_wake_cb(ctx: *mut core::ffi::c_void) {
1277    if ctx.is_null() {
1278        return;
1279    }
1280    // SAFETY: `ctx` is `WakeFanout::as_ctx` of a `static` pool entry.
1281    let fanout = unsafe { &*(ctx as *const super::wake_fanout::WakeFanout) };
1282    fanout.fire(nros_rmw_runtime_wake_cb);
1283}
1284
1285/// phase-467 Row 8 — the symbol a backend invokes when the ROS GRAPH changes.
1286///
1287/// `rmw_event_callback_t`'s shape, so it fits the ABI slot unchanged:
1288/// `user_data` plus a coalesced count, and no payload. The count is IGNORED on
1289/// purpose — a guard condition is a level, not a queue, so N changes observed
1290/// before the executor spins are one dispatch. That is the same collapse
1291/// `guard_try_process` already performs on a flag several `trigger()`s set.
1292///
1293/// RT-context contract: identical to [`nros_rmw_runtime_wake_cb`] and for the
1294/// same reason — a relaxed-store-plus-wake, O(1), no allocation, no lock. Both
1295/// are called from a transport or worker thread.
1296///
1297/// # Safety
1298///
1299/// `user_data` must be the pointer `Executor::install_graph_change_guard`
1300/// passed: a `GuardCondition` in a box that executor owns. The obligation is
1301/// discharged by `clear_graph_change_signal`, which retires the pointer from
1302/// the backend BEFORE the box is freed, from `close()` and from `drop`. Issue
1303/// 1385 is what the other ordering costs.
1304#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1305pub(crate) unsafe extern "C" fn nros_rmw_runtime_graph_change_cb(
1306    user_data: *const core::ffi::c_void,
1307    _count: usize,
1308) {
1309    if user_data.is_null() {
1310        return;
1311    }
1312    // SAFETY: see this function's `# Safety`.
1313    let guard = unsafe { &*(user_data as *const GuardCondition) };
1314    guard.trigger();
1315}
1316
1317/// Phase 124.B.7.c — Linux signalfd worker.
1318///
1319/// Owns a Linux `eventfd` plus a worker task that `read()`s the
1320/// fd and forwards it as a wake. The eventfd
1321/// write side is async-signal-safe per the kernel contract
1322/// (`write(2)` to an eventfd is permitted from signal handlers),
1323/// closing the gap that `pthread_cond_signal` leaves open on POSIX.
1324///
1325/// Lifecycle:
1326///   * Constructed lazily in `Executor::signal_fd()` on first
1327///     caller request.
1328///   * `Drop` writes a shutdown sentinel + joins the worker.
1329///
1330/// Caller flow (signal handler):
1331///   1. Get fd via `Executor::signal_fd()` before installing the
1332///      handler.
1333///   2. Handler does `eventfd_write(fd, 1)` (equivalently,
1334///      `write(fd, &1u64, 8)`).
1335///   3. Worker thread reads the fd, signals wake_cv. spin_once
1336///      blocked in cv.wait_timeout_while sees flag=true and exits.
1337///
1338/// phase-359 W10 — the worker is a PLATFORM TASK, not a `std::thread`, and it
1339/// forwards through the same [`NodeWake`](super::node_wake::NodeWake) the
1340/// runtime wake callback uses rather than a `std::sync::Condvar`. The eventfd
1341/// itself is still Linux — that is what makes the write async-signal-safe — so
1342/// this stays `target_os = "linux"`; what it no longer is, is std-only.
1343#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1344pub struct WakeSignalFd {
1345    fd: core::ffi::c_int,
1346    ctx: portable_atomic_util::Arc<SignalFdCtx>,
1347    task: Option<nros_platform_api::task::PlatformTask>,
1348}
1349
1350/// What the signalfd worker task needs, reachable through one pointer.
1351#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1352struct SignalFdCtx {
1353    fd: core::ffi::c_int,
1354    shutdown: portable_atomic::AtomicBool,
1355    /// The executor's wake state — the same `WakeCtx` the runtime callback
1356    /// decodes, reached as an address so the context is plainly `Send`.
1357    wake_ctx: usize,
1358}
1359
1360/// Worker entry: read the eventfd, forward as a wake, until shut down.
1361///
1362/// # Safety
1363/// `arg` must point at a live [`SignalFdCtx`] whose `wake_ctx` addresses a
1364/// `WakeCtx` that outlives this task.
1365#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1366unsafe extern "C" fn signal_fd_worker(arg: *mut core::ffi::c_void) -> *mut core::ffi::c_void {
1367    // SAFETY: the spawn site passes `Arc::as_ptr` of a ctx it keeps alive until
1368    // after the join in `Drop`.
1369    let ctx = unsafe { &*(arg as *const SignalFdCtx) };
1370    loop {
1371        let mut buf = [0u8; 8];
1372        // SAFETY: reading 8 bytes from an eventfd into an 8-byte buffer.
1373        let n = unsafe { libc::read(ctx.fd, buf.as_mut_ptr() as *mut core::ffi::c_void, 8) };
1374        if ctx.shutdown.load(portable_atomic::Ordering::Acquire) {
1375            return core::ptr::null_mut();
1376        }
1377        if n <= 0 {
1378            // EINTR / EOF — shutdown was re-checked above, so loop.
1379            continue;
1380        }
1381        // Same effect as `nros_rmw_runtime_wake_cb`, reached directly because
1382        // the shutdown check above plus `Drop`'s join is what guarantees the
1383        // `WakeCtx` is still alive.
1384        // SAFETY: `wake_ctx` addresses the executor's `WakeCtx`, which outlives
1385        // this task by that same guarantee.
1386        unsafe {
1387            let w = &*(ctx.wake_ctx as *const WakeCtx);
1388            w.flag.store(true, portable_atomic::Ordering::SeqCst);
1389            if let Some(wake) = w.node_wake.as_ref() {
1390                wake.signal();
1391            }
1392        }
1393    }
1394}
1395
1396#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1397impl WakeSignalFd {
1398    /// Spawn the worker. `wake_ctx_ptr` is the `*const WakeCtx`
1399    /// produced by `Executor::wake_ctx_ptr` — same value the
1400    /// runtime wake cb decodes.
1401    ///
1402    /// phase-359 W10 — returns `NodeError` rather than `std::io::Error`. The
1403    /// only caller is `Executor::signal_fd`, and the errno detail it used to
1404    /// carry had no consumer: the one failure a caller can act on is "this
1405    /// platform would not give me the worker", which is what
1406    /// `NotInitialized` says — the eventfd and the task are both subsystems
1407    /// this capability requires and neither is guaranteed.
1408    fn new(wake_ctx_ptr: *const WakeCtx) -> Result<Self, NodeError> {
1409        // SAFETY: `eventfd(2)` with a valid flag; returns -1 on failure.
1410        let fd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) };
1411        if fd < 0 {
1412            return Err(NodeError::NotInitialized);
1413        }
1414        let ctx = portable_atomic_util::Arc::new(SignalFdCtx {
1415            fd,
1416            shutdown: portable_atomic::AtomicBool::new(false),
1417            wake_ctx: wake_ctx_ptr as usize,
1418        });
1419        let arg = portable_atomic_util::Arc::as_ptr(&ctx) as *mut core::ffi::c_void;
1420        // SAFETY: `arg` points at a ctx this struct owns and keeps alive until
1421        // after the join in `Drop`.
1422        let Some(task) = (unsafe {
1423            nros_platform_api::task::PlatformTask::spawn(
1424                signal_fd_worker,
1425                arg,
1426                // A read(2) loop and one atomic store — the smallest stack any
1427                // port will honour is plenty.
1428                8192,
1429                c"nros-wakefd".as_ptr(),
1430            )
1431        }) else {
1432            // SAFETY: nothing else holds the fd — the task never started.
1433            unsafe { libc::close(fd) };
1434            return Err(NodeError::NotInitialized);
1435        };
1436        Ok(Self {
1437            fd,
1438            ctx,
1439            task: Some(task),
1440        })
1441    }
1442
1443    /// Returns the writable eventfd. The caller (typically a POSIX
1444    /// signal handler) writes any non-zero 8-byte value to trigger
1445    /// a wake. `write(2)` on an eventfd is async-signal-safe per
1446    /// `eventfd(2)` man page.
1447    pub fn fd(&self) -> core::ffi::c_int {
1448        self.fd
1449    }
1450}
1451
1452#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1453impl Drop for WakeSignalFd {
1454    fn drop(&mut self) {
1455        self.ctx
1456            .shutdown
1457            .store(true, portable_atomic::Ordering::Release);
1458        // Wake the worker so it re-checks shutdown: it is blocked in `read`,
1459        // which only this write can release.
1460        let one: u64 = 1;
1461        // SAFETY: an 8-byte write to our own eventfd.
1462        unsafe {
1463            libc::write(self.fd, &one as *const u64 as *const core::ffi::c_void, 8);
1464        }
1465        if let Some(task) = self.task.take() {
1466            task.join();
1467        }
1468        // SAFETY: the worker has exited, so nothing else touches the fd.
1469        unsafe { libc::close(self.fd) };
1470    }
1471}
1472
1473/// Phase 124.B.7.b — ISR / interrupt-context wake callback.
1474///
1475/// Same semantics as [`nros_rmw_runtime_wake_cb`] but constrained to
1476/// async-signal-safe / ISR-safe primitives.
1477///
1478/// Per-platform routing:
1479///
1480/// * **POSIX (std)**: `pthread_cond_signal` is NOT on the POSIX
1481///   async-signal-safe function list. Calling from a SIGUSR1
1482///   handler is technically UB. Real fix (Phase 124.B.7.c) routes
1483///   via `signalfd`/`eventfd` + a runtime worker thread — Linux
1484///   only, since neither syscall is POSIX; until that lands,
1485///   signal-handler callers MUST use
1486///   `rclc_guard_condition_trigger` from a **separate thread** (not
1487///   from the handler itself), OR set the wake_flag and rely on
1488///   the next poll deadline. This cb currently aliases the regular
1489///   `wake_cb` and is safe only from non-signal-handler ISR-like
1490///   contexts (e.g. timer thread, kernel callback).
1491///
1492/// * **RTOS no_std (Zephyr/FreeRTOS/ThreadX)**: routes through the
1493///   platform-cffi `condvar_signal_from_isr` slot. Each backend
1494///   uses its ISR-safe variant — `xSemaphoreGiveFromISR`,
1495///   `tx_semaphore_put`, `k_condvar_signal`.
1496///
1497/// `ctx` semantics identical to [`nros_rmw_runtime_wake_cb`].
1498#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1499#[allow(dead_code)] // Public exposure pending B.7.c signalfd worker.
1500pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb_from_isr(ctx: *mut core::ffi::c_void) {
1501    // Today: alias regular wake_cb. POSIX signal-handler safety
1502    // pending B.7.c (signalfd worker-thread forward). Documented in
1503    // the contract above so callers know the boundary.
1504    unsafe { nros_rmw_runtime_wake_cb(ctx) };
1505}
1506
1507/// Phase 258 (Track 2, 2a) — executor-owned component tick slot.
1508///
1509/// The layering-clean half of the W0-B `install` seam's tick fix
1510/// (phase-257 D2). A `nros`-layer `install`/`register_node_borrowed`
1511/// builds an `Arc<ComponentCell>` (the typed/poll-driven component
1512/// state) and enrolls it here via [`Executor::enroll_component`]; the
1513/// executor then drives `tick` on every enrolled slot at the tail of
1514/// each [`spin_once`](Executor::spin_once) — so `install`'d nodes
1515/// (C, C++, **and Rust owned-spin**) tick, closing the
1516/// service-client/action poll gap that the callback-`Arc`-only
1517/// lifetime left open.
1518///
1519/// The executor only sees raw pointers + `extern "C"` fn pointers (no
1520/// `nros` dep — `nros-node` is the lower layer):
1521///
1522/// * `state` — a *leaked* `Arc<ComponentCell>` (via `Arc::into_raw`),
1523///   re-borrowed by the `nros`-side `tick`/`drop` fns. Unlike a
1524///   pub/sub/timer component (kept alive by the executor's per-entity
1525///   callback `Arc` clones), a poll-only component has no callbacks, so
1526///   the slot must own a clone of the cell — hence the paired `drop`.
1527/// * `tick` — `nros`-side `extern "C"` fn that casts `state` back to
1528///   `&ComponentCell`, casts `exec_ctx` back to `*mut Executor`, and
1529///   runs that one cell's tick (mirrors `ExecutorNodeRuntime::run_ticks`).
1530/// * `drop` — `nros`-side `extern "C"` fn run on `Executor::drop` that
1531///   reconstitutes + drops the leaked `Arc`, so the executor owns the
1532///   cell's lifetime.
1533#[derive(Clone, Copy)]
1534pub struct ComponentSlot {
1535    /// Leaked `Arc<ComponentCell>` (opaque to the executor). Owned by
1536    /// this slot — dropped via `drop` on `Executor::drop`.
1537    pub state: *mut core::ffi::c_void,
1538    /// `nros`-side tick trampoline: `(state, exec_ctx)` where `exec_ctx`
1539    /// is `*mut Executor`. Drives one component's `tick`.
1540    pub tick: unsafe extern "C" fn(state: *mut core::ffi::c_void, exec_ctx: *mut core::ffi::c_void),
1541    /// `nros`-side drop trampoline: reconstitutes + drops the leaked
1542    /// `Arc<ComponentCell>` at `state`. Run once on `Executor::drop`.
1543    pub drop: unsafe extern "C" fn(state: *mut core::ffi::c_void),
1544    /// Issue 2020 -- the level that registered the component
1545    /// (`Executor::set_registering_level` at enrol time), whose pass runs its
1546    /// `tick`; `NO_OWNING_LEVEL` on an executor whose levels do not own
1547    /// their slots, where the executor's own worker ticks every component.
1548    pub level: u8,
1549    /// phase-521 W6 — whether the component's `tick` does any work. A tick
1550    /// runs once per spin and is how a component polls a service reply or
1551    /// completes an action goal, so an executor holding one keeps the spin
1552    /// cadence ([`Executor::entry_spin_budget`]). `false` only for a
1553    /// component that states it has no tick.
1554    pub ticks: bool,
1555}
1556
1557// SAFETY: two raw pointers + two extern "C" fn pointers. The `state` pointer's Send/Sync matches the owning
1558// `Executor` (`unsafe impl Send for Executor`); the fn pointers are
1559// Send/Sync by definition.
1560unsafe impl Send for ComponentSlot {}
1561unsafe impl Sync for ComponentSlot {}
1562
1563/// phase-271 — fixed capacity of the per-spin ready-sets (FIFO bitmap + EDF
1564/// heap) and the dispatch loop's upper bound. The executor's callback index is
1565/// carried in a `u64` active-mask (`1u64 << i`), so a callback table can hold at
1566/// most 64 entries regardless of per-entry sizing; the ready-sets are sized to
1567/// this ceiling (stack-transient) so any entry slice up to 64 dispatches
1568/// correctly. `EdfReadySet`'s presence bitmap independently asserts `N <= 64`.
1569pub(crate) const MAX_CALLBACK_SLOTS: usize = 64;
1570
1571/// phase-509 P3 (RFC-0108 D2, its deferred re-probe question) -- whether a
1572/// slot whose backend cannot attribute an arrival (XRCE, uORB) is probed after
1573/// every callback ([`Executor::set_reprobe_each_callback`]) or once per pass.
1574///
1575/// Measured on the host (`reprobe_cost_on_an_unattributed_executor`, release
1576/// build, x86-64, `NROS_EXECUTOR_MAX_CBS=32`): 32 unattributed subscriptions,
1577/// 4 ready per pass, empty callbacks. Once per pass 380-385 ns per pass;
1578/// after every callback 565-577 ns, i.e. +185-191 ns per pass, +46-48 ns per
1579/// callback, 1.65-1.71 ns per extra `has_data` (a mock's: no FFI, no
1580/// transport). The cost is one probe per unsettled slot per CALLBACK, so it
1581/// grows with slots x callbacks, and a real XRCE or uORB probe crosses the
1582/// rmw C ABI on an MCU several times slower than this host. Decided: once
1583/// per pass by default (an XRCE / uORB image keeps today's probe cost), with
1584/// the per-callback probe an opt-in. Pushed readiness (zenoh, which
1585/// attributes every arrival) joins mid-pass whatever this says, for one
1586/// atomic swap per callback.
1587pub const REPROBE_EACH_CALLBACK_DEFAULT: bool = false;
1588
1589/// Issue 1667 — the arena's allocation grain. Every region the arena hands out
1590/// starts on it and is a multiple of it, so a released region is always big
1591/// enough, and aligned enough, to hold its own free-list node.
1592pub(crate) const ARENA_GRAIN: usize = core::mem::align_of::<u64>();
1593
1594/// Issue 1667 — the smallest region the arena hands out: one free-list node
1595/// (`next`, `len`), rounded to the grain.
1596pub(crate) const ARENA_MIN_REGION: usize =
1597    (2 * core::mem::size_of::<usize>()).next_multiple_of(ARENA_GRAIN);
1598
1599/// Issue 1667 — the end of the free list.
1600const ARENA_FREE_NONE: usize = usize::MAX;
1601
1602/// Issue 1496 — one arena region, as an allocation hands it out.
1603#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1604pub(crate) struct FreedRegion {
1605    pub(crate) offset: usize,
1606    pub(crate) len: usize,
1607}
1608
1609/// Phase 305 W3 (issue 0255) — the slots of one executor's RAM remap table
1610/// (across all its nodes).
1611///
1612/// Issue 1971 (RFC-0107 step 1) -- the launch file's rules no longer live
1613/// here: they are one `const` table in flash every executor reads
1614/// ([`super::launch_remap`]). This table holds the RUNTIME rules: `--ros-args
1615/// -r` rules (`crate::ros_args::install_argv_remaps`) and rules a hand-written
1616/// caller declares with [`Executor::declare_remap`]. Its default is
1617/// [`crate::config::RUNTIME_REMAPS`]: 16 on a target with process arguments,
1618/// 0 on bare metal (no argv; `NROS_EXECUTOR_RUNTIME_REMAPS` restores slots).
1619pub const MAX_REMAPS: usize = crate::config::RUNTIME_REMAPS;
1620
1621/// Why [`Executor::declare_remap`] refused a rule -- never a silently dropped
1622/// routing rule.
1623#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1624pub enum RemapRefused {
1625    /// This executor has no runtime remap table: bare metal sizes it 0, since
1626    /// the launch's rules are the flash table and there is no argv. Restore
1627    /// slots with `NROS_EXECUTOR_RUNTIME_REMAPS`, or let codegen emit the rule
1628    /// into the launch table.
1629    NoRuntimeTable,
1630    /// The runtime table is full (`ExecutorSizing::remaps` slots, shared with
1631    /// `--ros-args -r` rules).
1632    TableFull,
1633    /// A string is longer than its slot (node name / namespace 64 bytes,
1634    /// `from` / `to` `MAX_RESOLVED_NAME_LEN`).
1635    TooLong,
1636}
1637
1638impl core::fmt::Display for RemapRefused {
1639    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1640        match self {
1641            Self::NoRuntimeTable => f.write_str(
1642                "declare_remap: this executor has no runtime remap table (0 slots: bare \
1643                 metal takes launch remaps from the flash table, nros_cpp_set_launch_remaps); \
1644                 set NROS_EXECUTOR_RUNTIME_REMAPS to restore runtime slots",
1645            ),
1646            Self::TableFull => f.write_str(
1647                "declare_remap: the runtime remap table is full (NROS_EXECUTOR_RUNTIME_REMAPS \
1648                 slots, shared with --ros-args -r rules)",
1649            ),
1650            Self::TooLong => f.write_str(
1651                "declare_remap: a name is longer than its slot (node name and namespace 64 \
1652                 bytes, from/to MAX_RESOLVED_NAME_LEN)",
1653            ),
1654        }
1655    }
1656}
1657
1658/// Which of [`crate::names::resolve_name_layered`]'s two tiers a stored rule
1659/// belongs to.
1660#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1661pub(crate) enum RemapTier {
1662    /// Projected from the launch file (`declare_remap`, which the generated
1663    /// entry calls). AUTHORITATIVE, scoped to one `(name, namespace)`.
1664    Launch,
1665    /// Parsed from `--ros-args` (`crate::ros_args::install_argv_remaps`).
1666    /// FALLBACK — consulted only when no launch rule matched. Scoped to every
1667    /// node, or to the nodes of one NAME (rcl matches a `node:` prefix
1668    /// against the name alone).
1669    Argv,
1670}
1671
1672/// Phase 305 W3 (issue 0255) — one remap rule. `from`/`to` are stored RAW (as
1673/// written); the lookup in [`Executor::resolve_entity_name_for`] expands both
1674/// sides against the owning node's identity via `crate::names` (exact-FQN
1675/// match, no wildcards).
1676///
1677/// The table holds BOTH tiers, so each rule records which one it belongs to.
1678/// The tier is still decided at resolution, by
1679/// [`crate::names::resolve_name_layered`]'s two parameters, never by where a
1680/// rule sits in this table — `tier_rules` splits the table into those two
1681/// arguments. That is what keeps declaration order from deciding between the
1682/// channels: an argv rule installed before a launch rule still loses to it.
1683pub(crate) struct RemapRule {
1684    /// For [`RemapTier::Launch`]: the owning node. For [`RemapTier::Argv`]:
1685    /// the node name a `name:from:=to` rule was restricted to, or EMPTY for a
1686    /// rule that applies to every node.
1687    pub(crate) node_name: heapless::String<64>,
1688    /// For [`RemapTier::Launch`] only (normalised, `/` for root). Unused by
1689    /// argv rules, whose node prefix carries no namespace.
1690    pub(crate) namespace: heapless::String<64>,
1691    pub(crate) from: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1692    pub(crate) to: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1693    pub(crate) tier: RemapTier,
1694}
1695
1696impl RemapRule {
1697    /// Does this rule belong to `tier` and apply to the node `(node_name, ns)`?
1698    /// `ns` must already be normalised (`/` for root).
1699    fn applies(&self, tier: RemapTier, node_name: &str, ns: &str) -> bool {
1700        self.tier == tier
1701            && match tier {
1702                RemapTier::Launch => {
1703                    self.node_name.as_str() == node_name && self.namespace.as_str() == ns
1704                }
1705                RemapTier::Argv => {
1706                    self.node_name.is_empty() || self.node_name.as_str() == node_name
1707                }
1708            }
1709    }
1710}
1711
1712/// The rules of one tier that apply to one node, in declaration order, as the
1713/// `(from, to)` pairs [`crate::names::resolve_name_layered`] takes. The ONE
1714/// place a stored table is split into the two tiers — the executor's resolver,
1715/// `NodeHandle`, and the Rust component road (through
1716/// `crate::ros_args::argv_fallback`) all read through it.
1717pub(crate) fn tier_rules<'r>(
1718    table: &'r [Option<RemapRule>],
1719    tier: RemapTier,
1720    node_name: &'r str,
1721    namespace: &'r str,
1722) -> impl Iterator<Item = (&'r str, &'r str)> + 'r {
1723    let ns = if namespace.is_empty() { "/" } else { namespace };
1724    table
1725        .iter()
1726        .flatten()
1727        .filter(move |r| r.applies(tier, node_name, ns))
1728        .map(|r| (r.from.as_str(), r.to.as_str()))
1729}
1730
1731/// Issue 1971 -- the two places an executor's remap rules live, read as the
1732/// two tiers of [`crate::names::resolve_name_layered`]:
1733///
1734/// * AUTHORITATIVE: the launch table in flash (`launch`) and, after it, any
1735///   rule a hand-written caller declared at run time (`RemapTier::Launch`
1736///   rows of `runtime`);
1737/// * FALLBACK: the `--ros-args -r` rules (`RemapTier::Argv` rows of
1738///   `runtime`).
1739///
1740/// The tier is decided by which parameter a rule is passed in, never by
1741/// where it sits, so an argv rule installed before a launch rule still loses.
1742#[derive(Clone, Copy)]
1743pub(crate) struct RemapView<'r> {
1744    pub(crate) launch: &'r [super::launch_remap::LaunchRemap],
1745    pub(crate) runtime: &'r [Option<RemapRule>],
1746}
1747
1748impl<'r> RemapView<'r> {
1749    /// The authoritative rules for one node: the flash table's, then the
1750    /// runtime-declared ones.
1751    pub(crate) fn launch_rules<'a>(
1752        self,
1753        node_name: &'a str,
1754        ns: &'a str,
1755    ) -> impl Iterator<Item = (&'a str, &'a str)> + 'a
1756    where
1757        'r: 'a,
1758    {
1759        super::launch_remap::launch_rules_for(self.launch, node_name, ns).chain(tier_rules(
1760            self.runtime,
1761            RemapTier::Launch,
1762            node_name,
1763            ns,
1764        ))
1765    }
1766
1767    /// The fallback (`--ros-args -r`) rules for one node.
1768    pub(crate) fn argv_rules<'a>(
1769        self,
1770        node_name: &'a str,
1771        ns: &'a str,
1772    ) -> impl Iterator<Item = (&'a str, &'a str)> + 'a
1773    where
1774        'r: 'a,
1775    {
1776        tier_rules(self.runtime, RemapTier::Argv, node_name, ns)
1777    }
1778
1779    /// `resolve_name_layered` over both tiers.
1780    pub(crate) fn resolve(
1781        self,
1782        source: &str,
1783        node_name: &str,
1784        ns: &str,
1785    ) -> Result<crate::names::ResolvedName, ()> {
1786        crate::names::resolve_name_layered(
1787            source,
1788            node_name,
1789            ns,
1790            self.launch_rules(node_name, ns),
1791            self.argv_rules(node_name, ns),
1792        )
1793    }
1794}
1795
1796/// The executor's halt flag, in the one shape each configuration can hold.
1797///
1798/// Issue 1177. With an allocator it is refcounted, because `halt_flag()` hands
1799/// a caller its own handle to set from a signal handler or another thread.
1800/// Without one there is no handle to hand out -- a C caller reaches the
1801/// executor through its own pointer and calls `cancel()` directly -- so a plain
1802/// atomic is the whole requirement.
1803///
1804/// `Arc<AtomicBool>` derefs to `AtomicBool`, so every `load`/`store` below is
1805/// written once and only construction and the getter are cfg'd.
1806#[cfg(feature = "alloc")]
1807pub(crate) type HaltFlag = portable_atomic_util::Arc<portable_atomic::AtomicBool>;
1808/// See [`HaltFlag`] above -- the no-allocator shape.
1809#[cfg(not(feature = "alloc"))]
1810pub(crate) type HaltFlag = portable_atomic::AtomicBool;
1811
1812pub struct Executor<'s> {
1813    /// Issue 0656 — the ROS domain this executor's entities belong to.
1814    ///
1815    /// Retained because it was NOT: `open_in` passed `config.domain_id` into
1816    /// `RmwConfig` and dropped it, so every entity built from the executor
1817    /// (rather than from a `Node`, which keeps its own) declared on domain 0
1818    /// whatever `ROS_DOMAIN_ID` said. The value was read, printed, and then not
1819    /// used where it counts — issue 0161's shape.
1820    pub(crate) domain_id: u32,
1821    pub(crate) session: SessionStore,
1822    /// phase-271 (issue 0110) — the six sized tables are no longer inline
1823    /// arrays baked to `nros-node`'s build-time consts; they borrow
1824    /// caller-owned, per-entry-sized storage (`&'s mut` slices carved from a
1825    /// raw `[MaybeUninit<u64>]` backing by [`super::storage::carve`]). Lets a
1826    /// fat native entry and a lean embedded entry in one shared-target
1827    /// workspace each size to its own topology. `Executor` stays non-generic
1828    /// (lifetime only) so the C/C++ FFI keeps wrapping one concrete type.
1829    pub(crate) arena: &'s mut [MaybeUninit<u8>],
1830    pub(crate) arena_used: usize,
1831    /// Issue 1496 / 1667 — the head of the FREE LIST: arena regions given back
1832    /// by a released entry, reused first-fit by the next allocation that fits.
1833    ///
1834    /// The list is INTRUSIVE — each free region holds its own node
1835    /// (`next`, `len`) in its first bytes — and kept in address order, so a
1836    /// release coalesces with both neighbours and nothing bounds how many holes
1837    /// there are. 1496's form was a fixed table of 8 that dropped a region when
1838    /// full and dropped any split tail under 64 B, and those bytes were gone
1839    /// for good: measured under a churning workload (issue 1667), three live
1840    /// entries leaked the whole 74 KiB arena inside 20 000 create/release
1841    /// rounds. A hole that ends at `arena_used` is given back to the bump
1842    /// pointer instead of being listed.
1843    pub(crate) arena_free_head: usize,
1844    /// Issue 1631 — the region the most recent ENTRY allocation handed out,
1845    /// waiting for `emplace_entry` to record it in the entry's
1846    /// `CallbackMeta::arena_len`. Every registration allocates its entry and
1847    /// then emplaces it, so the pair is adjacent; a registration that fails
1848    /// between them leaves a stale value that the next allocation overwrites.
1849    pub(crate) pending_region: Option<FreedRegion>,
1850    pub(crate) entries: &'s mut [Option<CallbackMeta>],
1851    /// Phase 110.B — registered scheduling contexts. Slot 0 is
1852    /// auto-populated with a `Fifo` SC at construction; every entry
1853    /// without an explicit binding maps to it via
1854    /// `sched_context_bindings`.
1855    pub(crate) sched_contexts: &'s mut [Option<super::sched_context::SchedContext>],
1856    /// Per-entry SC binding parallel to `entries`. Defaults to
1857    /// `SchedContextId(0)` (the auto-created Fifo SC).
1858    pub(crate) sched_context_bindings: &'s mut [super::sched_context::SchedContextId],
1859    /// Phase 110.E — user-space sporadic-server budget state per
1860    /// Sporadic-class SC. Slot indices match `sched_contexts`; non-
1861    /// Sporadic slots stay `None`.
1862    pub(crate) sporadic_states: &'s mut [Option<super::sched_context::SporadicState>],
1863    /// Phase 110.E.b — atomic sporadic state + opaque platform-timer
1864    /// handle for ISR-driven refill. Populated by
1865    /// `register_sporadic_timer`; dropped on Executor `Drop` via the
1866    /// stored `destroy_fn`.
1867    #[cfg(feature = "alloc")]
1868    pub(crate) sporadic_atomic_states: &'s mut [Option<(
1869        portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
1870        OpaqueTimerHandle,
1871    )>],
1872    /// Phase 110.G — major-frame length for time-triggered dispatch.
1873    /// `0` (default) disables the TT gate entirely; non-zero enables
1874    /// gating per
1875    /// `SchedContext.tt_window_offset_us / tt_window_duration_us`.
1876    pub(crate) major_frame_us: u32,
1877    pub(crate) trigger: Trigger,
1878    pub(crate) semantics: ExecutorSemantics,
1879    /// Node name for entities the executor registers on the node's behalf.
1880    /// Empty means unset — no liveliness tokens will be declared.
1881    pub(crate) node_name: heapless::String<64>,
1882    /// Phase 228.C — per-tier callback-group filter. Wildcard (register every
1883    /// callback — the single-tier degenerate case + today's behaviour) until
1884    /// [`set_active_groups`](Self::set_active_groups) names a non-empty set;
1885    /// after that this tier's executor accepts only callbacks whose
1886    /// `.callback_group()` is in it, and skips the others at registration.
1887    ///
1888    /// phase-409 — CARVED, and the wildcard is a separate flag rather than an
1889    /// `Option` around the table, because the table itself no longer lives in
1890    /// the value. "Filtering with an empty set" and "not filtering" stay
1891    /// distinguishable, which is the whole content of the old `Option`.
1892    pub(crate) active_groups: super::storage::CarvedVec<'s, super::storage::ActiveGroup>,
1893    /// Whether [`active_groups`](Self::active_groups) is a filter at all.
1894    /// `false` = wildcard (the old `None`).
1895    pub(crate) active_groups_filtering: bool,
1896    /// Node namespace (default: "/").
1897    pub(crate) namespace: heapless::String<64>,
1898    /// Phase 104.C.2 — rclcpp-style `add_node` table. Holds the
1899    /// per-Node metadata (name, namespace, rmw, locator, default
1900    /// SchedContext) for every Node attached to this Executor. The
1901    /// implicit "primary" Node (NodeId(0)) mirrors `node_name` +
1902    /// `namespace` above and is auto-populated on first use.
1903    ///
1904    /// phase-409 — CARVED. `NodeId` IS an index into this table, so the
1905    /// carved vector must keep push order and never gain a `swap_remove`.
1906    pub(crate) nodes: super::storage::CarvedVec<'s, super::node_record::NodeRecord>,
1907    /// Phase 272 (RFC-0047) — config-seeded node → sched-context bindings, keyed by the node's
1908    /// fully-qualified `(name, namespace)` pair. `NodeBuilder::build` consults this table to set a
1909    /// node's `default_sched` when no explicit `.sched()` was given. Empty ⇒ every node stays
1910    /// `SchedContextId(0)` (byte-identical to pre-272 behaviour). Sized by `MAX_NODES` (at most
1911    /// one tier per node — RFC-0047 OQ1).
1912    ///
1913    /// phase-409 — CARVED.
1914    pub(crate) node_sched_table: super::storage::CarvedVec<'s, super::storage::NodeSchedEntry>,
1915    /// Phase 273 (RFC-0047) — config-seeded per-callback-group sched bindings, keyed by the node's
1916    /// fully-qualified `(name, namespace)` pair PLUS the callback-group name. Overrides the node
1917    /// default for a callback created in that group. Empty ⇒ no per-group binding (node default
1918    /// stands). Sized by `MAX_CBS` — an upper bound on distinct callback-group bindings (you can
1919    /// never have more distinct group bindings than max callbacks).
1920    ///
1921    /// phase-409 (issue 0961) — CARVED, and it is the reason the phase exists:
1922    /// at ~168 B per slot this was `MAX_CBS` * 168 bytes INSIDE the value, so
1923    /// raising the handle limit from 14 to 36 — a fix for an unrelated failure —
1924    /// added ~3.7 KiB to the stack frame of every function that moves an
1925    /// `Executor`, and overflowed a 320 KiB part's main thread.
1926    pub(crate) group_sched_table: super::storage::CarvedVec<'s, super::storage::GroupSchedEntry>,
1927    /// Phase 305 W3 (issue 0255) — launch-baked per-node remap rules, keyed by
1928    /// the declaring node's `(name, namespace)` identity. Entries store the
1929    /// rule RAW (as written in launch); [`Self::resolve_entity_name`] expands
1930    /// both sides against the owning node's identity at lookup, via the shared
1931    /// `crate::names` seam (the same semantics the Rust `ExecutorSink` path
1932    /// applies). Declaration order is match order (first rule wins).
1933    /// Issue 0563 — CARVED, not inline. As a `heapless::Vec<RemapRule,
1934    /// MAX_REMAPS>` this one field was 6664 bytes of an 11632-byte `Executor`
1935    /// (57%), which is what made building an executor a ~9.3 KB stack
1936    /// temporary and overflowed the Zephyr Cortex-M main stack (issue 0552).
1937    /// It is the seventh sized table; phase-271 moved the other six here.
1938    /// `remap_len` is the fill cursor — occupied slots are `[0, remap_len)`.
1939    pub(crate) remap_table: &'s mut [Option<RemapRule>],
1940    pub(crate) remap_len: usize,
1941    /// Issue 1971 -- this executor's own launch remap table, when one was set
1942    /// on it ([`Self::set_launch_remaps`]); `None` reads the image's
1943    /// ([`super::launch_remap::launch_remaps`]), which the generated entry
1944    /// installs once before any tier is set up. A borrow of `.rodata`, never
1945    /// a copy.
1946    pub(crate) launch_remaps: Option<&'static [super::launch_remap::LaunchRemap]>,
1947    /// Phase 258 (Track 2, 2a) — executor-owned component tick registry.
1948    /// Enrolled by [`Executor::enroll_component`] (from `nros`'s
1949    /// `install`/`register_node_borrowed`); each slot's `tick` runs at the
1950    /// tail of [`spin_once`](Self::spin_once); each slot's `drop` runs on
1951    /// `Executor::drop`. Bounded `MAX_NODES` (matches `nodes`).
1952    ///
1953    /// phase-409 — CARVED.
1954    pub(crate) component_slots: super::storage::CarvedVec<'s, ComponentSlot>,
1955    /// Issue 2146 -- callback slots the host DEFERS (`set_deferred`): the
1956    /// spin queues them instead of running them. One bit per slot.
1957    pub(crate) deferred_mask: u64,
1958    /// Issue 2146 -- deferred slots queued by a spin and not yet run by
1959    /// `run_deferred` / `run_deferred_handle`.
1960    pub(crate) deferred_pending: u64,
1961    /// Issue 2146 -- the host's `(notifier, ctx)`, called once per queueing.
1962    pub(crate) defer_notify: Option<(super::types::DeferNotify, *mut core::ffi::c_void)>,
1963    /// Phase 104.C.3 — extra sessions opened by `node_builder.rmw()`
1964    /// calls that named a backend different from the Executor's
1965    /// primary session. Indexed by `NodeRecord.session_idx`
1966    /// (1..=N maps to `extra_sessions[N-1]`; idx 0 is the primary
1967    /// `self.session`). Sized by `NROS_EXECUTOR_MAX_NODES` since one
1968    /// extra session per Node is the worst case.
1969    ///
1970    /// phase-409 — CARVED, and the biggest single win: `ConcreteSession` is
1971    /// 524 B on the island, so this table alone was ~3.1 KiB of the value.
1972    /// Declared AFTER `session` so field-order drop still closes the primary
1973    /// session before the extras (`CarvedVec` owns its elements' drop).
1974    pub(crate) extra_sessions: super::storage::CarvedVec<'s, session::ConcreteSession>,
1975    /// Issue 0436 — `(rmw_name, locator)` for each entry of `extra_sessions`,
1976    /// the extras' equivalent of `primary_rmw_name` / `primary_locator`.
1977    ///
1978    /// Without it an extra session is ANONYMOUS, and
1979    /// `NodeBuilder::resolve_session_slot` could only recognise one by finding a
1980    /// previously-registered `NodeRecord` bound to it. Sessions opened by
1981    /// `open_multi*` have no such Node yet, so the FIRST `.rmw("zenoh")` node fell
1982    /// through to "open a new session" — a SECOND zenoh session, with an empty
1983    /// locator, which fails (see `primary_rmw_name`'s note: zenoh-pico's global
1984    /// state is a process singleton). That is why the PX4 bridge could open both
1985    /// sessions and then fail to bind its outward Node.
1986    ///
1987    /// Written unconditionally by `open_multi*`, but the only READER is
1988    /// `NodeBuilder::resolve_session_slot`, which is `rmw-cffi`-gated — so
1989    /// without that feature the field is genuinely unread and the
1990    /// workspace's `-D dead_code` is right to say so. Allow it exactly
1991    /// there rather than blanket-allowing a field that must stay live in
1992    /// every configuration that can reach the reader.
1993    ///
1994    /// phase-409 — CARVED.
1995    #[cfg_attr(not(feature = "rmw-cffi"), allow(dead_code))]
1996    pub(crate) extra_session_ids: super::storage::CarvedVec<'s, super::storage::ExtraSessionId>,
1997    /// Phase 156 — primary session's rmw name + locator, captured
1998    /// at `open*` time so `NodeBuilder::resolve_session_slot`'s
1999    /// cache lookup can detect when a `.rmw(name).locator(loc)`
2000    /// matches the primary (slot 0) instead of falling through to
2001    /// `CffiRmw::open_with_rmw` and trying to open a SECOND
2002    /// session against the same backend. zenoh-pico's global state
2003    /// is a process singleton; opening twice fails. Empty when
2004    /// constructed via `from_session(_ptr)` without `open*`
2005    /// recording the metadata; in that case the cache check
2006    /// degrades to "always miss" (today's behaviour).
2007    pub(crate) primary_rmw_name: heapless::String<32>,
2008    pub(crate) primary_locator: heapless::String<128>,
2009    // phase-359 W3 — `portable_atomic_util::Arc`, not `std::sync::Arc`. Same
2010    // atomically-refcounted pointer, available without `std`, and it compiles on
2011    // std too. W3 left the GATE on `std` because the public `halt_flag()` getter
2012    // was std-gated; W10 split that impl, so the field joined `wake_flag` on
2013    // `alloc`.
2014    //
2015    // Issue 1177 — the ALLOCATOR was never the requirement of the FLAG, only of
2016    // SHARING it. The flag is an `AtomicBool`; what needs an allocator is
2017    // `halt_flag()`, the getter that hands a caller its own refcounted handle to
2018    // set from a signal handler or another thread. Gating the flag on `alloc`
2019    // therefore gated the ability to STOP a no_std executor on a feature it does
2020    // not need -- and `nros-c`'s spin loop calls `cancel`/`is_halted`/
2021    // `enter_spin_loop`/`exit_spin_loop`/`is_spinning` unconditionally, so an
2022    // embedded image did not merely lose cancellation, it failed to compile.
2023    //
2024    // `HaltFlag` is the one type name that differs, so every reader below is
2025    // written once: `Arc<AtomicBool>` derefs to `AtomicBool`, so `load`/`store`
2026    // are identical on both sides and only construction and the getter are
2027    // cfg'd.
2028    pub(crate) halt_flag: HaltFlag,
2029    /// Is a spin loop running right now? — the observable behind
2030    /// `is_spinning()` (phase-417 W4.c, rclcpp's `Executor::is_spinning`).
2031    ///
2032    /// A plain atomic, not refcounted like `halt_flag`: nothing hands this one
2033    /// out. `halt_flag()` is a public getter that gives a caller its own
2034    /// handle to set from another thread; `spinning` is only ever set by the
2035    /// executor's own loops through `&self`, so it needs no shared ownership.
2036    ///
2037    /// Issue 1177 — this used to ride `alloc` "for the same reason the impl
2038    /// that reads it does". It never needed an allocator itself; it was gated
2039    /// by ASSOCIATION with `halt_flag`, and when that association turned out to
2040    /// be wrong this went with it.
2041    pub(crate) spinning: portable_atomic::AtomicBool,
2042    /// Phase 104.C.6 — shared executor wake flag. Any source of work
2043    /// (foreign thread handing off a callback, signal handler, future
2044    /// per-session vtable wake hook) sets this; `spin_once` swaps it to
2045    /// `false` on entry and, if it was `true`, polls every session with
2046    /// a 0-ms timeout instead of blocking. Lets one notification wake
2047    /// the executor regardless of which session the user is currently
2048    /// blocked on (the multi-RMW bridge case).
2049    // phase-359 W3 — ONE wake flag, both flavours. W2 had to leave this pair
2050    // alone because `wake_handle()` hands it out as a `std::sync::Arc`;
2051    // converting that signature is what lets the two collapse. Gated on
2052    // `alloc` (which `std` implies) because the Arc needs an allocator.
2053    #[cfg(feature = "alloc")]
2054    pub(crate) wake_flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
2055    /// Phase 124.B.2 — wake condvar paired with `wake_flag`. The
2056    /// runtime-supplied wake callback (`nros_rmw_runtime_wake_cb` in
2057    /// nros-rmw-cffi) writes `wake_flag = true` AND signals the executor's
2058    /// `NodeWake` (a condvar pair, ~~`wake_cv`~~ / ~~`wake_mu`~~, before
2059    /// phase-359 W10). `spin_once` blocks on it with a deadline instead of
2060    /// calling `drive_io` with the user's timeout — sub-poll-period wake
2061    /// latency.
2062    ///
2063    /// Poll-only backends (NULL `set_wake_callback` slot) leave the
2064    /// cb uninstalled; the cv wait still fires on its deadline,
2065    /// then drive_io(0) drains whatever the backend's internal
2066    /// poll has buffered.
2067    /// Phase 130.3 — Zephyr+std uses `nros_platform_wake_*` (k_sem)
2068    /// instead of `std::sync::Condvar` because Zephyr's libc
2069    /// `pthread_cond_timedwait` hangs past its deadline. `None`
2070    /// when the platform provider didn't link a wake primitive
2071    /// (e.g. test builds with `rmw-cffi` but no `platform-*`
2072    /// feature); spin_once falls back to driving the transport
2073    /// for the full timeout in that case.
2074    // phase-359 W2 — ONE field, not one per flavour. `portable_atomic_util::Arc`
2075    // compiles on std too, and `std` implies `alloc`, so the std and alloc arms
2076    // were two spellings of the same thing. The inner `NodeWake` was already
2077    // shared; only the Arc differed.
2078    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2079    pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
2080    /// Phase 130.4 — true when at least one session's backend
2081    /// installed the wake callback. Drives whether `spin_once`
2082    /// uses the wake-primitive wait (`NodeWake` / `Condvar`) or
2083    /// just `drive_io(timeout_ms)`. Poll-only backends
2084    /// (XRCE-DDS-Client, the dust-DDS shim) leave this `false`;
2085    /// Cyclone sets it — it fills the slot and backs it with a real
2086    /// `dds_set_listener` listener; the wait then becomes a no-op sleep that
2087    /// starves reliable retransmission (Phase 127.C.4 root
2088    /// cause: server's `send_response` flushes 100 ms once, then a
2089    /// blind `wait_ms(100)` sleeps with zero session activity, so
2090    /// the agent's ACK arrives into a stalled session and reliable
2091    /// redelivery never fires).
2092    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2093    pub(crate) has_async_wake: bool,
2094    /// phase-397 W2 — one bit per entry slot whose handle accepted an entity
2095    /// waker (`arm_entity_wake`): its arrivals mark [`WakeCtx::ready`] and wake
2096    /// this executor alone. A clear bit is an entry whose backend cannot
2097    /// attribute an arrival, which the session wake still covers. W3 needs
2098    /// both halves: the ready bits are only a complete answer when every live
2099    /// entry that can have data is attributed.
2100    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2101    pub(crate) entity_attributed: u64,
2102    /// phase-397 W3 — attributed slots the NEXT spin must ask `has_data`
2103    /// about, although no arrival marked them: each one that was ready on
2104    /// this spin (dispatched or not, since a dispatch may leave data behind
2105    /// and a failed trigger dispatches nothing), one held mid-dispatch when
2106    /// its arrival came, and one just armed (its data may predate the
2107    /// registration, which the ABI says the caller closes with one
2108    /// `has_data`). A slot drops out the first spin its `has_data` is false.
2109    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2110    pub(crate) entity_recheck: u64,
2111    /// Issue 2060 — attributed SUBSCRIPTION slots whose `has_data` the scan
2112    /// asks EVERY pass, arrival or not, because a status event is polled
2113    /// there. Every backend that carries subscription status events polls
2114    /// them from `has_data` (zenoh's deadline / liveliness / matched walk,
2115    /// Cyclone's `subscription_take_event` drain through the cffi adapter),
2116    /// and those are the events about NOTHING ARRIVING — the last publisher
2117    /// left, a deadline passed. A pushed slot is otherwise asked only after
2118    /// an arrival, so once the last sample was taken nothing asked again and
2119    /// the matched count never fell.
2120    ///
2121    /// Set by [`Executor::subscription_handle_mut`], the one road by which a
2122    /// status-event callback reaches an arena subscription's backend handle;
2123    /// cleared with the entry. A slot with no status callback is never in it,
2124    /// so an idle image that registered none still makes no probe at all —
2125    /// the phase-397 W3 property `readiness_probe_count` pins. No wake is
2126    /// added either: the cadence is the spin's own (one park per pass), and
2127    /// each backend rate-limits its own walk inside `has_data`.
2128    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2129    pub(crate) entity_status_polled: u64,
2130    /// phase-397 W3 — `has_data` calls the readiness scan has made, for the
2131    /// test that proves a pushed spin makes none. Wrapping; read only through
2132    /// [`Executor::readiness_probe_count`].
2133    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2134    pub(crate) readiness_probes: u32,
2135    /// phase-509 P3 (RFC-0108 D2) -- after every callback the dispatcher
2136    /// picks again from the ready set as it is now. Pushed readiness (an
2137    /// attributed slot's arrival) joins mid-pass always; a slot whose backend
2138    /// cannot attribute (XRCE, uORB, a mock) joins mid-pass only when this is
2139    /// set, by a `has_data` probe after each callback. Unset, such a slot is
2140    /// probed once per pass, at the scan. See
2141    /// [`Executor::set_reprobe_each_callback`] for the measured cost.
2142    pub(crate) reprobe_each_callback: bool,
2143    /// Phase 124.B.2 — opaque context Arc handed to backends via
2144    /// `set_wake_callback`. Lazy-allocated on first install; stays
2145    /// alive for the Executor's lifetime so the raw pointer stored
2146    /// in backends remains valid.
2147    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2148    pub(crate) wake_ctx: Option<portable_atomic_util::Arc<WakeCtx>>,
2149    /// Issue 1385 — has THIS executor handed `wake_ctx`'s pointer to a
2150    /// backend? The predicate `clear_wake_signal` reads at teardown.
2151    ///
2152    /// Not `wake_ctx.is_some()`: the ctx is also allocated by `signal_fd()`
2153    /// and by every guard condition, neither of which installs anything on a
2154    /// session. Clearing on those would clear a callback this executor never
2155    /// owned. (Since phase-436 a tier over a BORROWED session does install: it
2156    /// joins the session's wake fan-out, and its teardown is leaving it.)
2157    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2158    pub(crate) wake_cb_installed: bool,
2159    /// phase-436 — this executor's place in the wake fan-out of its PRIMARY
2160    /// session, when it has one. Every executor over a session joins the same
2161    /// entry, so an arrival wakes all of them, not only the one that happened
2162    /// to install into the backend's single slot. See `wake_fanout`.
2163    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2164    pub(crate) wake_membership: Option<super::wake_fanout::Membership>,
2165    /// phase-467 Row 8 — the graph-change target the BACKEND holds a pointer
2166    /// to, owned here.
2167    ///
2168    /// This box is the whole answer to issue 1385 on this path. The backend
2169    /// fires the graph-change edge from a transport or worker thread, so
2170    /// whatever it dereferences must outlive every such call — and the thing
2171    /// the caller has in hand is a `rcl_guard_condition_t` in ITS storage,
2172    /// which the caller may free or reuse at any time. Handing that pointer
2173    /// over would BE 1385. So the executor keeps its own handle to the same
2174    /// arena flag, at a heap address that does not move when the executor
2175    /// does, and [`clear_graph_change_signal`] retires the backend's pointer
2176    /// BEFORE this box drops.
2177    ///
2178    /// [`clear_graph_change_signal`]: Self::clear_graph_change_signal
2179    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2180    pub(crate) graph_change_target: Option<alloc::boxed::Box<GuardCondition>>,
2181    /// Phase 124.B.7.c — lazily-allocated Linux signalfd worker.
2182    /// Owned by the Executor; spawned on first `signal_fd()` call.
2183    /// Drop joins the worker thread and closes the fd.
2184    #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
2185    pub(crate) signal_fd: Option<WakeSignalFd>,
2186    /// Issue 1812 — CARVED from the backing's [`ParamHome`](super::storage::ParamHome)
2187    /// when the sizing carves a store; with `alloc`, one heap allocation
2188    /// otherwise. Without `alloc` there is no heap arm, and an executor whose
2189    /// backing carves no home refuses parameters by name instead.
2190    #[cfg(feature = "param-store")]
2191    pub(crate) params: Option<crate::param_store::Placed<'s, crate::param_store::ParamState<'s>>>,
2192    /// phase-382 W3' — the parameter store's slots, CARVED from this
2193    /// executor's backing and still uninitialised. Taken (left empty) the
2194    /// first time something builds the store; empty from the start when the
2195    /// sizing carved none, in which case the store comes from the heap.
2196    #[cfg(feature = "param-store")]
2197    pub(crate) param_slots: &'s mut [MaybeUninit<nros_params::ParameterSlot>],
2198    /// Issue 1812 — the services' home, CARVED beside the store's slots and
2199    /// still uninitialised. Taken with them; empty when the sizing carved no
2200    /// store.
2201    #[cfg(feature = "param-store")]
2202    pub(crate) param_home: &'s mut [MaybeUninit<super::storage::ParamHome>],
2203    /// Issue 1706 -- the parameter store's one allocation was refused by the
2204    /// platform heap. Recorded so the refusal is REPORTED once and not retried
2205    /// on every declaration: a retry cannot succeed on a heap that only shrinks
2206    /// from here, and on Zephyr each attempt prints its own `HEAP EXHAUSTED`.
2207    #[cfg(feature = "param-store")]
2208    pub(crate) param_store_refused: bool,
2209    /// phase-446 W6 -- the parameters each node's contract DECLARES, baked by
2210    /// the entry from the SystemModel. Empty (nothing checked) until an entry
2211    /// hands one over with [`Self::set_declared_params`].
2212    #[cfg(feature = "param-store")]
2213    pub(crate) declared_params: nros_params::DeclaredParams,
2214    /// phase-425 W3b — the `/clock` subscription this image installed, if any.
2215    /// `None` means no time source; the value is the handle so it can be
2216    /// cancelled when `use_sim_time` goes false.
2217    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2218    pub(crate) sim_time_source: Option<HandleId>,
2219    /// phase-425 W3b — the LAST REQUESTED state of `use_sim_time`, which is not
2220    /// the same thing as the installed state: a request can arrive before any
2221    /// node exists (`nros::main!` declares parameters BEFORE it registers
2222    /// components), and there is no node to hang a subscription on until then.
2223    /// `reconcile_ros_time_source` closes the gap on each spin.
2224    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2225    pub(crate) sim_time_requested: bool,
2226    /// Whether `use_sim_time` was ever stated to THIS executor.
2227    ///
2228    /// `sim_time_requested` alone cannot answer it: `false` is both "told to
2229    /// turn it off" and "never told anything", and the two license opposite
2230    /// actions on a PROCESS-GLOBAL gate. Without this, every executor that
2231    /// never heard of the parameter wrote `set_active(false)` on its first
2232    /// spin -- because the gate defaults to TRUE, so `false != is_active()`
2233    /// held -- and switched off a simulated clock somebody else installed.
2234    /// One `cargo test` process is exactly that situation: 344 sibling tests
2235    /// spin an executor each, and the sim-time test failed 3 runs of 3 while
2236    /// passing alone.
2237    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2238    pub(crate) sim_time_stated: bool,
2239    /// phase-430 W1 — the one-shot that says "`use_sim_time` is on and `/clock`
2240    /// has never spoken", so a MISCONFIGURED image is distinguishable from a
2241    /// merely PAUSED one. Armed when the source attaches, disarmed by the first
2242    /// sample or by the report. The policy, and the argument for its unit, are
2243    /// in [`crate::time_source::SilenceWatch`].
2244    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2245    pub(crate) sim_time_silence: crate::time_source::SilenceWatch,
2246    #[cfg(feature = "lifecycle-services")]
2247    pub(crate) lifecycle:
2248        Option<alloc::boxed::Box<crate::lifecycle_services::LifecycleRuntimeState>>,
2249    /// Wall-clock instant at which the previous `spin_once` exited. The
2250    /// timer delta on the next call is measured from this point so any
2251    /// time the caller spent between `spin_once` invocations (e.g. an
2252    /// explicit `thread::sleep`) counts toward timer accumulation just
2253    /// like time spent inside `drive_io`.
2254
2255    /// Monotonic clock endpoint for no_std timer accounting.
2256    // phase-359 W4 — ONE field. The std twin held an `Instant`; both now hold
2257    // µs from the single `now_us()` read.
2258    pub(crate) last_spin_end_us: Option<u64>,
2259    /// The executor's monotonic µs clock: `ExecutorConfig::clock_us` when the
2260    /// caller supplied one, else [`default_clock_us_fn`].
2261    ///
2262    /// phase-359 W10 — no longer `no_std`-only. The std build read an
2263    /// `Instant` through a separate field instead, which is how "what time is
2264    /// it" had two answers in one crate.
2265    pub(crate) clock_us_fn: Option<fn() -> u64>,
2266
2267    /// Consecutive `drive_io` failures on the PRIMARY session (issue 0324).
2268    ///
2269    /// Reset to 0 by any successful drive. A session that has died — router
2270    /// gone, lease expired, socket closed — used to keep returning `Ok(())`
2271    /// from `spin()` forever: the node looked alive, publishes went nowhere,
2272    /// and no callback fired. Nothing in the crate could report it; a
2273    /// `git grep` for a health surface found none, so every such
2274    /// investigation started from packet captures (issue 0268 burned days
2275    /// this way).
2276    ///
2277    /// A COUNTER rather than propagating the error out of `spin_once`,
2278    /// deliberately: `drive_io` returns `Err` for any non-OK backend code, and
2279    /// whether a benign poll timeout maps to one is backend-specific. Aborting
2280    /// the spin on a single failure would risk turning a transient into a dead
2281    /// node. A counter cannot regress behaviour and still makes the condition
2282    /// observable via [`Executor::session_io_failures`].
2283    pub(crate) consecutive_io_failures: u32,
2284    /// RFC-0052 W3b.2 — wall-clock (epoch µs) source for age monitors.
2285    pub(crate) epoch_us_fn: Option<fn() -> u64>,
2286    /// phase-483 W3 — the component registration frame the `nros` facade
2287    /// installs for the duration of a component's `register`, so the
2288    /// declarative entity constructors on [`Node`](super::node::Node) can reach
2289    /// the sink that owns the component's cell. Opaque here: nros-node sits
2290    /// below the crate that defines the frame. `None` outside a registration.
2291    pub(crate) component_frame: Option<core::ptr::NonNull<()>>,
2292    /// RFC-0052 W3b.4 — baked contract-monitor table (empty = uncontracted
2293    /// image; every monitor path below folds away).
2294    pub(crate) monitor_table: &'static [super::monitor::MonitorSpec],
2295    pub(crate) monitor_states: [super::monitor::MonitorState; super::monitor::MAX_MONITORS],
2296    /// Alive supervision, one record per SchedContext: dispatches are counted
2297    /// in the dispatch path where the SC index is already resolved, and read
2298    /// once per monitor tick. CARVED from the backing like `sporadic_states`.
2299    /// It began as an inline `[_; MAX_SC]` pair on the claim that `MAX_SC` is a
2300    /// build-time constant; it is a SIZING KNOB (`ExecutorSizing::sc`), so the
2301    /// header grew with it and `the_executor_value_does_not_scale_with_the_knobs`
2302    /// caught it (issue 0961).
2303    pub(crate) alive_slots: &'s mut [super::monitor::AliveSlot],
2304    /// phase-476 W0 — per-callback-slot lifetime tags, parallel to
2305    /// `entries`: the generation a handle must match, and the owning node.
2306    pub(crate) slot_tags: &'s mut [super::types::SlotTag],
2307    /// W3b.5 — baked subscriber age-contract table (empty = none).
2308    pub(crate) age_table: &'static [super::monitor::AgeMonitorSpec],
2309    pub(crate) age_states: [super::monitor::AgeState; super::monitor::MAX_AGE_MONITORS],
2310    /// phase-462 W3b — baked hazard table (empty = no contracted hazard). Its
2311    /// state lives in the rows' own `static` cells, so a hazard costs this
2312    /// value nothing.
2313    pub(crate) hazard_table: &'static [super::monitor::HazardSpec],
2314    /// phase-462 W4 -- the baked mode variants of the two monitor tables,
2315    /// index = mode id (0 = the base). Empty for an image with no modes.
2316    pub(crate) mode_tables: &'static [super::monitor::ModeTables],
2317    /// phase-462 W4 -- the last mode request this executor saw, and the mode
2318    /// whose tables it judges by. They differ only after a request for a mode
2319    /// this executor has no table for.
2320    pub(crate) mode_seen: u16,
2321    /// phase-462 W4c -- monotonic ms of the previous mode check, the start
2322    /// of the window a request seen at this one could have waited in.
2323    pub(crate) mode_checked_ms: Option<u32>,
2324    pub(crate) mode_applied: u16,
2325    /// W3b.5 — hook invoked on `DeadlineAction::Fault` (panic when unset).
2326    pub(crate) fault_fn: Option<fn(&super::monitor::Violation)>,
2327    /// phase-503 — the reaction hook and its context: called when a
2328    /// supervised route passes its deadline (`route-deadline-runtime`), and on
2329    /// `DeadlineAction::Fault` when no `fault_fn` is set.
2330    pub(crate) reaction_hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
2331    /// phase-509 P9b (RFC-0108 "As built in P9b") -- the lock every path that
2332    /// touches this executor takes once a second thread can reach it (one
2333    /// worker per level over one executor). `None` -- every one-level image --
2334    /// makes each guard a no-op. Read through a raw pointer by
2335    /// [`super::exec_lock::ExecGuard::acquire`], so it is written only while
2336    /// no second thread exists.
2337    pub(crate) exec_lock: Option<super::exec_lock::ExecLock>,
2338    /// phase-509 P9b item 2 -- the callbacks running in an open window, one
2339    /// record per window, each in its pass's frame (`exec_lock::Running`).
2340    /// Read and written only under the lock; null while no window is open.
2341    pub(crate) running: *mut super::exec_lock::Running,
2342    /// Issue #515 — the spin cadence audit runs once, on the first spin
2343    /// that carries a non-zero timeout (which is where the tier's
2344    /// declared spin period becomes visible to the executor).
2345    pub(crate) spin_quantization_checked: bool,
2346    /// phase-436 W1 — the cadence the quantization audit (issue 0515) actually
2347    /// ran against. Recorded because the audit only LOGS: without this a
2348    /// regression that fed it the wrong number is invisible to a test, and
2349    /// capping the park by the timer deadline is exactly such a regression
2350    /// waiting to happen.
2351    pub(crate) spin_quantization_us: u64,
2352    /// Release jitter: worst observed lateness of a `spin_period` wake
2353    /// against its own nominal schedule, in microseconds.
2354    ///
2355    /// Issue #515 added a STATIC audit for one cause of cadence error --
2356    /// a period that is not a multiple of the spin period. Nothing measured
2357    /// the actual release instants, and `audit_spin_quantization` says so in
2358    /// as many words: "the rate is preserved and no activation is dropped,
2359    /// so every runtime rule is (correctly) silent -- the jitter stays
2360    /// invisible until someone measures cadence on target."
2361    ///
2362    /// This is that measurement, and it is the one `cyclictest` exists to
2363    /// report: the deviation between a timer's programmed wake-up and the
2364    /// instant the task actually resumed, whose MAXIMUM is the figure of
2365    /// merit for a real-time system.
2366    ///
2367    /// `spin_period` already holds both numbers -- `next_us` is the nominal
2368    /// deadline and `now_us()` the actual -- and when the loop is late it
2369    /// skips the sleep and discards the difference. That difference is the
2370    /// jitter.
2371    pub(crate) max_release_jitter_us: u64,
2372    /// Clock reading at the previous `spin_once` entry, for the interval the
2373    /// jitter is measured over. `None` until the first spin, and reset by
2374    /// `clear_release_jitter_stats` so a window starts clean.
2375    pub(crate) last_spin_entry_us: Option<u64>,
2376    /// phase-474 I11 -- the release grid of a `spin_once(timeout)` loop with
2377    /// no declared cadence. See [`ReleaseGrid`].
2378    pub(crate) grid: ReleaseGrid,
2379    /// Jitter high-water at the previous `release-jitter-runtime` check, so
2380    /// the rule reports the DELTA. Same shape as `overruns_reported` on a
2381    /// timer header, and for the same reason: a maximum that has not moved
2382    /// is the fault already reported, not a new one.
2383    pub(crate) jitter_reported_us: u64,
2384    /// phase-509 P6 (RFC-0108 D3) -- this executor's one worker: its band
2385    /// (every dispatch level), its park (RFC-0106) and its stack record. The
2386    /// fields moved here from the executor unchanged; `Executor` is the
2387    /// one-level facade over the scheduler / worker split.
2388    pub(crate) worker: super::scheduler::Worker,
2389    /// The pacing quantum the spin loop was last driven at, in microseconds.
2390    /// This is the bound the jitter rule judges against -- the caller's own
2391    /// declared cadence, so nothing further has to be declared.
2392    pub(crate) spin_nominal_us: u64,
2393    /// Cadence DECLARED by the driver, in microseconds; `0` = not declared.
2394    ///
2395    /// Takes precedence over the `spin_once` timeout when judging release
2396    /// jitter, because the two are not the same quantity. The timeout is how
2397    /// long a spin may BLOCK waiting for work; the cadence is how often the
2398    /// loop intends to come round. A driver that blocks up to 10 ms and then
2399    /// sleeps 30 ms has a 30 ms cadence and a 10 ms timeout, and judging it
2400    /// against the timeout reports a loop that is late on every single wake.
2401    pub(crate) spin_nominal_declared_us: u64,
2402    /// phase-436 A2b -- the declared cadence is met by the `spin_once` WAIT
2403    /// itself (`set_spin_nominal_wait_paced_us`), not by a sleep the caller
2404    /// adds between spins (`set_spin_nominal_us`). A wait-paced cadence runs
2405    /// on the release grid and is worth one park granule; a self-paced one is
2406    /// judged from entry to entry and is worth a microsecond.
2407    pub(crate) spin_nominal_wait_paced: bool,
2408    /// Wakes that were already past their nominal deadline, and wakes total.
2409    ///
2410    /// The maximum alone cannot distinguish one bad wake from a loop that is
2411    /// late every single cycle, and those are different faults: the first is
2412    /// a glitch, the second means the period cannot be met at all.
2413    pub(crate) late_wakes: u32,
2414    pub(crate) total_wakes: u32,
2415    /// phase-436 A2c -- late wakes whose lateness exceeds the granularity the
2416    /// measurement was made at (`release_jitter_granularity_us`). On a loop
2417    /// paced by a tick-granular park nearly every wake is late by a fraction
2418    /// of a tick, by design; `late_wakes` counts those, this does not.
2419    pub(crate) late_wakes_beyond_granule: u32,
2420    /// Issue 0514 / 1635 / phase-474 I1 -- what happens to a detected contract
2421    /// violation: the log switch, the drain-and-report hook, the image's sink,
2422    /// and the ring (CARVED, phase-409) with its total and dropped counters.
2423    /// See [`super::monitor::ViolationChannel`].
2424    pub(crate) violations: super::monitor::ViolationChannel<'s>,
2425    /// Issue 0790 — hooks that run BEFORE the session is closed, while every
2426    /// entity still works. The load-bearing half: a device releasing a bus or
2427    /// parking an actuator has to publish its final state / answer its last
2428    /// request from HERE, because after teardown it cannot.
2429    ///
2430    /// `[Option<_>; N]` rather than a `heapless::Vec`, deliberately: the handle
2431    /// a caller holds IS the slot index, so a removal must leave every other
2432    /// index where it was. A `Vec`'s `swap_remove` would silently re-point one
2433    /// live handle at a different callback, and its `remove` would re-point all
2434    /// of them. Clearing a slot to `None` also lets the next registration reuse
2435    /// it, which a fill cursor could not.
2436    pub(crate) pre_shutdown_hooks:
2437        [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
2438    /// Issue 0790 — hooks that run AFTER the session is closed. rclcpp's
2439    /// `add_on_shutdown_callback` / `rclcpp::on_shutdown`. See
2440    /// [`Self::pre_shutdown_hooks`] for why this is an array.
2441    pub(crate) on_shutdown_hooks:
2442        [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
2443}
2444
2445impl<'s> Executor<'s> {
2446    /// phase-271 — assemble an executor over already-carved, caller-owned
2447    /// storage (`slices`, from [`super::storage::carve`]). Fills every
2448    /// non-storage field and reserves SC slot 0 for the default Fifo SC (carve
2449    /// left it `None`). The single builder shared by every constructor path.
2450    fn assemble(session: SessionStore, slices: super::storage::ExecutorSlices<'s>) -> Self {
2451        let super::storage::ExecutorSlices {
2452            arena,
2453            entries,
2454            sched_contexts,
2455            sched_context_bindings,
2456            sporadic_states,
2457            #[cfg(feature = "alloc")]
2458            sporadic_atomic_states,
2459            remaps,
2460            nodes,
2461            extra_sessions,
2462            extra_session_ids,
2463            node_sched_table,
2464            component_slots,
2465            active_groups,
2466            group_sched_table,
2467            monitor_violations,
2468            alive_slots,
2469            slot_tags,
2470            params: param_slots,
2471            param_home,
2472        } = slices;
2473        // phase-382 W3' — only an executor that can build a store keeps the
2474        // carved slots; without `param-services` the region (sized by the
2475        // build, not by a feature, so the layout never depends on one) is
2476        // simply never initialised. The services' home (issue 1812) is
2477        // zero-sized without the feature.
2478        #[cfg(not(feature = "param-store"))]
2479        let _ = (param_slots, param_home);
2480        // Slot 0 = the auto-created default Fifo SC (see field doc). carve
2481        // initialised the whole table to `None`; populate the reserved slot.
2482        if let Some(slot0) = sched_contexts.first_mut() {
2483            *slot0 = Some(super::sched_context::SchedContext::default());
2484        }
2485        // phase-412 -- stamp the self-report before anything can fail. `init`
2486        // is idempotent, so an image with two executors need not decide which
2487        // one owns the record, and `note_arena_capacity` records the slice this
2488        // executor was actually handed rather than the compiled constant: the
2489        // arena's placement is the caller's choice (issue 0900), so the two can
2490        // legitimately differ and the difference is worth seeing.
2491        crate::boot_report::init();
2492        crate::boot_report::note_arena_capacity(arena.len());
2493        crate::boot_report::checkpoint(crate::boot_report::Stage::ExecutorReady);
2494        Self {
2495            // `assemble` is reached from session-only entry points that have no
2496            // config, so 0 is the floor rather than a choice; `open_in` and any
2497            // binding that knows better overwrite it via `set_domain_id`.
2498            domain_id: 0,
2499            session,
2500            arena,
2501            arena_used: 0,
2502            arena_free_head: ARENA_FREE_NONE,
2503            pending_region: None,
2504            entries,
2505            sched_contexts,
2506            sched_context_bindings,
2507            sporadic_states,
2508            #[cfg(feature = "alloc")]
2509            sporadic_atomic_states,
2510            major_frame_us: 0,
2511            trigger: Trigger::Any,
2512            semantics: ExecutorSemantics::RclcppExecutor,
2513            node_name: heapless::String::new(),
2514            active_groups,
2515            active_groups_filtering: false,
2516            nodes,
2517            node_sched_table,
2518            group_sched_table,
2519            remap_table: remaps,
2520            remap_len: 0,
2521            launch_remaps: None,
2522            component_slots,
2523            deferred_mask: 0,
2524            deferred_pending: 0,
2525            defer_notify: None,
2526            extra_sessions,
2527            extra_session_ids,
2528            primary_rmw_name: heapless::String::new(),
2529            primary_locator: heapless::String::new(),
2530            namespace: {
2531                let mut ns = heapless::String::new();
2532                let _ = ns.push_str("/");
2533                ns
2534            },
2535            // Issue 1177 -- the only cfg the halt flag still needs. `HaltFlag`
2536            // is refcounted with an allocator and plain without one, and
2537            // nothing below this line can tell the difference.
2538            #[cfg(feature = "alloc")]
2539            halt_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
2540            #[cfg(not(feature = "alloc"))]
2541            halt_flag: portable_atomic::AtomicBool::new(false),
2542            spinning: portable_atomic::AtomicBool::new(false),
2543            #[cfg(feature = "alloc")]
2544            wake_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
2545            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2546            node_wake: super::node_wake::NodeWake::new().map(portable_atomic_util::Arc::new),
2547            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2548            wake_ctx: None,
2549            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2550            wake_cb_installed: false,
2551            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2552            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2553            wake_membership: None,
2554            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2555            graph_change_target: None,
2556            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2557            has_async_wake: false,
2558            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2559            entity_attributed: 0,
2560            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2561            entity_recheck: 0,
2562            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2563            entity_status_polled: 0,
2564            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2565            readiness_probes: 0,
2566            // Phase 141.A.3 — alloc-mode wake state init. Constructed
2567            // eagerly (NodeWake allocation) so the runtime cb can be
2568            // installed lazily on first session without a fallible
2569            // alloc inside spin_once. `None` when the platform
2570            // provider reports the primitive unavailable (matches
2571            // the std-RTOS path's `node_wake: Option<...>`).
2572            #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
2573            signal_fd: None,
2574            #[cfg(feature = "param-store")]
2575            params: None,
2576            #[cfg(feature = "param-store")]
2577            param_slots,
2578            #[cfg(feature = "param-store")]
2579            param_home,
2580            #[cfg(feature = "param-store")]
2581            param_store_refused: false,
2582            #[cfg(feature = "param-store")]
2583            declared_params: nros_params::DeclaredParams::EMPTY,
2584            #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2585            sim_time_source: None,
2586            #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2587            sim_time_requested: false,
2588            #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2589            sim_time_stated: false,
2590            #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2591            sim_time_silence: crate::time_source::SilenceWatch::new(),
2592            #[cfg(feature = "lifecycle-services")]
2593            lifecycle: None,
2594            // Initialise the spin endpoint to construction time so the
2595            // very first `spin_once` credits time the caller spent
2596            // *before* it (e.g. setup, an explicit pre-spin sleep) just
2597            // like time spent between later calls.
2598            // One field, one seed now (phase-359 W10): the clock is absolute
2599            // on every flavour, so it is READ here rather than assumed to be
2600            // zero at construction. The std arm used to seed `Some(0)` because
2601            // its epoch WAS construction; with one provider that special case
2602            // is gone.
2603            last_spin_end_us: default_clock_us_fn().map(|clock| clock()),
2604            clock_us_fn: default_clock_us_fn(),
2605            consecutive_io_failures: 0,
2606            // RFC-0052 W3b.5 — a build with a wall clock gets one by default so
2607            // age monitors activate without extra wiring; a build with neither a
2608            // platform port nor a host gets `None`, and its board installs
2609            // `config.epoch_us` in `from_session_in`/`open`.
2610            //
2611            // phase-359 W10 — the cfg pair that used to be here moved into
2612            // `default_epoch_us_fn`, beside the `default_clock_us_fn` it
2613            // mirrors. (The old comment pointed at "the `not(std)` blocks
2614            // above" for the board install; those blocks are no longer
2615            // cfg-gated — the config override applies on every flavour now.)
2616            epoch_us_fn: super::types::default_epoch_us_fn(),
2617            component_frame: None,
2618            monitor_table: &[],
2619            monitor_states: [super::monitor::MonitorState::default(); super::monitor::MAX_MONITORS],
2620            age_table: &[],
2621            age_states: [super::monitor::AgeState::default(); super::monitor::MAX_AGE_MONITORS],
2622            hazard_table: &[],
2623            mode_tables: &[],
2624            mode_seen: 0,
2625            mode_checked_ms: None,
2626            mode_applied: 0,
2627            fault_fn: None,
2628            reaction_hook: None,
2629            exec_lock: None,
2630            running: core::ptr::null_mut(),
2631            spin_quantization_checked: false,
2632            reprobe_each_callback: REPROBE_EACH_CALLBACK_DEFAULT,
2633            spin_quantization_us: 0,
2634            max_release_jitter_us: 0,
2635            last_spin_entry_us: None,
2636            grid: ReleaseGrid {
2637                period_us: 0,
2638                next_us: 0,
2639                skipped: 0,
2640            },
2641            jitter_reported_us: 0,
2642            worker: super::scheduler::Worker::one_level(),
2643            spin_nominal_us: 0,
2644            spin_nominal_declared_us: 0,
2645            spin_nominal_wait_paced: false,
2646            late_wakes: 0,
2647            total_wakes: 0,
2648            late_wakes_beyond_granule: 0,
2649            violations: super::monitor::ViolationChannel::new(monitor_violations),
2650            alive_slots,
2651            slot_tags,
2652            // Issue 0790 — both phase tables start empty. An image that
2653            // registers nothing pays these `None`s and a two-slot scan at
2654            // teardown, and nothing else.
2655            pre_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
2656            on_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
2657        }
2658    }
2659
2660    /// Create an owning executor over caller-supplied `backing`, sized by
2661    /// `sizing`. The core, non-generic, per-entry entry point (the `alloc`
2662    /// [`from_session`](Self::from_session) convenience leaks a default backing
2663    /// and calls this; the macro / C FFI pass an entry-sized backing).
2664    ///
2665    /// # Safety
2666    /// `backing` must be ≥ `sizing.u64_len()` words, stay alive for `'s`, and
2667    /// not be otherwise accessed while the executor lives (it aliases it).
2668    /// `sizing.cbs` must be ≤ 64 (the `u64` ready-set bitmask ceiling).
2669    pub(crate) unsafe fn from_session_in(
2670        session: session::ConcreteSession,
2671        backing: &'s mut [MaybeUninit<u64>],
2672        sizing: super::storage::ExecutorSizing,
2673    ) -> Self {
2674        let slices = unsafe { super::storage::carve(backing, sizing) };
2675        Self::assemble(SessionStore::Owned(session), slices)
2676    }
2677
2678    /// Create a borrowing executor over caller-supplied `backing`, sized by
2679    /// `sizing`. Counterpart to [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in) for
2680    /// the per-tier / C model (the session is borrowed, not owned).
2681    ///
2682    /// # Safety
2683    /// - `session_ptr` must point to a valid session that outlives the executor
2684    ///   and is not moved/dropped while it exists.
2685    /// - `backing` obligations as in [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in).
2686    pub(crate) unsafe fn from_session_ptr_in(
2687        session_ptr: *mut session::ConcreteSession,
2688        backing: &'s mut [MaybeUninit<u64>],
2689        sizing: super::storage::ExecutorSizing,
2690    ) -> Self {
2691        let slices = unsafe { super::storage::carve(backing, sizing) };
2692        Self::assemble(SessionStore::Borrowed(session_ptr), slices)
2693    }
2694}
2695
2696impl Executor<'static> {
2697    /// Create an executor from an already-opened session, using the build-time
2698    /// default sizing (`MAX_CBS`/`MAX_SC`/`ARENA_SIZE`). Convenience for
2699    /// std/alloc callers that don't size per-entry: it leaks a default-sized
2700    /// backing (executor-lifetime, one-time) and calls
2701    /// [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in). Per-entry sizing goes through
2702    /// the macro / `open_in` instead.
2703    #[cfg(feature = "alloc")]
2704    pub fn from_session(session: session::ConcreteSession) -> Self {
2705        let sizing = super::storage::ExecutorSizing::DEFAULT;
2706        // SAFETY: the backing is exactly `sizing.u64_len()` words,
2707        // `'static`, and uniquely owned by this executor.
2708        unsafe { Self::from_session_in(session, default_backing(sizing), sizing) }
2709    }
2710
2711    /// [`from_session`](Self::from_session) with an [`ExecutorConfig`], so a
2712    /// caller that brings its own session can also bring its own clock.
2713    ///
2714    /// issue 0709 / issue 0687 — `from_session` takes no config, and that is
2715    /// the path the no-port population uses: it accepts any `Session`, so a
2716    /// consumer with a non-cffi backend reaches the executor through it and had
2717    /// NO way to install `clock_us`. phase-359 W10 argued the `std`-without-a-
2718    /// port clock fallbacks could go because "a caller with a clock installs it
2719    /// through `ExecutorConfig::clock_us`" — true for `open`, false here, which
2720    /// is half of why that deletion was reverted.
2721    ///
2722    /// Only the timing sources are read from `config`; identity (locator,
2723    /// domain, names) belongs to the session the caller already opened. As in
2724    /// [`open_in`](Self::open_in), a `None` field does NOT clobber the
2725    /// platform default — it means "not specified" (the bug issue 0671
2726    /// records).
2727    #[cfg(feature = "alloc")]
2728    pub fn from_session_with(
2729        session: session::ConcreteSession,
2730        config: &super::types::ExecutorConfig<'_>,
2731    ) -> Self {
2732        let mut executor = Self::from_session(session);
2733        if let Some(clock) = config.clock_us {
2734            executor.clock_us_fn = Some(clock);
2735            executor.last_spin_end_us = Some(clock());
2736        }
2737        if let Some(epoch) = config.epoch_us {
2738            executor.epoch_us_fn = Some(epoch);
2739        }
2740        executor
2741    }
2742
2743    /// Create an executor from a borrowed session pointer, default-sized. The
2744    /// `alloc` convenience wrapper over
2745    /// [`from_session_ptr_in`](Self::from_session_ptr_in) — leaks a default
2746    /// backing so existing callers keep the zero-storage-arg signature.
2747    ///
2748    /// # Safety
2749    /// - `session_ptr` must point to a valid, initialized session that lives at
2750    ///   least as long as this executor.
2751    /// - The caller must not move or drop the session while the executor exists.
2752    #[cfg(feature = "alloc")]
2753    pub(crate) unsafe fn from_session_ptr(session_ptr: *mut session::ConcreteSession) -> Self {
2754        let sizing = super::storage::ExecutorSizing::DEFAULT;
2755        // SAFETY: the backing as in `from_session`; session_ptr contract
2756        // forwarded to `from_session_ptr_in`.
2757        unsafe { Self::from_session_ptr_in(session_ptr, default_backing(sizing), sizing) }
2758    }
2759}
2760
2761/// phase-474 I11 -- the release grid of a `spin_once(timeout)` loop with no
2762/// declared cadence: the period it was built for (`0` = no grid yet), the next
2763/// scheduled release, and the releases skipped because the loop came back a
2764/// whole period or more after they were due (the timer `Skip` policy: the
2765/// latest due point is served once, the older ones are dropped and counted,
2766/// never released in a burst). The park is bounded by `next_us`, so a loop is
2767/// released on `anchor + k * period` instead of `period` after its previous
2768/// spin ended. Scales with no knob; named in the header-size budget.
2769#[derive(Clone, Copy, Debug, Default)]
2770pub(crate) struct ReleaseGrid {
2771    pub(crate) period_us: u64,
2772    pub(crate) next_us: u64,
2773    pub(crate) skipped: u32,
2774}
2775
2776/// The two cadences a `period_us` timer alternates between under a `spin_us`
2777/// spin period, or `None` when the audit (issue #515) has nothing to say.
2778///
2779/// Split out of `audit_spin_quantization` so the decision can be asserted:
2780/// the audit only LOGS, so a regression in what it decides is otherwise
2781/// invisible to a test.
2782///
2783/// `None` for a period of zero, for a period that divides the spin period —
2784/// and, since issue 1321, for a timer whose activations are NOT paced by the
2785/// spin period at all. The warning's arithmetic is wall microseconds on both
2786/// sides; a `Ros` timer's activations are quantised by the `/clock` STEP, so
2787/// the sentence it would print ("activations will alternate between X and Y
2788/// us") is about nothing.
2789pub(crate) fn spin_quantization_span(
2790    clock_source: TimerClockSource,
2791    period_us: u64,
2792    spin_us: u64,
2793) -> Option<(u64, u64)> {
2794    if spin_us == 0 || period_us == 0 || period_us.is_multiple_of(spin_us) {
2795        return None;
2796    }
2797    // phase-521 W6 — an entry's idle budget is no cadence: its park ends at
2798    // each timer's own deadline, so nothing quantizes.
2799    if spin_us >= ENTRY_IDLE_BUDGET.as_micros() as u64 {
2800        return None;
2801    }
2802    if !clock_source.remaining_is_wall_time() {
2803        return None;
2804    }
2805    let early_us = (period_us / spin_us) * spin_us;
2806    Some((early_us, early_us.saturating_add(spin_us)))
2807}
2808
2809/// phase-436 — which deadline source bounded the last park.
2810///
2811/// Attribution is what turns "why did we wake" from a guess into a number.
2812/// Recording it costs one byte per spin and is the difference between a
2813/// schedulability argument you can audit and one you have to trust.
2814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2815#[repr(u8)]
2816pub enum WakeSourceId {
2817    /// The `spin_once` caller's own timeout — always a member of the source
2818    /// set, which is why the park is never unbounded.
2819    CallerBudget,
2820    /// A registered timer's next expiry (issue 1192).
2821    Timer,
2822    /// The backend's next internal event (lease keepalive, heartbeat,
2823    /// ACK-NACK). Folded into the same `min` as everything else rather than
2824    /// capping the budget separately upstream, so one rule decides the park
2825    /// and one attribution explains it.
2826    Session,
2827    /// A source the PLATFORM registered: a `k_poll` event set, an `epoll`
2828    /// group, a FreeRTOS queue set, a ThreadX event-flags group, or a
2829    /// bare-metal device ISR. The core never names an RTOS; it only asks each
2830    /// registered source when it will next need the executor.
2831    Platform(u8),
2832    /// phase-503 — the earliest deadline of a pending supervised route: the
2833    /// park ends there so `route-deadline-runtime` judges it on time.
2834    RouteDeadline,
2835}
2836
2837/// How many platform sources one executor can carry. A plain const, not a
2838/// sizing knob: the table is inline, so no allocator is required on the no_std
2839/// targets this seam mainly exists for.
2840pub const MAX_WAKE_SOURCES: usize = 8;
2841
2842/// phase-521 W3 -- the most `ReadinessSource::Waitable` handles one park
2843/// multiplexes: one per session of a tier, and the POSIX port's own cap.
2844#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2845pub(crate) const MAX_WAITABLE_HANDLES: usize = 8;
2846
2847/// When this source will next need the executor, in the executor's clock,
2848/// as microseconds FROM NOW.
2849///
2850/// `u64::MAX` means "nothing pending" — the source does not shorten the park.
2851/// That is how an ASYNC source spells itself: a socket or a DDS listener
2852/// contributes no deadline and instead breaks the park by signalling the wake
2853/// primitive, so async and deadline sources compose without a second
2854/// mechanism.
2855pub type NextDeadlineFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void) -> u64;
2856
2857/// Park until `deadline_us` from now, or until something signals, whichever
2858/// comes first.
2859///
2860/// Returns [`PARK_SIGNALLED`] (`0`) when woken by an event, [`PARK_EXPIRED`]
2861/// (`1`) when the deadline expired, [`PARK_YIELDED`] (`2`) when the slot did
2862/// not wait but handed the wait to its framework's scheduler, and a negative
2863/// value when this build cannot park at all.
2864///
2865/// phase-521 W8 (c) (RFC-0113 D9, ruling 2026-11-03) -- the YIELDED answer is
2866/// how a tier that a framework schedules waits: a PX4 tier's slot arms its
2867/// work item's `ScheduleDelayed(deadline_us)` (or `ScheduleClear()` when the
2868/// bound is the caller's budget alone) and returns at once, and `spin_once`
2869/// then returns to the work queue instead of blocking. An arrival reaches the
2870/// tier through `ScheduleNow()`. No executor API widens for it: the bound the
2871/// slot receives is the one every park receives.
2872///
2873/// A DEADLINE, not a duration, and that distinction is what makes bare metal
2874/// work: `nros-baremetal-common`'s own note says `wfi` with no pending
2875/// interrupt deadlocks, so a board must arm a timer compare BEFORE waiting.
2876/// A `sleep(duration)` seam cannot express that — the sleeper does not know
2877/// whether anyone armed an interrupt. A `ParkUntilFn` does, because the
2878/// deadline is exactly what the compare is armed to.
2879pub type ParkUntilFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void, deadline_us: u64) -> i8;
2880
2881/// Issue 2225 (RFC-0113 D9) -- the wake half of a framework's park slot:
2882/// called after every signal of the executor's wake object, from the
2883/// signalling context. A framework whose tier is a scheduled item rather than
2884/// a parked thread (a PX4 work item) re-schedules the tier here. Must be
2885/// thread-safe, non-blocking and O(1).
2886pub type WakeHookFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void);
2887
2888/// [`ParkUntilFn`]: woken by an event (`NROS_PLATFORM_PARK_SIGNALLED`).
2889pub const PARK_SIGNALLED: i8 = 0;
2890/// [`ParkUntilFn`]: the deadline expired (`NROS_PLATFORM_PARK_EXPIRED`).
2891pub const PARK_EXPIRED: i8 = 1;
2892/// [`ParkUntilFn`]: the slot handed the wait to its framework's scheduler and
2893/// returned without waiting (`NROS_PLATFORM_PARK_YIELDED`); `spin_once`
2894/// returns without dispatching. See [`ParkUntilFn`].
2895pub const PARK_YIELDED: i8 = 2;
2896
2897/// A registered deadline contributor.
2898#[derive(Clone, Copy)]
2899pub(crate) struct WakeSourceSlot {
2900    pub(crate) next: NextDeadlineFn,
2901    pub(crate) ctx: *mut core::ffi::c_void,
2902}
2903
2904impl<'s> Executor<'s> {
2905    /// Phase 228.B (RFC-0015) — construct a tier task's executor that **shares**
2906    /// a session opened once by the orchestration `main()`.
2907    ///
2908    /// In the per-tier execution model `main()` opens one RMW session, then
2909    /// spawns one RTOS task per priority tier; each task calls this to get an
2910    /// [`Executor`] over the *same* session (the `Borrowed` session store — this
2911    /// executor neither owns nor closes it), registers its tier's callback
2912    /// groups, and spins. Thin alias over [`ExecutorFfi::from_session_ptr`](crate::executor::ffi::ExecutorFfi::from_session_ptr).
2913    ///
2914    /// # Safety
2915    /// `session` must outlive every executor/task built from it (the
2916    /// orchestration `main()` holds it and never returns / WFIs), and must not
2917    /// be mutated except through these executors' spin calls.
2918    #[cfg(feature = "alloc")]
2919    pub unsafe fn open_with_session(session: *mut session::ConcreteSession) -> Executor<'static> {
2920        #[allow(unused_mut)]
2921        let mut exec = unsafe { Executor::<'static>::from_session_ptr(session) };
2922        // phase-436 — join the session's wake fan-out, so this tier is woken
2923        // by its arrivals instead of sleeping out its whole bound.
2924        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2925        exec.install_wake_signal_on_primary();
2926        exec
2927    }
2928
2929    /// phase-271 — per-tier borrowed-session constructor over caller-supplied,
2930    /// per-tier-sized `backing`. The sized counterpart to
2931    /// [`open_with_session`](Self::open_with_session): each RTOS tier task owns
2932    /// its own backing so tiers size independently.
2933    ///
2934    /// # Safety
2935    /// `session` obligations as in [`open_with_session`](Self::open_with_session);
2936    /// `backing`/`sizing` as in [`from_session_ptr_in`](Self::from_session_ptr_in).
2937    pub(crate) unsafe fn open_with_session_in(
2938        session: *mut session::ConcreteSession,
2939        backing: &'s mut [MaybeUninit<u64>],
2940        sizing: super::storage::ExecutorSizing,
2941    ) -> Self {
2942        #[allow(unused_mut)]
2943        let mut exec = unsafe { Self::from_session_ptr_in(session, backing, sizing) };
2944        // phase-436 — as `open_with_session`.
2945        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2946        exec.install_wake_signal_on_primary();
2947        exec
2948    }
2949
2950    /// Raw pointer to this executor's RMW session, for the per-tier model:
2951    /// the boot task opens the one session via [`Executor::open`] (the RMW
2952    /// session is a process-wide singleton — opening twice fails), then hands
2953    /// this pointer to each spawned tier task's
2954    /// [`Executor::open_with_session`]. The boot task's executor owns the
2955    /// session and outlives every borrower, so the pointer stays valid for the
2956    /// program's life. Works for both `Owned` and `Borrowed` stores.
2957    ///
2958    /// # Safety
2959    /// The returned pointer aliases `self.session`. Callers must keep `self`
2960    /// alive (not moved/dropped) for as long as any tier executor uses the
2961    /// pointer, and must only touch the session through executor spin calls
2962    /// (the RMW backend serializes concurrent access through its own locks).
2963    pub fn session_ptr(&mut self) -> *mut session::ConcreteSession {
2964        let ptr = &mut *self.session as *mut session::ConcreteSession;
2965        // phase-436 — the owner of an OWNED session claimed its wake fan-out
2966        // while this executor value could still move (inside `open*`). The
2967        // address handed out here is the one every tier is opened over, so
2968        // the entry is keyed by it from now on.
2969        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2970        if let (SessionStore::Owned(_), Some(m)) = (&self.session, self.wake_membership) {
2971            m.fanout().rekey(ptr as *mut core::ffi::c_void);
2972        }
2973        ptr
2974    }
2975
2976    /// Opaque, `Send` form of [`session_ptr`](Self::session_ptr) — the per-tier
2977    /// model hands this to each spawned tier task (it can cross the RTOS task /
2978    /// thread boundary, which a bare `*mut` cannot). See [`SessionHandle`].
2979    ///
2980    /// # Safety
2981    /// Same contract as [`session_ptr`](Self::session_ptr): `self` (the session
2982    /// owner) must outlive every executor built from the handle.
2983    pub fn session_handle(&mut self) -> SessionHandle {
2984        SessionHandle(self.session_ptr())
2985    }
2986
2987    /// Open an [`Executor`] over the session a [`SessionHandle`] refers to (the
2988    /// `Borrowed` store — neither owns nor closes it). The tier-task counterpart
2989    /// to [`session_handle`](Self::session_handle).
2990    ///
2991    /// # Safety
2992    /// The handle's session must still be alive (its owning executor not moved
2993    /// or dropped); access only through executor spin calls.
2994    #[cfg(feature = "alloc")]
2995    pub unsafe fn open_with_session_handle(handle: SessionHandle) -> Executor<'static> {
2996        unsafe { Executor::<'static>::open_with_session(handle.0) }
2997    }
2998
2999    /// phase-271 — sized counterpart to
3000    /// [`open_with_session_handle`](Self::open_with_session_handle) (per-tier
3001    /// backing).
3002    ///
3003    /// # Safety
3004    /// As [`open_with_session_handle`](Self::open_with_session_handle) +
3005    /// [`from_session_ptr_in`](Self::from_session_ptr_in).
3006    pub(crate) unsafe fn open_with_session_handle_in(
3007        handle: SessionHandle,
3008        backing: &'s mut [MaybeUninit<u64>],
3009        sizing: super::storage::ExecutorSizing,
3010    ) -> Self {
3011        unsafe { Self::open_with_session_in(handle.0, backing, sizing) }
3012    }
3013
3014    /// issue 1571 — a SPAWNED tier's executor over its slot of the entry's
3015    /// [`TierExecutorBacking`](super::TierExecutorBacking) — the road every
3016    /// board's `run_tiers` takes for each tier but the boot one.
3017    ///
3018    /// The slot's TYPE is the size: it is `ExecutorSizing::DEFAULT.u64_len()`
3019    /// words and this opens with `ExecutorSizing::DEFAULT`, so the pairing is
3020    /// exact by construction and lives here, once, rather than at five boards.
3021    /// [`open_with_session`](Self::open_with_session) is the heap twin this
3022    /// replaced on those roads.
3023    ///
3024    /// # Safety
3025    /// `session` obligations as in [`open_with_session`](Self::open_with_session).
3026    #[cfg(feature = "alloc")]
3027    pub(crate) unsafe fn open_with_session_slot(
3028        session: *mut session::ConcreteSession,
3029        slot: &'s mut super::TierExecutorBackingSlot,
3030    ) -> Self {
3031        unsafe {
3032            Self::open_with_session_in(
3033                session,
3034                &mut slot[..],
3035                super::storage::ExecutorSizing::DEFAULT,
3036            )
3037        }
3038    }
3039
3040    /// issue 1571 — [`open_with_session_slot`](Self::open_with_session_slot)
3041    /// over a [`SessionHandle`], for the boards whose tier tasks carry one.
3042    ///
3043    /// # Safety
3044    /// As [`open_with_session_handle`](Self::open_with_session_handle).
3045    #[cfg(feature = "alloc")]
3046    pub(crate) unsafe fn open_with_session_handle_slot(
3047        handle: SessionHandle,
3048        slot: &'s mut super::TierExecutorBackingSlot,
3049    ) -> Self {
3050        unsafe { Self::open_with_session_slot(handle.0, slot) }
3051    }
3052
3053    /// Phase 228.C — set this tier executor's active callback-group filter. The
3054    /// generated per-tier task calls this before registering nodes; afterwards
3055    /// only callbacks whose `.callback_group()` is in `groups` register here.
3056    /// An empty slice (or never calling it) leaves the wildcard — register all
3057    /// callbacks (the single-tier degenerate case + today's behaviour).
3058    pub fn set_active_groups(
3059        &mut self,
3060        groups: &[(&str, &str, &str)],
3061    ) -> Result<(), super::types::BindError> {
3062        // phase-409 — the table is CARVED and reused, so clear before refilling;
3063        // the old `Option<heapless::Vec>` got a fresh empty vector each call.
3064        self.active_groups.clear();
3065        if groups.is_empty() {
3066            self.active_groups_filtering = false;
3067            return Ok(());
3068        }
3069        for (name, ns, group) in groups {
3070            // issue 1172 — this used to be `if s.push_str(g).is_ok()`, which
3071            // DROPPED an oversized group name and left the tier filtering on a
3072            // set that silently lacked it. It fails closed (the entity is never
3073            // created), so the symptom is a missing entity with nothing to read
3074            // that says why. Same contract as `declare_remap` below: too long,
3075            // or table full, is the caller's error.
3076            let key = super::storage::node_identity_hash(name, ns);
3077            let mut s = heapless::String::new();
3078            let why = if s.push_str(group).is_err() {
3079                Some(super::types::BindError::NameTooLong)
3080            } else if self.active_groups.push((key, s)).is_err() {
3081                Some(super::types::BindError::TableFull)
3082            } else {
3083                None
3084            };
3085            if let Some(why) = why {
3086                // FAIL CLOSED, and say so. Returning here with a PARTIAL table
3087                // would leave the tier filtering on a subset — the same silent
3088                // narrowing, one step later. An empty table with filtering ON
3089                // accepts NOTHING (`group_filter_accepts` matches on an empty
3090                // slice), so the tier registers no entities at all: visible in
3091                // behaviour, where a missing entry is not. The `Err` is for the
3092                // caller that can report it; the state is safe for the one that
3093                // cannot.
3094                self.active_groups.clear();
3095                self.active_groups_filtering = true;
3096                return Err(why);
3097            }
3098        }
3099        self.active_groups_filtering = true;
3100        Ok(())
3101    }
3102
3103    /// The current callback-group filter, or `None` for the wildcard.
3104    fn active_group_filter(&self) -> Option<&[super::storage::ActiveGroup]> {
3105        self.active_groups_filtering
3106            .then(|| self.active_groups.as_slice())
3107    }
3108
3109    /// Phase 228.C — whether a callback in `group` should register in this
3110    /// executor under the current filter. The wildcard accepts everything.
3111    pub fn group_active(&self, node_name: &str, node_namespace: &str, group: &str) -> bool {
3112        group_filter_accepts(
3113            self.active_group_filter(),
3114            super::storage::node_identity_hash(node_name, node_namespace),
3115            group,
3116        )
3117    }
3118
3119    /// Set the node name and namespace used for liveliness tokens.
3120    ///
3121    /// Called by `open()` to propagate config values. When the executor
3122    /// creates entities, these values are attached to the
3123    /// Phase 156 — record the primary session's backend identity
3124    /// (rmw name + locator) so `NodeBuilder::resolve_session_slot`
3125    /// can detect when a `.rmw(name)` matches the primary instead
3126    /// of opening a SECOND backend session against the same
3127    /// singleton (zenoh-pico's `g_session` is process-wide;
3128    /// opening twice fails). `Executor::open*` calls this
3129    /// automatically; the C surface (`rclc_executor_init`) calls
3130    /// it manually because it constructs via `from_session_ptr`
3131    /// which doesn't know the open metadata. Empty strings = "no
3132    /// primary identity tracked"; the cache check degrades to
3133    /// always-miss.
3134    pub(crate) fn set_primary_identity(&mut self, rmw_name: &str, locator: &str) {
3135        self.primary_rmw_name.clear();
3136        let _ = self.primary_rmw_name.push_str(rmw_name);
3137        self.primary_locator.clear();
3138        let _ = self.primary_locator.push_str(locator);
3139    }
3140
3141    /// `TopicInfo`/`ServiceInfo` so the zenoh backend can declare liveliness.
3142    /// Issue 0656 — set the ROS domain for entities this executor declares.
3143    ///
3144    /// For bindings that build an executor from an existing session
3145    /// (`from_session_ptr_in`), where no `ExecutorConfig` is available and the
3146    /// domain would otherwise stay at its 0 floor.
3147    pub fn set_domain_id(&mut self, domain_id: u32) {
3148        self.domain_id = domain_id;
3149    }
3150
3151    /// The ROS domain this executor declares entities on.
3152    pub fn domain_id(&self) -> u32 {
3153        self.domain_id
3154    }
3155
3156    pub fn set_node_identity(&mut self, node_name: &str, namespace: &str) {
3157        self.node_name.clear();
3158        let _ = self.node_name.push_str(node_name);
3159        if !namespace.is_empty() {
3160            self.namespace.clear();
3161            let _ = self.namespace.push_str(namespace);
3162        }
3163    }
3164
3165    // =========================================================================
3166    // Phase 305 W3 (issue 0255) — per-node launch remap table
3167    // =========================================================================
3168
3169    /// Record one launch `<remap from= to=/>` rule for the node identified by
3170    /// `(node_name, namespace)`. Rules are matched in declaration order (first
3171    /// wins) by [`Self::resolve_entity_name_for`]. Errors when a string
3172    /// overflows its slot or the table is at `MAX_REMAPS` — callers surface
3173    /// this rather than silently dropping a routing rule.
3174    ///
3175    /// **This declares an AUTHORITATIVE rule.** Declaration order settles
3176    /// which of two launch rules for one name wins; it is not how an argv rule
3177    /// is ranked against a launch rule. The `--ros-args` rules share this table
3178    /// but are tagged as the fallback tier, and resolution reads both tiers
3179    /// through [`crate::names::resolve_name_layered`] — so an argv rule
3180    /// installed BEFORE a launch rule still loses to it.
3181    ///
3182    /// Issue 1971 -- a GENERATED entry no longer calls this: its launch rules
3183    /// are the flash table ([`super::launch_remap::set_launch_remaps`]). This
3184    /// writes the RUNTIME table, for hand-written callers; on bare metal that
3185    /// table is 0 slots by default and the call is refused by name
3186    /// ([`RemapRefused::NoRuntimeTable`]).
3187    pub fn declare_remap(
3188        &mut self,
3189        node_name: &str,
3190        namespace: &str,
3191        from: &str,
3192        to: &str,
3193    ) -> Result<(), RemapRefused> {
3194        if self.remap_table.is_empty() {
3195            return Err(RemapRefused::NoRuntimeTable);
3196        }
3197        let mut rule = RemapRule {
3198            node_name: heapless::String::new(),
3199            namespace: heapless::String::new(),
3200            from: heapless::String::new(),
3201            to: heapless::String::new(),
3202            tier: RemapTier::Launch,
3203        };
3204        let too_long = |_| RemapRefused::TooLong;
3205        rule.node_name.push_str(node_name).map_err(too_long)?;
3206        let ns = if namespace.is_empty() { "/" } else { namespace };
3207        rule.namespace.push_str(ns).map_err(too_long)?;
3208        rule.from.push_str(from).map_err(too_long)?;
3209        rule.to.push_str(to).map_err(too_long)?;
3210        // Same contract as the `heapless::Vec::push` this replaces: full table
3211        // is an error the caller surfaces, never a silently dropped rule.
3212        let slot = self
3213            .remap_table
3214            .get_mut(self.remap_len)
3215            .ok_or(RemapRefused::TableFull)?;
3216        *slot = Some(rule);
3217        self.remap_len += 1;
3218        Ok(())
3219    }
3220
3221    /// Resolve a source-level entity name for the node identified by
3222    /// `(node_name, namespace)`: ROS 2 name expansion (`~`/relative → FQN)
3223    /// plus this node's declared remap rules (exact-FQN match, first rule
3224    /// wins). Nodes with no rules still get expansion. Errors on an
3225    /// unexpandable name (see `crate::names::expand_name`).
3226    ///
3227    /// Two tiers, through [`crate::names::resolve_name_layered`]: the rules
3228    /// [`Self::declare_remap`] recorded for this node are AUTHORITATIVE (the
3229    /// generated entry declares the launch projection there), and the
3230    /// `--ros-args` rules `crate::ros_args::install_argv_remaps` put in the
3231    /// same table are the FALLBACK, consulted only when no launch rule
3232    /// matched. rcl does the same with its local and global arguments
3233    /// (`rcl_remap_topic_name`, Humble `rcl/remap.h`).
3234    #[allow(clippy::result_unit_err)]
3235    pub fn resolve_entity_name_for(
3236        &self,
3237        node_name: &str,
3238        namespace: &str,
3239        source: &str,
3240    ) -> Result<crate::names::ResolvedName, ()> {
3241        let ns = if namespace.is_empty() { "/" } else { namespace };
3242        self.remap_view().resolve(source, node_name, ns)
3243    }
3244
3245    /// Issue 1971 -- give this executor its own launch remap table instead of
3246    /// the image's ([`super::launch_remap::set_launch_remaps`]). A borrow of
3247    /// `.rodata`; nothing is copied.
3248    pub fn set_launch_remaps(&mut self, table: &'static [super::launch_remap::LaunchRemap]) {
3249        self.launch_remaps = Some(table);
3250    }
3251
3252    /// Both remap sources this executor resolves through: the launch table
3253    /// (flash) and the runtime table (RAM).
3254    pub(crate) fn remap_view(&self) -> RemapView<'_> {
3255        RemapView {
3256            launch: self
3257                .launch_remaps
3258                .unwrap_or_else(super::launch_remap::launch_remaps),
3259            runtime: &self.remap_table[..self.remap_len],
3260        }
3261    }
3262
3263    /// [`Self::resolve_entity_name_for`] against the executor's CURRENT node
3264    /// identity (`set_node_identity`) — the nros-c registration sites set that
3265    /// identity per node immediately before registering each entity.
3266    #[allow(clippy::result_unit_err)]
3267    pub fn resolve_entity_name(&self, source: &str) -> Result<crate::names::ResolvedName, ()> {
3268        self.resolve_entity_name_for(self.node_name.as_str(), self.namespace.as_str(), source)
3269    }
3270
3271    // =========================================================================
3272    // Phase 272 (RFC-0047) — node-name → sched-context table
3273    // =========================================================================
3274
3275    /// Seed a config-resolved tier binding by `(name, namespace)` before the
3276    /// node is built. `NodeBuilder::build` consults this table when no
3277    /// explicit `.sched()` override is given — the table entry then wins over
3278    /// the `SchedContextId(0)` default (precedence: explicit > table > 0).
3279    ///
3280    /// Call BEFORE `node_builder(name).build()`. An existing entry for the
3281    /// same `(name, namespace)` key is overwritten (last-write wins). Overflow
3282    /// past `MAX_NODES` is an `Err`, never a dropped binding — issue 1172. An
3283    /// empty `namespace` is normalised
3284    /// to `"/"` to match what `NodeBuilder::build` computes for a root-NS node.
3285    pub fn bind_node_name_sched(
3286        &mut self,
3287        name: &str,
3288        namespace: &str,
3289        sc: super::sched_context::SchedContextId,
3290    ) -> Result<(), super::types::BindError> {
3291        let norm_ns = if namespace.is_empty() { "/" } else { namespace };
3292        // Overwrite if there is already an entry for this (name, ns) pair.
3293        for entry in self.node_sched_table.iter_mut() {
3294            if entry.0.as_str() == name && entry.1.as_str() == norm_ns {
3295                entry.2 = sc;
3296                return Ok(());
3297            }
3298        }
3299        self.push_node_entry(name, norm_ns, sc, 0)
3300    }
3301
3302    /// phase-509 P2 (RFC-0108 D2) -- seed the contract rank of the node
3303    /// `(name, namespace)` before it is built: 0 is the most urgent, as codegen
3304    /// normalises the derivation's ranks. Every callback the node registers
3305    /// then dispatches at that rank inside its sched context's bucket
3306    /// ([`Priority::dispatch_level`](super::sched_context::Priority::dispatch_level)).
3307    /// A node with no seeded rank holds 0.
3308    ///
3309    /// Shares the node-name table with [`Self::bind_node_name_sched`]: one entry
3310    /// per node carries both, so neither call disturbs the other's half. Same
3311    /// error contract: a binding that does not fit is refused, never dropped.
3312    pub fn bind_node_rank(
3313        &mut self,
3314        name: &str,
3315        namespace: &str,
3316        rank: u8,
3317    ) -> Result<(), super::types::BindError> {
3318        let norm_ns = if namespace.is_empty() { "/" } else { namespace };
3319        for entry in self.node_sched_table.iter_mut() {
3320            if entry.0.as_str() == name && entry.1.as_str() == norm_ns {
3321                entry.3 = rank;
3322                return Ok(());
3323            }
3324        }
3325        self.push_node_entry(name, norm_ns, super::sched_context::SchedContextId(0), rank)
3326    }
3327
3328    /// The rank seeded for `(name, namespace)` by [`Self::bind_node_rank`], 0
3329    /// when none was.
3330    pub(super) fn lookup_node_rank(&self, name: &str, namespace: &str) -> u8 {
3331        self.node_sched_table
3332            .iter()
3333            .find(|e| e.0.as_str() == name && e.1.as_str() == namespace)
3334            .map_or(0, |e| e.3)
3335    }
3336
3337    /// A new node-name table entry.
3338    fn push_node_entry(
3339        &mut self,
3340        name: &str,
3341        norm_ns: &str,
3342        sc: super::sched_context::SchedContextId,
3343        rank: u8,
3344    ) -> Result<(), super::types::BindError> {
3345        // New entry. Too long, or table full, is an ERROR the caller surfaces
3346        // — never a dropped binding. A binding that vanishes leaves the node on
3347        // the wrong sched context with nothing to read that says so.
3348        let mut name_s = heapless::String::<64>::new();
3349        let mut ns_s = heapless::String::<64>::new();
3350        name_s
3351            .push_str(name)
3352            .map_err(|_| super::types::BindError::NameTooLong)?;
3353        ns_s.push_str(norm_ns)
3354            .map_err(|_| super::types::BindError::NameTooLong)?;
3355        self.node_sched_table
3356            .push((name_s, ns_s, sc, rank))
3357            .map_err(|_| super::types::BindError::TableFull)?;
3358        Ok(())
3359    }
3360
3361    /// Look up the seeded sched-context for `(name, namespace)`. Returns
3362    /// `None` when the table has no entry for this pair (unseed → default 0).
3363    /// `pub(super)` — visible only within the `executor` module (sibling
3364    /// `node_record` calls it from `NodeBuilder::build`).
3365    pub(super) fn lookup_node_sched(
3366        &self,
3367        name: &str,
3368        namespace: &str,
3369    ) -> Option<super::sched_context::SchedContextId> {
3370        for entry in self.node_sched_table.iter() {
3371            if entry.0.as_str() == name && entry.1.as_str() == namespace {
3372                return Some(entry.2);
3373            }
3374        }
3375        None
3376    }
3377
3378    // =========================================================================
3379    // Phase 273 (RFC-0047) — per-callback-group → sched-context table
3380    // =========================================================================
3381
3382    /// Seed a config-resolved tier binding by `(name, namespace, group)` before
3383    /// entities are registered. `apply_node_default_sched` consults this table
3384    /// first (group table > node default > `SchedContextId(0)`).
3385    ///
3386    /// Call BEFORE entity creation. An existing entry for the same
3387    /// `(name, namespace, group)` key is overwritten (last-write wins). Overflow
3388    /// past `MAX_CBS` is an `Err`, never a dropped binding — issue 1172. An
3389    /// empty `namespace` is normalised to
3390    /// `"/"` to match `NodeBuilder::build`. Mirror of `bind_node_name_sched`.
3391    pub fn bind_group_sched(
3392        &mut self,
3393        name: &str,
3394        namespace: &str,
3395        group: &str,
3396        sc: super::sched_context::SchedContextId,
3397    ) -> Result<(), super::types::BindError> {
3398        let norm_ns = if namespace.is_empty() { "/" } else { namespace };
3399        // Overwrite if there is already an entry for this (name, ns, group).
3400        for entry in self.group_sched_table.iter_mut() {
3401            if entry.0.as_str() == name && entry.1.as_str() == norm_ns && entry.2.as_str() == group
3402            {
3403                entry.3 = sc;
3404                return Ok(());
3405            }
3406        }
3407        // Same contract as the two above.
3408        let mut name_s = heapless::String::<64>::new();
3409        let mut ns_s = heapless::String::<64>::new();
3410        let mut grp_s = heapless::String::<32>::new();
3411        name_s
3412            .push_str(name)
3413            .map_err(|_| super::types::BindError::NameTooLong)?;
3414        ns_s.push_str(norm_ns)
3415            .map_err(|_| super::types::BindError::NameTooLong)?;
3416        grp_s
3417            .push_str(group)
3418            .map_err(|_| super::types::BindError::NameTooLong)?;
3419        self.group_sched_table
3420            .push((name_s, ns_s, grp_s, sc))
3421            .map_err(|_| super::types::BindError::TableFull)?;
3422        Ok(())
3423    }
3424
3425    /// Look up the seeded sched-context for `(name, namespace, group)`. Returns
3426    /// `None` when the table has no entry for this triple.
3427    fn lookup_group_sched(
3428        &self,
3429        name: &str,
3430        namespace: &str,
3431        group: &str,
3432    ) -> Option<super::sched_context::SchedContextId> {
3433        for entry in self.group_sched_table.iter() {
3434            if entry.0.as_str() == name
3435                && entry.1.as_str() == namespace
3436                && entry.2.as_str() == group
3437            {
3438                return Some(entry.3);
3439            }
3440        }
3441        None
3442    }
3443
3444    // =========================================================================
3445    // Phase 110.B — SchedContext API
3446    // =========================================================================
3447
3448    /// Identifier of the auto-created default `Fifo`-class scheduling
3449    /// context. Every callback registered without an explicit
3450    /// [`Self::bind_handle_to_sched_context`] binds to this SC.
3451    pub fn default_sched_context_id(&self) -> super::sched_context::SchedContextId {
3452        super::sched_context::SchedContextId(0)
3453    }
3454
3455    /// Register a new scheduling context. Returns a [`SchedContextId`](crate::executor::sched_context::SchedContextId)
3456    /// callers pass to [`Self::bind_handle_to_sched_context`] to attach
3457    /// callbacks. Phase 110.B.
3458    pub fn create_sched_context(
3459        &mut self,
3460        sc: super::sched_context::SchedContext,
3461    ) -> Result<super::sched_context::SchedContextId, NodeError> {
3462        // Slot 0 is reserved for the default Fifo SC; search 1..MAX_SC.
3463        for (i, slot) in self.sched_contexts.iter_mut().enumerate().skip(1) {
3464            if slot.is_none() {
3465                *slot = Some(sc);
3466                // Phase 110.E — Sporadic-class SCs get a sibling
3467                // `SporadicState` entry that the spin_once dispatch
3468                // path consults each cycle to refill the budget at
3469                // period boundaries and skip dispatch when budget
3470                // is exhausted.
3471                if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
3472                    let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
3473                    let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
3474                    self.sporadic_states[i] =
3475                        Some(super::sched_context::SporadicState::new(budget, period));
3476                }
3477                return Ok(super::sched_context::SchedContextId(i as u8));
3478            }
3479        }
3480        Err(NodeError::NoSchedContextSlot)
3481    }
3482
3483    /// RFC-0052 W3b.2 — wall-clock µs since the UNIX epoch, when this
3484    /// target has an epoch source (config `epoch_us`, defaulted from
3485    /// `SystemTime` on hosted configs). `None` = no wall clock; age
3486    /// monitors must not have been baked (the emitter refuses).
3487    pub fn epoch_now_us(&self) -> Option<u64> {
3488        self.epoch_us_fn.map(|f| f())
3489    }
3490
3491    /// RFC-0052 W3b.4 — install the baked contract-monitor table. Call
3492    /// BEFORE entity creation so `create_publisher` can attach each
3493    /// contracted endpoint's counter cell. Mirrors `set_qos_overrides`:
3494    /// `&'static`, codegen-baked, empty by default.
3495    ///
3496    /// phase-496 W1 -- the executor watches at most
3497    /// [`MAX_MONITORS`](super::monitor::MAX_MONITORS) rows
3498    /// (`NROS_EXECUTOR_MAX_MONITORS`). A generated entry cannot reach this with
3499    /// more: its table carries a compile-time assertion that names the knob,
3500    /// and the C++ installer refuses at runtime. A hand-written longer table is
3501    /// a debug-build panic naming the knob, because in release the spin loop
3502    /// would inspect only the first `MAX_MONITORS` and leave the rest silently
3503    /// unwatched.
3504    pub fn set_monitor_table(&mut self, table: &'static [super::monitor::MonitorSpec]) {
3505        if let Err(full) = super::monitor::check_table_capacity(table.len(), 0)
3506            && cfg!(debug_assertions)
3507        {
3508            panic!("set_monitor_table: {full}");
3509        }
3510        self.monitor_table = table;
3511    }
3512
3513    /// phase-496 W1 -- install BOTH baked monitor tables, or neither.
3514    ///
3515    /// The refusing installer: a table longer than this executor watches
3516    /// (`MAX_MONITORS` / `MAX_AGE_MONITORS`) is `Err`, and its `Display` names
3517    /// the knob to raise, before anything is installed. Nothing is truncated:
3518    /// the spin loop inspects only the first `MAX_*` specs, so accepting a
3519    /// longer table would boot with its tail silently unwatched. The C ABI's
3520    /// `nros_cpp_install_monitors` refuses on the same check.
3521    pub fn try_set_monitor_tables(
3522        &mut self,
3523        table: &'static [super::monitor::MonitorSpec],
3524        ages: &'static [super::monitor::AgeMonitorSpec],
3525    ) -> Result<(), super::monitor::MonitorTableFull> {
3526        super::monitor::check_table_capacity(table.len(), ages.len())?;
3527        self.monitor_table = table;
3528        self.age_table = ages;
3529        Ok(())
3530    }
3531
3532    /// The installed monitor table (empty unless the entry set one).
3533    #[must_use]
3534    pub fn monitor_table(&self) -> &'static [super::monitor::MonitorSpec] {
3535        self.monitor_table
3536    }
3537
3538    /// Issue 1676 — the counter cell a publisher on `topic_name` bumps, by
3539    /// exact topic match against the installed table (`None` = uncontracted).
3540    pub(crate) fn publisher_monitor_cell(
3541        &self,
3542        topic_name: &str,
3543    ) -> Option<&'static super::monitor::PubMonitorCell> {
3544        self.monitor_table
3545            .iter()
3546            .find(|m| m.topic == topic_name)
3547            .map(|m| m.cell)
3548    }
3549
3550    /// W3b.5 — install the baked subscriber age-contract table. Call
3551    /// BEFORE entity creation so `create_subscription` can attach each
3552    /// contracted endpoint's age cell (needs an epoch source — see
3553    /// `ExecutorConfig::epoch_us`; without one the take path records
3554    /// nothing and age monitors stay silent).
3555    ///
3556    /// phase-496 W1 -- at most
3557    /// [`MAX_AGE_MONITORS`](super::monitor::MAX_AGE_MONITORS) rows
3558    /// (`NROS_EXECUTOR_MAX_AGE_MONITORS`), on the same terms as
3559    /// [`Self::set_monitor_table`].
3560    pub fn set_age_table(&mut self, table: &'static [super::monitor::AgeMonitorSpec]) {
3561        if let Err(full) = super::monitor::check_table_capacity(0, table.len())
3562            && cfg!(debug_assertions)
3563        {
3564            panic!("set_age_table: {full}");
3565        }
3566        self.age_table = table;
3567    }
3568
3569    /// The installed age table (empty unless the entry set one).
3570    #[must_use]
3571    pub fn age_table(&self) -> &'static [super::monitor::AgeMonitorSpec] {
3572        self.age_table
3573    }
3574
3575    /// phase-462 W3b — install the baked hazard table. Each row's guards name
3576    /// rows of the tables [`Self::set_monitor_table`] and
3577    /// [`Self::set_age_table`] install, so install those first; a guard whose
3578    /// row is absent never faults. The observer runs after the monitor rows on
3579    /// every monitor tick and reports `hazard-detected`, `hazard-reaction` and
3580    /// `hazard-recovered` on the same drain.
3581    pub fn set_hazard_table(&mut self, table: &'static [super::monitor::HazardSpec]) {
3582        self.hazard_table = table;
3583    }
3584
3585    /// phase-462 W4 -- install the contract's mode variants of the two monitor
3586    /// tables; entry `i` is mode `i`, and entry 0 must be the base tables
3587    /// already installed. A switch is asked for with
3588    /// [`super::monitor::request_mode`] and applied at the next spin.
3589    ///
3590    /// # Errors
3591    ///
3592    /// [`super::monitor::ModeTablesMismatch`] when an entry's tables are not the base's
3593    /// length: a variant shares the base's cells and the per-row monitor
3594    /// state by index, so a variant of another length would judge one row
3595    /// by another's requirement. Nothing is installed.
3596    pub fn set_mode_tables(
3597        &mut self,
3598        modes: &'static [super::monitor::ModeTables],
3599    ) -> Result<(), super::monitor::ModeTablesMismatch> {
3600        for (id, m) in modes.iter().enumerate() {
3601            if m.monitors.len() != self.monitor_table.len() || m.ages.len() != self.age_table.len()
3602            {
3603                return Err(super::monitor::ModeTablesMismatch {
3604                    mode: id as u16,
3605                    monitors: (m.monitors.len(), self.monitor_table.len()),
3606                    ages: (m.ages.len(), self.age_table.len()),
3607                    missing_sc: None,
3608                });
3609            }
3610            if let Some(d) = m
3611                .deadlines
3612                .iter()
3613                .find(|d| !matches!(self.sched_contexts.get(usize::from(d.sc)), Some(Some(_))))
3614            {
3615                return Err(super::monitor::ModeTablesMismatch {
3616                    mode: id as u16,
3617                    monitors: (m.monitors.len(), self.monitor_table.len()),
3618                    ages: (m.ages.len(), self.age_table.len()),
3619                    missing_sc: Some(d.sc),
3620                });
3621            }
3622        }
3623        self.mode_tables = modes;
3624        Ok(())
3625    }
3626
3627    /// The mode whose tables this executor judges by (0 = the base).
3628    #[must_use]
3629    pub fn current_mode(&self) -> u16 {
3630        self.mode_applied
3631    }
3632
3633    /// phase-462 W4 -- once per spin, before any rule runs: enter the mode the
3634    /// application asked for, if this executor has tables for it.
3635    fn update_mode(&mut self, now_us: Option<u64>) {
3636        if self.mode_tables.is_empty() {
3637            return;
3638        }
3639        // phase-462 W4c -- a request seen now arrived after the previous
3640        // check, so this gap bounds how long it waited.
3641        let now_ms = now_us.map(|us| (us / 1_000) as u32);
3642        let waited_ms = match (now_ms, self.mode_checked_ms) {
3643            (Some(now), Some(prev)) => Some(now.wrapping_sub(prev)),
3644            _ => None,
3645        };
3646        self.mode_checked_ms = now_ms;
3647        let want = super::monitor::requested_mode();
3648        if want == self.mode_seen {
3649            return;
3650        }
3651        self.mode_seen = want;
3652        match self.mode_tables.get(usize::from(want)) {
3653            Some(t) => {
3654                self.monitor_table = t.monitors;
3655                self.age_table = t.ages;
3656                for d in t.deadlines {
3657                    if let Some(Some(sc)) = self.sched_contexts.get_mut(usize::from(d.sc)) {
3658                        sc.deadline_us = super::sched_context::OptUs::from_us(d.deadline_us);
3659                    }
3660                }
3661                if let Some(v) = super::monitor::check_mode_switch(t, waited_ms) {
3662                    self.violations.record(v);
3663                }
3664                self.mode_applied = want;
3665                nros_log::log_info!(
3666                    nros_log::get_logger("nros"),
3667                    "contract mode {} entered",
3668                    want
3669                );
3670            }
3671            None => nros_log::log_warn!(
3672                nros_log::get_logger("nros"),
3673                "contract mode {} requested, but this executor bakes {} mode table(s); \
3674                 staying in mode {}",
3675                want,
3676                self.mode_tables.len(),
3677                self.mode_applied
3678            ),
3679        }
3680    }
3681
3682    /// The installed hazard table (empty unless the entry set one).
3683    #[must_use]
3684    pub fn hazard_table(&self) -> &'static [super::monitor::HazardSpec] {
3685        self.hazard_table
3686    }
3687
3688    /// W3b.5 — override the wall-clock (epoch µs) source age monitors take
3689    /// message stamps against. Hosted builds default to `SystemTime`; a
3690    /// board with a synced RTC installs its own here (or via
3691    /// `ExecutorConfig::epoch_us`). Call BEFORE entity creation — the age
3692    /// hook captures this at `create_subscription` time.
3693    pub fn set_epoch_clock(&mut self, epoch_us: fn() -> u64) {
3694        self.epoch_us_fn = Some(epoch_us);
3695    }
3696
3697    /// W3b.5 — resolve a subscription's age hook at registration time:
3698    /// exact topic match against the baked age table, only for stamped
3699    /// types (`M::STAMP_OFFSET`). Issue 2215: with no epoch source the hook
3700    /// still records the stamped take (the silence rule's evidence), and the
3701    /// age is judged only once an epoch is installed.
3702    fn age_lookup<M: RosMessage>(&self, topic: &str) -> Option<super::arena::AgeMon> {
3703        M::STAMP_OFFSET?;
3704        self.age_table
3705            .iter()
3706            .find(|a| a.topic == topic)
3707            .map(|a| (a.cell, self.epoch_us_fn))
3708    }
3709
3710    /// Issue 2215 — bind a C/C++ callback subscription to its topic's
3711    /// contracted age row, so its stamped takes feed the silence rule (and,
3712    /// with an epoch source, the age rule) as the Rust typed paths' do.
3713    /// `stamp_offset` is the CDR byte offset of the type's `stamp.sec`
3714    /// (encapsulation header included; the C++ message type states it as
3715    /// `STAMP_OFFSET`). Returns `true` when a row was bound; `false` when the
3716    /// topic has no age row or `handle` is not a C callback subscription,
3717    /// which is not an error: such a subscription has nothing to feed.
3718    pub fn bind_raw_subscription_stamp(
3719        &mut self,
3720        handle: HandleId,
3721        topic: &str,
3722        stamp_offset: u16,
3723    ) -> bool {
3724        let Some(row) = self.age_table.iter().find(|a| a.topic == topic) else {
3725            return false;
3726        };
3727        let mon = Some(super::arena::RawAgeMon {
3728            cell: row.cell,
3729            epoch: self.epoch_us_fn,
3730            stamp_offset,
3731        });
3732        let i = self.live_slot(handle);
3733        let Some(meta) = self.entries.get(i).and_then(|m| m.as_ref()) else {
3734            return false;
3735        };
3736        let offset = meta.offset;
3737        type TryProcess = unsafe fn(*mut u8, u64, u8) -> Result<bool, TransportError>;
3738        let inplace = core::ptr::fn_addr_eq(
3739            meta.try_process,
3740            super::arena::sub_inplace_raw_c_try_process as TryProcess,
3741        );
3742        let buffered = core::ptr::fn_addr_eq(
3743            meta.try_process,
3744            super::arena::sub_buffered_raw_c_try_process as TryProcess,
3745        );
3746        // SAFETY: the entry at `offset` was written by
3747        // `add_arena_subscription_c_callback_with_capture` as the type its
3748        // `try_process` names, which is what the two tests above select on.
3749        unsafe {
3750            let ptr = (self.arena.as_mut_ptr() as *mut u8).add(offset);
3751            if inplace {
3752                (*(ptr as *mut super::arena::SubInplaceRawCEntry)).age_mon = mon;
3753            } else if buffered {
3754                (*(ptr as *mut SubBufferedRawCEntry)).age_mon = mon;
3755            } else {
3756                return false;
3757            }
3758        }
3759        true
3760    }
3761
3762    /// W3b.5 — install the `DeadlineAction::Fault` hook. Without one a
3763    /// fault-class deadline miss panics (watchdog-visible stop on
3764    /// embedded targets).
3765    /// Plan one `spin_once` against the caller's intended cadence, and return
3766    /// the park budget in microseconds.
3767    ///
3768    /// phase-474 I11 -- two cases:
3769    ///
3770    /// * **No declared cadence, non-zero timeout** (the generated entries'
3771    ///   `spin_once(10)` loop, `spin`, `spin_period`): the timeout is the
3772    ///   period and the loop runs on an absolute RELEASE GRID. The budget is
3773    ///   the time to the next grid point, not a whole period from now, so the
3774    ///   previous spin's work no longer pushes every later release back. A
3775    ///   loop that comes back after its next point was due is released at
3776    ///   once, late by the difference; one that comes back a whole period or
3777    ///   more after it is released once for the LATEST due point and the
3778    ///   older ones are skipped and counted -- the timer `Skip` policy, never
3779    ///   a burst. Lateness is judged at the release (`record_grid_release`).
3780    ///   A change of timeout re-anchors the grid at this entry.
3781    /// * **A declared cadence** (`set_spin_nominal_us`, the tier loops that
3782    ///   sleep between spins themselves): unchanged. Late is
3783    ///   `(now - last_entry) - cadence`, clamped at zero, and the budget is
3784    ///   the timeout.
3785    ///
3786    /// A zero timeout claims no cadence and is skipped entirely, which keeps
3787    /// `Future::wait`-style busy spins out of the statistic and off the grid.
3788    fn record_release_jitter(&mut self, timeout: core::time::Duration) -> u64 {
3789        let timeout_us = timeout.as_micros().min(u64::MAX as u128) as u64;
3790        if !self.spin_nominal_self_paced() {
3791            if timeout_us == 0 {
3792                return 0;
3793            }
3794            let Some(now) = self.now_us() else {
3795                return timeout_us;
3796            };
3797            // phase-436 A2b -- a wait-paced declaration supplies the grid's
3798            // period; otherwise the timeout stands in for it.
3799            let period_us = if self.spin_nominal_declared_us != 0 {
3800                self.spin_nominal_declared_us
3801            } else {
3802                timeout_us
3803            };
3804            self.spin_nominal_us = period_us;
3805            if self.grid.period_us != period_us {
3806                // First periodic spin, or a new period: anchor here, so the
3807                // first park is a whole period exactly as before.
3808                self.grid.period_us = period_us;
3809                self.grid.next_us = now.saturating_add(period_us);
3810            } else if now >= self.grid.next_us.saturating_add(period_us) {
3811                // The loop came back a whole period or more after its next
3812                // release was due: serve the latest due point once and drop
3813                // the ones before it. The first dropped point is a release
3814                // that never happened, so its lateness at this moment (a
3815                // period or more) is what the jitter rule judges.
3816                let missed = (now - self.grid.next_us) / period_us;
3817                self.note_dropped_release(now - self.grid.next_us);
3818                self.grid.skipped = self
3819                    .grid
3820                    .skipped
3821                    .saturating_add(missed.min(u32::MAX as u64) as u32);
3822                self.grid.next_us = self
3823                    .grid
3824                    .next_us
3825                    .saturating_add(missed.saturating_mul(period_us));
3826            }
3827            // The timeout still bounds the wait. With a declared period it
3828            // may be shorter than the period (a caller that passes whole
3829            // milliseconds for a 1.5 ms cadence); a wake before the grid
3830            // point is then not a release, and the next spin parks again.
3831            return self.grid.next_us.saturating_sub(now).min(timeout_us);
3832        }
3833        // phase-474 I7 -- the violation reporter's own time since the last
3834        // sample is not a late release (see `flush_violation_reports`). Taken
3835        // first, so a spin that judges nothing does not carry it forward.
3836        let report_us = core::mem::take(&mut self.violations.reports.debt_us);
3837        let nominal_us = self.spin_nominal_declared_us;
3838        let Some(now) = self.now_us() else {
3839            return timeout_us;
3840        };
3841        self.spin_nominal_us = nominal_us;
3842        if let Some(last) = self.last_spin_entry_us {
3843            let interval = now.saturating_sub(last).saturating_sub(report_us);
3844            self.total_wakes = self.total_wakes.saturating_add(1);
3845            if let Some(late) = interval.checked_sub(nominal_us)
3846                && late > 0
3847            {
3848                self.count_late_wake(late);
3849            }
3850        }
3851        self.last_spin_entry_us = Some(now);
3852        timeout_us
3853    }
3854
3855    /// phase-474 I11 -- judge a grid release, at the clock read after the
3856    /// park. A spin that wakes before its grid point (data arrived, a timer
3857    /// or another source was due first) is not a release: it dispatches and
3858    /// leaves the grid where it was. One that wakes at or after it is THE
3859    /// release of that point, late by `now - grid.next_us` (less the
3860    /// violation reporter's own time, phase-474 I7), and the grid advances by
3861    /// one period. Points the park itself overslept by a whole period are
3862    /// skipped at the next entry, not served in a burst.
3863    fn record_grid_release(&mut self, now: u64) {
3864        if self.spin_nominal_self_paced() || self.grid.period_us == 0 {
3865            return;
3866        }
3867        if now < self.grid.next_us {
3868            return;
3869        }
3870        let report_us = core::mem::take(&mut self.violations.reports.debt_us);
3871        let late = now
3872            .saturating_sub(self.grid.next_us)
3873            .saturating_sub(report_us);
3874        self.total_wakes = self.total_wakes.saturating_add(1);
3875        if late > 0 {
3876            self.count_late_wake(late);
3877        }
3878        self.grid.next_us = self.grid.next_us.saturating_add(self.grid.period_us);
3879        if now >= self.grid.next_us {
3880            // The park overslept the following point(s) too. This wake serves
3881            // one release; the points it ran past are skipped, not released
3882            // back to back on the next spins.
3883            let missed = (now - self.grid.next_us) / self.grid.period_us + 1;
3884            self.grid.skipped = self
3885                .grid
3886                .skipped
3887                .saturating_add(missed.min(u32::MAX as u64) as u32);
3888            self.grid.next_us = self
3889                .grid
3890                .next_us
3891                .saturating_add(missed.saturating_mul(self.grid.period_us));
3892        }
3893    }
3894
3895    /// One wake that came back `late_us` (> 0) after its release was due.
3896    fn count_late_wake(&mut self, late_us: u64) {
3897        self.late_wakes = self.late_wakes.saturating_add(1);
3898        if late_us > self.release_jitter_granularity_us() {
3899            self.late_wakes_beyond_granule = self.late_wakes_beyond_granule.saturating_add(1);
3900        }
3901        if late_us > self.max_release_jitter_us {
3902            self.max_release_jitter_us = late_us;
3903        }
3904    }
3905
3906    /// A cadence the caller meets with its own sleep between spins.
3907    fn spin_nominal_self_paced(&self) -> bool {
3908        self.spin_nominal_declared_us != 0 && !self.spin_nominal_wait_paced
3909    }
3910
3911    /// The lateness of a grid release that was dropped rather than served,
3912    /// into the high-water the jitter rule reads (not into the wake counts:
3913    /// no wake happened).
3914    fn note_dropped_release(&mut self, late_us: u64) {
3915        let late = late_us.saturating_sub(core::mem::take(&mut self.violations.reports.debt_us));
3916        if late > self.max_release_jitter_us {
3917            self.max_release_jitter_us = late;
3918        }
3919    }
3920
3921    /// phase-474 I11 -- grid releases skipped since the last
3922    /// `clear_release_jitter_stats`, because the loop came back after they
3923    /// were due. Zero for a declared-cadence or zero-timeout caller.
3924    pub fn skipped_releases(&self) -> u32 {
3925        self.grid.skipped
3926    }
3927
3928    /// Release-jitter statistics from the spin loop: worst lateness in
3929    /// microseconds, the number of wakes that were already late, and the
3930    /// number of wakes total.
3931    ///
3932    /// The maximum is the figure of merit, and the ratio is what tells the
3933    /// two failures apart: one late wake in ten thousand is a glitch, ten
3934    /// thousand in ten thousand means the period cannot be met at all.
3935    ///
3936    /// Zero on a build with no clock -- `spin_period` refuses to run at all
3937    /// there (issue 0709), so there is nothing to have measured.
3938    pub fn release_jitter(&self) -> (u64, u32, u32) {
3939        (
3940            self.max_release_jitter_us,
3941            self.late_wakes,
3942            self.total_wakes,
3943        )
3944    }
3945
3946    /// phase-436 A2c -- of the `late` wakes `release_jitter` counts, those
3947    /// late by more than [`Self::release_jitter_granularity_us`].
3948    ///
3949    /// A wake is late at one microsecond past its release. A loop paced by a
3950    /// tick-granular park comes back up to a tick past it on every wake, by
3951    /// design, so `late` approaches `total` on a loop that is keeping its
3952    /// period perfectly well. This count leaves that rounding out: it is the
3953    /// lateness the measurement can actually resolve. Cleared with the rest.
3954    pub fn late_wakes_beyond_granularity(&self) -> u32 {
3955        self.late_wakes_beyond_granule
3956    }
3957
3958    /// Reset the release-jitter statistics. For monitoring code that logs
3959    /// and clears per window, so a single early outlier does not pin the
3960    /// maximum for the life of the process.
3961    pub fn clear_release_jitter_stats(&mut self) {
3962        self.max_release_jitter_us = 0;
3963        self.late_wakes = 0;
3964        self.late_wakes_beyond_granule = 0;
3965        self.total_wakes = 0;
3966        self.last_spin_entry_us = None;
3967        self.jitter_reported_us = 0;
3968        self.grid.skipped = 0;
3969    }
3970
3971    pub fn set_fault_handler(&mut self, f: fn(&super::monitor::Violation)) {
3972        self.fault_fn = Some(f);
3973    }
3974
3975    /// phase-503 — install the REACTION hook: `f(event, ctx)` is called on the
3976    /// spin thread when a supervised route passes its deadline
3977    /// (`route-deadline-runtime`), with the row's reaction id, row index and
3978    /// mechanism. It also stands in for [`Self::set_fault_handler`] on a
3979    /// `DeadlineAction::Fault` when no fault handler is set (reaction id 0,
3980    /// the sched-context slot as the row), so a C or C++ image has a fault
3981    /// path that does not panic. Replaces any earlier hook; `None` removes it.
3982    ///
3983    /// The hook must not block and must not re-enter this executor: it starts
3984    /// the fallback, or hands it to another thread, and returns.
3985    pub fn set_reaction_hook(
3986        &mut self,
3987        hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
3988    ) {
3989        self.reaction_hook = hook;
3990    }
3991
3992    /// phase-509 P9b (RFC-0108 "As built in P9b") -- install the lock every
3993    /// FFI entry and every worker pass takes on this executor (`None` removes
3994    /// it). Call it while no second thread can reach the executor: a runner
3995    /// installs it before it spawns a level.
3996    pub fn set_exec_lock(&mut self, lock: Option<super::exec_lock::ExecLock>) {
3997        self.exec_lock = lock;
3998    }
3999
4000    /// phase-509 P9b -- the installed lock, if any.
4001    pub(crate) fn exec_lock(&self) -> Option<super::exec_lock::ExecLock> {
4002        self.exec_lock
4003    }
4004
4005    /// phase-509 P9b item 2 -- whether the callback of `slot` runs in a window
4006    /// on a thread other than the calling one. An operation on such an entry
4007    /// would race the callback, so the entry accessors answer as for a stale
4008    /// handle and cancel / reset are deferred to the end of the dispatch.
4009    /// `false` with no window open, which is every one-level image.
4010    pub(crate) fn running_elsewhere(&self, slot: usize) -> bool {
4011        if self.running.is_null() {
4012            return false;
4013        }
4014        let Some(me) = self.exec_lock.and_then(|l| l.current_thread()) else {
4015            return false;
4016        };
4017        let mut r = self.running;
4018        while !r.is_null() {
4019            // SAFETY: a record is linked only for its window and unlinked
4020            // under the lock before its frame ends (`pass`), and the caller
4021            // holds the lock.
4022            let rec = unsafe { &*r };
4023            if rec.slot == slot {
4024                return rec.thread != me;
4025            }
4026            r = rec.next;
4027        }
4028        false
4029    }
4030
4031    /// phase-509 P9b item 2 -- link `rec` into the open windows.
4032    ///
4033    /// # Safety
4034    /// `rec` stays alive and unmoved until [`Self::unlink_running`] removes
4035    /// it, under the lock.
4036    unsafe fn link_running(&mut self, rec: *mut super::exec_lock::Running) {
4037        // SAFETY: the caller's contract.
4038        unsafe { (*rec).next = self.running };
4039        self.running = rec;
4040    }
4041
4042    /// phase-509 P9b item 2 -- unlink `rec`, linked by
4043    /// [`Self::link_running`].
4044    fn unlink_running(&mut self, rec: *mut super::exec_lock::Running) {
4045        let mut at: *mut *mut super::exec_lock::Running = &mut self.running;
4046        // SAFETY: every linked record is alive (`link_running`'s contract),
4047        // and the list is touched only under the lock.
4048        unsafe {
4049            while !(*at).is_null() {
4050                if *at == rec {
4051                    *at = (*rec).next;
4052                    return;
4053                }
4054                at = &mut (**at).next;
4055            }
4056        }
4057    }
4058
4059    /// phase-509 P9b item 2 -- the dispatch level of `slot`'s occupant: its
4060    /// sched context's bucket, and inside `Normal` its rank.
4061    fn slot_level(&self, slot: usize) -> u8 {
4062        let sc_idx = self.sched_context_bindings[slot].0 as usize;
4063        let priority = self
4064            .sched_contexts
4065            .get(sc_idx)
4066            .and_then(|s| s.as_ref())
4067            .map_or(super::sched_context::Priority::Normal, |sc| sc.priority);
4068        let rank = self.slot_tags.get(slot).map_or(0, |t| t.rank);
4069        priority.dispatch_level(rank)
4070    }
4071
4072    /// phase-509 P9b item 2 -- the slots whose dispatch level is in `band`.
4073    /// Every slot for [`super::scheduler::Band::ALL`], the one-level worker's
4074    /// band, without looking at one.
4075    fn band_slots(&self, level: u8, band: super::scheduler::Band) -> u64 {
4076        // phase-509 P9b item 4 -- levels that own their slots by
4077        // registration (a level runner): the worker of `level` runs exactly
4078        // what its level's setup registered, whatever the ranks say.
4079        if self.worker.registering_level != super::scheduler::NO_OWNING_LEVEL {
4080            let mut mask = 0u64;
4081            for i in 0..self.entries.len().min(64) {
4082                if self.entries[i].is_some()
4083                    && self.slot_tags.get(i).is_some_and(|t| t.level == level)
4084                {
4085                    mask |= 1u64 << i;
4086                }
4087            }
4088            return mask;
4089        }
4090        if band == super::scheduler::Band::ALL {
4091            return u64::MAX;
4092        }
4093        let mut mask = 0u64;
4094        for i in 0..self.entries.len().min(64) {
4095            if self.entries[i].is_some() && band.contains(self.slot_level(i)) {
4096                mask |= 1u64 << i;
4097            }
4098        }
4099        mask
4100    }
4101
4102    /// phase-509 P9b item 4 -- the callbacks registered from now on belong
4103    /// to level `level`: its worker runs them, and no other does. The first
4104    /// call switches the executor to levels that own their slots by
4105    /// registration (a level runner sets each level up in turn); until then,
4106    /// and on every image that never calls it, a worker's band of dispatch
4107    /// levels decides.
4108    pub fn set_registering_level(&mut self, level: u8) {
4109        self.worker.registering_level = level.min(super::scheduler::NO_OWNING_LEVEL - 1);
4110        // Issue 2018 -- a level's own scheduling context is set after it
4111        // becomes the registering level, never inherited from the last one.
4112        self.worker.registering_sched = 0;
4113    }
4114
4115    /// Issue 2018 (RFC-0108 As-built) -- give the registering level its own
4116    /// scheduling context: the one its tier's policy lowers to
4117    /// ([`SchedContext::from_tier_policy`](super::sched_context::SchedContext::from_tier_policy)).
4118    /// Callbacks registered from now on, until the registering level
4119    /// changes, bind to it unless a node or callback-group binding names
4120    /// another. A level's budget is then charged to that level alone, never
4121    /// to the executor's default context, which every level shares.
4122    ///
4123    /// A `Sporadic` context is created with the executor's cooperative
4124    /// budget (it skips the level's callbacks once the budget is spent, until
4125    /// the period refills it). A runner whose kernel enforces the same
4126    /// budget on the level's thread hands it to the kernel with
4127    /// [`Self::defer_budget_to_kernel`], so the budget is enforced exactly
4128    /// once.
4129    pub fn set_level_sched_context(
4130        &mut self,
4131        sc: super::sched_context::SchedContext,
4132    ) -> Result<super::sched_context::SchedContextId, NodeError> {
4133        let id = self.create_sched_context(sc)?;
4134        self.worker.registering_sched = id.0;
4135        Ok(id)
4136    }
4137
4138    /// Issue 2018 -- the kernel enforces `sc`'s budget on the level's thread
4139    /// (NuttX `SCHED_SPORADIC`, Linux `SCHED_DEADLINE`), so the executor
4140    /// stops enforcing it: the context keeps its class, budget, period and
4141    /// deadline, and its callbacks are no longer skipped for want of budget.
4142    /// One enforcer per level, with one set of numbers. `false` when `sc`
4143    /// carries no cooperative budget (nothing to hand over).
4144    pub fn defer_budget_to_kernel(&mut self, sc: super::sched_context::SchedContextId) -> bool {
4145        match self.sporadic_states.get_mut(sc.0 as usize) {
4146            Some(st @ Some(_)) => {
4147                *st = None;
4148                true
4149            }
4150            _ => false,
4151        }
4152    }
4153
4154    /// Issue 2018 -- whether `sc`'s budget is enforced by this executor
4155    /// (a cooperative `Sporadic` context the kernel was not handed).
4156    pub fn enforces_budget(&self, sc: super::sched_context::SchedContextId) -> bool {
4157        self.sporadic_states
4158            .get(sc.0 as usize)
4159            .is_some_and(|s| s.is_some())
4160    }
4161
4162    /// Issue 2020 -- the level that owns what is registered now, `None` until
4163    /// a level runner first calls [`Self::set_registering_level`].
4164    pub fn registering_level(&self) -> Option<u8> {
4165        (self.worker.registering_level != super::scheduler::NO_OWNING_LEVEL)
4166            .then_some(self.worker.registering_level)
4167    }
4168
4169    /// Issue 2020 -- whether a pass ticks a component (or anything else
4170    /// registered) owned by `owner`, the level recorded at registration
4171    /// (`None`: no level owned it).
4172    ///
4173    /// `band_level` is the level of a band pass ([`Self::spin_band_once`]),
4174    /// `None` for a pass of the executor's own worker. When the levels own
4175    /// their slots by registration, a band pass ticks only its level's, and
4176    /// the executor's own worker (the boot level) ticks its own level's and
4177    /// any registered before a level was set. Otherwise the executor's own
4178    /// worker ticks everything and a band pass nothing, as before levels.
4179    pub fn pass_ticks_owner(&self, band_level: Option<u8>, owner: Option<u8>) -> bool {
4180        let owning = self.worker.registering_level != super::scheduler::NO_OWNING_LEVEL;
4181        match band_level {
4182            Some(l) => owning && owner == Some(l),
4183            None => !owning || owner.is_none() || owner == Some(self.worker.level),
4184        }
4185    }
4186
4187    /// phase-509 P9b item 2 -- the band the executor's own worker runs:
4188    /// every level by default. With band workers over the same executor
4189    /// ([`Self::spin_band_once`]), the bands must partition the levels, so
4190    /// each callback has one worker.
4191    pub fn set_worker_band(&mut self, band: super::scheduler::Band) {
4192        self.worker.band = band;
4193    }
4194
4195    /// phase-509 P9b item 2 -- one pass of the executor's own worker, through
4196    /// a raw pointer: `spin_once`, except that with a lock that has a thread
4197    /// identity installed ([`super::exec_lock::ExecLock::with_thread_id`]) the
4198    /// lock is released around each user callback, so other threads (band
4199    /// workers, FFI callers) reach the executor while it runs.
4200    ///
4201    /// # Safety
4202    /// `this` points at a live executor. If any other thread can reach it, a
4203    /// lock is installed and the calling thread holds it exactly once, and
4204    /// every other thread takes it before touching the executor. No reference
4205    /// to the executor is live in the caller across this call.
4206    pub unsafe fn spin_once_shared(
4207        this: *mut Self,
4208        timeout: core::time::Duration,
4209    ) -> SpinOnceResult {
4210        let mut discarded = None;
4211        // SAFETY: the caller's contract is `pass`'s.
4212        unsafe { Self::pass(this, PassMode::Shared, timeout, &mut discarded) }
4213    }
4214
4215    /// phase-509 P9b item 2 -- one pass of a band worker over an executor it
4216    /// shares: the readiness of `worker`'s band, then its callbacks, most
4217    /// urgent level first, the lock released around each one as in
4218    /// [`Self::spin_once_shared`]. A band pass does not park, drive the
4219    /// transport or run the executor-wide chores (parameter and lifecycle
4220    /// services, the monitors' rules): the executor's own worker does.
4221    /// Issue 2020 -- it does tick the components its level registered
4222    /// ([`Self::pass_ticks_owner`]), and the executor's own worker then does
4223    /// not.
4224    ///
4225    /// phase-509 P9b item 3 -- a worker with a waiter
4226    /// ([`super::scheduler::BandWorker::with_waiter`]) first parks, the lock
4227    /// released, for at most `timeout`: until its band's next timer is due or
4228    /// an arrival in its band signals it. Without one, `timeout` is unused
4229    /// and the pass polls.
4230    ///
4231    /// # Safety
4232    /// As [`Self::spin_once_shared`].
4233    pub unsafe fn spin_band_once(
4234        this: *mut Self,
4235        worker: &mut super::scheduler::BandWorker,
4236        timeout: core::time::Duration,
4237    ) -> SpinOnceResult {
4238        let mut discarded = None;
4239        // SAFETY: the caller's contract is `pass`'s.
4240        unsafe { Self::pass(this, PassMode::Band(worker), timeout, &mut discarded) }
4241    }
4242
4243    /// phase-509 P9b item 3 -- link `worker`'s waiter into this executor's
4244    /// arrival routing (RFC-0108 D7): an arrival attributed to a slot of its
4245    /// band then wakes it alone, an unattributed one wakes it with the rest.
4246    /// A worker with no waiter polls; this is then a no-op, as on a build
4247    /// with no arrival path. Attach each waiter to one executor, once.
4248    pub fn attach_band_worker(&mut self, worker: &super::scheduler::BandWorker) {
4249        let Some(waiter) = worker.waiter else {
4250            return;
4251        };
4252        waiter.slots.store(
4253            self.band_slots(worker.level, worker.band) as usize,
4254            portable_atomic::Ordering::SeqCst,
4255        );
4256        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4257        if let Some(ctx) = self.wake_ctx.as_ref() {
4258            let w = waiter as *const super::scheduler::BandWaiter as *mut _;
4259            let head = &ctx.band_waiters;
4260            let mut cur = head.load(portable_atomic::Ordering::Acquire);
4261            loop {
4262                waiter.next.store(cur, portable_atomic::Ordering::Release);
4263                match head.compare_exchange(
4264                    cur,
4265                    w,
4266                    portable_atomic::Ordering::AcqRel,
4267                    portable_atomic::Ordering::Acquire,
4268                ) {
4269                    Ok(_) => break,
4270                    Err(now) => cur = now,
4271                }
4272            }
4273        }
4274    }
4275
4276    /// phase-509 P9b item 2 -- a band pass's prelude: check in as its level,
4277    /// read the clock, and credit its timers with the time since ITS previous
4278    /// pass. Item 3: with a waiter and a positive `timeout`, park first, with
4279    /// the lock released, until the band's next timer is due, an arrival in
4280    /// the band signals the waiter, or `timeout` passes.
4281    ///
4282    /// # Safety
4283    /// As [`Self::owned_prelude`].
4284    unsafe fn band_prelude(
4285        this: *mut Self,
4286        window_lock: Option<super::exec_lock::ExecLock>,
4287        w: &mut super::scheduler::BandWorker,
4288        timeout: core::time::Duration,
4289    ) -> (Option<u64>, u64) {
4290        // SAFETY: the caller's contract; derived again after the park.
4291        let mut s: &mut Self = unsafe { &mut *this };
4292        if let Some(waiter) = w.waiter {
4293            let mask = s.band_slots(w.level, w.band);
4294            waiter
4295                .slots
4296                .store(mask as usize, portable_atomic::Ordering::SeqCst);
4297            let budget_us = timeout.as_micros().min(u64::MAX as u128) as u64;
4298            // A band timer's remainder is as of the worker's last pass; the
4299            // time since is already spent.
4300            let since_us = match (s.now_us(), w.last_spin_end_us) {
4301                (Some(now), Some(prev)) => now.saturating_sub(prev),
4302                _ => 0,
4303            };
4304            let bound_us = s
4305                .next_timer_deadline_in_us(mask)
4306                .map_or(budget_us, |t| t.saturating_sub(since_us).min(budget_us));
4307            if bound_us > 0 && !s.band_has_arrivals(mask) {
4308                let window = window_lock.map(super::exec_lock::Window::open);
4309                waiter.wait_us(bound_us);
4310                drop(window);
4311                // SAFETY: the lock is held again.
4312                s = unsafe { &mut *this };
4313            }
4314        }
4315        let now = s.now_us();
4316        super::level_liveness::LEVELS.check_in(w.level, now);
4317        match now {
4318            Some(now) => {
4319                let prev = w.last_spin_end_us.unwrap_or(now);
4320                w.last_spin_end_us = Some(now);
4321                (Some(now), now.saturating_sub(prev))
4322            }
4323            None => (None, 0),
4324        }
4325    }
4326
4327    /// phase-509 P9b item 3 -- whether an arrival for one of `mask`'s slots
4328    /// is already in the ready word (it then does not park).
4329    fn band_has_arrivals(&self, mask: u64) -> bool {
4330        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4331        if let Some(ctx) = self.wake_ctx.as_ref() {
4332            return ctx.ready.load(portable_atomic::Ordering::SeqCst) as u64 & mask != 0;
4333        }
4334        let _ = mask;
4335        false
4336    }
4337
4338    /// phase-503 — microseconds from now to the earliest deadline of a pending
4339    /// supervised route (0 when one is due), or `None` when none is waiting or
4340    /// the executor has no clock. A port that supervises with its own timer
4341    /// (`MECHANISM_TIMER`) re-arms it from this after each spin.
4342    pub fn route_next_due_us(&self) -> Option<u64> {
4343        // No table, no clock read: this runs on every park.
4344        if self.monitor_table.is_empty() {
4345            return None;
4346        }
4347        let clock = self.clock_us_fn?;
4348        super::monitor::route_next_due_us(self.monitor_table, clock())
4349    }
4350
4351    /// Wall microseconds since the last spin ended, the origin every timer's
4352    /// `elapsed_us` is credited from; 0 without a clock.
4353    fn since_last_spin_end_us(&self) -> u64 {
4354        match (self.clock_us_fn, self.last_spin_end_us) {
4355            (Some(clock), Some(prev)) => clock().saturating_sub(prev),
4356            _ => 0,
4357        }
4358    }
4359
4360    /// phase-503 W7 -- a [`RouteWatch`](super::monitor::RouteWatch) over this
4361    /// executor's supervised routes, which a port's timer or reaction thread
4362    /// reads from outside the spin thread: at its expiry it sees a route the
4363    /// running spin has already answered, without waiting for the spin to
4364    /// return. `None` without a monitor table or a clock. Take it after
4365    /// `set_monitor_table` and any clock change.
4366    pub fn route_watch(&self) -> Option<super::monitor::RouteWatch> {
4367        if self.monitor_table.is_empty() {
4368            return None;
4369        }
4370        let clock = self.clock_us_fn?;
4371        Some(super::monitor::RouteWatch::new(self.monitor_table, clock))
4372    }
4373
4374    /// Issue #515 — warn about periods the spin cadence cannot express.
4375    ///
4376    /// A timer fires on the first spin at or after its period elapses, so
4377    /// a period that is not an integer multiple of the executor's spin
4378    /// period lands on the spin grid instead of the declared cadence: a
4379    /// 33 ms timer on a 5 ms spin alternates 35 ms / 30 ms, mean 33.0.
4380    /// The rate is preserved and no activation is dropped, so every
4381    /// runtime rule is (correctly) silent — the jitter stays invisible
4382    /// until someone measures cadence on target.
4383    ///
4384    /// Runs ONCE, on the first spin carrying a non-zero timeout, because
4385    /// that timeout is where the tier's declared spin period becomes
4386    /// visible down here. A resolve-time diagnostic would be better
4387    /// still — the toolchain holds both numbers before the image is
4388    /// built — but this backstop needs no board or codegen change and
4389    /// catches hand-written spin loops too.
4390    /// phase-436 W1 (issue 1192) — how long the executor may park, in
4391    /// microseconds, given the caller's budget.
4392    ///
4393    /// The park used to be bounded by the caller's timeout narrowed by exactly
4394    /// one input, the BACKEND's `next_deadline_ms`. A registered timer is a
4395    /// deadline the EXECUTOR owns, and nothing let it shorten the sleep — so
4396    /// ~~`spin_default`~~'s 50 ms slept straight past a 10 ms timer, and the
4397    /// activations then arrived in the burst issue 0505 describes.
4398    ///
4399    /// This is the `min` over the deadline sources phase-436 names. The
4400    /// caller's budget is always a member, so the result is never unbounded
4401    /// and never zero-by-omission: with no timers registered the budget
4402    /// stands, which keeps a long idle wait a wait rather than a poll.
4403    ///
4404    /// Distinct from issue 0515's audit, which warns that a period does not
4405    /// divide the spin grid. That takes the grid as given; this moves it.
4406    ///
4407    /// `#[cfg(test)]`-gated (matching `mod tests`'s own gate in `mod.rs`):
4408    /// production code calls `next_wake_bound_attributed_us` directly to get
4409    /// the source alongside the bound, so this `.0`-only convenience has no
4410    /// caller outside the test module and is dead code in any build that
4411    /// does not compile it (every `cargo check`/`cargo doc`, and `cargo test
4412    /// --features rmw-cffi`, which excludes `mod tests` too).
4413    #[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
4414    pub(crate) fn next_wake_bound_us(&self, budget_us: u64) -> u64 {
4415        self.next_wake_bound_attributed_us(budget_us, None).0
4416    }
4417
4418    /// phase-436 W6 — contribute a deadline source.
4419    ///
4420    /// The core never names an RTOS: a port registers `k_poll`, `epoll`, a
4421    /// queue set or a device ISR here, and the executor only ever asks "when
4422    /// will you next need me?".
4423    ///
4424    /// Refuses past capacity rather than dropping silently. A source that was
4425    /// quietly discarded would leave the executor sleeping past a deadline it
4426    /// had been told about, which is the failure this whole phase exists to
4427    /// remove.
4428    pub fn register_wake_source(
4429        &mut self,
4430        next: NextDeadlineFn,
4431        ctx: *mut core::ffi::c_void,
4432    ) -> Result<WakeSourceId, NodeError> {
4433        if self.worker.wake_source_count as usize >= MAX_WAKE_SOURCES {
4434            return Err(NodeError::ExecutorFull);
4435        }
4436        let idx = self.worker.wake_source_count as usize;
4437        self.worker.wake_sources[idx] = Some(WakeSourceSlot { next, ctx });
4438        self.worker.wake_source_count += 1;
4439        Ok(WakeSourceId::Platform(idx as u8))
4440    }
4441
4442    /// Install THE thing that blocks. Singular by nature — only one primitive
4443    /// can actually wait — so this replaces whatever was there.
4444    /// `granularity_us` is the finest park this primitive can actually
4445    /// express. The port states it because only the port knows: the ABI
4446    /// signature is not the limit — an RTOS tick is a second, coarser one, and
4447    /// a timespec primitive is a finer one.
4448    pub(crate) fn set_park_primitive(
4449        &mut self,
4450        park: ParkUntilFn,
4451        ctx: *mut core::ffi::c_void,
4452        granularity_us: u64,
4453    ) {
4454        self.worker.park_primitive = Some((park, ctx));
4455        self.worker.park_granularity_declared_us = granularity_us;
4456    }
4457
4458    /// Issue 2225 (RFC-0113 D9) -- install (`Some`) or clear (`None`) the hook
4459    /// the executor's wake object calls after every signal: a backend's
4460    /// arrival callback, an attributed entity wake, the signal-fd worker. The
4461    /// wake half of a yielding park slot, for a tier that never waits on the
4462    /// object itself. Returns `false` when this build has no wake object.
4463    ///
4464    /// `ctx` must outlive the hook: clear it before `ctx` goes away.
4465    pub(crate) fn set_wake_hook(
4466        &mut self,
4467        hook: Option<WakeHookFn>,
4468        ctx: *mut core::ffi::c_void,
4469    ) -> bool {
4470        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4471        if let Some(w) = self.node_wake.as_ref() {
4472            w.set_hook(hook, ctx);
4473            return true;
4474        }
4475        let _ = (hook, ctx);
4476        false
4477    }
4478
4479    /// The wake primitive a port must hand its `ParkUntilFn` as `ctx`, or NULL
4480    /// when this build has none.
4481    ///
4482    /// phase-436 W7 — a park that waits on its own fresh object is NOT
4483    /// breakable by the backend's listener, and installing one makes latency
4484    /// worse: once a port has parked, the transport drain drops to
4485    /// non-blocking, so an unbreakable park sleeps out its full deadline with
4486    /// data already waiting. Waiting on THIS object keeps the async break.
4487    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4488    pub(crate) fn wake_raw_ptr(&self) -> *mut core::ffi::c_void {
4489        match self.node_wake.as_ref() {
4490            Some(w) => w.raw_ptr(),
4491            None => core::ptr::null_mut(),
4492        }
4493    }
4494
4495    /// phase-509 P7 (RFC-0108 D8.1) -- the level this executor runs, 0 the
4496    /// most urgent. An executor's runner sets it ([`Self::set_level`]); one it
4497    /// never set is level 0, which is every executor of a one-level image.
4498    /// It matches the generated `NROS_ENTRY_LEVEL_OF_<node>` of the nodes the
4499    /// executor hosts.
4500    pub fn level(&self) -> u8 {
4501        self.worker.level
4502    }
4503
4504    /// phase-509 P7 -- set the level this executor runs; its spins check in
4505    /// as that level. `false`, and nothing changed, at or past
4506    /// [`MAX_LEVELS`](super::MAX_LEVELS).
4507    pub fn set_level(&mut self, level: u8) -> bool {
4508        if level as usize >= super::level_liveness::MAX_LEVELS {
4509            return false;
4510        }
4511        self.worker.level = level;
4512        true
4513    }
4514
4515    /// Whether a platform park primitive is installed.
4516    pub fn has_park_primitive(&self) -> bool {
4517        self.worker.park_primitive.is_some()
4518    }
4519
4520    /// The bound and the source that produced it: the `min` over every
4521    /// deadline source, with the caller's budget as an ordinary member.
4522    ///
4523    /// The budget being a member is what makes the result never unbounded and
4524    /// never zero-by-omission, and it is why "no sources registered" needs no
4525    /// special case.
4526    pub(crate) fn next_wake_bound_attributed_us(
4527        &self,
4528        budget_us: u64,
4529        session_us: Option<u64>,
4530    ) -> (u64, WakeSourceId) {
4531        let mut best = (budget_us, WakeSourceId::CallerBudget);
4532
4533        // The backend's next internal event is a MEMBER, not a pre-cap. It was
4534        // folded into the budget upstream, which meant a park the session
4535        // actually won reported `CallerBudget` — the attribution named the
4536        // wrong source, and "why did we wake" is the question attribution
4537        // exists to answer.
4538        if let Some(s) = session_us
4539            && s < best.0
4540        {
4541            best = (s, WakeSourceId::Session);
4542        }
4543
4544        // Issue 2143 -- a timer's remainder is as of the last spin's END, the
4545        // point its `elapsed_us` was last credited; the time the host spent
4546        // since (a sleep between spins, the work of its own loop) is already
4547        // spent. Without this the bound was measured from a stale origin and
4548        // overslept the timer by exactly that gap -- the band prelude makes the
4549        // same correction for its own worker.
4550        if let Some(timer_us) = self
4551            .next_timer_deadline_us()
4552            .map(|t| t.saturating_sub(self.since_last_spin_end_us()))
4553            && timer_us < best.0
4554        {
4555            best = (timer_us, WakeSourceId::Timer);
4556        }
4557
4558        // phase-503 — a supervised route's deadline is the executor's own, like
4559        // a timer's: the park must end there, or a route whose output never
4560        // comes is judged only when something else wakes the loop.
4561        if let Some(due_us) = self.route_next_due_us()
4562            && due_us < best.0
4563        {
4564            best = (due_us, WakeSourceId::RouteDeadline);
4565        }
4566
4567        for (i, slot) in self.worker.wake_sources.iter().enumerate() {
4568            let Some(slot) = slot else { continue };
4569            // SAFETY: the callback and ctx were supplied together by the port
4570            // through `register_wake_source` and outlive the executor by its
4571            // contract; the call takes no ownership.
4572            let due = unsafe { (slot.next)(slot.ctx) };
4573            // `u64::MAX` is "nothing pending" — an async source spells itself
4574            // this way and breaks the park by signalling instead.
4575            if due != u64::MAX && due < best.0 {
4576                best = (due, WakeSourceId::Platform(i as u8));
4577            }
4578        }
4579
4580        best
4581    }
4582
4583    /// phase-436 — the bound the last park used, and which source won it.
4584    pub fn last_park(&self) -> (u64, WakeSourceId) {
4585        (self.worker.last_park_bound_us, self.worker.last_park_source)
4586    }
4587
4588    /// phase-436 W2 — the park the platform was actually asked for, after
4589    /// rounding the request up to an expressible value.
4590    ///
4591    /// Differs from `last_park().0` exactly when the request did not land on
4592    /// the platform's grid. Both are reported because a cadence that cannot be
4593    /// waited in must not be presented as if it were met.
4594    pub fn last_park_achieved_us(&self) -> u64 {
4595        self.worker.last_park_achieved_us
4596    }
4597
4598    /// phase-436 W3 (issue 1194) — the granularity the release-jitter
4599    /// measurement was actually made at, in microseconds.
4600    ///
4601    /// The jitter statistic is reported in microseconds, but the mechanism
4602    /// that PACES the loop decides how fine it can honestly be, and there are
4603    /// two mechanisms:
4604    ///
4605    /// * **No declared cadence** — the loop is paced by the blocking wait
4606    ///   itself, so it cannot come round more precisely than one park granule.
4607    ///   Reporting µs there is precision the wait cannot deliver.
4608    /// * **A declared cadence** (`set_spin_nominal_us`) — the caller paces
4609    ///   itself, as nros-cpp's tiers do with `platform_sleep_us` between
4610    ///   spins. That sleep is finer than the wait, so the measurement really
4611    ///   is good to a microsecond and coarsening it would understate it.
4612    ///
4613    /// Reported rather than applied: this does not change what the monitor
4614    /// judges, because silently coarsening a bound could mask real lateness.
4615    /// It says what the number is worth.
4616    pub fn release_jitter_granularity_us(&self) -> u64 {
4617        if self.spin_nominal_self_paced() {
4618            1
4619        } else {
4620            self.park_granularity_us()
4621        }
4622    }
4623
4624    /// The finest park this build can actually express, in microseconds.
4625    ///
4626    /// Comes from the INSTALLED primitive, because only the port knows. The
4627    /// exported ABI signature is not the limit in either direction (issue
4628    /// 1242):
4629    ///
4630    /// * an RTOS tick is a COARSER limit — ThreadX pins
4631    ///   `TX_TIMER_TICKS_PER_SECOND = 100`, a 10 ms tick, so a 1 ms park is not
4632    ///   achievable there however the ABI is spelled;
4633    /// * a timespec primitive is a FINER one — POSIX's `sem_timedwait` and
4634    ///   `pthread_cond_timedwait` are nanosecond-native, and only the
4635    ///   `uint32_t timeout_ms` signature floors them at a millisecond.
4636    ///
4637    /// This was a hardcoded `1_000` justified by "every `nros_platform_wake_*`
4638    /// port takes `uint32_t timeout_ms`". That was right for FreeRTOS by
4639    /// coincidence and wrong for the other two, and it made the W3 jitter
4640    /// report over-claim on ThreadX — the failure W3 exists to prevent,
4641    /// reproduced one layer below W3 in its own dependency.
4642    ///
4643    /// With nothing installed the fallback is 1 ms, which IS the honest floor
4644    /// of the `wake_wait_ms` ABI every port exports today.
4645    ///
4646    /// The declared granularity counts only while the park is USABLE. An
4647    /// executor whose backend has no live wake (XRCE, or a session whose
4648    /// callback is gone) never parks: `spin_once` waits in the transport's
4649    /// own receive instead, whose timeout is whole milliseconds. Claiming a
4650    /// POSIX park's 1 us there over-claimed the jitter report, and worse,
4651    /// rounded the bound to microseconds that the millisecond wait then
4652    /// truncated, so a sub-millisecond deadline became a non-blocking receive
4653    /// and the loop spun hot until it passed.
4654    pub fn park_granularity_us(&self) -> u64 {
4655        if self.worker.park_granularity_declared_us != 0
4656            && self.worker.park_primitive.is_some()
4657            && self.park_can_be_woken()
4658        {
4659            self.worker.park_granularity_declared_us
4660        } else {
4661            1_000
4662        }
4663    }
4664
4665    /// phase-436 — whether a park could be ended early by data arriving.
4666    ///
4667    /// A park ends on its bound or on the wake object. With a poll-only
4668    /// backend (XRCE), or a session whose wake callback is gone, nothing
4669    /// signals that object, so a park would be a blind sleep and data
4670    /// arriving mid-park would wait out the whole bound — the Phase 127.C.4
4671    /// stall (a reliable stream whose ACK lands in a session nobody drives).
4672    /// Builds without `alloc` + `rmw-cffi` have no wake callback at all and
4673    /// park as they always did.
4674    pub(crate) fn park_can_be_woken(&self) -> bool {
4675        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4676        {
4677            self.async_wake_live()
4678        }
4679        #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
4680        {
4681            true
4682        }
4683    }
4684
4685    /// phase-521 W6 (RFC-0113 D2) — the budget an entry's spin loop passes to
4686    /// [`spin_once`](Self::spin_once).
4687    ///
4688    /// An entry loop is `loop { spin_once(budget) }`, and each park already
4689    /// ends at the next deadline the executor knows (a timer, a backend's next
4690    /// internal event, a platform source) or on the wake object. So the
4691    /// budget is not a cadence: it bounds only a park nothing else bounds.
4692    /// Every entry used to pass 10 ms, which woke an idle tier 100 times a
4693    /// second whatever its park could do.
4694    ///
4695    /// [`ENTRY_IDLE_BUDGET`] when every session can end the wait itself: it
4696    /// states `Notify` with its wake callback live, or `Waitable` (whose
4697    /// handle the port multiplexes with the wake object). A cancel and a
4698    /// hosted termination post the same wake object. Otherwise
4699    /// [`ENTRY_POLL_BUDGET`]: what is left can wait on the wake object without
4700    /// its data posting it (a wake fan-out with no free member, more waitable
4701    /// sessions than one park multiplexes, a port with no wake object), so the
4702    /// short budget bounds how long its data waits. A session with no
4703    /// readiness form never gets here: it is refused at open (RFC-0113 D5).
4704    /// An enrolled component whose `tick` does work (it polls a service reply
4705    /// or completes action goals once per spin) also keeps the slice, so its
4706    /// tick rate does not change under it.
4707    pub fn entry_spin_budget(&self) -> core::time::Duration {
4708        if self.every_session_ends_the_wait() && !self.has_ticking_component() {
4709            ENTRY_IDLE_BUDGET
4710        } else {
4711            ENTRY_POLL_BUDGET
4712        }
4713    }
4714
4715    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4716    fn every_session_ends_the_wait(&self) -> bool {
4717        use nros_rmw::{ReadinessSource, Session as _};
4718        if self.node_wake.is_none() {
4719            return false;
4720        }
4721        let notify_live = self.async_wake_live();
4722        let primary = core::iter::once(self.session.readiness_source());
4723        let extra = self.extra_sessions.iter().map(|e| e.readiness_source());
4724        let mut waitable = 0usize;
4725        for source in primary.chain(extra) {
4726            match source {
4727                ReadinessSource::Waitable(_) => waitable += 1,
4728                ReadinessSource::Notify if notify_live => {}
4729                _ => return false,
4730            }
4731        }
4732        // More waitable sessions than one park multiplexes cannot be parked
4733        // on at all (`waitable_handles`).
4734        waitable <= MAX_WAITABLE_HANDLES
4735    }
4736
4737    #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
4738    fn every_session_ends_the_wait(&self) -> bool {
4739        false
4740    }
4741
4742    /// phase-521 W3 (RFC-0113 D3) -- write the `ReadinessSource::Waitable`
4743    /// handles of this executor's sessions (primary first, then the extra
4744    /// ones) into `out`, and return how many. Asked every spin: a session's
4745    /// handle exists only once its transport is connected.
4746    ///
4747    /// More waitable sessions than `out` holds is answered with ZERO, not a
4748    /// truncated set: parking on some handles but not others would be a missed
4749    /// wake. Zero leaves the executor on the short entry budget, whose drain
4750    /// sees all of them.
4751    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4752    pub(crate) fn waitable_handles(&self, out: &mut [i32; MAX_WAITABLE_HANDLES]) -> usize {
4753        use nros_rmw::{ReadinessSource, Session as _};
4754        let mut n = 0;
4755        let primary = core::iter::once(self.session.readiness_source());
4756        let extra = self.extra_sessions.iter().map(|e| e.readiness_source());
4757        for source in primary.chain(extra) {
4758            if let ReadinessSource::Waitable(fd) = source {
4759                if n == out.len() {
4760                    return 0;
4761                }
4762                out[n] = fd;
4763                n += 1;
4764            }
4765        }
4766        n
4767    }
4768
4769    /// Drive the primary session for `timeout_ms`, the transport-side half of
4770    /// the wait.
4771    ///
4772    /// phase-521 W6 (RFC-0113 D2) — after a park this is always a zero, so it
4773    /// drains rather than waits. The guard slices phase-436 cut a poll-only
4774    /// backend's receive into are gone with that backend: a session that
4775    /// states no readiness form is refused at open (D5), so nothing waits
4776    /// inside a transport's `recv` for a trigger that cannot end it.
4777    fn drive_primary(&mut self, timeout_ms: i32) -> Result<(), ()> {
4778        self.session.drive_io(timeout_ms).map_err(|_| ())
4779    }
4780
4781    pub(crate) fn round_park_up_us(&self, requested_us: u64) -> u64 {
4782        if requested_us == 0 {
4783            return 0;
4784        }
4785        let g = self.park_granularity_us();
4786        if g <= 1 {
4787            return requested_us;
4788        }
4789        requested_us.div_ceil(g).saturating_mul(g)
4790    }
4791
4792    /// Microseconds until the soonest live timer is due, or `None` when no
4793    /// timer is pending.
4794    ///
4795    /// A timer that is cancelled, or a one-shot that already fired, owes
4796    /// nothing and must not hold the park short — otherwise the executor keeps
4797    /// waking on a deadline nobody is waiting for.
4798    ///
4799    /// So does a timer whose remainder is not WALL microseconds
4800    /// ([`TimerClockSource::remaining_is_wall_time`], issue 1321): the number
4801    /// this returns is handed to the port's park primitive, so a ROS-time
4802    /// timer's simulated remainder would be a units error — and on a paused
4803    /// `/clock` a frozen one, waking the image once per timer period forever.
4804    /// Its wake source is a `/clock` message, which reaches `drive_io` like
4805    /// any sample.
4806    ///
4807    /// phase-509 P9b item 3 -- the executor's own worker's timers: those of
4808    /// its band (every timer for the one-level worker), so it does not wake
4809    /// for a timer another band's worker runs.
4810    pub(crate) fn next_timer_deadline_us(&self) -> Option<u64> {
4811        self.next_timer_deadline_in_us(self.band_slots(self.worker.level, self.worker.band))
4812    }
4813
4814    /// phase-509 P9b item 3 -- [`Self::next_timer_deadline_us`] over the
4815    /// timers in `mask`'s slots.
4816    pub(crate) fn next_timer_deadline_in_us(&self, mask: u64) -> Option<u64> {
4817        let arena_ptr = self.arena.as_ptr() as *const u8;
4818        let mut soonest: Option<u64> = None;
4819        for (i, meta) in self.entries.iter().enumerate() {
4820            let Some(meta) = meta else { continue };
4821            if i < 64 && mask & (1u64 << i) == 0 {
4822                continue;
4823            }
4824            // phase-509 P9b item 2 -- not a timer running in another worker's
4825            // window: its header is that worker's while it runs, and a
4826            // running timer is not the one to wait for.
4827            if !matches!(meta.kind, EntryKind::Timer) || Self::slot_held(self.slot_tags, i) {
4828                continue;
4829            }
4830            // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`, whose
4831            // leading layout is `TimerHeader` — the same projection
4832            // `audit_spin_quantization` relies on.
4833            let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
4834            if header.cancelled || header.period_us == 0 {
4835                continue;
4836            }
4837            if header.oneshot && header.fired {
4838                continue;
4839            }
4840            // Issue 1321 — the park is WALL microseconds, so only a timer
4841            // whose own clock runs in them may bound it.
4842            if !header.clock_source.remaining_is_wall_time() {
4843                continue;
4844            }
4845            // Already due: the bound is zero, and no other timer can beat it.
4846            let remaining = header.period_us.saturating_sub(header.elapsed_us);
4847            soonest = Some(match soonest {
4848                Some(cur) => cur.min(remaining),
4849                None => remaining,
4850            });
4851            if remaining == 0 {
4852                break;
4853            }
4854        }
4855        soonest
4856    }
4857
4858    fn audit_spin_quantization(&mut self, spin_us: u64) {
4859        self.spin_quantized_timers(spin_us, |period_us, early_us, late_us| {
4860            nros_log::log_warn!(
4861                nros_log::get_logger("nros"),
4862                "timer period {} us is not a multiple of the {} us spin period: activations will alternate between {} us and {} us (mean cadence preserved)",
4863                period_us,
4864                spin_us,
4865                early_us,
4866                late_us
4867            );
4868        });
4869    }
4870
4871    /// Each timer the executor's own worker runs whose period `spin_us`
4872    /// quantizes, as `(period_us, early_us, late_us)`. Split out of
4873    /// [`Self::audit_spin_quantization`], which only logs, so the choice of
4874    /// timers can be asserted.
4875    ///
4876    /// Issue 2050 -- only the timers in the worker's own band
4877    /// ([`Self::band_slots`], the same mask its pass dispatches from). In a
4878    /// level runner the executor's own worker is the boot level, paced at the
4879    /// boot level's spin period, while a spawned level's timers run on that
4880    /// level's band worker, whose park is bounded by its next timer deadline.
4881    /// Auditing every timer judged a 1 ms timer on a spawned level against a
4882    /// 10 ms boot spin it never ran under.
4883    pub(crate) fn spin_quantized_timers(&self, spin_us: u64, mut f: impl FnMut(u64, u64, u64)) {
4884        if spin_us == 0 {
4885            return;
4886        }
4887        let mask = self.band_slots(self.worker.level, self.worker.band);
4888        let arena_ptr = self.arena.as_ptr() as *const u8;
4889        for (i, meta) in self.entries.iter().enumerate() {
4890            let Some(meta) = meta.as_ref() else {
4891                continue;
4892            };
4893            if i < 64 && mask & (1u64 << i) == 0 {
4894                continue;
4895            }
4896            if !matches!(meta.kind, EntryKind::Timer) || Self::slot_held(self.slot_tags, i) {
4897                continue;
4898            }
4899            // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
4900            // whose leading layout is `TimerHeader`.
4901            let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
4902            let period_us = header.period_us;
4903            let Some((early_us, late_us)) =
4904                spin_quantization_span(header.clock_source, period_us, spin_us)
4905            else {
4906                continue;
4907            };
4908            f(period_us, early_us, late_us);
4909        }
4910    }
4911
4912    /// Issue #514 — whether the executor logs each violation as it is
4913    /// detected (the default).
4914    ///
4915    /// Turn this off in an application that reports violations its own
4916    /// way via [`Self::drain_violations`]; the ring is unaffected
4917    /// either way.
4918    pub fn set_report_violations(&mut self, enabled: bool) {
4919        self.violations.report = enabled;
4920    }
4921
4922    /// phase-474 I1 -- the drain-and-report hook: at the end of every spin,
4923    /// drain the ring and log each entry with its sequence number and the
4924    /// `total` / `dropped` counters (`contract violation #N: <rule> <fqn>
4925    /// measured=.. declared=.. (total=.. dropped=..)`).
4926    ///
4927    /// While on it REPLACES the log line at detection, so one verdict is one
4928    /// line, and an application that wanted to drain the ring itself sees an
4929    /// empty one. The generated C/C++ entries turn it on when the image sets
4930    /// `CONFIG_NROS_VIOLATION_DRAIN_REPORT` (off by default). The trace marker
4931    /// (`callback_trace::MARKER_VIOLATION`) and the SWD record do not depend
4932    /// on it: both are written at detection.
4933    pub fn set_violation_drain_report(&mut self, enabled: bool) {
4934        self.violations.drain_report = enabled;
4935    }
4936
4937    /// Issue #514 / phase-474 I1 -- violations evicted from the full ring
4938    /// before any drain took them.
4939    ///
4940    /// The ring keeps the LATEST `MAX_VIOLATIONS`, so non-zero means the
4941    /// OLDEST verdicts were lost, never the newest. (Before phase-474 a full
4942    /// ring refused the push, which kept the first verdicts since boot and
4943    /// lost every later one.)
4944    pub fn violations_dropped(&self) -> u32 {
4945        self.violations.counts.dropped
4946    }
4947
4948    /// phase-474 I1 -- contract violations this executor stored since boot
4949    /// (wrapping). The newest entry in the ring is number `violations_total()`;
4950    /// `total - dropped - pending` is how many drains have taken.
4951    pub fn violations_total(&self) -> u32 {
4952        self.violations.counts.total
4953    }
4954
4955    /// RFC-0052 W3b.4 -- drain pending contract violations, OLDEST first.
4956    /// Draining clears the ring. Call between spins, never from a callback.
4957    pub fn drain_violations(&mut self, mut f: impl FnMut(&super::monitor::Violation)) {
4958        self.violations.drain(|_, v| f(v));
4959    }
4960
4961    /// [`Self::drain_violations`] with each entry's sequence number (1-based,
4962    /// per executor; the same number the drain-and-report line and the trace
4963    /// marker carry), so a reader can tell a gap from a quiet interval.
4964    pub fn drain_violations_numbered(&mut self, f: impl FnMut(u32, &super::monitor::Violation)) {
4965        self.violations.drain(f);
4966    }
4967
4968    /// phase-474 I2 -- choose when this executor's contract monitors arm,
4969    /// overriding the build default (`NROS_MONITOR_ARM_ON_CALL`,
4970    /// `NROS_MONITOR_ARM_GRACE_MS`). Call before the first spin (an
4971    /// `arm_monitors` made before it is undone by this); after the first spin
4972    /// an executor that already armed stays armed.
4973    ///
4974    /// `on_call`: wait for [`Self::arm_monitors`] or
4975    /// [`super::monitor::request_monitor_arming`] (`rclc_monitors_arm()`).
4976    /// `grace_ms`: arm anyway that long after the first spin (0 = never by
4977    /// deadline; with `on_call == false`, 0 means "at the first spin").
4978    pub fn set_monitor_arming(&mut self, on_call: bool, grace_ms: u32) {
4979        let v = &mut self.violations.counts;
4980        v.arm_on_call = on_call;
4981        v.arm_grace_ms = grace_ms;
4982        if !v.spun {
4983            // Before the first spin, "armed" is just the policy's answer (no
4984            // rule has run yet); the first spin counts the arming once.
4985            v.armed = super::monitor::MonitorArming { on_call, grace_ms }.armed_at_first_spin();
4986        }
4987    }
4988
4989    /// phase-474 I2 -- the application says its start-up is over: this
4990    /// executor's monitors start judging now. Idempotent.
4991    ///
4992    /// Judgement restarts at arming: every rate/age/silence window reopens,
4993    /// a latency or age measured before arming is discarded, the release-
4994    /// jitter statistics are cleared (so the figure after RUN is RUN's), and a
4995    /// stack low-water mark already past the declared minimum is reported
4996    /// again, because stack depth reached at start-up is a real fault, not
4997    /// start-up noise.
4998    pub fn arm_monitors(&mut self) {
4999        self.arm_now("the application armed them");
5000    }
5001
5002    /// phase-474 I2 -- whether this executor's monitors are armed.
5003    pub fn monitors_armed(&self) -> bool {
5004        self.violations.counts.armed
5005    }
5006
5007    /// phase-474 I2 -- verdicts reached before the monitors armed: counted,
5008    /// never stored, logged, sunk or traced.
5009    pub fn violations_suppressed_before_arm(&self) -> u32 {
5010        self.violations.counts.suppressed_before_arm
5011    }
5012
5013    fn arm_now(&mut self, why: &str) {
5014        if self.violations.counts.armed {
5015            return;
5016        }
5017        self.violations.counts.armed = true;
5018        for st in self.monitor_states.iter_mut() {
5019            *st = super::monitor::MonitorState::default();
5020        }
5021        for st in self.age_states.iter_mut() {
5022            *st = super::monitor::AgeState::default();
5023        }
5024        for spec in self.monitor_table.iter() {
5025            spec.cell
5026                .max_latency_us
5027                .store(0, core::sync::atomic::Ordering::Relaxed);
5028        }
5029        // phase10-B3 -- a take waiting since start-up is not a route of RUN's.
5030        super::monitor::route_reset(self.monitor_table);
5031        for spec in self.age_table.iter() {
5032            spec.cell
5033                .max_age_ms
5034                .store(0, core::sync::atomic::Ordering::Relaxed);
5035            spec.cell
5036                .takes
5037                .store(0, core::sync::atomic::Ordering::Relaxed);
5038            spec.cell
5039                .arrivals
5040                .store(0, core::sync::atomic::Ordering::Relaxed);
5041        }
5042        self.clear_release_jitter_stats();
5043        self.worker.stack_headroom_reported = usize::MAX;
5044        self.worker.stack_headroom_last_check_us = None;
5045        // Counted once per executor: here once it has spun, else by the first
5046        // spin (which counts an executor that is armed by then either way).
5047        #[cfg(nros_boot_report)]
5048        if self.violations.counts.spun {
5049            super::monitor::NROS_VIOLATION_RECORD
5050                .armed
5051                .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
5052        }
5053        nros_log::log_info!(
5054            nros_log::get_logger("nros"),
5055            "contract monitors armed ({}); {} verdict(s) before arming suppressed",
5056            why,
5057            self.violations.counts.suppressed_before_arm
5058        );
5059    }
5060
5061    /// phase-474 I2 -- once per spin, before any rule runs: note the first
5062    /// spin, and arm when the policy says so.
5063    fn update_monitor_arming(&mut self, now_us: Option<u64>) {
5064        // Milliseconds in a wrapping `u32`, like the silence rule's lease
5065        // (`AgeState::last_take_ms`): this state is inline in the `Executor`
5066        // value, whose size is budgeted (issue 0961).
5067        let now_ms = now_us.map(|us| (us / 1_000) as u32);
5068        let v = &mut self.violations.counts;
5069        if !v.spun {
5070            v.spun = true;
5071            v.first_spin_ms = now_ms.unwrap_or(0);
5072            if v.armed {
5073                // Armed from the start (the default), or by a call made before
5074                // the first spin: count it in the record so a reader sees how
5075                // many executors are judging.
5076                #[cfg(nros_boot_report)]
5077                super::monitor::NROS_VIOLATION_RECORD
5078                    .armed
5079                    .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
5080                return;
5081            }
5082            if (super::monitor::MonitorArming {
5083                on_call: v.arm_on_call,
5084                grace_ms: v.arm_grace_ms,
5085            })
5086            .armed_at_first_spin()
5087            {
5088                self.arm_now("at the first spin");
5089                return;
5090            }
5091        }
5092        if v.armed {
5093            return;
5094        }
5095        if super::monitor::arm_requests() != v.arm_requests_seen {
5096            self.arm_now("the application asked: rclc_monitors_arm");
5097            return;
5098        }
5099        let grace_ms = v.arm_grace_ms;
5100        if grace_ms == 0 {
5101            return;
5102        }
5103        match now_ms {
5104            Some(now) if now.wrapping_sub(v.first_spin_ms) >= grace_ms => {
5105                self.arm_now("the grace period after the first spin elapsed");
5106            }
5107            // No clock: a grace period cannot elapse, and waiting forever would
5108            // leave the image unmonitored. Arm rather than guess a duration.
5109            None if !v.arm_on_call => {
5110                self.arm_now("no clock to time the grace period");
5111            }
5112            _ => {}
5113        }
5114    }
5115
5116    /// Issue 1635 -- hand every stored violation to `sink`, beside the log line
5117    /// (issue 0514's floor) and the ring push.
5118    ///
5119    /// phase-474 I7 -- not at detection any more: the verdict is queued and
5120    /// leaves from `Executor::flush_violation_reports` at the end of a spin with
5121    /// slack, so the report never costs the dispatch it judges.
5122    ///
5123    /// Before this nothing in a generated image drained the ring, so the log
5124    /// line was the only output and `/diagnostics` stayed silent on every C
5125    /// and C++ road. A report point the RUNTIME owns reaches every road at
5126    /// once — every board's spin loop goes through the executor — where one
5127    /// per entry template would have to be rebuilt for each board runner. The
5128    /// ring is still filled, so an application (or a fixture) that drains it
5129    /// with [`Self::drain_violations`] sees exactly what it did before.
5130    /// `None` removes the sink.
5131    ///
5132    /// # Safety
5133    /// `ctx` must stay valid for every call of the sink until it is replaced
5134    /// or this executor is dropped (see [`super::monitor::ViolationSink`]).
5135    pub unsafe fn set_violation_sink(
5136        &mut self,
5137        sink: Option<(super::monitor::ViolationSink, *mut core::ffi::c_void)>,
5138    ) {
5139        self.violations.sink = sink.map(|(f, ctx)| (f, ctx as usize));
5140        if self.violations.sink.is_none() {
5141            // Nothing is left to send them to.
5142            self.violations.reports.pending.clear();
5143            self.violations.reports.since_us = None;
5144        }
5145    }
5146
5147    /// phase-474 I7 -- hand the queued verdicts to the sink, if this is a slot
5148    /// for it. Called at the end of every spin, after its dispatches and its
5149    /// rules, so the report never runs inside the tick being judged.
5150    ///
5151    /// A slot is a spin end whose next timer is due no sooner than the last
5152    /// report took (`ReportQueue::last_cost_us`; 0 before the first report, so
5153    /// the first goes at the first spin end with no timer already due), or
5154    /// any spin end once the oldest pending verdict has waited
5155    /// [`REPORT_DEFER_MAX_US`](super::monitor::REPORT_DEFER_MAX_US). Without a
5156    /// clock there is nothing to measure slack with, so every spin end is a
5157    /// slot. The sink takes as many as it coalesces into one message; the rest
5158    /// wait for the next slot.
5159    ///
5160    /// The time the sink takes is the reporter's own and is stated by
5161    /// [`Self::violation_report_cost_us`]; the release-jitter rule does not
5162    /// charge it to the spin's cadence (it is not a late release of any
5163    /// application work), which is what used to turn one verdict into a
5164    /// `release-jitter-runtime` verdict of its own on the safety island.
5165    pub(crate) fn flush_violation_reports(&mut self) {
5166        let Some((sink, ctx)) = self.violations.sink else {
5167            return;
5168        };
5169        if self.violations.reports.pending.is_empty() {
5170            return;
5171        }
5172        let now = self.now_us();
5173        if let Some(now) = now {
5174            let since = *self.violations.reports.since_us.get_or_insert(now);
5175            let overdue = now.saturating_sub(since) >= super::monitor::REPORT_DEFER_MAX_US;
5176            let cost = self.violations.reports.last_cost_us as u64;
5177            let slack = match self.next_timer_deadline_us() {
5178                None => true,
5179                Some(remaining) => remaining > 0 && remaining >= cost,
5180            };
5181            if !slack && !overdue {
5182                return;
5183            }
5184        }
5185        // SAFETY: `Executor::set_violation_sink`'s contract -- `ctx` is valid
5186        // for every call until the sink is replaced or the executor dropped.
5187        let taken = unsafe {
5188            sink(
5189                ctx as *mut core::ffi::c_void,
5190                &self.violations.reports.pending[..],
5191            )
5192        };
5193        let q = &mut self.violations.reports;
5194        if taken == 0 {
5195            // The sink counted its failure; drop the oldest so a report that
5196            // can never be sent does not stall the queue. It stays in the ring
5197            // and the record.
5198            q.pending.remove(0);
5199            q.failed = q.failed.saturating_add(1);
5200        } else {
5201            let n = taken.min(q.pending.len());
5202            for _ in 0..n {
5203                q.pending.remove(0);
5204            }
5205        }
5206        q.since_us = None;
5207        if let (Some(t0), Some(t1)) = (now, self.now_us()) {
5208            let q = &mut self.violations.reports;
5209            let cost = t1.saturating_sub(t0);
5210            q.last_cost_us = cost.min(u32::MAX as u64) as u32;
5211            q.max_cost_us = q.max_cost_us.max(q.last_cost_us);
5212            q.debt_us = q.debt_us.saturating_add(cost);
5213        }
5214    }
5215
5216    /// phase-474 I7 -- what handing verdicts to the violation sink has cost
5217    /// this executor: `(last_us, max_us, pending, overflowed)`. `overflowed`
5218    /// counts verdicts evicted from the report queue by newer ones before a
5219    /// slot came (they are still in the ring and the SWD record).
5220    pub fn violation_report_cost_us(&self) -> (u32, u32, usize, u32) {
5221        let q = &self.violations.reports;
5222        (q.last_cost_us, q.max_cost_us, q.pending.len(), q.overflowed)
5223    }
5224
5225    /// THE monotonic-µs read. phase-359 W4 — every consumer goes through here.
5226    ///
5227    /// Before this there were FIVE spellings of "what time is it": this one,
5228    /// ~~`last_spin_end: Instant`~~, and two ad-hoc `static EPOCH: OnceLock<Instant>`
5229    /// blocks — plus `PlatformClock`, which nothing called because `Executor` is
5230    /// deliberately non-generic and the trait's methods are associated fns.
5231    ///
5232    /// `None` means no clock is available (no_std with no injected hook).
5233    /// Callers must degrade, not guess: a missing clock is why the sporadic
5234    /// refill and the major-frame phase behaved differently on no_std.
5235    fn now_us(&mut self) -> Option<u64> {
5236        self.clock_us_fn.map(|clock| clock())
5237    }
5238
5239    /// Run the rate/latency/age checks over the baked tables (single
5240    /// branch each when empty).
5241    fn run_contract_monitors(&mut self) {
5242        if !self.monitor_table.is_empty()
5243            && let Some(now_us) = self.now_us()
5244        {
5245            {
5246                for (i, spec) in self
5247                    .monitor_table
5248                    .iter()
5249                    .take(super::monitor::MAX_MONITORS)
5250                    .enumerate()
5251                {
5252                    if let Some(v) =
5253                        super::monitor::check_rate(spec, &mut self.monitor_states[i], now_us)
5254                    {
5255                        self.violations.record(v);
5256                    }
5257                    if let Some(v) =
5258                        super::monitor::check_latency(spec, &mut self.monitor_states[i])
5259                    {
5260                        self.violations.record(v);
5261                    }
5262                }
5263            }
5264        }
5265        if !self.age_table.is_empty() {
5266            // phase-462 W2 -- the age loop needs the clock now: `silence-runtime`
5267            // judges how long an endpoint has taken NOTHING, and `None` (a
5268            // build with no clock) makes that rule abstain rather than guess.
5269            // Read before the loop because the borrow of `age_states` below
5270            // outlives the `&mut self` the reader wants.
5271            let now_us = self.now_us();
5272            for (i, spec) in self
5273                .age_table
5274                .iter()
5275                .take(super::monitor::MAX_AGE_MONITORS)
5276                .enumerate()
5277            {
5278                if let Some(v) = super::monitor::check_age(spec, &mut self.age_states[i], now_us) {
5279                    self.violations.record(v);
5280                }
5281            }
5282        }
5283        // phase-462 W3b -- the hazard observer reads the verdict STATE the rows
5284        // above just updated, so it runs after them and on the same tick.
5285        if !self.hazard_table.is_empty()
5286            && let Some(now_us) = self.now_us()
5287        {
5288            let now_ms = (now_us / 1_000) as u32;
5289            // phase-462 W3c -- a detected hazard whose reacting path states a
5290            // `safe_state` asks the reaction hook to start it, once per
5291            // detection. Not before arming: a start-up verdict is only
5292            // counted (phase-474 I2), and it starts nothing either.
5293            let hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
5294            // play_launch phase 96 W4.2 -- the mode this executor judges by
5295            // says which rung of each hazard's ladder is active.
5296            let mode = self.mode_applied;
5297            let violations = &mut self.violations;
5298            for (row, spec) in self.hazard_table.iter().enumerate() {
5299                super::monitor::check_hazard(
5300                    spec,
5301                    self.monitor_table,
5302                    &self.monitor_states,
5303                    &self.age_states,
5304                    mode,
5305                    now_ms,
5306                    |v| {
5307                        if v.rule == "hazard-detected" && spec.reaction_id != 0 {
5308                            call_hazard_reaction(hook, spec, row, &v, now_us);
5309                        }
5310                        violations.record(v);
5311                    },
5312                );
5313            }
5314        }
5315        self.check_route_deadlines();
5316    }
5317
5318    /// phase-503 — `route-deadline-runtime`: every pending supervised route
5319    /// past `take + route_deadline_ms` is reported (once until a route is
5320    /// within budget again) and its reaction requested through the hook.
5321    /// Runs right after the park, whose bound includes the earliest due time
5322    /// ([`WakeSourceId::RouteDeadline`]), so a route whose output never comes
5323    /// is judged within one park granule of its deadline. A callback that does
5324    /// not return holds this check too: that is `MECHANISM_TIMER`'s case.
5325    fn check_route_deadlines(&mut self) {
5326        let table = self.monitor_table;
5327        if table.is_empty() || !super::monitor::route_deadline_active(table) {
5328            return;
5329        }
5330        let Some(now_us) = self.now_us() else {
5331            return;
5332        };
5333        // Start-up is not RUN (phase-474 I2): before arming a verdict is only
5334        // counted, and no reaction is requested for it either.
5335        let hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
5336        let violations = &mut self.violations;
5337        super::monitor::route_overdue(table, now_us, |row, v, first| {
5338            call_route_reaction(hook, table, row, &v, now_us);
5339            if first {
5340                violations.record(v);
5341            }
5342        });
5343    }
5344
5345    /// Issue #505 — report activations dropped by
5346    /// [`TimerOverrunPolicy::Skip`](super::arena::TimerOverrunPolicy)
5347    /// since the last check.
5348    ///
5349    /// Unlike the rate/age/latency rules this needs no baked spec table:
5350    /// every periodic timer counts its own overruns, and a dropped
5351    /// activation is a contract failure for any declared period. It runs
5352    /// on the same tick so violations land in the same ring the entry
5353    /// glue drains.
5354    /// Declare the minimum stack headroom this executor's thread must keep,
5355    /// in bytes. `0` (the default) disables the `stack-headroom-runtime`
5356    /// rule.
5357    ///
5358    /// Set by the entry that spawned the thread, because it is the only
5359    /// party that knows what stack it handed over: the executor never sees
5360    /// `stack_bytes`, and no portable query returns a task's total stack, so
5361    /// neither an absolute floor nor a percentage can be inferred here.
5362    /// Declare how often this executor's loop intends to come round, in
5363    /// microseconds. `0` (the default) falls back to the `spin_once` timeout.
5364    ///
5365    /// Needed because a driver's pacing and its blocking bound are separate
5366    /// numbers. nros-cpp's tier loops sleep `spin_period_us` between spins but
5367    /// pass a fixed 10 ms timeout, so the release-jitter rule judged every
5368    /// tier against 10 ms regardless of what the contract declared -- too lax
5369    /// for a 1 ms tier and far too strict for a 30 ms one.
5370    ///
5371    /// This also says the CALLER paces the loop (a sleep finer than the
5372    /// park), so the measurement is reported at 1 us. A loop paced by the
5373    /// `spin_once` wait itself declares with
5374    /// [`Self::set_spin_nominal_wait_paced_us`] instead.
5375    pub fn set_spin_nominal_us(&mut self, us: u64) {
5376        self.spin_nominal_declared_us = us;
5377        self.spin_nominal_wait_paced = false;
5378    }
5379
5380    /// phase-436 A2b -- declare the loop's cadence, in microseconds, for a
5381    /// loop paced by the `spin_once` wait itself. `0` withdraws it.
5382    ///
5383    /// [`Self::set_spin_nominal_us`] couples two facts: the period to judge
5384    /// against, and that the caller paces the loop with its own sleep. A loop
5385    /// like `for (;;) spin_once(period_ms)` has the first without the second.
5386    /// Declaring through the coupled setter would report its jitter at 1 us
5387    /// when the park it waits on is tick-granular (issue 1194's failure); not
5388    /// declaring at all makes the timeout the period, which is wrong whenever
5389    /// the timeout is not the period (a cadence that is not whole
5390    /// milliseconds, or one raised to a floor).
5391    ///
5392    /// Here the cadence is the period of the release grid that a
5393    /// `spin_once(timeout)` loop already runs on, and the timeout stays a
5394    /// bound on each wait. The jitter is judged at grid releases and reported
5395    /// at the park granularity.
5396    pub fn set_spin_nominal_wait_paced_us(&mut self, us: u64) {
5397        self.spin_nominal_declared_us = us;
5398        self.spin_nominal_wait_paced = us != 0;
5399    }
5400
5401    pub fn set_min_stack_headroom_bytes(&mut self, bytes: usize) {
5402        self.worker.min_stack_headroom_bytes = bytes;
5403        // phase-436 E4 — a new bound is checked on the next spin, not up to an
5404        // interval later.
5405        self.worker.stack_headroom_last_check_us = None;
5406        // phase-436 E3 — `check_stack_headroom_rule` treats a 0 from the port as
5407        // "not instrumented" and skips, which is right for the rule and wrong
5408        // for the reader: an armed rule that can never judge reports nothing,
5409        // exactly like one that judged and found the stack healthy. Ask the
5410        // port once, here. Entries set the bound at boot on the thread it
5411        // describes, so the answer is about the right stack and the warning
5412        // lands once per tier, next to the tier's other boot lines.
5413        // phase-436 E5 -- "does the port answer at all?" needs one byte, not
5414        // the whole unused region the full query would scan.
5415        self.worker.stack_headroom_blind =
5416            bytes != 0 && nros_platform_api::stack_unused_bytes_upto(1) == 0;
5417        if self.worker.stack_headroom_blind {
5418            nros_log::log_warn!(
5419                nros_log::get_logger("nros"),
5420                "stack-headroom-runtime: a {} B minimum is set but this port reports no stack \
5421                 data, so the rule will never check (Zephyr: enable CONFIG_INIT_STACKS)",
5422                bytes
5423            );
5424        }
5425    }
5426
5427    /// phase-436 E3 — whether `stack-headroom-runtime` is armed on a port that
5428    /// cannot measure, and so will never report a violation.
5429    ///
5430    /// `true` means the rule's silence is not evidence of headroom. Zephyr
5431    /// answers only with `CONFIG_INIT_STACKS && CONFIG_THREAD_STACK_INFO`;
5432    /// POSIX never does.
5433    pub fn stack_headroom_blind(&self) -> bool {
5434        self.worker.stack_headroom_blind
5435    }
5436
5437    /// Report a spin thread that has come closer to the end of its stack
5438    /// than `set_min_stack_headroom_bytes` allows.
5439    ///
5440    /// Runs on the same tick as the other rules and feeds the same ring.
5441    /// Queries the port at most once per `STACK_HEADROOM_CHECK_INTERVAL_US`
5442    /// (phase-436 E4): on a painted Zephyr stack the full query scans every
5443    /// unused byte, so an every-spin query put that scan inside the control
5444    /// loop. Since E5 the query is capped at the declared minimum, so a scan
5445    /// reads at most that many bytes.
5446    /// Nothing at all when no minimum was declared.
5447    fn check_stack_headroom_rule(&mut self) {
5448        if self.worker.min_stack_headroom_bytes == 0 {
5449            return;
5450        }
5451        self.worker.stack_headroom_spins_since_check = self
5452            .worker
5453            .stack_headroom_spins_since_check
5454            .saturating_add(1);
5455        let now_us = self.now_us();
5456        if !super::monitor::stack_headroom_check_due(
5457            now_us,
5458            self.worker.stack_headroom_last_check_us,
5459            self.worker.stack_headroom_spins_since_check,
5460        ) {
5461            return;
5462        }
5463        self.worker.stack_headroom_last_check_us = Some(now_us.unwrap_or(0));
5464        self.worker.stack_headroom_spins_since_check = 0;
5465        self.worker.stack_headroom_checks = self.worker.stack_headroom_checks.saturating_add(1);
5466        // phase-436 E5 -- the CAPPED query. The rule only asks whether
5467        // `min_stack_headroom_bytes` are unused, and a port that scans for
5468        // paint answers that by reading those bytes alone. Below the cap the
5469        // answer is the exact headroom, which is what a breach reports, so
5470        // no second, full scan is needed. E4's throttle still applies.
5471        let unused =
5472            nros_platform_api::stack_unused_bytes_upto(self.worker.min_stack_headroom_bytes);
5473        // 0 means the port does not instrument stacks, not that the stack is
5474        // full. Reporting a violation there would be a fault invented from an
5475        // absence of data.
5476        if unused == 0 {
5477            return;
5478        }
5479        if let Some(v) = super::monitor::check_stack_headroom(
5480            unused,
5481            self.worker.min_stack_headroom_bytes,
5482            &mut self.worker.stack_headroom_reported,
5483        ) {
5484            self.violations.record(v);
5485        }
5486    }
5487
5488    /// AUTOSAR-style ALIVE supervision: report a SchedContext that produced
5489    /// no dispatch at all over a full window while declaring a period.
5490    ///
5491    /// Every other rule here fires when something happens and is wrong. This
5492    /// one fires when nothing happens, which is the failure a watchdog
5493    /// exists for and the one `rate-hierarchy-runtime` cannot see: that rule
5494    /// counts publishes on an endpoint, so a callback that stops firing while
5495    /// its topic is published from elsewhere leaves it satisfied.
5496    fn check_alive_supervision(&mut self) {
5497        let Some(now_us) = self.now_us() else {
5498            // No clock, no windows. Silence here is honest: the rule cannot
5499            // tell a stalled context from a fast one without time.
5500            return;
5501        };
5502        let n = self.alive_slots.len().min(self.sched_contexts.len());
5503        for i in 0..n {
5504            let period_us = match self.sched_contexts[i].as_ref() {
5505                Some(sc) => sc.period_us.get().map(|nz| nz.get() as u64).unwrap_or(0),
5506                None => continue,
5507            };
5508            let slot = &mut self.alive_slots[i];
5509            if let Some(v) =
5510                super::monitor::check_alive(period_us, slot.dispatches, &mut slot.state, now_us)
5511            {
5512                self.violations.record(v);
5513            }
5514        }
5515    }
5516
5517    /// Issue #515 — report a spin wake a whole period late.
5518    ///
5519    /// Runs on the same tick as `check_timer_overruns` and feeds the same
5520    /// ring, so the entry glue drains it with the other rules and no caller
5521    /// needs to know this one exists. Like that rule it needs no spec table:
5522    /// the bound is the spin period the caller already passes in.
5523    fn check_release_jitter_rule(&mut self) {
5524        let (max_us, _late, _total) = self.release_jitter();
5525        let period_us = self.spin_nominal_us;
5526        if let Some(v) =
5527            super::monitor::check_release_jitter(max_us, &mut self.jitter_reported_us, period_us)
5528        {
5529            self.violations.record(v);
5530        }
5531    }
5532
5533    fn check_timer_overruns(&mut self) {
5534        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5535        for i in 0..self.entries.len() {
5536            let Some(meta) = self.entries[i].as_ref() else {
5537                continue;
5538            };
5539            // phase-509 P9b item 2 -- a timer whose callback runs in another
5540            // worker's window is that worker's to touch; its overrun is
5541            // reported by the next pass that finds it idle.
5542            if !matches!(meta.kind, EntryKind::Timer) || Self::slot_held(self.slot_tags, i) {
5543                continue;
5544            }
5545            // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
5546            // which shares its leading layout with `TimerHeader`. The
5547            // baseline lives in the header too, so this needs no state
5548            // parallel to `entries` (whose capacity is a runtime slice
5549            // length, not a const).
5550            let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
5551            if let Some(v) = super::monitor::check_timer_overrun(
5552                header.overruns,
5553                &mut header.overruns_reported,
5554                0,
5555            ) {
5556                self.violations.record(v);
5557            }
5558        }
5559    }
5560
5561    /// RFC-0052 / phase-296 W3a — replace the DEFAULT scheduling context
5562    /// (slot 0, the SC every unbound callback dispatches through).
5563    ///
5564    /// The run_tiers model runs one Executor per tier, so a tier-wide
5565    /// scheduling policy (`[tasks.<t>] class/budget_us/period_us` and the
5566    /// TT window) is exactly "this executor's default SC". Boards call
5567    /// this once, before entity creation; explicit per-handle/per-group
5568    /// bindings still take precedence (they never resolve to slot 0).
5569    ///
5570    /// Sporadic-class SCs get the same sibling `SporadicState` the
5571    /// `create_sched_context` path builds, so budget refill/exhaustion
5572    /// applies to the default queue too.
5573    pub fn set_default_sched_context(&mut self, sc: super::sched_context::SchedContext) {
5574        self.sched_contexts[0] = Some(sc);
5575        if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
5576            let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
5577            let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
5578            self.sporadic_states[0] =
5579                Some(super::sched_context::SporadicState::new(budget, period));
5580        } else {
5581            self.sporadic_states[0] = None;
5582        }
5583    }
5584
5585    /// Bind a registered callback to a scheduling context. The next
5586    /// `spin_once` cycle dispatches the callback through that SC's
5587    /// queue (FIFO bitmap or EDF heap). Phase 110.B.
5588    pub fn bind_handle_to_sched_context(
5589        &mut self,
5590        handle: HandleId,
5591        sc_id: super::sched_context::SchedContextId,
5592    ) -> Result<(), NodeError> {
5593        let i = self.live_slot(handle);
5594        if i >= self.entries.len() {
5595            return Err(NodeError::InvalidSchedContextBinding);
5596        }
5597        if self.entries[i].is_none() {
5598            return Err(NodeError::InvalidSchedContextBinding);
5599        }
5600        let sc_idx = sc_id.0 as usize;
5601        if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
5602            return Err(NodeError::InvalidSchedContextBinding);
5603        }
5604        self.sched_context_bindings[i] = sc_id;
5605        Ok(())
5606    }
5607
5608    /// Phase 110.G — enable time-triggered dispatch by setting the
5609    /// executor's major-frame length. Once set, every `spin_once`
5610    /// cycle gates dispatch through each entry's bound SC's
5611    /// `tt_window_offset_us` / `tt_window_duration_us` fields:
5612    /// dispatch only fires when the current monotonic time falls
5613    /// inside the window `[off, off + duration) mod major_frame`.
5614    ///
5615    /// `major_frame_us = 0` disables the TT gate (default state).
5616    /// Setting a non-zero major frame after callbacks are already
5617    /// registered is allowed — TT gates take effect on the next
5618    /// `spin_once` cycle.
5619    pub fn register_time_triggered_dispatcher(&mut self, major_frame_us: u32) {
5620        self.major_frame_us = major_frame_us;
5621    }
5622
5623    /// Phase 110.G — apply a declarative cyclic schedule.
5624    ///
5625    /// One-shot helper that wraps the underlying primitives:
5626    /// validates the schedule (major_frame > 0, no overlapping
5627    /// windows, every window fits inside the major frame), sets the
5628    /// executor's major-frame length, then materialises one
5629    /// `SchedContext` per window with `class = TimeTriggered` +
5630    /// the window's offset / duration. Returns the per-window
5631    /// [`SchedContextId`](crate::executor::sched_context::SchedContextId) array so callers can immediately
5632    /// `bind_handle_to_sched_context(handle, sc_id)` for their
5633    /// subscription / timer handles.
5634    ///
5635    /// `N` is the schedule's *declared* maximum window count;
5636    /// `schedule.window_count` gates how many SCs are actually
5637    /// created. Unused trailing slots return
5638    /// `SchedContextId::default()` (sentinel — callers must respect
5639    /// `window_count`).
5640    pub fn apply_time_triggered_schedule<const N: usize>(
5641        &mut self,
5642        schedule: &super::sched_context::TimeTriggeredSchedule<N>,
5643    ) -> Result<
5644        [super::sched_context::SchedContextId; N],
5645        super::sched_context::TimeTriggeredScheduleError,
5646    > {
5647        schedule.validate()?;
5648        self.major_frame_us = schedule.major_frame_us;
5649        // SC slot 0 is the auto-created default; reusing it as a
5650        // sentinel for unused trailing slots is safe because the
5651        // caller respects `schedule.window_count`.
5652        let mut ids: [super::sched_context::SchedContextId; N] =
5653            [super::sched_context::SchedContextId(0); N];
5654        for (i, window) in schedule.windows[..schedule.window_count].iter().enumerate() {
5655            // TT is not a scheduling class (phase-482 W6 deleted the
5656            // `SchedClass::TimeTriggered` variant): it is a per-SC *window gate* on top of
5657            // the existing class-based dispatch (Fifo here keeps
5658            // the EDF / Sporadic budgets out of the picture for
5659            // pure cyclic schedules). The window-gate fields set
5660            // below are what `spin_once`'s 110.G runtime gate
5661            // actually reads.
5662            let sc = super::sched_context::SchedContext {
5663                tt_window_offset_us: super::sched_context::OptUs::from_us(window.offset_us),
5664                tt_window_duration_us: super::sched_context::OptUs::from_us(window.duration_us),
5665                ..super::sched_context::SchedContext::new_fifo()
5666            };
5667            ids[i] = self.create_sched_context(sc).map_err(|_| {
5668                super::sched_context::TimeTriggeredScheduleError::WindowCountOverflow
5669            })?;
5670        }
5671        Ok(ids)
5672    }
5673
5674    /// Phase 110.E.b — register an ISR-driven refill timer for an
5675    /// already-created Sporadic SC. The caller invokes their
5676    /// platform's `PlatformTimer::create_periodic` with the returned
5677    /// `Arc<AtomicSporadicState>` as `user_data` and the
5678    /// `atomic_sporadic_refill_thunk` as the callback, then hands
5679    /// the resulting platform handle to this method via
5680    /// `OpaqueTimerHandle::new(handle, destroy_fn)`.
5681    ///
5682    /// The Executor stores both the Arc and the handle so Drop can
5683    /// clean them up. Calling this on a non-Sporadic SC returns
5684    /// `Err(InvalidSchedContextBinding)`.
5685    #[cfg(feature = "alloc")]
5686    pub fn register_sporadic_timer(
5687        &mut self,
5688        sc_id: super::sched_context::SchedContextId,
5689        timer: OpaqueTimerHandle,
5690    ) -> Result<portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>, NodeError>
5691    {
5692        let i = sc_id.0 as usize;
5693        if i >= self.sched_contexts.len() {
5694            return Err(NodeError::InvalidSchedContextBinding);
5695        }
5696        let sc = self.sched_contexts[i]
5697            .as_ref()
5698            .ok_or(NodeError::InvalidSchedContextBinding)?;
5699        if !matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
5700            return Err(NodeError::InvalidSchedContextBinding);
5701        }
5702        let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
5703        let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
5704        let state = portable_atomic_util::Arc::new(super::sched_context::AtomicSporadicState::new(
5705            budget, period,
5706        ));
5707        self.sporadic_atomic_states[i] = Some((portable_atomic_util::Arc::clone(&state), timer));
5708        Ok(state)
5709    }
5710
5711    /// Inspect a registered scheduling context. Phase 110.B.
5712    pub fn sched_context(
5713        &self,
5714        sc_id: super::sched_context::SchedContextId,
5715    ) -> Option<&super::sched_context::SchedContext> {
5716        self.sched_contexts.get(sc_id.0 as usize)?.as_ref()
5717    }
5718
5719    /// Phase 104.C.2 — start a rclcpp-style Node builder for this
5720    /// Executor. The returned [`NodeBuilder`](super::node_record::NodeBuilder)
5721    /// is chainable:
5722    ///
5723    /// ```ignore
5724    /// let id = exec.node_builder("ingress")
5725    ///     .rmw("zenoh")
5726    ///     .locator("tcp/127.0.0.1:7447")
5727    ///     .sched(my_sc_id)
5728    ///     .build()?;
5729    /// ```
5730    ///
5731    /// In Phase 104.C.2 the Node table is storage-only — all
5732    /// registered Nodes share the Executor's primary session. Per-
5733    /// Node session binding (the bridge feature) lands in Phase
5734    /// 104.C.3 when the session cache is wired.
5735    pub fn node_builder<'a, 'cfg>(
5736        &'a mut self,
5737        name: &'cfg str,
5738    ) -> super::node_record::NodeBuilder<'a, 'cfg, 's> {
5739        super::node_record::NodeBuilder {
5740            executor: self,
5741            name,
5742            namespace: None,
5743            rmw_name: None,
5744            locator: None,
5745            domain_id: None,
5746            sched: None,
5747            session_idx: None,
5748        }
5749    }
5750
5751    /// Return the Node table — Phase 104.C.2 read accessor.
5752    pub fn nodes(&self) -> &[super::node_record::NodeRecord] {
5753        &self.nodes
5754    }
5755
5756    /// Borrow a Node's metadata by id, returning `None` if the id
5757    /// is out of range.
5758    pub fn node(&self, id: super::node_record::NodeId) -> Option<&super::node_record::NodeRecord> {
5759        self.nodes.get(id.index())
5760    }
5761
5762    /// Phase 189.M1 — an executor-borrowing node handle for the entity builders
5763    /// (`exec.node_mut(id).subscription(t)...` / `.create_subscription(...)`).
5764    /// A short-lived `&mut Executor` borrow — use one at a time; entity handles
5765    /// are owned and outlive it (see `Node`).
5766    /// phase-483 W3 — the component registration frame, for the `nros`
5767    /// facade's declarative constructors. Not a user API.
5768    #[doc(hidden)]
5769    pub fn __component_frame(&self) -> Option<core::ptr::NonNull<()>> {
5770        self.component_frame
5771    }
5772
5773    /// phase-483 W3 — install or clear the component registration frame.
5774    /// The caller guarantees the pointee outlives the installation.
5775    #[doc(hidden)]
5776    pub fn __set_component_frame(&mut self, frame: Option<core::ptr::NonNull<()>>) {
5777        self.component_frame = frame;
5778    }
5779
5780    pub fn node_mut(&mut self, id: super::node_record::NodeId) -> super::node::Node<'_, 's> {
5781        super::node::Node::new(self, id)
5782    }
5783
5784    /// Issue #52 — install the baked QoS-override table on one node. Every
5785    /// entity created on it AFTERWARDS folds the matching `(topic, role)`
5786    /// entries into its QoS, before the backend-compat check — so an override
5787    /// the active RMW cannot honour still errors loudly rather than silently
5788    /// downgrading.
5789    ///
5790    /// Called by the generated entry (`nros::main!` → the register seam) right
5791    /// after the node is created and before the component declares entities.
5792    /// Unknown `node_id` is a no-op.
5793    pub fn set_node_qos_overrides(
5794        &mut self,
5795        node_id: super::node_record::NodeId,
5796        overrides: &'static [super::node_record::QoSOverrideCode],
5797    ) {
5798        if let Some(r) = self.nodes.get_mut(node_id.index()) {
5799            r.qos_overrides = overrides;
5800        }
5801    }
5802
5803    /// Phase 104.C.3 — resolve a session-slot index to a mutable
5804    /// session reference. Slot 0 = the Executor's primary session;
5805    /// slots 1..=N = the `extra_sessions` vec opened by
5806    /// `node_builder.rmw(name)` calls that named a backend
5807    /// different from the primary.
5808    pub(crate) fn session_at_mut(&mut self, idx: u8) -> Option<&mut session::ConcreteSession> {
5809        if idx == 0 {
5810            Some(&mut *self.session)
5811        } else {
5812            self.extra_sessions.get_mut((idx - 1) as usize)
5813        }
5814    }
5815
5816    /// [`Self::session_at_mut`] plus a shared borrow of the remap table, for
5817    /// building a [`NodeHandle`] — which needs both at once, and gets them
5818    /// here as two disjoint field borrows rather than through two `&mut self`
5819    /// calls the borrow checker could not split.
5820    pub(crate) fn session_and_remaps_at_mut(
5821        &mut self,
5822        idx: u8,
5823    ) -> Option<(&mut session::ConcreteSession, RemapView<'_>)> {
5824        let remaps = RemapView {
5825            launch: self
5826                .launch_remaps
5827                .unwrap_or_else(super::launch_remap::launch_remaps),
5828            runtime: &self.remap_table[..self.remap_len],
5829        };
5830        let session = if idx == 0 {
5831            Some(&mut *self.session)
5832        } else {
5833            self.extra_sessions.get_mut((idx - 1) as usize)
5834        };
5835        session.map(|s| (s, remaps))
5836    }
5837
5838    /// Phase 104.C.9.b — resolve the per-Node session for direct
5839    /// entity creation paths (C++ FFI publisher / subscription /
5840    /// service that bypass the `register_*_on` arena dispatch).
5841    /// Returns `None` when `node_id` is out of range or the Node's
5842    /// `session_idx` lands outside the executor's session table.
5843    pub(crate) fn node_session_mut(
5844        &mut self,
5845        node_id: super::node_record::NodeId,
5846    ) -> Option<&mut session::ConcreteSession> {
5847        let session_idx = self.nodes.get(node_id.index())?.session_idx;
5848        self.session_at_mut(session_idx)
5849    }
5850
5851    /// Phase 189.M1 — create a typed publisher bound to a node's session.
5852    /// Backs `node.publisher(t).typed::<M>().build()` on the
5853    /// executor-borrowing [`Node`](super::node::Node); the returned
5854    /// handle is owned and outlives the `Node`.
5855    pub fn create_publisher_on<M: crate::rmw_type_registry::MessageForRmw>(
5856        &mut self,
5857        node_id: super::node_record::NodeId,
5858        topic_name: &str,
5859        qos: QoSProfile,
5860    ) -> Result<crate::executor::handles::EmbeddedPublisher<M>, NodeError> {
5861        // RFC-0088 / phase-421 W1 — the message's declared format must be the
5862        // one the linked backend speaks. Universal: the const lives on
5863        // `RosMessage`, which `MessageForRmw` requires under every backend.
5864        crate::format_check::assert_message_format::<M>();
5865        // Phase 212.K.7.6.b — register `M`'s cyclonedds descriptor before
5866        // creating the underlying publisher handle. No-op for other RMWs.
5867        crate::rmw_type_registry::register_type::<M>()?;
5868        let handle = self.create_raw_publisher_handle_on(
5869            node_id,
5870            topic_name,
5871            <M as RosMessage>::TYPE_NAME,
5872            <M as RosMessage>::TYPE_HASH,
5873            qos,
5874        )?;
5875        // RFC-0052 W3b.4 — attach the contracted endpoint's counter cell.
5876        let monitor = self
5877            .monitor_table
5878            .iter()
5879            .find(|m| m.topic == topic_name)
5880            .map(|m| m.cell);
5881        Ok(crate::executor::handles::EmbeddedPublisher {
5882            handle,
5883            event_regs: crate::executor::handles::empty_event_regs(),
5884            monitor,
5885            epoch: self.epoch_us_fn,
5886            _phantom: PhantomData,
5887        })
5888    }
5889
5890    /// Phase 189.M1 — create a generic (type-erased) publisher bound to a
5891    /// node's session. Backs `node.publisher(t).generic(ty, hash).build()`;
5892    /// the bridge re-publishes through this handle on the dest session.
5893    pub fn create_publisher_raw_on(
5894        &mut self,
5895        node_id: super::node_record::NodeId,
5896        topic_name: &str,
5897        type_name: &str,
5898        type_hash: &str,
5899        qos: QoSProfile,
5900    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
5901        let handle =
5902            self.create_raw_publisher_handle_on(node_id, topic_name, type_name, type_hash, qos)?;
5903        Ok(crate::executor::handles::EmbeddedRawPublisher {
5904            handle,
5905            arena: crate::executor::handles::TxArena::new(),
5906            event_regs: crate::executor::handles::empty_event_regs(),
5907            // Issue 1676 — attach the contracted endpoint's counter cell by
5908            // exact topic match against the installed table, the rule
5909            // `NodeHandle::create_publisher` applies. This is the publisher
5910            // the Rust component road creates, so without it no contracted
5911            // Rust publisher was ever counted.
5912            monitor: self.publisher_monitor_cell(topic_name),
5913        })
5914    }
5915
5916    /// Shared prelude for the publisher-on-node paths: resolve the node's
5917    /// identity + session slot, build the [`TopicInfo`], validate QoS, and
5918    /// create the backend publisher handle. Mirrors
5919    /// `register_subscription_buffered_raw_on`'s session resolution so a
5920    /// bridge's source sub + dest pub agree on topic construction.
5921    fn create_raw_publisher_handle_on(
5922        &mut self,
5923        node_id: super::node_record::NodeId,
5924        topic_name: &str,
5925        type_name: &str,
5926        type_hash: &str,
5927        qos: QoSProfile,
5928    ) -> Result<session::RmwPublisher, NodeError> {
5929        let (node_name, ns, session_idx, overrides) = {
5930            let r = self
5931                .nodes
5932                .get(node_id.index())
5933                .ok_or(NodeError::InvalidSchedContextBinding)?;
5934            (
5935                r.name.clone(),
5936                r.namespace.clone(),
5937                r.session_idx,
5938                r.qos_overrides,
5939            )
5940        };
5941        // Issue #52 — fold the node's baked overrides for this topic BEFORE
5942        // `validate_against`, so an override the backend cannot honour errors
5943        // loudly instead of being silently dropped.
5944        let qos = super::node_record::apply_qos_override_codes(
5945            qos,
5946            topic_name,
5947            nros_rmw::QoSOverrideRole::Publisher,
5948            overrides,
5949        );
5950        let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
5951            .with_domain(self.domain_id)
5952            .with_namespace(&ns);
5953        if !node_name.is_empty() {
5954            topic = topic.with_node_name(&node_name);
5955        }
5956        // issue 1608 -- the publisher half of the declared-QoS check, before
5957        // validation so a policy the contract raises this publisher to is one
5958        // the backend is asked about. See `declared_qos::honour_publisher`.
5959        let qos = crate::declared_qos::honour_publisher(topic.type_name, topic.name, qos)?;
5960        let session = self
5961            .session_at_mut(session_idx)
5962            .ok_or(NodeError::BackendMismatch)?;
5963        super::node::validate_qos_or_report(
5964            &qos,
5965            Session::supported_qos_policies(session),
5966            "publisher",
5967            topic_name,
5968        )?;
5969        session
5970            .create_publisher(&topic, qos)
5971            .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))
5972    }
5973
5974    /// Phase 124.B.1 — install the executor's wake callback onto the
5975    /// primary session. Best-effort: backends that don't override
5976    /// `Session::set_wake_callback` (poll-only XRCE, bare-metal)
5977    /// ignore the call and continue to be drained on the executor's
5978    /// deadline-bound cv-wait boundary.
5979    ///
5980    /// `pub` for issue 1385, and for the same reason
5981    /// [`set_primary_identity`](Self::set_primary_identity) is: the three
5982    /// `Executor::open*` paths call this automatically, and a binding that
5983    /// builds its executor over a session it BORROWS
5984    /// ([`from_session_ptr_in`](Self::from_session_ptr_in)) reaches none of
5985    /// them, so it has to call it itself. `rclc_executor_init` is that
5986    /// binding, and until 1385 it did not — leaving `has_async_wake` false
5987    /// for the life of every C executor on every backend.
5988    ///
5989    /// Pairs with `clear_wake_signal`, which the
5990    /// executor runs from `close()` and from `drop`. A caller that installs
5991    /// owes nothing further: the teardown is the executor's.
5992    ///
5993    /// phase-436 — the backend's slot holds a `WakeFanout`
5994    /// and this executor JOINS it, so every executor over one session is woken
5995    /// by an arrival: the boot executor and each spawned tier
5996    /// (`open_with_session*` now installs too), or several C executors over
5997    /// one support context. Idempotent. If the fan-out pool is exhausted the
5998    /// executor installs its own context directly, as before; if the entry's
5999    /// member table is full it joins nothing and reads as having no async
6000    /// wake, so it never parks on a wake nobody will signal.
6001    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6002    pub(crate) fn install_wake_signal_on_primary(&mut self) {
6003        use nros_rmw::Session as _;
6004        if self.wake_membership.is_some() {
6005            return;
6006        }
6007        let ctx = self.wake_ctx_ptr();
6008        let key = self.session_ptr() as *mut core::ffi::c_void;
6009        let supports = self.session.supports_wake_callback();
6010        let fanout = match super::wake_fanout::find(key) {
6011            Some(f) => Some(f),
6012            None => super::wake_fanout::claim(key).inspect(|f| {
6013                f.set_live(supports);
6014                // SAFETY: the context is a `static` pool entry, valid for the
6015                // program's life; `clear_wake_signal` takes the callback off
6016                // before returning the entry to the pool.
6017                unsafe {
6018                    self.session
6019                        .set_wake_callback(Some(nros_rmw_fanout_wake_cb), f.as_ctx());
6020                }
6021            }),
6022        };
6023        match fanout {
6024            Some(f) => {
6025                if let Some(m) = f.join(ctx) {
6026                    self.wake_membership = Some(m);
6027                    // Issue 1385 — this executor now owes a teardown: leaving
6028                    // the fan-out (and, for the session's owner or its last
6029                    // member, taking the callback off the backend).
6030                    self.wake_cb_installed = true;
6031                }
6032            }
6033            None => {
6034                // SAFETY: `ctx` points at executor-owned wake state that
6035                // outlives the installation and is cleared by
6036                // `clear_wake_signal` on `close()`/drop.
6037                unsafe {
6038                    self.session
6039                        .set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
6040                }
6041                self.wake_cb_installed = true;
6042                if supports {
6043                    self.has_async_wake = true;
6044                }
6045            }
6046        }
6047    }
6048
6049    /// phase-436 — does an arrival on any of this executor's sessions signal
6050    /// its wake object? Through the primary's fan-out (live until the
6051    /// session's owner takes the callback down), or a direct install on the
6052    /// primary or an extra session.
6053    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6054    pub(crate) fn async_wake_live(&self) -> bool {
6055        self.has_async_wake || self.wake_membership.is_some_and(|m| m.is_live())
6056    }
6057
6058    /// Phase 124.B.1 — install the wake callback onto an extra
6059    /// session opened by `node_builder.rmw(...)`. Called from
6060    /// `NodeBuilder::build()` right after `extra_sessions.push(...)`.
6061    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6062    pub(crate) fn install_wake_signal_on_extra(&mut self, idx: usize) {
6063        use nros_rmw::Session as _;
6064        let ctx = self.wake_ctx_ptr();
6065        if let Some(s) = self.extra_sessions.get_mut(idx) {
6066            // SAFETY: same executor-owned wake state as the primary session;
6067            // the extra session is owned by this executor.
6068            unsafe {
6069                s.set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
6070            }
6071            // Issue 1385 — as on the primary.
6072            self.wake_cb_installed = true;
6073            if s.supports_wake_callback() {
6074                self.has_async_wake = true;
6075            }
6076        }
6077    }
6078
6079    /// Issue 1385 — the teardown half of `install_wake_signal_on_*`, and the
6080    /// obligation `nros_rmw_runtime_wake_cb`'s own SAFETY comment has asserted
6081    /// since phase 124: *"Executor::drop must clear the callback via
6082    /// `set_wake_callback(None, _)` on all sessions before dropping
6083    /// wake_ctx"*. Nothing did. The string `set_wake_callback(None` occurred
6084    /// exactly once in the tree — in that comment.
6085    ///
6086    /// The context the backend holds is `Arc::as_ptr(&self.wake_ctx)`, freed
6087    /// with this executor. Leaving it installed is a dangling callback the
6088    /// backend may invoke from a worker thread or an ISR. On the Rust `open*`
6089    /// paths the session is `SessionStore::Owned` and dies in the same drop,
6090    /// so the window is narrow; on the C path it is arbitrary —
6091    /// `rclc_executor_fini` drops the executor and zero-fills `_opaque` while
6092    /// the session lives on in `rclc_support_t` until `rclc_support_fini`.
6093    ///
6094    /// Clears the primary and every extra session, then latches off, so
6095    /// `close()` followed by drop clears once.
6096    ///
6097    /// Only fires when THIS executor installed (see
6098    /// `install_wake_signal_on_primary`). Executors sharing one session were
6099    /// last-writer-wins on the backend's single slot until phase-436: the first
6100    /// to tear down cleared the other's wake. The slot now holds a wake
6101    /// fan-out, so a member's teardown is leaving it, and the callback comes
6102    /// off the backend only with the session's owner or its last member.
6103    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6104    pub(crate) fn clear_wake_signal(&mut self) {
6105        use nros_rmw::Session as _;
6106        if !self.wake_cb_installed {
6107            return;
6108        }
6109        self.wake_cb_installed = false;
6110        // phase-436 — a fan-out member leaves first; the callback comes off
6111        // the backend only when the session's OWNER leaves (the session dies
6112        // with it) or the last member does. A membership from an entry the
6113        // owner already released is a no-op, and must not touch the entry's
6114        // next tenant.
6115        let take_off_primary = match self.wake_membership.take() {
6116            Some(m) => {
6117                let fanout = m.fanout();
6118                let current = m.is_current();
6119                m.leave(nros_platform_api::yield_now);
6120                let owned = matches!(self.session, SessionStore::Owned(_));
6121                if current && (owned || fanout.member_count() == 0) {
6122                    // SAFETY: as below; the entry is released only after the
6123                    // backend can no longer start a callback on it.
6124                    unsafe { self.session.set_wake_callback(None, core::ptr::null_mut()) };
6125                    fanout.release(nros_platform_api::yield_now);
6126                }
6127                false
6128            }
6129            None => true,
6130        };
6131        // SAFETY: clearing takes no context, and both sessions are still live
6132        // here — this runs BEFORE `Session::close` in `close()` and before the
6133        // session field is dropped in `Drop`.
6134        unsafe {
6135            if take_off_primary {
6136                self.session.set_wake_callback(None, core::ptr::null_mut());
6137            }
6138            for s in self.extra_sessions.iter_mut() {
6139                s.set_wake_callback(None, core::ptr::null_mut());
6140            }
6141        }
6142        self.has_async_wake = false;
6143    }
6144
6145    /// phase-467 Row 8 — hand the PRIMARY session a graph-change target it may
6146    /// trigger from a worker thread, and take ownership of that target.
6147    ///
6148    /// The install half of the pair; [`clear_graph_change_signal`] is the
6149    /// other and the executor owes it. `guard` is CLONED, so the caller keeps
6150    /// its own handle to the same arena flag — that is the whole point: the
6151    /// pointer the backend receives is the executor's box, never the caller's
6152    /// storage. Issue 1385 is the same hazard on the wake slot, found after
6153    /// the fact; this is the shape that cannot reach it.
6154    ///
6155    /// PRIMARY only, deliberately. A graph is a property of a session, and the
6156    /// extra sessions of `node_builder.rmw(...)` each have their own — feeding
6157    /// several into one guard would make "the graph changed" unattributable
6158    /// while costing the caller the ability to ask which. A caller wanting a
6159    /// second backend's graph asks that session for its own.
6160    ///
6161    /// Returns `Err(Unsupported)` from a backend with no graph. A previously
6162    /// installed target is retired first, so an executor never has two.
6163    ///
6164    /// [`clear_graph_change_signal`]: Self::clear_graph_change_signal
6165    ///
6166    /// `pub(crate)`, not `pub`: the public verb is
6167    /// [`register_graph_change_guard_on`], which does the probe, the
6168    /// registration and this in the one order that cannot hand back a guard
6169    /// nothing fires. Exposing the install alone would let a caller reach the
6170    /// second step without the first.
6171    ///
6172    /// [`register_graph_change_guard_on`]: Self::register_graph_change_guard_on
6173    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6174    pub(crate) fn install_graph_change_guard(
6175        &mut self,
6176        guard: &GuardCondition,
6177    ) -> Result<(), NodeError> {
6178        use nros_rmw::Session as _;
6179        self.clear_graph_change_signal();
6180        let boxed = alloc::boxed::Box::new(guard.clone());
6181        let ptr = (&*boxed) as *const GuardCondition as *const core::ffi::c_void;
6182        // SAFETY: `ptr` names heap storage this executor owns and which
6183        // `clear_graph_change_signal` retires from the backend before it is
6184        // freed. `GuardCondition` is `Send + Sync` and its `trigger()` is a
6185        // relaxed store plus the runtime wake hook, which is what a foreign
6186        // thread is allowed to do here.
6187        let rc = unsafe {
6188            self.session
6189                .set_graph_change_callback(Some(nros_rmw_runtime_graph_change_cb), ptr)
6190        };
6191        match rc {
6192            Ok(()) => {
6193                self.graph_change_target = Some(boxed);
6194                Ok(())
6195            }
6196            // `boxed` drops here, and nothing holds its pointer: the backend
6197            // either refused or failed, so there is no installed callback to
6198            // retire first.
6199            Err(e) => Err(NodeError::Transport(e)),
6200        }
6201    }
6202
6203    /// phase-467 Row 8 — can the primary session deliver a graph-change edge?
6204    ///
6205    /// Asked by CLEARING, which is the one probe that costs nothing and cannot
6206    /// lie: a backend with no graph answers `Unsupported` to a `None` install
6207    /// just as it does to a real one, and an idempotent clear on a backend
6208    /// that HAS a graph is a no-op. Nullity of the vtable slot cannot answer
6209    /// it — `rust_adapter.rs` fills the slot for every Rust backend whether or
6210    /// not that backend overrides the trait method, which
6211    /// `check-rmw-slot-producers`'s own report says in as many words.
6212    ///
6213    /// The caller is `register_graph_change_guard_on`, which asks BEFORE it
6214    /// consumes an executor handle slot, so an unsupported backend costs
6215    /// nothing and the caller is never handed a guard nothing fires.
6216    /// `pub(crate)` for the same reason the install is: asking costs a CLEAR,
6217    /// so a caller that asks and then does nothing has silently retired a live
6218    /// target. The public verb asks once, on the caller's behalf.
6219    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6220    pub(crate) fn supports_graph_change_callback(&mut self) -> bool {
6221        use nros_rmw::Session as _;
6222        // Retire whatever is installed first — the probe IS a clear, so doing
6223        // it without this would silently drop a live target.
6224        self.clear_graph_change_signal();
6225        // SAFETY: a `None` install takes no context and installs nothing.
6226        unsafe {
6227            self.session
6228                .set_graph_change_callback(None, core::ptr::null())
6229                .is_ok()
6230        }
6231    }
6232
6233    /// phase-467 Row 8 — the teardown half of
6234    /// [`install_graph_change_guard`](Self::install_graph_change_guard), and
6235    /// the reason that install cannot recreate issue 1385.
6236    ///
6237    /// Order is the whole content: tell the backend to stop FIRST, then drop
6238    /// the box it was pointing at. Reversed, the window between the free and
6239    /// the clear is exactly 1385's dangling callback — and on the C path that
6240    /// window is arbitrary, because the session outlives the executor.
6241    ///
6242    /// Latched on `graph_change_target.is_some()`, not on a separate flag: the
6243    /// box IS the record of having installed, so `close()` followed by drop
6244    /// clears once and an executor that never installed clears nothing. That
6245    /// is the same predicate `clear_wake_signal` needs a bool for only because
6246    /// its context is shared with `signal_fd()` and every guard condition.
6247    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6248    pub(crate) fn clear_graph_change_signal(&mut self) {
6249        use nros_rmw::Session as _;
6250        if self.graph_change_target.is_none() {
6251            return;
6252        }
6253        // SAFETY: clearing takes no context, and the session is still live
6254        // here — this runs BEFORE `Session::close` in `close()` and before the
6255        // session field is dropped in `Drop`.
6256        unsafe {
6257            let _ = self
6258                .session
6259                .set_graph_change_callback(None, core::ptr::null());
6260        }
6261        // Only now. The backend has been told, so nothing can be holding this
6262        // pointer when it is freed.
6263        self.graph_change_target = None;
6264    }
6265
6266    /// Phase 124.B.2 — opaque context pointer the runtime wake
6267    /// callback receives. Encodes `(flag, mu, cv)` as a borrowed
6268    /// `&WakeCtx` reference; the callback decodes via
6269    /// `*const WakeCtx`.
6270    ///
6271    /// Lifetime: tied to the Executor instance. WakeCtx storage
6272    /// lives inside Executor (lazy-allocated on first install), so
6273    /// the pointer stays valid as long as the Executor is.
6274    /// Phase 124.B.7.c — POSIX signal-handler-safe wake fd.
6275    ///
6276    /// Returns a Linux `eventfd` that callers (typically POSIX
6277    /// signal handlers) can `write(fd, &1u64, 8)` to from any
6278    /// context, including signal handlers. A runtime-owned worker
6279    /// thread reads the fd and signals the executor's `NodeWake`, unblocking
6280    /// `spin_once`.
6281    ///
6282    /// The worker thread is spawned lazily on first call and
6283    /// joined on Executor drop. Linux-only and gated behind
6284    /// `feature = "signal-fd-wake"`; binaries that don't install
6285    /// signal handlers shouldn't enable it.
6286    ///
6287    /// Returns the raw fd. The Executor retains ownership; do not
6288    /// `close()` it from the caller.
6289    #[cfg(all(feature = "signal-fd-wake", feature = "rmw-cffi", target_os = "linux"))]
6290    /// phase-359 W10 — was `std::io::Result`. Same values, an error type that
6291    /// does not require `std`.
6292    pub fn signal_fd(&mut self) -> Result<core::ffi::c_int, NodeError> {
6293        let ctx_ptr = self.wake_ctx_ptr() as *const WakeCtx;
6294        if self.signal_fd.is_none() {
6295            self.signal_fd = Some(WakeSignalFd::new(ctx_ptr)?);
6296        }
6297        Ok(self.signal_fd.as_ref().expect("just set").fd())
6298    }
6299
6300    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6301    fn wake_ctx_ptr(&mut self) -> *mut core::ffi::c_void {
6302        if self.wake_ctx.is_none() {
6303            self.wake_ctx = Some(portable_atomic_util::Arc::new(WakeCtx::new(
6304                self.wake_flag.clone(),
6305                self.node_wake.clone(),
6306            )));
6307        }
6308        let arc = self.wake_ctx.as_ref().expect("just set");
6309        portable_atomic_util::Arc::as_ptr(arc) as *mut core::ffi::c_void
6310    }
6311
6312    /// Phase 104.C.4 — apply a Node's default SchedContext to a
6313    /// freshly-registered handle. Called from every `_inner`
6314    /// register variant after the entry slot is committed. No-op
6315    /// when `node_id` is None (legacy path), when the Node is
6316    /// out of range, or when the Node's `default_sched` is the
6317    /// auto-created Fifo slot (0) which matches the executor's
6318    /// default binding already.
6319    ///
6320    /// Phase 273 (RFC-0047) — extends with an optional `group` name.
6321    /// Precedence: **group table > node default > no binding** (SC 0).
6322    /// When `group` is `Some(g)`, consults `group_sched_table` first;
6323    /// if no entry exists for `(name, namespace, g)` falls back to the
6324    /// node's `default_sched`. When `group` is `None` the group table
6325    /// is not consulted (unchanged phase-272 path).
6326    ///
6327    /// Handles can still override per-call via
6328    /// `bind_handle_to_sched_context(handle, sc_id)` post-register.
6329    pub(crate) fn apply_node_default_sched(
6330        &mut self,
6331        slot: usize,
6332        node_id: Option<super::node_record::NodeId>,
6333        group: Option<&str>,
6334    ) {
6335        let Some(id) = node_id else { return };
6336        // Copy name, namespace, and default_sched out so the borrow on
6337        // `self.nodes` is released before the immutable `lookup_group_sched`
6338        // borrow and the mutable `sched_context_bindings` write below.
6339        let (name, namespace, node_sc, rank) = {
6340            let Some(rec) = self.nodes.get(id.index()) else {
6341                return;
6342            };
6343            (
6344                rec.name.clone(),
6345                rec.namespace.clone(),
6346                rec.default_sched,
6347                rec.rank,
6348            )
6349        };
6350        // phase-509 P2 -- the entry dispatches at its node's rank, whatever
6351        // sched context it ends up bound to.
6352        if let Some(tag) = self.slot_tags.get_mut(slot) {
6353            tag.rank = rank;
6354        }
6355        // Phase 273: group table > node default.
6356        let sc = match group {
6357            Some(g) => self
6358                .lookup_group_sched(name.as_str(), namespace.as_str(), g)
6359                .unwrap_or(node_sc),
6360            None => node_sc,
6361        };
6362        if sc.0 == 0 {
6363            return;
6364        }
6365        if slot >= self.entries.len() {
6366            return;
6367        }
6368        let sc_idx = sc.0 as usize;
6369        if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
6370            return;
6371        }
6372        self.sched_context_bindings[slot] = sc;
6373    }
6374
6375    /// Phase 104.C.3.2 — scoped Node-handle access. The closure
6376    /// receives a [`NodeHandle`](crate::executor::NodeHandle) bound to the
6377    /// requested [`NodeId`](crate::executor::NodeId)'s
6378    /// session + identity. Use the standard `Node::create_publisher`,
6379    /// `create_subscription`, etc. APIs inside.
6380    ///
6381    /// rclcpp-aligned bridge pattern:
6382    ///
6383    /// ```ignore
6384    /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
6385    /// let node_out = exec.node_builder("egress").rmw("xrce").build()?;
6386    ///
6387    /// let pub_out = exec.with_node(node_out, |n| {
6388    ///     n.create_publisher::<Int32>("/fwd")
6389    /// })??;
6390    ///
6391    /// exec.with_node(node_in, |n| {
6392    ///     n.create_subscription_buffered::<Int32, _, 1024>(
6393    ///         "/src", qos(), move |m| { let _ = pub_out.publish(m); }
6394    ///     )
6395    /// })??;
6396    /// ```
6397    ///
6398    /// The closure can return any type; double-`?` unwraps the
6399    /// outer `Result<R, NodeError>` from `with_node` and the inner
6400    /// result returned by the closure.
6401    /// Phase 104.C.3.3.d — flat-Result variant of
6402    /// [`with_node`](Self::with_node). When the closure already
6403    /// returns `Result<R, NodeError>`, this avoids the double-`?`:
6404    ///
6405    /// ```ignore
6406    /// // Without `with_node_try`:
6407    /// let pub_ = exec.with_node(id, |n| n.create_publisher(...))??;
6408    ///
6409    /// // With `with_node_try`:
6410    /// let pub_ = exec.with_node_try(id, |n| n.create_publisher(...))?;
6411    /// ```
6412    pub fn with_node_try<R>(
6413        &mut self,
6414        id: super::node_record::NodeId,
6415        f: impl FnOnce(&mut NodeHandle<'_>) -> Result<R, NodeError>,
6416    ) -> Result<R, NodeError> {
6417        self.with_node(id, f)?
6418    }
6419
6420    pub fn with_node<R>(
6421        &mut self,
6422        id: super::node_record::NodeId,
6423        f: impl FnOnce(&mut NodeHandle<'_>) -> R,
6424    ) -> Result<R, NodeError> {
6425        let (name, ns, session_idx) = {
6426            let r = self
6427                .nodes
6428                .get(id.index())
6429                .ok_or(NodeError::InvalidSchedContextBinding)?;
6430            (r.name.clone(), r.namespace.clone(), r.session_idx)
6431        };
6432        let monitors = self.monitor_table;
6433        let age_monitors = self.age_table;
6434        let epoch = self.epoch_us_fn;
6435        let domain_id = self.domain_id;
6436        let (session, remaps) = self
6437            .session_and_remaps_at_mut(session_idx)
6438            .ok_or(NodeError::BackendMismatch)?;
6439        // SAFETY: short-lived scoped reference. `Node::new` takes
6440        // `&mut ConcreteSession`; lifetime is bound to this fn's
6441        // body via the closure's borrow of `node`.
6442        // issue 0801 (second half) — the executor's domain, NOT a literal 0.
6443        // 429d5a581 fixed the ELEVEN arena `TopicInfo`s and left the THREE
6444        // `NodeHandle::new` sites, so an entity created through a node HANDLE
6445        // (`with_node`, `node`, ~~`node_on`~~) still declared on domain 0 while the
6446        // arena path declared on the configured one. Same split the issue is
6447        // about, one constructor over: `loan_e2e` publishes through a handle and
6448        // subscribes through the arena, so it delivered on domain 0 and on
6449        // nothing else.
6450        let mut node = NodeHandle::new(name, ns, session, remaps, domain_id);
6451        // RFC-0052 W3b.4/.5 — seed the baked monitor tables so contracted
6452        // publishers/subscribers attach their cells without entry glue.
6453        node.set_monitors(monitors);
6454        node.set_age_monitors(age_monitors, epoch);
6455        Ok(f(&mut node))
6456    }
6457
6458    /// Find a registered executor node by final name and namespace.
6459    pub fn node_id_by_name(
6460        &self,
6461        name: &str,
6462        namespace: &str,
6463    ) -> Option<super::node_record::NodeId> {
6464        self.nodes
6465            .iter()
6466            .enumerate()
6467            .find(|(_, node)| node.name.as_str() == name && node.namespace.as_str() == namespace)
6468            .map(|(index, _)| super::node_record::NodeId::from_raw(index as u8))
6469    }
6470
6471    /// Create a node on this executor.
6472    ///
6473    /// Registers the node in the executor's table, deduplicating on
6474    /// `(name, namespace)` — phase-376 W5/B1.
6475    ///
6476    /// # This is `rclrs::Executor::create_node`
6477    ///
6478    /// phase-427 W10 (RFC-0089 "Context and `init`, settled"). Same name, same
6479    /// place in the sequence — `Context` → executor → node — so a ported rclrs
6480    /// `main` lands here unchanged:
6481    ///
6482    /// ```ignore
6483    /// let context = nros::Context::default_from_env()?;
6484    /// let mut executor = context.create_executor()?;
6485    /// let mut node = executor.create_node("talker")?;
6486    /// ```
6487    ///
6488    /// Two differences from upstream, both structural and both named in the
6489    /// ledger row (`rust:Executor::create_node`): ours takes `&mut self`,
6490    /// because the node is registered in the executor's compile-time-sized
6491    /// table rather than allocated, and it hands back a borrowing
6492    /// [`NodeHandle`] rather than an `Arc<Node>`, because there is no
6493    /// allocator. Neither is silent — the compiler names both.
6494    ///
6495    /// The name comes from HERE, not from
6496    /// [`ExecutorConfig::node_name`](crate::ExecutorConfig::node_name), whose
6497    /// builder spelling is deprecated for exactly this reason: one executor
6498    /// holds several named nodes (RFC-0047), so a name on the session config
6499    /// can only ever describe the first of them.
6500    ///
6501    /// Until 2026-08-24 this path registered NOTHING. It built a `NodeHandle`
6502    /// and returned it, so `node_id_by_name` could not find a node the caller
6503    /// had just created, and two calls with one name handed out two handles the
6504    /// executor had never heard of. `create_node_on_with_domain` had the dedup
6505    /// (phase-267 added it there when N bridge endpoints overflowed the table);
6506    /// the plain path never got it.
6507    ///
6508    /// That is a prerequisite for the `create_node` vtable slot, whose contract
6509    /// is that the runtime calls it ONCE per distinct `(name, namespace)`:
6510    /// without a registry to check, the runtime would call it once per
6511    /// `create_node` and every backend would need its own dedup — which is the
6512    /// registry the slot exists to delete (zenoh's `ensure_node_liveliness`
6513    /// linear-scans `per_node_liveliness` for exactly this reason).
6514    ///
6515    /// The table is bounded by `MAX_NODES` (`NROS_EXECUTOR_MAX_NODES`, default
6516    /// 4), so a caller creating a fifth DISTINCT node now gets
6517    /// `NodeError::NodeTableFull` where it previously got a handle. That is the
6518    /// bound doing its job: a node the executor does not know about cannot
6519    /// carry a sched context, a QoS override, or a graph identity. Repeated
6520    /// calls with the SAME name are free.
6521    ///
6522    /// phase-515 R2 -- takes rclrs's `impl IntoNodeOptions`: a bare name
6523    /// (`create_node("talker")`) or a [`NodeOptions`](crate::NodeOptions)
6524    /// (`create_node("talker".namespace("/robot"))`). Here the options'
6525    /// DEFAULTS mean "the executor's": namespace `/` places the node in the
6526    /// executor's own namespace and domain `0` on its own session, as a bare
6527    /// name always did. A non-default value is applied, so an explicit `/`
6528    /// under an executor configured with another namespace, or an explicit
6529    /// domain 0 under another domain, is not expressible here; open a node on
6530    /// another domain with [`create_node_on_with_domain`](Self::create_node_on_with_domain).
6531    pub fn create_node<'o>(
6532        &mut self,
6533        options: impl crate::IntoNodeOptions<'o>,
6534    ) -> Result<super::node::Node<'_, 's>, NodeError> {
6535        let options = options.into_node_options();
6536        let name = options.name;
6537        if name.len() > 64 {
6538            return Err(NodeError::NameTooLong);
6539        }
6540        let namespace = (options.namespace != "/").then_some(options.namespace);
6541        // Dedup against the namespace the node lands in -- the executor's
6542        // own when none is named, which is what `node_builder` gives it.
6543        // phase-483 W2: this returns THE node type (`Node`, re-exported as
6544        // `nros::Node`), the same handle `node_mut` gives a component, where
6545        // it used to return a session-borrowing `NodeHandle` with a different
6546        // method set.
6547        let lookup_ns = match namespace {
6548            Some(ns) => heapless::String::<64>::try_from(ns).map_err(|_| NodeError::NameTooLong)?,
6549            None => self.namespace.clone(),
6550        };
6551        let id = match self.node_id_by_name(name, lookup_ns.as_str()) {
6552            Some(id) => id,
6553            None => {
6554                let mut builder = self.node_builder(name);
6555                if let Some(ns) = namespace {
6556                    builder = builder.namespace(ns);
6557                }
6558                if options.domain_id != 0 {
6559                    builder = builder.domain_id(options.domain_id);
6560                }
6561                builder.build()?
6562            }
6563        };
6564        Ok(super::node::Node::new(self, id))
6565    }
6566
6567    /// Phase 128.F.2 — bridge-mode node factory. Registers a Node
6568    /// bound to the named RMW backend by opening (or reusing) an
6569    /// extra session via `node_builder().rmw(rmw).build()`, then
6570    /// returns a [`NodeHandle`](crate::executor::NodeHandle) borrowing that
6571    /// session. Use when the
6572    /// binary intentionally links more than one backend and a Node
6573    /// must speak a specific one.
6574    ///
6575    /// The single-backend common case should keep using
6576    /// [`create_node`](Self::create_node) — this entry costs an
6577    /// extra session lookup and serves no purpose when only one
6578    /// backend is registered.
6579    #[cfg(feature = "rmw-cffi")]
6580    pub fn create_node_on(
6581        &mut self,
6582        name: &str,
6583        rmw: &str,
6584    ) -> Result<super::node::Node<'_, 's>, NodeError> {
6585        self.create_node_on_with_domain(name, rmw, None, None)
6586    }
6587
6588    /// Like [`create_node_on`](Self::create_node_on) but pins the extra
6589    /// session's domain id. Required for a multi-domain config-driven bridge:
6590    /// an extra RMW session's participant domain follows the **node builder's**
6591    /// `domain_id` (`resolve_session_slot` → `domain_id.unwrap_or(0)`), NOT the
6592    /// `SessionSpec`'s — so without this an egress on a non-zero domain silently
6593    /// opens on domain 0 and never matches its receiver (phase-267 issue 0109).
6594    /// `None` domain preserves the legacy domain-0 default. `locator` pins the
6595    /// extra session's address — REQUIRED for an agent-based backend (xrce: the
6596    /// Micro-XRCE-DDS Agent addr) whose session can't be opened locator-less;
6597    /// `None` keeps the rmw-default (cyclonedds is domain-discovered, no locator).
6598    pub fn create_node_on_with_domain(
6599        &mut self,
6600        name: &str,
6601        rmw: &str,
6602        domain_id: Option<u32>,
6603        locator: Option<&str>,
6604    ) -> Result<super::node::Node<'_, 's>, NodeError> {
6605        if name.len() > 64 {
6606            return Err(NodeError::NameTooLong);
6607        }
6608        // Reuse an existing Node of this name rather than growing the node table
6609        // (phase-267 non-flat): a config-driven bridge calls this once per bridge
6610        // ENDPOINT, and the same session node (`s0`/`s1`) recurs across every
6611        // `[[bridge]]`. Without dedup, N bridges push 2N records and overflow
6612        // `MAX_NODES`. Names are unique per session in a generated bridge config,
6613        // so matching by name is unambiguous.
6614        let id = if let Some(i) = self.nodes.iter().position(|n| n.name.as_str() == name) {
6615            super::node_record::NodeId::from_raw(i as u8)
6616        } else {
6617            let mut builder = self.node_builder(name).rmw(rmw);
6618            if let Some(d) = domain_id {
6619                builder = builder.domain_id(d);
6620            }
6621            if let Some(loc) = locator {
6622                builder = builder.locator(loc);
6623            }
6624            builder.build()?
6625        };
6626        Ok(super::node::Node::new(self, id))
6627    }
6628
6629    /// Drive transport I/O (poll network, dispatch callbacks).
6630    #[allow(dead_code)]
6631    pub(crate) fn drive_io(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
6632        self.session
6633            .drive_io(timeout_ms)
6634            .map_err(|_| NodeError::Transport(TransportError::PollFailed))
6635    }
6636
6637    /// Close the underlying session, running the shutdown hooks around it.
6638    ///
6639    /// Issue 0790. The order is the feature:
6640    ///
6641    /// 1. every registered PRE-shutdown hook, while the session is still open
6642    ///    and every entity still works — this is where a node publishes a final
6643    ///    state, answers a last request, parks an actuator or releases a bus;
6644    /// 2. the session close;
6645    /// 3. every registered ON-shutdown hook.
6646    ///
6647    /// A hook runs EXACTLY ONCE: each phase table is emptied before its first
6648    /// hook is invoked, so a second `close()` — or the [`Drop`] sweep after one
6649    /// — finds nothing left to run. Step 2 runs even
6650    /// if a pre-shutdown hook was registered and step 3 even if the close
6651    /// failed — a hook cannot strand the session, and a dead session must not
6652    /// strand the hooks.
6653    ///
6654    /// # This is a CLEAN-STOP facility and nothing more
6655    ///
6656    /// A watchdog reset, a hard fault or a panic does not come through here, so
6657    /// these hooks do not run then. Nothing in a fixed static table can promise
6658    /// otherwise, and an API that implied it would be worse than none: hardware
6659    /// that must be safe across an abnormal stop needs a hardware answer (a
6660    /// pull-down, a watchdog-driven output disable), not a callback.
6661    pub fn close(&mut self) -> Result<(), NodeError> {
6662        self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
6663        // Issue 1385 — hand the callback back BEFORE closing the session that
6664        // holds it. `close()` leaves the executor alive, so without this a
6665        // later `set_wake_callback` from the drop sweep would reach a session
6666        // the backend has already torn down.
6667        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6668        self.clear_wake_signal();
6669        // phase-467 Row 8 — the same obligation on the graph-change slot, and
6670        // it has to be discharged in the same place for the same reason.
6671        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6672        self.clear_graph_change_signal();
6673        let result = self
6674            .session
6675            .close()
6676            .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed));
6677        self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
6678        result
6679    }
6680
6681    /// Register a hook to run BEFORE the session is closed (issue 0790).
6682    ///
6683    /// rclcpp's `Context::add_pre_shutdown_callback`. Returns the handle
6684    /// [`Self::remove_pre_shutdown_callback`] takes, or
6685    /// [`NodeError::ShutdownCallbacksFull`] when the phase table is full —
6686    /// raise `NROS_EXECUTOR_MAX_SHUTDOWN_CBS` (default 2) at build time.
6687    ///
6688    /// # Safety
6689    /// `callback` must be safe to invoke exactly once with `context`, and
6690    /// `context` must stay valid until the hook runs or is removed. The hook
6691    /// runs on whichever task calls [`Self::close`] (or drops the executor),
6692    /// which is not necessarily the task that registered it.
6693    pub(crate) unsafe fn add_pre_shutdown_callback(
6694        &mut self,
6695        callback: super::types::ShutdownCallbackFn,
6696        context: *mut core::ffi::c_void,
6697    ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
6698        Self::claim_shutdown_slot(
6699            &mut self.pre_shutdown_hooks,
6700            super::types::ShutdownPhase::Pre,
6701            callback,
6702            context,
6703        )
6704    }
6705
6706    /// Register a hook to run AFTER the session is closed (issue 0790).
6707    ///
6708    /// rclcpp's `Context::add_on_shutdown_callback` / `rclcpp::on_shutdown`.
6709    /// Entities are gone by the time it runs, so anything that needs the wire
6710    /// belongs in [`Self::add_pre_shutdown_callback`] instead.
6711    ///
6712    /// # Safety
6713    /// Same contract as [`Self::add_pre_shutdown_callback`].
6714    pub(crate) unsafe fn add_on_shutdown_callback(
6715        &mut self,
6716        callback: super::types::ShutdownCallbackFn,
6717        context: *mut core::ffi::c_void,
6718    ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
6719        Self::claim_shutdown_slot(
6720            &mut self.on_shutdown_hooks,
6721            super::types::ShutdownPhase::Post,
6722            callback,
6723            context,
6724        )
6725    }
6726
6727    /// Remove a pre-shutdown hook. `true` if `handle` named a live one.
6728    ///
6729    /// rclcpp's `Context::remove_pre_shutdown_callback`, and `bool` for the
6730    /// same reason: "it was not there" is an ordinary answer (the hook may
6731    /// already have run), not an error. A handle issued for the OTHER phase
6732    /// returns `false` and removes nothing — that is what the phase tag in
6733    /// [`ShutdownCallbackHandle`] buys.
6734    ///
6735    /// [`ShutdownCallbackHandle`]: super::types::ShutdownCallbackHandle
6736    pub(crate) fn remove_pre_shutdown_callback(
6737        &mut self,
6738        handle: super::types::ShutdownCallbackHandle,
6739    ) -> bool {
6740        Self::release_shutdown_slot(
6741            &mut self.pre_shutdown_hooks,
6742            super::types::ShutdownPhase::Pre,
6743            handle,
6744        )
6745    }
6746
6747    /// Remove an on-shutdown hook. See [`Self::remove_pre_shutdown_callback`].
6748    pub(crate) fn remove_on_shutdown_callback(
6749        &mut self,
6750        handle: super::types::ShutdownCallbackHandle,
6751    ) -> bool {
6752        Self::release_shutdown_slot(
6753            &mut self.on_shutdown_hooks,
6754            super::types::ShutdownPhase::Post,
6755            handle,
6756        )
6757    }
6758
6759    /// How many hooks are currently registered for `phase`. Diagnostic /
6760    /// test surface, and what a "did my registration land?" assertion reads.
6761    pub fn shutdown_callback_count(&self, phase: super::types::ShutdownPhase) -> usize {
6762        let table = match phase {
6763            super::types::ShutdownPhase::Pre => &self.pre_shutdown_hooks,
6764            super::types::ShutdownPhase::Post => &self.on_shutdown_hooks,
6765        };
6766        table.iter().flatten().count()
6767    }
6768
6769    /// Claim the first free slot of a phase table.
6770    fn claim_shutdown_slot(
6771        table: &mut [Option<super::types::ShutdownHook>],
6772        phase: super::types::ShutdownPhase,
6773        callback: super::types::ShutdownCallbackFn,
6774        context: *mut core::ffi::c_void,
6775    ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
6776        for (index, slot) in table.iter_mut().enumerate() {
6777            if slot.is_some() {
6778                continue;
6779            }
6780            let handle = super::types::ShutdownCallbackHandle::new(phase, index)
6781                .ok_or(NodeError::ShutdownCallbacksFull)?;
6782            *slot = Some(super::types::ShutdownHook { callback, context });
6783            return Ok(handle);
6784        }
6785        Err(NodeError::ShutdownCallbacksFull)
6786    }
6787
6788    /// Clear a slot of a phase table, rejecting a handle from the other phase.
6789    fn release_shutdown_slot(
6790        table: &mut [Option<super::types::ShutdownHook>],
6791        phase: super::types::ShutdownPhase,
6792        handle: super::types::ShutdownCallbackHandle,
6793    ) -> bool {
6794        if handle.phase() != Some(phase) {
6795            return false;
6796        }
6797        match table.get_mut(handle.index()) {
6798            Some(slot) => slot.take().is_some(),
6799            None => false,
6800        }
6801    }
6802
6803    /// Run — and consume — every hook registered for `phase`, in registration
6804    /// order.
6805    ///
6806    /// The table is EMPTIED BEFORE the first callback runs, not slot by slot as
6807    /// the loop walks it, and both halves of that matter:
6808    ///
6809    /// * "exactly once" becomes a property of the table rather than of the
6810    ///   caller — a second `close()`, or the `Drop` sweep after one, finds
6811    ///   nothing left to run;
6812    /// * the `&mut self` borrow ENDS before any foreign code is invoked. A hook
6813    ///   is an `extern "C" fn` that may hold a raw pointer back to this
6814    ///   executor (the C and C++ shims hand out exactly that), so a hook that
6815    ///   registers or removes another one must not be running inside a live
6816    ///   `&mut` into the table it is touching.
6817    pub(crate) fn run_shutdown_hooks(&mut self, phase: super::types::ShutdownPhase) {
6818        let table = match phase {
6819            super::types::ShutdownPhase::Pre => &mut self.pre_shutdown_hooks,
6820            super::types::ShutdownPhase::Post => &mut self.on_shutdown_hooks,
6821        };
6822        // `ShutdownHook` is `Copy` (a fn pointer and a raw pointer), so this is
6823        // a register-width move of a table whose default size is two slots —
6824        // not a reason to keep the borrow open across the calls.
6825        let hooks = *table;
6826        *table = [None; crate::config::MAX_SHUTDOWN_CBS];
6827        for hook in hooks.iter().flatten() {
6828            // SAFETY: the `add_*_shutdown_callback` caller promised `callback`
6829            // is safe to invoke once with `context`, and that `context` stays
6830            // valid until the hook runs or is removed. This is that one call,
6831            // and the table is already cleared so it cannot happen again.
6832            unsafe {
6833                (hook.callback)(hook.context);
6834            }
6835        }
6836    }
6837
6838    /// Phase 258 (Track 2, 2a) — enroll a component into the executor-owned
6839    /// tick registry. Called by `nros`'s `install`/`register_node_borrowed`
6840    /// after it builds the `Arc<ComponentCell>`: `state` is the leaked
6841    /// `Arc<ComponentCell>` (the slot takes ownership), `tick`/`drop` are the
6842    /// `nros`-side trampolines (see `ComponentSlot`). The slot's `tick`
6843    /// runs at the tail of every [`spin_once`](Self::spin_once); its `drop`
6844    /// runs once on `Executor::drop`. Issue 2020 -- on an executor whose
6845    /// levels own their slots, the component belongs to the registering
6846    /// level, and only that level's pass ticks it.
6847    ///
6848    /// Returns `Err(())` when the registry is full (`MAX_NODES` — raise via
6849    /// `NROS_EXECUTOR_MAX_NODES` at build time). On error the caller still
6850    /// owns `state` (the slot was not stored) and must drop it.
6851    ///
6852    /// # Safety
6853    /// `state` must be a `*mut` produced by leaking the component cell the
6854    /// `tick`/`drop` trampolines expect (an `Arc<ComponentCell>` via
6855    /// `Arc::into_raw` in the canonical `nros` caller), and must remain valid
6856    /// until the matching `drop` runs. `tick` must be safe to invoke with
6857    /// `(state, exec_ctx = *mut Executor)` each spin; `drop` must be safe to
6858    /// invoke exactly once with `state`.
6859    #[doc(hidden)]
6860    #[allow(clippy::result_unit_err)]
6861    pub unsafe fn enroll_component(
6862        &mut self,
6863        state: *mut core::ffi::c_void,
6864        tick: unsafe extern "C" fn(*mut core::ffi::c_void, *mut core::ffi::c_void),
6865        drop: unsafe extern "C" fn(*mut core::ffi::c_void),
6866    ) -> Result<(), ()> {
6867        unsafe { self.enroll_component_ticking(state, tick, drop, true) }
6868    }
6869
6870    /// phase-521 W6 — [`enroll_component`](Self::enroll_component), stating
6871    /// whether the component's `tick` does any work. `ticks == false` lets
6872    /// the entry park past the spin cadence (see
6873    /// [`entry_spin_budget`](Self::entry_spin_budget)).
6874    ///
6875    /// # Safety
6876    /// As [`enroll_component`](Self::enroll_component).
6877    #[doc(hidden)]
6878    #[allow(clippy::result_unit_err)]
6879    pub unsafe fn enroll_component_ticking(
6880        &mut self,
6881        state: *mut core::ffi::c_void,
6882        tick: unsafe extern "C" fn(*mut core::ffi::c_void, *mut core::ffi::c_void),
6883        drop: unsafe extern "C" fn(*mut core::ffi::c_void),
6884        ticks: bool,
6885    ) -> Result<(), ()> {
6886        let level = self.worker.registering_level;
6887        self.component_slots
6888            .push(ComponentSlot {
6889                state,
6890                tick,
6891                drop,
6892                level,
6893                ticks,
6894            })
6895            .map_err(|_| ())
6896    }
6897
6898    /// phase-521 W6 — whether an enrolled component polls from its `tick`.
6899    pub fn has_ticking_component(&self) -> bool {
6900        self.component_slots.iter().any(|s| s.ticks)
6901    }
6902
6903    /// Phase 258 (Track 2, 2a) — current enrolled component-slot count.
6904    /// Diagnostic / test surface.
6905    #[doc(hidden)]
6906    pub fn component_slot_count(&self) -> usize {
6907        self.component_slots.len()
6908    }
6909
6910    /// issue #140 — the enrolled components' opaque `state` pointers, in enroll
6911    /// order. Each is the *leaked* `Arc<ComponentCell>` `enroll_component` was
6912    /// handed (see `ComponentSlot::state`); the `nros` layer re-borrows them
6913    /// to fold per-component dispatch counters into
6914    /// `observed_callback_counts` — install-seam components
6915    /// (`register_node_borrowed`) live ONLY here, not in
6916    /// `ExecutorNodeRuntime::components`, so the hosted-spin counts read zero
6917    /// without this surface.
6918    #[doc(hidden)]
6919    pub fn enrolled_component_states(&self) -> impl Iterator<Item = *mut core::ffi::c_void> + '_ {
6920        self.component_slots.iter().map(|slot| slot.state)
6921    }
6922
6923    /// Get a reference to the underlying session.
6924    pub fn session(&self) -> &session::ConcreteSession {
6925        &self.session
6926    }
6927
6928    /// Get a mutable reference to the underlying session.
6929    pub fn session_mut(&mut self) -> &mut session::ConcreteSession {
6930        &mut self.session
6931    }
6932
6933    /// Phase 124.F.3 — session-level connectivity probe. Wire-level
6934    /// round-trip "is the peer / agent / router still reachable?"
6935    /// — cheaper than the service-availability probe (no discovery
6936    /// state required).
6937    ///
6938    /// Returns `Ok(())` on reply within `timeout_ms`,
6939    /// `Err(NodeError::Transport(Timeout))` on no reply,
6940    /// `Err(NodeError::Transport(Unsupported))` when the active
6941    /// backend can't probe.
6942    ///
6943    /// Mirrors micro-ROS's `rmw_uros_ping_agent`. Useful for
6944    /// reconnect-on-link-loss patterns: bare-metal code can call
6945    /// `ping(100)` periodically and tear down / re-open the session
6946    /// on timeout.
6947    pub fn ping(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
6948        use nros_rmw::Session;
6949        self.session
6950            .ping_session(timeout_ms)
6951            .map_err(NodeError::Transport)
6952    }
6953
6954    /// phase-381 W4 — every node on the graph, with its namespace.
6955    ///
6956    /// `visit(name, namespace, enclave)` is called once per node and returns
6957    /// `false` to stop early. A VISITOR rather than a returned collection
6958    /// because there is no allocator at this layer and the graph has no bound
6959    /// the caller can know; peak extra memory is one entry. Every string is
6960    /// BORROWED for the duration of the call.
6961    ///
6962    /// `enclave` is `None` where the backend does not track one, which is what
6963    /// lets one call answer both `rmw_get_node_names` and
6964    /// `rmw_get_node_names_with_enclaves`.
6965    ///
6966    /// **This reports what has already been DISCOVERED, and never blocks.** The
6967    /// first call after startup legitimately sees a partial graph — the backend
6968    /// keeps a standing query fed by `spin`, so the view fills in over
6969    /// successive calls. Code that waits for a peer should poll, not call once
6970    /// and conclude. An empty result is "nobody seen yet", never "nobody
6971    /// exists".
6972    ///
6973    /// `Err(Transport(Unsupported))` from a backend with no graph — distinct
6974    /// from an empty graph, deliberately.
6975    pub fn get_node_names(
6976        &mut self,
6977        visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
6978    ) -> Result<(), NodeError> {
6979        use nros_rmw::Session;
6980        self.session
6981            .get_node_names(visit)
6982            .map_err(NodeError::Transport)
6983    }
6984
6985    /// phase-381 W4 — every topic on the graph, with the types on it.
6986    ///
6987    /// `visit(topic_name, types)` is called once per distinct TOPIC — a topic
6988    /// carrying two types is one call with two entries, not two calls. `types`
6989    /// may legitimately be empty on a partially discovered graph: reporting the
6990    /// name without a type beats dropping it.
6991    ///
6992    /// Same discovery caveat as [`Self::get_node_names`].
6993    pub fn get_topic_names_and_types(
6994        &mut self,
6995        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6996    ) -> Result<(), NodeError> {
6997        use nros_rmw::Session;
6998        self.session
6999            .get_topic_names_and_types(visit)
7000            .map_err(NodeError::Transport)
7001    }
7002
7003    /// phase-381 W4 — every service on the graph, with its types.
7004    /// As [`Self::get_topic_names_and_types`], over servers and clients.
7005    pub fn get_service_names_and_types(
7006        &mut self,
7007        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7008    ) -> Result<(), NodeError> {
7009        use nros_rmw::Session;
7010        self.session
7011            .get_service_names_and_types(visit)
7012            .map_err(NodeError::Transport)
7013    }
7014
7015    /// phase-381 W4 — how many publishers are visible on `topic_name`.
7016    ///
7017    /// `topic_name` is a ROS name (`"/chatter"`). A count reflects what has
7018    /// been DISCOVERED, so it can be low right after startup and is never a
7019    /// proof of absence — see [`Self::get_node_names`].
7020    pub fn count_publishers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
7021        use nros_rmw::Session;
7022        self.session
7023            .count_publishers(topic_name)
7024            .map_err(NodeError::Transport)
7025    }
7026
7027    /// phase-381 W4 — how many subscribers are visible on `topic_name`.
7028    /// See [`Self::count_publishers`] for the caveats.
7029    pub fn count_subscribers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
7030        use nros_rmw::Session;
7031        self.session
7032            .count_subscribers(topic_name)
7033            .map_err(NodeError::Transport)
7034    }
7035
7036    /// Issue 1978 — how many service CLIENTS are visible on `service_name`,
7037    /// upstream (Jazzy) `rcl_count_clients`. `service_name` is a ROS name
7038    /// (`"/add_two_ints"`); same discovery caveats as
7039    /// [`Self::count_publishers`], and a backend with no graph answers
7040    /// `Unsupported`, never `0`.
7041    pub fn count_clients(&mut self, service_name: &str) -> Result<usize, NodeError> {
7042        use nros_rmw::Session;
7043        self.session
7044            .count_clients(service_name)
7045            .map_err(NodeError::Transport)
7046    }
7047
7048    /// Issue 1978 — how many service SERVERS are visible on `service_name`,
7049    /// upstream (Jazzy) `rcl_count_services`. See [`Self::count_clients`].
7050    pub fn count_services(&mut self, service_name: &str) -> Result<usize, NodeError> {
7051        use nros_rmw::Session;
7052        self.session
7053            .count_services(service_name)
7054            .map_err(NodeError::Transport)
7055    }
7056
7057    /// phase-381 W4 — what one named node PUBLISHES, with the types.
7058    ///
7059    /// `visit(topic_name, types)` per distinct topic. A node the graph has not
7060    /// discovered yields no visits, which is not an error — see
7061    /// [`Self::get_node_names`] for why an empty answer means "not seen yet".
7062    pub fn get_publisher_names_and_types_by_node(
7063        &mut self,
7064        node_name: &str,
7065        node_namespace: &str,
7066        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7067    ) -> Result<(), NodeError> {
7068        use nros_rmw::{GraphEntityKind, Session};
7069        self.session
7070            .get_names_and_types_by_node(
7071                GraphEntityKind::Publisher,
7072                node_name,
7073                node_namespace,
7074                visit,
7075            )
7076            .map_err(NodeError::Transport)
7077    }
7078
7079    /// phase-381 W4 — what one named node SUBSCRIBES to, with the types.
7080    ///
7081    /// **`subscription`, not `subscriber`** — this is rclrs's spelling
7082    /// (`get_subscription_names_and_types_by_node`), and the Rust surface takes
7083    /// its vocabulary from rclrs so a user porting Rust ROS 2 code types what
7084    /// they already know. The C surface says `subscriber` because rcl does, and
7085    /// the vtable slot says `subscriber` because upstream rmw does. Three
7086    /// layers, three upstreams, one word each — not drift. Issue 0788 owns the
7087    /// wider verb sweep.
7088    pub fn get_subscription_names_and_types_by_node(
7089        &mut self,
7090        node_name: &str,
7091        node_namespace: &str,
7092        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7093    ) -> Result<(), NodeError> {
7094        use nros_rmw::{GraphEntityKind, Session};
7095        self.session
7096            .get_names_and_types_by_node(
7097                GraphEntityKind::Subscriber,
7098                node_name,
7099                node_namespace,
7100                visit,
7101            )
7102            .map_err(NodeError::Transport)
7103    }
7104
7105    /// phase-381 W4 — what services one named node SERVES, with the types.
7106    pub fn get_service_names_and_types_by_node(
7107        &mut self,
7108        node_name: &str,
7109        node_namespace: &str,
7110        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7111    ) -> Result<(), NodeError> {
7112        use nros_rmw::{GraphEntityKind, Session};
7113        self.session
7114            .get_names_and_types_by_node(GraphEntityKind::Service, node_name, node_namespace, visit)
7115            .map_err(NodeError::Transport)
7116    }
7117
7118    /// phase-381 W4 — what services one named node CALLS, with the types.
7119    pub fn get_client_names_and_types_by_node(
7120        &mut self,
7121        node_name: &str,
7122        node_namespace: &str,
7123        visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7124    ) -> Result<(), NodeError> {
7125        use nros_rmw::{GraphEntityKind, Session};
7126        self.session
7127            .get_names_and_types_by_node(GraphEntityKind::Client, node_name, node_namespace, visit)
7128            .map_err(NodeError::Transport)
7129    }
7130
7131    /// phase-381 W4 — the publishers on `topic_name`, one visit each.
7132    ///
7133    /// Each `GraphEndpointInfo` BORROWS its strings for the duration of the
7134    /// visit; copy anything kept.
7135    ///
7136    /// It carries no QoS. The granted profile is what would answer "why is
7137    /// nothing arriving", and no backend can read one back yet — reporting the
7138    /// remote's DECLARED profile instead would be a confident wrong answer, so
7139    /// the field is absent rather than misleading.
7140    pub fn get_publishers_info_by_topic(
7141        &mut self,
7142        topic_name: &str,
7143        visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
7144    ) -> Result<(), NodeError> {
7145        use nros_rmw::Session;
7146        self.session
7147            .get_endpoint_info_by_topic(true, topic_name, visit)
7148            .map_err(NodeError::Transport)
7149    }
7150
7151    /// phase-381 W4 — the subscriptions on `topic_name`, one visit each.
7152    /// See [`Self::get_publishers_info_by_topic`].
7153    pub fn get_subscriptions_info_by_topic(
7154        &mut self,
7155        topic_name: &str,
7156        visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
7157    ) -> Result<(), NodeError> {
7158        use nros_rmw::Session;
7159        self.session
7160            .get_endpoint_info_by_topic(false, topic_name, visit)
7161            .map_err(NodeError::Transport)
7162    }
7163
7164    /// phase-444 — block until at least `count` publishers are visible on
7165    /// `topic_name`, or `timeout` elapses. Upstream's `rcl_wait_for_publishers`.
7166    ///
7167    /// Returns `Ok(true)` when the graph showed at least `count` inside the
7168    /// budget, `Ok(false)` on timeout. The startup-ordering primitive one
7169    /// entity kind over from [`EmbeddedServiceClient::wait_for_service`](super::handles::EmbeddedServiceClient::wait_for_service),
7170    /// and built the same way: a spin on a discovery read, so other
7171    /// subscriptions and timers keep making progress while it waits.
7172    ///
7173    /// **This is the one blocking member of the graph family, and it does not
7174    /// weaken the family's contract — it composes it.** The query underneath
7175    /// ([`count_publishers`](Self::count_publishers)) still reports only what
7176    /// has been DISCOVERED and still never blocks; what waits is this loop,
7177    /// and it waits by POLLING that query. RFC-0021 is the rule that governs
7178    /// it, and that rule does not forbid blocking: it requires a blocking
7179    /// helper to take an executor and drive it, which is what makes the
7180    /// timeout reliable on a single-threaded transport and what makes the
7181    /// reentrancy visible at the call site.
7182    ///
7183    /// **`Unsupported` is propagated IMMEDIATELY, not waited out** — and this
7184    /// is the one place it deliberately differs from `wait_for_service`, which
7185    /// treats a backend that cannot answer as "keep waiting" and reports
7186    /// `Ok(false)` at the deadline (issue 1087). That is right for a service
7187    /// probe, where not-yet-answerable is a transient. It is wrong here: a
7188    /// backend with no graph (XRCE) can never answer, so spending the budget
7189    /// to return `Ok(false)` would report "no publishers appeared" for a
7190    /// backend that cannot see publishers at all — collapsing "cannot tell
7191    /// you" into "not there", which is exactly the distinction RFC-0036 exists
7192    /// to keep and which every other method in this family promises to keep.
7193    ///
7194    /// `count == 0` is satisfied immediately and without asking the backend —
7195    /// including on a backend with no graph, because "at least zero" is true
7196    /// of every graph and needs no discovery to establish.
7197    pub fn wait_for_publishers(
7198        &mut self,
7199        topic_name: &str,
7200        count: usize,
7201        timeout: core::time::Duration,
7202    ) -> Result<bool, NodeError> {
7203        self.wait_for_endpoints(true, topic_name, count, timeout)
7204    }
7205
7206    /// phase-444 — block until at least `count` subscribers are visible on
7207    /// `topic_name`. Upstream's `rcl_wait_for_subscribers`. See
7208    /// [`wait_for_publishers`](Self::wait_for_publishers) for the contract,
7209    /// and for why `Unsupported` returns rather than waits.
7210    pub fn wait_for_subscribers(
7211        &mut self,
7212        topic_name: &str,
7213        count: usize,
7214        timeout: core::time::Duration,
7215    ) -> Result<bool, NodeError> {
7216        self.wait_for_endpoints(false, topic_name, count, timeout)
7217    }
7218
7219    /// The one loop both `wait_for_*` verbs run — a shared helper rather than
7220    /// a second spelling, because the pair differs only in which counter it
7221    /// polls and a copied loop is how the two drift.
7222    fn wait_for_endpoints(
7223        &mut self,
7224        publishers: bool,
7225        topic_name: &str,
7226        count: usize,
7227        timeout: core::time::Duration,
7228    ) -> Result<bool, NodeError> {
7229        if count == 0 {
7230            return Ok(true);
7231        }
7232        // `?`, not a swallow: a backend that cannot see the graph says so now.
7233        if self.count_endpoints(publishers, topic_name)? >= count {
7234            return Ok(true);
7235        }
7236        let spin_interval =
7237            core::time::Duration::from_millis(super::handles::DEFAULT_SPIN_INTERVAL_MS);
7238        let max_spins =
7239            (timeout.as_millis() as u64 / super::handles::DEFAULT_SPIN_INTERVAL_MS).max(1);
7240        let mut budget = super::handles::WaitBudget::new(max_spins, timeout);
7241        loop {
7242            self.spin_once(budget.next_spin_interval(spin_interval));
7243            if self.count_endpoints(publishers, topic_name)? >= count {
7244                return Ok(true);
7245            }
7246            if !budget.tick() {
7247                return Ok(false);
7248            }
7249        }
7250    }
7251
7252    /// Which counter a `wait_for_*` polls. Keeps the boolean's meaning in ONE
7253    /// place instead of at both call sites.
7254    fn count_endpoints(&mut self, publishers: bool, topic_name: &str) -> Result<usize, NodeError> {
7255        if publishers {
7256            self.count_publishers(topic_name)
7257        } else {
7258            self.count_subscribers(topic_name)
7259        }
7260    }
7261
7262    /// Get a mutable reference to an action client core in the arena by entry index.
7263    ///
7264    /// # Safety
7265    /// The caller must ensure that `entry_index` refers to an `ActionClientRawArenaEntry`.
7266    #[doc(hidden)]
7267    pub(crate) unsafe fn action_client_core_mut(
7268        &mut self,
7269        entry_index: usize,
7270    ) -> Option<&mut super::action_core::ActionClientCore> {
7271        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7272        if !matches!(meta.kind, EntryKind::ActionClient) {
7273            return None;
7274        }
7275        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7276        unsafe {
7277            let entry_ptr = arena_ptr.add(meta.offset)
7278                as *mut super::arena::ActionClientRawArenaEntry<
7279                    { crate::config::DEFAULT_RX_BUF_SIZE },
7280                    { crate::config::DEFAULT_RX_BUF_SIZE },
7281                    { crate::config::DEFAULT_RX_BUF_SIZE },
7282                >;
7283            Some(&mut (*entry_ptr).core)
7284        }
7285    }
7286
7287    /// Get a mutable reference to a service-client arena entry (Phase 82).
7288    ///
7289    /// Returns `None` if `entry_index` doesn't refer to a service client
7290    /// entry. The default reply buffer size is assumed because the C API
7291    /// always uses the default — the entry was registered via
7292    /// `register_service_client_raw_sized::<DEFAULT_RX_BUF_SIZE>`.
7293    ///
7294    /// # Safety
7295    /// `entry_index` must refer to a `ServiceClientRawArenaEntry`.
7296    pub(crate) unsafe fn service_client_entry_mut(
7297        &mut self,
7298        entry_index: usize,
7299    ) -> Option<&mut super::arena::ServiceClientRawArenaEntry<{ crate::config::DEFAULT_RX_BUF_SIZE }>>
7300    {
7301        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7302        if !matches!(meta.kind, EntryKind::ServiceClient) {
7303            return None;
7304        }
7305        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7306        unsafe {
7307            let entry_ptr = arena_ptr.add(meta.offset)
7308                as *mut super::arena::ServiceClientRawArenaEntry<
7309                    { crate::config::DEFAULT_RX_BUF_SIZE },
7310                >;
7311            Some(&mut *entry_ptr)
7312        }
7313    }
7314
7315    // ------------------------------------------------------------------
7316    // issue 1437 / phase-444 — the ARENA half of the granted-QoS read-back.
7317    //
7318    // An entity registered with a callback lives in the executor arena, and
7319    // the C / C++ handle the user holds is then bookkeeping: its inline
7320    // storage is untouched and the only identity it carries is the entry
7321    // index. So an accessor that reads the user's own storage answers for
7322    // the POLL road and returns an absence on the CALLBACK road — which is
7323    // the road a ported `rclcpp` node takes, i.e. the common one. These
7324    // three close that reach.
7325    //
7326    // Each reads the entry's HANDLE and nothing else, which is why one
7327    // function per kind serves every entry SHAPE of that kind: every arena
7328    // entry is `#[repr(C)]` with its handle as the first field (12
7329    // subscription shapes, 2 service-server, 2 service-client), so
7330    // `arena_base + offset` IS the handle pointer whatever buffer sizes and
7331    // callback types the shape was monomorphised with.
7332    // `service_client_entry_mut` above has to name ONE shape because it
7333    // reaches past the handle; these do not, and that is the whole
7334    // difference. `arena::tests::every_arena_entry_begins_with_its_handle`
7335    // pins the invariant.
7336
7337    /// The `RmwSubscriber` of an arena-registered subscription, by entry index.
7338    ///
7339    /// `None` when `entry_index` names no entry, or names one that is not a
7340    /// subscription.
7341    ///
7342    /// # Safety
7343    /// `entry_index` must be an index this executor handed out. The borrow is
7344    /// valid only while the arena is not re-entered.
7345    #[doc(hidden)]
7346    pub(crate) unsafe fn subscription_handle(
7347        &self,
7348        entry_index: usize,
7349    ) -> Option<&session::RmwSubscriber> {
7350        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7351        if !matches!(meta.kind, EntryKind::Subscription) {
7352            return None;
7353        }
7354        let arena_ptr = self.arena.as_ptr() as *const u8;
7355        // SAFETY: `meta.offset` is inside the arena by construction, and every
7356        // subscription entry shape begins with its `RmwSubscriber`.
7357        Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwSubscriber) })
7358    }
7359
7360    /// [`Executor::subscription_handle`], mutably — issue 1972: registering a
7361    /// status-event callback (`Subscription::register_event_callback`) takes
7362    /// `&mut`, and the C API's subscription is arena-owned.
7363    ///
7364    /// Issue 2060 — taking it also makes the readiness scan probe this
7365    /// subscription's `has_data` on every pass, where its backend polls the
7366    /// status events (see `entity_status_polled`).
7367    ///
7368    /// # Safety
7369    /// See [`Executor::subscription_handle`]; in addition no other borrow of
7370    /// the same entry may be live.
7371    #[doc(hidden)]
7372    pub(crate) unsafe fn subscription_handle_mut(
7373        &mut self,
7374        entry_index: usize,
7375    ) -> Option<&mut session::RmwSubscriber> {
7376        let slot = self.slot_of_raw(entry_index)?;
7377        let meta = self.entries.get(slot)?.as_ref()?;
7378        if !matches!(meta.kind, EntryKind::Subscription) {
7379            return None;
7380        }
7381        let offset = meta.offset;
7382        // Issue 2060 — `&mut` to an arena subscriber's handle exists to
7383        // register a status-event callback, and those events are polled from
7384        // `has_data`: from now on the scan asks it every pass, not only after
7385        // an arrival. Marked before the registration is known to succeed; a
7386        // refused one costs one cheap probe per pass, a missed mark costs the
7387        // event.
7388        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7389        if slot < super::entity_wake::SLOTS {
7390            self.entity_status_polled |= 1u64 << slot;
7391        }
7392        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7393        // SAFETY: as `subscription_handle`; `&mut self` makes the borrow unique.
7394        Some(unsafe { &mut *(arena_ptr.add(offset) as *mut session::RmwSubscriber) })
7395    }
7396
7397    /// The `RmwServiceServer` of an arena-registered service, by entry index.
7398    ///
7399    /// # Safety
7400    /// See [`Executor::subscription_handle`].
7401    #[doc(hidden)]
7402    pub(crate) unsafe fn service_server_handle(
7403        &self,
7404        entry_index: usize,
7405    ) -> Option<&session::RmwServiceServer> {
7406        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7407        if !matches!(meta.kind, EntryKind::Service) {
7408            return None;
7409        }
7410        let arena_ptr = self.arena.as_ptr() as *const u8;
7411        // SAFETY: as above; every service-server entry shape begins with its
7412        // `RmwServiceServer`.
7413        Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwServiceServer) })
7414    }
7415
7416    /// The `RmwServiceClient` of an arena-registered client, by entry index.
7417    ///
7418    /// # Safety
7419    /// See [`Executor::subscription_handle`].
7420    #[doc(hidden)]
7421    pub(crate) unsafe fn service_client_handle(
7422        &self,
7423        entry_index: usize,
7424    ) -> Option<&session::RmwServiceClient> {
7425        let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7426        if !matches!(meta.kind, EntryKind::ServiceClient) {
7427            return None;
7428        }
7429        let arena_ptr = self.arena.as_ptr() as *const u8;
7430        // SAFETY: as above; both service-client entry shapes begin with their
7431        // `RmwServiceClient` — the typed one through `ServiceClientSendHeader`,
7432        // itself `#[repr(C)]` and handle-first.
7433        Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwServiceClient) })
7434    }
7435
7436    /// Set the executor-level trigger condition.
7437    ///
7438    /// Controls which handles must be ready before `spin_once` dispatches
7439    /// callbacks. Defaults to [`Trigger::Any`](crate::Trigger).
7440    pub fn set_trigger(&mut self, trigger: Trigger) {
7441        self.trigger = trigger;
7442    }
7443
7444    /// Set the executor data communication semantics.
7445    ///
7446    /// Choose between `Direct` (process in place) and `LET`
7447    /// (snapshot-then-process) semantics. See [`ExecutorSemantics`].
7448    pub fn set_semantics(&mut self, semantics: ExecutorSemantics) {
7449        self.semantics = semantics;
7450    }
7451
7452    /// Set the invocation mode for a specific handle.
7453    ///
7454    /// Controls whether the callback fires on every spin
7455    /// ([`Always`](InvocationMode::Always)) or only when new data
7456    /// arrives ([`OnNewData`](InvocationMode::OnNewData), the default).
7457    pub fn set_invocation(&mut self, id: HandleId, mode: InvocationMode) {
7458        let slot = self.live_slot(id);
7459        if let Some(Some(meta)) = self.entries.get_mut(slot) {
7460            meta.invocation = mode;
7461        }
7462    }
7463
7464    // ========================================================================
7465    // Arena-based callback registration
7466    // ========================================================================
7467
7468    /// Arena bytes claimed by registered entities so far.
7469    ///
7470    /// EXACT, not a worst case: the arena is a bump allocator and every
7471    /// `arena_alloc` charges `size_of::<T>()`, so nothing is reserved per slot.
7472    /// `ARENA_SIZE` itself is derived the other way — every slot budgeted at
7473    /// the ActionClient worst case — which is issue 0900, and this accessor is
7474    /// how an image can find out what it actually needs.
7475    pub fn arena_used(&self) -> usize {
7476        self.arena_used
7477    }
7478
7479    /// Total arena bytes this executor was given.
7480    ///
7481    /// The arena is a borrowed slice; where its storage lives is the caller's
7482    /// choice, not a property of this type. Three answers are real — a `.bss`
7483    /// static (the C/C++ entries, and the `alloc` constructors since phase-392
7484    /// W6), the heap (those constructors' fallback), and a stack-declared
7485    /// `rclc_executor_t`. See `super::backing` and `report_arena_headroom`.
7486    pub fn arena_capacity(&self) -> usize {
7487        self.arena.len()
7488    }
7489
7490    /// Bump-allocate space for `T` in the arena. Returns the byte offset.
7491    pub(crate) fn arena_alloc<T>(&mut self) -> Result<usize, NodeError> {
7492        // Issue 1667 — every region is grain-aligned and at least one free-list
7493        // node long, so it can be listed when it is released.
7494        let align = core::mem::align_of::<T>().max(ARENA_GRAIN);
7495        let size = Self::arena_region_len(core::mem::size_of::<T>());
7496        if let Some((offset, granted)) = self.arena_take_freed(size, align) {
7497            crate::boot_report::note_alloc(size, self.arena_used);
7498            self.note_entry_region(offset, granted);
7499            return Ok(offset);
7500        }
7501        let aligned_offset = (self.arena_used + align - 1) & !(align - 1);
7502        let new_used = aligned_offset + size;
7503        if new_used > self.arena.len() {
7504            // issue 0900 — `BufferTooSmall` is returned by a dozen other paths,
7505            // so on a target where a return code is all you get, arena
7506            // exhaustion is indistinguishable from a message that did not fit.
7507            return Err(self.arena_exhausted(
7508                core::any::type_name::<T>(),
7509                size,
7510                new_used - self.arena.len(),
7511            ));
7512        }
7513        self.arena_used = new_used;
7514        crate::boot_report::note_alloc(size, new_used);
7515        self.note_entry_region(aligned_offset, size);
7516        Ok(aligned_offset)
7517    }
7518
7519    /// Issue 1631 — remember the region an ENTRY allocation handed out, for
7520    /// `emplace_entry` to record. Only the two entry allocators call this; a
7521    /// callback capture rides inside the entry's trailing region (phase-476
7522    /// W0), so it is part of what they record.
7523    fn note_entry_region(&mut self, offset: usize, len: usize) {
7524        self.pending_region = Some(FreedRegion { offset, len });
7525    }
7526
7527    /// Issue 1667 — the length a region of `len` requested bytes occupies:
7528    /// rounded up to the grain, and never shorter than a free-list node.
7529    const fn arena_region_len(len: usize) -> usize {
7530        let len = len.next_multiple_of(ARENA_GRAIN);
7531        if len < ARENA_MIN_REGION {
7532            ARENA_MIN_REGION
7533        } else {
7534            len
7535        }
7536    }
7537
7538    /// Bytes a callback CAPTURE takes in an entry's trailing region: its length
7539    /// rounded up to the arena's `u64` grain. 0 for no capture.
7540    ///
7541    /// phase-476 W0 — a capture used to be a SEPARATE arena allocation
7542    /// (~~`stow_capture`~~ over ~~`arena_alloc_bytes`~~), which `release_entry` never
7543    /// gave back: it frees exactly the region `emplace_entry` recorded, and that
7544    /// was the entry's. Placing the capture at the END of the entry's own
7545    /// trailing region puts it inside the recorded region, so releasing the
7546    /// entry releases its capture.
7547    pub(crate) fn capture_trailing_len(capture: Option<&[u8]>) -> usize {
7548        match capture {
7549            Some(b) if !b.is_empty() => b.len().next_multiple_of(core::mem::align_of::<u64>()),
7550            _ => 0,
7551        }
7552    }
7553
7554    /// Copy a caller's callback CAPTURE to arena offset `at` and return the
7555    /// context pointer the entry should dispatch with — phase-456 W1, made a
7556    /// runtime length by W8, made part of the entry's region by phase-476 W0.
7557    ///
7558    /// `context` is one pointer. That carries a C++ `[this]` capture and not a
7559    /// `[this, state]` (two) or a `[obj, method]` (three, because a
7560    /// pointer-to-member-function is two words on the Itanium ABI) — the
7561    /// distribution phase-442 W0 measured over this tree and the porting corpus.
7562    /// So the capture lives in the arena beside the registration that uses it:
7563    /// arena bytes never move once allocated, and the returned pointer is into
7564    /// them rather than into anything of the caller's, so the C++ side keeps
7565    /// nothing.
7566    ///
7567    /// `at` must start [`capture_trailing_len`](Self::capture_trailing_len)
7568    /// bytes the caller's entry allocation reserved for it. `None` and an empty
7569    /// slice both mean "no capture", and both give the caller's own `context`
7570    /// straight back, which is every pre-W1 caller.
7571    pub(crate) fn place_capture(
7572        &mut self,
7573        at: usize,
7574        capture: Option<&[u8]>,
7575        context: *mut core::ffi::c_void,
7576    ) -> *mut core::ffi::c_void {
7577        let bytes = match capture {
7578            Some(b) if !b.is_empty() => b,
7579            _ => return context,
7580        };
7581        debug_assert!(at + bytes.len() <= self.arena.len());
7582        // SAFETY: the caller's entry allocation reserved
7583        // `capture_trailing_len(capture)` bytes at `at` for exactly this, and
7584        // `bytes` is a caller slice that cannot alias the arena (the arena is
7585        // borrowed mutably by `self` for the whole call).
7586        unsafe {
7587            let dst = (self.arena.as_mut_ptr() as *mut u8).add(at);
7588            core::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len());
7589            dst as *mut core::ffi::c_void
7590        }
7591    }
7592
7593    /// Bump-allocate space for `T` plus `trailing_bytes` extra bytes.
7594    ///
7595    /// Returns `(entry_offset, trailing_offset)`. The trailing region starts
7596    /// immediately after `T` (aligned to 8 bytes).
7597    pub(crate) fn arena_alloc_with_trailing<T>(
7598        &mut self,
7599        trailing_bytes: usize,
7600    ) -> Result<(usize, usize), NodeError> {
7601        let align = core::mem::align_of::<T>().max(ARENA_GRAIN);
7602        let entry_size = core::mem::size_of::<T>();
7603        // Issue 1496 — a released region that holds the entry AND its trailing
7604        // region is reused whole. The trailing region keeps its rule: it starts
7605        // on a `u64` boundary after the entry, so the request is sized by that.
7606        let u64_align = core::mem::align_of::<u64>();
7607        let whole = Self::arena_region_len(entry_size.next_multiple_of(u64_align) + trailing_bytes);
7608        if let Some((entry_offset, granted)) = self.arena_take_freed(whole, align) {
7609            let trailing_offset = (entry_offset + entry_size).next_multiple_of(u64_align);
7610            crate::boot_report::note_alloc(whole, self.arena_used);
7611            self.note_entry_region(entry_offset, granted);
7612            return Ok((entry_offset, trailing_offset));
7613        }
7614        let entry_offset = self.arena_used.next_multiple_of(align);
7615        // Trailing region starts on an 8-byte (u64) boundary after the entry.
7616        let trailing_offset =
7617            (entry_offset + entry_size).next_multiple_of(core::mem::align_of::<u64>());
7618        // Issue 1667 — the region ends on the grain and is at least a node.
7619        let new_used = entry_offset + whole;
7620        debug_assert!(new_used >= trailing_offset + trailing_bytes);
7621        if new_used > self.arena.len() {
7622            // This path reported NOTHING until phase-412's self-report went in,
7623            // while its sibling `arena_alloc` has named the knob since issue
7624            // 0900. Half of arena exhaustion was therefore silent -- and it is
7625            // the half carrying buffered subscriptions and action entries,
7626            // which is what an island image actually allocates.
7627            return Err(self.arena_exhausted(
7628                core::any::type_name::<T>(),
7629                new_used - entry_offset,
7630                new_used - self.arena.len(),
7631            ));
7632        }
7633        self.arena_used = new_used;
7634        crate::boot_report::note_alloc(new_used - entry_offset, new_used);
7635        self.note_entry_region(entry_offset, new_used - entry_offset);
7636        Ok((entry_offset, trailing_offset))
7637    }
7638
7639    /// The ONE exhaustion path every arena allocator goes through — issues
7640    /// 0900, 1036, 1496.
7641    ///
7642    /// It names the knob (`report_arena_exhausted`), writes the first failure
7643    /// and its shortfall to the boot record (the only channel a console-less
7644    /// board has), says whether the failure is a SHAPE problem rather than a
7645    /// size one (released regions that together hold the request, none of
7646    /// which alone does), and — where the image asked for it — halts through
7647    /// the platform's fatal hook, so the record survives instead of the board
7648    /// running on with an entity missing. Three spellings of this existed, and
7649    /// the `_with_trailing` one reported nothing until phase-412.
7650    fn arena_exhausted(&self, entity: &'static str, request: usize, shortfall: usize) -> NodeError {
7651        super::arena::report_arena_exhausted(
7652            entity,
7653            shortfall,
7654            self.arena_used,
7655            self.arena.len(),
7656            self.arena_released(),
7657            request,
7658        );
7659        crate::boot_report::note_alloc_failed(request, shortfall);
7660        super::arena::arena_exhaustion_is_fatal();
7661        NodeError::BufferTooSmall
7662    }
7663
7664    /// Issue 1667 — read the free-list node at `offset`: `(next, len)`.
7665    fn arena_hole(&self, offset: usize) -> (usize, usize) {
7666        // SAFETY: `offset` is a listed free region: in bounds, grain-aligned and
7667        // at least `ARENA_MIN_REGION` long, written by `arena_set_hole`.
7668        unsafe {
7669            let p = (self.arena.as_ptr() as *const u8).add(offset) as *const usize;
7670            (p.read_unaligned(), p.add(1).read_unaligned())
7671        }
7672    }
7673
7674    /// Issue 1667 — write the free-list node at `offset`.
7675    fn arena_set_hole(&mut self, offset: usize, next: usize, len: usize) {
7676        debug_assert!(len >= ARENA_MIN_REGION && offset + len <= self.arena.len());
7677        // SAFETY: as `arena_hole`; the region is free, so nothing else reads it.
7678        unsafe {
7679            let p = (self.arena.as_mut_ptr() as *mut u8).add(offset) as *mut usize;
7680            p.write_unaligned(next);
7681            p.add(1).write_unaligned(len);
7682        }
7683    }
7684
7685    /// Point `prev`'s node (or the head, for `ARENA_FREE_NONE`) at `next`.
7686    fn arena_link(&mut self, prev: usize, next: usize) {
7687        if prev == ARENA_FREE_NONE {
7688            self.arena_free_head = next;
7689        } else {
7690            let (_, len) = self.arena_hole(prev);
7691            self.arena_set_hole(prev, next, len);
7692        }
7693    }
7694
7695    /// Issue 1496 / 1667 — first-fit over the free list. Returns the offset and
7696    /// the length GRANTED, which is the request plus any tail too short to list
7697    /// on its own: that tail belongs to the allocation, so its release gives it
7698    /// back. A hole whose alignment slack would be too short to list is skipped
7699    /// rather than split, for the same reason — no byte is ever orphaned.
7700    fn arena_take_freed(&mut self, size: usize, align: usize) -> Option<(usize, usize)> {
7701        let mut prev = ARENA_FREE_NONE;
7702        let mut cur = self.arena_free_head;
7703        while cur != ARENA_FREE_NONE {
7704            let (next, len) = self.arena_hole(cur);
7705            let start = cur.next_multiple_of(align);
7706            let head = start - cur;
7707            let fits = start + size <= cur + len;
7708            if fits && (head == 0 || head >= ARENA_MIN_REGION) {
7709                let tail = cur + len - (start + size);
7710                let granted = if tail < ARENA_MIN_REGION {
7711                    size + tail
7712                } else {
7713                    size
7714                };
7715                // What stays listed: the head slack, the tail, both, or neither.
7716                let mut after = next;
7717                if tail >= ARENA_MIN_REGION {
7718                    let t = start + size;
7719                    self.arena_set_hole(t, after, tail);
7720                    after = t;
7721                }
7722                if head > 0 {
7723                    self.arena_set_hole(cur, after, head);
7724                } else {
7725                    self.arena_link(prev, after);
7726                }
7727                return Some((start, granted));
7728            }
7729            prev = cur;
7730            cur = next;
7731        }
7732        None
7733    }
7734
7735    /// Issue 1496 / 1667 — give a region back. It is inserted in address order
7736    /// and coalesced with both neighbours, so a loop that frees what it
7737    /// allocated returns the arena to the shape it had; a hole that then ends
7738    /// at `arena_used` is handed back to the bump pointer.
7739    pub(crate) fn arena_release(&mut self, offset: usize, len: usize) {
7740        debug_assert!(len >= ARENA_MIN_REGION && len.is_multiple_of(ARENA_GRAIN));
7741        // `pprev` -> `prev` -> [offset] -> `cur`, in address order.
7742        let mut pprev = ARENA_FREE_NONE;
7743        let mut prev = ARENA_FREE_NONE;
7744        let mut cur = self.arena_free_head;
7745        while cur != ARENA_FREE_NONE && cur < offset {
7746            pprev = prev;
7747            prev = cur;
7748            cur = self.arena_hole(cur).0;
7749        }
7750        let (mut start, mut size) = (offset, len);
7751        let mut next = cur;
7752        if next != ARENA_FREE_NONE && start + size == next {
7753            let (after, next_len) = self.arena_hole(next);
7754            size += next_len;
7755            next = after;
7756        }
7757        if prev != ARENA_FREE_NONE {
7758            let (_, prev_len) = self.arena_hole(prev);
7759            if prev + prev_len == start {
7760                start = prev;
7761                size += prev_len;
7762                // `prev` is absorbed: link from ITS predecessor.
7763                prev = pprev;
7764            }
7765        }
7766        if start + size == self.arena_used {
7767            // The top of the arena: give it back to the bump pointer.
7768            self.arena_used = start;
7769            self.arena_link(prev, next);
7770            return;
7771        }
7772        self.arena_set_hole(start, next, size);
7773        self.arena_link(prev, start);
7774    }
7775
7776    /// Issue 1496 — bytes released back to the arena and not yet reused.
7777    /// Bytes handed back to the bump pointer are not counted: `arena_used`
7778    /// already went down by them.
7779    pub fn arena_released(&self) -> usize {
7780        let mut total = 0;
7781        let mut cur = self.arena_free_head;
7782        while cur != ARENA_FREE_NONE {
7783            let (next, len) = self.arena_hole(cur);
7784            total += len;
7785            cur = next;
7786        }
7787        total
7788    }
7789
7790    /// Issue 1667 — how many separate holes the free list holds. For tests and
7791    /// diagnostics.
7792    pub fn arena_holes(&self) -> usize {
7793        let mut count = 0;
7794        let mut cur = self.arena_free_head;
7795        while cur != ARENA_FREE_NONE {
7796            count += 1;
7797            cur = self.arena_hole(cur).0;
7798        }
7799        count
7800    }
7801
7802    /// Issue 1496 — RELEASE one callback entry: drop the entry in place (its
7803    /// RMW handles' `Drop`s destroy the backend entities, so nothing stays
7804    /// advertised), free its callback slot, and give its arena bytes back for
7805    /// reuse. `false`, touching nothing, when `index` is not a live entry of
7806    /// `kind`.
7807    ///
7808    /// Issue 1631 — the bytes released are the ones `emplace_entry` RECORDED
7809    /// from the allocator (`CallbackMeta::arena_len`), entry and trailing
7810    /// region together. 1496's form took the size from the caller, which had
7811    /// to restate the concrete entry type — impossible for a buffered
7812    /// subscription (its trailing region is sized at runtime) or a timer (its
7813    /// closure type is the registering crate's).
7814    ///
7815    /// # Safety
7816    /// No handle naming `index` may be used afterwards: the slot and its bytes
7817    /// go to the next registration.
7818    pub(crate) unsafe fn release_entry(&mut self, index: usize, kind: EntryKind) -> bool {
7819        let live = self
7820            .entries
7821            .get(index)
7822            .and_then(|e| e.as_ref())
7823            .is_some_and(|m| m.kind == kind);
7824        if !live {
7825            return false;
7826        }
7827        // Issue 1667 — the entry's own callback is running (it, or something it
7828        // called, asked for this release). Mark it and let `finish_dispatch` do
7829        // the release when the callback returns. The handle stops resolving
7830        // now, so a second request answers `false` and nothing dispatches it.
7831
7832        if let Some(tag) = self.slot_tags.get(index)
7833            && tag.flags() & super::types::SlotTag::IN_FLIGHT != 0
7834        {
7835            let was = tag.set(super::types::SlotTag::RELEASE_PENDING);
7836            return was & super::types::SlotTag::RELEASE_PENDING == 0;
7837        }
7838        // phase-397 W2 — the slot's next occupant arms (or not) on its own.
7839        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7840        if index < super::entity_wake::SLOTS {
7841            self.entity_attributed &= !(1u64 << index);
7842            self.entity_recheck &= !(1u64 << index);
7843            self.entity_status_polled &= !(1u64 << index);
7844        }
7845        // Issue 2146 -- the slot's next occupant starts inline and unqueued.
7846        self.forget_deferred(index);
7847        let Some(meta) = self.entries[index].take() else {
7848            return false;
7849        };
7850        // SAFETY: the entry was written by its registration and not yet
7851        // dropped; `drop_fn` matches its concrete type.
7852        unsafe {
7853            let data_ptr = (self.arena.as_mut_ptr() as *mut u8).add(meta.offset);
7854            (meta.drop_fn)(data_ptr);
7855        }
7856        // The entry's binding goes; the CONTEXT's budget state stays. It is
7857        // indexed by sched context, not by entry slot, and other entries may
7858        // still be bound to it: clearing `sporadic_states[index]` here dropped
7859        // the budget of whichever context shared the released slot's number.
7860        self.sched_context_bindings[index] = super::sched_context::SchedContextId(0);
7861        // phase10-B3 -- a released trigger subscription stops starting routes.
7862        if matches!(kind, EntryKind::Subscription) {
7863            super::monitor::route_unbind(self.monitor_table, index);
7864            super::monitor::deadline_unbind(self.age_table, index);
7865        }
7866        if let Some(len) = meta.arena_len.get() {
7867            self.arena_release(meta.offset, len);
7868        }
7869        if let Some(tag) = self.slot_tags.get_mut(index) {
7870            tag.owner = 0;
7871            tag.rank = 0;
7872            tag.reset_flags();
7873        }
7874        true
7875    }
7876
7877    /// Issue 1667 — mark `slot`'s callback as running, so a release it requests
7878    /// is deferred rather than dropping the entry under it.
7879    #[inline]
7880    pub(super) fn begin_dispatch(slot_tags: &[super::types::SlotTag], slot: usize) {
7881        if let Some(tag) = slot_tags.get(slot) {
7882            tag.set(super::types::SlotTag::IN_DISPATCH);
7883        }
7884    }
7885
7886    /// Issue 1705 — `slot`'s entry is not to be touched by the spin thread
7887    /// this pass: its release is pending (it is never dispatched again), or
7888    /// (phase-509 P9b item 2) its callback is running, in another worker's
7889    /// window or below this pass on the same stack. So one callback never
7890    /// runs twice at once, and a pass never probes or sweeps an entry whose
7891    /// callback another thread is in.
7892    #[inline]
7893    pub(super) fn slot_held(slot_tags: &[super::types::SlotTag], slot: usize) -> bool {
7894        slot_tags.get(slot).is_some_and(|t| {
7895            t.flags() & (super::types::SlotTag::RELEASE_PENDING | super::types::SlotTag::IN_FLIGHT)
7896                != 0
7897        })
7898    }
7899
7900    /// phase-509 P3 (RFC-0108 D2) -- after a callback, merge what became
7901    /// ready while it ran into this pass's ready set, so the next pick is
7902    /// from the set as it is now.
7903    ///
7904    /// - An attributed slot's arrival (its bit in the ready word) joins.
7905    /// - With [`Self::set_reprobe_each_callback`], a slot that cannot be
7906    ///   attributed is probed (`has_data`) and joins when it has data.
7907    /// - A slot the pass already `settled` (queued, dispatched or held back by
7908    ///   a gate) does not join: it runs at most once per pass. An arrival for
7909    ///   one is kept for the next pass's scan.
7910    /// - A slot whose context gates its dispatch (a sporadic budget, a
7911    ///   time-triggered window) waits for the next pass, where the scan
7912    ///   applies the gate.
7913    fn merge_mid_pass(
7914        &mut self,
7915        fifo: &mut super::ready_set::RankedFifoSet<{ MAX_CALLBACK_SLOTS }>,
7916        edf: &mut super::ready_set::BucketedEdfSet<
7917            { super::sched_context::Priority::COUNT },
7918            { MAX_CALLBACK_SLOTS },
7919        >,
7920        settled: &mut u64,
7921        arena_ptr: *mut u8,
7922        band_mask: u64,
7923    ) {
7924        let mut fresh: u64 = 0;
7925        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7926        let attributed = self.entity_attributed;
7927        #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
7928        let attributed: u64 = 0;
7929        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7930        {
7931            // phase-509 P9b item 2 -- this band's arrivals only.
7932            let arrived = self
7933                .wake_ctx
7934                .as_ref()
7935                .map_or(0, |ctx| take_band_bits(&ctx.ready, band_mask))
7936                & attributed;
7937            // Kept for the next pass: its scan asks `has_data` (the bit has
7938            // left the word, and its wake already ended no park early).
7939            self.entity_recheck |= arrived & *settled;
7940            fresh |= arrived & !*settled;
7941        }
7942        if self.reprobe_each_callback {
7943            let mut probes: u32 = 0;
7944            for (i, meta) in self.entries.iter().enumerate() {
7945                let bit = 1u64 << i;
7946                let Some(meta) = meta else { continue };
7947                if (*settled | attributed | fresh) & bit != 0
7948                    || band_mask & bit == 0
7949                    || matches!(meta.kind, EntryKind::Timer | EntryKind::GuardCondition)
7950                    || matches!(meta.invocation, InvocationMode::Always)
7951                    || Self::slot_held(self.slot_tags, i)
7952                {
7953                    continue;
7954                }
7955                probes = probes.wrapping_add(1);
7956                // SAFETY: as the readiness scan's probe: `meta.offset` is this
7957                // live entry's arena slot, and `has_data` only reads it.
7958                if unsafe { (meta.has_data)(arena_ptr.add(meta.offset) as *const u8) } {
7959                    fresh |= bit;
7960                }
7961            }
7962            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7963            {
7964                self.readiness_probes = self.readiness_probes.wrapping_add(probes);
7965            }
7966            #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
7967            let _ = probes;
7968        }
7969        // Issue 2146 -- a deferred slot that became ready mid-pass is queued,
7970        // not joined to the pass.
7971        if self.deferred_mask & fresh != 0 {
7972            fresh = self.defer_ready(fresh);
7973        }
7974        if fresh == 0 {
7975            return;
7976        }
7977        if !self.age_table.is_empty() {
7978            super::monitor::credit_arrivals(self.age_table, fresh);
7979        }
7980        let mut rest = fresh;
7981        while rest != 0 {
7982            let i = rest.trailing_zeros() as usize;
7983            let bit = 1u64 << i;
7984            rest &= !bit;
7985            if self.entries.get(i).is_none_or(|e| e.is_none()) || Self::slot_held(self.slot_tags, i)
7986            {
7987                #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7988                {
7989                    self.entity_recheck |= bit & attributed;
7990                }
7991                continue;
7992            }
7993            let sc_idx = self.sched_context_bindings[i].0 as usize;
7994            let sc = self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref());
7995            let class = sc.map_or(super::sched_context::SchedClass::Fifo, |sc| sc.class);
7996            let priority = sc.map_or(super::sched_context::Priority::Normal, |sc| sc.priority);
7997            let windowed = self.major_frame_us > 0
7998                && sc.is_some_and(|sc| sc.tt_window_duration_us.get().is_some());
7999            if matches!(class, super::sched_context::SchedClass::Sporadic) || windowed {
8000                #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8001                {
8002                    self.entity_recheck |= bit & attributed;
8003                }
8004                continue;
8005            }
8006            let is_edf = matches!(class, super::sched_context::SchedClass::Edf);
8007            let job = super::types::ActiveJob {
8008                sort_key: if is_edf {
8009                    sc.and_then(|sc| sc.deadline_us.get())
8010                        .map_or(u32::MAX, |nz| nz.get())
8011                } else {
8012                    i as u32
8013                },
8014                desc_idx: i as super::types::DescIdx,
8015            };
8016            if is_edf {
8017                let _ = edf.insert_into(priority.index(), job);
8018            } else {
8019                let rank = self.slot_tags.get(i).map_or(0, |t| t.rank);
8020                let _ = fifo.insert_at(priority.dispatch_level(rank), job);
8021            }
8022            *settled |= bit;
8023            // As the scan does: a ready attributed slot is asked again next
8024            // spin, since one dispatch may leave data behind.
8025            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8026            {
8027                self.entity_recheck |= bit & attributed;
8028            }
8029        }
8030    }
8031
8032    /// Issue 1667 — `slot`'s callback has returned. Complete a release it
8033    /// requested while running.
8034    pub(super) fn finish_dispatch(&mut self, slot: usize) {
8035        let Some(tag) = self.slot_tags.get(slot) else {
8036            return;
8037        };
8038        // phase-509 P9b item 2 -- a cancel or reset deferred behind this
8039        // callback first, while the entry is still marked running.
8040        if tag.flags()
8041            & (super::types::SlotTag::CANCEL_PENDING | super::types::SlotTag::RESET_PENDING)
8042            != 0
8043        {
8044            self.apply_deferred_timer_op(slot);
8045        }
8046        let Some(tag) = self.slot_tags.get(slot) else {
8047            return;
8048        };
8049        let was = tag.clear(super::types::SlotTag::IN_DISPATCH);
8050        if was & super::types::SlotTag::RELEASE_PENDING != 0 {
8051            self.complete_deferred_release(slot);
8052        }
8053    }
8054
8055    /// Issue 1667 / 1705 — finish a release that was deferred while a dispatch
8056    /// of the entry was in flight, if none is any more. A no-op while one still
8057    /// is: `reap_deferred_releases` retries.
8058    fn complete_deferred_release(&mut self, slot: usize) {
8059        let Some(tag) = self.slot_tags.get(slot) else {
8060            return;
8061        };
8062        let f = tag.flags();
8063        if f & super::types::SlotTag::RELEASE_PENDING == 0
8064            || f & super::types::SlotTag::IN_FLIGHT != 0
8065        {
8066            return;
8067        }
8068        if let Some(kind) = self
8069            .entries
8070            .get(slot)
8071            .and_then(|e| e.as_ref())
8072            .map(|m| m.kind)
8073        {
8074            // SAFETY: the release was requested through a generation-checked
8075            // handle, and `resolve_handle` has refused the slot since; no
8076            // dispatch holds the entry (`IN_FLIGHT` is clear).
8077            let _ = unsafe { self.release_entry(slot, kind) };
8078        }
8079    }
8080
8081    /// Issue 1705 — complete every deferred release whose last dispatch has
8082    /// finished. `finish_dispatch` completes one deferred behind a callback
8083    /// on the spin thread when it returns; this is the backstop at the top
8084    /// of every `spin_once` (it served the retired OS-priority worker pool,
8085    /// whose worker could not touch the executor, phase-509 P11).
8086    pub(crate) fn reap_deferred_releases(&mut self) {
8087        for slot in 0..self.slot_tags.len() {
8088            if self.slot_tags[slot].flags() & super::types::SlotTag::RELEASE_PENDING != 0 {
8089                self.complete_deferred_release(slot);
8090            }
8091        }
8092    }
8093
8094    /// Issue 1631 — release a SUBSCRIPTION entry, whatever registration shape
8095    /// made it (typed, raw, in-place, buffered, with-info). See
8096    /// `release_entry` (crate-private): the entry is dropped, so its
8097    /// subscriber leaves the graph; its slot and arena bytes are reused by the
8098    /// next registration. `false`, touching nothing, when `handle` is not a
8099    /// live subscription.
8100    ///
8101    /// # Safety
8102    /// No copy of `handle` may be used afterwards — the slot is handed to the
8103    /// next registration, which may be of any kind.
8104    pub(crate) unsafe fn release_subscription(&mut self, handle: HandleId) -> bool {
8105        match self.resolve_handle(handle) {
8106            Some(slot) => unsafe { self.release_entry(slot, EntryKind::Subscription) },
8107            None => false,
8108        }
8109    }
8110
8111    /// Issue 1631 — release a TIMER entry; the timer's callback (and anything
8112    /// its closure captured) is dropped and never fires again. As
8113    /// [`release_subscription`](Self::release_subscription).
8114    ///
8115    /// # Safety
8116    /// As [`release_subscription`](Self::release_subscription).
8117    pub(crate) unsafe fn release_timer(&mut self, handle: HandleId) -> bool {
8118        match self.resolve_handle(handle) {
8119            Some(slot) => unsafe { self.release_entry(slot, EntryKind::Timer) },
8120            None => false,
8121        }
8122    }
8123
8124    /// Issue 1631 — release a SERVICE SERVER entry; the server leaves the
8125    /// graph. As [`release_subscription`](Self::release_subscription).
8126    ///
8127    /// # Safety
8128    /// As [`release_subscription`](Self::release_subscription).
8129    pub(crate) unsafe fn release_service(&mut self, handle: HandleId) -> bool {
8130        match self.resolve_handle(handle) {
8131            Some(slot) => unsafe { self.release_entry(slot, EntryKind::Service) },
8132            None => false,
8133        }
8134    }
8135
8136    /// Issue 1631 — release a SERVICE CLIENT entry; the client leaves the
8137    /// graph and any reply it was waiting for is abandoned with it. As
8138    /// [`release_subscription`](Self::release_subscription).
8139    ///
8140    /// # Safety
8141    /// As [`release_subscription`](Self::release_subscription).
8142    pub(crate) unsafe fn release_service_client(&mut self, handle: HandleId) -> bool {
8143        match self.resolve_handle(handle) {
8144            Some(slot) => unsafe { self.release_entry(slot, EntryKind::ServiceClient) },
8145            None => false,
8146        }
8147    }
8148
8149    /// Issue 1667 — release a GUARD CONDITION entry: its callback is dropped and
8150    /// `spin_once` stops polling it; the slot and its arena bytes go to the next
8151    /// registration.
8152    ///
8153    /// Unlike the other release verbs this one has a contract a generation
8154    /// cannot discharge. The [`GuardCondition`] trigger handle holds a pointer
8155    /// to the flag INSIDE the entry, and it is `Clone` + `Send`, so a clone on
8156    /// another thread (an ISR, a worker) would write into whatever reuses those
8157    /// bytes. The C++ `GuardCondition` is non-copyable and the one holder of its
8158    /// trigger handle, which is why its destructor can call this.
8159    ///
8160    /// # Safety
8161    /// No [`GuardCondition`] trigger handle for this entry — the one
8162    /// registration returned, or any clone of it — may be used afterwards.
8163    pub(crate) unsafe fn release_guard_condition(&mut self, handle: HandleId) -> bool {
8164        match self.resolve_handle(handle) {
8165            Some(slot) => unsafe { self.release_entry(slot, EntryKind::GuardCondition) },
8166            None => false,
8167        }
8168    }
8169
8170    /// Find the next free entry slot index.
8171    pub(crate) fn next_entry_slot(&self) -> Result<usize, NodeError> {
8172        self.entries
8173            .iter()
8174            .position(|e| e.is_none())
8175            // Issue 0095 — the callback-entry table (`NROS_EXECUTOR_MAX_CBS`,
8176            // default 4) is full. Distinct from `BufferTooSmall` so the register
8177            // seam can tell the user to raise the knob.
8178            .ok_or(NodeError::ExecutorFull)
8179    }
8180
8181    /// Install a finished [`CallbackMeta`] into its slot.
8182    ///
8183    /// phase-8 (`docs/design/callback_tracing.rst`) — the ONE choke point
8184    /// every registration site funnels through, so the
8185    /// nros_callback_register(handle, kind, name) event is emitted once,
8186    /// here, rather than at the 25 sites that build a `CallbackMeta`. The
8187    /// design's registration half is deliberately EXHAUSTIVE across every
8188    /// `EntryKind` even though the leaf hooks are staged: a callback that was
8189    /// registered but never observed then prints as "registered, not
8190    /// instrumented" instead of being silently absent, so an incomplete hook
8191    /// set announces itself in the output rather than looking like a
8192    /// measurement.
8193    ///
8194    /// It is the assignment — not [`next_entry_slot`](Self::next_entry_slot)
8195    /// — because a slot is claimed BEFORE the fallible work (session lookup,
8196    /// arena allocation, handle creation) that can still return `Err`.
8197    /// Emitting at slot-claim time would announce callbacks that do not
8198    /// exist.
8199    pub(crate) fn emplace_entry(
8200        &mut self,
8201        slot: usize,
8202        mut meta: CallbackMeta,
8203        name: TraceName<'_>,
8204    ) {
8205        // Issue 1631 — record the region the entry's allocation handed out,
8206        // so `release_entry` gives back exactly that. Matched on the OFFSET so
8207        // a stale region (a registration that failed after allocating) is
8208        // never attributed to a different entry.
8209        meta.arena_len = match self.pending_region.take() {
8210            Some(r) if r.offset == meta.offset => super::arena::ArenaLen::new(r.len),
8211            _ => super::arena::ArenaLen::UNRECORDED,
8212        };
8213        trace_register(slot, meta.kind, name);
8214        // phase10-B3 -- a subscription on a route's trigger topic is the
8215        // route's trigger. The table is installed before any node exists.
8216        if let (EntryKind::Subscription, TraceName::Text(topic)) = (meta.kind, &name) {
8217            super::monitor::route_bind(self.monitor_table, topic, slot);
8218        }
8219        // phase-463 W7 — the slot's identity for the host profile, keyed on the
8220        // arena base the dispatch sees (stable for the executor's life).
8221        #[cfg(feature = "profile-mode")]
8222        super::profile::on_register(self.arena.as_ptr() as usize, slot, meta.kind, name);
8223        self.entries[slot] = Some(meta);
8224        // Issue 2018 -- the registering level's own context (0, the default,
8225        // when it has none); a node or group binding still overrides it.
8226        if let Some(b) = self.sched_context_bindings.get_mut(slot) {
8227            *b = super::sched_context::SchedContextId(self.worker.registering_sched);
8228        }
8229        // phase-476 W0 — a new occupant gets a new generation, so every handle
8230        // issued for the slot's previous occupant stops resolving. Owner is
8231        // cleared; a registration made for a node sets it after this
8232        // (`set_entry_owner`).
8233        if let Some(tag) = self.slot_tags.get_mut(slot) {
8234            tag.generation = tag.next_generation();
8235            tag.owner = 0;
8236            tag.rank = 0;
8237            // phase-509 P9b item 4 -- the level being set up owns it.
8238            tag.level = self.worker.registering_level;
8239            tag.reset_flags();
8240        }
8241    }
8242
8243    /// phase-397 W2 — register `slot`'s entity with THIS executor's wake
8244    /// context, through `register` (the entry handle's `register_waker`).
8245    ///
8246    /// Called by every subscription, service and client registration AFTER
8247    /// the entry is written into the arena: the handle's address is stable
8248    /// from then on, and a backend that keeps a pointer into the handle (the
8249    /// cffi route's per-entity `user_data`) must not see it move.
8250    ///
8251    /// Records whether the backend accepted it in `entity_attributed`. A
8252    /// backend that cannot attribute an arrival returns `false`, and the
8253    /// session-scoped wake keeps covering that entry. Without the async wake
8254    /// primitive, or without `rmw-cffi`, there is nothing to wake and this does
8255    /// nothing.
8256    #[allow(unused_variables, unused_mut)]
8257    pub(crate) fn arm_entity_wake(
8258        &mut self,
8259        slot: usize,
8260        register: impl FnOnce(&core::task::Waker) -> bool,
8261    ) {
8262        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8263        {
8264            if self.node_wake.is_none() || slot >= super::entity_wake::SLOTS {
8265                return;
8266            }
8267            self.wake_ctx_ptr();
8268            let Some(waker) = self
8269                .wake_ctx
8270                .as_ref()
8271                .and_then(|ctx| super::entity_wake::waker(ctx, slot))
8272            else {
8273                return;
8274            };
8275            let bit = 1u64 << slot;
8276            if register(&waker) {
8277                self.entity_attributed |= bit;
8278                // W3 — the backend replays nothing that arrived before this
8279                // call, so the first spin asks `has_data` once.
8280                self.entity_recheck |= bit;
8281            } else {
8282                self.entity_attributed &= !bit;
8283                self.entity_recheck &= !bit;
8284            }
8285        }
8286    }
8287
8288    /// [`arm_entity_wake`](Self::arm_entity_wake) for the entry of type `T`
8289    /// at arena `offset`: the one place a registration site's entry is read
8290    /// back to reach its handle.
8291    ///
8292    /// # Safety
8293    ///
8294    /// A `T` was written at `offset` and is the live occupant of `slot`. It
8295    /// stays at that address until its release, which drops the handle (and
8296    /// with it the backend's registration) first.
8297    pub(crate) unsafe fn arm_entry_wake<T>(
8298        &mut self,
8299        slot: usize,
8300        offset: usize,
8301        register: impl FnOnce(&T, &core::task::Waker) -> bool,
8302    ) {
8303        let entry = self
8304            .arena
8305            .as_ptr()
8306            .cast::<u8>()
8307            .wrapping_add(offset)
8308            .cast::<T>();
8309        // SAFETY: the caller's contract — a live `T` at `offset`.
8310        self.arm_entity_wake(slot, |w| register(unsafe { &*entry }, w));
8311    }
8312
8313    /// phase-397 W2 — the ready word, taken: every slot an attributed arrival
8314    /// marked since the last take. `spin_once` consumes it (W3), so a caller
8315    /// that also spins sees only what arrived since the last spin. Tests only.
8316    #[doc(hidden)]
8317    pub fn take_entity_ready_bits(&self) -> u64 {
8318        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8319        if let Some(ctx) = self.wake_ctx.as_ref() {
8320            return ctx.ready.swap(0, core::sync::atomic::Ordering::SeqCst) as u64;
8321        }
8322        0
8323    }
8324
8325    /// phase-397 W2 — the slots whose backend accepted an entity waker.
8326    #[doc(hidden)]
8327    pub fn entity_attributed_slots(&self) -> u64 {
8328        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8329        return self.entity_attributed;
8330        #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
8331        0
8332    }
8333
8334    /// phase-397 W3 — how many `has_data` calls the readiness scan has made
8335    /// (wrapping). An attributed slot's readiness comes from its arrival bit,
8336    /// so a spin over attributed entries alone adds nothing here. Tests only;
8337    /// `0` in a build with no entity wake, where it is not counted.
8338    #[doc(hidden)]
8339    pub fn readiness_probe_count(&self) -> u32 {
8340        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8341        return self.readiness_probes;
8342        #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
8343        0
8344    }
8345
8346    /// phase-509 P3 (RFC-0108 D2) -- probe the slots whose backend cannot
8347    /// attribute an arrival (XRCE, uORB) after every callback, so one made
8348    /// ready by a callback runs in the same pass, in rank order, instead of
8349    /// at the next pass's scan. Pushed readiness joins mid-pass either way.
8350    /// The default is `REPROBE_EACH_CALLBACK_DEFAULT` (in `executor/spin.rs`); the phase-509 P3
8351    /// host measurement that chose it is recorded beside that constant.
8352    pub fn set_reprobe_each_callback(&mut self, on: bool) {
8353        self.reprobe_each_callback = on;
8354    }
8355
8356    /// phase-476 W0 — the handle for the CURRENT occupant of `slot`. Every
8357    /// registration returns this, after `emplace_entry` has stamped the slot.
8358    pub(crate) fn handle_for(&self, slot: usize) -> HandleId {
8359        let generation = self.slot_tags.get(slot).map_or(0, |t| t.generation);
8360        HandleId::new(slot, generation)
8361    }
8362
8363    /// phase-476 W0 (issue 1667) — the slot `id` names, if and only if `id` was
8364    /// issued for the entry that occupies it NOW. A handle whose entry was
8365    /// released, or whose slot has since been taken by another registration of
8366    /// any kind, answers `None`, never the next occupant.
8367    pub fn resolve_handle(&self, id: HandleId) -> Option<usize> {
8368        let slot = id.slot();
8369        let live = self.entries.get(slot).is_some_and(|e| e.is_some())
8370            && self
8371                .slot_tags
8372                .get(slot)
8373                .is_none_or(|t| t.flags() & super::types::SlotTag::RELEASE_PENDING == 0);
8374        // Generation 0 is `HandleId::for_owned_slot`: a uniquely-owned holder
8375        // naming its own live slot. Every ISSUED handle carries >= 1.
8376        let current = id.generation() == 0
8377            || self
8378                .slot_tags
8379                .get(slot)
8380                .is_some_and(|t| t.generation == id.generation());
8381        (live && current).then_some(slot)
8382    }
8383
8384    /// Issue 1703 — the slot behind a by-index accessor's `entry_index`
8385    /// argument, which callers hand in in TWO spellings: a bare slot (nros-c's
8386    /// stored `arena_entry_index`, `node_runtime`'s resolved slot, an action's
8387    /// owned slot) or the PACKED handle an FFI issued (`HandleId::to_raw`,
8388    /// what nros-cpp's `handle_id_` and nros-c's `subscription.handle_id`
8389    /// hold since phase-476 W0). A bare slot has generation 0, so both decode
8390    /// through [`HandleId::from_raw`] and resolve here: the slot for a live
8391    /// entry, generation-checked when one was issued, `None` for a stale or
8392    /// foreign handle.
8393    ///
8394    /// The five accessors indexed `entries` with the argument as given. W0 made
8395    /// the FFI value a packed `(generation << 16) | slot`, so every packed
8396    /// caller read past the table and got `None` — `async_send_request` on a
8397    /// C++ callback client returned `INVALID_ARGUMENT` on every call, and the
8398    /// granted-QoS read-backs answered for no entity.
8399    fn slot_of_raw(&self, entry_index: usize) -> Option<usize> {
8400        self.resolve_handle(HandleId::from_raw(entry_index))
8401            .filter(|&slot| !self.running_elsewhere(slot))
8402    }
8403
8404    /// phase-476 W0 — [`resolve_handle`](Self::resolve_handle) as an index that
8405    /// is OUT OF RANGE for a stale handle, so an existing `entries.get(..)`
8406    /// chain answers `None` for it with no further change.
8407    ///
8408    /// phase-509 P9b item 2 -- also out of range while the entry's callback
8409    /// runs in another thread's window (`running_elsewhere`): the accessors
8410    /// that go through here touch the entry's arena data, which that callback
8411    /// owns until it returns. The caller sees what a stale handle gets, and
8412    /// may retry once the callback has returned.
8413    pub(crate) fn live_slot(&self, id: HandleId) -> usize {
8414        self.resolve_handle(id)
8415            .filter(|&slot| !self.running_elsewhere(slot))
8416            .unwrap_or(usize::MAX)
8417    }
8418
8419    /// phase-476 W0 — record that `node` registered the entry `id` names, so
8420    /// [`release_node`](Self::release_node) can find it. `false` when `id` is
8421    /// stale.
8422    pub(crate) fn set_entry_owner(&mut self, id: HandleId, node: crate::executor::NodeId) -> bool {
8423        let Some(slot) = self.resolve_handle(id) else {
8424            return false;
8425        };
8426        let Ok(biased) = u8::try_from(node.index() + 1) else {
8427            return false;
8428        };
8429        self.slot_tags[slot].owner = biased;
8430        true
8431    }
8432
8433    /// phase-476 W0 — release every entry `node` registered, whatever its kind.
8434    /// Returns how many were released.
8435    ///
8436    /// This is what a node's destruction calls. A C++ callback's capture is
8437    /// copied into the arena and commonly holds the node's `this`, so an entry
8438    /// that outlived its node would dispatch into freed memory.
8439    ///
8440    /// # Safety
8441    /// No handle to any of those entries may be used afterwards except through
8442    /// a lookup that checks the generation ([`resolve_handle`](Self::resolve_handle)),
8443    /// which every public lookup on this type does.
8444    pub(crate) unsafe fn release_node(&mut self, node: crate::executor::NodeId) -> usize {
8445        let Ok(biased) = u8::try_from(node.index() + 1) else {
8446            return 0;
8447        };
8448        let mut released = 0;
8449        for slot in 0..self.entries.len() {
8450            if self.slot_tags.get(slot).is_some_and(|t| t.owner == biased) {
8451                let kind = match self.entries[slot].as_ref() {
8452                    Some(m) => m.kind,
8453                    None => continue,
8454                };
8455                // SAFETY: forwarded from this function's contract.
8456                if unsafe { self.release_entry(slot, kind) } {
8457                    released += 1;
8458                }
8459            }
8460        }
8461        released
8462    }
8463
8464    /// Open a subscription: claim a slot, resolve the node's identity, create
8465    /// the backend subscriber, and answer the in-place question ONCE.
8466    ///
8467    /// **phase-456 W8 — the one prologue.** Every registration that creates its
8468    /// own subscriber goes through here, in every language. The two callers
8469    /// that do not are the two that cannot: `add_arena_subscription_callback`
8470    /// is handed a subscriber someone else built (uORB needs a
8471    /// `&'static orb_metadata`, which no `TopicInfo` can express), so there is
8472    /// no topic to resolve and no session to reach — a fourth axis under issue
8473    /// 1319's rows that W8's three-axis decomposition did not predict: WHO
8474    /// CREATED THE SUBSCRIBER. It is a real axis, it is orthogonal to the other
8475    /// three, and it has exactly one member.
8476    ///
8477    /// Everything downstream of this function differs per delivery shape: the
8478    /// arena entry's type, its `try_process`, and whether it has a receive
8479    /// region at all. That part is irreducible and stays at the call site.
8480    pub(crate) fn open_subscription(
8481        &mut self,
8482        req: &SubscriptionRequest<'_>,
8483    ) -> Result<SubscriptionOpen, NodeError> {
8484        let slot = self.next_entry_slot()?;
8485        let (node_name, ns, session_idx) = match req.node_id {
8486            Some(id) => {
8487                let r = self
8488                    .nodes
8489                    .get(id.index())
8490                    .ok_or(NodeError::InvalidSchedContextBinding)?;
8491                (r.name.clone(), r.namespace.clone(), r.session_idx)
8492            }
8493            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
8494        };
8495        let mut topic = TopicInfo::new(req.topic_name, req.type_name, req.type_hash)
8496            .with_domain(self.domain_id)
8497            .with_namespace(&ns);
8498        if !node_name.is_empty() {
8499            topic = topic.with_node_name(&node_name);
8500        }
8501        // Phase 231 (RFC-0038) / phase-402 W2 — hand the backend a
8502        // receive-buffer size so it can size-class its receive storage
8503        // (zenoh-pico: small vs large). Only when the caller actually stated
8504        // one: `with_rx_buffer_hint(0)` would be a claim of "zero bytes", not
8505        // "no opinion".
8506        if let Some(hint) = req.backend_hint {
8507            if hint != 0 {
8508                topic = topic.with_rx_buffer_hint(hint);
8509            }
8510        }
8511        // phase-454 W10/W13 — the depth this registration asks for against the
8512        // depth this system DECLARED for the topic. Before the backend call AND
8513        // before the arena claim, so the region this registration takes and the
8514        // one the build reserved are the same number; unchanged when nothing
8515        // was declared, which is every image with no contract sidecar. See
8516        // `declared_qos::honour` for why Rust TAKES the declared depth where C
8517        // and C++ assert it at the call site.
8518        //
8519        // phase-456 W8 — this used to be `declared_qos::check` here and
8520        // `honour` in each of the twelve hand-rolled prologues this function
8521        // replaced. Collapsing them onto `check` would have silently reverted
8522        // W13 for every Rust registration: `check` validates and returns, while
8523        // `honour` REWRITES the depth the backend is then given. One consulting
8524        // site is the point of this function, and it has to be the honouring
8525        // one.
8526        let qos = crate::declared_qos::honour(topic.type_name, topic.name, req.qos)?;
8527        // phase-519 W3b (RFC-0074) — the subscription's declared arrival rate,
8528        // for a backend that paces delivery itself (XRCE asks its agent for
8529        // `rate_hz x rx_buffer_hint` bytes a second). Unchanged when nothing
8530        // was declared.
8531        let topic = crate::declared_qos::with_declared_arrival_rate(topic);
8532        let age_table = self.age_table;
8533        let (handle, deadline_row) = {
8534            let session = self
8535                .session_at_mut(session_idx)
8536                .ok_or(NodeError::BackendMismatch)?;
8537            // phase-462 W5b — a contract `deadline` the backend cannot judge
8538            // is judged by the silence rule instead, never dropped silently and
8539            // never a boot refusal (`monitor::admit_contract_deadline`).
8540            let (qos, deadline_row) = super::monitor::admit_contract_deadline(
8541                qos,
8542                nros_rmw::Session::supported_qos_policies(session),
8543                age_table,
8544                req.topic_name,
8545            );
8546            let handle = session
8547                .create_subscription(&topic, qos)
8548                .map_err(NodeError::Transport)?;
8549            (handle, deadline_row)
8550        };
8551        if let Some(row) = deadline_row {
8552            row.cell
8553                .deadline_slot
8554                .store(slot as u32 + 1, core::sync::atomic::Ordering::Relaxed);
8555            nros_log::log_info!(
8556                nros_log::get_logger("nros_node"),
8557                "qos: subscription '{}' deadline {} ms is judged by silence-runtime \
8558                 (the backend does not judge it)",
8559                req.topic_name,
8560                row.deadline_ms
8561            );
8562        }
8563        // Phase 231 Wave 0.2 (RFC-0038), phase-456 W8 — THE consulting site.
8564        // Deliberately `&&`-guarded by the delivery shape rather than by the
8565        // language: a shape that must hand the callback more than borrowed
8566        // bytes cannot use the capability however capable the backend is.
8567        let in_place = req.in_place_capable && {
8568            use nros_rmw::Subscription as _;
8569            handle.supports_process_in_place()
8570        };
8571        // phase-457 W3 — tell a metadata probe what this CALL SITE answered.
8572        //
8573        // `req.in_place_capable` and NOT `in_place`: the conjunction above
8574        // includes the backend a probe links, which is the RECORDING backend and
8575        // not the one the image ships. The sizing descriptor knows the shipped
8576        // backend from the image's `rmw` name and composes the two halves
8577        // itself; what it cannot see is this half, which is why nine of the
8578        // eleven registration entry points were previously credited with a
8579        // capability they do not have (issue 1340).
8580        //
8581        // AFTER the backend call, so a registration that failed reports
8582        // nothing — a row for a subscription that does not exist would raise the
8583        // probe's count, and an over-count of subscriptions is a real cost in
8584        // every pool that derives from it.
8585        #[cfg(feature = "registration-observer")]
8586        super::registration_observer::observe(super::registration_observer::ObservedRegistration {
8587            topic: topic.name,
8588            type_name: topic.type_name,
8589            in_place_capable: req.in_place_capable,
8590            // Issue 1648 -- the slot this registration is about to claim, read
8591            // off the request rather than inferred: `DEFAULT_RX_BUF_SIZE`
8592            // (`RX_BUF`) is the closure buffer (`unbounded`), anything else a
8593            // stated bound.
8594            claims_closure_buffer: req.slot_bytes == crate::config::DEFAULT_RX_BUF_SIZE,
8595        });
8596        Ok(SubscriptionOpen {
8597            slot,
8598            handle,
8599            qos,
8600            in_place,
8601            slot_bytes: req.slot_bytes,
8602        })
8603    }
8604
8605    /// Typed buffered subscription core (the `node_mut(id).subscription(t)
8606    /// .typed::<M>()` builder lowers here). Routes the typed subscription
8607    /// through the [`NodeId`]'s session + identity (rclcpp `add_node` pattern).
8608    ///
8609    /// Phase 403 W2 -- `rx_bytes` says how many bytes each buffer slot claims
8610    /// from the arena.
8611    ///
8612    /// phase-392 W3c -- `Some(n)` is verbatim; **`None` now means DERIVE from
8613    /// `M`**, falling back to `RX_BUF` only where no bound is reachable (an
8614    /// unbounded type, or a backend whose `MessageForRmw` carries no schema).
8615    /// It used to mean `RX_BUF` unconditionally, so a subscription that named no
8616    /// number was charged the global for every slot and a consumer who did not
8617    /// know `.rx_buffer_from_type()` existed paid it silently. The opt-out is
8618    /// now the explicit one: `.rx_buffer::<N>()` passes `Some(N)`.
8619    ///
8620    /// It is a RUNTIME `usize` and not a second const generic because on THIS
8621    /// path `RX_BUF` was never an array length -- it reaches
8622    /// `buffered_region_size`, `TripleBuffer::init` and `SpscRing::init`, all of
8623    /// which take a plain `usize`. That is the only reason W2's per-type sizing
8624    /// is expressible here at all: `Sub<{ M::BOUND }>` is
8625    /// `error: generic parameters may not be used in const operations` on stable
8626    /// (rustc 1.97.1), so the sibling `register_subscription_with_info_sized_inner`
8627    /// / `..._with_safety_sized_inner`, whose entries really do hold
8628    /// `[u8; RX_BUF]`, still need `nros::rx_buffer_for!` at the call site.
8629    pub(crate) fn register_subscription_buffered_on<M, F, const RX_BUF: usize>(
8630        &mut self,
8631        node_id: super::node_record::NodeId,
8632        topic_name: &str,
8633        qos: QoSProfile,
8634        callback: F,
8635        group: Option<&str>,
8636        rx_bytes: Option<usize>,
8637    ) -> Result<HandleId, NodeError>
8638    where
8639        M: crate::rmw_type_registry::MessageForRmw + 'static,
8640        F: FnMut(&M) + 'static,
8641    {
8642        type Entry<M, F> = SubBufferedEntry<M, F>;
8643
8644        // RFC-0088 / phase-421 W1 — the message's declared format must be the
8645        // one the linked backend speaks. Universal: the const lives on
8646        // `RosMessage`, which `MessageForRmw` requires under every backend.
8647        crate::format_check::assert_message_format::<M>();
8648        // Phase 212.K.7.6.b — see `create_publisher_on`.
8649        crate::rmw_type_registry::register_type::<M>()?;
8650
8651        // W3b.5 — contracted-endpoint age hook (None = free).
8652        let age_mon = self.age_lookup::<M>(topic_name);
8653
8654        // Phase 403 W2 -- one number for the whole allocation. The region size,
8655        // the strategy's slot size and the pointer arithmetic must agree, so
8656        // they read the same local rather than each spelling `RX_BUF`.
8657        //
8658        // phase-392 W3c -- and `None` no longer means `RX_BUF`, it means DERIVE.
8659        // This is the one choke point every default registration funnels
8660        // through, so putting the derivation here is what makes it the default
8661        // at all four of them (`create_subscription`, `create_subscription_in_group`,
8662        // the builder's `build()`, and the `/clock` time source) instead of at
8663        // whichever one a wave happened to touch. An explicit `Some(n)` -- what
8664        // `.rx_buffer::<N>()` and `.rx_buffer_from_type()` pass -- is still
8665        // verbatim, so the opt-out and the opt-in both bypass this line.
8666        let slot_bytes = crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF);
8667
8668        let open = self.open_subscription(&SubscriptionRequest {
8669            node_id: Some(node_id),
8670            topic_name,
8671            type_name: <M as RosMessage>::TYPE_NAME,
8672            type_hash: <M as RosMessage>::TYPE_HASH,
8673            qos,
8674            // Phase 392 W3a — the TYPE's bound, not `RX_BUF`. The arena slot
8675            // size says nothing about the message: a 64-byte type and a 4 KiB
8676            // type both hinted the same number, so the class was chosen from a
8677            // value unrelated to what arrives. Falls back to `RX_BUF` for an
8678            // unbounded type, where no bound exists to state (phase 380).
8679            backend_hint: Some(crate::rmw_type_registry::subscription_rx_hint::<M>(RX_BUF)),
8680            slot_bytes,
8681            // A typed Rust callback takes `&M` and nothing else, so borrowed
8682            // bytes are enough: deserialize straight from the backend's slot.
8683            in_place_capable: true,
8684        })?;
8685        let SubscriptionOpen {
8686            slot,
8687            handle,
8688            qos,
8689            in_place,
8690            slot_bytes,
8691        } = open;
8692
8693        // Phase 231 Wave 0.2 (RFC-0038) — in-place dispatch when the backend
8694        // advertises it: deserialize straight from the borrowed receive slot,
8695        // no arena buffer (copy #1 removed). Else the buffered path below.
8696        if in_place {
8697            let entry_offset = self.arena_alloc::<SubInplaceEntry<M, F>>()?;
8698            unsafe {
8699                let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8700                let entry_ptr = arena_ptr.add(entry_offset) as *mut SubInplaceEntry<M, F>;
8701                core::ptr::write(
8702                    entry_ptr,
8703                    SubInplaceEntry {
8704                        handle,
8705                        callback,
8706                        age_mon,
8707                        _phantom: PhantomData,
8708                    },
8709                );
8710            }
8711            let meta = CallbackMeta {
8712                offset: entry_offset,
8713                kind: EntryKind::Subscription,
8714                arena_len: super::arena::ArenaLen::UNRECORDED,
8715                try_process: sub_inplace_try_process::<M, F>,
8716                has_data: sub_inplace_has_data::<M, F>,
8717                pre_sample: no_pre_sample,
8718                invocation: InvocationMode::OnNewData,
8719                drop_fn: drop_entry::<SubInplaceEntry<M, F>>,
8720            };
8721            self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8722            // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
8723            // SAFETY: a `SubInplaceEntry<M, F>` was written at `entry_offset` just above.
8724            unsafe {
8725                self.arm_entry_wake::<SubInplaceEntry<M, F>>(slot, entry_offset, |e, w| {
8726                    nros_rmw::Subscription::register_waker(&e.handle, w)
8727                })
8728            };
8729            self.apply_node_default_sched(slot, Some(node_id), group);
8730            return Ok(self.handle_for(slot));
8731        }
8732
8733        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
8734
8735        let (entry_offset, trailing_offset) =
8736            self.arena_alloc_with_trailing::<Entry<M, F>>(trailing_bytes)?;
8737
8738        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
8739
8740        let buffer = if qos.depth <= 1 {
8741            BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
8742        } else {
8743            BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
8744        };
8745
8746        unsafe {
8747            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8748            let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<M, F>;
8749            core::ptr::write(
8750                entry_ptr,
8751                Entry {
8752                    handle,
8753                    buffer,
8754                    callback,
8755                    age_mon,
8756                    _phantom: PhantomData,
8757                },
8758            );
8759        }
8760
8761        let meta = CallbackMeta {
8762            offset: entry_offset,
8763            kind: EntryKind::Subscription,
8764            arena_len: super::arena::ArenaLen::UNRECORDED,
8765            try_process: sub_buffered_try_process::<M, F>,
8766            has_data: sub_buffered_has_data::<M, F>,
8767            pre_sample: no_pre_sample,
8768            invocation: InvocationMode::OnNewData,
8769            drop_fn: drop_entry::<Entry<M, F>>,
8770        };
8771        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8772        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
8773        // SAFETY: a `Entry<M, F>` was written at `entry_offset` just above.
8774        unsafe {
8775            self.arm_entry_wake::<Entry<M, F>>(slot, entry_offset, |e, w| {
8776                nros_rmw::Subscription::register_waker(&e.handle, w)
8777            })
8778        };
8779        // Phase 104.C.4 — apply Node's default SchedContext.
8780        self.apply_node_default_sched(slot, Some(node_id), group);
8781        Ok(self.handle_for(slot))
8782    }
8783
8784    /// Generic (type-erased) buffered subscription core (the
8785    /// `node_mut(id).subscription(t).generic(ty, hash)` builder lowers here).
8786    /// Routes the subscriber creation through the [`NodeId`]'s
8787    /// session + identity (rclcpp `add_node` pattern).
8788    ///
8789    /// Use this in bridge code where two Nodes bind to different RMW
8790    /// backends:
8791    ///
8792    /// ```ignore
8793    /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
8794    /// let pub_out = exec.with_node(node_out, |n| {
8795    ///     n.create_publisher_raw("/fwd", TYPE, HASH)
8796    /// })??;
8797    /// exec.register_subscription_buffered_raw_on::<_, 1024>(
8798    ///     node_in, "/src", TYPE, HASH, qos(), None,
8799    ///     move |bytes: &[u8]| { let _ = pub_out.publish_serialized(bytes); },
8800    /// )?;
8801    /// ```
8802    #[allow(clippy::too_many_arguments)]
8803    pub(crate) fn register_subscription_buffered_raw_on<F, const RX_BUF: usize>(
8804        &mut self,
8805        node_id: super::node_record::NodeId,
8806        topic_name: &str,
8807        type_name: &str,
8808        type_hash: &str,
8809        qos: QoSProfile,
8810        backend_hint: Option<usize>,
8811        callback: F,
8812    ) -> Result<HandleId, NodeError>
8813    where
8814        F: FnMut(&[u8]) + 'static,
8815    {
8816        // Issue #52 — fold the node's baked overrides for this topic before the
8817        // backend-compat check runs inside `create_subscription`.
8818        //
8819        // **This is the one registration that does it**, which phase-456 W8
8820        // noticed while collapsing the twelve prologues into one and did NOT
8821        // change: the other eleven reach `create_subscription` with the QoS
8822        // their caller passed. Whether that is a defect or a deliberate limit
8823        // of the generic bridge path is a question with a measurable answer and
8824        // no answer here, so the asymmetry stays where it was, stated.
8825        let overrides = self
8826            .nodes
8827            .get(node_id.index())
8828            .ok_or(NodeError::InvalidSchedContextBinding)?
8829            .qos_overrides;
8830        let qos = super::node_record::apply_qos_override_codes(
8831            qos,
8832            topic_name,
8833            nros_rmw::QoSOverrideRole::Subscription,
8834            overrides,
8835        );
8836
8837        let SubscriptionOpen {
8838            slot,
8839            handle,
8840            qos,
8841            in_place,
8842            slot_bytes,
8843        } = self.open_subscription(&SubscriptionRequest {
8844            node_id: Some(node_id),
8845            topic_name,
8846            type_name,
8847            type_hash,
8848            qos,
8849            // Issue 2208 -- the caller's statement of the type's bound. A
8850            // type-erased caller that knows the type (the declarative
8851            // runtime, from its entity metadata) states it; one that does not
8852            // passes `None`, "stated nothing".
8853            backend_hint,
8854            slot_bytes: RX_BUF,
8855            // Issue 1340 — `FnMut(&[u8])` IS a borrowed-bytes callback, which is
8856            // exactly what `process_raw_in_place` hands over, so this shape takes
8857            // the in-place row the way the C raw path does.
8858            //
8859            // This used to be `false` because the buffered entry is shared with
8860            // `Self::add_arena_subscription_callback` and "an in-place twin would
8861            // have to serve both". It does not: that caller supplies its own
8862            // subscriber and never reaches `open_subscription`, so no in-place
8863            // report can arrive there. The branch below is local to this entry
8864            // point, and the shared buffered emplace is untouched.
8865            //
8866            // phase-457 W5 (issue 1522) — spelled as the SHAPE rather than as a
8867            // literal, because the declarative registrar lowers here and the
8868            // metadata probe must state the same bool without registering. One
8869            // definition (`DeclaredSubscriptionShape::in_place_capable`), two
8870            // readers, so the row the build prices and the claim made here
8871            // moved together when it flipped.
8872            in_place_capable: super::declared_shape::DeclaredSubscriptionShape::BufferedRaw
8873                .in_place_capable(),
8874        })?;
8875
8876        if in_place {
8877            // No receive region: the backend's slot is the buffer for the
8878            // duration of the callback. This is the whole of issue 1340's saving
8879            // on the generic path — `buffered_region_size(depth, RX_BUF)` is
8880            // never reserved.
8881            type Entry<F> = super::arena::SubInplaceRawEntry<F>;
8882            let entry_offset = self.arena_alloc::<Entry<F>>()?;
8883            unsafe {
8884                let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8885                let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<F>;
8886                core::ptr::write(entry_ptr, Entry { handle, callback });
8887            }
8888            let meta = CallbackMeta {
8889                offset: entry_offset,
8890                kind: EntryKind::Subscription,
8891                arena_len: super::arena::ArenaLen::UNRECORDED,
8892                try_process: super::arena::sub_inplace_raw_try_process::<F>,
8893                has_data: super::arena::sub_inplace_raw_has_data::<F>,
8894                pre_sample: no_pre_sample,
8895                invocation: InvocationMode::OnNewData,
8896                drop_fn: drop_entry::<Entry<F>>,
8897            };
8898            // Same trace name as the buffered arm, so a trace reads the same
8899            // whichever dispatch the backend chose.
8900            self.emplace_entry(slot, meta, TraceName::Slot("sub", slot));
8901            // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
8902            // SAFETY: a `Entry<F>` was written at `entry_offset` just above.
8903            unsafe {
8904                self.arm_entry_wake::<Entry<F>>(slot, entry_offset, |e, w| {
8905                    nros_rmw::Subscription::register_waker(&e.handle, w)
8906                })
8907            };
8908        } else {
8909            self.emplace_raw_buffered_subscription(slot, handle, qos, slot_bytes, callback)?;
8910        }
8911        // Phase 104.C.4 — apply Node's default SchedContext.
8912        self.apply_node_default_sched(slot, Some(node_id), None);
8913        Ok(self.handle_for(slot))
8914    }
8915
8916    /// Register a borrowed (zero-copy) buffered subscription (Phase 229.6,
8917    /// issue 0007 / RFC-0033 `borrowed` mode).
8918    ///
8919    /// `B` is the code-generated borrowed-message marker (e.g. `ImageViewable`)
8920    /// implementing [`ViewableMessage`](nros_core::ViewableMessage); the
8921    /// callback receives `&B::View<'a>` — a lifetime-carrying message whose
8922    /// unbounded sequence/string fields borrow directly from the receive buffer
8923    /// (no `heapless::Vec` copy). The view is valid only for the callback's
8924    /// duration.
8925    ///
8926    /// **Triple-buffer only.** A borrowed view must reference exactly one
8927    /// well-defined buffer slot for the callback's duration; an SPSC ring
8928    /// (`qos.depth > 1`) keeps several samples in flight with no single such
8929    /// slot. `qos.depth > 1` is therefore rejected with
8930    /// [`TransportError::Unsupported`].
8931    pub(crate) fn register_subscription_buffered_borrowed_on<B, F, const RX_BUF: usize>(
8932        &mut self,
8933        node_id: super::node_record::NodeId,
8934        topic_name: &str,
8935        qos: QoSProfile,
8936        callback: F,
8937    ) -> Result<HandleId, NodeError>
8938    where
8939        B: nros_core::ViewableMessage + 'static,
8940        F: for<'a> FnMut(&B::View<'a>) + 'static,
8941    {
8942        type Entry<B, F> = SubBufferedViewEntry<B, F>;
8943
8944        // Borrowed views require a single well-defined slot (triple buffer).
8945        if qos.depth > 1 {
8946            return Err(NodeError::Transport(TransportError::Unsupported));
8947        }
8948
8949        let SubscriptionOpen { slot, handle, .. } =
8950            self.open_subscription(&SubscriptionRequest {
8951                node_id: Some(node_id),
8952                topic_name,
8953                type_name: <B as ViewableMessage>::TYPE_NAME,
8954                type_hash: <B as ViewableMessage>::TYPE_HASH,
8955                qos,
8956                backend_hint: None,
8957                slot_bytes: RX_BUF,
8958                // The view borrows the arena's own triple-buffer slot for the
8959                // callback's duration and `ViewableMessage::deserialize_view` is
8960                // what produces it, so there is no `FnMut(&[u8])` here to hand a
8961                // backend slot to. Making this shape in-place is a real option and
8962                // a separate change: it would move the borrow from the arena to the
8963                // backend's ring, which is a lifetime argument this item did not
8964                // make.
8965                in_place_capable: false,
8966            })?;
8967
8968        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, RX_BUF);
8969        let (entry_offset, trailing_offset) =
8970            self.arena_alloc_with_trailing::<Entry<B, F>>(trailing_bytes)?;
8971        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
8972
8973        // depth <= 1 guaranteed above → always triple buffer.
8974        let buffer = BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, RX_BUF) });
8975
8976        unsafe {
8977            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8978            let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<B, F>;
8979            core::ptr::write(
8980                entry_ptr,
8981                Entry {
8982                    handle,
8983                    buffer,
8984                    callback,
8985                    _phantom: PhantomData,
8986                },
8987            );
8988        }
8989
8990        let meta = CallbackMeta {
8991            offset: entry_offset,
8992            kind: EntryKind::Subscription,
8993            arena_len: super::arena::ArenaLen::UNRECORDED,
8994            try_process: sub_buffered_view_try_process::<B, F>,
8995            has_data: sub_buffered_view_has_data::<B, F>,
8996            pre_sample: no_pre_sample,
8997            invocation: InvocationMode::OnNewData,
8998            drop_fn: drop_entry::<Entry<B, F>>,
8999        };
9000        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9001        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9002        // SAFETY: a `Entry<B, F>` was written at `entry_offset` just above.
9003        unsafe {
9004            self.arm_entry_wake::<Entry<B, F>>(slot, entry_offset, |e, w| {
9005                nros_rmw::Subscription::register_waker(&e.handle, w)
9006            })
9007        };
9008        self.apply_node_default_sched(slot, Some(node_id), None);
9009        Ok(self.handle_for(slot))
9010    }
9011
9012    /// Register a raw (type-erased) buffered subscription whose callback
9013    /// also receives a [`RawMessageInfo`](nros_core::RawMessageInfo)
9014    /// carrying the sample's wire **attachment** (Phase 189.M1).
9015    ///
9016    /// Backs the `node.subscription(t).generic(..).message_info().build(cb)`
9017    /// builder — the cross-RMW bridge reads the bridge_origin tag from
9018    /// `info.attachment()` for echo suppression. One sample per
9019    /// `spin_once`; the attachment is staged in a flat per-entry buffer
9020    /// (cap `RAW_INFO_ATT_CAP`).
9021    pub fn register_subscription_buffered_raw_info_on<F, const RX_BUF: usize>(
9022        &mut self,
9023        node_id: super::node_record::NodeId,
9024        topic_name: &str,
9025        type_name: &str,
9026        type_hash: &str,
9027        qos: QoSProfile,
9028        callback: F,
9029    ) -> Result<HandleId, NodeError>
9030    where
9031        F: FnMut(&[u8], &nros_core::RawMessageInfo) + 'static,
9032    {
9033        type Entry<F> = SubBufferedRawInfoEntry<F>;
9034
9035        let SubscriptionOpen {
9036            slot,
9037            handle,
9038            slot_bytes,
9039            ..
9040        } = self.open_subscription(&SubscriptionRequest {
9041            node_id: Some(node_id),
9042            topic_name,
9043            type_name,
9044            type_hash,
9045            qos,
9046            backend_hint: None,
9047            slot_bytes: RX_BUF,
9048            // The callback also receives the sample's wire ATTACHMENT, which
9049            // `process_raw_in_place` does not carry: it hands over borrowed
9050            // payload bytes and nothing else. Not a limitation of the backend.
9051            in_place_capable: false,
9052        })?;
9053
9054        // Issue 1860 -- in the trailing region, as the C twin. A raw subscription
9055        // has no type to derive from, so the size is still `RX_BUF`.
9056        let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<F>>(slot_bytes)?;
9057        unsafe {
9058            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9059            let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
9060            let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
9061            core::ptr::write(
9062                entry_ptr,
9063                Entry {
9064                    handle,
9065                    buffer,
9066                    att: [0u8; super::arena::RAW_INFO_ATT_CAP],
9067                    callback,
9068                },
9069            );
9070        }
9071
9072        let meta = CallbackMeta {
9073            offset,
9074            kind: EntryKind::Subscription,
9075            arena_len: super::arena::ArenaLen::UNRECORDED,
9076            try_process: sub_buffered_raw_info_try_process::<F>,
9077            has_data: sub_buffered_raw_info_has_data::<F>,
9078            pre_sample: no_pre_sample,
9079            invocation: InvocationMode::OnNewData,
9080            drop_fn: drop_entry::<Entry<F>>,
9081        };
9082        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9083        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9084        // SAFETY: a `Entry<F>` was written at `offset` just above.
9085        unsafe {
9086            self.arm_entry_wake::<Entry<F>>(slot, offset, |e, w| {
9087                nros_rmw::Subscription::register_waker(&e.handle, w)
9088            })
9089        };
9090        self.apply_node_default_sched(slot, Some(node_id), None);
9091        Ok(self.handle_for(slot))
9092    }
9093
9094    /// Phase 250 (Wave 2) — register a generic (type-erased) raw subscription
9095    /// that surfaces E2E [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC +
9096    /// sequence gap/dup) alongside the raw CDR bytes
9097    /// (`FnMut(&[u8], &IntegrityStatus)`). The type-erased analog of
9098    /// `register_subscription_with_safety_sized_inner`: the validator lives in
9099    /// the `RmwSubscriber` (`take_validated`), so the subscriber is created
9100    /// plainly and no `register_type::<M>()` is needed (the declarative `Node`
9101    /// path is generic). Used by the declarative runtime's `.safety()` opt-in.
9102    #[cfg(feature = "safety-e2e")]
9103    #[allow(clippy::too_many_arguments)]
9104    pub fn register_subscription_buffered_raw_safety_on<F, const RX_BUF: usize>(
9105        &mut self,
9106        node_id: super::node_record::NodeId,
9107        topic_name: &str,
9108        type_name: &str,
9109        type_hash: &str,
9110        qos: QoSProfile,
9111        backend_hint: Option<usize>,
9112        callback: F,
9113    ) -> Result<HandleId, NodeError>
9114    where
9115        F: FnMut(&[u8], &nros_rmw::IntegrityStatus) + 'static,
9116    {
9117        use super::arena::{
9118            SubBufferedRawSafetyEntry, sub_buffered_raw_safety_has_data,
9119            sub_buffered_raw_safety_try_process,
9120        };
9121        type Entry<F> = SubBufferedRawSafetyEntry<F>;
9122
9123        let SubscriptionOpen {
9124            slot,
9125            handle,
9126            slot_bytes,
9127            ..
9128        } = self.open_subscription(&SubscriptionRequest {
9129            node_id: Some(node_id),
9130            topic_name,
9131            type_name,
9132            type_hash,
9133            qos,
9134            backend_hint, // Issue 2208 -- as the basic raw path.
9135            slot_bytes: RX_BUF,
9136            // The sample is taken through `take_validated`, which computes the
9137            // integrity status as it copies. `process_raw_in_place` has no
9138            // validating form, so this shape cannot use it.
9139            //
9140            // phase-457 W5 (issue 1522) — as the SHAPE, for the reason its
9141            // sibling in `register_subscription_buffered_raw_on` gives.
9142            in_place_capable: super::declared_shape::DeclaredSubscriptionShape::BufferedRawSafety
9143                .in_place_capable(),
9144        })?;
9145
9146        // Issue 1860 -- in the trailing region; untyped, so still `RX_BUF` bytes.
9147        let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<F>>(slot_bytes)?;
9148        unsafe {
9149            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9150            let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
9151            let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
9152            core::ptr::write(
9153                entry_ptr,
9154                Entry {
9155                    handle,
9156                    buffer,
9157                    callback,
9158                },
9159            );
9160        }
9161
9162        let meta = CallbackMeta {
9163            offset,
9164            kind: EntryKind::Subscription,
9165            arena_len: super::arena::ArenaLen::UNRECORDED,
9166            try_process: sub_buffered_raw_safety_try_process::<F>,
9167            has_data: sub_buffered_raw_safety_has_data::<F>,
9168            pre_sample: no_pre_sample,
9169            invocation: InvocationMode::OnNewData,
9170            drop_fn: drop_entry::<Entry<F>>,
9171        };
9172        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9173        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9174        // SAFETY: a `Entry<F>` was written at `offset` just above.
9175        unsafe {
9176            self.arm_entry_wake::<Entry<F>>(slot, offset, |e, w| {
9177                nros_rmw::Subscription::register_waker(&e.handle, w)
9178            })
9179        };
9180        self.apply_node_default_sched(slot, Some(node_id), None);
9181        Ok(self.handle_for(slot))
9182    }
9183
9184    /// Register a raw byte-shaped callback against a pre-built
9185    /// `RmwSubscriber` handle.
9186    ///
9187    /// Backend-agnostic primitive — the caller is responsible for
9188    /// obtaining the handle by whatever route the active backend
9189    /// supports:
9190    ///
9191    /// - **Generic ROS-typed flow**: call `Session::create_subscription`
9192    ///   on `self.session_mut()` with a [`TopicInfo`]. The
9193    ///   `node_mut(id).subscription(t).generic(ty, hash)` builder is the
9194    ///   convenience wrapper for this path.
9195    /// - **Backend-specific flow** (e.g. uORB needs `&'static orb_metadata`):
9196    ///   reach into the concrete session via [`Self::session_mut`] and
9197    ///   call its backend-specific create method, then hand the handle
9198    ///   here. `nros-px4::uorb::create_subscription_with_callback` is
9199    ///   the example.
9200    ///
9201    /// The arena-store + vtable wiring is identical to
9202    /// `register_subscription_buffered_raw_on`; the only thing that varies is
9203    /// where the handle came from. Callback fires on every message
9204    /// delivery during [`spin_once`](Self::spin_once); bytes are
9205    /// passed as `&[u8]`.
9206    pub fn add_arena_subscription_callback<F, const RX_BUF: usize>(
9207        &mut self,
9208        handle: session::RmwSubscriber,
9209        qos: QoSProfile,
9210        callback: F,
9211    ) -> Result<HandleId, NodeError>
9212    where
9213        F: FnMut(&[u8]) + 'static,
9214    {
9215        let slot = self.next_entry_slot()?;
9216        self.emplace_raw_buffered_subscription(slot, handle, qos, RX_BUF, callback)?;
9217        Ok(self.handle_for(slot))
9218    }
9219
9220    /// Write a raw buffered subscription entry into an already-claimed slot —
9221    /// phase-456 W8.
9222    ///
9223    /// The shared tail of [`Self::add_arena_subscription_callback`] and
9224    /// `register_subscription_buffered_raw_on`, which differ only in where the
9225    /// subscriber came from: one is handed a live `RmwSubscriber`, the other
9226    /// resolves a topic through [`Self::open_subscription`] and gets one back.
9227    /// Both then build exactly this entry, and they used to build it twice —
9228    /// the bridge path by CALLING the other, which meant it claimed a slot,
9229    /// resolved a topic, and then let the callee claim a second slot it did not
9230    /// need.
9231    fn emplace_raw_buffered_subscription<F>(
9232        &mut self,
9233        slot: usize,
9234        handle: session::RmwSubscriber,
9235        qos: QoSProfile,
9236        slot_bytes: usize,
9237        callback: F,
9238    ) -> Result<(), NodeError>
9239    where
9240        F: FnMut(&[u8]) + 'static,
9241    {
9242        type Entry<F> = SubBufferedRawEntry<F>;
9243
9244        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
9245
9246        let (entry_offset, trailing_offset) =
9247            self.arena_alloc_with_trailing::<Entry<F>>(trailing_bytes)?;
9248
9249        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
9250
9251        let buffer = if qos.depth <= 1 {
9252            BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
9253        } else {
9254            BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
9255        };
9256
9257        unsafe {
9258            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9259            let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<F>;
9260            core::ptr::write(
9261                entry_ptr,
9262                Entry {
9263                    handle,
9264                    buffer,
9265                    callback,
9266                },
9267            );
9268        }
9269
9270        let meta = CallbackMeta {
9271            offset: entry_offset,
9272            kind: EntryKind::Subscription,
9273            arena_len: super::arena::ArenaLen::UNRECORDED,
9274            try_process: sub_buffered_raw_try_process::<F>,
9275            has_data: sub_buffered_raw_has_data::<F>,
9276            pre_sample: no_pre_sample,
9277            invocation: InvocationMode::OnNewData,
9278            drop_fn: drop_entry::<Entry<F>>,
9279        };
9280        self.emplace_entry(slot, meta, TraceName::Slot("sub", slot));
9281        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9282        // SAFETY: a `Entry<F>` was written at `entry_offset` just above.
9283        unsafe {
9284            self.arm_entry_wake::<Entry<F>>(slot, entry_offset, |e, w| {
9285                nros_rmw::Subscription::register_waker(&e.handle, w)
9286            })
9287        };
9288        Ok(())
9289    }
9290
9291    pub(crate) fn register_subscription_with_info_sized_inner<M, F, const RX_BUF: usize>(
9292        &mut self,
9293        node_id: Option<super::node_record::NodeId>,
9294        topic_name: &str,
9295        qos: QoSProfile,
9296        callback: F,
9297        // Issue 1860 -- `Some(n)` is a size the caller NAMED (`.rx_buffer::<N>()`),
9298        // used verbatim; `None` derives it from `M` exactly as the buffered path
9299        // does, falling back to `RX_BUF` only where `M` states no bound.
9300        rx_bytes: Option<usize>,
9301    ) -> Result<HandleId, NodeError>
9302    where
9303        M: crate::rmw_type_registry::MessageForRmw + 'static,
9304        F: FnMut(&M, Option<&nros_core::MessageInfo>) + 'static,
9305    {
9306        type Entry<M, F> = SubInfoEntry<M, F>;
9307
9308        // Phase 212.K.7.6.b — see `create_publisher_on`.
9309        crate::rmw_type_registry::register_type::<M>()?;
9310
9311        let SubscriptionOpen {
9312            slot,
9313            handle,
9314            slot_bytes,
9315            ..
9316        } = self.open_subscription(&SubscriptionRequest {
9317            node_id,
9318            topic_name,
9319            type_name: <M as RosMessage>::TYPE_NAME,
9320            type_hash: <M as RosMessage>::TYPE_HASH,
9321            qos,
9322            backend_hint: None,
9323            slot_bytes: crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF),
9324            // The entry's `pre_sample` hook stages a `MessageInfo` beside the
9325            // payload before the callback runs; an in-place drain has no
9326            // pre-sample step to hang it on.
9327            in_place_capable: false,
9328        })?;
9329
9330        let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<M, F>>(slot_bytes)?;
9331
9332        unsafe {
9333            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9334            let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
9335            let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F>;
9336            core::ptr::write(
9337                entry_ptr,
9338                Entry {
9339                    handle,
9340                    buffer,
9341                    sampled_len: 0,
9342                    callback,
9343                    _phantom: PhantomData,
9344                },
9345            );
9346        }
9347
9348        let meta = CallbackMeta {
9349            offset,
9350            kind: EntryKind::Subscription,
9351            arena_len: super::arena::ArenaLen::UNRECORDED,
9352            try_process: sub_info_try_process::<M, F>,
9353            has_data: sub_info_has_data::<M, F>,
9354            pre_sample: sub_info_pre_sample::<M, F>,
9355            invocation: InvocationMode::OnNewData,
9356            drop_fn: drop_entry::<Entry<M, F>>,
9357        };
9358        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9359        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9360        // SAFETY: a `Entry<M, F>` was written at `offset` just above.
9361        unsafe {
9362            self.arm_entry_wake::<Entry<M, F>>(slot, offset, |e, w| {
9363                nros_rmw::Subscription::register_waker(&e.handle, w)
9364            })
9365        };
9366        self.apply_node_default_sched(slot, node_id, None);
9367        Ok(self.handle_for(slot))
9368    }
9369
9370    #[cfg(feature = "safety-e2e")]
9371    pub(crate) fn register_subscription_with_safety_sized_inner<M, F, const RX_BUF: usize>(
9372        &mut self,
9373        node_id: Option<super::node_record::NodeId>,
9374        topic_name: &str,
9375        qos: QoSProfile,
9376        callback: F,
9377        // Issue 1860 -- as `register_subscription_with_info_sized_inner`.
9378        rx_bytes: Option<usize>,
9379    ) -> Result<HandleId, NodeError>
9380    where
9381        M: crate::rmw_type_registry::MessageForRmw + 'static,
9382        F: FnMut(&M, &nros_rmw::IntegrityStatus) + 'static,
9383    {
9384        type Entry<M, F> = SubSafetyEntry<M, F>;
9385
9386        // Phase 212.K.7.6.b — see `create_publisher_on`.
9387        crate::rmw_type_registry::register_type::<M>()?;
9388
9389        let SubscriptionOpen {
9390            slot,
9391            handle,
9392            slot_bytes,
9393            ..
9394        } = self.open_subscription(&SubscriptionRequest {
9395            node_id,
9396            topic_name,
9397            type_name: <M as RosMessage>::TYPE_NAME,
9398            type_hash: <M as RosMessage>::TYPE_HASH,
9399            qos,
9400            backend_hint: None,
9401            slot_bytes: crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF),
9402            // Same as the raw safety sibling: the status comes out of
9403            // `take_validated`, which is a copying take.
9404            in_place_capable: false,
9405        })?;
9406
9407        let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<M, F>>(slot_bytes)?;
9408
9409        unsafe {
9410            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9411            let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
9412            let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F>;
9413            core::ptr::write(
9414                entry_ptr,
9415                Entry {
9416                    handle,
9417                    buffer,
9418                    sampled_len: 0,
9419                    callback,
9420                    _phantom: PhantomData,
9421                },
9422            );
9423        }
9424
9425        let meta = CallbackMeta {
9426            offset,
9427            kind: EntryKind::Subscription,
9428            arena_len: super::arena::ArenaLen::UNRECORDED,
9429            try_process: sub_safety_try_process::<M, F>,
9430            has_data: sub_safety_has_data::<M, F>,
9431            pre_sample: sub_safety_pre_sample::<M, F>,
9432            invocation: InvocationMode::OnNewData,
9433            drop_fn: drop_entry::<Entry<M, F>>,
9434        };
9435        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9436        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9437        // SAFETY: a `Entry<M, F>` was written at `offset` just above.
9438        unsafe {
9439            self.arm_entry_wake::<Entry<M, F>>(slot, offset, |e, w| {
9440                nros_rmw::Subscription::register_waker(&e.handle, w)
9441            })
9442        };
9443        self.apply_node_default_sched(slot, node_id, None);
9444        Ok(self.handle_for(slot))
9445    }
9446
9447    /// Register a service callback with the default buffer size.
9448    ///
9449    /// The callback is stored in the arena and invoked during [`spin_once()`](Self::spin_once).
9450    pub fn register_service<Svc, F>(
9451        &mut self,
9452        service_name: &str,
9453        callback: F,
9454    ) -> Result<HandleId, NodeError>
9455    where
9456        Svc: RosService + 'static,
9457        Svc::Request: crate::rmw_type_registry::MessageForRmw,
9458        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9459        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
9460    {
9461        self.register_service_sized::<Svc, F, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, callback)
9462    }
9463
9464    /// Register a service callback with custom request/reply buffer sizes.
9465    pub fn register_service_sized<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
9466        &mut self,
9467        service_name: &str,
9468        callback: F,
9469    ) -> Result<HandleId, NodeError>
9470    where
9471        Svc: RosService + 'static,
9472        Svc::Request: crate::rmw_type_registry::MessageForRmw,
9473        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9474        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
9475    {
9476        type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
9477
9478        // Phase 212.K.7.7.b — register both halves of the service round-trip
9479        // under cyclonedds. No-op for other RMWs. Mirrors the K.7.6.b hook
9480        // on `Node::create_service_sized`.
9481        crate::rmw_type_registry::register_type::<Svc::Request>()?;
9482        crate::rmw_type_registry::register_type::<Svc::Reply>()?;
9483
9484        let slot = self.next_entry_slot()?;
9485        let node_name: heapless::String<64> = self.node_name.clone();
9486        let ns: heapless::String<64> = self.namespace.clone();
9487        let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
9488            .with_domain(self.domain_id)
9489            .with_namespace(&ns);
9490        if !node_name.is_empty() {
9491            info = info.with_node_name(&node_name);
9492        }
9493        let handle = self
9494            .session
9495            .create_service(&info, QoSProfile::services_default())
9496            .map_err(NodeError::Transport)?;
9497
9498        let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
9499
9500        // SAFETY: same guarantees as register_subscription_sized.
9501        unsafe {
9502            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9503            let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
9504            core::ptr::write(
9505                entry_ptr,
9506                Entry {
9507                    handle,
9508                    req_buffer: [0u8; REQ_BUF],
9509                    reply_buffer: [0u8; REPLY_BUF],
9510                    callback,
9511                    _phantom: PhantomData,
9512                },
9513            );
9514        }
9515
9516        let meta = CallbackMeta {
9517            offset,
9518            kind: EntryKind::Service,
9519            arena_len: super::arena::ArenaLen::UNRECORDED,
9520            try_process: srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
9521            has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
9522            pre_sample: no_pre_sample,
9523            invocation: InvocationMode::OnNewData,
9524            drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
9525        };
9526        self.emplace_entry(slot, meta, TraceName::Text(service_name));
9527        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9528        // SAFETY: a `Entry<Svc, F, REQ_BUF, REPLY_BUF>` was written at `offset` just above.
9529        unsafe {
9530            self.arm_entry_wake::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>(slot, offset, |e, w| {
9531                nros_rmw::ServiceTrait::register_waker(&e.handle, w)
9532            })
9533        };
9534        Ok(self.handle_for(slot))
9535    }
9536
9537    /// Phase 104.C.3.3.a — Node-aware variant of
9538    /// [`register_service_sized`](Self::register_service_sized).
9539    pub fn register_service_sized_on<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
9540        &mut self,
9541        node_id: super::node_record::NodeId,
9542        service_name: &str,
9543        qos: QoSProfile,
9544        callback: F,
9545    ) -> Result<HandleId, NodeError>
9546    where
9547        Svc: RosService + 'static,
9548        Svc::Request: crate::rmw_type_registry::MessageForRmw,
9549        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9550        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
9551    {
9552        self.register_service_sized_on_inner::<Svc, F, REQ_BUF, REPLY_BUF>(
9553            node_id,
9554            service_name,
9555            qos,
9556            callback,
9557            srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
9558        )
9559    }
9560
9561    /// Issue 1942 — [`register_service_sized_on`](Self::register_service_sized_on)
9562    /// for a callback that also receives the request's
9563    /// [`ServiceInfo`](nros_core::ServiceInfo) — rclrs's `(request, ServiceInfo)`
9564    /// service callback. A field the backend cannot report arrives as `None`.
9565    pub fn register_service_with_info_sized_on<
9566        Svc,
9567        F,
9568        const REQ_BUF: usize,
9569        const REPLY_BUF: usize,
9570    >(
9571        &mut self,
9572        node_id: super::node_record::NodeId,
9573        service_name: &str,
9574        qos: QoSProfile,
9575        callback: F,
9576    ) -> Result<HandleId, NodeError>
9577    where
9578        Svc: RosService + 'static,
9579        Svc::Request: crate::rmw_type_registry::MessageForRmw,
9580        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9581        F: FnMut(&Svc::Request, &nros_core::ServiceInfo) -> Svc::Reply + 'static,
9582    {
9583        self.register_service_sized_on_inner::<Svc, F, REQ_BUF, REPLY_BUF>(
9584            node_id,
9585            service_name,
9586            qos,
9587            callback,
9588            srv_try_process_with_info::<Svc, F, REQ_BUF, REPLY_BUF>,
9589        )
9590    }
9591
9592    /// The one body behind both callback shapes: they share [`SrvEntry`] and
9593    /// differ only in the dispatch function.
9594    fn register_service_sized_on_inner<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
9595        &mut self,
9596        node_id: super::node_record::NodeId,
9597        service_name: &str,
9598        qos: QoSProfile,
9599        callback: F,
9600        try_process: unsafe fn(*mut u8, u64, u8) -> Result<bool, TransportError>,
9601    ) -> Result<HandleId, NodeError>
9602    where
9603        Svc: RosService + 'static,
9604        Svc::Request: crate::rmw_type_registry::MessageForRmw,
9605        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9606        F: 'static,
9607    {
9608        type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
9609
9610        // Phase 212.K.7.7.b — see `register_service_sized`.
9611        crate::rmw_type_registry::register_type::<Svc::Request>()?;
9612        crate::rmw_type_registry::register_type::<Svc::Reply>()?;
9613
9614        let slot = self.next_entry_slot()?;
9615        let (node_name, ns, session_idx) = {
9616            let r = self
9617                .nodes
9618                .get(node_id.index())
9619                .ok_or(NodeError::InvalidSchedContextBinding)?;
9620            (r.name.clone(), r.namespace.clone(), r.session_idx)
9621        };
9622        let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
9623            .with_domain(self.domain_id)
9624            .with_namespace(&ns);
9625        if !node_name.is_empty() {
9626            info = info.with_node_name(&node_name);
9627        }
9628        let handle = {
9629            let session = self
9630                .session_at_mut(session_idx)
9631                .ok_or(NodeError::BackendMismatch)?;
9632            // Phase 193.5 — validate against the backend's supported policies
9633            // (no silent downgrade); request/reply effectively requires RELIABLE.
9634            super::node::validate_qos_or_report(
9635                &qos,
9636                session.supported_qos_policies(),
9637                "service",
9638                service_name,
9639            )?;
9640            session
9641                .create_service(&info, qos)
9642                .map_err(NodeError::Transport)?
9643        };
9644
9645        let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
9646        unsafe {
9647            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9648            let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
9649            core::ptr::write(
9650                entry_ptr,
9651                Entry {
9652                    handle,
9653                    req_buffer: [0u8; REQ_BUF],
9654                    reply_buffer: [0u8; REPLY_BUF],
9655                    callback,
9656                    _phantom: PhantomData,
9657                },
9658            );
9659        }
9660
9661        let meta = CallbackMeta {
9662            offset,
9663            kind: EntryKind::Service,
9664            arena_len: super::arena::ArenaLen::UNRECORDED,
9665            try_process,
9666            has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
9667            pre_sample: no_pre_sample,
9668            invocation: InvocationMode::OnNewData,
9669            drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
9670        };
9671        self.emplace_entry(slot, meta, TraceName::Text(service_name));
9672        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9673        // SAFETY: a `Entry<Svc, F, REQ_BUF, REPLY_BUF>` was written at `offset` just above.
9674        unsafe {
9675            self.arm_entry_wake::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>(slot, offset, |e, w| {
9676                nros_rmw::ServiceTrait::register_waker(&e.handle, w)
9677            })
9678        };
9679        self.apply_node_default_sched(slot, Some(node_id), None);
9680        Ok(self.handle_for(slot))
9681    }
9682
9683    /// Phase 104.C.3.3.a — Node-aware variant of
9684    /// [`register_service`](Self::register_service).
9685    pub fn register_service_on<Svc, F>(
9686        &mut self,
9687        node_id: super::node_record::NodeId,
9688        service_name: &str,
9689        callback: F,
9690    ) -> Result<HandleId, NodeError>
9691    where
9692        Svc: RosService + 'static,
9693        Svc::Request: crate::rmw_type_registry::MessageForRmw,
9694        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9695        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
9696    {
9697        self.register_service_sized_on::<
9698            Svc,
9699            F,
9700            { crate::config::DEFAULT_RX_BUF_SIZE },
9701            { crate::config::DEFAULT_RX_BUF_SIZE },
9702        >(node_id, service_name, QoSProfile::services_default(), callback)
9703    }
9704
9705    // ========================================================================
9706    // Timer registration
9707    // ========================================================================
9708
9709    /// The ONE timer registration in the tree (phase-430 W4).
9710    ///
9711    /// Every timer verb — the four `Executor::register_timer*` entry points
9712    /// below, `Node::create_timer_in_group` / `create_timer_on_clock*`, the
9713    /// declarative `nros::Node` timer, and the C/C++ FFI that lower to them —
9714    /// constructs its arena entry HERE. Until W4 there were four copies of this
9715    /// body differing only in `oneshot`, `clock_source` and whether the sched
9716    /// binding was applied, and phase-425's clock axis reached exactly one of
9717    /// them; that is how a `nros::main!` component could declare a timer but
9718    /// not a ROS-time one.
9719    ///
9720    /// `node_id` / `group` feed `apply_node_default_sched` (phase 273: group
9721    /// table > node default > SC 0). `node_id == None` is the legacy unscoped
9722    /// path and makes that call a no-op.
9723    pub(crate) fn register_timer_entry<F>(
9724        &mut self,
9725        node_id: Option<super::node_record::NodeId>,
9726        period: TimerDuration,
9727        source: TimerClockSource,
9728        oneshot: bool,
9729        group: Option<&str>,
9730        callback: F,
9731    ) -> Result<HandleId, NodeError>
9732    where
9733        F: FnMut() + 'static,
9734    {
9735        let mut callback = callback;
9736        self.register_timer_entry_with(
9737            node_id,
9738            period,
9739            source,
9740            oneshot,
9741            true,
9742            group,
9743            None,
9744            core::ptr::null_mut(),
9745            move |_| move |_: &TimerCallInfo| callback(),
9746        )
9747    }
9748
9749    /// [`register_timer_entry`](Self::register_timer_entry), with a CAPTURE
9750    /// the arena holds — phase-476 W2.
9751    ///
9752    /// The callback is BUILT after the allocation, by `make`, from the context
9753    /// pointer the entry should dispatch with: `place_capture`'s copy of
9754    /// `capture` when there is one, otherwise `context` unchanged. That is what
9755    /// lets the capture ride in the timer entry's own trailing region (so a
9756    /// release frees it) while the closure that reads it is still a plain
9757    /// `TimerEntry<F>`.
9758    ///
9759    /// Issue 1974 — the stored callback takes the dispatcher's
9760    /// [`TimerCallInfo`]; a plain `FnMut()` is adapted by ignoring it, so there
9761    /// is one entry type and one dispatch whichever flavour the caller wrote.
9762    /// `autostart == false` builds the entry CANCELLED (rcl's
9763    /// `rcl_timer_init2(..., autostart = false)`): it never fires until
9764    /// [`reset_timer`](Self::reset_timer) starts it.
9765    #[allow(clippy::too_many_arguments)]
9766    pub(crate) fn register_timer_entry_with<F, M>(
9767        &mut self,
9768        node_id: Option<super::node_record::NodeId>,
9769        period: TimerDuration,
9770        source: TimerClockSource,
9771        oneshot: bool,
9772        autostart: bool,
9773        group: Option<&str>,
9774        capture: Option<&[u8]>,
9775        context: *mut core::ffi::c_void,
9776        make: M,
9777    ) -> Result<HandleId, NodeError>
9778    where
9779        F: FnMut(&TimerCallInfo) + 'static,
9780        M: FnOnce(*mut core::ffi::c_void) -> F,
9781    {
9782        let slot = self.next_entry_slot()?;
9783        let capture_len = Self::capture_trailing_len(capture);
9784        // No capture keeps the exact pre-W2 allocation, so a timer that captures
9785        // nothing costs what it always did.
9786        let (offset, context) = if capture_len == 0 {
9787            (self.arena_alloc::<TimerEntry<F>>()?, context)
9788        } else {
9789            let (offset, at) = self.arena_alloc_with_trailing::<TimerEntry<F>>(capture_len)?;
9790            (offset, self.place_capture(at, capture, context))
9791        };
9792        let callback = make(context);
9793
9794        unsafe {
9795            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9796            let entry_ptr = arena_ptr.add(offset) as *mut TimerEntry<F>;
9797            core::ptr::write(
9798                entry_ptr,
9799                TimerEntry {
9800                    period_us: period.as_micros(),
9801                    elapsed_us: 0,
9802                    overruns: 0,
9803                    overruns_reported: 0,
9804                    oneshot,
9805                    fired: false,
9806                    cancelled: !autostart,
9807                    overrun_policy: TimerOverrunPolicy::default(),
9808                    clock_source: source,
9809                    // Seeded HERE rather than on the first poll: a zero would
9810                    // make the first delta the whole epoch, which `Skip` would
9811                    // then coalesce into one immediate activation and `CatchUp`
9812                    // into a replay burst of ~10^9 periods. `Steady` reads no
9813                    // clock at all, so its seed is 0 and is never read.
9814                    last_clock_ns: source.now_ns(),
9815                    callback,
9816                },
9817            );
9818        }
9819
9820        let meta = CallbackMeta {
9821            offset,
9822            kind: EntryKind::Timer,
9823            arena_len: super::arena::ArenaLen::UNRECORDED,
9824            try_process: timer_try_process::<F>,
9825            has_data: always_ready,
9826            pre_sample: no_pre_sample,
9827            invocation: InvocationMode::Always,
9828            drop_fn: drop_entry::<TimerEntry<F>>,
9829        };
9830        self.emplace_entry(slot, meta, TraceName::TimerPeriod(period.as_micros()));
9831        // Phase 273 — apply group sched binding (group > node default > SC 0).
9832        self.apply_node_default_sched(slot, node_id, group);
9833        Ok(self.handle_for(slot))
9834    }
9835
9836    /// Register a repeating timer callback.
9837    ///
9838    /// The callback fires every `period` milliseconds during [`spin_once()`](Self::spin_once).
9839    /// The timer delta is approximated by the `timeout_ms` argument to `spin_once`.
9840    pub fn register_timer<F>(
9841        &mut self,
9842        period: TimerDuration,
9843        callback: F,
9844    ) -> Result<HandleId, NodeError>
9845    where
9846        F: FnMut() + 'static,
9847    {
9848        self.register_timer_entry(
9849            None,
9850            period,
9851            TimerClockSource::Steady,
9852            false,
9853            None,
9854            callback,
9855        )
9856    }
9857
9858    /// Register a repeating timer driven by a CLOCK rather than by the spin
9859    /// delta — phase-425 W4, the shape rclcpp spells
9860    /// `create_timer(node, clock, period, cb)`.
9861    ///
9862    /// [`register_timer`](Self::register_timer) is the wall timer: it consumes
9863    /// the executor's monotonic spin delta and no simulator can slow it down.
9864    /// This one reads `source` on every poll and advances by the difference, so
9865    /// a [`TimerClockSource::Ros`] timer follows `/clock` — it stops while the
9866    /// simulator is paused, halves with a bag replayed at 0.5x, and restarts
9867    /// its period on a backwards jump instead of stalling for the length of it.
9868    ///
9869    /// With no `/clock` source installed, a `Ros` timer reads system time and
9870    /// behaves like a wall timer with NTP steps, which is the same fallback
9871    /// `rclcpp::Clock` has: a node written for simulation still runs standalone.
9872    ///
9873    /// The node-level spellings of this are `Node::create_timer_on_clock`
9874    /// and the declarative `DeclaredNode::create_timer_on_clock` (phase-430 W4);
9875    /// all of them reach `register_timer_entry`, which is the only place a
9876    /// timer entry is built.
9877    pub fn register_timer_on_clock<F>(
9878        &mut self,
9879        period: TimerDuration,
9880        source: TimerClockSource,
9881        callback: F,
9882    ) -> Result<HandleId, NodeError>
9883    where
9884        F: FnMut() + 'static,
9885    {
9886        self.register_timer_entry(None, period, source, false, None, callback)
9887    }
9888
9889    /// Register a one-shot timer callback.
9890    ///
9891    /// The callback fires once after `delay` milliseconds, then becomes inert.
9892    pub fn register_timer_oneshot<F>(
9893        &mut self,
9894        delay: TimerDuration,
9895        callback: F,
9896    ) -> Result<HandleId, NodeError>
9897    where
9898        F: FnMut() + 'static,
9899    {
9900        self.register_timer_entry(None, delay, TimerClockSource::Steady, true, None, callback)
9901    }
9902
9903    /// phase-476 W2 — register a timer whose callback is a C function pointer
9904    /// and whose CAPTURE the arena holds: the `nros-cpp` road for a C++
9905    /// lambda, the timer twin of
9906    /// [`add_arena_subscription_c_callback_with_capture`](Self::add_arena_subscription_c_callback_with_capture).
9907    ///
9908    /// `capture`'s bytes are copied into the timer entry's trailing region and
9909    /// `callback` is called with a pointer to that copy, so the caller keeps
9910    /// nothing — which is what lets the C++ side hand back a two-word handle
9911    /// instead of a heap cell the node had to keep alive. Releasing the timer
9912    /// releases the capture with it.
9913    ///
9914    /// `source` picks the clock: [`TimerClockSource::Steady`] is the wall timer
9915    /// that advances with the spin delta.
9916    ///
9917    /// `capture_drop`, when given, is called with the same pointer when the
9918    /// entry is dropped (released, or the executor torn down), BEFORE its bytes
9919    /// go back to the arena. A C++ capture is a callable that may own something
9920    /// (a `std::shared_ptr`, a `std::string`), and its bytes were MOVED here by
9921    /// copy, so this is the only place its destructor can run.
9922    pub(crate) fn register_timer_c_capturing(
9923        &mut self,
9924        node_id: Option<super::node_record::NodeId>,
9925        period: TimerDuration,
9926        source: TimerClockSource,
9927        callback: unsafe extern "C" fn(*mut core::ffi::c_void),
9928        capture: Option<&[u8]>,
9929        capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
9930    ) -> Result<HandleId, NodeError> {
9931        self.register_timer_entry_with(
9932            node_id,
9933            period,
9934            source,
9935            false,
9936            true,
9937            None,
9938            capture,
9939            core::ptr::null_mut(),
9940            move |ctx| {
9941                let held = super::arena::CapturedCallback {
9942                    callback,
9943                    guard: super::arena::CaptureGuard {
9944                        ctx,
9945                        drop: capture_drop,
9946                    },
9947                };
9948                move |_: &TimerCallInfo| held.call()
9949            },
9950        )
9951    }
9952
9953    /// [`register_timer_c_capturing`](Self::register_timer_c_capturing) with
9954    /// the dispatcher's [`TimerCallInfo`] handed to the C callback, a callback
9955    /// group, and rcl's `autostart` — issue 1974, the road a C++
9956    /// `TimerInfoCallbackType` lambda and `create_wall_timer(..., autostart)`
9957    /// take.
9958    ///
9959    /// `I` is the FFI's own `#[repr(C)]` mirror of the info struct (the C++
9960    /// header is generated without includes, so it cannot name this crate's
9961    /// type); it is built from the executor's `TimerCallInfo` by `From` on each
9962    /// activation — a copy of two words, never a second computation.
9963    ///
9964    /// `autostart == false` registers the timer cancelled; `reset_timer`
9965    /// starts it. `group` binds it as `register_timer_on` does.
9966    #[allow(clippy::too_many_arguments)]
9967    pub(crate) fn register_timer_c_capturing_with_info<I>(
9968        &mut self,
9969        node_id: Option<super::node_record::NodeId>,
9970        period: TimerDuration,
9971        source: TimerClockSource,
9972        autostart: bool,
9973        group: Option<&str>,
9974        callback: unsafe extern "C" fn(*const I, *mut core::ffi::c_void),
9975        capture: Option<&[u8]>,
9976        capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
9977    ) -> Result<HandleId, NodeError>
9978    where
9979        I: From<TimerCallInfo> + 'static,
9980    {
9981        self.register_timer_entry_with(
9982            node_id,
9983            period,
9984            source,
9985            false,
9986            autostart,
9987            group,
9988            capture,
9989            core::ptr::null_mut(),
9990            move |ctx| {
9991                let guard = super::arena::CaptureGuard {
9992                    ctx,
9993                    drop: capture_drop,
9994                };
9995                move |info: &TimerCallInfo| {
9996                    // Borrow the WHOLE guard: a closure naming only `guard.ctx`
9997                    // would capture that `Copy` field alone and let the guard
9998                    // (whose `Drop` runs the capture's destructor) die here.
9999                    let guard = &guard;
10000                    let ffi_info = I::from(*info);
10001                    // SAFETY: the registrant's contract — `callback` reads its
10002                    // capture through the pointer it is given, and the info
10003                    // pointer is valid for the duration of the call.
10004                    unsafe { callback(&ffi_info, guard.ctx) }
10005                }
10006            },
10007        )
10008    }
10009
10010    /// Register a repeating timer whose callback receives the dispatcher's
10011    /// [`TimerCallInfo`] — issue 1974, the Rust counterpart of Jazzy's
10012    /// `rclcpp::TimerInfoCallbackType` / `rcl_timer_call_info_t`.
10013    ///
10014    /// `source` picks the clock both the period and the two call times are
10015    /// measured on. `autostart == false` creates the timer CANCELLED (rcl's
10016    /// `rcl_timer_init2(..., autostart)`): it does not fire until
10017    /// [`reset_timer`](Self::reset_timer) starts it, and its first period
10018    /// counts from that reset.
10019    ///
10020    /// ```ignore
10021    /// executor.register_timer_with_info(
10022    ///     TimerDuration::from_millis(10),
10023    ///     TimerClockSource::Steady,
10024    ///     true,
10025    ///     |info| jitter.record(info.lateness_ns()),
10026    /// )?;
10027    /// ```
10028    pub fn register_timer_with_info<F>(
10029        &mut self,
10030        period: TimerDuration,
10031        source: TimerClockSource,
10032        autostart: bool,
10033        callback: F,
10034    ) -> Result<HandleId, NodeError>
10035    where
10036        F: FnMut(&TimerCallInfo) + 'static,
10037    {
10038        self.register_timer_with_info_on(None, period, source, autostart, None, callback)
10039    }
10040
10041    /// [`register_timer_with_info`](Self::register_timer_with_info) bound to a
10042    /// node and an optional callback group — the executor primitive behind
10043    /// `Node::create_timer_with_info` and the C `rcl_timer_init2` / `rclc_timer_init_default2` /
10044    /// `rclc_timer_init_with_info` timers (issue 1974). `node_id`/`group` feed
10045    /// the sched binding exactly as [`register_timer_on`](Self::register_timer_on).
10046    pub fn register_timer_with_info_on<F>(
10047        &mut self,
10048        node_id: Option<super::node_record::NodeId>,
10049        period: TimerDuration,
10050        source: TimerClockSource,
10051        autostart: bool,
10052        group: Option<&str>,
10053        callback: F,
10054    ) -> Result<HandleId, NodeError>
10055    where
10056        F: FnMut(&TimerCallInfo) + 'static,
10057    {
10058        self.register_timer_entry_with(
10059            node_id,
10060            period,
10061            source,
10062            false,
10063            autostart,
10064            group,
10065            None,
10066            core::ptr::null_mut(),
10067            move |_| callback,
10068        )
10069    }
10070
10071    /// Phase 273 (RFC-0047) — register a repeating timer callback bound to a
10072    /// specific node and optional callback group. The group name is threaded to
10073    /// `apply_node_default_sched` so the seeded `group_sched_table` assigns
10074    /// the timer's callback to the group's `SchedContext`. When `group` is
10075    /// `None` the node's `default_sched` applies (phase-272 behavior).
10076    ///
10077    /// This is the executor-level primitive called by the Rust `_in_group` API
10078    /// (`Node::create_timer_in_group`) and the C/C++ group-aware timer FFI.
10079    pub fn register_timer_on<F>(
10080        &mut self,
10081        node_id: Option<super::node_record::NodeId>,
10082        period: TimerDuration,
10083        callback: F,
10084        group: Option<&str>,
10085    ) -> Result<HandleId, NodeError>
10086    where
10087        F: FnMut() + 'static,
10088    {
10089        self.register_timer_entry(
10090            node_id,
10091            period,
10092            TimerClockSource::Steady,
10093            false,
10094            group,
10095            callback,
10096        )
10097    }
10098
10099    // ========================================================================
10100    // Raw callback registration (for C API)
10101    // ========================================================================
10102
10103    /// The kept C-FFI subscription core (Phase 189.M2.b): registers a
10104    /// raw `RawSubscriptionCallback` fn-ptr + `context` against an
10105    /// optional node's session. The Rust ergonomic surface is the
10106    /// `node.subscription(t)` builder (closures); this is the single
10107    /// primitive the `nros-c` thin wrapper lowers to. `node_id == None`
10108    /// is the legacy single-node path.
10109    #[allow(clippy::too_many_arguments)]
10110    pub(crate) fn add_arena_subscription_c_callback<const RX_BUF: usize>(
10111        &mut self,
10112        node_id: Option<super::node_record::NodeId>,
10113        topic_name: &str,
10114        type_name: &str,
10115        type_hash: &str,
10116        qos: QoSProfile,
10117        callback: RawSubscriptionCallback,
10118        context: *mut core::ffi::c_void,
10119        group: Option<&str>,
10120        rx_buffer_hint: usize,
10121    ) -> Result<HandleId, NodeError> {
10122        self.add_arena_subscription_c_callback_with_capture::<RX_BUF>(
10123            node_id,
10124            topic_name,
10125            type_name,
10126            type_hash,
10127            qos,
10128            callback,
10129            context,
10130            group,
10131            rx_buffer_hint,
10132            None,
10133            None,
10134        )
10135    }
10136
10137    /// The same registration, with the caller's CAPTURE held by the arena —
10138    /// phase-456 W1.
10139    ///
10140    /// `context` is one pointer, which carries a C++ `[this]` capture and not a
10141    /// `[this, state]` or a `[obj, method]`. When `capture` is `Some`, its
10142    /// bytes are copied into the arena and `context` is repointed at that copy,
10143    /// so the caller keeps nothing and nothing the caller owns is referenced
10144    /// after this returns. When it is `None` the behaviour is exactly the
10145    /// pre-existing one, which is why the plain entry point above is a wrapper
10146    /// rather than a second implementation.
10147    ///
10148    /// **phase-456 W8 changed two things here.** The capture is a RUNTIME
10149    /// length — see `Self::place_capture` — so there is no constant the C++
10150    /// side has to match and no `BufferTooSmall` reachable only by drifting
10151    /// from it. And this path now reaches the in-place dispatch that the Rust
10152    /// typed path has had since phase-231: `RawSubscriptionCallback` is
10153    /// `(const uint8_t*, size_t, void*)`, so the backend's borrowed receive
10154    /// slot IS the argument, and a C or C++ subscription on zenoh or XRCE
10155    /// claims no receive region at all.
10156    #[allow(clippy::too_many_arguments)]
10157    pub(crate) fn add_arena_subscription_c_callback_with_capture<const RX_BUF: usize>(
10158        &mut self,
10159        node_id: Option<super::node_record::NodeId>,
10160        topic_name: &str,
10161        type_name: &str,
10162        type_hash: &str,
10163        qos: QoSProfile,
10164        callback: RawSubscriptionCallback,
10165        context: *mut core::ffi::c_void,
10166        group: Option<&str>,
10167        // phase-402 W2 / issue 0896 — bytes the caller expects to receive; 0 =
10168        // no opinion. A size-classing backend (zenoh-pico) routes on this, and
10169        // a subscription that states nothing takes the SMALL class whatever its
10170        // message type. The C path had no way to say it until the options
10171        // struct existed.
10172        rx_buffer_hint: usize,
10173        capture: Option<&[u8]>,
10174        // phase-476 W2 — called on the arena's copy of `capture` when the entry
10175        // is dropped; see `CaptureGuard`. `None` when the capture owns nothing.
10176        capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
10177    ) -> Result<HandleId, NodeError> {
10178        // phase-403 W3/W5 -- size the arena slot from the TYPE, not from the
10179        // image-wide default. `rx_buffer_hint` already arrives here (phase-402
10180        // routed it to the backend's payload class and stopped); spending it on
10181        // the allocation as well is what makes the buffer per-type.
10182        //
10183        // This is the whole saving on the raw path. `RX_BUF` is
10184        // DEFAULT_RX_BUF_SIZE, so every slot -- publishers and timers included --
10185        // was charged the largest subscription's buffer. Measured on
10186        // mr-canhubk344: 36 handles at the correct 2052-byte bound wanted a
10187        // 242096-byte arena of a 77968-byte region; only 13 of those handles
10188        // receive anything.
10189        //
10190        // 0 keeps the old behaviour, and it means "this CALLER stated nothing"
10191        // rather than "this type is unbounded" -- an unbounded type is a build
10192        // error now, so a zero here is a caller that did not ask, never a type
10193        // that could not answer.
10194        let slot_bytes = if rx_buffer_hint != 0 {
10195            rx_buffer_hint
10196        } else {
10197            RX_BUF
10198        };
10199
10200        let SubscriptionOpen {
10201            slot,
10202            handle,
10203            qos,
10204            in_place,
10205            slot_bytes,
10206        } = self.open_subscription(&SubscriptionRequest {
10207            node_id,
10208            topic_name,
10209            type_name,
10210            type_hash,
10211            qos,
10212            backend_hint: Some(rx_buffer_hint),
10213            slot_bytes,
10214            // phase-456 W8 — `RawSubscriptionCallback` takes borrowed bytes and
10215            // a context, which is exactly what `process_raw_in_place` hands it.
10216            in_place_capable: true,
10217        })?;
10218
10219        // The capture is placed AFTER the backend call and BEFORE either entry
10220        // is written, so both dispatch shapes get the same context and a failed
10221        // `create_subscription` costs no arena bytes. phase-476 W0 — it rides
10222        // at the end of the entry's own trailing region, so releasing the entry
10223        // gives it back.
10224        let capture_len = Self::capture_trailing_len(capture);
10225
10226        if in_place {
10227            let (entry_offset, capture_at) =
10228                self.arena_alloc_with_trailing::<super::arena::SubInplaceRawCEntry>(capture_len)?;
10229            let context = self.place_capture(capture_at, capture, context);
10230            unsafe {
10231                let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10232                let entry_ptr =
10233                    arena_ptr.add(entry_offset) as *mut super::arena::SubInplaceRawCEntry;
10234                core::ptr::write(
10235                    entry_ptr,
10236                    super::arena::SubInplaceRawCEntry {
10237                        handle,
10238                        callback,
10239                        context,
10240                        capture: super::arena::CaptureGuard {
10241                            ctx: context,
10242                            drop: capture_drop,
10243                        },
10244                        age_mon: None,
10245                    },
10246                );
10247            }
10248            let meta = CallbackMeta {
10249                offset: entry_offset,
10250                kind: EntryKind::Subscription,
10251                arena_len: super::arena::ArenaLen::UNRECORDED,
10252                try_process: super::arena::sub_inplace_raw_c_try_process,
10253                has_data: super::arena::sub_inplace_raw_c_has_data,
10254                pre_sample: no_pre_sample,
10255                invocation: InvocationMode::OnNewData,
10256                drop_fn: drop_entry::<super::arena::SubInplaceRawCEntry>,
10257            };
10258            self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10259            // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10260            // SAFETY: a `super::arena::SubInplaceRawCEntry` was written at `entry_offset` just above.
10261            unsafe {
10262                self.arm_entry_wake::<super::arena::SubInplaceRawCEntry>(
10263                    slot,
10264                    entry_offset,
10265                    |e, w| nros_rmw::Subscription::register_waker(&e.handle, w),
10266                )
10267            };
10268            self.apply_node_default_sched(slot, node_id, group);
10269            return Ok(self.handle_for(slot));
10270        }
10271
10272        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
10273
10274        // The ring first, then the capture on the next `u64` boundary after it.
10275        let ring_len = trailing_bytes.next_multiple_of(core::mem::align_of::<u64>());
10276        let (entry_offset, trailing_offset) =
10277            self.arena_alloc_with_trailing::<SubBufferedRawCEntry>(ring_len + capture_len)?;
10278        let context = self.place_capture(trailing_offset + ring_len, capture, context);
10279
10280        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
10281
10282        let buffer = if qos.depth <= 1 {
10283            BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
10284        } else {
10285            BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
10286        };
10287
10288        unsafe {
10289            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10290            let entry_ptr = arena_ptr.add(entry_offset) as *mut SubBufferedRawCEntry;
10291            core::ptr::write(
10292                entry_ptr,
10293                SubBufferedRawCEntry {
10294                    handle,
10295                    buffer,
10296                    callback,
10297                    context,
10298                    capture: super::arena::CaptureGuard {
10299                        ctx: context,
10300                        drop: capture_drop,
10301                    },
10302                    age_mon: None,
10303                },
10304            );
10305        }
10306
10307        let meta = CallbackMeta {
10308            offset: entry_offset,
10309            kind: EntryKind::Subscription,
10310            arena_len: super::arena::ArenaLen::UNRECORDED,
10311            try_process: sub_buffered_raw_c_try_process,
10312            has_data: sub_buffered_raw_c_has_data,
10313            pre_sample: no_pre_sample,
10314            invocation: InvocationMode::OnNewData,
10315            drop_fn: drop_entry::<SubBufferedRawCEntry>,
10316        };
10317        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10318        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10319        // SAFETY: a `SubBufferedRawCEntry` was written at `entry_offset` just above.
10320        unsafe {
10321            self.arm_entry_wake::<SubBufferedRawCEntry>(slot, entry_offset, |e, w| {
10322                nros_rmw::Subscription::register_waker(&e.handle, w)
10323            })
10324        };
10325        self.apply_node_default_sched(slot, node_id, group);
10326        Ok(self.handle_for(slot))
10327    }
10328
10329    /// phase-417 W5.a (RFC-0089 stage 5) — the TYPED C-FFI subscription core:
10330    /// the same registration as [`Self::add_arena_subscription_c_callback`],
10331    /// plus caller-owned message storage and the erased deserialiser for its
10332    /// type, so the callback receives a DESERIALISED message instead of CDR
10333    /// bytes.
10334    ///
10335    /// This is the Rust half of rclc's
10336    /// `rclc_executor_add_subscription(executor, subscription, msg, callback,
10337    /// invocation)`. rclc has no allocator on that path either — it makes the
10338    /// CALLER own the storage, which is exactly what `msg` is here. The C
10339    /// ledger recorded our byte-oriented delivery as forced by "no allocator";
10340    /// the compared surface operates under the same constraint, so the reason
10341    /// was false and the divergence was avoidable (issue 1022 / W-C3).
10342    ///
10343    /// The raw path is untouched and stays: a byte-oriented subscriber is a
10344    /// legitimate thing to want, and this is additive.
10345    ///
10346    /// # Contract on failure
10347    /// If `deserialize` returns non-zero the callback is **not** invoked, the
10348    /// sample is dropped, `try_process` reports
10349    /// `TransportError::DeserializationError` (so it lands in
10350    /// `SpinOnceResult::subscription_errors`), and a rate-limited `nros_log`
10351    /// error names the length and the return code. A message that does not fit
10352    /// the caller's storage arrives here as exactly that failure: both the
10353    /// bounded-string and bounded-sequence decoders refuse rather than
10354    /// truncate, so there is no silent short read.
10355    ///
10356    /// # Safety
10357    /// `msg` must point to storage of the type `deserialize` writes, valid for
10358    /// as long as the subscription is registered. Nothing here drops it.
10359    #[allow(clippy::too_many_arguments)]
10360    pub(crate) unsafe fn add_arena_subscription_c_typed_callback<const RX_BUF: usize>(
10361        &mut self,
10362        node_id: Option<super::node_record::NodeId>,
10363        topic_name: &str,
10364        type_name: &str,
10365        type_hash: &str,
10366        qos: QoSProfile,
10367        msg: core::ptr::NonNull<core::ffi::c_void>,
10368        deserialize: RawMessageDeserializeFn,
10369        callback: TypedSubscriptionCallback,
10370        context: *mut core::ffi::c_void,
10371        group: Option<&str>,
10372        // Same meaning as on the raw path: bytes the caller expects to receive,
10373        // 0 = no opinion. A typed caller normally HAS an answer — the generated
10374        // `<Msg>_RX_MAX_SERIALIZED_SIZE` — which is why the C entry point takes
10375        // it rather than defaulting.
10376        rx_buffer_hint: usize,
10377    ) -> Result<HandleId, NodeError> {
10378        let SubscriptionOpen {
10379            slot,
10380            handle,
10381            qos,
10382            slot_bytes: rx_bytes,
10383            ..
10384        } = self.open_subscription(&SubscriptionRequest {
10385            node_id,
10386            topic_name,
10387            type_name,
10388            type_hash,
10389            qos,
10390            backend_hint: Some(rx_buffer_hint),
10391            // phase-403 W3/W5, unchanged: the hint sizes the arena slot, and 0
10392            // means "this caller stated nothing", never "this type is zero
10393            // bytes".
10394            slot_bytes: if rx_buffer_hint != 0 {
10395                rx_buffer_hint
10396            } else {
10397                RX_BUF
10398            },
10399            // The callback receives the caller's DESERIALISED storage, written
10400            // by `deserialize` from the sample's bytes, so borrowed bytes would
10401            // be enough and this shape could take the in-place row. It does not
10402            // yet: `SubBufferedTypedCEntry` also owns the `msg` pointer and the
10403            // deserialiser, so an in-place twin is a second entry type rather
10404            // than a flag, and phase-456 W8's acceptance is the RAW C path.
10405            // Stated here rather than left blank because a `false` nobody wrote
10406            // down is what this whole item is about.
10407            in_place_capable: false,
10408        })?;
10409
10410        let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, rx_bytes);
10411
10412        let (entry_offset, trailing_offset) =
10413            self.arena_alloc_with_trailing::<SubBufferedTypedCEntry>(trailing_bytes)?;
10414
10415        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
10416
10417        let buffer = if qos.depth <= 1 {
10418            BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, rx_bytes) })
10419        } else {
10420            BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, rx_bytes, qos.depth as usize) })
10421        };
10422
10423        unsafe {
10424            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10425            let entry_ptr = arena_ptr.add(entry_offset) as *mut SubBufferedTypedCEntry;
10426            core::ptr::write(
10427                entry_ptr,
10428                SubBufferedTypedCEntry {
10429                    handle,
10430                    buffer,
10431                    msg: msg.as_ptr(),
10432                    deserialize,
10433                    callback,
10434                    context,
10435                },
10436            );
10437        }
10438
10439        let meta = CallbackMeta {
10440            offset: entry_offset,
10441            kind: EntryKind::Subscription,
10442            arena_len: super::arena::ArenaLen::UNRECORDED,
10443            try_process: sub_buffered_typed_c_try_process,
10444            has_data: sub_buffered_typed_c_has_data,
10445            pre_sample: no_pre_sample,
10446            invocation: InvocationMode::OnNewData,
10447            drop_fn: drop_entry::<SubBufferedTypedCEntry>,
10448        };
10449        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10450        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10451        // SAFETY: a `SubBufferedTypedCEntry` was written at `entry_offset` just above.
10452        unsafe {
10453            self.arm_entry_wake::<SubBufferedTypedCEntry>(slot, entry_offset, |e, w| {
10454                nros_rmw::Subscription::register_waker(&e.handle, w)
10455            })
10456        };
10457        self.apply_node_default_sched(slot, node_id, group);
10458        Ok(self.handle_for(slot))
10459    }
10460
10461    /// Issue 2036 — register a raw subscription whose closure receives each
10462    /// sample's [`MessageInfo`](nros_core::MessageInfo) (`None` from a backend
10463    /// that reports none): the raw twin of the typed `with_info` path, for a
10464    /// caller that deserializes itself. Backs the C++
10465    /// `(const M&, const rclcpp::MessageInfo&)` subscription callback
10466    /// (`nros_cpp_subscription_register_message_info`).
10467    ///
10468    /// `rx_buffer_hint` sizes both the backend's payload class and the arena
10469    /// slot, as on [`add_arena_subscription_c_info_callback`](crate::executor::ffi::ExecutorFfi::add_arena_subscription_c_info_callback);
10470    /// 0 falls back to `DEFAULT_RX_BUF_SIZE`.
10471    #[allow(clippy::too_many_arguments)]
10472    pub fn add_arena_subscription_msg_info_closure<F>(
10473        &mut self,
10474        node_id: Option<super::node_record::NodeId>,
10475        topic_name: &str,
10476        type_name: &str,
10477        type_hash: &str,
10478        qos: QoSProfile,
10479        rx_buffer_hint: usize,
10480        callback: F,
10481    ) -> Result<HandleId, NodeError>
10482    where
10483        F: FnMut(&[u8], Option<&nros_core::MessageInfo>) + 'static,
10484    {
10485        type Entry<F> = SubBufferedRawMsgInfoEntry<F>;
10486
10487        let SubscriptionOpen {
10488            slot,
10489            handle,
10490            slot_bytes: rx_bytes,
10491            ..
10492        } = self.open_subscription(&SubscriptionRequest {
10493            node_id,
10494            topic_name,
10495            type_name,
10496            type_hash,
10497            qos,
10498            backend_hint: Some(rx_buffer_hint),
10499            slot_bytes: if rx_buffer_hint != 0 {
10500                rx_buffer_hint
10501            } else {
10502                crate::config::DEFAULT_RX_BUF_SIZE
10503            },
10504            // The metadata comes with a COPYING take (`take_serialized_with_info`).
10505            in_place_capable: false,
10506        })?;
10507
10508        let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry<F>>(rx_bytes)?;
10509        unsafe {
10510            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10511            let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
10512            let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
10513            core::ptr::write(
10514                entry_ptr,
10515                Entry {
10516                    handle,
10517                    buffer,
10518                    callback,
10519                },
10520            );
10521        }
10522
10523        let meta = CallbackMeta {
10524            offset,
10525            kind: EntryKind::Subscription,
10526            arena_len: super::arena::ArenaLen::UNRECORDED,
10527            try_process: sub_buffered_raw_msg_info_try_process::<F>,
10528            has_data: sub_buffered_raw_msg_info_has_data::<F>,
10529            pre_sample: no_pre_sample,
10530            invocation: InvocationMode::OnNewData,
10531            drop_fn: drop_entry::<Entry<F>>,
10532        };
10533        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10534        // SAFETY: an `Entry<F>` was written at `offset` just above.
10535        unsafe {
10536            self.arm_entry_wake::<Entry<F>>(slot, offset, |e, w| {
10537                nros_rmw::Subscription::register_waker(&e.handle, w)
10538            })
10539        };
10540        self.apply_node_default_sched(slot, node_id, None);
10541        Ok(self.handle_for(slot))
10542    }
10543
10544    /// Phase 189.M3.4 — register a raw C-fn-ptr subscription whose callback
10545    /// also receives the sample's wire **attachment**
10546    /// ([`RawSubscriptionInfoCallback`]: `(data, len, attachment, att_len,
10547    /// context)`) — the C analog of the Rust
10548    /// `node.subscription(t).generic(..).message_info()` builder. Backs the C
10549    /// FFI `rclc_executor_add_subscription_raw_with_info`. Flat per-entry
10550    /// payload + attachment buffers (cap `RAW_INFO_ATT_CAP`);
10551    /// one sample per `spin_once`.
10552    #[allow(clippy::too_many_arguments)]
10553    pub(crate) fn add_arena_subscription_c_info_callback<const RX_BUF: usize>(
10554        &mut self,
10555        node_id: Option<super::node_record::NodeId>,
10556        topic_name: &str,
10557        type_name: &str,
10558        type_hash: &str,
10559        qos: QoSProfile,
10560        callback: RawSubscriptionInfoCallback,
10561        context: *mut core::ffi::c_void,
10562        // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
10563        // opinion. It now buys BOTH halves: the BACKEND's payload size-class
10564        // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
10565        // survives only as the fallback for a caller that states nothing, which
10566        // is exactly its role on the plain C path.
10567        rx_buffer_hint: usize,
10568    ) -> Result<HandleId, NodeError> {
10569        type Entry = SubBufferedRawInfoCEntry;
10570
10571        let SubscriptionOpen {
10572            slot,
10573            handle,
10574            slot_bytes: rx_bytes,
10575            ..
10576        } = self.open_subscription(&SubscriptionRequest {
10577            node_id,
10578            topic_name,
10579            type_name,
10580            type_hash,
10581            qos,
10582            backend_hint: Some(rx_buffer_hint),
10583            // phase-408 W5b — the hint sizes the ARENA too, not just the
10584            // backend's size class. 0 still means "this caller stated nothing",
10585            // so `RX_BUF` -- `DEFAULT_RX_BUF_SIZE` at every call site -- is what
10586            // it falls back to, and an unhinted registration claims the same
10587            // bytes it always did.
10588            slot_bytes: if rx_buffer_hint != 0 {
10589                rx_buffer_hint
10590            } else {
10591                RX_BUF
10592            },
10593            // Carries the wire attachment — see the Rust `_raw_info_on` twin.
10594            in_place_capable: false,
10595        })?;
10596
10597        let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
10598        unsafe {
10599            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10600            let buf = super::arena::TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
10601            let entry_ptr = arena_ptr.add(offset) as *mut Entry;
10602            core::ptr::write(
10603                entry_ptr,
10604                Entry {
10605                    handle,
10606                    buffer: buf,
10607                    att: [0u8; super::arena::RAW_INFO_ATT_CAP],
10608                    callback,
10609                    context,
10610                },
10611            );
10612        }
10613
10614        let meta = CallbackMeta {
10615            offset,
10616            kind: EntryKind::Subscription,
10617            arena_len: super::arena::ArenaLen::UNRECORDED,
10618            try_process: sub_buffered_raw_info_c_try_process,
10619            has_data: sub_buffered_raw_info_c_has_data,
10620            pre_sample: no_pre_sample,
10621            invocation: InvocationMode::OnNewData,
10622            drop_fn: drop_entry::<Entry>,
10623        };
10624        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10625        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10626        // SAFETY: a `Entry` was written at `offset` just above.
10627        unsafe {
10628            self.arm_entry_wake::<Entry>(slot, offset, |e, w| {
10629                nros_rmw::Subscription::register_waker(&e.handle, w)
10630            })
10631        };
10632        self.apply_node_default_sched(slot, node_id, None);
10633        Ok(self.handle_for(slot))
10634    }
10635
10636    /// Phase 269 W3 — register a raw C-fn-ptr subscription whose callback
10637    /// ALSO surfaces the sample's E2E integrity status (CRC + sequence gap/dup)
10638    /// alongside the CDR bytes — the C/C++ component-callback analog of Rust's
10639    /// `register_subscription_buffered_raw_safety_on` (`FnMut(&[u8], &IntegrityStatus)`).
10640    ///
10641    /// The executor validates the sample via `take_validated` and unpacks the
10642    /// [`nros_rmw::IntegrityStatus`] into three plain scalars (gap, duplicate,
10643    /// crc_valid) before calling `callback`. This avoids introducing a
10644    /// cbindgen-visible struct at the executor layer; the C/C++ headers pack them
10645    /// back into their local integrity-status typedef.
10646    ///
10647    /// Requires the `safety-e2e` feature. Backed by `SubBufferedRawSafetyCEntry`.
10648    #[cfg(feature = "safety-e2e")]
10649    #[allow(clippy::too_many_arguments)]
10650    pub(crate) fn add_arena_subscription_c_validated_callback<const RX_BUF: usize>(
10651        &mut self,
10652        node_id: Option<super::node_record::NodeId>,
10653        topic_name: &str,
10654        type_name: &str,
10655        type_hash: &str,
10656        qos: QoSProfile,
10657        callback: super::types::RawSubscriptionSafetyCallback,
10658        context: *mut core::ffi::c_void,
10659        // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
10660        // opinion. It now buys BOTH halves: the BACKEND's payload size-class
10661        // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
10662        // survives only as the fallback for a caller that states nothing, which
10663        // is exactly its role on the plain C path.
10664        rx_buffer_hint: usize,
10665    ) -> Result<HandleId, NodeError> {
10666        use super::arena::{
10667            SubBufferedRawSafetyCEntry, TrailingBuf, sub_buffered_raw_safety_c_has_data,
10668            sub_buffered_raw_safety_c_try_process,
10669        };
10670        type Entry = SubBufferedRawSafetyCEntry;
10671
10672        let SubscriptionOpen {
10673            slot,
10674            handle,
10675            slot_bytes: rx_bytes,
10676            ..
10677        } = self.open_subscription(&SubscriptionRequest {
10678            node_id,
10679            topic_name,
10680            type_name,
10681            type_hash,
10682            qos,
10683            backend_hint: Some(rx_buffer_hint),
10684            // phase-408 W5b — same as the info sibling: one flat slot in the
10685            // trailing region, sized from the hint, `RX_BUF` only as the
10686            // stated-nothing fallback.
10687            slot_bytes: if rx_buffer_hint != 0 {
10688                rx_buffer_hint
10689            } else {
10690                RX_BUF
10691            },
10692            // Carries the integrity status, which comes from `take_validated`.
10693            in_place_capable: false,
10694        })?;
10695
10696        let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
10697        unsafe {
10698            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10699            let buf = TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
10700            let entry_ptr = arena_ptr.add(offset) as *mut Entry;
10701            core::ptr::write(
10702                entry_ptr,
10703                Entry {
10704                    handle,
10705                    buffer: buf,
10706                    callback,
10707                    context,
10708                },
10709            );
10710        }
10711
10712        let meta = CallbackMeta {
10713            offset,
10714            kind: EntryKind::Subscription,
10715            arena_len: super::arena::ArenaLen::UNRECORDED,
10716            try_process: sub_buffered_raw_safety_c_try_process,
10717            has_data: sub_buffered_raw_safety_c_has_data,
10718            pre_sample: no_pre_sample,
10719            invocation: InvocationMode::OnNewData,
10720            drop_fn: drop_entry::<Entry>,
10721        };
10722        self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10723        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10724        // SAFETY: a `Entry` was written at `offset` just above.
10725        unsafe {
10726            self.arm_entry_wake::<Entry>(slot, offset, |e, w| {
10727                nros_rmw::Subscription::register_waker(&e.handle, w)
10728            })
10729        };
10730        self.apply_node_default_sched(slot, node_id, None);
10731        Ok(self.handle_for(slot))
10732    }
10733
10734    /// Register a raw (untyped) service callback.
10735    ///
10736    /// Register a raw (untyped) service callback with the default buffer size.
10737    ///
10738    /// The callback receives and produces CDR bytes without typed
10739    /// deserialization/serialization. Used by the C API wrapper.
10740    pub(crate) fn register_service_raw(
10741        &mut self,
10742        service_name: &str,
10743        service_type: &str,
10744        service_hash: &str,
10745        callback: RawServiceCallback,
10746        context: *mut core::ffi::c_void,
10747    ) -> Result<HandleId, NodeError> {
10748        self.register_service_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(
10749            service_name,
10750            service_type,
10751            service_hash,
10752            QoSProfile::services_default(),
10753            callback,
10754            context,
10755        )
10756    }
10757
10758    /// Register a raw (untyped) service callback with custom buffer sizes + QoS.
10759    ///
10760    /// `REQ_BUF` and `REPLY_BUF` set the stack-allocated CDR buffers
10761    /// for the request and reply respectively. Increase for services
10762    /// with large payloads (e.g., parameter services). `qos` applies to both
10763    /// the request + reply endpoints (Phase 193.2c).
10764    #[allow(clippy::too_many_arguments)]
10765    pub(crate) fn register_service_raw_sized<const REQ_BUF: usize, const REPLY_BUF: usize>(
10766        &mut self,
10767        service_name: &str,
10768        service_type: &str,
10769        service_hash: &str,
10770        qos: QoSProfile,
10771        callback: RawServiceCallback,
10772        context: *mut core::ffi::c_void,
10773    ) -> Result<HandleId, NodeError> {
10774        self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF, RawServiceCallback>(
10775            None,
10776            service_name,
10777            service_type,
10778            service_hash,
10779            qos,
10780            callback,
10781            context,
10782            None,
10783            None,
10784        )
10785    }
10786
10787    /// Phase 104.C.3.3.a — Node-aware variant of
10788    /// [`Self::register_service_raw_sized`]. C-FFI path.
10789    #[allow(clippy::too_many_arguments)]
10790    pub(crate) fn register_service_raw_sized_on<const REQ_BUF: usize, const REPLY_BUF: usize>(
10791        &mut self,
10792        node_id: super::node_record::NodeId,
10793        service_name: &str,
10794        service_type: &str,
10795        service_hash: &str,
10796        qos: QoSProfile,
10797        callback: RawServiceCallback,
10798        context: *mut core::ffi::c_void,
10799    ) -> Result<HandleId, NodeError> {
10800        self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF, RawServiceCallback>(
10801            Some(node_id),
10802            service_name,
10803            service_type,
10804            service_hash,
10805            qos,
10806            callback,
10807            context,
10808            None,
10809            None,
10810        )
10811    }
10812
10813    /// phase-488 — [`Self::register_service_raw_sized`] / `_on` with the
10814    /// handler's CAPTURE copied into the entry's own region, as
10815    /// [`Self::add_arena_subscription_c_callback_with_capture`] does for a
10816    /// subscription. The dispatch context becomes the arena's copy, and
10817    /// `capture_drop` (when given) destroys it with the entry. This is what lets a
10818    /// C++ service handler carry state (`std::bind(&Node::on_x, this, _1, _2)`,
10819    /// a capturing lambda): a context word holds a `[this]` and not a
10820    /// pointer-to-member plus its object.
10821    #[allow(clippy::too_many_arguments)]
10822    pub(crate) fn register_service_raw_capturing<const REQ_BUF: usize, const REPLY_BUF: usize>(
10823        &mut self,
10824        node_id: Option<super::node_record::NodeId>,
10825        service_name: &str,
10826        service_type: &str,
10827        service_hash: &str,
10828        qos: QoSProfile,
10829        callback: RawServiceCallback,
10830        capture: Option<&[u8]>,
10831        capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
10832    ) -> Result<HandleId, NodeError> {
10833        self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF, RawServiceCallback>(
10834            node_id,
10835            service_name,
10836            service_type,
10837            service_hash,
10838            qos,
10839            callback,
10840            core::ptr::null_mut(),
10841            capture,
10842            capture_drop,
10843        )
10844    }
10845
10846    /// Issue 2081 — a raw service whose callback also receives the request's
10847    /// id (client writer GUID + client sequence number), rclc's
10848    /// `rclc_service_callback_with_request_id_t` shape in CDR bytes. The id is
10849    /// the one the backend reported for the request being dispatched
10850    /// ([`ServiceTrait::request_info`](nros_rmw::ServiceTrait::request_info)),
10851    /// all zeros when it reported none.
10852    ///
10853    /// One entry point for every registration shape: `node_id` as
10854    /// `register_service_raw_sized_on` (or `None`), and `capture` as
10855    /// `register_service_raw_capturing` (or `None`, with `context`
10856    /// passed through instead).
10857    #[allow(clippy::too_many_arguments)]
10858    pub fn register_service_raw_with_request_id_sized<
10859        const REQ_BUF: usize,
10860        const REPLY_BUF: usize,
10861    >(
10862        &mut self,
10863        node_id: Option<super::node_record::NodeId>,
10864        service_name: &str,
10865        service_type: &str,
10866        service_hash: &str,
10867        qos: QoSProfile,
10868        callback: RawServiceRequestIdCallback,
10869        context: *mut core::ffi::c_void,
10870        capture: Option<&[u8]>,
10871        capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
10872    ) -> Result<HandleId, NodeError> {
10873        self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF, RawServiceRequestIdCallback>(
10874            node_id,
10875            service_name,
10876            service_type,
10877            service_hash,
10878            qos,
10879            callback,
10880            context,
10881            capture,
10882            capture_drop,
10883        )
10884    }
10885
10886    #[allow(clippy::too_many_arguments)]
10887    fn register_service_raw_sized_inner<
10888        const REQ_BUF: usize,
10889        const REPLY_BUF: usize,
10890        C: super::arena::RawServiceDispatch,
10891    >(
10892        &mut self,
10893        node_id: Option<super::node_record::NodeId>,
10894        service_name: &str,
10895        service_type: &str,
10896        service_hash: &str,
10897        qos: QoSProfile,
10898        callback: C,
10899        context: *mut core::ffi::c_void,
10900        capture: Option<&[u8]>,
10901        capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
10902    ) -> Result<HandleId, NodeError> {
10903        let slot = self.next_entry_slot()?;
10904        let (node_name, ns, session_idx) = match node_id {
10905            Some(id) => {
10906                let r = self
10907                    .nodes
10908                    .get(id.index())
10909                    .ok_or(NodeError::InvalidSchedContextBinding)?;
10910                (r.name.clone(), r.namespace.clone(), r.session_idx)
10911            }
10912            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
10913        };
10914        let mut info = ServiceInfo::new(service_name, service_type, service_hash)
10915            .with_domain(self.domain_id)
10916            .with_namespace(&ns);
10917        if !node_name.is_empty() {
10918            info = info.with_node_name(&node_name);
10919        }
10920        let handle = {
10921            let session = self
10922                .session_at_mut(session_idx)
10923                .ok_or(NodeError::BackendMismatch)?;
10924            // Phase 193.5 — validate against the backend's supported policies
10925            // (no silent downgrade); request/reply effectively requires RELIABLE.
10926            super::node::validate_qos_or_report(
10927                &qos,
10928                session.supported_qos_policies(),
10929                "service",
10930                service_name,
10931            )?;
10932            session
10933                .create_service(&info, qos)
10934                .map_err(NodeError::Transport)?
10935        };
10936
10937        // phase-488 — a capture rides at the end of the entry's own region, as
10938        // a subscription's does, so releasing the entry gives it back. No
10939        // capture keeps `arena_alloc`, so a C caller's arena use does not move.
10940        let capture_len = Self::capture_trailing_len(capture);
10941        let (offset, context) = if capture_len == 0 {
10942            (
10943                self.arena_alloc::<SrvRawEntry<REQ_BUF, REPLY_BUF, C>>()?,
10944                context,
10945            )
10946        } else {
10947            let (offset, capture_at) =
10948                self.arena_alloc_with_trailing::<SrvRawEntry<REQ_BUF, REPLY_BUF, C>>(capture_len)?;
10949            (offset, self.place_capture(capture_at, capture, context))
10950        };
10951
10952        unsafe {
10953            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10954            let entry_ptr = arena_ptr.add(offset) as *mut SrvRawEntry<REQ_BUF, REPLY_BUF, C>;
10955            core::ptr::write(
10956                entry_ptr,
10957                SrvRawEntry {
10958                    handle,
10959                    req_buffer: [0u8; REQ_BUF],
10960                    reply_buffer: [0u8; REPLY_BUF],
10961                    callback,
10962                    context,
10963                    capture: super::arena::CaptureGuard {
10964                        ctx: context,
10965                        drop: capture_drop,
10966                    },
10967                },
10968            );
10969        }
10970
10971        let meta = CallbackMeta {
10972            offset,
10973            kind: EntryKind::Service,
10974            arena_len: super::arena::ArenaLen::UNRECORDED,
10975            try_process: srv_raw_try_process::<REQ_BUF, REPLY_BUF, C>,
10976            has_data: srv_raw_has_data::<REQ_BUF, REPLY_BUF, C>,
10977            pre_sample: no_pre_sample,
10978            invocation: InvocationMode::OnNewData,
10979            drop_fn: drop_entry::<SrvRawEntry<REQ_BUF, REPLY_BUF, C>>,
10980        };
10981        self.emplace_entry(slot, meta, TraceName::Text(service_name));
10982        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10983        // SAFETY: a `SrvRawEntry<REQ_BUF, REPLY_BUF, C>` was written at `offset` just above.
10984        unsafe {
10985            self.arm_entry_wake::<SrvRawEntry<REQ_BUF, REPLY_BUF, C>>(slot, offset, |e, w| {
10986                nros_rmw::ServiceTrait::register_waker(&e.handle, w)
10987            })
10988        };
10989        self.apply_node_default_sched(slot, node_id, None);
10990        Ok(self.handle_for(slot))
10991    }
10992
10993    // ========================================================================
10994    // Raw service client registration (Phase 82)
10995    // ========================================================================
10996
10997    /// Register a raw (untyped) service client with the default reply
10998    /// buffer size.
10999    ///
11000    /// The client is owned by the executor's arena. Each `spin_once`
11001    /// dispatch polls the in-flight reply slot via `take_response_raw`
11002    /// and fires the registered callback when the response arrives.
11003    /// Used by the C API thin wrapper — see Phase 82.
11004    pub(crate) fn register_service_client_raw(
11005        &mut self,
11006        service_name: &str,
11007        service_type: &str,
11008        service_hash: &str,
11009        callback: Option<RawResponseCallback>,
11010        context: *mut core::ffi::c_void,
11011    ) -> Result<HandleId, NodeError> {
11012        self.register_service_client_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }>(
11013            service_name,
11014            service_type,
11015            service_hash,
11016            QoSProfile::services_default(),
11017            callback,
11018            context,
11019        )
11020    }
11021
11022    /// Register a raw service client with a custom reply buffer size + QoS.
11023    ///
11024    /// `qos` applies to the client's request + reply endpoints (Phase 193.3b);
11025    /// defaults to [`QoSProfile::services_default`] via the convenience
11026    /// wrapper.
11027    #[allow(clippy::too_many_arguments)]
11028    pub(crate) fn register_service_client_raw_sized<const REPLY_BUF: usize>(
11029        &mut self,
11030        service_name: &str,
11031        service_type: &str,
11032        service_hash: &str,
11033        qos: QoSProfile,
11034        callback: Option<RawResponseCallback>,
11035        context: *mut core::ffi::c_void,
11036    ) -> Result<HandleId, NodeError> {
11037        self.register_service_client_raw_sized_inner::<REPLY_BUF>(
11038            None,
11039            service_name,
11040            service_type,
11041            service_hash,
11042            qos,
11043            callback,
11044            context,
11045            None,
11046        )
11047    }
11048
11049    /// Phase 104.C.3.3.a — Node-aware variant of
11050    /// [`Self::register_service_client_raw_sized`]. Routes the client
11051    /// creation through the named Node's session.
11052    #[allow(clippy::too_many_arguments)]
11053    pub(crate) fn register_service_client_raw_sized_on<const REPLY_BUF: usize>(
11054        &mut self,
11055        node_id: super::node_record::NodeId,
11056        service_name: &str,
11057        service_type: &str,
11058        service_hash: &str,
11059        qos: QoSProfile,
11060        callback: Option<RawResponseCallback>,
11061        context: *mut core::ffi::c_void,
11062    ) -> Result<HandleId, NodeError> {
11063        self.register_service_client_raw_sized_inner::<REPLY_BUF>(
11064            Some(node_id),
11065            service_name,
11066            service_type,
11067            service_hash,
11068            qos,
11069            callback,
11070            context,
11071            None,
11072        )
11073    }
11074
11075    /// phase-515 C3 — a raw service client whose response callback reads a
11076    /// CAPTURE the arena holds, the client twin of
11077    /// [`register_service_raw_capturing`](Self::register_service_raw_capturing).
11078    ///
11079    /// rclc's typed `rclc_executor_add_client(executor, client, response_msg,
11080    /// callback)` needs three words at dispatch — the caller's response
11081    /// storage, the type's deserialiser and the callback — and the raw entry
11082    /// has one context word. The capture is copied to the end of the entry's
11083    /// own region and `callback` is handed a pointer to that copy, so the C
11084    /// side keeps nothing and releasing the client releases the capture.
11085    /// `rclc_client_call` swaps its own one-shot pair in and out of the entry
11086    /// and restores this one, so a typed client can still be called
11087    /// synchronously.
11088    #[allow(clippy::too_many_arguments)]
11089    pub(crate) fn register_service_client_raw_capturing<const REPLY_BUF: usize>(
11090        &mut self,
11091        node_id: Option<super::node_record::NodeId>,
11092        service_name: &str,
11093        service_type: &str,
11094        service_hash: &str,
11095        qos: QoSProfile,
11096        callback: Option<RawResponseCallback>,
11097        capture: Option<&[u8]>,
11098    ) -> Result<HandleId, NodeError> {
11099        self.register_service_client_raw_sized_inner::<REPLY_BUF>(
11100            node_id,
11101            service_name,
11102            service_type,
11103            service_hash,
11104            qos,
11105            callback,
11106            core::ptr::null_mut(),
11107            capture,
11108        )
11109    }
11110
11111    #[allow(clippy::too_many_arguments)]
11112    fn register_service_client_raw_sized_inner<const REPLY_BUF: usize>(
11113        &mut self,
11114        node_id: Option<super::node_record::NodeId>,
11115        service_name: &str,
11116        service_type: &str,
11117        service_hash: &str,
11118        qos: QoSProfile,
11119        callback: Option<RawResponseCallback>,
11120        context: *mut core::ffi::c_void,
11121        capture: Option<&[u8]>,
11122    ) -> Result<HandleId, NodeError> {
11123        let slot = self.next_entry_slot()?;
11124        let (node_name, ns, session_idx) = match node_id {
11125            Some(id) => {
11126                let r = self
11127                    .nodes
11128                    .get(id.index())
11129                    .ok_or(NodeError::InvalidSchedContextBinding)?;
11130                (r.name.clone(), r.namespace.clone(), r.session_idx)
11131            }
11132            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
11133        };
11134        let mut info = ServiceInfo::new(service_name, service_type, service_hash)
11135            .with_domain(self.domain_id)
11136            .with_namespace(&ns);
11137        if !node_name.is_empty() {
11138            info = info.with_node_name(&node_name);
11139        }
11140        let handle = {
11141            let session = self
11142                .session_at_mut(session_idx)
11143                .ok_or(NodeError::BackendMismatch)?;
11144            // Phase 193.5 — validate against the backend's supported policies
11145            // (no silent downgrade); request/reply effectively requires RELIABLE.
11146            super::node::validate_qos_or_report(
11147                &qos,
11148                session.supported_qos_policies(),
11149                "client",
11150                service_name,
11151            )?;
11152            session
11153                .create_client(&info, qos)
11154                .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
11155        };
11156
11157        // phase-515 C3 — a capture rides at the end of the entry's own region,
11158        // as a service's does (phase-488). No capture keeps `arena_alloc`, so a
11159        // client registered without one uses exactly the arena it always did.
11160        let capture_len = Self::capture_trailing_len(capture);
11161        let (offset, context) = if capture_len == 0 {
11162            (
11163                self.arena_alloc::<ServiceClientRawArenaEntry<REPLY_BUF>>()?,
11164                context,
11165            )
11166        } else {
11167            let (offset, capture_at) = self
11168                .arena_alloc_with_trailing::<ServiceClientRawArenaEntry<REPLY_BUF>>(capture_len)?;
11169            (offset, self.place_capture(capture_at, capture, context))
11170        };
11171        unsafe {
11172            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11173            let entry_ptr = arena_ptr.add(offset) as *mut ServiceClientRawArenaEntry<REPLY_BUF>;
11174            core::ptr::write(
11175                entry_ptr,
11176                ServiceClientRawArenaEntry {
11177                    handle,
11178                    reply_buffer: [0u8; REPLY_BUF],
11179                    pending: false,
11180                    reply_ready: core::sync::atomic::AtomicBool::new(false),
11181                    callback,
11182                    request_id_callback: None,
11183                    context,
11184                    sequences: super::arena::ClientSequences::new(),
11185                },
11186            );
11187        }
11188
11189        let meta = CallbackMeta {
11190            offset,
11191            kind: EntryKind::ServiceClient,
11192            arena_len: super::arena::ArenaLen::UNRECORDED,
11193            try_process: service_client_raw_try_process::<REPLY_BUF>,
11194            has_data: always_ready,
11195            pre_sample: no_pre_sample,
11196            invocation: InvocationMode::Always,
11197            drop_fn: drop_entry::<ServiceClientRawArenaEntry<REPLY_BUF>>,
11198        };
11199        self.emplace_entry(slot, meta, TraceName::Text(service_name));
11200        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
11201        // SAFETY: a `ServiceClientRawArenaEntry<REPLY_BUF>` was written at `offset` just above.
11202        unsafe {
11203            self.arm_entry_wake::<ServiceClientRawArenaEntry<REPLY_BUF>>(slot, offset, |e, w| {
11204                nros_rmw::ClientTrait::register_waker(&e.handle, w)
11205            })
11206        };
11207        self.apply_node_default_sched(slot, node_id, None);
11208        Ok(self.handle_for(slot))
11209    }
11210
11211    /// RFC-0041 / Phase 239.1 — register a **typed callback** service client.
11212    /// The reply is eager-drained at `spin_once` and dispatched to `callback` as
11213    /// a deserialized `Svc::Reply`. Returns the scheduling [`HandleId`] and a
11214    /// `*mut` to the arena entry's send header (used to build the typed
11215    /// [`ServiceClientCallback`](super::handles::ServiceClientCallback)).
11216    #[allow(clippy::too_many_arguments)]
11217    pub(crate) fn register_service_client_callback<Svc, F, const REPLY_BUF: usize>(
11218        &mut self,
11219        node_id: Option<super::node_record::NodeId>,
11220        service_name: &str,
11221        service_type: &str,
11222        service_hash: &str,
11223        qos: QoSProfile,
11224        callback: F,
11225    ) -> Result<(HandleId, *mut ServiceClientSendHeader<REPLY_BUF>), NodeError>
11226    where
11227        Svc: nros_core::RosService + 'static,
11228        // Issue 2148 — `ReplyOnly(closure)` or `ReplyWithInfo(closure)`.
11229        F: super::arena::ClientReplyCallback<Svc::Reply> + 'static,
11230    {
11231        let slot = self.next_entry_slot()?;
11232        let (node_name, ns, session_idx) = match node_id {
11233            Some(id) => {
11234                let r = self
11235                    .nodes
11236                    .get(id.index())
11237                    .ok_or(NodeError::InvalidSchedContextBinding)?;
11238                (r.name.clone(), r.namespace.clone(), r.session_idx)
11239            }
11240            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
11241        };
11242        let mut info = ServiceInfo::new(service_name, service_type, service_hash)
11243            .with_domain(self.domain_id)
11244            .with_namespace(&ns);
11245        if !node_name.is_empty() {
11246            info = info.with_node_name(&node_name);
11247        }
11248        let handle = {
11249            let session = self
11250                .session_at_mut(session_idx)
11251                .ok_or(NodeError::BackendMismatch)?;
11252            super::node::validate_qos_or_report(
11253                &qos,
11254                session.supported_qos_policies(),
11255                "client",
11256                service_name,
11257            )?;
11258            session
11259                .create_client(&info, qos)
11260                .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
11261        };
11262
11263        let offset = self.arena_alloc::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>()?;
11264        let hdr_ptr = unsafe {
11265            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11266            let entry_ptr =
11267                arena_ptr.add(offset) as *mut ServiceClientCallbackEntry<Svc, F, REPLY_BUF>;
11268            core::ptr::write(
11269                entry_ptr,
11270                ServiceClientCallbackEntry {
11271                    hdr: ServiceClientSendHeader {
11272                        handle,
11273                        reply_buffer: [0u8; REPLY_BUF],
11274                        pending: false,
11275                        reply_ready: core::sync::atomic::AtomicBool::new(false),
11276                        sequences: super::arena::ClientSequences::new(),
11277                    },
11278                    callback,
11279                    _phantom: core::marker::PhantomData,
11280                },
11281            );
11282            &mut (*entry_ptr).hdr as *mut ServiceClientSendHeader<REPLY_BUF>
11283        };
11284
11285        let meta = CallbackMeta {
11286            offset,
11287            kind: EntryKind::ServiceClient,
11288            arena_len: super::arena::ArenaLen::UNRECORDED,
11289            try_process: service_client_callback_try_process::<Svc, F, REPLY_BUF>,
11290            has_data: always_ready,
11291            pre_sample: no_pre_sample,
11292            invocation: InvocationMode::Always,
11293            drop_fn: drop_entry::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>,
11294        };
11295        self.emplace_entry(slot, meta, TraceName::Text(service_name));
11296        // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
11297        // SAFETY: a `ServiceClientCallbackEntry<Svc, F, REPLY_BUF>` was written at `offset` just above.
11298        unsafe {
11299            self.arm_entry_wake::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>(
11300                slot,
11301                offset,
11302                |e, w| nros_rmw::ClientTrait::register_waker(&e.hdr.handle, w),
11303            )
11304        };
11305        self.apply_node_default_sched(slot, node_id, None);
11306        Ok((self.handle_for(slot), hdr_ptr))
11307    }
11308
11309    // ========================================================================
11310    // Guard condition registration
11311    // ========================================================================
11312
11313    /// Register a guard condition with a callback, on no particular node.
11314    ///
11315    /// Returns both the [`HandleId`] for trigger configuration and a
11316    /// [`GuardCondition`] for triggering from other threads.
11317    ///
11318    /// **This is the ours-only PRIMITIVE, not the verb a user reaches for.**
11319    /// phase-417 W4.e settled that the NODE owns a guard condition in all three
11320    /// languages, so the spelling an application writes is
11321    /// [`Node::create_guard_condition`](crate::executor::Node::create_guard_condition)
11322    /// — which is this function with a node bound, so the guard inherits that
11323    /// node's `SchedContext` like every other entity the node creates. This one
11324    /// stays because the executor is what owns the arena the flag lives in, and
11325    /// because an image with no node at all (a bare `Executor::open` plus a
11326    /// wake source) still has to be able to make one.
11327    pub fn register_guard_condition<F>(
11328        &mut self,
11329        callback: F,
11330    ) -> Result<(HandleId, GuardCondition), NodeError>
11331    where
11332        F: FnMut() + 'static,
11333    {
11334        self.register_guard_condition_on(None, callback)
11335    }
11336
11337    /// The node-bound form of [`register_guard_condition`](Self::register_guard_condition)
11338    /// — phase-417 W4.e.
11339    ///
11340    /// `node_id == None` is the legacy nodeless path and behaves exactly as
11341    /// `register_guard_condition` always did. With a node, the registered slot
11342    /// takes that node's default `SchedContext`, which is what "the node owns
11343    /// it" has to mean for a callback that shares one executor with every other
11344    /// tier in the image (RFC-0047).
11345    pub fn register_guard_condition_on<F>(
11346        &mut self,
11347        node_id: Option<super::node_record::NodeId>,
11348        callback: F,
11349    ) -> Result<(HandleId, GuardCondition), NodeError>
11350    where
11351        F: FnMut() + 'static,
11352    {
11353        let slot = self.next_entry_slot()?;
11354        let offset = self.arena_alloc::<GuardConditionEntry<F>>()?;
11355
11356        unsafe {
11357            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11358            let entry_ptr = arena_ptr.add(offset) as *mut GuardConditionEntry<F>;
11359            core::ptr::write(
11360                entry_ptr,
11361                GuardConditionEntry {
11362                    flag: portable_atomic::AtomicBool::new(false),
11363                    callback,
11364                },
11365            );
11366
11367            // Create a handle pointing to the flag in the arena
11368            let flag_ptr = &(*entry_ptr).flag as *const portable_atomic::AtomicBool;
11369            #[allow(unused_mut)]
11370            let mut guard_handle = GuardCondition::new(flag_ptr);
11371            // Phase 124.B.5 — wire the wake callback so trigger()
11372            // also signals the executor's wake_cv.
11373            #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
11374            {
11375                let ctx = self.wake_ctx_ptr();
11376                guard_handle.set_wake_cb(nros_rmw_runtime_wake_cb, ctx);
11377            }
11378
11379            let meta = CallbackMeta {
11380                offset,
11381                kind: EntryKind::GuardCondition,
11382                arena_len: super::arena::ArenaLen::UNRECORDED,
11383                try_process: guard_try_process::<F>,
11384                has_data: guard_has_data::<F>,
11385                pre_sample: no_pre_sample,
11386                invocation: InvocationMode::OnNewData,
11387                drop_fn: drop_entry::<GuardConditionEntry<F>>,
11388            };
11389            self.emplace_entry(slot, meta, TraceName::Slot("guard", slot));
11390            self.apply_node_default_sched(slot, node_id, None);
11391
11392            Ok((self.handle_for(slot), guard_handle))
11393        }
11394    }
11395
11396    /// phase-467 Row 8 — a guard condition the BACKEND triggers when the ROS
11397    /// graph changes. Upstream's `rcl_node_get_graph_guard_condition`, under
11398    /// this project's guard-condition shape.
11399    ///
11400    /// **The verb is upstream's, the shape is ours, and the difference is
11401    /// stated rather than silent.** `rcl_node_get_graph_guard_condition`
11402    /// RETURNS a borrowed handle to a guard the node owns, which the caller
11403    /// then adds to a wait set. RFC-0018/0019 forbids returning an owned
11404    /// handle across the C seam, we decline wait sets (RFC-0002: one executor
11405    /// per RTOS task), and a guard condition here is created into CALLER
11406    /// storage with its callback bound at creation. So this CREATES one —
11407    /// exactly as [`register_guard_condition_on`] does — and differs from it
11408    /// only in who pulls the trigger.
11409    ///
11410    /// Order matters and is the reason this is one verb rather than two. The
11411    /// backend is asked whether it HAS a graph before a handle slot is
11412    /// consumed, so a caller on XRCE or uORB gets `Unsupported` and an
11413    /// unchanged executor, never a guard condition nothing will ever fire.
11414    ///
11415    /// [`register_guard_condition_on`]: Self::register_guard_condition_on
11416    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
11417    pub fn register_graph_change_guard_on<F>(
11418        &mut self,
11419        node_id: Option<super::node_record::NodeId>,
11420        callback: F,
11421    ) -> Result<(HandleId, GuardCondition), NodeError>
11422    where
11423        F: FnMut() + 'static,
11424    {
11425        // The probe FIRST — see the doc comment. `supports_*` asks by
11426        // clearing, which is idempotent and costs nothing.
11427        if !self.supports_graph_change_callback() {
11428            return Err(NodeError::Transport(TransportError::Unsupported));
11429        }
11430        let (handle_id, guard) = self.register_guard_condition_on(node_id, callback)?;
11431        self.install_graph_change_guard(&guard)?;
11432        Ok((handle_id, guard))
11433    }
11434
11435    /// The nodeless form of
11436    /// [`register_graph_change_guard_on`](Self::register_graph_change_guard_on),
11437    /// for an image with a session and no node — the same pairing
11438    /// [`register_guard_condition`](Self::register_guard_condition) has.
11439    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
11440    pub fn register_graph_change_guard<F>(
11441        &mut self,
11442        callback: F,
11443    ) -> Result<(HandleId, GuardCondition), NodeError>
11444    where
11445        F: FnMut() + 'static,
11446    {
11447        self.register_graph_change_guard_on(None, callback)
11448    }
11449
11450    /// The same two verbs on a build with no `rmw-cffi` backend seam, or no
11451    /// allocator — they REFUSE rather than disappear.
11452    ///
11453    /// The cfg'd-out spelling was the first shape and it was wrong in a way
11454    /// `just check fast` cannot see: `rclc_node_get_graph_guard_condition` is
11455    /// a C ABI symbol and calls this unconditionally, so a feature
11456    /// combination without the seam stopped `nros-c` from COMPILING
11457    /// (`check-build`'s `workspace-features`, which is the lane that unifies
11458    /// nothing and therefore sees each combination as a user would).
11459    ///
11460    /// Refusing is also the honest answer rather than a compile fix. The
11461    /// graph-change edge is installed on a SESSION through the backend seam;
11462    /// without that seam there is no backend to install it on, and the
11463    /// executor-owned target needs an allocator for the box the backend holds
11464    /// a pointer to. `Unsupported` is what a caller gets from a backend with
11465    /// no graph too, so the C entry point answers `RCL_RET_UNSUPPORTED` in
11466    /// both cases and a ported program sees one behaviour, not two.
11467    #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
11468    pub fn register_graph_change_guard_on<F>(
11469        &mut self,
11470        node_id: Option<super::node_record::NodeId>,
11471        callback: F,
11472    ) -> Result<(HandleId, GuardCondition), NodeError>
11473    where
11474        F: FnMut() + 'static,
11475    {
11476        let _ = (node_id, callback);
11477        Err(NodeError::Transport(TransportError::Unsupported))
11478    }
11479
11480    /// The nodeless form on a build with no backend seam — see
11481    /// [`register_graph_change_guard_on`](Self::register_graph_change_guard_on).
11482    #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
11483    pub fn register_graph_change_guard<F>(
11484        &mut self,
11485        callback: F,
11486    ) -> Result<(HandleId, GuardCondition), NodeError>
11487    where
11488        F: FnMut() + 'static,
11489    {
11490        self.register_graph_change_guard_on(None, callback)
11491    }
11492
11493    // ========================================================================
11494    // Timer control methods
11495    // ========================================================================
11496
11497    /// Cancel a timer. A cancelled timer will not fire but still accumulates
11498    /// elapsed time. The timer can be restarted with [`reset_timer()`](Self::reset_timer).
11499    ///
11500    /// phase-509 P9b item 2 -- while the timer's callback runs in another
11501    /// thread's window, the cancel is recorded and applied when it returns.
11502    pub fn cancel_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
11503        if self.defer_timer_op(id, super::types::SlotTag::CANCEL_PENDING)? {
11504            return Ok(());
11505        }
11506        let meta = self
11507            .entries
11508            .get(self.live_slot(id))
11509            .and_then(|e| e.as_ref())
11510            .ok_or(NodeError::BufferTooSmall)?;
11511        if !matches!(meta.kind, EntryKind::Timer) {
11512            return Err(NodeError::BufferTooSmall);
11513        }
11514        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11515        // SAFETY: meta.offset points to a valid TimerEntry<F> which shares
11516        // layout with TimerHeader for its initial fields (both #[repr(C)]).
11517        let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
11518        header.cancelled = true;
11519        Ok(())
11520    }
11521
11522    /// Reset a timer. Clears the cancelled state and resets the elapsed time
11523    /// to zero, so the timer starts a fresh period.
11524    ///
11525    /// phase-509 P9b item 2 -- deferred like [`Self::cancel_timer`].
11526    pub fn reset_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
11527        if self.defer_timer_op(id, super::types::SlotTag::RESET_PENDING)? {
11528            return Ok(());
11529        }
11530        let meta = self
11531            .entries
11532            .get(self.live_slot(id))
11533            .and_then(|e| e.as_ref())
11534            .ok_or(NodeError::BufferTooSmall)?;
11535        if !matches!(meta.kind, EntryKind::Timer) {
11536            return Err(NodeError::BufferTooSmall);
11537        }
11538        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11539        let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
11540        header.cancelled = false;
11541        header.elapsed_us = 0;
11542        // Issue 1974 — a CLOCK timer advances by `now - last_clock_ns`, and a
11543        // cancelled timer is not polled, so its last reading is from when it
11544        // was cancelled (or, for `autostart = false`, registered). Without a
11545        // re-seed the first poll after this reset would credit the whole
11546        // cancelled interval as one step and fire at once — "a fresh period"
11547        // would hold for wall timers only. `Steady` reads no clock; its seed
11548        // stays 0 and is never read.
11549        header.last_clock_ns = header.clock_source.now_ns();
11550        Ok(())
11551    }
11552
11553    /// phase-509 P9b item 2 -- record a cancel (`op` = `CANCEL_PENDING`) or a
11554    /// reset (`RESET_PENDING`) of the timer `id` when its callback runs in
11555    /// another thread's window, and answer `true`; `false` when it is not,
11556    /// and the caller applies the operation now. The later of the two wins.
11557    fn defer_timer_op(&mut self, id: HandleId, op: u8) -> Result<bool, NodeError> {
11558        let Some(slot) = self.resolve_handle(id) else {
11559            return Ok(false);
11560        };
11561        if !self.running_elsewhere(slot) {
11562            return Ok(false);
11563        }
11564        if !self.entries[slot]
11565            .as_ref()
11566            .is_some_and(|m| matches!(m.kind, EntryKind::Timer))
11567        {
11568            return Err(NodeError::BufferTooSmall);
11569        }
11570        let tag = &self.slot_tags[slot];
11571        tag.clear(super::types::SlotTag::CANCEL_PENDING | super::types::SlotTag::RESET_PENDING);
11572        tag.set(op);
11573        Ok(true)
11574    }
11575
11576    /// phase-509 P9b item 2 -- apply the cancel or reset deferred while the
11577    /// timer in `slot` ran (`defer_timer_op`). Under the lock, its callback
11578    /// returned.
11579    fn apply_deferred_timer_op(&mut self, slot: usize) {
11580        let Some(tag) = self.slot_tags.get(slot) else {
11581            return;
11582        };
11583        let was =
11584            tag.clear(super::types::SlotTag::CANCEL_PENDING | super::types::SlotTag::RESET_PENDING);
11585        if was & (super::types::SlotTag::CANCEL_PENDING | super::types::SlotTag::RESET_PENDING) == 0
11586        {
11587            return;
11588        }
11589        let id = self.handle_for(slot);
11590        if was & super::types::SlotTag::CANCEL_PENDING != 0 {
11591            let _ = self.cancel_timer(id);
11592        } else {
11593            let _ = self.reset_timer(id);
11594        }
11595    }
11596
11597    /// Check if a timer is cancelled.
11598    ///
11599    /// phase-509 P9b item 2 -- a cancel or reset still pending behind the
11600    /// timer's running callback is the answer, as if it had applied.
11601    pub fn timer_is_canceled(&self, id: HandleId) -> bool {
11602        if let Some(slot) = self.resolve_handle(id)
11603            && self.running_elsewhere(slot)
11604        {
11605            let f = self.slot_tags[slot].flags();
11606            if f & super::types::SlotTag::CANCEL_PENDING != 0 {
11607                return true;
11608            }
11609            if f & super::types::SlotTag::RESET_PENDING != 0 {
11610                return false;
11611            }
11612        }
11613        // `resolve_handle`, not `live_slot`: a running callback's dispatch
11614        // never writes `cancelled` (only `cancel_timer` / `reset_timer` do,
11615        // and they are deferred while it runs elsewhere), so the flag is read
11616        // as a plain field, through a raw pointer, with no reference to the
11617        // entry the callback holds.
11618        let meta = match self
11619            .entries
11620            .get(self.resolve_handle(id).unwrap_or(usize::MAX))
11621            .and_then(|e| e.as_ref())
11622        {
11623            Some(m) if matches!(m.kind, EntryKind::Timer) => m,
11624            _ => return false,
11625        };
11626        let arena_ptr = self.arena.as_ptr() as *const u8;
11627        // SAFETY: `meta.offset` is this live entry's slot inside the arena.
11628        let header = unsafe { arena_ptr.add(meta.offset) as *const TimerHeader };
11629        // SAFETY: a Timer entry's arena slot starts with its `TimerHeader`;
11630        // see above for why the read races no write.
11631        unsafe { core::ptr::addr_of!((*header).cancelled).read() }
11632    }
11633
11634    /// Get the period of a timer in milliseconds (truncated — see
11635    /// [`Self::timer_period_us`]), or `None` if the handle is not a
11636    /// valid timer.
11637    pub fn timer_period_ms(&self, id: HandleId) -> Option<u64> {
11638        let meta = self
11639            .entries
11640            .get(self.live_slot(id))
11641            .and_then(|e| e.as_ref())
11642            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11643        let arena_ptr = self.arena.as_ptr() as *const u8;
11644        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11645        Some(header.period_us / 1000)
11646    }
11647
11648    /// Get the period of a timer in microseconds, or `None` if the
11649    /// handle is not a valid timer.
11650    pub fn timer_period_us(&self, id: HandleId) -> Option<u64> {
11651        let meta = self
11652            .entries
11653            .get(self.live_slot(id))
11654            .and_then(|e| e.as_ref())
11655            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11656        let arena_ptr = self.arena.as_ptr() as *const u8;
11657        // SAFETY: same layout invariant as `timer_period_ms`.
11658        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11659        Some(header.period_us)
11660    }
11661
11662    /// Which clock advances this timer, or `None` if the handle is not a valid
11663    /// timer.
11664    ///
11665    /// issue 1975 — the state `rclcpp::TimerBase::get_clock()` reports. The
11666    /// header field is the one `arena::timer_try_process` reads, so the answer
11667    /// is the dispatcher's own rather than a copy a wrapper kept beside it: a
11668    /// wall timer answers [`TimerClockSource::Steady`], a clock timer the
11669    /// source it was registered with.
11670    pub fn timer_clock_source(&self, id: HandleId) -> Option<TimerClockSource> {
11671        let meta = self
11672            .entries
11673            .get(self.live_slot(id))
11674            .and_then(|e| e.as_ref())
11675            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11676        let arena_ptr = self.arena.as_ptr() as *const u8;
11677        // SAFETY: same layout invariant as `timer_period_us`.
11678        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11679        Some(header.clock_source)
11680    }
11681
11682    /// Swap a registered timer's period, in microseconds, returning the one
11683    /// it had — or `None` if the handle is not a valid timer.
11684    ///
11685    /// `TimerHeader::period_us` is the number `arena::timer_try_process`
11686    /// compares `elapsed_us` against, so this writes the period the
11687    /// DISPATCHER honours rather than a second copy of it (RFC-0019). The C
11688    /// entry point `rcl_timer_exchange_period` also keeps
11689    /// `rcl_timer_t::period_ns` in step, because that field is the
11690    /// registration input a later `rclc_executor_add_timer` would re-read.
11691    ///
11692    /// `elapsed_us` is deliberately NOT rewound, which is rcl's own behaviour:
11693    /// `rcl_timer_exchange_period` swaps the period and touches nothing else,
11694    /// so a timer shortened below its accumulated elapsed time becomes ready
11695    /// at once and one lengthened past it simply waits longer. A caller that
11696    /// wants the new period to start fresh calls
11697    /// [`reset_timer`](Self::reset_timer) after it.
11698    ///
11699    /// phase-417 W5.c — the Rust half of `c:timer_exchange_period`, the last
11700    /// timer row the C ledger filed as `gap`. The answer lives here and only
11701    /// the spelling lives in the wrapper, the same split
11702    /// [`timer_elapsed_us`](Self::timer_elapsed_us) made.
11703    pub(crate) fn exchange_timer_period_us(
11704        &mut self,
11705        id: HandleId,
11706        new_period_us: u64,
11707    ) -> Option<u64> {
11708        let offset = self
11709            .entries
11710            .get(self.live_slot(id))
11711            .and_then(|e| e.as_ref())
11712            .filter(|m| matches!(m.kind, EntryKind::Timer))?
11713            .offset;
11714        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11715        // SAFETY: same layout invariant as `timer_period_us`.
11716        let header = unsafe { &mut *(arena_ptr.add(offset) as *mut TimerHeader) };
11717        let old_period_us = header.period_us;
11718        header.period_us = new_period_us;
11719        Some(old_period_us)
11720    }
11721
11722    /// Set a timer's overrun policy (issue #505). Timers default to
11723    /// [`TimerOverrunPolicy::Skip`]; switch to
11724    /// [`TimerOverrunPolicy::CatchUp`] for timers whose every activation
11725    /// is a unit of work that must not be lost.
11726    pub fn set_timer_overrun_policy(
11727        &mut self,
11728        id: HandleId,
11729        policy: TimerOverrunPolicy,
11730    ) -> Result<(), NodeError> {
11731        let meta = self
11732            .entries
11733            .get(self.live_slot(id))
11734            .and_then(|e| e.as_ref())
11735            .ok_or(NodeError::BufferTooSmall)?;
11736        if !matches!(meta.kind, EntryKind::Timer) {
11737            return Err(NodeError::BufferTooSmall);
11738        }
11739        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11740        // SAFETY: same layout invariant as `cancel_timer`.
11741        let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
11742        header.overrun_policy = policy;
11743        Ok(())
11744    }
11745
11746    /// Periods this timer missed and dropped under
11747    /// [`TimerOverrunPolicy::Skip`] (issue #505), or `None` if the handle
11748    /// is not a valid timer.
11749    ///
11750    /// Monotonic and saturating. A growing count is the on-target signal
11751    /// that a tier is not keeping up with its declared cadence — the
11752    /// symptom an external observer would otherwise have to infer by
11753    /// differencing timestamps.
11754    pub fn timer_overruns(&self, id: HandleId) -> Option<u32> {
11755        let meta = self
11756            .entries
11757            .get(self.live_slot(id))
11758            .and_then(|e| e.as_ref())
11759            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11760        let arena_ptr = self.arena.as_ptr() as *const u8;
11761        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11762        Some(header.overruns)
11763    }
11764
11765    /// Microseconds accumulated since this timer last fired, or `None` if the
11766    /// handle is not a valid timer.
11767    ///
11768    /// This is `TimerHeader::elapsed_us` — the SAME counter
11769    /// `arena::timer_try_process` compares against the period and rewinds on
11770    /// every activation, so it is the executor's own answer rather than a
11771    /// second one derived from a clock. `None` is not "zero elapsed": a caller
11772    /// that cannot tell them apart reports a timer that has just fired and a
11773    /// handle that is not a timer identically.
11774    ///
11775    /// phase-417 W5.c — added because the state was already here and nothing
11776    /// exposed it, which is why `c:timer_get_time_since_last_call` and
11777    /// `c:timer_is_ready` were both filed as gaps. The C forwarders
11778    /// (`rcl_timer_get_time_since_last_call`, `rcl_timer_is_ready`) read
11779    /// this; RFC-0019 puts the answer in Rust and only the spelling in the
11780    /// wrapper.
11781    pub(crate) fn timer_elapsed_us(&self, id: HandleId) -> Option<u64> {
11782        let meta = self
11783            .entries
11784            .get(self.live_slot(id))
11785            .and_then(|e| e.as_ref())
11786            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11787        let arena_ptr = self.arena.as_ptr() as *const u8;
11788        // SAFETY: same layout invariant as `timer_period_us`.
11789        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11790        Some(header.elapsed_us)
11791    }
11792
11793    /// Would this timer fire on the next dispatch pass? `None` if the handle
11794    /// is not a valid timer.
11795    ///
11796    /// The predicate is `arena::timer_try_process`'s own guard, in the same
11797    /// order: a cancelled timer is never ready, a one-shot that already fired
11798    /// is never ready again, and otherwise readiness is `elapsed >= period`.
11799    /// Keeping the two in one shape is the point — a readiness answer that
11800    /// disagrees with the dispatcher is worse than no answer at all.
11801    pub(crate) fn timer_is_ready(&self, id: HandleId) -> Option<bool> {
11802        let meta = self
11803            .entries
11804            .get(self.live_slot(id))
11805            .and_then(|e| e.as_ref())
11806            .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11807        let arena_ptr = self.arena.as_ptr() as *const u8;
11808        // SAFETY: same layout invariant as `timer_period_us`.
11809        let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11810        if header.cancelled || (header.oneshot && header.fired) {
11811            return Some(false);
11812        }
11813        Some(header.elapsed_us >= header.period_us)
11814    }
11815
11816    /// Nanoseconds until this timer next fires — NEGATIVE when it is overdue.
11817    /// `None` if the handle is not a valid timer.
11818    ///
11819    /// phase-417 G6. THE computation, and it has to be: rcl's own pair are
11820    /// `next_call_time` and `next_call_time - now`, so upstream cannot have
11821    /// them disagree either. Before this it lived in `nros-c`
11822    /// (~~`remaining_period_ns`~~ + `time_until_next_call_ns_of`), and the C++
11823    /// half of `cpp:Timer::time_until_trigger` would have been a second
11824    /// derivation of the same subtraction in a second crate — the shape
11825    /// CLAUDE.md's "fix the CLASS, add ONE shared helper rather than a second
11826    /// spelling" rule names. `nros-c` forwards here now; so does `nros-cpp`.
11827    ///
11828    /// **The unit is nanoseconds and the RESOLUTION is microseconds.** The
11829    /// arena's timer accounting is microsecond-based (issue #505), so this is a
11830    /// microsecond quantity scaled by 1000. The unit is rcl's; the resolution
11831    /// is ours.
11832    ///
11833    /// Saturating rather than wrapping: `period_us` and `elapsed_us` are both
11834    /// `u64`, so their difference in nanoseconds can leave `i64` in either
11835    /// direction, and a wrap would turn "overdue by a lot" into "due in a
11836    /// long time" — the sign is the whole answer here.
11837    pub fn timer_time_until_next_call_ns(&self, id: HandleId) -> Option<i64> {
11838        let period_us = self.timer_period_us(id)?;
11839        let elapsed_us = self.timer_elapsed_us(id)?;
11840        let delta_ns = (period_us as i128 - elapsed_us as i128).saturating_mul(1_000);
11841        Some(if delta_ns > i64::MAX as i128 {
11842            i64::MAX
11843        } else if delta_ns < i64::MIN as i128 {
11844            i64::MIN
11845        } else {
11846            delta_ns as i64
11847        })
11848    }
11849
11850    // ========================================================================
11851    // spin_once (three-phase: readiness -> trigger -> dispatch)
11852    // ========================================================================
11853
11854    /// Drive I/O and dispatch registered callbacks once.
11855    ///
11856    /// Three-phase execution:
11857    /// 1. **Readiness scan** — query each handle's `has_data()`.
11858    /// 2. **Trigger evaluation** — check if the executor-level trigger passes.
11859    /// 3. **Dispatch** — invoke callbacks according to their `InvocationMode`.
11860    ///
11861    /// Returns a [`SpinOnceResult`] with counts of processed items and errors.
11862    ///
11863    /// # Arguments
11864    /// * `timeout` — upper bound on the I/O wait. Saturated at
11865    ///   `i32::MAX` ms (~24 days) for the underlying transport call.
11866    ///
11867    /// Phase 84.D7: unified on `core::time::Duration`. The previous
11868    /// `timeout_ms: i32` signature had a latent footgun where
11869    /// `spin_once(-1)` silently froze timers while still polling I/O;
11870    /// `Duration` has no negative sentinel.
11871    /// Consecutive `drive_io` failures on the primary session (issue 0324).
11872    ///
11873    /// `0` means the last drive succeeded. A value that keeps climbing across
11874    /// spins is a session that is no longer doing I/O — router gone, lease
11875    /// expired, socket closed — which otherwise presents as a node that spins
11876    /// `Ok(())` forever while publishing nowhere and firing no callbacks.
11877    ///
11878    /// Transient failures happen, so a single non-zero reading is not a fault;
11879    /// a threshold (say, "more than a few consecutive spins") is the useful
11880    /// signal. Extra (bridge / multi-domain) sessions are best-effort and are
11881    /// deliberately NOT counted here.
11882    pub(crate) fn session_io_failures(&self) -> u32 {
11883        self.consecutive_io_failures
11884    }
11885
11886    /// Whether the primary session drove I/O successfully on the last spin.
11887    ///
11888    /// Convenience over [`ExecutorFfi::session_io_failures`](crate::executor::ffi::ExecutorFfi::session_io_failures) for the common
11889    /// "is my transport alive?" check.
11890    pub fn session_io_healthy(&self) -> bool {
11891        self.consecutive_io_failures == 0
11892    }
11893
11894    /// Drive I/O once, then dispatch EVERY callback that is ready — the tick
11895    /// primitive the rest of the family is built from.
11896    ///
11897    /// OURS-ONLY (RFC-0089): rclcpp's `spin_once` executes the NEXT ready item
11898    /// and has no timeout; rclpy's is `spin_once(node, timeout_sec)`. Ours is a
11899    /// blocking wait with a budget, which an RTOS task that must not spin-poll
11900    /// needs and which neither upstream has a verb for. What it DOES when work
11901    /// is ready is rclcpp's `spin_some` — the whole ready set, one pass
11902    /// (RFC-0002 §3) — so [`spin_some`](Self::spin_some) is the drain verb and
11903    /// this is the waiting one.
11904    ///
11905    /// `timeout` is the upper bound on the I/O wait. Since phase-436 W2 it is
11906    /// carried in MICROSECONDS all the way to the park (issue 1193 — an
11907    /// `as_millis()` here truncated `from_micros(500)` to `0`, and a zero
11908    /// timeout selects the non-blocking path, so a sub-millisecond `spin()`
11909    /// was a 100 % CPU loop with no warning); the conversion to whatever the
11910    /// platform primitive accepts happens once, at the park, and rounds UP.
11911    /// `Duration` has no negative sentinel, which is what the pre-phase-84
11912    /// `timeout_ms: i32` signature used to silently mean "freeze the timers
11913    /// and poll I/O forever".
11914    pub fn spin_once(&mut self, timeout: core::time::Duration) -> SpinOnceResult {
11915        let mut discarded = None;
11916        self.spin_once_capturing(timeout, &mut discarded)
11917    }
11918
11919    /// [`spin_once`](Self::spin_once), plus the FIRST failing callback's
11920    /// error.
11921    ///
11922    /// phase-427 — [`SpinOnceResult`] counts errors and drops their values,
11923    /// and `SpinOnceResult` is `Copy`, so widening it to carry a
11924    /// (non-`Copy`, `alloc`-variant-bearing) [`TransportError`] would change a
11925    /// public type every consumer copies. The value is needed in exactly one
11926    /// place — `SpinOptions::stop_on_first_error`, where `spin` has to return
11927    /// the first error rather than a count — so it is threaded out through an
11928    /// out-parameter that lives in the caller's frame. Nothing is added to
11929    /// `Executor`, so a no-alloc image pays nothing for it.
11930    ///
11931    /// `first_error` is written at most once: subsequent failures in the same
11932    /// pass are counted in the result and their values dropped, exactly as
11933    /// before.
11934    fn spin_once_capturing(
11935        &mut self,
11936        timeout: core::time::Duration,
11937        first_error: &mut Option<TransportError>,
11938    ) -> SpinOnceResult {
11939        // SAFETY: `self` is exclusive for the whole call, and an owned pass
11940        // never releases the executor's lock (`PassMode::Owned`), so nothing
11941        // else reaches the executor while it runs.
11942        unsafe { Self::pass(self, PassMode::Owned, timeout, first_error) }
11943    }
11944
11945    /// One pass, over a raw executor pointer (phase-509 P9b item 2, RFC-0108
11946    /// "As built in P9b"). See [`PassMode`]: an owned pass is the
11947    /// `spin_once` every image ran before; a shared pass releases the
11948    /// executor's lock around each user callback, and a band pass runs one
11949    /// worker's band.
11950    ///
11951    /// No `&mut Self` lives across a callback window: `s` is re-derived from
11952    /// `this` after each one, and nothing borrowed from the executor before a
11953    /// window is used after it.
11954    ///
11955    /// # Safety
11956    /// `this` points at a live executor. For `PassMode::Owned`, nothing else
11957    /// reaches it for the call. Otherwise the executor's lock is installed and
11958    /// held exactly once by the calling thread, and every other thread that
11959    /// reaches the executor takes the lock first.
11960    unsafe fn pass(
11961        this: *mut Self,
11962        mut mode: PassMode<'_>,
11963        timeout: core::time::Duration,
11964        first_error: &mut Option<TransportError>,
11965    ) -> SpinOnceResult {
11966        // SAFETY: the caller's contract; re-derived after every window.
11967        #[allow(unused_mut)]
11968        let mut s: &mut Self = unsafe { &mut *this };
11969        // Issue 1705 — releases deferred behind a dispatch that has since
11970        // finished are completed here, before this pass looks at the entries.
11971        s.reap_deferred_releases();
11972        // phase-509 P9b item 2 -- the lock this pass releases around each
11973        // user callback (and, item 3, around its park): only a shared or band
11974        // pass, and only when the lock can tell the callback's thread from
11975        // the others. `None` -- an owned pass, or no lock -- runs the whole
11976        // pass under the caller's hold.
11977        let window_lock = match mode {
11978            PassMode::Owned => None,
11979            _ => s.exec_lock.filter(|l| l.opens_windows()),
11980        };
11981        // phase-509 P9b item 2 -- the owned and shared passes run the
11982        // executor's own worker's prelude (sim time, services, jitter, the
11983        // park, the transport drive); a band pass runs its own clock and
11984        // (item 3) its own park.
11985        //
11986        // SAFETY: the caller's contract is the preludes'; `s` is not used
11987        // across the call and is derived again after it.
11988        let (now_us_this_spin, delta_us) = match &mut mode {
11989            PassMode::Band(w) => unsafe { Self::band_prelude(this, window_lock, w, timeout) },
11990            _ => unsafe { Self::owned_prelude(this, window_lock, timeout) },
11991        };
11992        // SAFETY: the prelude returns holding the lock again.
11993        s = unsafe { &mut *this };
11994        // phase-521 W8 (c) -- the park slot yielded to its framework's
11995        // scheduler: nothing is due (the bound was not zero) and nothing is
11996        // pending, so there is nothing to scan. The tier runs again when its
11997        // work item is scheduled.
11998        if core::mem::take(&mut s.worker.park_yielded) {
11999            return SpinOnceResult::new();
12000        }
12001        // phase-509 P9b item 2 -- this pass's band (every level for the
12002        // one-level worker) and its slots. The scan, the sweep and the drain
12003        // see only these, so a worker never takes another band's work.
12004        let (level, band) = match &mode {
12005            PassMode::Band(w) => (w.level, w.band),
12006            _ => (s.worker.level, s.worker.band),
12007        };
12008        let band_mask = s.band_slots(level, band);
12009        let is_band_pass = matches!(mode, PassMode::Band(_));
12010        let window_thread = window_lock.and_then(|l| l.current_thread()).unwrap_or(0);
12011
12012        let arena_ptr = s.arena.as_mut_ptr() as *mut u8;
12013
12014        // Phase 1: Readiness scan (Phase 110.A.b — backed by FifoReadySet).
12015        //
12016        // `bits` carries data-readiness only (used by trigger eval +
12017        // by `InvocationMode::OnNewData`). `always_mask` carries the
12018        // `InvocationMode::Always` entries that fire regardless of
12019        // data presence. The dispatcher drains `bits | always_mask`
12020        // after the trigger passes. phase-509 P2 -- in dispatch-level
12021        // order (the sched context's bucket, and inside `Normal` the
12022        // node's rank), registration order (lowest bit first) among
12023        // equals, so an image whose nodes hold one rank is bit-identical
12024        // to the pre-refactor `for (i, meta) in entries.iter().enumerate()`
12025        // loop.
12026        let mut bits: u64 = 0;
12027        let mut count: usize = 0;
12028        let mut non_timer_mask: u64 = 0;
12029        // Issue 2162 -- the guard conditions. A ready guard passes
12030        // `Trigger::Any` like a ready subscription does (rclc's
12031        // `rclc_executor_trigger_any` counts every handle), but an IDLE guard
12032        // never holds `Any` or `All` back, so it stays out of `non_timer_mask`.
12033        let mut guard_mask: u64 = 0;
12034        let mut always_mask: u64 = 0;
12035
12036        // phase-397 W3 (RFC-0084) — an ATTRIBUTED slot's readiness is pushed,
12037        // not probed. Its arrivals set its bit in the ready word (W2), so the
12038        // bit is read and `has_data` is not called. Three cases still ask:
12039        //
12040        // - a slot in `entity_recheck`, which may hold data no arrival will
12041        //   announce again (see the field);
12042        // - a subscription with a status-event callback (issue 2060,
12043        //   `entity_status_polled`), whose backend polls those events there;
12044        // - a slot whose backend cannot attribute (XRCE, uORB, a timer, a
12045        //   guard condition), exactly as before.
12046        //
12047        // The bit means the data ARRIVED, not that it is still PRESENT: an
12048        // arrival that races a drain may dispatch to an empty queue, and every
12049        // `try_process` already answers `Ok(false)` to that. A bit cleared
12050        // before its dispatch would lose a message; one that over-reports
12051        // costs one empty call.
12052        //
12053        // The word is taken AFTER the wait and the drive above, so it holds
12054        // every arrival that ended this spin's park, including those a
12055        // single-threaded backend delivered inside `drive_io`. One that lands
12056        // after the take stays in the word and has signalled the wake, so the
12057        // next spin does not park on it.
12058        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
12059        let (attributed, arrived, recheck) = {
12060            let attributed = s.entity_attributed;
12061            // phase-509 P9b item 2 -- only this band's arrivals: another
12062            // band's bits stay in the word for its worker.
12063            let arrived = s
12064                .wake_ctx
12065                .as_ref()
12066                .map_or(0, |ctx| take_band_bits(&ctx.ready, band_mask));
12067            let recheck = s.entity_recheck & band_mask;
12068            s.entity_recheck &= !band_mask;
12069            // Issue 2060 — a slot whose status events are polled from
12070            // `has_data` is asked every pass, like a recheck that never
12071            // drains. It is folded in AFTER the clear, so it never leaks into
12072            // `entity_recheck` and leaves the moment its entry is released.
12073            let recheck = recheck | (s.entity_status_polled & band_mask);
12074            (attributed, arrived & attributed, recheck & attributed)
12075        };
12076        #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
12077        let (attributed, arrived, recheck) = (0u64, 0u64, 0u64);
12078        // Attributed slots the scan could not settle (held mid-dispatch).
12079        let mut carry: u64 = 0;
12080        let mut probes: u32 = 0;
12081
12082        for (i, meta) in s.entries.iter().enumerate() {
12083            let Some(meta) = meta else { continue };
12084            let bit = 1u64 << i;
12085            if band_mask & bit == 0 {
12086                continue;
12087            }
12088            if Self::slot_held(s.slot_tags, i) {
12089                carry |= (arrived | recheck) & bit;
12090                continue;
12091            }
12092            {
12093                let pushed = attributed & bit != 0;
12094                let ready = if pushed && arrived & bit != 0 {
12095                    true
12096                } else if pushed && recheck & bit == 0 {
12097                    false
12098                } else {
12099                    probes = probes.wrapping_add(1);
12100                    let data_ptr = unsafe { arena_ptr.add(meta.offset) as *const u8 };
12101                    unsafe { (meta.has_data)(data_ptr) }
12102                };
12103                if ready {
12104                    bits |= bit;
12105                }
12106                match meta.kind {
12107                    EntryKind::Timer => {}
12108                    EntryKind::GuardCondition => guard_mask |= bit,
12109                    _ => non_timer_mask |= bit,
12110                }
12111                if matches!(meta.invocation, InvocationMode::Always) {
12112                    always_mask |= 1u64 << i;
12113                }
12114                count += 1;
12115            }
12116        }
12117        // Issue 2146 -- a DEFERRED slot that is ready is queued for the
12118        // host's drain (and its notifier called), never dispatched here, and
12119        // takes no part in the trigger. Taken out before the recheck below:
12120        // the drain asks for the recheck when it runs the callback.
12121        if s.deferred_mask != 0 {
12122            bits = s.defer_ready(bits);
12123            non_timer_mask &= !s.deferred_mask;
12124            always_mask &= !s.deferred_mask;
12125        }
12126        // Every attributed slot that was ready is asked again next spin: a
12127        // dispatch takes what one call takes, and a trigger that does not
12128        // pass dispatches nothing, so either may leave data behind.
12129        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
12130        {
12131            s.entity_recheck |= carry | (bits & attributed);
12132            s.readiness_probes = s.readiness_probes.wrapping_add(probes);
12133        }
12134        #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
12135        let _ = (carry, probes);
12136
12137        // phase-462 W5b — an armed deadline row's arrival evidence.
12138        if bits != 0 && !s.age_table.is_empty() {
12139            super::monitor::credit_arrivals(s.age_table, bits);
12140        }
12141
12142        let snapshot = ReadinessSnapshot { bits, count };
12143
12144        // Phase 2: Trigger evaluation
12145        let trigger_passes = match &s.trigger {
12146            // Issue 2162 -- a triggered guard beside an idle subscription
12147            // waited for unrelated traffic (possibly forever) while `Any`
12148            // read only `non_timer_mask`. The `non_timer_mask == 0` arm is
12149            // unchanged: a timer/guard-only executor still passes every spin.
12150            Trigger::Any => bits & (non_timer_mask | guard_mask) != 0 || non_timer_mask == 0,
12151            Trigger::All => bits & non_timer_mask == non_timer_mask,
12152            Trigger::One(id) => snapshot.is_ready(*id),
12153            Trigger::AllOf(set) => snapshot.all_ready(*set),
12154            Trigger::AnyOf(set) => snapshot.any_ready(*set),
12155            Trigger::Always => true,
12156            Trigger::Predicate(f) => f(&snapshot),
12157            Trigger::RawPredicate { callback, context } => {
12158                // Convert ReadinessSnapshot bitmask to a bool array for the C callback
12159                let mut ready_array = [false; 64];
12160                for (i, slot) in ready_array
12161                    .iter_mut()
12162                    .enumerate()
12163                    .take(snapshot.count.min(64))
12164                {
12165                    *slot = snapshot.bits & (1u64 << i) != 0;
12166                }
12167                // SAFETY: The callback and context are provided by the C API caller.
12168                // The ready_array is valid for snapshot.count elements.
12169                unsafe { callback(ready_array.as_ptr(), snapshot.count, *context) }
12170            }
12171        };
12172
12173        if !trigger_passes {
12174            // Timers still need delta accumulation even when trigger doesn't pass
12175            //
12176            // phase-8 — `.enumerate()` (rather than a bare `.flatten()`) because
12177            // `try_process` now carries the entry's slot index for the callback
12178            // trace hooks. This sweep FIRES timer callbacks, so it is a real
12179            // dispatch path and must attribute them like the drain below does.
12180            //
12181            // phase-474 I6 -- and it must TIME them like the drain does. This
12182            // is the path every timer takes on a node that also subscribes
12183            // (the default `Trigger::Any` fails whenever no subscription has
12184            // a sample), and it measured nothing, so `max-latency-runtime`
12185            // never judged a timer path: the safety island's 250 ms overrun of
12186            // a 206 ms path stored no verdict. One clock read before and one
12187            // after each timer's `try_process`, only when a latency contract
12188            // exists and a clock is injected; the publish-count snapshot is a
12189            // fixed array on the stack, nothing allocates. The measured span
12190            // is callback entry to exit, as in the drains; release jitter is
12191            // `release-jitter-runtime`'s.
12192            let mon_table = s.monitor_table;
12193            // phase10-B3 -- the route rule reads the same clock and snapshot:
12194            // a timer tick is where a sampled route's output is published.
12195            let lat_clock = s.clock_us_fn.filter(|_| {
12196                mon_table.iter().any(|m| m.max_latency_ms > 0)
12197                    || super::monitor::route_active(mon_table)
12198            });
12199            for i in 0..s.entries.len() {
12200                let Some(meta) = s.entries[i].as_ref() else {
12201                    continue;
12202                };
12203                if matches!(meta.kind, EntryKind::Timer)
12204                    && band_mask & (1u64 << i) != 0
12205                    && !Self::slot_held(s.slot_tags, i)
12206                {
12207                    let data_ptr = unsafe { arena_ptr.add(meta.offset) };
12208                    let try_process = meta.try_process;
12209                    let counts_before = snapshot_pub_counts(mon_table, lat_clock.is_some());
12210                    let start_us = lat_clock.map(|c| c());
12211                    Self::begin_dispatch(s.slot_tags, i);
12212                    // phase-509 P9b item 2 -- the callback window: see the
12213                    // drain below.
12214                    let mut rec = super::exec_lock::Running {
12215                        slot: i,
12216                        thread: window_thread,
12217                        next: core::ptr::null_mut(),
12218                    };
12219                    let window = window_lock.map(|l| {
12220                        // SAFETY: `rec` outlives the window and is unlinked
12221                        // under the lock right after it.
12222                        unsafe { s.link_running(&mut rec) };
12223                        super::exec_lock::Window::open(l)
12224                    });
12225                    // phase-463 W7 -- the host profile times this sweep too:
12226                    // it is the path every timer of a node that also
12227                    // subscribes takes (see above), so missing it measured
12228                    // the talker's timer at zero invocations.
12229                    //
12230                    // The OFF arm is the original `let _ =`, untouched, and
12231                    // that is MEASURED rather than tidy: every binding tried
12232                    // in its place (`let _p`, `let p; drop(p)`) moved
12233                    // `spin_once_capturing` by 49-64 B in the Zephyr image
12234                    // (phase-463 W5 I3c). Hence the second `unsafe` block,
12235                    // recorded in the unsafe census.
12236                    #[cfg(not(feature = "profile-mode"))]
12237                    let _ = unsafe { try_process(data_ptr, delta_us, i as u8) };
12238                    #[cfg(feature = "profile-mode")]
12239                    {
12240                        super::profile::dispatch_begin(arena_ptr as usize, i);
12241                        let processed = unsafe { try_process(data_ptr, delta_us, i as u8) };
12242                        super::profile::dispatch_end(matches!(processed, Ok(true)));
12243                    }
12244                    // Read before the lock is taken back, so a wait for it
12245                    // is not counted as the callback's latency.
12246                    let swept_end_us = start_us.and(lat_clock).map(|c| c());
12247                    if let Some(w) = window {
12248                        drop(w);
12249                        // SAFETY: the lock is held again; nothing borrowed
12250                        // from the executor before the window is used after.
12251                        s = unsafe { &mut *this };
12252                        s.unlink_running(&mut rec);
12253                    }
12254                    if let (Some(end_us), Some(t0)) = (swept_end_us, start_us) {
12255                        let elapsed_us = end_us.saturating_sub(t0).min(u32::MAX as u64) as u32;
12256                        attribute_latency(mon_table, true, &counts_before, elapsed_us);
12257                        let hook = s.reaction_hook.filter(|_| s.violations.counts.armed);
12258                        let violations = &mut s.violations;
12259                        super::monitor::route_judge(
12260                            mon_table,
12261                            &counts_before,
12262                            end_us,
12263                            |row, v, record, react| {
12264                                if react {
12265                                    call_route_reaction(hook, mon_table, row, &v, end_us);
12266                                }
12267                                if record {
12268                                    violations.record(v)
12269                                }
12270                            },
12271                        );
12272                    }
12273                    s.finish_dispatch(i);
12274                }
12275            }
12276
12277            // phase-509 P9b item 2 -- the executor-wide chores below are the
12278            // executor's own worker's. Issue 2020 -- the band's own
12279            // components' ticks are the band's.
12280            if is_band_pass {
12281                // SAFETY: the pass's contract; `s` is not used after it.
12282                unsafe { Self::tick_components(this, Some(level)) };
12283                return SpinOnceResult::new();
12284            }
12285
12286            // Parameter services live outside the arena and must be processed
12287            // regardless of trigger state, otherwise ROS 2 param queries time out.
12288            #[cfg(feature = "param-store")]
12289            {
12290                #[allow(unused_mut)] // only the services add to it
12291                let mut handled = 0usize;
12292                #[cfg(feature = "param-services")]
12293                if let Some(params) = &mut s.params {
12294                    {
12295                        let crate::param_store::ParamState {
12296                            server,
12297                            services,
12298                            buffers,
12299                            staged,
12300                            ..
12301                        } = &mut **params;
12302                        // phase-426 W3 — one set per node; every set is pumped,
12303                        // because `ros2 param list` addresses each node's own
12304                        // six and a set nobody polls times out.
12305                        //
12306                        // Issue 1270 -- all of them through ONE buffer pair.
12307                        // Issue 1271 -- a request that got no reply is logged
12308                        // inside, once per service; there is no error to drop.
12309                        if let Some(buffers) = buffers {
12310                            for set in services.iter_mut() {
12311                                handled += set.process(server, staged, buffers);
12312                            }
12313                        }
12314                    }
12315                }
12316                // phase-425 W3b — a `ros2 param set … use_sim_time true` arrives
12317                // through this path.
12318                s.note_param_services_ran(handled);
12319            }
12320
12321            // Same treatment for lifecycle services — `ros2 lifecycle get`
12322            // must succeed even when no callbacks fired this tick.
12323            //
12324            // Issue 1587 — `process` also drains the state machine's
12325            // announcement queue onto `~/transition_event`, so a transition
12326            // triggered from application code between two spins (autostart is
12327            // the common one) reaches a watching supervisor here.
12328            // SAFETY: see the matching invariant on the later call site.
12329            #[cfg(feature = "lifecycle-services")]
12330            if let Some(lc) = &mut s.lifecycle {
12331                let _ = unsafe { lc.process() };
12332            }
12333
12334            // phase-474 I7 -- an idle spin is the natural slot for the queued
12335            // reports.
12336            s.flush_violation_reports();
12337
12338            return SpinOnceResult::new();
12339        }
12340
12341        // Phase 2.5: LET pre-sample (only when LogicalExecutionTime)
12342        //
12343        // Sample all subscription data into entry buffers BEFORE dispatching
12344        // any callbacks. This ensures all callbacks in this cycle see a
12345        // consistent snapshot of data from the same point in time.
12346        // Services are NOT pre-sampled (request-reply is sequential).
12347        if matches!(s.semantics, ExecutorSemantics::LogicalExecutionTime) {
12348            for (i, meta) in s.entries.iter().enumerate() {
12349                let Some(meta) = meta else { continue };
12350                if matches!(meta.kind, EntryKind::Subscription)
12351                    && band_mask & (1u64 << i) != 0
12352                    && !Self::slot_held(s.slot_tags, i)
12353                {
12354                    let data_ptr = unsafe { arena_ptr.add(meta.offset) };
12355                    unsafe { (meta.pre_sample)(data_ptr) };
12356                }
12357            }
12358        }
12359
12360        // Phase 3: Dispatch (Phase 110.C — bucketed by SC.priority).
12361        //
12362        // Two ready-set families, each split across `Priority::COUNT`
12363        // buckets (Critical / Normal / BestEffort). Per-entry SC
12364        // `class` selects FIFO bitmap vs EDF heap; SC `priority`
12365        // selects the bucket within. Drain order:
12366        //   for each bucket in priority order (Critical first):
12367        //     drain EDF heap (deadline-priority), then FIFO bitmap
12368        //     (registration-order)
12369        // Default workloads — every entry on the auto-default Fifo SC
12370        // (Normal priority) — populate only `fifo[Normal]`, so
12371        // dispatch order is bit-identical to 110.B.b for those.
12372        const NB: usize = super::sched_context::Priority::COUNT;
12373        let mut result = SpinOnceResult::new();
12374        // phase-509 P2 (RFC-0108 D2) -- the FIFO half is ordered by dispatch
12375        // level: a bucket's reserved level, or `1 + rank` inside `Normal`, the
12376        // rank being the one the entry's node was seeded with. Equal levels
12377        // keep registration order, so an image with one rank is unchanged.
12378        let mut fifo: super::ready_set::RankedFifoSet<{ MAX_CALLBACK_SLOTS }> =
12379            super::ready_set::RankedFifoSet::new();
12380        let mut edf: super::ready_set::BucketedEdfSet<NB, { MAX_CALLBACK_SLOTS }> =
12381            super::ready_set::BucketedEdfSet::new();
12382        let active_mask = bits | always_mask;
12383
12384        // Phase 110.E — refill any Sporadic SC budgets at period
12385        // boundaries before deciding what to dispatch this cycle.
12386        // Refill is polled (not ISR-driven) — coarse but correct
12387        // upper-bound bandwidth limiter.
12388        // phase-359 W4 — was `#[cfg(feature = "std")]` with NO no_std arm, so
12389        // polled Sporadic budgets never refilled on embedded: they exhausted
12390        // once and stayed exhausted. That was a consequence of "no clock on
12391        // no_std", which is false whenever a `clock_us` hook is injected. Now it
12392        // runs on either flavour when a clock exists, and is skipped when none
12393        // does — identical to today's behaviour in that case.
12394        if let Some(now_us) = now_us_this_spin {
12395            let now_ms = now_us / 1000;
12396            // issue 0736 — REFILL only. This used to also charge the cycle's
12397            // `delta_us` as the per-SC consumption estimate ("worst-case
12398            // attribution", pending a higher-precision clock hook). That hook
12399            // landed, and the attribution was not merely coarse: `delta_us` is
12400            // the wall-clock gap between spins, not CPU the callbacks spent, so
12401            // wherever the gap exceeds the budget the SC is exhausted on every
12402            // spin regardless of what it ran. Consumption is charged from
12403            // measured callback runtime in `consume_dispatch_runtime_us`.
12404            for slot in s.sporadic_states.iter_mut().flatten() {
12405                let _ = slot.refill(now_ms);
12406            }
12407        }
12408
12409        for i in 0..s.entries.len() {
12410            if active_mask & (1u64 << i) == 0 {
12411                continue;
12412            }
12413            let sc_idx = s.sched_context_bindings[i].0 as usize;
12414            let sc_class_priority_deadline = s
12415                .sched_contexts
12416                .get(sc_idx)
12417                .and_then(|s| s.as_ref())
12418                .map(|sc| {
12419                    (
12420                        sc.class,
12421                        sc.priority.index(),
12422                        sc.deadline_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX),
12423                    )
12424                });
12425            let (sc_class, bucket, deadline_us) = sc_class_priority_deadline.unwrap_or((
12426                super::sched_context::SchedClass::Fifo,
12427                super::sched_context::Priority::Normal.index(),
12428                u32::MAX,
12429            ));
12430            // Phase 110.E — Sporadic SC dispatch is suppressed when
12431            // its budget is exhausted. Atomic path (110.E.b PlatformTimer
12432            // refill) takes precedence when registered; polled path
12433            // (cycle-level delta_us attribution) handles the unregistered
12434            // case. Either way, exhausted budget skips dispatch.
12435            if matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
12436                #[cfg(feature = "alloc")]
12437                let atomic_has_budget = s
12438                    .sporadic_atomic_states
12439                    .get(sc_idx)
12440                    .and_then(|s| s.as_ref())
12441                    .map(|(state, _)| state.has_budget());
12442                #[cfg(not(feature = "alloc"))]
12443                let atomic_has_budget: Option<bool> = None;
12444                let has_budget = match atomic_has_budget {
12445                    Some(b) => b,
12446                    None => s
12447                        .sporadic_states
12448                        .get(sc_idx)
12449                        .and_then(|s| s.as_ref())
12450                        .map(|s| s.budget_remaining_us > 0)
12451                        .unwrap_or(true),
12452                };
12453                if !has_budget {
12454                    // issue 0736 — keep the TIMER'S CLOCK honest even while the
12455                    // budget gates its dispatch.
12456                    //
12457                    // A timer's `elapsed_us` only advances inside
12458                    // `timer_try_process`, which is reached only when the entry
12459                    // is DISPATCHED, and each dispatch is handed just THIS
12460                    // cycle's `delta_us`. So every cycle skipped here was
12461                    // dropped from the timer's sense of time: it did not fire,
12462                    // it did not learn that its period had passed, and — because
12463                    // the overrun counter is driven by that same `elapsed_us` —
12464                    // it did not count the activation it missed.
12465                    //
12466                    // That is why the throttle was invisible. Measured on
12467                    // nuttx-arm/rust: ~238 activations lost against ~40
12468                    // reported, so five in six missed firings were unaccounted
12469                    // ANYWHERE. The tier ran at a quarter of its declared rate
12470                    // and the only honest signal was the delivery count itself.
12471                    //
12472                    // Advancing the clock here does not dispatch anything and
12473                    // does not defeat the budget. It makes the entry's own
12474                    // accounting independent of whether the executor CHOSE to
12475                    // run it, which is the property every "did this meet its
12476                    // declaration?" question needs, and it lets the existing
12477                    // `Skip` policy count the backlog and the existing
12478                    // `timer-overrun-runtime` rule report it.
12479                    if let Some(meta) = s.entries[i].as_ref()
12480                        && matches!(meta.kind, EntryKind::Timer)
12481                    {
12482                        // SAFETY: a Timer entry's arena slot holds a
12483                        // `TimerEntry<F>`, whose leading layout IS
12484                        // `TimerHeader` — the same cast the overrun reporter
12485                        // one screen up already relies on.
12486                        let header =
12487                            unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
12488                        // Issue 1321 — on the TIMER'S clock, not the
12489                        // executor's. This line read
12490                        // `header.elapsed_us += delta_us`, which is wall
12491                        // microseconds: for a `Ros` timer that advanced a
12492                        // simulated-time counter by wall time, so a paused
12493                        // `/clock` no longer paused the timer — the one
12494                        // property a ROS-time timer has. Unbounded, unlike
12495                        // 1321's park bound, because it can fire a callback
12496                        // that no clock step earned.
12497                        super::arena::timer_advance_without_dispatch(header, delta_us);
12498                    }
12499                    // issue 0736 — a budget skip used to be a bare `continue`,
12500                    // which is the silent-drop shape 0737 gated one layer out:
12501                    // the entry is simply not dispatched and nothing anywhere
12502                    // says so, so a permanently starved tier is
12503                    // indistinguishable from an idle one. It took a hand-run
12504                    // 45 s image and a per-executor probe to learn that this
12505                    // line was skipping 96 % of one tier's spins.
12506                    // The rolling window catches a budget that THROTTLES; the
12507                    // streak below catches one that starves outright. issue
12508                    // 0736 needed both: the measured case skipped ~3 spins in
12509                    // 4 without ever reaching a streak, so the tier ran at a
12510                    // quarter of its declared rate in silence.
12511                    if let Some((skips, total)) = s
12512                        .sporadic_states
12513                        .get_mut(sc_idx)
12514                        .and_then(|st| st.as_mut())
12515                        .and_then(|st| st.take_budget_window())
12516                    {
12517                        nros_log::log_warn!(
12518                            nros_log::get_logger("nros"),
12519                            "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.",
12520                            sc_idx,
12521                            skips,
12522                            total
12523                        );
12524                    }
12525                    if let Some(streak) = s
12526                        .sporadic_states
12527                        .get_mut(sc_idx)
12528                        .and_then(|st| st.as_mut())
12529                        .and_then(|st| st.note_budget_skip())
12530                    {
12531                        nros_log::log_warn!(
12532                            nros_log::get_logger("nros"),
12533                            "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.",
12534                            streak,
12535                            sc_idx
12536                        );
12537                    }
12538                    continue;
12539                }
12540                // Phase 110.E.b follow-up — per-callback runtime
12541                // accounting (replaces this cycle-level attribution)
12542                // is applied at dispatch time below via
12543                // `consume_dispatch_runtime_us`. We only update the
12544                // polled-path `SporadicState` (no_std fallback) here
12545                // because the atomic path now records actual
12546                // wall-clock per-callback runtime. The
12547                // `delta_us` over-attribution that previously hit the
12548                // atomic state was a worst-case bandwidth limiter;
12549                // per-callback measurement is strictly tighter.
12550                #[cfg(not(feature = "alloc"))]
12551                {
12552                    let _ = sc_idx; // polled-state path lives in
12553                    // `sporadic_states`; this branch is a no-op when
12554                    // the atomic path is enabled.
12555                }
12556            }
12557            // Phase 110.G — TT window gate, orthogonal to class.
12558            // Skips dispatch when the SC has a TT window AND the
12559            // current monotonic time is outside it. Both gates apply
12560            // independently — a Sporadic SC with a TT window must
12561            // pass both.
12562            if s.major_frame_us > 0 {
12563                let sc_opt = s.sched_contexts.get(sc_idx).and_then(|s| s.as_ref());
12564                if let Some(sc) = sc_opt {
12565                    let off = sc.tt_window_offset_us.get().map(|nz| nz.get()).unwrap_or(0);
12566                    let dur = sc
12567                        .tt_window_duration_us
12568                        .get()
12569                        .map(|nz| nz.get())
12570                        .unwrap_or(0);
12571                    if dur > 0 {
12572                        // Compute current phase within the major
12573                        // frame using the accumulated `delta_us` clock
12574                        // (std-only precise; no_std uses `delta_us`
12575                        // approximation from spin cadence).
12576                        // phase-359 W4 — the shared read. The no_std arm used
12577                        // to be `now_us = delta_us`, i.e. a per-spin INTERVAL
12578                        // used as an absolute phase clock, which the comment
12579                        // above called an approximation. It is now the real
12580                        // clock when one is injected, and falls back to the old
12581                        // approximation only when none is.
12582                        let now_us = now_us_this_spin.unwrap_or(delta_us);
12583                        let phase = (now_us % s.major_frame_us as u64) as u32;
12584                        let in_window = if off + dur <= s.major_frame_us {
12585                            phase >= off && phase < off + dur
12586                        } else {
12587                            // Window wraps the major frame boundary.
12588                            let end = (off as u64 + dur as u64) % s.major_frame_us as u64;
12589                            phase >= off || (phase as u64) < end
12590                        };
12591                        if !in_window {
12592                            continue;
12593                        }
12594                    }
12595                }
12596            }
12597            let is_edf = matches!(sc_class, super::sched_context::SchedClass::Edf);
12598            let job = super::types::ActiveJob {
12599                sort_key: if is_edf { deadline_us } else { i as u32 },
12600                desc_idx: i as super::types::DescIdx,
12601            };
12602            if is_edf {
12603                let _ = edf.insert_into(bucket, job);
12604            } else {
12605                let rank = s.slot_tags.get(i).map_or(0, |t| t.rank);
12606                let level = super::sched_context::Priority::from_index(bucket).dispatch_level(rank);
12607                // phase-509 P6 / P9b item 2 -- the scan saw only this pass's
12608                // band, so nothing it queued is another band's.
12609                debug_assert!(
12610                    s.worker.registering_level != super::scheduler::NO_OWNING_LEVEL
12611                        || band.contains(level)
12612                );
12613                let _ = fifo.insert_at(level, job);
12614            }
12615        }
12616        // phase-509 P3 -- every slot this pass has already settled: queued
12617        // above, or held back by a gate (budget, window, worker). A slot in
12618        // it never joins the pass again, so each entry runs at most once per
12619        // pass (a timer's clock advances by one `delta_us` per pass, and a
12620        // callback that readies itself cannot hold the pass forever).
12621        let mut settled: u64 = active_mask;
12622        // phase-509 P3 -- the LET semantics dispatch the pass's snapshot,
12623        // sampled before any callback ran; nothing joins it mid-pass.
12624        // A trigger narrower than "any" (`One`, `AllOf`, a predicate) chose
12625        // this pass's work from the scan; it stays the scan's.
12626        let repick = !matches!(s.semantics, ExecutorSemantics::LogicalExecutionTime)
12627            && matches!(s.trigger, Trigger::Any | Trigger::Always);
12628
12629        // SAFETY: each `desc_idx` we pop was set above only when the
12630        // corresponding `entries[i]` slot was `Some`; no Executor
12631        // mutation happens between that scan and this dispatch.
12632        let mut dispatch_one = |meta: &CallbackMeta,
12633                                desc_idx: usize,
12634                                arena_ptr: *mut u8,
12635                                delta_us: u64,
12636                                result: &mut SpinOnceResult| {
12637            // Phase 141.B.2 — capture T1 at subscription dispatch
12638            // entry. Probe pairs it with the most recent T0 from
12639            // `nros_rmw_runtime_wake_cb` (std + alloc variants)
12640            // and pushes T1 - T0 onto the ring buffer 141.C
12641            // drains. No-op when the probe feature is off or
12642            // no cycle reader is installed. Other entry kinds
12643            // (Service / Timer / GuardCondition) skip the probe
12644            // because the 141 acceptance is specifically
12645            // wake-to-subscription-dispatch latency.
12646            #[cfg(feature = "wake-latency-probe")]
12647            if matches!(meta.kind, EntryKind::Subscription) {
12648                super::wake_probe::on_dispatch();
12649            }
12650            let data_ptr = unsafe { arena_ptr.add(meta.offset) };
12651            // phase-8 — `desc_idx` is the entry slot index, threaded through so
12652            // the leaf hooks in `arena.rs` can name the callback they bracket.
12653            // `MAX_CALLBACK_SLOTS` is 64 (enforced by the `u64` ready-set
12654            // bitmask), so the `as u8` cannot truncate a live slot.
12655            // phase-463 W7 — time the dispatch and attribute its publishes
12656            // (host `profile-mode` only). ONE call site for both builds, so no
12657            // second `unsafe` block; the binding is the only change an RTOS
12658            // image sees, and its loadable bytes measured identical (phase-463
12659            // W5 I3c, Zephyr cpp/talker).
12660            #[cfg(feature = "profile-mode")]
12661            super::profile::dispatch_begin(arena_ptr as usize, desc_idx);
12662            let processed = unsafe { (meta.try_process)(data_ptr, delta_us, desc_idx as u8) };
12663            #[cfg(feature = "profile-mode")]
12664            super::profile::dispatch_end(matches!(processed, Ok(true)));
12665            match processed {
12666                Ok(true) => match meta.kind {
12667                    EntryKind::Subscription => result.subscriptions_processed += 1,
12668                    EntryKind::Service
12669                    | EntryKind::ServiceClient
12670                    | EntryKind::ActionServer
12671                    | EntryKind::ActionClient => result.services_handled += 1,
12672                    EntryKind::Timer => result.timers_fired += 1,
12673                    EntryKind::GuardCondition => {}
12674                },
12675                Ok(false) => {}
12676                Err(e) => {
12677                    // phase-427 — the first one is kept, the rest are counted.
12678                    // `stop_on_first_error` is what reads it; when nobody
12679                    // asked, the `Option` is a local the optimiser drops.
12680                    if first_error.is_none() {
12681                        *first_error = Some(e);
12682                    }
12683                    match meta.kind {
12684                        EntryKind::Subscription => result.subscription_errors += 1,
12685                        EntryKind::Service
12686                        | EntryKind::ServiceClient
12687                        | EntryKind::ActionServer
12688                        | EntryKind::ActionClient => result.service_errors += 1,
12689                        EntryKind::Timer | EntryKind::GuardCondition => {}
12690                    }
12691                }
12692            }
12693        };
12694
12695        // Phase 110.E.b follow-up — per-callback runtime accounting
12696        // for Sporadic SCs. Wall-clock-measure each dispatch and
12697        // consume the elapsed microseconds from the bound SC's
12698        // atomic budget. This replaces the cycle-level over-
12699        // attribution that previously charged the FULL `delta_us`
12700        // against every Sporadic SC regardless of which entries
12701        // actually fired — accurate per-callback measurement is the
12702        // shape the design doc's per-callback runtime acceptance
12703        // calls out.
12704        //
12705        // phase-359 W10 — this was `feature = "std"`-gated, because it "needs a
12706        // `core::time::Instant`-equivalent monotonic clock ... until a
12707        // board-side monotonic-microsecond accessor lands". That accessor
12708        // landed: `now_us()` reads the platform monotonic counter on every
12709        // flavour. So per-callback sporadic accounting is no longer something
12710        // only a hosted build gets, and the no_std fallback to polled
12711        // `SporadicState` cycle deltas is no longer the only option on target.
12712        let consume_dispatch_runtime_us =
12713            |desc_idx: usize,
12714             elapsed_us: u32,
12715             sched_context_bindings: &[super::sched_context::SchedContextId],
12716             sched_contexts: &[Option<super::sched_context::SchedContext>],
12717             sporadic_states: &mut [Option<super::sched_context::SporadicState>],
12718             alive_slots: &mut [super::monitor::AliveSlot],
12719             #[cfg(feature = "alloc")] sporadic_atomic_states: &[Option<(
12720                portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
12721                OpaqueTimerHandle,
12722            )>]| {
12723                let sc_idx = sched_context_bindings[desc_idx].0 as usize;
12724                // Alive supervision counts EVERY dispatch, before the class
12725                // gate below: a Fifo or time-triggered context can fall
12726                // silent exactly as a sporadic one can, and the rule that
12727                // notices must not be scoped to the class that happens to
12728                // carry a budget.
12729                if let Some(slot) = alive_slots.get_mut(sc_idx) {
12730                    slot.dispatches = slot.dispatches.wrapping_add(1);
12731                }
12732                let sc_class = sched_contexts
12733                    .get(sc_idx)
12734                    .and_then(|s| s.as_ref())
12735                    .map(|sc| sc.class)
12736                    .unwrap_or(super::sched_context::SchedClass::Fifo);
12737                if !matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
12738                    return;
12739                }
12740                #[cfg(feature = "alloc")]
12741                if let Some((state, _)) =
12742                    sporadic_atomic_states.get(sc_idx).and_then(|s| s.as_ref())
12743                {
12744                    state.consume(elapsed_us);
12745                    // Unconditional, unlike the overrun record below: the
12746                    // budget is sized FROM this number, so it has to exist
12747                    // before the budget is right rather than only after it
12748                    // is wrong.
12749                    state.record_exec(elapsed_us);
12750                    // Phase 110.E.b — overrun detection. Cooperative
12751                    // single-thread can't preempt a runaway callback,
12752                    // so post-dispatch wall-clock comparison delivers
12753                    // the same observable signal as the design's
12754                    // oneshot-IRQ-and-cancel pattern, without needing
12755                    // a separate timer per SC. `budget_capacity_us` is
12756                    // the per-period budget the SC was sized against;
12757                    // any callback exceeding that has run past its
12758                    // bandwidth allotment.
12759                    if elapsed_us > state.budget_capacity_us {
12760                        state.record_overrun(elapsed_us - state.budget_capacity_us);
12761                    }
12762                }
12763                // issue 0736 — the POLLED state is charged unconditionally,
12764                // and it is the one that matters: `has_budget` prefers the
12765                // atomic state only when a refill timer was registered, which
12766                // no board or entry does. Before this, the alloc arm charged a
12767                // state nothing populated and the no_std arm discarded the
12768                // measurement outright (`let _ = (sc_idx, elapsed_us)`), so no
12769                // shipped image recorded per-callback runtime anywhere — while
12770                // the comment above claimed it did.
12771                if let Some(state) = sporadic_states.get_mut(sc_idx).and_then(|s| s.as_mut()) {
12772                    state.consume(elapsed_us);
12773                }
12774            };
12775
12776        // W3b.5 — post-dispatch contract checks. `lat_active` gates the
12777        // per-dispatch publish-count snapshot (attribution of dispatch
12778        // elapsed time to monitored publishers whose counter advanced);
12779        // `dl_active` gates elapsed measurement for deadline actions. The third
12780        // term used to be `cfg!(feature = "std")` — "std measures anyway for
12781        // the sporadic path" — which made a COMPILE-TIME flavour stand in for
12782        // "is there a clock". It is now the runtime question it always was.
12783        let mon_table = s.monitor_table;
12784        let lat_active = mon_table.iter().any(|m| m.max_latency_ms > 0);
12785        let dl_active = s.sched_contexts.iter().flatten().any(|sc| {
12786            sc.deadline_us.is_some()
12787                && !matches!(
12788                    sc.deadline_action,
12789                    super::sched_context::DeadlineAction::Ignore
12790                )
12791        });
12792        let mon_clock = s.clock_us_fn;
12793        // phase10-B3 -- the route rule needs the clock and the publish-count
12794        // snapshot, and nothing else; without a clock it judges nothing.
12795        let route_on = mon_clock.is_some() && super::monitor::route_active(mon_table);
12796        // phase-503 -- a late supervised route reacts at completion too; not
12797        // before the monitors are armed (phase-474 I2), as after the park.
12798        let route_hook = s.reaction_hook.filter(|_| s.violations.counts.armed);
12799        let counts_on = lat_active || route_on;
12800        // phase-359 W4 — one predicate, no cfg block. `cfg!` is an expression,
12801        // so the flavour difference stays a value instead of a branch: std
12802        // measures unconditionally because the sporadic runtime accounting
12803        // below (itself std-only) consumes the result, which is exactly what
12804        // the comment above described and what the four-arm sites encoded.
12805        let measure_us = counts_on || dl_active || mon_clock.is_some();
12806        // phase-359 W4 — ONE hoisted µs reader for the per-dispatch latency
12807        // measurement below. Hoisted because `now_us()` takes `&mut self` and
12808        // the dispatch loop already holds borrows; the std arm copies the epoch
12809        // (a `Copy` `Instant`) so reading inside the loop touches no `self`.
12810        // This is the last cfg pair in the timing path: the two call sites it
12811        // serves each had FOUR arms (start + elapsed, per flavour).
12812        let read_us = move || mon_clock.map(|c| c()).unwrap_or(0);
12813        // Deferred deadline-miss violations (the loop body holds an
12814        // immutable borrow of `s.entries`, so the ring is fed after).
12815        let mut deadline_misses: heapless::Vec<
12816            super::monitor::Violation,
12817            { super::monitor::MAX_VIOLATIONS },
12818        > = heapless::Vec::new();
12819        // SCs whose remaining callbacks this cycle are skipped
12820        // (`DeadlineAction::Skip`) — bitmask over SC slots.
12821        let mut skipped_scs: u64 = 0;
12822
12823        // For each priority bucket (Critical → Normal → BestEffort),
12824        // drain EDF first then FIFO so an EDF callback in this bucket
12825        // beats a FIFO peer at the same priority, but no lower-priority
12826        // entry runs while a higher-priority bucket has work pending.
12827        // Strict static priority across buckets; non-preemptive within
12828        // an in-flight callback (see Phase 110.D).
12829        // phase-509 P3 (RFC-0108 D2) -- one pick at a time. Each pick is the
12830        // most urgent queued job (bucket by bucket: EDF heap, then the ranked
12831        // FIFO's levels), and after each callback the ready set is merged
12832        // with what became ready while it ran, so an urgent callback made
12833        // ready by a less urgent one runs before the rest of the pass. Was a
12834        // drain of the scan's snapshot, bucket by bucket.
12835        loop {
12836            let mut next = None;
12837            for bucket in 0..NB {
12838                if let Some(job) = edf.pop_from(bucket) {
12839                    next = Some(job);
12840                    break;
12841                }
12842                let (level_lo, level_hi) =
12843                    super::sched_context::Priority::from_index(bucket).level_range();
12844                if let Some(job) = fifo.pop_in(level_lo, level_hi) {
12845                    next = Some(job);
12846                    break;
12847                }
12848            }
12849            let Some(job) = next else { break };
12850            let i = job.desc_idx as usize;
12851            let sc_idx = s.sched_context_bindings[i].0 as usize;
12852            if sc_idx < 64 && skipped_scs & (1u64 << sc_idx) != 0 {
12853                continue; // W3b.5 DeadlineAction::Skip containment
12854            }
12855            if let Some(meta) = s.entries[i]
12856                && !Self::slot_held(s.slot_tags, i)
12857            {
12858                let counts_before = snapshot_pub_counts(mon_table, counts_on);
12859                // Measured only when a consumer wants it, matching the
12860                // no_std arm this replaces — the std arm used to measure
12861                // unconditionally.
12862                let start_us = measure_us.then(read_us);
12863                let took_before = result.subscriptions_processed;
12864                Self::begin_dispatch(s.slot_tags, i);
12865                // phase-509 P9b item 2 (RFC-0108 "As built in P9b") -- the
12866                // callback window. With a lock that can tell threads apart,
12867                // a shared or band pass gives it for the callback, so a more
12868                // urgent worker (or an FFI caller) reaches the executor
12869                // while this callback runs. What crosses the window is a
12870                // copy of the entry's meta and its pinned arena address; the
12871                // running mark (`begin_dispatch`) keeps every other pass off
12872                // the entry and defers its release, and `rec` names this
12873                // thread as the one running it. `s` is derived again after.
12874                let mut rec = super::exec_lock::Running {
12875                    slot: i,
12876                    thread: window_thread,
12877                    next: core::ptr::null_mut(),
12878                };
12879                let window = window_lock.map(|l| {
12880                    // SAFETY: `rec` outlives the window and is unlinked under
12881                    // the lock right after it.
12882                    unsafe { s.link_running(&mut rec) };
12883                    super::exec_lock::Window::open(l)
12884                });
12885                dispatch_one(&meta, i, arena_ptr, delta_us, &mut result);
12886                let end_us = start_us.map(|_| read_us());
12887                if let Some(w) = window {
12888                    drop(w);
12889                    // SAFETY: the lock is held again; nothing borrowed from
12890                    // the executor before the window is used after.
12891                    s = unsafe { &mut *this };
12892                    s.unlink_running(&mut rec);
12893                }
12894                let elapsed_us: Option<u32> = start_us
12895                    .zip(end_us)
12896                    .map(|(t0, t1)| t1.saturating_sub(t0))
12897                    .map(|d| d.min(u32::MAX as u64) as u32);
12898                // phase10-B3 -- a subscription dispatch takes its sample
12899                // first, so the dispatch start is the take.
12900                if route_on && let (Some(t0), Some(t1)) = (start_us, end_us) {
12901                    if result.subscriptions_processed != took_before {
12902                        super::monitor::route_take(mon_table, i, t0);
12903                    }
12904                    super::monitor::route_judge(
12905                        mon_table,
12906                        &counts_before,
12907                        t1,
12908                        |row, v, record, react| {
12909                            if react {
12910                                call_route_reaction(route_hook, mon_table, row, &v, t1);
12911                            }
12912                            if record {
12913                                let _ = deadline_misses.push(v);
12914                            }
12915                        },
12916                    );
12917                }
12918                if let Some(elapsed_us) = elapsed_us {
12919                    consume_dispatch_runtime_us(
12920                        i,
12921                        elapsed_us,
12922                        &s.sched_context_bindings[..],
12923                        &s.sched_contexts[..],
12924                        &mut s.sporadic_states[..],
12925                        &mut s.alive_slots[..],
12926                        #[cfg(feature = "alloc")]
12927                        &s.sporadic_atomic_states[..],
12928                    );
12929                }
12930                if let Some(elapsed_us) = elapsed_us {
12931                    attribute_latency(mon_table, lat_active, &counts_before, elapsed_us);
12932                    check_deadline_miss(
12933                        s.sched_contexts.get(sc_idx).and_then(|s| s.as_ref()),
12934                        sc_idx,
12935                        elapsed_us,
12936                        &mut deadline_misses,
12937                        &mut skipped_scs,
12938                        s.fault_fn,
12939                        s.reaction_hook,
12940                        end_us.unwrap_or(0),
12941                    );
12942                }
12943                s.finish_dispatch(i);
12944            }
12945            if repick {
12946                s.merge_mid_pass(&mut fifo, &mut edf, &mut settled, arena_ptr, band_mask);
12947            }
12948        }
12949
12950        // W3b.5 — feed deferred deadline misses into the violation ring.
12951        for v in deadline_misses {
12952            s.violations.record(v);
12953        }
12954        // phase-509 P9b item 2 -- the rules and the executor-wide chores
12955        // below are the executor's own worker's. Issue 2020 -- the band's
12956        // own components' ticks are the band's.
12957        if is_band_pass {
12958            // SAFETY: the pass's contract; `s` is not used after it.
12959            unsafe { Self::tick_components(this, Some(level)) };
12960            return result;
12961        }
12962
12963        // Issue #505 — same ring, same cycle. This runs AFTER dispatch,
12964        // unlike the windowed rate/age/latency rules at the top of the
12965        // spin: the activations it reports were dropped by the dispatch
12966        // that just happened, and a tier that is stalling should not have
12967        // to wait for the next spin to say so.
12968        s.check_timer_overruns();
12969        s.check_release_jitter_rule();
12970        s.check_stack_headroom_rule();
12971        s.check_alive_supervision();
12972        // phase-474 I7 -- the queued /diagnostics reports, after this spin's
12973        // dispatches and rules, when the next timer leaves room for them.
12974        s.flush_violation_reports();
12975        // phase-474 I1 -- the opt-in drain-and-report hook, after every rule of
12976        // this spin has had its say.
12977        s.violations.drain_and_report();
12978
12979        // Process parameter services (outside the arena)
12980        #[cfg(feature = "param-store")]
12981        #[allow(unused_mut)] // only the services add to it
12982        let mut handled = 0usize;
12983        #[cfg(feature = "param-services")]
12984        if let Some(params) = &mut s.params {
12985            {
12986                let crate::param_store::ParamState {
12987                    server,
12988                    services,
12989                    buffers,
12990                    staged,
12991                    ..
12992                } = &mut **params;
12993                // phase-426 W3 -- one set per node; see the sibling site above,
12994                // including why nothing here can discard an error (1270/1271).
12995                if let Some(buffers) = buffers {
12996                    for set in services.iter_mut() {
12997                        let n = set.process(server, staged, buffers);
12998                        result.services_handled += n;
12999                        handled += n;
13000                    }
13001                }
13002            }
13003        }
13004        #[cfg(feature = "param-store")]
13005        s.note_param_services_ran(handled);
13006
13007        // Process lifecycle services (outside the arena).
13008        //
13009        // SAFETY: `change_state` dispatches a user-supplied C callback through a
13010        // raw function pointer stored in `LifecyclePollingNodeCtx`. The caller
13011        // of `register_lifecycle_services` guarantees the callback/context pair
13012        // stays live for as long as the executor (see that method's docs).
13013        #[cfg(feature = "lifecycle-services")]
13014        if let Some(lc) = &mut s.lifecycle {
13015            if let Ok(n) = unsafe { lc.process() } {
13016                result.services_handled += n;
13017            }
13018        }
13019
13020        // Phase 258 (Track 2, 2a) — executor-owned component tick pass.
13021        // Mirrors `ExecutorNodeRuntime::run_ticks`: after the transport +
13022        // callbacks have been pumped, drive each enrolled component's `tick`
13023        // (service-client/action poll, etc.). `exec_ctx` hands the component
13024        // the whole executor as a raw `*mut Executor` so its tick can
13025        // reborrow it (the same disjoint-field raw-ptr pattern run_ticks
13026        // uses). Index-iterate over `Copy` slots so no borrow of
13027        // `s.component_slots` is held while `tick` runs (which aliases
13028        // `self` through `exec_ctx`).
13029        //
13030        // Issue 2020 -- only the components this pass's level owns.
13031        // SAFETY: the pass's contract; `s` is not used after the call.
13032        unsafe { Self::tick_components(this, None) };
13033
13034        result
13035    }
13036
13037    /// Phase 258 (Track 2, 2a) -- the component tick pass at the tail of a
13038    /// pass: each enrolled component's `tick` (service-client polls,
13039    /// action-server completion and feedback), with the whole executor as
13040    /// `exec_ctx`. Issue 2020 -- only the components the pass's level owns
13041    /// ([`Self::pass_ticks_owner`]): `band_level` is a band pass's level,
13042    /// `None` for the executor's own worker.
13043    ///
13044    /// The lock stays held across every tick, as it always was: a component
13045    /// is ticked by one level's thread only, and never by two at once.
13046    ///
13047    /// # Safety
13048    /// As [`Self::pass`], called with the lock held (when installed) and no
13049    /// reference to the executor live in the caller.
13050    unsafe fn tick_components(this: *mut Self, band_level: Option<u8>) {
13051        let exec_ctx = this as *mut core::ffi::c_void;
13052        let mut i = 0;
13053        loop {
13054            // SAFETY: re-derived after each tick, which aliases the executor
13055            // through `exec_ctx`; the slot is copied out, so no borrow of
13056            // `component_slots` is outstanding during the call.
13057            let s: &Self = unsafe { &*this };
13058            let Some(slot) = s.component_slots.get(i).copied() else {
13059                break;
13060            };
13061            i += 1;
13062            let owner = (slot.level != super::scheduler::NO_OWNING_LEVEL).then_some(slot.level);
13063            if !s.pass_ticks_owner(band_level, owner) {
13064                continue;
13065            }
13066            // SAFETY: `slot.state` is the leaked cell the matching `tick`
13067            // expects (enrolled via `enroll_component`); `exec_ctx` is a live
13068            // `*mut Executor`.
13069            unsafe { (slot.tick)(slot.state, exec_ctx) };
13070        }
13071    }
13072
13073    /// The executor's own worker's prelude to a pass: everything
13074    /// `spin_once` did before its readiness scan. Returns the pass's clock
13075    /// read and the elapsed time its timers are credited.
13076    /// # Safety
13077    /// As [`Self::pass`]; `window_lock` is the pass's (`None` for an owned
13078    /// pass), and with it the calling thread holds the lock exactly once.
13079    unsafe fn owned_prelude(
13080        this: *mut Self,
13081        window_lock: Option<super::exec_lock::ExecLock>,
13082        timeout: core::time::Duration,
13083    ) -> (Option<u64>, u64) {
13084        // SAFETY: the caller's contract; re-derived after every window.
13085        #[allow(unused_mut)]
13086        let mut s: &mut Self = unsafe { &mut *this };
13087        // phase-509 P7 (RFC-0108 D8.2) -- this level is alive: it reached a
13088        // spin. A level stuck in a callback stops checking in, which is what
13089        // the image's watchdog feed (`take_every_level_alive`) waits on.
13090        let now = s.now_us();
13091        super::level_liveness::LEVELS.check_in(s.worker.level, now);
13092        // phase-436 W2 (issue 1193) — the budget is carried in MICROSECONDS.
13093        // This was `as_millis()`, which truncates: `from_micros(500)` became
13094        // `0`, and a zero timeout selects the non-blocking path — so a
13095        // sub-millisecond `spin()` was a 100 % CPU loop with no warning. The
13096        // conversion to whatever the platform primitive accepts happens once,
13097        // at the park, and rounds UP. The budget itself is `grid_budget_us`
13098        // below (phase-474 I11).
13099
13100        // phase-425 W3b — bring the `/clock` subscription in line with
13101        // `use_sim_time`. One bool comparison in the settled case; the work only
13102        // happens on a transition, or while a request is waiting for its first
13103        // node to exist.
13104        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
13105        {
13106            #[cfg(feature = "param-store")]
13107            s.settle_builder_reserved_parameters();
13108            s.reconcile_ros_time_source();
13109        }
13110
13111        // phase-426 W3 — attach the six parameter services to any node that
13112        // does not have them yet. Same reason as the reconcile above: the
13113        // request routinely arrives before there is a node to publish under
13114        // (`nros::main!` emits `apply_param_services` ahead of its per-node
13115        // `register` calls, by design). One length comparison once settled.
13116        //
13117        // Issue 1271 -- a failure is LOGGED, once, rather than discarded. On
13118        // Cyclone this fails on every spin with `Transport(Unsupported)`
13119        // (issue 1268), and with the result dropped "this node has no
13120        // parameter services" was a fact nothing on the image stated.
13121        #[cfg(feature = "param-services")]
13122        if s.parameter_services_pending()
13123            && let Err(e) = s.reconcile_parameter_services()
13124        {
13125            s.report_parameter_service_reconcile_failure(e);
13126        }
13127
13128        // Release jitter, measured HERE rather than in `spin_period`, because
13129        // this is the function every driver goes through.
13130        //
13131        // The first version instrumented `spin_period` only, and that was the
13132        // same mistake issue 0736 records one layer down: a measurement placed
13133        // on a path no shipped image takes. `nros-cpp`'s tier trampoline paces
13134        // itself with a `spin_once` loop at `spin_period_us` (see its
13135        // `run_components` docs, step 4), so `spin_period` is not what the C++
13136        // entry -- and therefore not what the Zephyr lane -- actually calls.
13137        // The field recorded zero forever while claiming to measure cadence.
13138        //
13139        // `timeout` is the caller's intended pacing quantum: `spin_period`
13140        // passes its period, and the generated entries pass 10 ms. phase-474
13141        // I11 -- with no declared cadence it is the period of an absolute
13142        // release grid, and the park budget is the time to the next grid
13143        // point (see `record_release_jitter`); the release is judged after the
13144        // park, against the grid (`record_grid_release`). A zero timeout means
13145        // "poll, no cadence claimed", so it is neither judged nor gridded.
13146        let grid_budget_us = s.record_release_jitter(timeout);
13147
13148        // issue 0900 — one-shot advisory when the arena is far larger than what
13149        // registered. First spin rather than "end of registration", because
13150        // there is no such point: an app may register lazily.
13151        super::arena::maybe_report_arena_headroom(s.arena_used, s.arena.len());
13152
13153        // phase-412 -- the same moment, recorded where a board with no log sink
13154        // can still be asked. Reaching this stage is the positive result the
13155        // advisory above cannot deliver on the island: registration completed.
13156        crate::boot_report::checkpoint(crate::boot_report::Stage::FirstSpin);
13157
13158        // Phase 110.0 — the backend's next internal-event deadline (lease
13159        // keepalive, heartbeat, ACK-NACK timeout, ...). Default backend impl
13160        // returns `None`, so this contributes nothing unless the active
13161        // backend opts in.
13162        //
13163        // phase-436 W6 — a CANDIDATE now, not a pre-cap. Folding it into the
13164        // budget here meant a park the session won was attributed to
13165        // `CallerBudget`, so the one number that explains a wake named the
13166        // wrong source.
13167        let session_us = s
13168            .session
13169            .next_deadline_ms()
13170            .map(|next| (next as u64).saturating_mul(1000));
13171
13172        // phase-436 W1 (issue 1192) — and by the next TIMER deadline, which is
13173        // the one class of deadline the executor itself owns. Before this the
13174        // park was bounded by the caller's budget narrowed only by the
13175        // BACKEND's next event, so ~~`spin_default`~~'s 50 ms slept straight past
13176        // a registered 10 ms timer.
13177        //
13178        // Attribution is recorded here rather than derived later: this is the
13179        // only point where the losing candidates are still in hand.
13180        let (park_bound_us, park_source) =
13181            s.next_wake_bound_attributed_us(grid_budget_us, session_us);
13182        s.worker.last_park_bound_us = park_bound_us;
13183        s.worker.last_park_source = park_source;
13184        // phase-436 W2 (issue 1193) — round UP to what the primitive can
13185        // express. The old `as_millis()` truncated, so a sub-millisecond
13186        // request became a zero park: the non-blocking path, a busy loop.
13187        let park_achieved_us = s.round_park_up_us(park_bound_us);
13188        s.worker.last_park_achieved_us = park_achieved_us;
13189
13190        // phase-436 W6 — a port that installed a park primitive does its
13191        // waiting HERE, on the bound every source agreed on, and the transport
13192        // is then drained non-blockingly.
13193        //
13194        // This is the path bare metal needs: a `ParkUntilFn` can arm a
13195        // timer compare before `wfi`, which `sleep(duration)` cannot express —
13196        // and `nros-baremetal-common` notes that `wfi` with no pending
13197        // interrupt deadlocks, so arming first is not optional there.
13198        //
13199        // Additive by construction: with no primitive installed this is a
13200        // no-op and every existing port keeps the behaviour it has.
13201        // phase-436 — park only when something can END the park early. A
13202        // park ends on the bound or on the wake object; with a poll-only
13203        // backend (XRCE), or a session whose wake callback is gone, nothing
13204        // signals that object, so the park would be a blind sleep and data
13205        // arriving mid-park would wait out the whole bound — the Phase
13206        // 127.C.4 stall (a reliable stream whose ACK lands in a session nobody
13207        // drives). Those executors wait on the wake object below, on the short
13208        // entry budget (`entry_spin_budget`).
13209        let park_can_be_woken = s.park_can_be_woken();
13210
13211        // phase-521 W3 (RFC-0113 D3) -- a session whose readiness is a
13212        // WAITABLE handle (a socket on POSIX / NuttX) is parked on: the port
13213        // multiplexes the handles with this executor's own wake object, so
13214        // data arriving ends the park and a guard trigger still does too. The
13215        // tier then drains its sessions non-blockingly below, instead of
13216        // blocking inside the transport's `recv` (and slicing that wait for
13217        // a guard condition, phase-436 D5). A port that cannot wait on handles
13218        // answers `None` and the wait below is the one it always had.
13219        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
13220        let handle_parked = if park_achieved_us > 0 {
13221            let mut handles = [0i32; MAX_WAITABLE_HANDLES];
13222            let n = s.waitable_handles(&mut handles);
13223            match s.node_wake.as_ref() {
13224                Some(wake) if n > 0 => {
13225                    let wake: *const super::node_wake::NodeWake = &**wake;
13226                    let _window = window_lock.map(super::exec_lock::Window::open);
13227                    // SAFETY: the wake object is the executor's (`Arc`, set at
13228                    // open and never replaced), so it outlives the window.
13229                    unsafe { &*wake }
13230                        .park_handles_us(&handles[..n], park_achieved_us)
13231                        .is_some()
13232                }
13233                _ => false,
13234            }
13235        } else {
13236            false
13237        };
13238        #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
13239        let handle_parked = false;
13240        // SAFETY: the lock is held again (the window, if any, has closed).
13241        s = unsafe { &mut *this };
13242
13243        // phase-509 P9b item 3 -- a shared pass parks with the lock released
13244        // (a window), so the band workers run while the executor's own
13245        // worker waits. Nothing borrowed from the executor crosses it.
13246        let mut yielded = false;
13247        let parked = handle_parked
13248            || match s.worker.park_primitive {
13249                Some((park, ctx)) if park_achieved_us > 0 && park_can_be_woken => {
13250                    let _window = window_lock.map(super::exec_lock::Window::open);
13251                    // SAFETY: `park` and `ctx` were supplied together by the port
13252                    // through `set_park_primitive` and outlive the executor by its
13253                    // contract.
13254                    //
13255                    // phase-436 — a negative answer is "cannot park": the ABI's
13256                    // own contract, and what a Rust-implemented port's trait
13257                    // default returns at once. Counting that as a park would
13258                    // drop the drain below to non-blocking and spin hot, so the
13259                    // spin falls back to the wait it would have had uninstalled.
13260                    let rc = unsafe { park(ctx, park_achieved_us) };
13261                    yielded = rc == PARK_YIELDED;
13262                    rc >= 0
13263                }
13264                _ => false,
13265            };
13266        // SAFETY: the lock is held again (the window, if any, has closed).
13267        s = unsafe { &mut *this };
13268        // phase-521 W8 (c) -- a YIELDED slot did not wait: its framework runs
13269        // this tier again at the bound or on an arrival, so the pass returns.
13270        // Not while a wake is already pending, though: then the work is here
13271        // now, and the answer counts as a park that ended at once.
13272        #[cfg(feature = "alloc")]
13273        let pending = s.wake_flag.load(portable_atomic::Ordering::SeqCst);
13274        #[cfg(not(feature = "alloc"))]
13275        let pending = false;
13276        s.worker.park_yielded = yielded && !pending;
13277
13278        // Having already waited, the transport drain must not block again —
13279        // otherwise the park and the drive would sum, and the cadence would be
13280        // up to twice what was asked. Same trap as passing a period to
13281        // `spin_once` instead of declaring it (#648).
13282        let timeout_ms = if parked {
13283            0
13284        } else {
13285            (park_achieved_us / 1000).min(i32::MAX as u64) as i32
13286        };
13287
13288        // Wall-clock-accurate timer accumulation. Measure real time
13289        // since the previous `spin_once` exited (or, on the first call,
13290        // since `drive_io` started). Two failure modes the requested
13291        // `timeout_ms` doesn't capture:
13292        //  1. `drive_io` returns early — e.g. zenoh-pico's condvar wakes
13293        //     on data arrival, well under 1 ms.
13294        //  2. The caller spends time outside `spin_once` (explicit sleep,
13295        //     ROS-2 cooperative scheduling, etc.) and that time should
13296        //     still count toward timers.
13297        // Crediting the requested timeout to timers in either case ticks
13298        // them faster than wall-clock — observed as a 30 Hz control loop
13299        // overshooting to >200 Hz under sustained traffic. Carry the
13300        // sub-ms remainder across calls so precision is preserved.
13301        // phase-359 W4 — the spin-ENTRY read, one spelling. Kept distinct from
13302        // the post-IO read below because the first spin's delta is measured from
13303        // entry; collapsing them would make that delta 0 on the first call only.
13304        let spin_start_us = s.now_us();
13305
13306        // phase-474 I2 -- arm the monitors when the policy says start-up is over,
13307        // before any rule of this spin can judge.
13308        s.update_monitor_arming(spin_start_us);
13309        // phase-462 W4 -- and switch to the requested mode's tables, before
13310        // any rule of this spin judges by them.
13311        s.update_mode(spin_start_us);
13312
13313        // RFC-0052 W3b.4 — contract monitors tick once per spin (window
13314        // logic inside; single branch when the baked table is empty).
13315        s.run_contract_monitors();
13316
13317        // Phase 104.C.6 — shared executor wake. Swap-and-clear the
13318        // wake flag; if it was set before this `spin_once` entered,
13319        // skip the blocking wait on the primary session and poll
13320        // every session non-blockingly. Lets a wake signal from any
13321        // thread (or, post-104.C.6.b, any backend's vtable hook)
13322        // pre-empt whichever session the executor would otherwise
13323        // sleep on. Cost on the no-wake path is one atomic swap.
13324        // phase-359 W10 — ONE swap, at `alloc` scope.
13325        //
13326        // This was `#[cfg(feature = "std")]` while the alloc wait arm did its
13327        // own swap further down. Merging the two arms made that two swaps of
13328        // one flag per spin, the first consuming what the second tested, so the
13329        // arm would have read `was_woken == false` forever. Deleting the outer
13330        // one instead broke a different thing: `wake_flag` is `alloc`-gated and
13331        // "spin_once consumes the wake flag" is an invariant of every alloc
13332        // build, but the merged arm only runs with `rmw-cffi` — so a
13333        // `std`-without-`rmw-cffi` build stopped clearing it, which
13334        // `test_wake_cleared_each_spin` catches. It belongs here, at the scope
13335        // of the field it reads.
13336        #[cfg(feature = "alloc")]
13337        #[allow(unused_variables)]
13338        let was_woken = s.wake_flag.swap(false, portable_atomic::Ordering::SeqCst);
13339
13340        // Phase 124.B.4 — condvar-blocked wait.
13341        //
13342        // RT contract:
13343        //  * cv.wait_timeout_while: bounded by `timeout_ms`.
13344        //    Predicate is O(1) — one atomic swap + Instant::now.
13345        //    No allocation. PI-mutex consideration: wake_mu held
13346        //    only during predicate check (microseconds);
13347        //    contended worst-case = notify_all execution time
13348        //    (~10s of µs).
13349        //  * Backend's `set_wake_callback`-installed cb is called
13350        //    on async data arrival from its transport-notify path
13351        //    (worker thread, ISR-safe variant via 124.B.7). The
13352        //    runtime cb writes wake_flag + signals wake_cv,
13353        //    unblocking this loop sub-poll-period.
13354        //  * Poll-only backends (XRCE, bare-metal) leave the slot
13355        //    NULL; the cv wait still fires on its deadline, then
13356        //    drive_io(0) drains whatever the backend's internal
13357        //    poll has buffered. Equivalent to their pre-124
13358        //    behaviour minus the blocking wait inside drive_io.
13359        //
13360        // Lost-wakeup safe: SeqCst flag write happens-before
13361        // notify, and the waiter checks the flag under wake_mu in
13362        // the predicate. If wake fires between drain and cv.wait
13363        // entry, the predicate sees flag=true on first eval and
13364        // exits immediately.
13365        // Phase 130.4 — only sleep in the wake wait when a backend actually
13366        // installed `set_wake_callback`. Poll-only backends (XRCE) leave the
13367        // vtable slot NULL → `has_async_wake == false` →
13368        // drive_io for the caller's full timeout instead of sleeping in a
13369        // never-signaled wait that starves reliable retransmission (Phase
13370        // 127.C.4 root cause: the server's `send_response` flushes 100 ms once,
13371        // then a blind `wait_ms(100)` sleeps with zero session activity, so the
13372        // agent's ACK arrives into a stalled session and reliable redelivery
13373        // never fires).
13374        //
13375        // Phase 248 (C2) — which primitive is a RUNTIME choice from the
13376        // platform vtable's wake probe, not a compile-time per-RTOS `cfg`:
13377        // `node_wake.is_some()` means `nros_platform_wake_*` is linked and
13378        // `nros_rmw_runtime_wake_cb` signals it on transport arrival.
13379        // phase-359 W10 — ONE arm. This was a `std` arm and an `alloc` arm with
13380        // the same body: block on the platform wake primitive when a backend
13381        // installed the callback and nothing has woken us yet, else drive the
13382        // transport for the caller's full timeout. They stopped differing when
13383        // the condvar fallback was deleted — the std arm's `else` branch had
13384        // already become "drive for the full timeout", which is what the alloc
13385        // arm always did — so keeping two was keeping a fork that agreed.
13386        // phase-417 W4.e — the FIRST arm is what the swap-and-clear above says
13387        // it is for, and it was missing: a wake that landed BETWEEN two spins
13388        // set the flag, and this expression then took the `else` and drove the
13389        // transport for the caller's FULL timeout, so the pending work waited
13390        // out the very budget the wake exists to cut short. Measured with a
13391        // guard condition triggered from another thread just before a 400 ms
13392        // spin: 400 ms to dispatch, against 50 ms for the same trigger issued
13393        // while the spin was already parked in `wait_ms`. Being woken means
13394        // there IS work, so the right timeout is 0 — poll every session
13395        // non-blockingly and go dispatch it, which is what the comment at the
13396        // swap has claimed since Phase 104.C.6.
13397        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
13398        let primary_drive_timeout_ms = {
13399            if was_woken {
13400                // Issue 1385 — drain the wake PRIMITIVE too.
13401                //
13402                // `nros_rmw_runtime_wake_cb` sets both halves of one signal:
13403                // the flag, and the platform primitive. The swap above
13404                // consumed the flag and this arm returns without entering the
13405                // wait, so a primitive left posted makes the NEXT spin's
13406                // `wait_ms` return instantly on a signal already acted on —
13407                // that spin does not wait at all, and whatever it should have
13408                // waited for is dispatched a whole spin late.
13409                //
13410                // `wait_ms(0)` is the non-blocking take: every port's wait is a
13411                // deadline (`sem_timedwait`, `k_sem_take`, `xSemaphoreTake`),
13412                // so zero means "take it if it is there", never "wait
13413                // forever".
13414                //
13415                // Measured, not reasoned: with the C executor installing a
13416                // callback (the other half of 1385), an arrival taken by this
13417                // arm left the spin after it returning in 0 ms where it owed
13418                // 60 ms of waiting — which is what `node_guard_condition.c`'s
13419                // mid-spin case caught, and what `executor_backend_wake.c`'s
13420                // second lower bound now pins. Latent until then only because
13421                // no C spin had ever entered the wait arm.
13422                if let Some(wake) = s.node_wake.as_ref() {
13423                    let _ = wake.wait_ms(0);
13424                }
13425                0
13426            } else if let Some(wake) = s.node_wake.as_ref() {
13427                // phase-521 W6 (RFC-0113 D2) — the wait is on the wake object
13428                // whatever the session's form. A session that states no
13429                // readiness form is refused at open (D5), so the transport's
13430                // own `recv` is never the wait any more. A `Notify` session
13431                // whose callback is live posts this object on an arrival; a
13432                // `Waitable` session was parked on above. What reaches here
13433                // unwoken by its data (a wake fan-out with no free member, more
13434                // waitable sessions than one park multiplexes) is the case
13435                // `entry_spin_budget` keeps on the short budget, so the wait
13436                // stays bounded by it and the drain below picks the data up.
13437                // phase-436 — after a park this wait has a zero timeout and
13438                // the park has already waited on this same object, so it is a
13439                // second syscall that can only time out. Measured on a native
13440                // tier: one extra futex wait per iteration, doubling the idle
13441                // context switches. A signal that lands between the park's
13442                // return and here stays posted and ends the next park at once,
13443                // which costs one spin and loses nothing.
13444                if !parked {
13445                    // phase-509 P9b item 3 -- the wait is a window too: the
13446                    // wake object is the executor's (`Arc`, set at open and
13447                    // never replaced), reached through a raw pointer.
13448                    let wake: *const super::node_wake::NodeWake = &**wake;
13449                    let _window = window_lock.map(super::exec_lock::Window::open);
13450                    // SAFETY: the object outlives the executor's open window.
13451                    let _ = unsafe { &*wake }.wait_ms(timeout_ms as u32);
13452                }
13453                // SAFETY: the lock is held again.
13454                s = unsafe { &mut *this };
13455                // Drain any flag the cb set while we were waiting.
13456                let _ = s.wake_flag.swap(false, portable_atomic::Ordering::SeqCst);
13457                0
13458            } else {
13459                // No wake object linked (a port that supplies no
13460                // `nros_platform_wake_*`): the backend's drive is the only
13461                // wait there is.
13462                timeout_ms
13463            }
13464        };
13465
13466        // std builds without rmw-cffi (mock-session tests, future alternative
13467        // backends) keep the original "drive_io is non-blocking" assumption.
13468        // `std` implies `alloc`, so this is the only std configuration the arm
13469        // above does not cover.
13470        #[cfg(all(feature = "alloc", not(feature = "rmw-cffi")))]
13471        let primary_drive_timeout_ms = 0;
13472
13473        // no_std without (alloc + rmw-cffi) keeps the legacy
13474        // full-timeout drive_io call.
13475        #[cfg(all(
13476            not(feature = "alloc"),
13477            not(all(feature = "alloc", feature = "rmw-cffi"))
13478        ))]
13479        let primary_drive_timeout_ms = timeout_ms;
13480
13481        // issue 0324 — the primary session's I/O result is TRACKED, not
13482        // discarded. See `consecutive_io_failures` for why this is a counter
13483        // rather than an early return.
13484        match s.drive_primary(primary_drive_timeout_ms) {
13485            Ok(()) => s.consecutive_io_failures = 0,
13486            Err(_) => s.consecutive_io_failures = s.consecutive_io_failures.saturating_add(1),
13487        }
13488        for extra in s.extra_sessions.iter_mut() {
13489            // Best-effort BY DESIGN: extra sessions are bridge / multi-domain
13490            // attachments, and one of them failing must not stall the primary
13491            // spin. The old code expressed this the same way it expressed the
13492            // primary's discarded error — identically — so the intent was
13493            // invisible. It is now the only `let _ =` of the two.
13494            let _ = extra.drive_io(0);
13495        }
13496
13497        // phase-359 W4 — ONE clock read per spin, shared by the delta below and
13498        // every consumer further down. Replaces the std/no_std delta pair AND
13499        // two ad-hoc `static EPOCH: OnceLock<Instant>` blocks that each kept
13500        // their own std-only epoch.
13501        let now_us_this_spin = s.now_us();
13502        // phase-474 I11 -- the release instant is the end of the park, so it
13503        // is judged on this read, before any dispatch of this spin.
13504        if let Some(now) = now_us_this_spin {
13505            s.record_grid_release(now);
13506        }
13507
13508        // Same rule on both flavours: measure elapsed when a clock exists, else
13509        // credit the REQUESTED timeout. That fallback was previously reachable
13510        // only on no_std; on std `Instant` always answered. It stays unreachable
13511        // on std for the same reason — `now_us()` is infallible there — so this
13512        // is one expression, not a behaviour change.
13513        let delta_us = match now_us_this_spin {
13514            Some(now) => {
13515                let prev = s
13516                    .last_spin_end_us
13517                    .unwrap_or_else(|| spin_start_us.unwrap_or(now));
13518                s.last_spin_end_us = Some(now);
13519                now.saturating_sub(prev)
13520            }
13521            None => (timeout_ms as u64).saturating_mul(1000),
13522        };
13523
13524        // phase-430 W1 — the silence watch is credited the SAME `delta_us` the
13525        // wall timers below are, which is the whole of its unit argument: the
13526        // diagnostic arrives after as much time as this image's own wall timers
13527        // believe has passed, on whatever clock it actually has.
13528        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
13529        s.note_sim_time_silence(delta_us);
13530
13531        if !s.spin_quantization_checked && timeout_ms > 0 {
13532            s.spin_quantization_checked = true;
13533            // The TIER's declared cadence, not the capped park bound. Since
13534            // phase-436 W1 the two differ whenever a timer is due sooner than
13535            // the budget, and auditing the cap would compare the timer period
13536            // against itself — an exact multiple every time, so issue 0515's
13537            // warning would go silent on precisely the images that have a
13538            // timer to quantize. `spin_nominal_us` is the declared cadence
13539            // (`set_spin_nominal_us`, else the caller's timeout), recorded at
13540            // the top of this spin before any capping.
13541            s.spin_quantization_us = s.spin_nominal_us;
13542            s.audit_spin_quantization(s.spin_quantization_us);
13543        }
13544        (now_us_this_spin, delta_us)
13545    }
13546
13547    /// Drive I/O and dispatch callbacks forever — the body of an RTOS task.
13548    ///
13549    /// EXTENSION (RFC-0089). Not a rename of upstream's `spin`: the `!` is the
13550    /// point. A bare-metal entry has no shutdown source to return TO, and
13551    /// declaring that lets the compiler drop the epilogue and lets the caller
13552    /// write it as the last expression of a `-> !` task body with no
13553    /// `unreachable!()`. [`spin`](Self::spin) is the verb that can end, and it
13554    /// is upstream's.
13555    ///
13556    /// phase-427 — was `spin(Duration) -> !`, which squatted on rclrs's name
13557    /// with a different contract (a ported `executor.spin(SpinOptions)` line
13558    /// had to be renamed by hand, the one edit on the tutorial's happy path
13559    /// that no real difference forced). ~~`spin_default()`~~ went with it: it named
13560    /// a 50 ms poll interval and had zero call sites, and the interval is now
13561    /// [`SpinOptions::poll_interval`], defaulting to
13562    /// [`DEFAULT_SPIN_POLL_INTERVAL`](super::types::DEFAULT_SPIN_POLL_INTERVAL).
13563    ///
13564    /// **Envelope.** Only `poll_interval` and `stop_on_first_error` can mean
13565    /// anything here; `timeout`, `only_next` and `max_callbacks` are ENDING
13566    /// conditions and this verb does not end. Passing one is a caller error and
13567    /// says so at `LOG_ERR` on entry rather than being quietly dropped — a
13568    /// discarded ending condition is precisely the "compiles and differs" case
13569    /// RFC-0089 exists to make loud, and a `-> !` signature has no other
13570    /// channel. `stop_on_first_error` likewise cannot RETURN the error; it
13571    /// logs it and keeps spinning, because a task body has nowhere to put it.
13572    ///
13573    /// ```ignore
13574    /// let mut executor = Executor::open(&config)?;
13575    /// executor.register_subscription::<Int32, _>("/topic", |msg| { /* ... */ })?;
13576    /// executor.spin_forever(SpinOptions::default()); // never returns
13577    /// ```
13578    pub fn spin_forever(&mut self, opts: SpinOptions) -> ! {
13579        if opts.timeout.is_some() || opts.only_next || opts.max_callbacks.is_some() {
13580            nros_log::log_error!(
13581                nros_log::get_logger("nros"),
13582                "spin_forever: an ending condition (timeout / only_next / max_callbacks) \
13583                 was supplied to a verb that never returns and is being IGNORED \
13584                 -- use `Executor::spin` for a loop that can end"
13585            );
13586        }
13587        let poll = opts.poll_interval;
13588        let report_errors = opts.stop_on_first_error;
13589        loop {
13590            let mut first_error = None;
13591            self.spin_once_capturing(poll, &mut first_error);
13592            if report_errors && let Some(e) = first_error {
13593                nros_log::log_error!(
13594                    nros_log::get_logger("nros"),
13595                    "spin_forever: a callback failed ({:?}); `stop_on_first_error` cannot \
13596                     end a `-> !` loop, so this is a report, not a stop",
13597                    e
13598                );
13599            }
13600        }
13601    }
13602
13603    /// Execute the work that is ready NOW and return — rclcpp's
13604    /// `Executor::spin_some(max_duration)`.
13605    ///
13606    /// ADOPT-BOUNDED (RFC-0089). Upstream's drain verb: collect what is ready,
13607    /// execute it, come back. It never waits for new work — that is
13608    /// [`spin_once`](Self::spin_once), which takes a budget and blocks on the
13609    /// transport until it expires.
13610    ///
13611    /// **Envelope, two ways it is weaker than rclcpp's:**
13612    ///
13613    /// * rclcpp collects the ready set ONCE and executes it. One
13614    ///   `spin_once(0)` here is already that whole set (RFC-0002 §3), so this
13615    ///   would be a single call — except a callback may enqueue work for
13616    ///   another entity, which rclcpp's `spin_all` re-collects and its
13617    ///   `spin_some` leaves for the next call. Ours re-drains until a pass does
13618    ///   NO work, which is `spin_all`'s answer, because the alternative is
13619    ///   work sitting in the arena until someone spins again. Bounded by
13620    ///   `max`, so a callback that feeds itself cannot wedge the caller.
13621    /// * `max` is checked BETWEEN passes, never inside one, so it is a floor on
13622    ///   when this returns and not a ceiling: a single long callback overruns
13623    ///   it. There is no preemption here to make it a ceiling.
13624    ///
13625    /// `max` of [`Duration::ZERO`](core::time::Duration::ZERO) means no limit —
13626    /// rclcpp's own default — and is the spelling a build with no clock must
13627    /// use. A non-zero `max` on a clockless build is an ERROR rather than an
13628    /// unbounded drain (issue 0709, the same rule as `spin`'s `timeout`).
13629    ///
13630    /// The `Result` is about that PRECONDITION and nothing else. A failed
13631    /// callback is counted in each pass's `SpinOnceResult` and does not end the
13632    /// drain, matching `spin`'s default (`SpinOptions::stop_on_first_error` is
13633    /// `false`); rclcpp's `spin_some` returns `void` and reports callback
13634    /// failures nowhere at all.
13635    ///
13636    /// # Example
13637    ///
13638    /// ```ignore
13639    /// // Drain the ready set, then get on with the caller's own work.
13640    /// executor.spin_some(core::time::Duration::ZERO)?;
13641    /// ```
13642    pub fn spin_some(&mut self, max: core::time::Duration) -> Result<(), NodeError> {
13643        let bounded = !max.is_zero();
13644        let start_us = self.now_us();
13645        if bounded && start_us.is_none() {
13646            nros_log::log_error!(
13647                nros_log::get_logger("nros"),
13648                "spin_some: a max duration was requested but this build has no clock \
13649                 - install one with `ExecutorConfig::clock_us`, or pass \
13650                 `Duration::ZERO` for rclcpp's unbounded default (issue 0709)"
13651            );
13652            return Err(NodeError::NotInitialized);
13653        }
13654        let max_us = max.as_micros().min(u64::MAX as u128) as u64;
13655
13656        // No `enter_spin_loop` here, deliberately: `is_spinning` answers "a
13657        // blocking loop is running that a peer's `cancel()` has to reach", and
13658        // this verb returns on its own. Raising the flag for the length of a
13659        // drain would make a `cancel()` from another task look acted-upon when
13660        // nothing was waiting for it.
13661        loop {
13662            let result = self.spin_once(core::time::Duration::ZERO);
13663            if !result.any_work() {
13664                break;
13665            }
13666            if bounded
13667                && let (Some(start), Some(now)) = (start_us, self.now_us())
13668                && now.saturating_sub(start) >= max_us
13669            {
13670                break;
13671            }
13672        }
13673        Ok(())
13674    }
13675
13676    // ========================================================================
13677    // spin_one_period (no_std)
13678    // ========================================================================
13679
13680    /// Process one iteration and return remaining sleep time.
13681    ///
13682    /// This is `no_std` compatible — the caller is responsible for the actual
13683    /// delay using platform-specific sleep.
13684    ///
13685    /// # Arguments
13686    /// * `period_ms` - Target period in milliseconds
13687    /// * `elapsed_ms` - Time elapsed since last call (used for timer ticking)
13688    ///
13689    /// # Example
13690    ///
13691    /// ```ignore
13692    /// loop {
13693    ///     let r = executor.spin_one_period(10, elapsed_ms);
13694    ///     platform_sleep_ms(r.remaining_ms);
13695    /// }
13696    /// ```
13697    pub fn spin_one_period(&mut self, period_ms: u64, elapsed_ms: u64) -> SpinPeriodPollingResult {
13698        let result = self.spin_once(core::time::Duration::from_millis(elapsed_ms));
13699        SpinPeriodPollingResult {
13700            work: result,
13701            remaining_ms: period_ms.saturating_sub(elapsed_ms),
13702        }
13703    }
13704}
13705
13706// ============================================================================
13707// Parameter services (cfg param-services)
13708// ============================================================================
13709
13710#[cfg(feature = "param-services")]
13711impl<'s> Executor<'s> {
13712    /// Register the 6 ROS 2 parameter services for this node.
13713    ///
13714    /// Creates service servers for `get_parameters`, `set_parameters`,
13715    /// `set_parameters_atomically`, `list_parameters`, `describe_parameters`,
13716    /// and `get_parameter_types`.
13717    ///
13718    /// The service names follow the ROS 2 convention: `/{namespace}/{node_name}/{suffix}`.
13719    /// For the default namespace `/`, this becomes `/{node_name}/{suffix}` (e.g.
13720    /// `/sentinel/list_parameters`).
13721    ///
13722    /// Parameter services are stored outside the arena and don't consume
13723    /// callback slots.
13724    ///
13725    /// Issue 2023 — every node served this way also PUBLISHES
13726    /// `/parameter_events` (`rcl_interfaces/msg/ParameterEvent`), rclcpp's and
13727    /// rclrs's default: a start-up event, then one event per declare, set and
13728    /// undeclare. One VOLATILE publisher per executor
13729    /// ([`PARAM_EVENT_PUBLISHERS`](crate::parameter_services::PARAM_EVENT_PUBLISHERS)).
13730    /// A node opts out with
13731    /// [`disable_parameter_events_on`](Self::disable_parameter_events_on)
13732    /// (rclc's `notify_changed_over_dds = false`).
13733    ///
13734    /// # Example
13735    ///
13736    /// ```ignore
13737    /// let config = ExecutorConfig::from_env().node_name("talker");
13738    /// let mut executor = Executor::open(&config)?;
13739    /// executor.register_parameter_services()?;
13740    /// executor.declare_parameter("start_value", ParameterValue::Integer(0));
13741    /// ```
13742    pub fn register_parameter_services(&mut self) -> Result<(), NodeError> {
13743        // Issue 1706 -- six services over a store that does not exist would
13744        // answer every request with "not declared"; say what went wrong
13745        // instead (`report_parameter_store_refused` has named the size).
13746        if !self.ensure_parameter_store() {
13747            return Err(NodeError::Transport(TransportError::BadAlloc));
13748        }
13749        if let Some(params) = &mut self.params {
13750            params.requested = true;
13751        }
13752        // phase-426 W3 — with no node yet, RECORD the request and stop. The
13753        // reconcile's no-node fallback publishes under the executor's own
13754        // identity, and taking it here would pin node 0 to that name before the
13755        // node that IS node 0 has been built — which is the pre-W3 behaviour
13756        // this work item exists to remove. Every generated entry arrives in
13757        // exactly this state (`apply_param_services` runs ahead of the per-node
13758        // `register` calls, by design), so the fallback belongs on the spin
13759        // path, where "no node" has stopped being merely "not yet".
13760        if self.nodes.is_empty() {
13761            return Ok(());
13762        }
13763        self.reconcile_parameter_services()
13764    }
13765
13766    /// phase-426 W3 — is there a node whose six services are not up yet?
13767    ///
13768    /// The cheap half of the reconcile, kept separate so `spin_once` can test
13769    /// it without carrying the reconcile's frame (see below).
13770    #[inline]
13771    pub(crate) fn parameter_services_pending(&self) -> bool {
13772        self.params.as_deref().is_some_and(|p| {
13773            p.requested
13774                && p.services.len() < self.nodes.len().max(1)
13775                // Issue 1268 — a failure that cannot change is not pending
13776                // work. This used to retry six `create_service` calls on EVERY
13777                // spin, for the life of an image that would never serve them,
13778                // and the result was discarded; 1271 made it say so once, which
13779                // left the retrying. An explicit
13780                // `register_parameter_services()` still tries, because that is
13781                // a caller asking rather than the spin guessing.
13782                && p.reconcile_failure.as_ref().is_none_or(|f| !f.permanent)
13783        })
13784    }
13785
13786    /// Issue 1271 -- say, once, that the parameter services could not be
13787    /// created, instead of discarding the result on every spin.
13788    ///
13789    /// The spin keeps retrying (a node registered later, or a transport that
13790    /// comes up late, may still succeed); this only decides whether anyone is
13791    /// told. Cold and out of line: it formats, and `spin_once`'s frame must
13792    /// not carry that.
13793    #[cold]
13794    #[inline(never)]
13795    fn report_parameter_service_reconcile_failure(&mut self, e: NodeError) {
13796        let Some(params) = self.params.as_deref_mut() else {
13797            return;
13798        };
13799        if params.reconcile_failure_reported {
13800            return;
13801        }
13802        params.reconcile_failure_reported = true;
13803        // Issue 1268 — name the node and the service. "could not be created
13804        // ({:?})" named neither, so a reader could not tell a name too long for
13805        // one node from a backend that will serve none of the six.
13806        let (node_fqn, service, permanent) = match params.reconcile_failure.as_ref() {
13807            Some(f) => (f.node_fqn.as_str(), f.service, f.permanent),
13808            None => ("<unknown node>", "<unknown service>", false),
13809        };
13810        nros_log::log_error!(
13811            nros_log::get_logger("nros"),
13812            "parameter services could not be created for node {} at service {}: {:?}. \
13813             `ros2 param` cannot reach {} of this image's {} node(s). {} (issues 1271, 1268).",
13814            node_fqn,
13815            service,
13816            e,
13817            self.nodes.len().max(1) - params.services.len(),
13818            self.nodes.len().max(1),
13819            if permanent {
13820                "Not retrying: this failure cannot change without a rebuild."
13821            } else {
13822                "Retrying on every spin; logged once."
13823            }
13824        );
13825    }
13826
13827    /// phase-426 W3 — publish one set of six under every node that does not
13828    /// have one yet.
13829    ///
13830    /// **Why a reconcile and not a registration.** `nros::main!` emits
13831    /// `apply_param_services` BEFORE its per-node `register` calls, by design:
13832    /// the store has to exist when each component cell captures it. So at the
13833    /// moment the request is made the node table is EMPTY and there is no FQN
13834    /// to publish under — registering there is how the six ended up pinned to
13835    /// the executor's own identity in the first place. The request is recorded
13836    /// and satisfied here, at the head of every spin, exactly as
13837    /// `reconcile_ros_time_source` does for `use_sim_time` and for the same
13838    /// reason.
13839    ///
13840    /// **An executor with no registered node still gets one set**, under its
13841    /// own `node_name`/`namespace` and keyed `NodeId::PRIMARY` — the identity
13842    /// [`declare_parameter`](Self::declare_parameter) writes to. That is the
13843    /// pre-W3 behaviour, preserved for the direct-API images that never call
13844    /// `node_builder` (`param-chatter-talker` is one), and it is keyed the same
13845    /// as the first node record, so a node registered later inherits the set
13846    /// rather than getting a second one under a different name.
13847    ///
13848    /// Cheap in the settled case: one length comparison — but the FRAME is not
13849    /// cheap, so it is `inline(never)` and the spin site tests
13850    /// [`parameter_services_pending`](Self::parameter_services_pending) first.
13851    /// Building one set used to materialise six `EmbeddedServiceServer`s by
13852    /// value (2 x `PARAM_SERVICE_BUFFER_SIZE` each, ~48 KiB at the 4096
13853    /// default) before they were boxed; inlined into `spin_once` that landed
13854    /// on every spin frame whether or not the branch was taken. Issue 1270
13855    /// took the buffers out of the set -- one pair per executor, allocated on
13856    /// the heap directly -- but the set still carries six backend handles and
13857    /// a name, so the frame stays out of `spin_once`. Issue 0756's rule one
13858    /// call up — a parameter-service frame does not fit an embedded thread
13859    /// stack, and `test_open_threaded_spawn_and_halt` died with a bare SIGSEGV
13860    /// the first time this was reachable from the spin path.
13861    #[inline(never)]
13862    pub(crate) fn reconcile_parameter_services(&mut self) -> Result<(), NodeError> {
13863        let Some(params) = self.params.as_deref() else {
13864            return Ok(());
13865        };
13866        if !params.requested {
13867            return Ok(());
13868        }
13869        // The nodes wanted, minus the ones already served. Collected first
13870        // because building a set borrows `self.session` mutably while
13871        // `self.nodes` / `self.params` are still borrowed by the scan.
13872        let want = self.nodes.len().max(1);
13873        if params.services.len() >= want {
13874            return Ok(());
13875        }
13876        let mut todo: heapless::Vec<
13877            (u8, heapless::String<64>, heapless::String<64>),
13878            { crate::param_sizing::MAX_SERVICE_SETS },
13879        > = heapless::Vec::new();
13880        for index in 0..want {
13881            let key = nros_params::NodeKey::new(index as u8);
13882            if params.services.iter().any(|set| set.param_node() == key) {
13883                continue;
13884            }
13885            let (name, namespace) = match self.nodes.get(index) {
13886                Some(record) => (record.name.clone(), record.namespace.clone()),
13887                // No node table entry: the executor's own implicit primary
13888                // identity, which is what `NodeId::PRIMARY` means.
13889                None => (self.node_name.clone(), self.namespace.clone()),
13890            };
13891            if todo.push((index as u8, name, namespace)).is_err() {
13892                return Err(NodeError::NodeTableFull);
13893            }
13894        }
13895        // Issue 1270 -- the ONE buffer pair every set is answered through,
13896        // allocated with the first set so an image that declares parameters
13897        // and serves none pays nothing for it. `params` is `Some` here: the
13898        // scan above returned early otherwise.
13899        // Issue 1812 -- a carved home hands its pair over with the state, so
13900        // this is reached only on the heap fallback.
13901        if let Some(params) = self.params.as_deref_mut()
13902            && params.buffers.is_none()
13903        {
13904            // Issue 1551 — an exhausted heap is an error this fn returns, not
13905            // an abort through libstd's OOM hook.
13906            #[cfg(feature = "alloc")]
13907            {
13908                params.buffers = Some(
13909                    crate::parameter_services::ParamServiceBuffers::try_with_capacity(
13910                        crate::parameter_services::param_service_buffer_bytes(),
13911                    )
13912                    .ok_or(NodeError::Transport(TransportError::BadAlloc))?,
13913                );
13914            }
13915            #[cfg(not(feature = "alloc"))]
13916            return Err(NodeError::Transport(TransportError::BadAlloc));
13917        }
13918        for (index, name, namespace) in todo {
13919            let servers = match self.build_parameter_service_set(
13920                nros_params::NodeKey::new(index),
13921                &name,
13922                &namespace,
13923            ) {
13924                Ok(servers) => servers,
13925                Err((e, service)) => {
13926                    // Issue 1268 — keep WHAT failed and WHERE, for the one log
13927                    // line, and decide once whether retrying could ever help.
13928                    // `Unsupported` from the transport is a property of the
13929                    // build (the backend has no descriptor for the
13930                    // `rcl_interfaces` types), so a later spin asks the same
13931                    // question and gets the same answer.
13932                    let permanent = matches!(e, NodeError::Transport(TransportError::Unsupported));
13933                    if let Some(params) = self.params.as_deref_mut() {
13934                        let mut node_fqn = heapless::String::<256>::new();
13935                        if let Ok(fqn) = crate::names::fully_qualified_name(&name, &namespace) {
13936                            let _ = node_fqn.push_str(fqn.as_str());
13937                        }
13938                        params.reconcile_failure =
13939                            Some(crate::parameter_services::ParamServiceReconcileFailure {
13940                                node_fqn,
13941                                service,
13942                                permanent,
13943                            });
13944                    }
13945                    return Err(e);
13946                }
13947            };
13948            // Issue 1812 -- the set lives INLINE in the (carved) state now, not
13949            // in a box of its own.
13950            // Issue 0745 — PRESERVE an already-initialized store: launch-param
13951            // seeding may have run before any node existed (services can't be
13952            // registered that early). Overwriting here would wipe the seeds.
13953            match &mut self.params {
13954                Some(params) => params
13955                    .services
13956                    .push(servers)
13957                    .map_err(|_| NodeError::NodeTableFull)?,
13958                None => {
13959                    // Issue 0756 — see new_param_state: never build a ParamState
13960                    // by value, it does not fit an embedded thread stack.
13961                    // Issue 1706 -- the store is fallible; the refusal is
13962                    // reported once by `ensure_parameter_store`'s spelling.
13963                    if self.param_store_refused {
13964                        return Err(NodeError::Transport(TransportError::BadAlloc));
13965                    }
13966                    let Some(mut state) = Self::new_param_state(
13967                        core::mem::take(&mut self.param_slots),
13968                        core::mem::take(&mut self.param_home),
13969                    ) else {
13970                        self.param_store_refused = true;
13971                        report_parameter_store_refused();
13972                        return Err(NodeError::Transport(TransportError::BadAlloc));
13973                    };
13974                    state.requested = true;
13975                    let _ = state.services.push(servers);
13976                    self.params = Some(state);
13977                }
13978            }
13979            // phase-430 W2 — "declare `use_sim_time` on EVERY node, as rclcpp
13980            // does", said where the nodes finally exist. This is the only place
13981            // that knows the full set: `ensure_parameter_store` runs before any
13982            // node record does, so it can only seed the implicit PRIMARY, and
13983            // the further nodes an image composes onto one executor arrive
13984            // later. Seeding as each set of six goes up keeps the two in step —
13985            // the parameter is declared on exactly the nodes a
13986            // `ros2 param set <node> use_sim_time true` can reach.
13987            //
13988            // Idempotent by construction: `declare` refuses a name the node
13989            // already holds and the placeholder flag is only raised on
13990            // acceptance, so re-seeding PRIMARY here neither duplicates the
13991            // entry nor re-marks an application's own declaration as ours.
13992            self.seed_use_sim_time_default(super::node_record::NodeId::from_raw(index));
13993            // Issue 2023 — and `/parameter_events`, as rclcpp's and rclrs's
13994            // node does from its constructor. Seeded AFTER `use_sim_time`, so
13995            // the node's start-up event names it, as upstream's does.
13996            self.start_parameter_events_for_served_node(super::node_record::NodeId::from_raw(
13997                index,
13998            ));
13999        }
14000        // Issue 1271 -- every node is served now, so a later failure (a node
14001        // registered after this one) is news again and earns its own line.
14002        if let Some(params) = self.params.as_deref_mut() {
14003            params.reconcile_failure_reported = false;
14004            // Issue 1268 -- and drop the record with it, so a later failure is
14005            // reported against the node it actually happened on.
14006            params.reconcile_failure = None;
14007        }
14008        Ok(())
14009    }
14010
14011    /// phase-426 W3 — create the six service servers for ONE node.
14012    ///
14013    /// Split out of [`register_parameter_services`](Self::register_parameter_services)
14014    /// because there are now N of these per image rather than one, and because
14015    /// the six `create_param_srv` call sites are what
14016    /// `check-infra-queryable-counts` reads to hold `PARAM_SERVICE_QUERYABLES`
14017    /// to the number of queryables a set actually claims.
14018    #[inline(never)]
14019    fn build_parameter_service_set(
14020        &mut self,
14021        node: nros_params::NodeKey,
14022        node_name: &str,
14023        namespace: &str,
14024    ) -> Result<crate::parameter_services::ParameterServiceServers, (NodeError, &'static str)> {
14025        use crate::parameter_services::{
14026            DescribeParameters, GetParameterTypes, GetParameters, ListParameters,
14027            ParamServiceHandle as PSrv, ParameterServiceServers, SetParameters,
14028            SetParametersAtomically,
14029        };
14030        use nros_core::RosService;
14031
14032        // The node FQN, from the one implementation. This was the third
14033        // hand-rolled copy of the same join (`crate::names::fully_qualified_name`
14034        // is the first, `Node::fully_qualified_name` was the second) — and the
14035        // three did not agree: this one absolutised a bare namespace, that one
14036        // did not. A join with three spellings has no answer to "what does the
14037        // tree do with `my_ns`".
14038        let node_fqn = crate::names::fully_qualified_name(node_name, namespace)
14039            .map_err(|()| (NodeError::NameTooLong, "get_parameters"))?;
14040        // The service names below still take the two halves separately.
14041        let ns: &str = namespace;
14042        let nn: &str = node_name;
14043
14044        /// Build a service name like `{node_fqn}/{suffix}` and create the server handle.
14045        ///
14046        /// Issue 1268 — the error carries the SUFFIX, so a failure can name the
14047        /// service a tool would have called instead of "one of six".
14048        fn create_param_srv<Svc>(
14049            session: &mut session::ConcreteSession,
14050            domain_id: u32,
14051            node_fqn: &str,
14052            namespace: &str,
14053            node_name: &str,
14054            suffix: &'static str,
14055        ) -> Result<session::RmwServiceServer, (NodeError, &'static str)>
14056        where
14057            Svc: RosService,
14058            Svc::Request: crate::rmw_type_registry::MessageForRmw,
14059            Svc::Reply: crate::rmw_type_registry::MessageForRmw,
14060        {
14061            // Issue 1268 — REGISTER THE TYPES FIRST, exactly as a typed user
14062            // service does (`register_service_sized`). A backend that keys
14063            // topics on a registered type descriptor — Cyclone does — fails
14064            // `create_service` with `Unsupported` for a type it has never been
14065            // told about, and nothing else in the image registers the
14066            // `rcl_interfaces` service types: the generated typesupport of a
14067            // downstream covers ITS OWN message packages, and the backend bakes
14068            // in only `ParticipantEntitiesInfo` for the graph. So the six
14069            // parameter services failed on every spin while the image's own
14070            // services worked, which is what made the cause look like "no
14071            // service support" for two phases (the issue-0745 comment).
14072            //
14073            // A no-op on backends that need no descriptors (zenoh, xrce), which
14074            // is why zenoh served this same image all along.
14075            let register = |e: NodeError| (e, suffix);
14076            crate::rmw_type_registry::register_type::<Svc::Request>().map_err(register)?;
14077            crate::rmw_type_registry::register_type::<Svc::Reply>().map_err(register)?;
14078            let mut name = heapless::String::<256>::new();
14079            name.push_str(node_fqn)
14080                .map_err(|_| (NodeError::NameTooLong, suffix))?;
14081            name.push_str("/")
14082                .map_err(|_| (NodeError::NameTooLong, suffix))?;
14083            name.push_str(suffix)
14084                .map_err(|_| (NodeError::NameTooLong, suffix))?;
14085            let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
14086                // issue 0824 follow-up — `domain_id` is a PARAMETER, not `self.domain_id`:
14087                // this is a nested `fn`, not a method, so `self` is not in scope and
14088                // the original spelling was E0434. It only fails under feature sets
14089                // that compile this arm, which is why `--all-features` caught it and
14090                // the narrower lanes did not.
14091                .with_domain(domain_id)
14092                .with_namespace(namespace);
14093            if !node_name.is_empty() {
14094                info = info.with_node_name(node_name);
14095            }
14096            // issue 0793 — the parameter services get the PARAMETER profile
14097            // (`rmw_qos_profile_parameters`: KEEP_LAST(1000), reliable,
14098            // volatile), not the generic services one. `QOS_PROFILE_PARAMETERS`
14099            // existed with the right depth and had no caller, so every parameter
14100            // server ran on a depth-10 queue while ROS 2 gives them 1000 — which
14101            // matters exactly when a tool sets many parameters at once, the case
14102            // the deep queue is for.
14103            session
14104                .create_service(&info, QoSProfile::parameter_services_default())
14105                .map_err(|e| (NodeError::Transport(e), suffix))
14106        }
14107
14108        let get_handle = create_param_srv::<GetParameters>(
14109            &mut self.session,
14110            self.domain_id,
14111            &node_fqn,
14112            ns,
14113            nn,
14114            "get_parameters",
14115        )?;
14116        let set_handle = create_param_srv::<SetParameters>(
14117            &mut self.session,
14118            self.domain_id,
14119            &node_fqn,
14120            ns,
14121            nn,
14122            "set_parameters",
14123        )?;
14124        let set_atomic_handle = create_param_srv::<SetParametersAtomically>(
14125            &mut self.session,
14126            self.domain_id,
14127            &node_fqn,
14128            ns,
14129            nn,
14130            "set_parameters_atomically",
14131        )?;
14132        let list_handle = create_param_srv::<ListParameters>(
14133            &mut self.session,
14134            self.domain_id,
14135            &node_fqn,
14136            ns,
14137            nn,
14138            "list_parameters",
14139        )?;
14140        let desc_handle = create_param_srv::<DescribeParameters>(
14141            &mut self.session,
14142            self.domain_id,
14143            &node_fqn,
14144            ns,
14145            nn,
14146            "describe_parameters",
14147        )?;
14148        let types_handle = create_param_srv::<GetParameterTypes>(
14149            &mut self.session,
14150            self.domain_id,
14151            &node_fqn,
14152            ns,
14153            nn,
14154            "get_parameter_types",
14155        )?;
14156
14157        Ok(ParameterServiceServers::new(
14158            // phase-426 W3 — the set carries the node whose parameters it
14159            // serves, so a handler can never read the store without saying
14160            // whose parameters it means.
14161            node,
14162            // Issue 1271 -- carried so a dropped request is reported against
14163            // the service a tool actually called.
14164            node_fqn,
14165            // Issue 1270 -- handles only. The buffers are the executor's one
14166            // shared pair (`ParamState::buffers`), not six per node.
14167            PSrv::<GetParameters>::new(get_handle),
14168            PSrv::<SetParameters>::new(set_handle),
14169            PSrv::<SetParametersAtomically>::new(set_atomic_handle),
14170            PSrv::<ListParameters>::new(list_handle),
14171            PSrv::<DescribeParameters>::new(desc_handle),
14172            PSrv::<GetParameterTypes>::new(types_handle),
14173        ))
14174    }
14175
14176    /// phase-426 W3 — the node FQNs this executor publishes parameter services
14177    /// under, in registration order.
14178    ///
14179    /// The acceptance question for W3 ("does a two-node image expose both
14180    /// FQNs?") answered without a wire: the six services are created from
14181    /// exactly this list, so asserting it asserts what `ros2 param list` will
14182    /// enumerate. The wire assertion is W6.
14183    pub fn parameter_service_node_names(
14184        &self,
14185    ) -> heapless::Vec<crate::names::ResolvedName, { crate::param_sizing::MAX_SERVICE_SETS }> {
14186        let mut out = heapless::Vec::new();
14187        let Some(params) = self.params.as_deref() else {
14188            return out;
14189        };
14190        for set in params.services.iter() {
14191            let index = set.param_node().raw() as usize;
14192            let (name, namespace) = match self.nodes.get(index) {
14193                Some(record) => (record.name.as_str(), record.namespace.as_str()),
14194                None => (self.node_name.as_str(), self.namespace.as_str()),
14195            };
14196            if let Ok(fqn) = crate::names::fully_qualified_name(name, namespace) {
14197                let _ = out.push(fqn);
14198            }
14199        }
14200        out
14201    }
14202
14203    // phase-359 W10 / issue 0080 — ~~`enable_parameter_persistence{,_with}`~~ are
14204    // GONE with the seam they attached. Issue 0080 ruled on-device parameter
14205    // persistence a non-goal in July and listed both by name; nothing in the
14206    // tree ever called them, and the only backend that could be passed
14207    // (~~`FileParamStore`~~) is deleted. Runtime get/set/describe stay — it is the
14208    // PERSISTENCE half that was dropped.
14209}
14210
14211// ============================================================================
14212// Lifecycle services (cfg lifecycle-services)
14213// ============================================================================
14214
14215#[cfg(feature = "lifecycle-services")]
14216impl<'s> Executor<'s> {
14217    /// Register the five REP-2002 lifecycle services on this executor.
14218    ///
14219    /// After this call, `ros2 lifecycle set|get|list|nodes` can drive the
14220    /// stored [`LifecyclePollingNodeCtx`](crate::lifecycle::LifecyclePollingNodeCtx)
14221    /// through the node's lifecycle. The state machine is created fresh
14222    /// (starting in `Unconfigured`); callers register their transition
14223    /// callbacks via [`Executor::lifecycle_state_machine_mut`].
14224    ///
14225    /// # Safety
14226    /// Registered callbacks on the state machine are C FFI function pointers.
14227    /// The caller must keep the callback code and any context it captures
14228    /// valid for as long as the executor processes services.
14229    pub fn register_lifecycle_services(&mut self) -> Result<(), NodeError> {
14230        let ns = self.namespace.clone();
14231        let nn = self.node_name.clone();
14232        self.register_lifecycle_services_named(&ns, &nn)
14233    }
14234
14235    /// [`register_lifecycle_services`](Self::register_lifecycle_services),
14236    /// named after `node` rather than the executor's own identity.
14237    ///
14238    /// phase-488 node 2 (Nav2): a hosted C++ `rclcpp_lifecycle::LifecycleNode`
14239    /// is a node CREATED on the process executor, whose own identity is the
14240    /// default `node`. Registering through the executor served
14241    /// `/node/change_state` beside a phantom `/node`, and `ros2 lifecycle get
14242    /// /velocity_smoother` answered "Node not found". Parameter services were
14243    /// already per node (the reconcile); this is the same for lifecycle.
14244    pub fn register_lifecycle_services_on(
14245        &mut self,
14246        node: super::node_record::NodeId,
14247    ) -> Result<(), NodeError> {
14248        let (ns, nn) = {
14249            let r = self.node(node).ok_or(NodeError::NotInitialized)?;
14250            (r.namespace.clone(), r.name.clone())
14251        };
14252        self.register_lifecycle_services_named(&ns, &nn)
14253    }
14254
14255    fn register_lifecycle_services_named(&mut self, ns: &str, nn: &str) -> Result<(), NodeError> {
14256        use crate::{
14257            lifecycle::LifecyclePollingNodeCtx,
14258            lifecycle_services::{
14259                ChangeState, GetAvailableStates, GetAvailableTransitions, GetState,
14260                LifecycleRuntimeState, LifecycleServiceServers,
14261            },
14262        };
14263        use nros_core::RosService;
14264
14265        // Build the node FQN from namespace + node_name (same convention as
14266        // register_parameter_services).
14267        let mut node_fqn = heapless::String::<256>::new();
14268        if ns.is_empty() || ns == "/" {
14269            node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14270            node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
14271        } else {
14272            node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14273            node_fqn
14274                .push_str(ns.trim_matches('/'))
14275                .map_err(|_| NodeError::NameTooLong)?;
14276            node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14277            node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
14278        }
14279
14280        fn create_lc_srv<Svc>(
14281            session: &mut session::ConcreteSession,
14282            domain_id: u32,
14283            node_fqn: &str,
14284            namespace: &str,
14285            node_name: &str,
14286            suffix: &str,
14287        ) -> Result<session::RmwServiceServer, NodeError>
14288        where
14289            Svc: RosService,
14290            Svc::Request: crate::rmw_type_registry::MessageForRmw,
14291            Svc::Reply: crate::rmw_type_registry::MessageForRmw,
14292        {
14293            // Issue 1293 — register both halves first, as `create_param_srv`
14294            // does since issue 1268: Cyclone creates a topic only for a type it
14295            // holds a descriptor for. Three of the five request types are
14296            // EMPTY, and an empty schema had no descriptor until codegen gave
14297            // it rosidl's `structure_needs_at_least_one_member` byte. A no-op
14298            // on backends that need no descriptors (zenoh, xrce).
14299            crate::rmw_type_registry::register_type::<Svc::Request>()?;
14300            crate::rmw_type_registry::register_type::<Svc::Reply>()?;
14301            let mut name = heapless::String::<256>::new();
14302            name.push_str(node_fqn)
14303                .map_err(|_| NodeError::NameTooLong)?;
14304            name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14305            name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
14306            let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
14307                .with_domain(domain_id)
14308                .with_namespace(namespace);
14309            if !node_name.is_empty() {
14310                info = info.with_node_name(node_name);
14311            }
14312            session
14313                .create_service(&info, QoSProfile::services_default())
14314                .map_err(NodeError::Transport)
14315        }
14316
14317        /// Issue 1587 — the ONE publisher the REP-2002 family claims,
14318        /// `~/transition_event`. Shaped like `create_lc_srv` beside it both
14319        /// because the naming rule is the same (node FQN + suffix) and
14320        /// because `check-infra-queryable-counts` counts the creation sites
14321        /// to hold `LIFECYCLE_SERVICE_PUBLISHERS` to them, exactly as it
14322        /// already holds `LIFECYCLE_SERVICE_QUERYABLES` to the five above.
14323        fn create_lc_pub<Msg: crate::rmw_type_registry::MessageForRmw>(
14324            session: &mut session::ConcreteSession,
14325            domain_id: u32,
14326            node_fqn: &str,
14327            namespace: &str,
14328            node_name: &str,
14329            suffix: &str,
14330            qos: QoSProfile,
14331        ) -> Result<session::RmwPublisher, NodeError> {
14332            // Issue 1293 — same rule as `create_lc_srv`: Cyclone needs the
14333            // `TransitionEvent` descriptor before it can create the topic.
14334            crate::rmw_type_registry::register_type::<Msg>()?;
14335            let mut name = heapless::String::<256>::new();
14336            name.push_str(node_fqn)
14337                .map_err(|_| NodeError::NameTooLong)?;
14338            name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14339            name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
14340            let mut info = TopicInfo::new(
14341                &name,
14342                <Msg as nros_core::RosMessage>::TYPE_NAME,
14343                Msg::TYPE_HASH,
14344            )
14345            .with_domain(domain_id)
14346            .with_namespace(namespace);
14347            if !node_name.is_empty() {
14348                info = info.with_node_name(node_name);
14349            }
14350            session
14351                .create_publisher(&info, qos)
14352                .map_err(NodeError::Transport)
14353        }
14354
14355        let cs_handle = create_lc_srv::<ChangeState>(
14356            &mut self.session,
14357            self.domain_id,
14358            &node_fqn,
14359            ns,
14360            nn,
14361            "change_state",
14362        )?;
14363        let gs_handle = create_lc_srv::<GetState>(
14364            &mut self.session,
14365            self.domain_id,
14366            &node_fqn,
14367            ns,
14368            nn,
14369            "get_state",
14370        )?;
14371        let gas_handle = create_lc_srv::<GetAvailableStates>(
14372            &mut self.session,
14373            self.domain_id,
14374            &node_fqn,
14375            ns,
14376            nn,
14377            "get_available_states",
14378        )?;
14379        let gat_handle = create_lc_srv::<GetAvailableTransitions>(
14380            &mut self.session,
14381            self.domain_id,
14382            &node_fqn,
14383            ns,
14384            nn,
14385            "get_available_transitions",
14386        )?;
14387        let gtg_handle = create_lc_srv::<GetAvailableTransitions>(
14388            &mut self.session,
14389            self.domain_id,
14390            &node_fqn,
14391            ns,
14392            nn,
14393            "get_transition_graph",
14394        )?;
14395
14396        // Issue 1587 — REP-2002's communication interface is the five
14397        // services PLUS this. VOLATILE, so it costs a publisher slot and a
14398        // liveliness token and NO queryable; see
14399        // `LIFECYCLE_SERVICE_PUBLISHERS` for the measurement that settled it.
14400        let te_handle = create_lc_pub::<nros_lifecycle_msgs::msg::TransitionEvent>(
14401            &mut self.session,
14402            self.domain_id,
14403            &node_fqn,
14404            ns,
14405            nn,
14406            crate::lifecycle_services::TRANSITION_EVENT_SUFFIX,
14407            crate::lifecycle_services::transition_event_qos(),
14408        )?;
14409
14410        // Issue 2070 -- built in their allocation, never on this frame.
14411        let servers = LifecycleServiceServers::try_new_boxed([
14412            cs_handle, gs_handle, gas_handle, gat_handle, gtg_handle,
14413        ])
14414        .ok_or(NodeError::Transport(TransportError::BadAlloc))?;
14415
14416        self.lifecycle = Some(alloc::boxed::Box::new(LifecycleRuntimeState {
14417            state_machine: LifecyclePollingNodeCtx::new(),
14418            services: servers,
14419            transition_event: te_handle,
14420        }));
14421
14422        Ok(())
14423    }
14424
14425    /// The `~/transition_event` publisher, for the tests that assert what
14426    /// reached the wire. Not a public capability: the executor announces, and
14427    /// a second publisher on the same topic would be a second source of truth.
14428    /// Its only callers are the mock-session tests, which a `rmw-cffi` build
14429    /// does not compile.
14430    #[cfg(all(test, not(feature = "rmw-cffi")))]
14431    pub(crate) fn lifecycle_transition_event_publisher(&self) -> Option<&session::RmwPublisher> {
14432        self.lifecycle.as_ref().map(|lc| &lc.transition_event)
14433    }
14434
14435    /// Mutable access to the lifecycle state machine, if registered.
14436    ///
14437    /// Used to register transition callbacks before spinning and to read the
14438    /// current state from application code.
14439    pub fn lifecycle_state_machine_mut(
14440        &mut self,
14441    ) -> Option<&mut crate::lifecycle::LifecyclePollingNodeCtx> {
14442        self.lifecycle.as_mut().map(|lc| &mut lc.state_machine)
14443    }
14444
14445    /// Immutable access to the lifecycle state machine, if registered.
14446    pub fn lifecycle_state_machine(&self) -> Option<&crate::lifecycle::LifecyclePollingNodeCtx> {
14447        self.lifecycle.as_ref().map(|lc| &lc.state_machine)
14448    }
14449
14450    /// Register a safe lifecycle node (issue 0335 / phase-317).
14451    ///
14452    /// Binds the five REP-2002 lifecycle services and wires each transition to
14453    /// the [`LifecycleCallbacks`](crate::lifecycle::LifecycleCallbacks) trait
14454    /// impl — the safe Rust counterpart to the C++ `nros::LifecycleNode`. No
14455    /// `unsafe` in user code; alloc-free (monomorphized trampolines).
14456    ///
14457    /// `node` must outlive this executor's lifecycle registration: the context
14458    /// pointer stored here is dereferenced on every transition until the
14459    /// executor is finalized. The executor spins single-threaded, so the
14460    /// `&mut node` reconstituted inside a callback is never aliased.
14461    pub fn register_lifecycle_node<T: crate::lifecycle::LifecycleCallbacks>(
14462        &mut self,
14463        node: &mut T,
14464    ) -> Result<(), NodeError> {
14465        use crate::lifecycle::{LifecycleCallbackSlot as Slot, trampolines};
14466
14467        self.register_lifecycle_services()?;
14468        let sm = self
14469            .lifecycle_state_machine_mut()
14470            .ok_or(NodeError::NotInitialized)?;
14471        sm.set_context(node as *mut T as *mut core::ffi::c_void);
14472        sm.register(Slot::Configure, Some(trampolines::on_configure::<T>));
14473        sm.register(Slot::Activate, Some(trampolines::on_activate::<T>));
14474        sm.register(Slot::Deactivate, Some(trampolines::on_deactivate::<T>));
14475        sm.register(Slot::Cleanup, Some(trampolines::on_cleanup::<T>));
14476        sm.register(Slot::Shutdown, Some(trampolines::on_shutdown::<T>));
14477        sm.register(Slot::Error, Some(trampolines::on_error::<T>));
14478        Ok(())
14479    }
14480}
14481
14482// ============================================================================
14483// phase-446 W6 -- the contract's declared parameters (cfg param-services)
14484// ============================================================================
14485
14486#[cfg(feature = "param-store")]
14487impl<'s> Executor<'s> {
14488    /// Hand the executor the parameters each node's contract declares.
14489    ///
14490    /// The entry calls this before any node registers. It only RECORDS the
14491    /// table: the check runs where an application declares a parameter (the
14492    /// `nros` node runtime), not in [`Self::declare_parameter_on`], because
14493    /// that funnel also carries the launch seed, whose value type is still
14494    /// inferred from text and was already held to the contract by the
14495    /// resolver.
14496    pub fn set_declared_params(&mut self, declared: nros_params::DeclaredParams) {
14497        self.declared_params = declared;
14498    }
14499
14500    /// The table [`Self::set_declared_params`] recorded (empty by default).
14501    pub fn declared_params(&self) -> nros_params::DeclaredParams {
14502        self.declared_params
14503    }
14504}
14505
14506// ============================================================================
14507// Parameter declaration API (cfg param-services)
14508// ============================================================================
14509
14510#[cfg(feature = "param-store")]
14511impl<'s> Executor<'s> {
14512    /// phase-461 W6 — build the parameter STORE and register no service.
14513    ///
14514    /// What an image declaring the `params` axis (and not `param_services`)
14515    /// calls ahead of its nodes: launch seeds and `declare_parameter` land in
14516    /// the store, and `ros2 param` reaches none of it, because no queryable
14517    /// was created. The primary node's `use_sim_time` placeholder is seeded
14518    /// as it is for a served store; nothing can SET it here, which is the
14519    /// axis' stated limit.
14520    ///
14521    /// Idempotent, and a later [`Self::register_parameter_services`] keeps
14522    /// this store and adds the six services on top of it.
14523    pub fn register_parameter_store(&mut self) -> Result<(), NodeError> {
14524        if self.ensure_parameter_store() {
14525            Ok(())
14526        } else {
14527            Err(NodeError::Transport(TransportError::BadAlloc))
14528        }
14529    }
14530
14531    /// Issue 0745 — lazily create the parameter STORE (no services yet).
14532    /// Launch-param seeding runs before any node is constructed; the six
14533    /// service servers attach later in `register_parameter_services`,
14534    /// which preserves this store.
14535    /// `true` when the store exists afterwards. `false` only when the platform
14536    /// heap refused its allocation (issue 1706), which is reported once, here,
14537    /// with the size it asked for.
14538    fn ensure_parameter_store(&mut self) -> bool {
14539        if self.params.is_none() {
14540            if self.param_store_refused {
14541                return false;
14542            }
14543            let Some(state) = Self::new_param_state(
14544                core::mem::take(&mut self.param_slots),
14545                core::mem::take(&mut self.param_home),
14546            ) else {
14547                self.param_store_refused = true;
14548                report_parameter_store_refused();
14549                return false;
14550            };
14551            self.params = Some(state);
14552            // phase-430 W2 — the executor's implicit PRIMARY node exists the
14553            // moment the store does, whether or not a node record has been
14554            // built yet, so it is seeded here. The other node keys are seeded
14555            // in `reconcile_parameter_services`, as each one's set of six goes
14556            // up.
14557            self.seed_use_sim_time_default(super::node_record::NodeId::PRIMARY);
14558        }
14559        true
14560    }
14561
14562    /// phase-430 W2 — declare `use_sim_time = false`, as rclcpp does on EVERY
14563    /// node.
14564    ///
14565    /// Upstream auto-declares the parameter in `rclcpp::Node`'s constructor, so
14566    /// `ros2 param list` shows it and `ros2 param set <node> use_sim_time true`
14567    /// reaches a node whose author never wrote a line about simulated time.
14568    /// Ours honoured the parameter only if the APP had declared it: the store
14569    /// refuses an undeclared set (`allow_undeclared` is false), so the runtime
14570    /// switch worked on exactly the nodes that least needed it.
14571    ///
14572    /// The seed is a PLACEHOLDER, tracked by `ParamState::sim_time_seeded`,
14573    /// because the store refuses a duplicate name: without that flag, seeding
14574    /// here would turn an application's own `declare_parameter("use_sim_time",
14575    /// Bool(true))` into a refusal and the auto-declared default would silently
14576    /// beat the app. `yield_seeded_use_sim_time` steps the placeholder aside
14577    /// for the first real declaration, whatever value or descriptor it carries.
14578    ///
14579    /// PER NODE, both here and in the flag. phase-426 keyed the store and the
14580    /// six services by node, so "every node" is now literally expressible and
14581    /// this is where it is said: `ensure_parameter_store` seeds the executor's
14582    /// implicit PRIMARY, and `reconcile_parameter_services` seeds each further
14583    /// node key as its set of six goes up — which is exactly the set of nodes a
14584    /// `ros2 param set <node> use_sim_time true` can reach.
14585    ///
14586    /// It deliberately does NOT go through `note_reserved_parameter`, so
14587    /// `sim_time_stated` stays false: an auto-declared default is not a
14588    /// STATEMENT by anyone. Recording it as one would make every executor with
14589    /// parameter services write `set_active(false)` onto a PROCESS-GLOBAL gate
14590    /// on its first spin — switching off a simulated clock another executor in
14591    /// the same image installed, which is exactly the collision
14592    /// `sim_time_stated` was added for (issue 1104). That property is per node
14593    /// too: seeding N nodes must still be N statements by nobody.
14594    ///
14595    /// Gated on `sim-time` as well as `param-services`: an image that cannot
14596    /// act on the parameter should not advertise it, because a settable knob
14597    /// that does nothing is worse than an absent one.
14598    fn seed_use_sim_time_default(&mut self, node: super::node_record::NodeId) {
14599        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14600        if let Some(params) = self.params.as_deref_mut()
14601            && let Some(seeded) = params.sim_time_seeded.get_mut(node.index())
14602            && params.server.declare(
14603                node.into(),
14604                crate::time_source::USE_SIM_TIME_PARAM,
14605                nros_params::ParameterValue::Bool(false),
14606            )
14607        {
14608            *seeded = true;
14609        }
14610        let _ = node;
14611    }
14612
14613    /// phase-430 W2 — step the seeded `use_sim_time` aside for an application
14614    /// that declares it itself on `node`. Returns whether it did.
14615    ///
14616    /// The app's declaration must WIN — its own default, its own descriptor —
14617    /// and a declaration is refused when the name is already taken, so the
14618    /// placeholder has to leave before the real one arrives. Only ever the
14619    /// placeholder: once a real declaration has landed the flag is false and a
14620    /// second declaration is refused exactly as it was before W2.
14621    ///
14622    /// Only ever THIS node's placeholder, too: an app that names
14623    /// `use_sim_time` on one node of an image has said nothing about the
14624    /// others, and their seeds stay placeholders for whoever declares next.
14625    fn yield_seeded_use_sim_time(&mut self, node: super::node_record::NodeId, name: &str) -> bool {
14626        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14627        if name == crate::time_source::USE_SIM_TIME_PARAM
14628            && let Some(params) = self.params.as_deref_mut()
14629            && let Some(seeded) = params.sim_time_seeded.get_mut(node.index())
14630            && core::mem::replace(seeded, false)
14631        {
14632            return params.server.remove(node.into(), name);
14633        }
14634        let _ = (node, name);
14635        false
14636    }
14637
14638    /// The HEAP home for the parameter slot table, used only when the
14639    /// executor's backing carved none.
14640    ///
14641    /// phase-382 W2' — `ParameterServer` no longer OWNS its slots; it borrows
14642    /// a caller-placed [`nros_params::ParameterTable`]. phase-382 W3' carves
14643    /// that table out of the caller's executor backing whenever the build
14644    /// KNOWS the image builds a store ([`ExecutorSizing::params`](super::storage::ExecutorSizing::params), from
14645    /// `nros_params::IMPLIED_STORE_SLOTS`); this function is what remains for a
14646    /// store the build could not see — one reached only from application
14647    /// code — and for an executor opened with a sizing that carves none. It
14648    /// makes a single heap allocation, leaked, one per such executor that
14649    /// touches parameters. The leak is what buys the `'static`
14650    /// the borrow needs without making `ParamState` self-referential (a
14651    /// `ParamState` that owned the storage AND a server borrowing it is not
14652    /// expressible), and it is bounded — `ensure_parameter_store` runs this
14653    /// at most once per executor.
14654    ///
14655    /// Issue 0756 — the placement dance is still here because the SIZE is
14656    /// still here; it only moved off `ParameterServer` and onto
14657    /// `ParameterStorage`. That storage is 285,184 bytes at the default
14658    /// `MAX_PARAMETERS=32` and 2,281,472 at 256 (`ParameterValue` is sized by
14659    /// its `StringArray` variant, so every slot costs ~8.5 KiB regardless of
14660    /// what it holds). `Box::new(ParameterStorage::new())` materialises all of
14661    /// that on the caller's stack before copying it into the allocation,
14662    /// because Rust has no placement-new. On the Zephyr lane that silently
14663    /// overran the thread stack: an image built with 256 slots boots to
14664    /// `dds_create_participant` and hangs with no fault and no output, while
14665    /// 32 — which fits the 512 KiB main stack the cyclonedds snippet asks for
14666    /// — runs clean. Initialising through the allocation bounds the largest
14667    /// stack temporary at one slot, so the knob no longer decides whether boot
14668    /// survives.
14669    ///
14670    /// Issue 1706 -- FALLIBLE. This is the one allocation every parameter
14671    /// declaration reaches, in every language and on every RTOS, and it is the
14672    /// largest single request most images make. `Box::new_uninit` sent a
14673    /// failure to the OOM handler: Zephyr printed `HEAP EXHAUSTED` and died in
14674    /// issue 0589's recursion, FreeRTOS heap_4 hung in `MALLOC FAILED`. Every
14675    /// caller already has a refusal to return (`declare_parameter` is a
14676    /// `bool`), so the failure is now one, named by
14677    /// [`report_parameter_store_refused`] with the size it asked for.
14678    ///
14679    /// Issue 1812 -- `alloc` only. Without a heap there is no fallback, and
14680    /// the store is the carved one or none (see `new_param_state`).
14681    #[cfg(feature = "alloc")]
14682    fn leak_parameter_storage() -> Option<nros_params::ParameterTable<'static>> {
14683        #[cfg(all(test, feature = "alloc"))]
14684        if RefuseParameterStore::armed() {
14685            return None;
14686        }
14687        let mut uninit = nros_rmw::fallible::try_box_uninit::<nros_params::ParameterStorage>()?;
14688        // Safety: `try_box_uninit` gives a correctly-sized, correctly-aligned
14689        // allocation for exactly this type, and `init_in_place` writes every
14690        // slot exactly once before `assume_init` observes it.
14691        unsafe {
14692            nros_params::ParameterStorage::init_in_place(uninit.as_mut_ptr());
14693            Some(alloc::boxed::Box::leak(uninit.assume_init()).as_table())
14694        }
14695    }
14696
14697    /// Build the executor's `ParamState`.
14698    ///
14699    /// phase-382 W3' — `carved` are the backing's store slots: non-empty, they
14700    /// ARE the store and the heap is not asked; empty, the store falls back to
14701    /// [`leak_parameter_storage`](Self::leak_parameter_storage).
14702    ///
14703    /// Issue 1812 — and `home` is the services' carved region, which the
14704    /// backing places on the same condition: non-empty, the state, its service
14705    /// sets, the buffer pair and the staging slot are built IN it and nothing
14706    /// here allocates. Otherwise the state is one heap allocation, initialised
14707    /// in place (`ParamState::try_boxed` — it is several KiB now, so never a
14708    /// by-value `Box::new`). Without `alloc` that fallback does not exist and
14709    /// an executor whose backing carved nothing gets `None`, which every caller
14710    /// reports as the named refusal (`report_parameter_store_refused`).
14711    fn new_param_state(
14712        carved: &'s mut [MaybeUninit<nros_params::ParameterSlot>],
14713        home: &'s mut [MaybeUninit<super::storage::ParamHome>],
14714    ) -> Option<crate::param_store::Placed<'s, crate::param_store::ParamState<'s>>> {
14715        let table = if carved.is_empty() {
14716            #[cfg(feature = "alloc")]
14717            {
14718                Self::leak_parameter_storage()?
14719            }
14720            #[cfg(not(feature = "alloc"))]
14721            {
14722                return None;
14723            }
14724        } else {
14725            // Said once per store, so a boot log answers "where did the store
14726            // come from" without a debugger: issue 1706's measurements were
14727            // all heap-peak arithmetic, and this line is the direct answer.
14728            nros_log::log_info!(
14729                nros_log::get_logger("nros"),
14730                "parameter store: {} slots ({} B) + {} ({} B) carved from the executor backing",
14731                carved.len(),
14732                core::mem::size_of_val(carved),
14733                // Issue 1897 -- the home is the store's state, plus the
14734                // services' sets and buffers only where they are compiled.
14735                if cfg!(feature = "param-services") {
14736                    "state and services"
14737                } else {
14738                    "state"
14739                },
14740                core::mem::size_of_val(home)
14741            );
14742            nros_params::ParameterTable::init_in(carved)
14743        };
14744        let server = nros_params::ParameterServer::new_in(table);
14745        match home.first_mut() {
14746            Some(home) => Some(crate::param_store::ParamHome::init_in(home, server)),
14747            #[cfg(feature = "alloc")]
14748            None => crate::param_store::ParamState::try_boxed(server),
14749            #[cfg(not(feature = "alloc"))]
14750            None => None,
14751        }
14752    }
14753
14754    /// Install `-p` / `--params-file` parameter overrides — phase-482 W5.
14755    ///
14756    /// They are applied when a parameter is DECLARED: the declaration's value
14757    /// is replaced by the override's, typed by it (~~`apply_argv_param_override`~~).
14758    /// So an override reaches a parameter on every road that declares through
14759    /// this executor — C, C++ and Rust — including the launch file's baked
14760    /// values, which the generated entry declares through the same seam.
14761    ///
14762    /// That makes argv WIN over a launch-baked parameter, the opposite of the
14763    /// remap tiers, deliberately: overriding a baked value at run time without
14764    /// a rebuild is what RFC-0015 §9 asks for ("a runtime override has
14765    /// identical effect to a `set_parameter` request after boot"), and upstream
14766    /// agrees — a launch file's parameters reach the node as command-line
14767    /// arguments themselves, where the later one wins.
14768    ///
14769    /// Overrides accumulate in argv order and the LAST one that matches a
14770    /// declaration wins, as rcl's do. A `node:` override matches the node of
14771    /// that NAME; one without matches every node.
14772    ///
14773    /// Fails only when the parameter store could not be allocated (issue 1706),
14774    /// in which case nothing is installed.
14775    #[cfg(feature = "alloc")]
14776    pub fn install_argv_params<'p, I>(&mut self, params: I) -> Result<usize, NodeError>
14777    where
14778        I: IntoIterator<Item = crate::ros_args::ParamArg<'p>>,
14779    {
14780        if !self.ensure_parameter_store() {
14781            return Err(NodeError::Transport(TransportError::BadAlloc));
14782        }
14783        let Some(state) = self.params.as_deref_mut() else {
14784            return Err(NodeError::Transport(TransportError::BadAlloc));
14785        };
14786        let mut n = 0;
14787        for p in params {
14788            state
14789                .argv_overrides
14790                .push(crate::param_store::ArgvParamOverride {
14791                    node: p.node.map(alloc::string::String::from),
14792                    name: alloc::string::String::from(p.name),
14793                    value: alloc::string::String::from(p.value),
14794                });
14795            n += 1;
14796        }
14797        Ok(n)
14798    }
14799
14800    /// The `-p` / `--params-file` overrides that apply to `node`, one per
14801    /// name — issue 2057, for rclcpp's
14802    /// `NodeParametersInterface::get_parameter_overrides()`.
14803    ///
14804    /// The selection is the one a declaration makes
14805    /// (`argv_param_override`): the LAST override of a name that applies
14806    /// wins, and a `node:` override applies only to the node of that name.
14807    /// Each text is typed as a declaration with no default types it (bool,
14808    /// integer, double, then string), because a listing has no declared type
14809    /// to follow. An override no type accepts (a string past the store's
14810    /// bound) is left out. Yields in argv order of the winning override.
14811    #[cfg(all(feature = "param-store", feature = "alloc"))]
14812    pub(crate) fn argv_param_overrides_on(
14813        &self,
14814        node: super::node_record::NodeId,
14815    ) -> impl Iterator<Item = (&str, nros_params::ParameterValue)> + '_ {
14816        let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
14817        let list: &[crate::param_store::ArgvParamOverride] = self
14818            .params
14819            .as_deref()
14820            .map(|s| s.argv_overrides.as_slice())
14821            .unwrap_or(&[]);
14822        let applies = move |o: &crate::param_store::ArgvParamOverride| {
14823            o.node.as_deref().is_none_or(|n| n == node_name)
14824        };
14825        list.iter()
14826            .enumerate()
14827            .filter(move |(i, o)| {
14828                applies(o) && !list[i + 1..].iter().any(|l| l.name == o.name && applies(l))
14829            })
14830            .filter_map(|(_, o)| {
14831                typed_param_override(&o.value, &nros_params::ParameterValue::NotSet)
14832                    .map(|v| (o.name.as_str(), v))
14833            })
14834    }
14835
14836    /// [`argv_param_overrides_on`](Self::argv_param_overrides_on), one call of
14837    /// `f` per override with the typed value BORROWED (issue 2104). An
14838    /// iterator hands each ~8 KiB value out by value through its `next`
14839    /// frame and then into the caller's; here ONE value is refilled per
14840    /// override (issue 2118), which took `nros_cpp_node_list_param_overrides`
14841    /// from 17.3 KiB to 9.0 KiB on Cortex-M3.
14842    #[cfg(all(feature = "param-store", feature = "alloc"))]
14843    pub(crate) fn for_each_argv_param_override_on(
14844        &self,
14845        node: super::node_record::NodeId,
14846        mut f: impl FnMut(&str, &nros_params::ParameterValue),
14847    ) -> usize {
14848        let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
14849        let list: &[crate::param_store::ArgvParamOverride] = self
14850            .params
14851            .as_deref()
14852            .map(|s| s.argv_overrides.as_slice())
14853            .unwrap_or(&[]);
14854        let applies = |o: &crate::param_store::ArgvParamOverride| {
14855            o.node.as_deref().is_none_or(|n| n == node_name)
14856        };
14857        // Issue 2118 -- ONE value for the whole walk, refilled per override.
14858        let mut value = nros_params::ParameterValue::NotSet;
14859        let mut n = 0;
14860        for (i, o) in list.iter().enumerate() {
14861            if !applies(o) || list[i + 1..].iter().any(|l| l.name == o.name && applies(l)) {
14862                continue;
14863            }
14864            if typed_param_override_into(&o.value, &nros_params::ParameterValue::NotSet, &mut value)
14865            {
14866                f(o.name.as_str(), &value);
14867                n += 1;
14868            }
14869        }
14870        n
14871    }
14872
14873    /// Issue 2070 — does an argv override name this parameter? The cheap
14874    /// question [`declare_parameter_on_ref`](Self::declare_parameter_on_ref)
14875    /// asks before it enters the frame that types one.
14876    #[cfg(not(feature = "alloc"))]
14877    fn has_argv_param_override(&self, _node: super::node_record::NodeId, _name: &str) -> bool {
14878        false
14879    }
14880
14881    #[cfg(feature = "alloc")]
14882    fn has_argv_param_override(&self, node: super::node_record::NodeId, name: &str) -> bool {
14883        let Some(state) = self.params.as_deref() else {
14884            return false;
14885        };
14886        if state.argv_overrides.is_empty() {
14887            return false;
14888        }
14889        let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
14890        state
14891            .argv_overrides
14892            .iter()
14893            .any(|o| o.name == name && o.node.as_deref().is_none_or(|n| n == node_name))
14894    }
14895
14896    /// The value the last matching argv override gives a declaration of
14897    /// `name` on `node`, typed by `declared` and written into `out`; `false`
14898    /// when no override applies and `declared` stands.
14899    ///
14900    /// An override whose text does not parse as the declared type is NOT
14901    /// applied: it is logged at error severity, naming the parameter, the text
14902    /// and the type, and the declared default stands (RFC-0015 §9.6 — "log +
14903    /// ignore, never panic"). An array-typed declaration takes no override yet.
14904    ///
14905    /// Issue 2104 -- `declared` is borrowed and only the override is
14906    /// produced. Taking a clone of it and handing back a `(value, bool)` held
14907    /// three ~8 KiB values across two frames (25.5 KiB on Cortex-M3). Issue
14908    /// 2118 -- and the override is written into the caller's slot rather than
14909    /// returned, which held two (17 KiB).
14910    #[cfg(not(feature = "alloc"))]
14911    fn argv_param_override(
14912        &self,
14913        _node: super::node_record::NodeId,
14914        _name: &str,
14915        _declared: &nros_params::ParameterValue,
14916        _out: &mut nros_params::ParameterValue,
14917    ) -> bool {
14918        // Issue 1812 -- argv overrides are a hosted-edge input installed
14919        // through `install_argv_params`, which is `alloc`-only.
14920        false
14921    }
14922
14923    #[cfg(feature = "alloc")]
14924    fn argv_param_override(
14925        &self,
14926        node: super::node_record::NodeId,
14927        name: &str,
14928        declared: &nros_params::ParameterValue,
14929        out: &mut nros_params::ParameterValue,
14930    ) -> bool {
14931        let Some(state) = self.params.as_deref() else {
14932            return false;
14933        };
14934        if state.argv_overrides.is_empty() {
14935            return false;
14936        }
14937        let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
14938        let Some(o) = state
14939            .argv_overrides
14940            .iter()
14941            .rev()
14942            .find(|o| o.name == name && o.node.as_deref().is_none_or(|n| n == node_name))
14943        else {
14944            return false;
14945        };
14946        let typed = typed_param_override_into(&o.value, declared, out);
14947        if !typed {
14948            nros_log::log_error!(
14949                nros_log::get_logger("nros"),
14950                "parameter override `{}:={}` is not a {:?}; the declared default stands",
14951                name,
14952                o.value,
14953                declared.param_type()
14954            );
14955        }
14956        typed
14957    }
14958
14959    /// Declare a parameter with a value on the executor's PRIMARY node.
14960    ///
14961    /// phase-426 W1 — the store is keyed by node, and this is the spelling for
14962    /// the executor's implicit primary node (`NodeId::PRIMARY`), which is the
14963    /// identity `register_parameter_services` publishes and the one every
14964    /// single-node entry means. Name a different node with
14965    /// [`declare_parameter_on`](Self::declare_parameter_on).
14966    ///
14967    /// phase-428 W6 — `#[must_use]`, for the reason `[[nodiscard]]` went on
14968    /// C++'s `Result`: the `else { false }` arm is silently discarded at a
14969    /// call site that drops the value, and `Node::logger` two files over
14970    /// already carries the attribute — so this was inconsistency, not house
14971    /// style. A declare that failed and was ignored is a parameter the program
14972    /// believes it has.
14973    #[must_use]
14974    #[deprecated(
14975        since = "0.6.0",
14976        note = "the node is the parameter receiver (phase-515 R10): `node.declare_parameter::<T>(name).default(value).mandatory()` on the `Node` that `create_node` / `node_mut(id)` returns"
14977    )]
14978    pub fn declare_parameter(&mut self, name: &str, value: nros_params::ParameterValue) -> bool {
14979        self.declare_parameter_on(super::node_record::NodeId::PRIMARY, name, value)
14980    }
14981
14982    /// Declare a parameter with a value on a named node.
14983    ///
14984    /// `#[must_use]` for the reason [`declare_parameter`](Self::declare_parameter)
14985    /// carries it: issue 1257's two dropped results moved to THIS method when
14986    /// they were fixed, so the attribute on the wrapper alone guarded nothing
14987    /// a launch-seed site could still drop.
14988    #[must_use]
14989    pub(crate) fn declare_parameter_on(
14990        &mut self,
14991        node: super::node_record::NodeId,
14992        name: &str,
14993        value: nros_params::ParameterValue,
14994    ) -> bool {
14995        self.declare_parameter_on_ref(node, name, &value)
14996    }
14997
14998    /// [`declare_parameter_on`](Self::declare_parameter_on), borrowing the
14999    /// value — issue 2070, and THE declare path the by-value spelling forwards
15000    /// to. A `ParameterValue` is ~8 KiB at the default capacities, and moving
15001    /// it through each frame of the declare path cost ~57 KiB of stack for one
15002    /// `declare_parameter<double>` (aarch64; ~67 KiB on Cortex-M3). Borrowed,
15003    /// it is copied once, into its slot.
15004    #[must_use]
15005    pub(crate) fn declare_parameter_on_ref(
15006        &mut self,
15007        node: super::node_record::NodeId,
15008        name: &str,
15009        value: &nros_params::ParameterValue,
15010    ) -> bool {
15011        self.ensure_parameter_store();
15012        // The argv override is a hosted-edge input and rare; its frame (which
15013        // holds the typed override) is entered only when one applies, so the
15014        // common path carries no second value.
15015        if self.has_argv_param_override(node, name) {
15016            return self.declare_parameter_overridden(node, name, value);
15017        }
15018        self.declare_parameter_resolved(node, name, value)
15019    }
15020
15021    /// Issue 2070 — the override branch of
15022    /// [`declare_parameter_on_ref`](Self::declare_parameter_on_ref), in its
15023    /// own frame.
15024    #[inline(never)]
15025    fn declare_parameter_overridden(
15026        &mut self,
15027        node: super::node_record::NodeId,
15028        name: &str,
15029        declared: &nros_params::ParameterValue,
15030    ) -> bool {
15031        // Issue 2118 -- the frame's one value, filled in place.
15032        let mut value = nros_params::ParameterValue::NotSet;
15033        if self.argv_param_override(node, name, declared, &mut value) {
15034            self.declare_parameter_resolved(node, name, &value)
15035        } else {
15036            self.declare_parameter_resolved(node, name, declared)
15037        }
15038    }
15039
15040    /// The store write and the reserved-name hook, for a value whose argv
15041    /// override has already been applied.
15042    fn declare_parameter_resolved(
15043        &mut self,
15044        node: super::node_record::NodeId,
15045        name: &str,
15046        value: &nros_params::ParameterValue,
15047    ) -> bool {
15048        // phase-430 W2 — an application declaring `use_sim_time` itself wins
15049        // over the auto-declared default, so the placeholder leaves first.
15050        let yielded = self.yield_seeded_use_sim_time(node, name);
15051        let accepted = match &mut self.params {
15052            Some(params) => params.server.declare_ref(node.into(), name, value),
15053            None => false,
15054        };
15055        if yielded && !accepted {
15056            // The app's own declaration was refused on its own terms (a full
15057            // store, an ill-formed range). Put the rclcpp default back rather
15058            // than leaving the name undeclared — that is a state neither the
15059            // app nor upstream asked for, and it would silently un-do W2 for
15060            // this node.
15061            self.seed_use_sim_time_default(node);
15062        }
15063        // phase-425 W3b — `use_sim_time` is RESERVED, exactly as in ROS 2: its
15064        // value is not a value the app reads, it is the switch that attaches the
15065        // time source. This is the one seam every language funnels through
15066        // (`nros::main!`'s launch bakes via `apply_param_services`, nros-c's
15067        // the rclc parameter server's `declare`, nros-cpp's `params_shim`), so hooking it
15068        // here covers all of them instead of once per entry path.
15069        //
15070        // phase-430 W3 / issue 1202 — the hook runs AFTER the store's verdict
15071        // and only on acceptance. It used to run first and unconditionally, so
15072        // a re-declaration the store REFUSES (the name is already there) still
15073        // flipped the switch: the store kept `use_sim_time = true` while the
15074        // `/clock` source detached, leaving an executor with sim time recorded
15075        // as on and no source attached, which is neither of the two states the
15076        // parameter can name.
15077        if accepted {
15078            self.note_reserved_parameter(name);
15079        }
15080        accepted
15081    }
15082
15083    /// phase-425 W3b — record a reserved parameter's effect. Today that is
15084    /// `use_sim_time` and nothing else: it is the tree's only reserved name,
15085    /// and [`time_source::USE_SIM_TIME_PARAM`](crate::time_source::USE_SIM_TIME_PARAM)
15086    /// is the only such constant.
15087    ///
15088    /// Called only after the store ACCEPTED a declaration, and it reads the
15089    /// value back OUT of the store instead of taking the caller's: what the
15090    /// switch must follow is what the store holds, and the two are the same
15091    /// thing only while every write succeeds (issue 1202). The read is
15092    /// [`refresh_use_sim_time_from_store`](Self::refresh_use_sim_time_from_store),
15093    /// the same one the runtime `ros2 param set` path uses, so the declare path
15094    /// and the wire path cannot disagree about the value.
15095    ///
15096    /// A non-bool `use_sim_time` is IGNORED rather than rejected: parameter
15097    /// declaration has no channel to report a complaint on (it returns "did the
15098    /// store take it"), and refusing the declaration outright would fail a node
15099    /// for a parameter ROS 2 lets it declare. The time source simply does not
15100    /// attach, which is the same outcome as `false`.
15101    ///
15102    /// Not covered: [`Executor::parameter`](Self::parameter)'s
15103    /// [`ParameterBuilder`](nros_params::ParameterBuilder) borrows the server
15104    /// and declares through it, so the executor never sees the write and
15105    /// `use_sim_time` named that way attaches nothing — issue 1203.
15106    /// Issue 1203 — finish a `use_sim_time` that went through
15107    /// [`parameter`](Self::parameter)'s builder.
15108    ///
15109    /// The builder held the store while it declared, so nothing told the
15110    /// executor; this is the spin after, reading the store back. A builder that
15111    /// declared the name gets the reserved-parameter hook a `declare_parameter`
15112    /// would have run. One that was dropped without declaring leaves the node
15113    /// with no `use_sim_time`, because `parameter_on` stepped the placeholder
15114    /// aside for it, so the placeholder goes back.
15115    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
15116    fn settle_builder_reserved_parameters(&mut self) {
15117        let Some(params) = self.params.as_deref_mut() else {
15118            return;
15119        };
15120        if !params.sim_time_via_builder.contains(&true) {
15121            return;
15122        }
15123        let pending = core::mem::replace(
15124            &mut params.sim_time_via_builder,
15125            [false; crate::param_sizing::MAX_SERVICE_SETS],
15126        );
15127        let mut declared = false;
15128        for (index, _) in pending.iter().enumerate().filter(|(_, p)| **p) {
15129            let node = super::node_record::NodeId(index as u8);
15130            let present = self.params.as_deref().is_some_and(|p| {
15131                p.server
15132                    .get(node.into(), crate::time_source::USE_SIM_TIME_PARAM)
15133                    .is_some()
15134            });
15135            if present {
15136                declared = true;
15137            } else {
15138                self.seed_use_sim_time_default(node);
15139            }
15140        }
15141        if declared {
15142            self.note_reserved_parameter(crate::time_source::USE_SIM_TIME_PARAM);
15143        }
15144    }
15145
15146    fn note_reserved_parameter(&mut self, name: &str) {
15147        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
15148        if name == crate::time_source::USE_SIM_TIME_PARAM {
15149            self.refresh_use_sim_time_from_store();
15150        }
15151        let _ = name;
15152    }
15153
15154    /// phase-425 W3b — a parameter service handled `n` requests this spin.
15155    ///
15156    /// The hook exists so `use_sim_time` can be re-read after a runtime
15157    /// `ros2 param set`, WITHOUT a per-spin scan of the store. It takes the
15158    /// count unconditionally and ignores it when `sim-time` is off, rather than
15159    /// the call sites carrying the feature test: `handled` was then assigned and
15160    /// never read in the `param-services`-without-`sim-time` combo, which is a
15161    /// `-D warnings` error `check-build` catches and the narrower per-crate
15162    /// clippy runs do not.
15163    #[inline]
15164    fn note_param_services_ran(&mut self, handled: usize) {
15165        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
15166        if handled > 0 {
15167            self.refresh_use_sim_time_from_store();
15168        }
15169        let _ = handled;
15170        self.report_truncated_descriptions();
15171        #[cfg(feature = "param-services")]
15172        self.flush_parameter_events();
15173    }
15174
15175    /// phase-446 F2 -- log each parameter whose description did not fit
15176    /// `NROS_MAX_PARAM_DESCRIPTION_LEN`, once, naming the knob.
15177    ///
15178    /// `nros-params` has no logger, so it truncates, records the truncation on
15179    /// the stored descriptor, and hands it over here. Asked on every spin
15180    /// because a declaration can arrive through builders this executor never
15181    /// sees the end of (`parameter::<T>()` returns the store's own builder);
15182    /// while nothing is pending the store answers from one flag, without
15183    /// walking its table.
15184    #[cold]
15185    fn report_truncated_descriptions(&mut self) {
15186        let Some(params) = self.params.as_deref_mut() else {
15187            return;
15188        };
15189        let nodes = &self.nodes;
15190        params.server.take_truncated_descriptions(|key, name| {
15191            let (ns, node) = nodes
15192                .get(key.index())
15193                .map_or(("", "?"), |r| (r.namespace.as_str(), r.name.as_str()));
15194            nros_log::log_warn!(
15195                nros_log::get_logger("nros"),
15196                "parameter `{}` on node {}/{}: its description is longer than \
15197                 NROS_MAX_PARAM_DESCRIPTION_LEN ({} bytes), so `ros2 param describe` \
15198                 answers it truncated at a character boundary. Raise \
15199                 NROS_MAX_PARAM_DESCRIPTION_LEN to keep it whole; 0 stores no \
15200                 descriptions. Logged once for this parameter (phase-446 F2).",
15201                name,
15202                ns.trim_end_matches('/'),
15203                node,
15204                nros_params::MAX_PARAM_DESCRIPTION_LEN
15205            );
15206        });
15207    }
15208
15209    /// Declare a parameter with a value and descriptor on the PRIMARY node.
15210    /// Returns `true` if successful.
15211    ///
15212    /// phase-428 W6 remainder — the sibling of [`Self::declare_parameter`],
15213    /// with the identical `else { false }` arm and no attribute. W6 marked the
15214    /// one method its audit named; this is the site three lines below it, which
15215    /// is the shape CLAUDE.md's "fix the CLASS, not the reported site" rule
15216    /// exists for.
15217    #[must_use]
15218    #[deprecated(
15219        since = "0.6.0",
15220        note = "the node is the parameter receiver (phase-515 R10): `node.declare_parameter_with_descriptor(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15221    )]
15222    pub fn declare_parameter_with_descriptor(
15223        &mut self,
15224        name: &str,
15225        value: nros_params::ParameterValue,
15226        descriptor: nros_params::ParameterDescriptor,
15227    ) -> bool {
15228        self.declare_parameter_with_descriptor_on(
15229            super::node_record::NodeId::PRIMARY,
15230            name,
15231            value,
15232            descriptor,
15233        )
15234    }
15235
15236    /// Declare a parameter with a value and descriptor on a named node.
15237    pub(crate) fn declare_parameter_with_descriptor_on(
15238        &mut self,
15239        node: super::node_record::NodeId,
15240        name: &str,
15241        value: nros_params::ParameterValue,
15242        descriptor: nros_params::ParameterDescriptor,
15243    ) -> bool {
15244        self.ensure_parameter_store();
15245        // phase-430 W2 — same rule as the sibling above: the app's own
15246        // declaration, descriptor and all, replaces the auto-declared default.
15247        let yielded = self.yield_seeded_use_sim_time(node, name);
15248        // Issue 2070 — borrowed, as on the sibling path; the override branch
15249        // has its own frame.
15250        let accepted = if self.has_argv_param_override(node, name) {
15251            self.declare_described_overridden(node, name, &value, descriptor)
15252        } else {
15253            match &mut self.params {
15254                Some(params) => params
15255                    .server
15256                    .try_declare_ref(node.into(), name, &value, Some(descriptor))
15257                    .is_ok(),
15258                None => false,
15259            }
15260        };
15261        if yielded && !accepted {
15262            self.seed_use_sim_time_default(node);
15263        }
15264        // phase-430 W3 / issue 1202 — the sibling declare path, which had NO
15265        // reserved hook at all: `declare_parameter_with_descriptor` stored
15266        // `use_sim_time` and attached nothing, so the same declaration meant
15267        // two different things depending on whether the app passed a
15268        // descriptor. Same seam, same verdict ordering, one shared hook.
15269        if accepted {
15270            self.note_reserved_parameter(name);
15271        }
15272        accepted
15273    }
15274
15275    /// Issue 2070 — the override branch of
15276    /// [`declare_parameter_with_descriptor_on`](Self::declare_parameter_with_descriptor_on),
15277    /// in its own frame.
15278    ///
15279    /// phase-482 W5 — an argv override that the DESCRIPTOR refuses (out of
15280    /// range, or read-only with a different value) must not cost the
15281    /// parameter its existence: log it, then declare the code's default.
15282    #[inline(never)]
15283    fn declare_described_overridden(
15284        &mut self,
15285        node: super::node_record::NodeId,
15286        name: &str,
15287        declared: &nros_params::ParameterValue,
15288        descriptor: nros_params::ParameterDescriptor,
15289    ) -> bool {
15290        // Issue 2118 -- the frame's one value, filled in place.
15291        let mut value = nros_params::ParameterValue::NotSet;
15292        let overridden = self.argv_param_override(node, name, declared, &mut value);
15293        let Some(params) = &mut self.params else {
15294            return false;
15295        };
15296        if params
15297            .server
15298            .try_declare_ref(
15299                node.into(),
15300                name,
15301                if overridden { &value } else { declared },
15302                Some(descriptor.clone()),
15303            )
15304            .is_ok()
15305        {
15306            return true;
15307        }
15308        if !overridden {
15309            return false;
15310        }
15311        nros_log::log_error!(
15312            nros_log::get_logger("nros"),
15313            "parameter override for `{}` was refused by its descriptor; the declared \
15314             default stands",
15315            name
15316        );
15317        params
15318            .server
15319            .try_declare_ref(node.into(), name, declared, Some(descriptor))
15320            .is_ok()
15321    }
15322
15323    /// THE writer — phase-426 W2.
15324    ///
15325    /// Set an ALREADY-DECLARED parameter on the executor's primary node, with
15326    /// the same rules a remote `ros2 param set` gets: read-only, type, range
15327    /// (min/max/step), and issue 1151's refusal of a name the node never
15328    /// declared unless that node opted in through
15329    /// [`allow_undeclared_parameters_on`](Self::allow_undeclared_parameters_on).
15330    ///
15331    /// It routes through [`ParameterServer::apply`](nros_params::ParameterServerOps::apply),
15332    /// which is what makes the C and C++ facades, the by-value oracle, the
15333    /// streaming service path and the atomic pre-check agree — a facade that
15334    /// wrote the slot directly would be a second answer to "may this set
15335    /// happen", which is the class RFC-0019/0020 forbids. An in-image caller
15336    /// that genuinely wants declare-on-write names
15337    /// `params_mut()`/`set_or_declare` and has opted in by naming it.
15338    ///
15339    /// Returns `Err(ParameterError::NotFound)` when the store does not exist
15340    /// yet (no parameter has been declared and no services registered).
15341    #[deprecated(
15342        since = "0.6.0",
15343        note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter(name, value)` on the `Node` that `create_node` / `node_mut(id)` returns"
15344    )]
15345    pub fn set_parameter(
15346        &mut self,
15347        name: &str,
15348        value: nros_params::ParameterValue,
15349    ) -> Result<(), nros_params::ParameterError> {
15350        self.set_parameter_on(super::node_record::NodeId::PRIMARY, name, value)
15351    }
15352
15353    /// [`set_parameter`](Self::set_parameter) on a named node.
15354    pub(crate) fn set_parameter_on(
15355        &mut self,
15356        node: super::node_record::NodeId,
15357        name: &str,
15358        value: nros_params::ParameterValue,
15359    ) -> Result<(), nros_params::ParameterError> {
15360        self.set_parameter_on_ref(node, name, &value)
15361    }
15362
15363    /// [`set_parameter_on`](Self::set_parameter_on), borrowing the value --
15364    /// issue 2104, and the one the by-value spelling forwards to. The value is
15365    /// copied once, into its slot (`ParameterServer::apply_ref`).
15366    pub(crate) fn set_parameter_on_ref(
15367        &mut self,
15368        node: super::node_record::NodeId,
15369        name: &str,
15370        value: &nros_params::ParameterValue,
15371    ) -> Result<(), nros_params::ParameterError> {
15372        // Reserved parameters (`use_sim_time`) take effect on a set exactly as
15373        // they do on a declare — this is the seam every language funnels
15374        // through, so hooking it once here covers all of them.
15375        //
15376        // AFTER the store's verdict and only on `Success`, for the reason issue
15377        // 1202 gives one function up: the hook used to run first and off the
15378        // caller's value, so a write the store REFUSED still moved the switch
15379        // and left sim time recorded as on with no source attached. `apply` has
15380        // more ways to refuse than `declare` does — read-only, type, range,
15381        // undeclared-without-the-node-option — so a writer that hooked first
15382        // would reach that state from four directions instead of one.
15383        let result = match &mut self.params {
15384            Some(params) => params.server.apply_ref(node.into(), name, value),
15385            None => Err(nros_params::ParameterError::NotFound),
15386        };
15387        if result.is_ok() {
15388            self.note_reserved_parameter(name);
15389        }
15390        result
15391    }
15392
15393    /// phase-491 W2 (RFC-0105 D7) — rclc's LOCAL typed set on `node`:
15394    /// type, the on-set hooks with `(old, new)`, the write. Read-only and
15395    /// range are NOT checked, because rclc's owner of a parameter is not bound
15396    /// by them; remote writes keep [`set_parameter_on`](Self::set_parameter_on)'s
15397    /// rules. The reserved-parameter hook runs on success, as on every writer.
15398    pub(crate) fn set_parameter_local_on(
15399        &mut self,
15400        node: super::node_record::NodeId,
15401        name: &str,
15402        value: nros_params::ParameterValue,
15403    ) -> Result<(), nros_params::ParameterError> {
15404        let result = match &mut self.params {
15405            Some(params) => params.server.set_local(node.into(), name, value),
15406            None => Err(nros_params::ParameterError::NotFound),
15407        };
15408        if result.is_ok() {
15409            self.note_reserved_parameter(name);
15410        }
15411        result
15412    }
15413
15414    #[cfg(feature = "param-services")]
15415    /// Issue 2023 — the serving half of `/parameter_events`: a node whose six
15416    /// services just came up publishes its events unless it opted out. A
15417    /// failure is LOGGED and the services stay up: `ros2 param` still works,
15418    /// and a publisher slot the image did not size for is a fact to report,
15419    /// not a reason to take the services down with it.
15420    #[inline(never)]
15421    fn start_parameter_events_for_served_node(&mut self, node: super::node_record::NodeId) {
15422        if self
15423            .params
15424            .as_deref()
15425            .is_some_and(|p| p.events_opt_out.get(node.into()))
15426        {
15427            return;
15428        }
15429        if let Err(e) = self.enable_parameter_events_on(node) {
15430            report_parameter_events_refused(e);
15431        }
15432    }
15433
15434    #[cfg(feature = "param-services")]
15435    /// Issue 2023 — `node` publishes no `/parameter_events` (rclc's
15436    /// `notify_changed_over_dds = false`; rclcpp's
15437    /// `start_parameter_event_publisher(false)`). Stops logging its changes,
15438    /// and keeps [`register_parameter_services`](Self::register_parameter_services)
15439    /// from switching them on. The executor's one publisher stays up if
15440    /// another node uses it.
15441    pub(crate) fn disable_parameter_events_on(&mut self, node: super::node_record::NodeId) {
15442        if !self.ensure_parameter_store() {
15443            return;
15444        }
15445        if let Some(params) = self.params.as_deref_mut() {
15446            params.events_opt_out.set(node.into(), true);
15447            params.server.set_parameter_events(node.into(), false);
15448        }
15449    }
15450
15451    #[cfg(feature = "param-services")]
15452    /// phase-491 W3 (RFC-0105 D8) — publish `node`'s parameter changes on
15453    /// `/parameter_events` (rclc's `notify_changed_over_dds`).
15454    ///
15455    /// Creates the executor's one `/parameter_events` publisher on the first
15456    /// call (see `ParamState::event_publisher`), typed
15457    /// `rcl_interfaces/msg/ParameterEvent`, RELIABLE + VOLATILE as upstream's
15458    /// `parameter_events` profile. Its depth is upstream's 1000 on a hosted
15459    /// build and `NROS_PARAM_EVENTS_DEPTH` (default 1) otherwise, because a
15460    /// reliable DDS writer's history is memory the RMW reserves; depth is not
15461    /// a matching criterion, so an upstream subscriber still matches.
15462    /// Idempotent.
15463    pub(crate) fn enable_parameter_events_on(
15464        &mut self,
15465        node: super::node_record::NodeId,
15466    ) -> Result<(), NodeError> {
15467        type Event = nros_rcl_interfaces::msg::ParameterEvent;
15468        if !self.ensure_parameter_store() {
15469            return Err(NodeError::Transport(TransportError::BadAlloc));
15470        }
15471        let idx = node.index();
15472        if idx >= crate::param_sizing::MAX_SERVICE_SETS {
15473            return Err(NodeError::NodeTableFull);
15474        }
15475        // Issue 2023 — an explicit request overrides an earlier opt-out.
15476        if let Some(params) = self.params.as_deref_mut() {
15477            params.events_opt_out.set(node.into(), false);
15478        }
15479        if self
15480            .params
15481            .as_ref()
15482            .is_some_and(|p| p.event_publisher.is_some())
15483        {
15484            if let Some(params) = self.params.as_mut() {
15485                params.server.set_parameter_events(node.into(), true);
15486            }
15487            return Ok(());
15488        }
15489        // The event is encoded into the services' reply buffer; on the heap
15490        // road that pair is allocated with the first service set, which may
15491        // not exist yet.
15492        #[cfg(feature = "alloc")]
15493        if let Some(params) = self.params.as_mut()
15494            && params.buffers.is_none()
15495        {
15496            params.buffers = Some(
15497                crate::parameter_services::ParamServiceBuffers::try_with_capacity(
15498                    crate::parameter_services::param_service_buffer_bytes(),
15499                )
15500                .ok_or(NodeError::Transport(TransportError::BadAlloc))?,
15501            );
15502        }
15503        crate::rmw_type_registry::register_type::<Event>()?;
15504        let qos = QoSProfile::parameter_events_default().depth(nros_params::PARAM_EVENTS_DEPTH);
15505        let handle = create_param_event_pub(self, node, qos)?;
15506        if let Some(params) = self.params.as_mut() {
15507            params.event_publisher = Some(handle);
15508            params.server.set_parameter_events(node.into(), true);
15509        }
15510        Ok(())
15511    }
15512
15513    #[cfg(feature = "param-services")]
15514    /// phase-491 W3 — publish every pending parameter change, one
15515    /// `ParameterEvent` each, oldest first. Runs after each spin's parameter
15516    /// services and after every C or C++ parameter call; nothing allocates.
15517    pub fn flush_parameter_events(&mut self) {
15518        let stamp_ns = self
15519            .epoch_us_fn
15520            .or(self.clock_us_fn)
15521            .map_or(0, |clock| clock().saturating_mul(1000));
15522        let mut lost: u32 = 0;
15523        loop {
15524            let Some(params) = self.params.as_mut() else {
15525                return;
15526            };
15527            // Issue 2023 — a node's start-up event first, then the log.
15528            let Some(event) = params
15529                .server
15530                .pop_initial_parameter_event()
15531                .or_else(|| params.server.pop_parameter_event())
15532            else {
15533                break;
15534            };
15535            let idx = event.node.index();
15536            let mut fqn = heapless::String::<256>::new();
15537            match self.nodes.get(idx) {
15538                Some(r) => {
15539                    if let Ok(f) = crate::names::fully_qualified_name(&r.name, &r.namespace) {
15540                        let _ = fqn.push_str(f.as_str());
15541                    }
15542                }
15543                None => {
15544                    if let Ok(f) =
15545                        crate::names::fully_qualified_name(&self.node_name, &self.namespace)
15546                    {
15547                        let _ = fqn.push_str(f.as_str());
15548                    }
15549                }
15550            }
15551            let Some(params) = self.params.as_mut() else {
15552                return;
15553            };
15554            let crate::param_store::ParamState {
15555                server,
15556                buffers,
15557                event_publisher,
15558                ..
15559            } = &mut **params;
15560            let (Some(publisher), Some(buffers)) = (event_publisher.as_ref(), buffers.as_mut())
15561            else {
15562                continue;
15563            };
15564            let value = match event.kind {
15565                nros_params::ParameterEventKind::Deleted => None,
15566                _ => server.get(event.node, event.name.as_str()),
15567            };
15568            let encoded = crate::parameter_services::encode_parameter_event(
15569                buffers.reply_mut(),
15570                stamp_ns,
15571                fqn.as_str(),
15572                event.kind,
15573                event.name.as_str(),
15574                value,
15575            );
15576            match encoded {
15577                Some(n) => {
15578                    use nros_rmw::Publisher as _;
15579                    if publisher.publish_raw(&buffers.reply_mut()[..n]).is_err() {
15580                        lost = lost.saturating_add(1);
15581                    }
15582                }
15583                None => {
15584                    lost = lost.saturating_add(1);
15585                }
15586            }
15587        }
15588        let dropped = self
15589            .params
15590            .as_mut()
15591            .map_or(0, |p| p.server.take_parameter_events_dropped());
15592        let lost = lost.saturating_add(dropped);
15593        if lost > 0 {
15594            Self::report_parameter_events_lost(lost);
15595        }
15596    }
15597
15598    #[cfg(feature = "param-services")]
15599    #[cold]
15600    fn report_parameter_events_lost(lost: u32) {
15601        // Issue 2023 — an event that does not fit is REFUSED whole, never
15602        // truncated into a malformed sample; this line is how anyone hears.
15603        nros_log::log_error!(
15604            nros_log::get_logger("nros"),
15605            "{} /parameter_events message(s) were not published: the change log \
15606             (PARAMETER_EVENT_LOG_LEN) filled between two flushes, an event did not fit the \
15607             parameter-service reply buffer (NROS_PARAM_SERVICE_BUFFER_SIZE) and was refused \
15608             rather than truncated, or the publish failed. The parameters themselves changed \
15609             (phase-491 W3, issue 2023).",
15610            lost
15611        );
15612    }
15613
15614    /// phase-491 W2 — a remote `NOT_SET` on `node` deletes the parameter
15615    /// (rclc) instead of being refused (rclcpp). See
15616    /// [`ParameterServer::set_delete_on_not_set`](nros_params::ParameterServerOps::set_delete_on_not_set).
15617    pub(crate) fn delete_parameters_on_not_set_on(
15618        &mut self,
15619        node: super::node_record::NodeId,
15620        on: bool,
15621    ) {
15622        self.ensure_parameter_store();
15623        if let Some(params) = &mut self.params {
15624            params.server.set_delete_on_not_set(node.into(), on);
15625        }
15626    }
15627
15628    /// May a remote set DECLARE a name this node never declared? Upstream's
15629    /// `allow_undeclared_parameters` node option (issue 1151), per node since
15630    /// phase-426 W1: switching it on for one node leaves its siblings
15631    /// refusing.
15632    pub(crate) fn allow_undeclared_parameters_on(
15633        &mut self,
15634        node: super::node_record::NodeId,
15635        allow: bool,
15636    ) {
15637        self.ensure_parameter_store();
15638        if let Some(params) = &mut self.params {
15639            params.server.set_allow_undeclared(node.into(), allow);
15640        }
15641    }
15642
15643    /// [`allow_undeclared_parameters_on`](Self::allow_undeclared_parameters_on)
15644    /// for the primary node.
15645    #[deprecated(
15646        since = "0.6.0",
15647        note = "the node is the parameter receiver (phase-515 R10): `node.allow_undeclared_parameters(allow)` on the `Node` that `create_node` / `node_mut(id)` returns"
15648    )]
15649    pub fn allow_undeclared_parameters(&mut self, allow: bool) {
15650        self.allow_undeclared_parameters_on(super::node_record::NodeId::PRIMARY, allow);
15651    }
15652
15653    /// Get a parameter value by name from the primary node.
15654    #[deprecated(
15655        since = "0.6.0",
15656        note = "the node is the parameter receiver (phase-515 R10): `node.get_parameter(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
15657    )]
15658    pub fn get_parameter(&self, name: &str) -> Option<&nros_params::ParameterValue> {
15659        self.get_parameter_on(super::node_record::NodeId::PRIMARY, name)
15660    }
15661
15662    /// Get a parameter value by name from a named node.
15663    pub(crate) fn get_parameter_on(
15664        &self,
15665        node: super::node_record::NodeId,
15666        name: &str,
15667    ) -> Option<&nros_params::ParameterValue> {
15668        self.params.as_deref()?.server.get(node.into(), name)
15669    }
15670
15671    /// Get an integer parameter value by name from the primary node (convenience).
15672    pub fn get_parameter_integer(&self, name: &str) -> Option<i64> {
15673        self.params
15674            .as_ref()?
15675            .server
15676            .get_integer(super::node_record::NodeId::PRIMARY.into(), name)
15677    }
15678
15679    /// Get a reference to the parameter server (if registered).
15680    pub fn params(&self) -> Option<&nros_params::ParameterServer<'s>> {
15681        self.params.as_deref().map(|p| &p.server)
15682    }
15683
15684    /// Get a mutable reference to the parameter server (if registered).
15685    pub fn params_mut(&mut self) -> Option<&mut nros_params::ParameterServer<'s>> {
15686        self.params.as_deref_mut().map(|p| &mut p.server)
15687    }
15688
15689    /// Create a typed parameter builder (rclrs-compatible API).
15690    ///
15691    /// Returns a [`ParameterBuilder`] for fluent parameter declaration with
15692    /// `.default()`, `.description()`, `.range()`, and terminal methods
15693    /// `.mandatory()`, `.optional()`, or `.read_only()`.
15694    ///
15695    /// Returns [`NodeError::NotInitialized`] if parameter services have
15696    /// not been registered yet — call [`register_parameter_services`]
15697    /// first.
15698    ///
15699    /// # Example
15700    ///
15701    /// ```ignore
15702    /// let max_speed = executor.parameter::<f64>("max_speed")?
15703    ///     .default(25.0)
15704    ///     .description("Maximum velocity (m/s)")
15705    ///     .read_only()?;
15706    /// ```
15707    ///
15708    /// [`ParameterBuilder`]: nros_params::ParameterBuilder
15709    /// [`register_parameter_services`]: Self::register_parameter_services
15710    #[deprecated(
15711        since = "0.6.0",
15712        note = "the node is the parameter receiver (phase-515 R10): `node.declare_parameter::<T>(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
15713    )]
15714    pub fn parameter<'a, T: nros_params::ParameterVariant>(
15715        &'a mut self,
15716        name: &'a str,
15717    ) -> Result<nros_params::ParameterBuilder<'a, 's, T>, NodeError> {
15718        self.parameter_on(super::node_record::NodeId::PRIMARY, name)
15719    }
15720
15721    /// [`parameter`](Self::parameter) on a named node.
15722    pub(crate) fn parameter_on<'a, T: nros_params::ParameterVariant>(
15723        &'a mut self,
15724        node: super::node_record::NodeId,
15725        name: &'a str,
15726    ) -> Result<nros_params::ParameterBuilder<'a, 's, T>, NodeError> {
15727        // phase-515 R10 — the store comes into being on first use, as it
15728        // does for `declare_parameter_on`: `Node::declare_parameter` is the
15729        // user-facing declaration now, and it must not need a call the old
15730        // one did not. `NotInitialized` remains for a heap that refused it.
15731        self.ensure_parameter_store();
15732        // Issue 1203 — a reserved name the executor acts on. The builder
15733        // declares through its own borrow of the store, so the seeded
15734        // placeholder has to leave first (or the declaration is refused, the
15735        // way a second `declare_parameter` is), and the next spin reads back
15736        // what was declared.
15737        #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
15738        if name == crate::time_source::USE_SIM_TIME_PARAM {
15739            self.yield_seeded_use_sim_time(node, name);
15740            if let Some(pending) = self
15741                .params
15742                .as_deref_mut()
15743                .and_then(|p| p.sim_time_via_builder.get_mut(node.index()))
15744            {
15745                *pending = true;
15746            }
15747        }
15748        let server = self
15749            .params
15750            .as_mut()
15751            .map(|p| &mut p.server)
15752            .ok_or(NodeError::NotInitialized)?;
15753        Ok(nros_params::ParameterBuilder::new(
15754            server,
15755            node.into(),
15756            name,
15757        ))
15758    }
15759
15760    // ========================================================================
15761    // phase-417 W4.a — the surface C and C++ forward to
15762    //
15763    // Every one of these is a forwarder onto `nros_params::ParameterServer`,
15764    // the ONE store. They exist on the executor because that is the object the
15765    // FFI holds: a C caller has an `rclc_executor_t*` and an `rcl_node_t*`,
15766    // and a C++ node has its `node_id`. Nothing here keeps state — a wrapper
15767    // that did would be the second store phase-426 removed.
15768    // ========================================================================
15769
15770    /// Attach a description and free-text constraints to a declared parameter
15771    /// on `node` — rclc's `rclc_add_parameter_description`.
15772    pub(crate) fn set_parameter_description_on(
15773        &mut self,
15774        node: super::node_record::NodeId,
15775        name: &str,
15776        description: &str,
15777        additional_constraints: &str,
15778    ) -> bool {
15779        match &mut self.params {
15780            Some(params) => params.server.set_parameter_description(
15781                node.into(),
15782                name,
15783                description,
15784                additional_constraints,
15785            ),
15786            None => false,
15787        }
15788    }
15789
15790    /// [`set_parameter_description_on`](Self::set_parameter_description_on) for
15791    /// the primary node.
15792    #[deprecated(
15793        since = "0.6.0",
15794        note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter_description(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15795    )]
15796    pub fn set_parameter_description(
15797        &mut self,
15798        name: &str,
15799        description: &str,
15800        additional_constraints: &str,
15801    ) -> bool {
15802        self.set_parameter_description_on(
15803            super::node_record::NodeId::PRIMARY,
15804            name,
15805            description,
15806            additional_constraints,
15807        )
15808    }
15809
15810    /// Mark a declared parameter read-only — rclc's
15811    /// `rclc_set_parameter_read_only`. Every later write, local or remote, is
15812    /// refused.
15813    pub(crate) fn set_parameter_read_only_on(
15814        &mut self,
15815        node: super::node_record::NodeId,
15816        name: &str,
15817        read_only: bool,
15818    ) -> bool {
15819        match &mut self.params {
15820            Some(params) => params
15821                .server
15822                .set_parameter_read_only(node.into(), name, read_only),
15823            None => false,
15824        }
15825    }
15826
15827    /// [`set_parameter_read_only_on`](Self::set_parameter_read_only_on) for the
15828    /// primary node.
15829    #[deprecated(
15830        since = "0.6.0",
15831        note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter_read_only(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15832    )]
15833    pub fn set_parameter_read_only(&mut self, name: &str, read_only: bool) -> bool {
15834        self.set_parameter_read_only_on(super::node_record::NodeId::PRIMARY, name, read_only)
15835    }
15836
15837    /// Attach an integer range — rclc's
15838    /// `rclc_add_parameter_constraint_integer`. Refuses an ill-formed range or
15839    /// one the current value does not satisfy.
15840    pub(crate) fn set_parameter_integer_range_on(
15841        &mut self,
15842        node: super::node_record::NodeId,
15843        name: &str,
15844        min: i64,
15845        max: i64,
15846        step: i64,
15847    ) -> bool {
15848        match &mut self.params {
15849            Some(params) => {
15850                params
15851                    .server
15852                    .set_parameter_integer_range(node.into(), name, min, max, step)
15853            }
15854            None => false,
15855        }
15856    }
15857
15858    /// [`set_parameter_integer_range_on`](Self::set_parameter_integer_range_on)
15859    /// for the primary node.
15860    #[deprecated(
15861        since = "0.6.0",
15862        note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter_integer_range(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15863    )]
15864    pub fn set_parameter_integer_range(
15865        &mut self,
15866        name: &str,
15867        min: i64,
15868        max: i64,
15869        step: i64,
15870    ) -> bool {
15871        self.set_parameter_integer_range_on(
15872            super::node_record::NodeId::PRIMARY,
15873            name,
15874            min,
15875            max,
15876            step,
15877        )
15878    }
15879
15880    /// Attach a floating-point range — rclc's
15881    /// `rclc_add_parameter_constraint_double`.
15882    pub(crate) fn set_parameter_float_range_on(
15883        &mut self,
15884        node: super::node_record::NodeId,
15885        name: &str,
15886        min: f64,
15887        max: f64,
15888        step: f64,
15889    ) -> bool {
15890        match &mut self.params {
15891            Some(params) => {
15892                params
15893                    .server
15894                    .set_parameter_float_range(node.into(), name, min, max, step)
15895            }
15896            None => false,
15897        }
15898    }
15899
15900    /// [`set_parameter_float_range_on`](Self::set_parameter_float_range_on) for
15901    /// the primary node.
15902    #[deprecated(
15903        since = "0.6.0",
15904        note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter_float_range(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15905    )]
15906    pub fn set_parameter_float_range(&mut self, name: &str, min: f64, max: f64, step: f64) -> bool {
15907        self.set_parameter_float_range_on(super::node_record::NodeId::PRIMARY, name, min, max, step)
15908    }
15909
15910    /// The descriptor of a declared parameter — rclcpp's
15911    /// `describe_parameter`, rclrs's `ParameterBuilder` readback. `None` when
15912    /// the parameter is not declared on `node`, or was declared without one.
15913    pub(crate) fn describe_parameter_on(
15914        &self,
15915        node: super::node_record::NodeId,
15916        name: &str,
15917    ) -> Option<&nros_params::ParameterDescriptor> {
15918        self.params
15919            .as_ref()?
15920            .server
15921            .get_descriptor(node.into(), name)
15922    }
15923
15924    /// [`describe_parameter_on`](Self::describe_parameter_on) for the primary
15925    /// node.
15926    #[deprecated(
15927        since = "0.6.0",
15928        note = "the node is the parameter receiver (phase-515 R10): `node.describe_parameter(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
15929    )]
15930    pub fn describe_parameter(&self, name: &str) -> Option<&nros_params::ParameterDescriptor> {
15931        self.describe_parameter_on(super::node_record::NodeId::PRIMARY, name)
15932    }
15933
15934    /// The declared TYPE of a parameter — rclcpp's `get_parameter_types`, and
15935    /// the local answer to the `~/get_parameter_types` service.
15936    pub(crate) fn get_parameter_type_on(
15937        &self,
15938        node: super::node_record::NodeId,
15939        name: &str,
15940    ) -> Option<nros_params::ParameterType> {
15941        self.params.as_deref()?.server.get_type(node.into(), name)
15942    }
15943
15944    /// [`get_parameter_type_on`](Self::get_parameter_type_on) for the primary
15945    /// node.
15946    #[deprecated(
15947        since = "0.6.0",
15948        note = "the node is the parameter receiver (phase-515 R10): `node.get_parameter_type(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
15949    )]
15950    pub fn get_parameter_type(&self, name: &str) -> Option<nros_params::ParameterType> {
15951        self.get_parameter_type_on(super::node_record::NodeId::PRIMARY, name)
15952    }
15953
15954    /// The names a node has declared, filtered by prefix — rclcpp's
15955    /// `list_parameters`, and the local answer to `~/list_parameters`. An
15956    /// empty prefix lists them all.
15957    pub(crate) fn list_parameters_on<'p>(
15958        &'p self,
15959        node: super::node_record::NodeId,
15960        prefix: &'p str,
15961    ) -> impl Iterator<Item = &'p str> {
15962        self.params
15963            .as_ref()
15964            .into_iter()
15965            .flat_map(move |p| p.server.list_with_prefix(node.into(), prefix))
15966    }
15967
15968    /// [`list_parameters_on`](Self::list_parameters_on) for the primary node.
15969    #[deprecated(
15970        since = "0.6.0",
15971        note = "the node is the parameter receiver (phase-515 R10): `node.list_parameters(prefix)` on the `Node` that `create_node` / `node_mut(id)` returns"
15972    )]
15973    pub fn list_parameters<'p>(&'p self, prefix: &'p str) -> impl Iterator<Item = &'p str> {
15974        self.list_parameters_on(super::node_record::NodeId::PRIMARY, prefix)
15975    }
15976
15977    /// All-or-nothing multi-set on `node` — rclcpp's
15978    /// `set_parameters_atomically`.
15979    ///
15980    /// We SERVED `~/set_parameters_atomically` and offered it in no language,
15981    /// so the only way to reach our own atomic set was over the wire. This is
15982    /// the same call the service handler makes.
15983    ///
15984    /// `items` yields `None` for an element that could not be turned into a
15985    /// value at all; that refuses the batch like any failing check.
15986    pub(crate) fn set_parameters_atomically_on<'x, I>(
15987        &mut self,
15988        node: super::node_record::NodeId,
15989        items: I,
15990    ) -> Result<(), nros_params::ParameterError>
15991    where
15992        I: Iterator<Item = Option<(&'x str, nros_params::ParameterValue)>> + Clone,
15993    {
15994        match &mut self.params {
15995            Some(params) => params.server.apply_atomically(node.into(), items),
15996            None => Err(nros_params::ParameterError::NotFound),
15997        }
15998    }
15999
16000    /// [`set_parameters_atomically_on`](Self::set_parameters_atomically_on),
16001    /// with element `i` FILLED into one scratch value by `fill(i, slot)`
16002    /// (`ParameterServer::apply_atomically_with`, issue 2104). For a caller
16003    /// that would otherwise build each ~8 KiB value on its own stack.
16004    pub(crate) fn set_parameters_atomically_with_on<'x>(
16005        &mut self,
16006        node: super::node_record::NodeId,
16007        count: usize,
16008        fill: impl FnMut(usize, &mut nros_params::ParameterValue) -> Option<&'x str>,
16009    ) -> Result<(), nros_params::ParameterError> {
16010        match &mut self.params {
16011            Some(params) => params
16012                .server
16013                .apply_atomically_with(node.into(), count, fill),
16014            None => Err(nros_params::ParameterError::NotFound),
16015        }
16016    }
16017
16018    /// [`set_parameters_atomically_on`](Self::set_parameters_atomically_on) for
16019    /// the primary node.
16020    #[deprecated(
16021        since = "0.6.0",
16022        note = "the node is the parameter receiver (phase-515 R10): `node.set_parameters_atomically(items)` on the `Node` that `create_node` / `node_mut(id)` returns"
16023    )]
16024    pub fn set_parameters_atomically<'x, I>(
16025        &mut self,
16026        items: I,
16027    ) -> Result<(), nros_params::ParameterError>
16028    where
16029        I: Iterator<Item = Option<(&'x str, nros_params::ParameterValue)>> + Clone,
16030    {
16031        self.set_parameters_atomically_on(super::node_record::NodeId::PRIMARY, items)
16032    }
16033
16034    /// Undeclare a parameter — rclcpp's `undeclare_parameter`, rclc's
16035    /// `rclc_delete_parameter`. The slot is freed for a later declaration.
16036    pub(crate) fn undeclare_parameter_on(
16037        &mut self,
16038        node: super::node_record::NodeId,
16039        name: &str,
16040    ) -> bool {
16041        match &mut self.params {
16042            Some(params) => params.server.remove(node.into(), name),
16043            None => false,
16044        }
16045    }
16046
16047    /// [`undeclare_parameter_on`](Self::undeclare_parameter_on) for the primary
16048    /// node.
16049    #[deprecated(
16050        since = "0.6.0",
16051        note = "the node is the parameter receiver (phase-515 R10): `node.undeclare_parameter(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
16052    )]
16053    pub fn undeclare_parameter(&mut self, name: &str) -> bool {
16054        self.undeclare_parameter_on(super::node_record::NodeId::PRIMARY, name)
16055    }
16056
16057    /// Register an accept/reject hook for writes to `node` — rclcpp's
16058    /// `add_on_set_parameters_callback`.
16059    ///
16060    /// It runs on every write that reaches the store through
16061    /// [`set_parameter_on`](Self::set_parameter_on), which is the seam the six
16062    /// `rcl_interfaces/srv/*` servers and both wrapper languages share, so ONE
16063    /// registration covers a remote `ros2 param set` and a local one alike.
16064    ///
16065    /// `None` when all [`nros_params::MAX_ON_SET_CALLBACKS`] slots are taken.
16066    pub(crate) fn add_on_set_parameters_callback_on(
16067        &mut self,
16068        node: super::node_record::NodeId,
16069        callback: nros_params::OnSetParameterFn,
16070        ctx: nros_params::OnSetContext,
16071    ) -> Option<nros_params::OnSetParameterHandle> {
16072        self.ensure_parameter_store();
16073        self.params
16074            .as_mut()?
16075            .server
16076            .add_on_set_parameters_callback(node.into(), callback, ctx)
16077    }
16078
16079    /// [`add_on_set_parameters_callback_on`](Self::add_on_set_parameters_callback_on)
16080    /// for the primary node.
16081    #[deprecated(
16082        since = "0.6.0",
16083        note = "the node is the parameter receiver (phase-515 R10): `node.add_on_set_parameters_callback(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
16084    )]
16085    pub fn add_on_set_parameters_callback(
16086        &mut self,
16087        callback: nros_params::OnSetParameterFn,
16088        ctx: nros_params::OnSetContext,
16089    ) -> Option<nros_params::OnSetParameterHandle> {
16090        self.add_on_set_parameters_callback_on(super::node_record::NodeId::PRIMARY, callback, ctx)
16091    }
16092
16093    /// Unregister a hook — rclcpp's `remove_on_set_parameters_callback`.
16094    pub fn remove_on_set_parameters_callback(
16095        &mut self,
16096        handle: nros_params::OnSetParameterHandle,
16097    ) -> bool {
16098        match &mut self.params {
16099            Some(params) => params.server.remove_on_set_parameters_callback(handle),
16100            None => false,
16101        }
16102    }
16103
16104    /// rclrs's `Node::use_undeclared_parameters` — a handle that reads and
16105    /// writes parameters this node never declared.
16106    ///
16107    /// The handle writes through `set_or_declare`, not through the wire-facing
16108    /// `apply`: `allow_undeclared_parameters` governs what a REMOTE set may
16109    /// create, and a caller who reached for this type has opted in by naming
16110    /// it. That is rclrs's split too.
16111    pub(crate) fn use_undeclared_parameters_on(
16112        &mut self,
16113        node: super::node_record::NodeId,
16114    ) -> Result<nros_params::Parameters<'_, 's>, NodeError> {
16115        self.ensure_parameter_store(); // phase-515 R10, as `parameter_on`
16116        let server = self
16117            .params
16118            .as_mut()
16119            .map(|p| &mut p.server)
16120            .ok_or(NodeError::NotInitialized)?;
16121        Ok(nros_params::Parameters::new(server, node.into()))
16122    }
16123
16124    /// [`use_undeclared_parameters_on`](Self::use_undeclared_parameters_on) for
16125    /// the primary node.
16126    #[deprecated(
16127        since = "0.6.0",
16128        note = "the node is the parameter receiver (phase-515 R10): `node.use_undeclared_parameters()` on the `Node` that `create_node` / `node_mut(id)` returns"
16129    )]
16130    pub fn use_undeclared_parameters(
16131        &mut self,
16132    ) -> Result<nros_params::Parameters<'_, 's>, NodeError> {
16133        self.use_undeclared_parameters_on(super::node_record::NodeId::PRIMARY)
16134    }
16135}
16136
16137// ============================================================================
16138// Executor lifecycle — cancel / is_spinning (phase-417 W4.c)
16139// ============================================================================
16140
16141// Issue 1177 -- UN-GATED. This block was `#[cfg(feature = "alloc")]` because
16142// `halt_flag` and `spinning` were `alloc` fields, and neither needed to be: the
16143// allocator is required to SHARE the halt flag (`halt_flag()` hands out a
16144// refcounted handle), never to hold it.
16145//
16146// Gating it meant a no_std executor could not be stopped, and worse, that
16147// `nros-c`'s spin loop -- which calls `cancel`, `is_halted`, `is_spinning`,
16148// `enter_spin_loop` and `exit_spin_loop` with no cfg of its own -- did not
16149// COMPILE for an embedded image. The safety island found that; no
16150// merge-gating lane builds this crate without `alloc`.
16151impl<'s> Executor<'s> {
16152    /// Request the executor to stop spinning — `rclcpp::Executor::cancel`.
16153    ///
16154    /// # ADOPT-BOUNDED (RFC-0089)
16155    ///
16156    /// `cancel` sets a flag the spin loop observes at the NEXT POLL BOUNDARY,
16157    /// so it returns BEFORE spinning has actually stopped;
16158    /// [`is_spinning()`](Self::is_spinning) is the observable that tells you
16159    /// when it has. rclcpp makes the same promise in the same shape, but the
16160    /// boundary here is one `spin_once` timeout wide (a backend's `drive_io`
16161    /// blocks up to the poll interval), so "next boundary" is a duration a
16162    /// caller can measure rather than an implementation detail. The wake flag
16163    /// below shortens it; it does not remove it.
16164    ///
16165    /// Nothing here tears down the session: an executor that has been cancelled
16166    /// is still initialised, still owns its entities, and can be spun again.
16167    /// Teardown is a different verb (`fini` / `shutdown`), and collapsing the
16168    /// two costs a full discovery round on every mode change.
16169    ///
16170    /// Safe to call from another thread or a signal handler.
16171    ///
16172    /// Also raises the Phase 104.C.6 wake flag so a `spin_once` already blocked
16173    /// inside a backend's `drive_io` falls through to the cancel check on its
16174    /// next loop iteration instead of waiting out its full `timeout_ms` first.
16175    pub fn cancel(&self) {
16176        self.halt_flag
16177            .store(true, core::sync::atomic::Ordering::SeqCst);
16178        // The wake flag is the `Arc` half and still needs an allocator. A
16179        // core-only image simply waits out the poll interval, which is the
16180        // bounded behaviour the envelope above already promises.
16181        #[cfg(feature = "alloc")]
16182        self.wake_flag
16183            .store(true, core::sync::atomic::Ordering::SeqCst);
16184        // phase-521 W6 (RFC-0113 D2) — and end a PARK, not only a backend
16185        // wait. An entry parks until its next deadline rather than for a
16186        // fixed slice, so a cancel that only raised the flag would wait out
16187        // that whole park. `nros_platform_wake_signal` is the port's ISR- and
16188        // signal-safe post, the one an arrival uses.
16189        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
16190        if let Some(wake) = self.node_wake.as_ref() {
16191            wake.signal();
16192        }
16193    }
16194
16195    /// phase-521 W6 (RFC-0113 D2) — wake every worker parked over this
16196    /// executor: its own and every level's band worker, as an arrival nothing
16197    /// attributes does. A level parks until its band's next deadline or an
16198    /// arrival, not for a fixed period, so a runner that stops its levels must
16199    /// end those parks or wait them out. Lock-free; safe from any thread.
16200    /// Raises no halt: a woken worker runs one pass and parks again unless its
16201    /// runner's own stop flag says otherwise.
16202    pub fn wake_all_workers(&self) {
16203        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
16204        if let Some(ctx) = self.wake_ctx.as_ref() {
16205            ctx.signal();
16206        }
16207    }
16208
16209    /// Has a [`cancel()`](Self::cancel) been REQUESTED?
16210    ///
16211    /// Not the same question as [`is_spinning()`](Self::is_spinning), and both
16212    /// are kept because both have callers:
16213    ///
16214    /// * `is_halted()` — "someone asked this executor to stop." True the instant
16215    ///   `cancel()` returns, and it stays true until the next spin loop clears
16216    ///   it on entry. This is the flag a hand-rolled `while !exec.is_halted()`
16217    ///   loop polls, which is what a BSP that drives `spin_once` itself writes.
16218    /// * `is_spinning()` — "a spin loop is running right now." Answers the other
16219    ///   half of the envelope: whether the cancel has been ACTED on yet.
16220    ///
16221    /// The two are true together for as long as one poll takes, which is exactly
16222    /// the window the ADOPT-BOUNDED envelope on `cancel()` describes. Neither is
16223    /// derivable from the other: an executor that was never spun is neither
16224    /// halted nor spinning, and one cancelled mid-poll is both.
16225    ///
16226    /// The name is the pre-rename spelling and is deliberately NOT deprecated —
16227    /// `cancel`'s rclcpp pair is a `spinning` observable, not an
16228    /// `is_cancel_requested` one, so there is no ROS 2 name to adopt here and a
16229    /// third spelling would buy nothing.
16230    pub fn is_halted(&self) -> bool {
16231        self.halt_flag.load(core::sync::atomic::Ordering::SeqCst)
16232    }
16233
16234    /// Is a spin loop running on this executor right now?
16235    ///
16236    /// rclcpp spells this `Executor::is_spinning()`. True between the entry and
16237    /// the exit of [`spin`](Self::spin),
16238    /// [`spin_period`](Self::spin_period), the `open_threaded` worker loop, and
16239    /// the C / C++ API loops — every construct that owns its own iteration.
16240    ///
16241    /// A bare `spin_once` does NOT set it: one poll is not a spin, and a caller
16242    /// running its own `while` around `spin_once` is the one who knows it is
16243    /// looping. Such a caller can say so with
16244    /// [`enter_spin_loop`](crate::executor::ffi::ExecutorFfi::enter_spin_loop).
16245    ///
16246    /// See [`is_halted()`](Self::is_halted) for why this is a different question
16247    /// from "was cancel requested".
16248    pub fn is_spinning(&self) -> bool {
16249        self.spinning.load(core::sync::atomic::Ordering::SeqCst)
16250    }
16251
16252    /// Declare that the caller is entering a spin loop it drives itself.
16253    ///
16254    /// The seam the C and C++ APIs use so their loops answer
16255    /// [`is_spinning()`](Self::is_spinning) without keeping a flag of their own
16256    /// (RFC-0019: ergonomics may live in the wrapper, state may not). Also
16257    /// CLEARS any pending cancel, matching what `spin` does on entry:
16258    /// a cancel requested before a spin started is not a cancel of THIS spin.
16259    ///
16260    /// Pair it with [`exit_spin_loop`](Self::exit_spin_loop) on EVERY exit path,
16261    /// `break` and early `return` alike — a loop that leaves without it reports
16262    /// `is_spinning() == true` forever. It is a method pair rather than an RAII
16263    /// guard on purpose: a guard would have to borrow the executor for the whole
16264    /// loop, and every caller needs `&mut` inside the body to poll.
16265    ///
16266    /// Takes `&self` (the flags are atomics), so the borrow ends at the call.
16267    pub(crate) fn enter_spin_loop(&self) {
16268        self.halt_flag
16269            .store(false, core::sync::atomic::Ordering::SeqCst);
16270        self.spinning
16271            .store(true, core::sync::atomic::Ordering::SeqCst);
16272    }
16273
16274    /// End the scope opened by [`enter_spin_loop`](Self::enter_spin_loop).
16275    ///
16276    /// Idempotent. Leaves the cancel flag alone: "someone asked me to stop" is
16277    /// the caller's answer to keep or clear, and clearing it here would lose the
16278    /// reason the loop exited.
16279    pub(crate) fn exit_spin_loop(&self) {
16280        self.spinning
16281            .store(false, core::sync::atomic::Ordering::SeqCst);
16282    }
16283}
16284
16285// ============================================================================
16286// std-gated spin and halt methods
16287// ============================================================================
16288
16289// phase-359 W10 — this impl used to be ONE `#[cfg(feature = "std")]` block.
16290// Only the wall-clock spin loops in it need `std` (`Instant`, `thread::sleep`,
16291// `thread::spawn`); `halt` / `is_halted` / `wake` are plain atomic stores on
16292// flags the struct already owns, and `halt_flag` / `wake_handle` only need
16293// `Arc`. Gating the BLOCK rather than the items made a no_std image unable to
16294// stop its own executor — `ExecutorNodeRuntime::spin` carried a matching `std`
16295// gate for no reason but this one.
16296// phase-359 W10 — `alloc`, not `std`. The wall-clock spin loops were the
16297// reason this block was std-gated: `Instant` for the deadline and
16298// `thread::sleep` for the pacing. Both now go through the platform — the
16299// executor's own `now_us()` and `nros_platform_sleep_us` — so a no_std
16300// image can run the same blocking loops a hosted one does instead of
16301// hand-rolling them in its BSP.
16302#[cfg(feature = "alloc")]
16303impl<'s> Executor<'s> {
16304    /// Blocking spin loop with configurable exit conditions — rclrs's
16305    /// `Executor::spin(SpinOptions)`.
16306    ///
16307    /// ADOPT-BOUNDED (RFC-0089). Same name, same argument, same meaning: run
16308    /// until an exit condition in `opts` says stop. Two bounds, both visible in
16309    /// the signature:
16310    ///
16311    /// * **The error channel is one error, not all of them.** rclrs returns
16312    ///   `Vec<RclrsError>` and the caller writes `.first_error()?`; there is no
16313    ///   allocator here, so ours returns `Result<(), NodeError>` carrying the
16314    ///   FIRST failing callback's error and drops the rest (they are still
16315    ///   counted in each pass's [`SpinOnceResult`]). Ported line for line, the
16316    ///   edit is `.first_error()?` -> `?`, which the compiler points at.
16317    /// * **The loop is a poll loop**, so `opts.poll_interval` is the quantum at
16318    ///   which every exit condition is observed — a `cancel()` from another
16319    ///   task, the `timeout` expiring, `max_callbacks` being reached. Requesting
16320    ///   a `timeout` on a build with no clock is an ERROR, not a silent
16321    ///   never-ending loop (issue 0709).
16322    ///
16323    /// phase-427 — this method was ~~`spin_blocking`~~. `spin` was taken by the
16324    /// `-> !` task body, which is now [`spin_forever`](Self::spin_forever).
16325    ///
16326    /// Runs until one of:
16327    /// - [`cancel()`](Self::cancel) is called (from
16328    ///   another thread or signal handler)
16329    /// - `opts.timeout` expires (if set)
16330    /// - `opts.max_callbacks` is reached (if set)
16331    /// - `opts.only_next` is true (single iteration)
16332    /// - a callback failed and `opts.stop_on_first_error` is set
16333    ///
16334    /// # Example
16335    ///
16336    /// ```ignore
16337    /// // Spin until cancelled
16338    /// executor.spin(SpinOptions::default())?;
16339    ///
16340    /// // Spin with 5-second timeout
16341    /// executor.spin(SpinOptions::new().timeout(core::time::Duration::from_secs(5)))?;
16342    ///
16343    /// // Single iteration
16344    /// executor.spin(SpinOptions::spin_once())?;
16345    /// ```
16346    pub fn spin(&mut self, opts: SpinOptions) -> Result<(), NodeError> {
16347        // phase-427 — was a private ~~`POLL_INTERVAL`~~ const; it is
16348        // `SpinOptions::poll_interval` now, defaulting to the same 10 ms, so
16349        // `spin` and `spin_forever` read one value instead of two literals.
16350        let poll_interval = opts.poll_interval;
16351
16352        // phase-359 W10 — the executor's own clock. `now_us()` reads the
16353        // PLATFORM's monotonic counter, and nothing else: the platform API is
16354        // the clock, so a build with a port has one and a build without has
16355        // none. There is no `std::time::Instant` third answer any more, which
16356        // is what used to make "what time is it" depend on whether some crate
16357        // in the graph happened to name `std`.
16358        //
16359        // `None` means this build has NO clock at all — and issue 0709 is what
16360        // the previous answer here cost. It read: "a timeout cannot be honoured
16361        // then, and pretending otherwise would exit immediately or never; the
16362        // honest reading is no deadline". It then picked NEVER. A caller that
16363        // asked for 50 ms got an infinite loop, silently, in the one API whose
16364        // whole contract is "returns after N ms" — ten hours of a CI lane
16365        // before anyone read it as stuck rather than slow.
16366        //
16367        // Neither of the two silent readings is honest. An unmet precondition
16368        // is an ERROR (repo rule, fail-loud): the caller supplied a time
16369        // quantity this build cannot measure, and only they can decide what to
16370        // do about it. An UNTIMED `spin` still runs until halt, which
16371        // is a promise this build can keep.
16372        let start_us = self.now_us();
16373        if opts.timeout.is_some() && start_us.is_none() {
16374            nros_log::log_error!(
16375                nros_log::get_logger("nros"),
16376                "spin: a timeout was requested but this build has no clock \
16377                 — install one with `ExecutorConfig::clock_us` (issue 0709)"
16378            );
16379            return Err(NodeError::NotInitialized);
16380        }
16381        // `as_micros()` rather than a hand-rolled `ms * 1_000`: the multiply
16382        // was unchecked, and the unit conversion is `Duration`'s job.
16383        let timeout_us = opts.timeout.map(|d| d.as_micros() as u64);
16384        let mut total_callbacks = 0usize;
16385
16386        // phase-417 W4.c — clears the cancel flag AND raises `is_spinning`. The
16387        // clear is the pre-existing behaviour (a cancel requested before this
16388        // spin is not a cancel of this spin); the raise is what lets a peer
16389        // thread see that its `cancel()` has not been acted on yet. Every exit
16390        // below is a `break` to the single tail, which is where the scope ends.
16391        self.enter_spin_loop();
16392
16393        loop {
16394            if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
16395                break;
16396            }
16397
16398            if let (Some(start), Some(limit)) = (start_us, timeout_us)
16399                && self
16400                    .now_us()
16401                    .is_some_and(|now| now.saturating_sub(start) >= limit)
16402            {
16403                break;
16404            }
16405
16406            let mut callback_error = None;
16407            let result = self.spin_once_capturing(poll_interval, &mut callback_error);
16408            total_callbacks += result.total();
16409
16410            // phase-427 — the first error, and only the first: rclrs's
16411            // `Vec<RclrsError>` needs an allocator we do not have. The scope is
16412            // closed before returning so a caller that reads `is_spinning()`
16413            // after an error sees `false`, exactly as after a clean exit.
16414            if opts.stop_on_first_error
16415                && let Some(e) = callback_error
16416            {
16417                self.exit_spin_loop();
16418                return Err(NodeError::Transport(e));
16419            }
16420
16421            if opts.max_callbacks.is_some_and(|max| total_callbacks >= max) {
16422                break;
16423            }
16424
16425            if opts.only_next {
16426                break;
16427            }
16428        }
16429
16430        self.exit_spin_loop();
16431        Ok(())
16432    }
16433
16434    /// Execute one period with wall-clock overrun detection.
16435    ///
16436    /// Calls [`spin_once()`](Self::spin_once), measures wall-clock time, sleeps
16437    /// for the remainder if under budget.
16438    ///
16439    /// # Example
16440    ///
16441    /// ```ignore
16442    /// let period = core::time::Duration::from_millis(10);
16443    /// let result = executor.spin_one_period_timed(period);
16444    /// if result.overrun {
16445    ///     log::warn!("Period overrun: {:?}", result.elapsed);
16446    /// }
16447    /// ```
16448    pub fn spin_one_period_timed(
16449        &mut self,
16450        period: core::time::Duration,
16451    ) -> super::types::SpinPeriodResult {
16452        let start_us = self.now_us();
16453        let result = self.spin_once(period);
16454        // With no clock there is nothing to measure and nothing to sleep off;
16455        // report zero elapsed and no overrun, which is what an unmeasured
16456        // period is.
16457        let elapsed = start_us
16458            .zip(self.now_us())
16459            .map(|(s, e)| core::time::Duration::from_micros(e.saturating_sub(s)))
16460            .unwrap_or_default();
16461        let overrun = elapsed > period;
16462        if !overrun && start_us.is_some() {
16463            platform_sleep(period - elapsed);
16464        }
16465        super::types::SpinPeriodResult {
16466            work: result,
16467            overrun,
16468            elapsed,
16469        }
16470    }
16471
16472    /// Spin at a fixed rate with drift compensation. Blocks until halted.
16473    ///
16474    /// Uses wall-clock time to maintain the target rate. The next invocation
16475    /// time is accumulated (not reset to `now + period`) to prevent cumulative
16476    /// drift.
16477    ///
16478    /// # Example
16479    ///
16480    /// ```ignore
16481    /// // 100Hz control loop — blocks until halt() is called
16482    /// executor.spin_period(core::time::Duration::from_millis(10))?;
16483    /// ```
16484    pub fn spin_period(&mut self, period: core::time::Duration) -> Result<(), NodeError> {
16485        let period_us = period.as_micros().min(u64::MAX as u128) as u64;
16486        // Absolute next-deadline in the executor's own clock. issue 0709 — the
16487        // sibling of `spin`'s guard above: with no clock there is no
16488        // pacing, and the loop would run as fast as `spin_once` returns while
16489        // the caller believes it is running at `period`. A requested period
16490        // this build cannot honour is a configuration error, not a silent
16491        // busy-loop.
16492        let Some(start_us) = self.now_us() else {
16493            nros_log::log_error!(
16494                nros_log::get_logger("nros"),
16495                "spin_period: a period was requested but this build has no clock \
16496                 — install one with `ExecutorConfig::clock_us` (issue 0709)"
16497            );
16498            return Err(NodeError::NotInitialized);
16499        };
16500        // phase-417 W4.c — after the clock guard, not before it: a call that
16501        // returns an error never spun, and must not report that it did. (This
16502        // also moved the cancel-flag clear past the guard, which is the same
16503        // correction.)
16504        self.enter_spin_loop();
16505        let mut next_us = Some(start_us + period_us);
16506
16507        loop {
16508            if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
16509                break;
16510            }
16511
16512            self.spin_once(period);
16513
16514            if let Some(next) = next_us {
16515                // Jitter is recorded in `spin_once` above, which this loop
16516                // calls every iteration -- counting it here as well would
16517                // double every wake on this one driver.
16518                if let Some(now) = self.now_us()
16519                    && now < next
16520                {
16521                    platform_sleep(core::time::Duration::from_micros(next - now));
16522                }
16523                // Accumulate to prevent drift (not = now + period)
16524                next_us = Some(next + period_us);
16525            }
16526        }
16527        self.exit_spin_loop();
16528        Ok(())
16529    }
16530}
16531
16532#[cfg(feature = "alloc")]
16533impl<'s> Executor<'s> {
16534    /// Phase 110.D.b — move this Executor onto a fresh OS thread,
16535    /// apply a per-thread scheduling policy via the caller-supplied
16536    /// `apply_policy` function, and run the spin loop until
16537    /// `ThreadHandle::halt` fires.
16538    ///
16539    /// The function-pointer indirection on `apply_policy` lets the
16540    /// caller pass any platform's `PlatformScheduler::set_current_thread_policy`
16541    /// without forcing `Executor` to be generic over the platform —
16542    /// keeps the existing `Executor` type stable.
16543    ///
16544    /// Multi-executor preemption (the actual hard-RT win) comes from
16545    /// the OS scheduler — call `open_threaded` once per criticality
16546    /// tier, each with its own policy / priority. The kernel handles
16547    /// preemption across executors; within a single executor,
16548    /// dispatch remains non-preemptive (110.A–C bucketed sets).
16549    ///
16550    /// # Safety
16551    ///
16552    /// Moves `self` across thread boundaries. `Executor` contains a
16553    /// raw `*mut session::ConcreteSession` when constructed via
16554    /// `from_session_ptr`; the caller must ensure that pointer's
16555    /// referent stays valid across the lifetime of the spawned thread
16556    /// and that no other thread mutates the session concurrently.
16557    /// `from_session` (Owned) is safer — `ConcreteSession` ownership
16558    /// transfers cleanly into the thread.
16559    #[cfg(feature = "alloc")]
16560    pub unsafe fn open_threaded(
16561        self,
16562        policy: nros_platform_api::SchedPolicy,
16563        apply_policy: fn(
16564            nros_platform_api::SchedPolicy,
16565        ) -> Result<(), nros_platform_api::SchedError>,
16566        spin_period: core::time::Duration,
16567    ) -> ThreadHandle
16568    where
16569        // phase-271 — the spawned thread owns `self` for an unbounded lifetime,
16570        // so its borrowed storage must be `'static` (a leaked/`static` backing —
16571        // the `alloc` convenience constructors or a program-lifetime region).
16572        's: 'static,
16573    {
16574        let halt = self.halt_flag.clone();
16575        // phase-359 W10 — a platform task, not `std::thread`. The two other
16576        // executor-owned workers moved earlier; this is the third and last, and
16577        // it is the one the ABI fits best: `open_threaded` exists to give a
16578        // spin loop an OS SCHEDULING POLICY, which is not something a build
16579        // without an OS was ever going to get from `std`.
16580        //
16581        // The closure becomes a heap context because the entry point crosses C
16582        // as `*mut c_void`. It is reclaimed by the trampoline on exit rather
16583        // than leaked: unlike a tier task, this loop RETURNS — that is what
16584        // `halt` is for.
16585        let ctx = alloc::boxed::Box::into_raw(alloc::boxed::Box::new(ThreadedCtx {
16586            executor: self,
16587            policy,
16588            apply_policy,
16589            spin_period,
16590        }));
16591        // SAFETY: `ctx` stays live until the trampoline reclaims it, which
16592        // happens on the spawned task and only after the loop exits.
16593        let task = unsafe {
16594            nros_platform_api::task::PlatformTask::spawn(
16595                threaded_spin_trampoline,
16596                ctx as *mut core::ffi::c_void,
16597                OPEN_THREADED_STACK_BYTES,
16598                c"nros-exec".as_ptr(),
16599            )
16600        };
16601        let Some(task) = task else {
16602            // The platform refused (or cannot size) a task. Reclaim the
16603            // context and hand back a handle that owns nothing — `halt` and
16604            // `join` stay callable, which keeps the caller's shape identical to
16605            // the success path.
16606            //
16607            // SAFETY: nothing was spawned, so this pointer has no other owner.
16608            drop(unsafe { alloc::boxed::Box::from_raw(ctx) });
16609            return ThreadHandle { task: None, halt };
16610        };
16611        ThreadHandle {
16612            task: Some(task),
16613            halt,
16614        }
16615    }
16616
16617    /// Phase 104.C.6 — wake the executor from another thread / ISR /
16618    /// signal handler.
16619    ///
16620    /// Sets the shared `wake_flag`. The next `spin_once` swap-clears the
16621    /// flag, skips the blocking wait on the primary session, and polls
16622    /// every session non-blockingly so whatever queued the wake is
16623    /// observed in a single iteration. Idempotent — multiple `wake()`
16624    /// calls collapse into one observed wake per `spin_once`.
16625    pub fn wake(&self) {
16626        self.wake_flag
16627            .store(true, core::sync::atomic::Ordering::SeqCst);
16628    }
16629}
16630
16631#[cfg(feature = "alloc")]
16632impl<'s> Executor<'s> {
16633    /// Get a clone of the halt flag for use in signal handlers or other threads.
16634    ///
16635    /// # Example
16636    ///
16637    /// ```ignore
16638    /// let halt = executor.halt_flag();
16639    /// std::thread::spawn(move || {
16640    ///     std::thread::sleep(Duration::from_secs(5));
16641    ///     halt.store(true, Ordering::SeqCst);
16642    /// });
16643    /// executor.spin(SpinOptions::default())?;
16644    /// ```
16645    pub fn halt_flag(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
16646        self.halt_flag.clone()
16647    }
16648
16649    /// Phase 104.C.6 — clone of the shared wake flag for cross-thread
16650    /// use (signal handlers, foreign threads, future per-backend vtable
16651    /// wake hooks).
16652    ///
16653    /// # Example
16654    ///
16655    /// ```ignore
16656    /// let wake = executor.wake_handle();
16657    /// std::thread::spawn(move || {
16658    ///     // ... compute something ...
16659    ///     // hand off to executor by setting the flag.
16660    ///     wake.store(true, Ordering::SeqCst);
16661    /// });
16662    /// loop { executor.spin_once(Duration::from_millis(100)); }
16663    /// ```
16664    pub fn wake_handle(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
16665        self.wake_flag.clone()
16666    }
16667}
16668
16669/// Phase 110.E.b — opaque per-platform timer handle. Stores the
16670/// raw platform handle (a boxed POSIX `timer_t`, FreeRTOS `TimerHandle_t`,
16671/// etc.) plus a destroy thunk so the
16672/// Executor can clean up without being generic over the platform.
16673///
16674/// Caller of `register_sporadic_timer` builds this via
16675/// `OpaqueTimerHandle::new(handle, destroy_fn)` after their
16676/// `PlatformTimer::create_periodic` call returns.
16677#[cfg(feature = "alloc")]
16678pub struct OpaqueTimerHandle {
16679    handle: *mut core::ffi::c_void,
16680    destroy_fn: extern "C" fn(*mut core::ffi::c_void),
16681}
16682
16683#[cfg(feature = "alloc")]
16684unsafe impl Send for OpaqueTimerHandle {}
16685#[cfg(feature = "alloc")]
16686unsafe impl Sync for OpaqueTimerHandle {}
16687
16688#[cfg(feature = "alloc")]
16689impl OpaqueTimerHandle {
16690    /// # Safety
16691    /// `handle` must be a live platform-specific timer handle that
16692    /// `destroy_fn` knows how to drop. Caller surrenders ownership
16693    /// of the underlying handle to the Executor.
16694    pub unsafe fn new(
16695        handle: *mut core::ffi::c_void,
16696        destroy_fn: extern "C" fn(*mut core::ffi::c_void),
16697    ) -> Self {
16698        Self { handle, destroy_fn }
16699    }
16700}
16701
16702#[cfg(feature = "alloc")]
16703impl Drop for OpaqueTimerHandle {
16704    fn drop(&mut self) {
16705        if !self.handle.is_null() {
16706            (self.destroy_fn)(self.handle);
16707            self.handle = core::ptr::null_mut();
16708        }
16709    }
16710}
16711
16712/// Handle returned from [`Executor::open_threaded`]. Holds the
16713/// spawned thread's join handle and a clone of the executor's halt
16714/// flag. Drop runs `halt() + join()` so the thread can't outlive the
16715/// handle.
16716#[cfg(feature = "alloc")]
16717pub struct ThreadHandle {
16718    task: Option<nros_platform_api::task::PlatformTask>,
16719    halt: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
16720}
16721
16722/// Default stack for an `open_threaded` spin loop. `std::thread`'s default was
16723/// 2 MiB; the executor's own storage is caller-carved and lives elsewhere, so
16724/// this carries call frames only — the same reasoning (and size) as the NuttX
16725/// tier spawn, which measured this against a real RTOS default.
16726#[cfg(feature = "alloc")]
16727const OPEN_THREADED_STACK_BYTES: usize = 65536;
16728
16729/// What [`Executor::open_threaded`] hands its task, in place of a closure's
16730/// captures. Owned by the trampoline, which reclaims it when the loop exits.
16731#[cfg(feature = "alloc")]
16732struct ThreadedCtx {
16733    executor: Executor<'static>,
16734    policy: nros_platform_api::SchedPolicy,
16735    apply_policy: fn(nros_platform_api::SchedPolicy) -> Result<(), nros_platform_api::SchedError>,
16736    spin_period: core::time::Duration,
16737}
16738
16739/// The spawned spin loop.
16740///
16741/// # Safety
16742/// `arg` must be the `Box<ThreadedCtx>` raw pointer `open_threaded` created,
16743/// passed exactly once.
16744#[cfg(feature = "alloc")]
16745unsafe extern "C" fn threaded_spin_trampoline(
16746    arg: *mut core::ffi::c_void,
16747) -> *mut core::ffi::c_void {
16748    // SAFETY: the caller's contract — this is the pointer `open_threaded`
16749    // leaked, and the task is the only consumer of it.
16750    let mut ctx = unsafe { alloc::boxed::Box::from_raw(arg as *mut ThreadedCtx) };
16751    // Apply the requested OS scheduling policy to this fresh task. Failure is
16752    // reported but not propagated — a runtime that fails to lift to SCHED_FIFO
16753    // still spins correctly at SCHED_OTHER (just without RT guarantees).
16754    let _ = (ctx.apply_policy)(ctx.policy);
16755    // phase-417 W4.c — this loop owns its own iteration, so it is a spin and
16756    // must say so. NOT `enter_spin_loop()`: that clears the cancel flag, and
16757    // `open_threaded` hands the caller a `ThreadHandle` whose `halt()` may
16758    // already have fired before this task was scheduled. Raising `is_spinning`
16759    // without touching the cancel bit is the honest report here.
16760    ctx.executor
16761        .spinning
16762        .store(true, core::sync::atomic::Ordering::SeqCst);
16763    while !ctx.executor.is_halted() {
16764        ctx.executor.spin_once(ctx.spin_period);
16765    }
16766    ctx.executor.exit_spin_loop();
16767    core::ptr::null_mut()
16768}
16769
16770#[cfg(feature = "alloc")]
16771impl ThreadHandle {
16772    /// Signal the spawned executor thread to stop. The thread exits
16773    /// on its next `spin_once` iteration.
16774    pub fn halt(&self) {
16775        self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
16776    }
16777
16778    /// Wait for the spawned task to exit. After `join`, calling it again is a
16779    /// no-op.
16780    ///
16781    /// phase-359 W10 — was `std::thread::Result<()>`, which carried a panic
16782    /// payload this can no longer produce: a platform task has no unwinding
16783    /// join. Same two outcomes, an error type that does not require `std` —
16784    /// the trade `signal_fd` already made in this campaign. `NotInitialized`
16785    /// means the platform refused to host the task at spawn time — nothing was
16786    /// started, so there is nothing to wait for. (An existing variant rather
16787    /// than a new one: `NodeError` is mapped across the C and C++ FFI, so a new
16788    /// variant is a gate-checked ABI change and this needs no new meaning.)
16789    pub fn join(mut self) -> Result<(), NodeError> {
16790        self.halt();
16791        match self.task.take() {
16792            Some(t) => {
16793                t.join();
16794                Ok(())
16795            }
16796            None => Err(NodeError::NotInitialized),
16797        }
16798    }
16799}
16800
16801#[cfg(feature = "alloc")]
16802impl Drop for ThreadHandle {
16803    fn drop(&mut self) {
16804        self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
16805        if let Some(t) = self.task.take() {
16806            t.join();
16807        }
16808    }
16809}
16810
16811// SAFETY: Phase 110.D.b — `Executor` contains a raw `*mut
16812// session::ConcreteSession` only on the `from_session_ptr` (Borrowed)
16813// path; the `from_session` (Owned) path is plain Send-able. The
16814// `unsafe fn open_threaded` entry point documents the safety
16815// contract for Borrowed sessions; for Owned sessions the Send claim
16816// is unconditional.
16817// phase-359 W10 — `alloc`, not `std`: this assertion exists for
16818// `open_threaded`, which now hands the executor to a PLATFORM task. The
16819// crossing is the same one; only the thing doing the crossing changed.
16820#[cfg(feature = "alloc")]
16821unsafe impl<'s> Send for Executor<'s> {}
16822
16823/// phase-509 P9b item 2 -- take the bits of `mask` out of a ready word and
16824/// leave the rest for the workers of the other bands. One swap for the
16825/// one-level worker's every-slot mask, as before.
16826#[cfg(any(has_rmw, test))]
16827#[allow(dead_code)] // no ready word without `alloc` + `rmw-cffi`
16828#[inline]
16829fn take_band_bits(word: &portable_atomic::AtomicUsize, mask: u64) -> u64 {
16830    if mask == u64::MAX {
16831        word.swap(0, portable_atomic::Ordering::SeqCst) as u64
16832    } else {
16833        (word.fetch_and(!(mask as usize), portable_atomic::Ordering::SeqCst) as u64) & mask
16834    }
16835}
16836
16837/// phase-509 P9b item 2 (RFC-0108 "As built in P9b") -- how a pass runs.
16838#[cfg(any(has_rmw, test))]
16839pub(crate) enum PassMode<'w> {
16840    /// `spin_once(&mut self)`: the executor's own worker; the lock, if one
16841    /// is installed, is held for the whole pass.
16842    Owned,
16843    /// The executor's own worker through a raw pointer
16844    /// ([`Executor::spin_once_shared`]): the lock is released around each
16845    /// user callback when it carries a thread identity.
16846    Shared,
16847    /// A band worker's pass ([`Executor::spin_band_once`]).
16848    Band(&'w mut super::scheduler::BandWorker),
16849}
16850
16851impl<'s> Drop for Executor<'s> {
16852    fn drop(&mut self) {
16853        // Issue 0790 — a teardown that never went through `close()` still gets
16854        // its ordered shutdown hooks, and gets them in the SAME order. The C
16855        // API's `rclc_executor_fini` is exactly that path: it drops the
16856        // executor in place and leaves the session to `rclc_support_fini`, so
16857        // without this the whole facility would be silently inert for every C
16858        // entry. Entities are still live at the top of `drop` — the component
16859        // cells and the arena entries below are what tears them down — so this
16860        // is the last moment a pre-shutdown hook can do what it exists to do.
16861        //
16862        // After a normal `close()` both tables are already empty and these two
16863        // calls are a pair of `None` scans.
16864        self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
16865        // Phase 258 (Track 2, 2a) — release executor-owned component cells
16866        // first (before the arena entries), so a component's `drop`
16867        // trampoline can still touch its own (cell-owned) state. Each slot
16868        // owns a leaked `Arc<ComponentCell>`; its `drop` reconstitutes +
16869        // drops that Arc exactly once.
16870        for slot in self.component_slots.iter() {
16871            // SAFETY: `slot.state` is the leaked cell enrolled via
16872            // `enroll_component`; `slot.drop` is its matching trampoline, run
16873            // exactly once here (slots are not removed before Drop).
16874            unsafe {
16875                (slot.drop)(slot.state);
16876            }
16877        }
16878        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
16879        for meta in self.entries.iter().flatten() {
16880            // SAFETY: each entry was written by `ptr::write` in `add_*` and
16881            // has not been dropped yet. `drop_fn` matches the concrete type.
16882            unsafe {
16883                let data_ptr = arena_ptr.add(meta.offset);
16884                (meta.drop_fn)(data_ptr);
16885            }
16886        }
16887        // Issue 0790 — the post-teardown half, after the entities are gone.
16888        // Same "already empty after `close()`" note as the pre pass above.
16889        self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
16890        // Issue 1385 — LAST in the body, and that is the whole point: the
16891        // context the backend holds is `wake_ctx`, a field, so it is still
16892        // alive for every line above and is freed only after `drop` returns.
16893        // Clearing here is therefore the SHORTEST window in which a callback
16894        // can fire into live state, not the longest — entity teardown above
16895        // may still be talking to the backend.
16896        //
16897        // A no-op after `close()` (the latch), and a no-op for an executor
16898        // that never installed. For the C path this is the only clear there
16899        // is: `rclc_executor_fini` runs this `drop_in_place` and THEN
16900        // zero-fills the storage the callback pointed into.
16901        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
16902        self.clear_wake_signal();
16903        // phase-467 Row 8 — and the graph-change target, before the box it
16904        // names is freed with this executor.
16905        #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
16906        self.clear_graph_change_signal();
16907    }
16908}
16909
16910/// Phase 274.W1 — borrowed-executor session sharing + active-groups gating.
16911///
16912/// Validates three primitives introduced for RFC-0015 Model 1:
16913/// - `session_handle` / `open_with_session_handle` (Borrowed session store —
16914///   the borrowed executor does not own or close the session on drop).
16915/// - `set_active_groups` + `group_active` (callback-group filter gating).
16916///
16917/// Uses `MockSession`; gated `not(feature = "rmw-cffi")` because the mock
16918/// session exists only there.
16919#[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
16920mod p274_w1_tier_executor_tests {
16921    use super::Executor;
16922    use crate::mock::MockSession;
16923
16924    #[test]
16925    fn session_handle_borrowed_executor_shares_session_ptr() {
16926        // Open the primary executor (session owner).
16927        let session = MockSession::new();
16928        let mut primary = Executor::from_session(session);
16929
16930        // Record the primary's session pointer for later comparison.
16931        let primary_session_ptr = primary.session_ptr();
16932
16933        // Get the opaque session handle (into_raw for C FFI; here we keep it raw).
16934        let handle = primary.session_handle();
16935
16936        // Open a second executor over the SAME session (Borrowed — does not own it).
16937        // SAFETY: `primary` (the session owner) outlives `borrowed` in this scope.
16938        let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
16939
16940        // Both executors must expose the same session pointer.
16941        assert_eq!(
16942            primary.session_ptr(),
16943            borrowed.session_ptr(),
16944            "borrowed executor must share the primary's session pointer"
16945        );
16946        assert_eq!(
16947            borrowed.session_ptr(),
16948            primary_session_ptr,
16949            "session pointer must be stable across session_handle / open_with_session_handle"
16950        );
16951
16952        // Drop the borrowed executor — the primary's session must remain valid.
16953        drop(borrowed);
16954
16955        // Primary still exposes the same session pointer (session was NOT closed by drop).
16956        assert_eq!(
16957            primary.session_ptr(),
16958            primary_session_ptr,
16959            "primary session pointer must be unchanged after dropping the borrowed executor"
16960        );
16961    }
16962
16963    /// An oversized group name is REFUSED and fails CLOSED.
16964    ///
16965    /// issue 1172 — this used to be `if s.push_str(g).is_ok()`, which dropped
16966    /// the name and left the tier filtering on a set that silently lacked it.
16967    /// Two things are asserted because either alone would let the bug back:
16968    /// the call must REPORT the failure, and the resulting filter must accept
16969    /// NOTHING rather than fall back to the wildcard, which would be
16970    /// fail-open.
16971    #[test]
16972    fn an_oversized_group_name_is_refused_and_fails_closed() {
16973        let session = MockSession::new();
16974        let mut primary = Executor::from_session(session);
16975        let handle = primary.session_handle();
16976        // SAFETY: primary outlives borrowed.
16977        let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
16978
16979        // `GroupName` is `heapless::String<32>`.
16980        let too_long = "g".repeat(33);
16981        assert!(
16982            borrowed
16983                .set_active_groups(&[("n", "/", too_long.as_str())])
16984                .is_err(),
16985            "an oversized group name must be reported, not dropped"
16986        );
16987        assert!(
16988            !borrowed.group_active("n", "/", &too_long),
16989            "the name that did not fit must not be active"
16990        );
16991        assert!(
16992            !borrowed.group_active("n", "/", "anything-else"),
16993            "a refused filter must accept NOTHING — falling back to the \
16994             wildcard would run every callback on this tier"
16995        );
16996    }
16997
16998    #[test]
16999    fn set_active_groups_gates_group_active() {
17000        let session = MockSession::new();
17001        let mut primary = Executor::from_session(session);
17002        let handle = primary.session_handle();
17003
17004        // SAFETY: primary outlives borrowed.
17005        let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
17006
17007        // Before gating: wildcard — every group is accepted.
17008        assert!(
17009            borrowed.group_active("n", "/", "ctrl"),
17010            "default (wildcard) must accept every group"
17011        );
17012        assert!(
17013            borrowed.group_active("n", "/", "telem"),
17014            "default (wildcard) must accept every group"
17015        );
17016
17017        // Gate borrowed to only the "ctrl" group (one-tier filter).
17018        borrowed
17019            .set_active_groups(&[("n", "/", "ctrl")])
17020            .expect("fits");
17021
17022        assert!(
17023            borrowed.group_active("n", "/", "ctrl"),
17024            "\"ctrl\" must be active after set_active_groups([\"ctrl\"])"
17025        );
17026        assert!(
17027            !borrowed.group_active("n", "/", "telem"),
17028            "\"telem\" must NOT be active when only \"ctrl\" is gated"
17029        );
17030        assert!(
17031            !borrowed.group_active("n", "/", "planning"),
17032            "\"planning\" must NOT be active when only \"ctrl\" is gated"
17033        );
17034
17035        // Primary is unaffected (it still uses the wildcard).
17036        assert!(
17037            primary.group_active("n", "/", "telem"),
17038            "primary executor must remain unaffected (still wildcard)"
17039        );
17040
17041        // Clear the filter on borrowed — back to wildcard.
17042        borrowed
17043            .set_active_groups(&[])
17044            .expect("the wildcard always fits");
17045        assert!(
17046            borrowed.group_active("n", "/", "telem"),
17047            "after clearing, borrowed must accept all groups again"
17048        );
17049    }
17050
17051    #[test]
17052    fn session_handle_into_raw_from_raw_round_trip() {
17053        let session = MockSession::new();
17054        let mut primary = Executor::from_session(session);
17055        let session_ptr = primary.session_ptr();
17056
17057        let handle = primary.session_handle();
17058        let raw = handle.into_raw();
17059
17060        // into_raw must return a non-null pointer matching the session address.
17061        assert!(!raw.is_null());
17062        assert_eq!(raw as *mut _, session_ptr);
17063
17064        // from_raw must reconstruct a handle that opens the same borrowed executor.
17065        // SAFETY: primary still owns the session, raw is its address.
17066        let handle2 = unsafe { crate::executor::SessionHandle::from_raw(raw) };
17067        let mut borrowed = unsafe { Executor::open_with_session_handle(handle2) };
17068        assert_eq!(
17069            borrowed.session_ptr(),
17070            session_ptr,
17071            "from_raw reconstructed handle must open executor on the same session"
17072        );
17073    }
17074}
17075
17076/// W3b.5 — snapshot the monitored publishers' counters before a dispatch
17077/// (only when a latency contract exists; otherwise a zeroed array that
17078/// `attribute_latency` never reads).
17079fn snapshot_pub_counts(
17080    table: &'static [super::monitor::MonitorSpec],
17081    active: bool,
17082) -> [u32; super::monitor::MAX_MONITORS] {
17083    let mut counts = [0u32; super::monitor::MAX_MONITORS];
17084    if active {
17085        for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
17086            counts[k] = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
17087        }
17088    }
17089    counts
17090}
17091
17092/// W3b.5 — attribute one dispatch's elapsed time to every monitored
17093/// publisher whose counter advanced during it (an upper bound on the
17094/// node-path take → publish latency: the callback deserialized, ran, and
17095/// published within `elapsed_us`).
17096fn attribute_latency(
17097    table: &'static [super::monitor::MonitorSpec],
17098    active: bool,
17099    counts_before: &[u32; super::monitor::MAX_MONITORS],
17100    elapsed_us: u32,
17101) {
17102    if !active {
17103        return;
17104    }
17105    for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
17106        if spec.max_latency_ms == 0 {
17107            continue;
17108        }
17109        let now = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
17110        if now != counts_before[k] {
17111            spec.cell
17112                .max_latency_us
17113                .fetch_max(elapsed_us, core::sync::atomic::Ordering::Relaxed);
17114        }
17115    }
17116}
17117
17118/// W3b.5 — enforce the bound SC's deadline after a dispatch. A miss maps
17119/// through [`DeadlineAction`](super::sched_context::DeadlineAction):
17120/// `Warn`/`Skip`/`Fault` all report; `Skip` additionally masks the SC's
17121/// remaining callbacks for this cycle; `Fault` invokes the fault hook
17122/// (panic when none is registered).
17123/// phase-503 -- hand a route deadline verdict of `table[row]` to the reaction
17124/// hook, if one is installed (and the monitors are armed: the caller passes
17125/// `None` otherwise). Shared by the overdue check after the park and the
17126/// completion verdict of a late supervised route (`route_judge`).
17127fn call_route_reaction(
17128    hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
17129    table: &[super::monitor::MonitorSpec],
17130    row: usize,
17131    v: &super::monitor::Violation,
17132    fired_at_us: u64,
17133) {
17134    let Some((f, ctx)) = hook else { return };
17135    let spec = &table[row];
17136    let ev = super::monitor::ReactionEvent {
17137        reaction_id: spec.reaction_id as u32,
17138        row: row as u32,
17139        rule_code: super::monitor::rule_code(v.rule),
17140        measured_ms: v.measured,
17141        declared_ms: v.declared,
17142        mechanism: spec.mechanism,
17143        fired_at_us,
17144    };
17145    // SAFETY: `f` and `ctx` were registered together through
17146    // `set_reaction_hook`, whose caller keeps `ctx` valid for the executor's
17147    // life; the event lives across the call.
17148    unsafe { f(&ev, ctx) };
17149}
17150
17151/// phase-462 W3c -- [`call_route_reaction`] for a detected hazard: the event
17152/// names the hazard's reaction id and its index in the hazard table.
17153fn call_hazard_reaction(
17154    hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
17155    spec: &super::monitor::HazardSpec,
17156    row: usize,
17157    v: &super::monitor::Violation,
17158    fired_at_us: u64,
17159) {
17160    let Some((f, ctx)) = hook else { return };
17161    let ev = super::monitor::ReactionEvent {
17162        reaction_id: spec.reaction_id as u32,
17163        row: row as u32,
17164        rule_code: super::monitor::rule_code(v.rule),
17165        measured_ms: v.measured,
17166        declared_ms: v.declared,
17167        mechanism: super::monitor::MECHANISM_EXECUTOR,
17168        fired_at_us,
17169    };
17170    // SAFETY: as `call_route_reaction` -- `f` and `ctx` were registered
17171    // together through `set_reaction_hook`.
17172    unsafe { f(&ev, ctx) };
17173}
17174
17175// The deadline, the SC slot, the miss sinks and both fault paths, plus the
17176// fire time the reaction hook reports (play_launch phase 96 W4.2).
17177#[allow(clippy::too_many_arguments)]
17178fn check_deadline_miss(
17179    sc: Option<&super::sched_context::SchedContext>,
17180    sc_idx: usize,
17181    elapsed_us: u32,
17182    misses: &mut heapless::Vec<super::monitor::Violation, { super::monitor::MAX_VIOLATIONS }>,
17183    skipped_scs: &mut u64,
17184    fault_fn: Option<fn(&super::monitor::Violation)>,
17185    reaction_hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
17186    fired_at_us: u64,
17187) {
17188    use super::sched_context::DeadlineAction;
17189    let Some(sc) = sc else { return };
17190    let Some(deadline_us) = sc.deadline_us.get().map(|nz| nz.get()) else {
17191        return;
17192    };
17193    if matches!(sc.deadline_action, DeadlineAction::Ignore) || elapsed_us <= deadline_us {
17194        return;
17195    }
17196    let v = super::monitor::Violation {
17197        rule: "deadline-miss-runtime",
17198        // Entries carry no name at this altitude; the SC slot stands in.
17199        fqn: "sched-context",
17200        measured: elapsed_us,
17201        declared: deadline_us,
17202    };
17203    match sc.deadline_action {
17204        DeadlineAction::Ignore => {}
17205        DeadlineAction::Warn => {
17206            let _ = misses.push(v);
17207        }
17208        DeadlineAction::Skip => {
17209            if sc_idx < 64 {
17210                *skipped_scs |= 1u64 << sc_idx;
17211            }
17212            let _ = misses.push(v);
17213        }
17214        DeadlineAction::Fault => {
17215            if let Some(f) = fault_fn {
17216                f(&v);
17217                let _ = misses.push(v);
17218            } else if let Some((f, ctx)) = reaction_hook {
17219                // phase-503 — the reaction hook is the fault path a C or C++
17220                // image can install; reaction id 0, the SC slot as the row.
17221                let ev = super::monitor::ReactionEvent {
17222                    reaction_id: super::monitor::REACTION_NONE as u32,
17223                    row: sc_idx as u32,
17224                    rule_code: super::monitor::rule_code(v.rule),
17225                    measured_ms: elapsed_us / 1_000,
17226                    declared_ms: deadline_us / 1_000,
17227                    mechanism: super::monitor::MECHANISM_EXECUTOR,
17228                    fired_at_us,
17229                };
17230                // SAFETY: as in `check_route_deadlines`.
17231                unsafe { f(&ev, ctx) };
17232                let _ = misses.push(v);
17233            } else {
17234                panic!(
17235                    "nros: deadline fault — dispatch ran {elapsed_us} us past a {deadline_us} us deadline"
17236                );
17237            }
17238        }
17239    }
17240}
17241
17242// Timer-accounting clock default (issue: no_std tiers with no `clock_us`
17243// credited each spin the REQUESTED timeout — spin.rs `delta_us` fallback —
17244// so shared-session wakes made low-rate timers fire early (a 100 ms timer
17245// at ~67 ms on Zephyr native_sim) and tick rounding made fast tiers fire
17246// late. Measured on that native_sim image; mechanism documented at the
17247// fallback site.)
17248//
17249// Every platform port exports `nros_platform_clock_ns` through the same
17250// linkage contract the wake primitives rely on (`nros_platform_export_clock!`
17251// in nros-platform-cffi), so an rmw-cffi no_std build can safely default the
17252// executor clock to it; `ExecutorConfig::clock_us` stays as an override.
17253#[cfg(feature = "rmw-cffi")]
17254unsafe extern "C" {
17255    fn nros_platform_clock_ns() -> u64;
17256}
17257
17258#[cfg(feature = "rmw-cffi")]
17259fn default_platform_clock_us() -> u64 {
17260    // SAFETY: bare query of the platform's monotonic ns counter; the symbol
17261    // is guaranteed by whichever platform port linked the binary (the same
17262    // contract `nros_platform_wake_*` already depends on).
17263    //
17264    // RFC-0073 made the ABI nanoseconds; the executor's own accounting is
17265    // still microseconds (`clock_us_fn`, `delta_us`), so the division lives
17266    // here rather than being pushed into every port.
17267    unsafe { nros_platform_clock_ns() / 1_000 }
17268}
17269
17270/// Sleep, through the platform ABI.
17271///
17272/// phase-359 W10 — the spin loops used `std::thread::sleep`. Every port already
17273/// exports `nros_platform_sleep_us` (the ABI's own pacing primitive, the one an
17274/// RTOS build has always used), so a hosted loop and an embedded one now pace
17275/// the same way. The µs entry point is used rather than `_ms` because
17276/// `spin_one_period_timed` sleeps off a remainder, which rounds badly at
17277/// millisecond granularity on short periods.
17278#[cfg(feature = "alloc")]
17279pub(crate) fn platform_sleep(d: core::time::Duration) {
17280    unsafe extern "C" {
17281        fn nros_platform_sleep_us(us: usize);
17282    }
17283    let us = d.as_micros().min(usize::MAX as u128) as usize;
17284    if us == 0 {
17285        return;
17286    }
17287    // SAFETY: a bare pacing call with no pointer arguments, guaranteed by
17288    // whichever port linked the binary — the same contract the clock and wake
17289    // symbols in this file already rely on.
17290    unsafe { nros_platform_sleep_us(us) }
17291}
17292
17293/// A monotonic µs reader, or `None` when this build has no clock at all.
17294///
17295/// phase-359 W10 — one provider, chosen by what is LINKED rather than by which
17296/// flavour the crate was built in. W4 unified the executor's clock ACCESSOR and
17297/// said the provider was the last piece; this is it.
17298///
17299/// `rmw-cffi` means a platform port is linked, and every port exports
17300/// `nros_platform_clock_ns` under the same contract the wake primitives use —
17301/// so a hosted build reads the same counter an embedded one does, instead of
17302/// `Instant` on one and the platform on the other. `Instant` survives only
17303/// where there is no port to ask: `std` without `rmw-cffi`.
17304///
17305/// phase-359 W10 follow-up — this comment claimed the metadata PROBE was that
17306/// configuration, and **it is not**. The probe's generated manifest deps
17307/// `nros-platform-cffi` with `posix-c-port` (issue 0288 layer 5, so it links),
17308/// and its `nros` resolves with `rmw-cffi` ON — feature-unified from the
17309/// component's board crate. Measured on a real probe tree: `alloc default
17310/// macros rmw-cffi ros-humble std`. It takes the PORT arm.
17311///
17312/// What is real: `nros-rmw-metadata` and `nros-tests` do resolve `nros` with
17313/// `std` and no `rmw-cffi`, and have no port crate in their graphs. But nothing
17314/// in either uses this arm — deleting it and building the workspace
17315/// `--all-targets` is clean, and there is no in-tree caller of
17316/// `nros::time::now` at all. So the arm serves an OUT-OF-TREE contract only: a
17317/// consumer with `std`, no `rmw-cffi`, and its own `Session`.
17318pub(crate) fn default_clock_us_fn() -> Option<fn() -> u64> {
17319    #[cfg(feature = "rmw-cffi")]
17320    {
17321        Some(default_platform_clock_us)
17322    }
17323    #[cfg(not(feature = "rmw-cffi"))]
17324    {
17325        None
17326    }
17327}
17328
17329// =============================================================================
17330// phase-425 W3b — the `/clock` time source's executor half.
17331//
17332// Its OWN impl block, gated on `sim-time`, because the methods were first
17333// written next to `declare_parameter` — which lives in a
17334// `#[cfg(feature = "param-store")]` block, so `reconcile_ros_time_source`
17335// silently disappeared in every combo without parameter services, including the
17336// one `spin_once` calls it from.
17337// =============================================================================
17338#[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17339impl<'s> Executor<'s> {
17340    /// phase-425 W3b — bring the `/clock` subscription in line with the last
17341    /// requested `use_sim_time`.
17342    ///
17343    /// Called at the head of every spin, and cheap when there is nothing to do:
17344    /// the common case is one bool comparison. It is a RECONCILE rather than an
17345    /// action at the request site because the request routinely arrives before
17346    /// there is a node to hang the subscription on — `nros::main!` emits
17347    /// `apply_param_services` before its per-node `register` calls, by design,
17348    /// so the store exists when each cell is created.
17349    ///
17350    /// Turning it off stops SAMPLES from being installed; the subscription
17351    /// itself stays, because the executor has no entity removal and inventing
17352    /// one for this would be a much larger change than the switch is worth. The
17353    /// gate is `time_source::set_active`, which the subscription callback reads.
17354    ///
17355    /// Turning it off also does NOT clear the override: a node that stops
17356    /// listening keeps the last simulated time rather than jumping back to the
17357    /// wall clock, which every ROS-time timer would otherwise absorb as a
17358    /// backwards jump.
17359    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17360    pub(crate) fn reconcile_ros_time_source(&mut self) {
17361        // An executor nobody told about `use_sim_time` has NO opinion, and the
17362        // gate is process-global, so writing its default here is not a no-op --
17363        // it is one executor overruling another. Say nothing until asked.
17364        if !self.sim_time_stated {
17365            return;
17366        }
17367        if self.sim_time_requested == crate::time_source::is_active()
17368            && (!self.sim_time_requested || self.sim_time_source.is_some())
17369        {
17370            return;
17371        }
17372        crate::time_source::set_active(self.sim_time_requested);
17373        if !self.sim_time_requested {
17374            // phase-430 W1 — the source is no longer wanted, so its silence is
17375            // no longer a symptom. Disarming here (rather than only re-arming
17376            // on attach) is what makes a later re-attach start a FRESH
17377            // interval: the watch that was counting is put down, not paused.
17378            self.sim_time_silence.disarm();
17379            return;
17380        }
17381        if self.sim_time_source.is_none() {
17382            // No node yet — stay pending and try again next spin. Not an error:
17383            // it is the ordinary order of a generated entry, which declares
17384            // parameters before it registers components.
17385            if self.nodes.is_empty() {
17386                return;
17387            }
17388            if let Ok(handle) = self.install_ros_time_source(
17389                super::node_record::NodeId::PRIMARY,
17390                crate::time_source::CLOCK_TOPIC,
17391            ) {
17392                self.sim_time_source = Some(handle);
17393                // phase-430 W1 — the interval starts HERE, at attach, and not
17394                // at construction: before the subscription exists there is
17395                // nothing to be silent about, so counting from boot would
17396                // accuse an image that spent the interval bringing a transport
17397                // up.
17398                self.sim_time_silence.arm();
17399            }
17400        } else {
17401            // Re-attached: `use_sim_time` went false and back to true while the
17402            // subscription (which the executor cannot remove) stayed. W1 asks
17403            // for the one-shot to reset in exactly this case.
17404            self.sim_time_silence.arm();
17405        }
17406    }
17407
17408    /// phase-430 W1 — credit one spin's elapsed time to the silence watch and
17409    /// emit the diagnostic if this is the spin that crosses the threshold.
17410    ///
17411    /// `delta_us` is the executor's OWN per-spin elapsed measure — the same one
17412    /// every wall timer on this executor accumulates, including its "no clock,
17413    /// credit the requested timeout" fallback. See
17414    /// [`SILENCE_WARN_US`](crate::time_source::SILENCE_WARN_US) for why that is
17415    /// the unit and not a spin count or an independent wall clock.
17416    ///
17417    /// Scope: the source the EXECUTOR installed for `use_sim_time`. An
17418    /// application that calls
17419    /// [`Node::install_ros_time_source`](super::node::Node::install_ros_time_source)
17420    /// directly is not watched, because the line this would print names
17421    /// `use_sim_time`, and that application never set it — the honest
17422    /// diagnostic for the explicit path would be a different sentence, and
17423    /// inventing one nobody asked for is how a warning becomes noise.
17424    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17425    fn note_sim_time_silence(&mut self, delta_us: u64) {
17426        if self.sim_time_silence.tick(
17427            delta_us,
17428            nros_core::clock::Clock::is_ros_time_override_active(),
17429        ) {
17430            crate::time_source::report_silence(crate::time_source::CLOCK_TOPIC);
17431        }
17432    }
17433
17434    /// phase-430 W1 — how many times this executor has reported that
17435    /// `use_sim_time` is on with no `/clock` sample.
17436    ///
17437    /// The count rather than a bool, and an accessor rather than a log grep: a
17438    /// diagnostic whose contract is "exactly once" needs an assertion that can
17439    /// tell one from two, and a log sink is not present on every target that
17440    /// runs the test. It is also the question an application supervising its
17441    /// own configuration would ask.
17442    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17443    pub fn sim_time_silence_warnings(&self) -> u32 {
17444        self.sim_time_silence.reports()
17445    }
17446
17447    /// phase-425 W3b — re-read `use_sim_time` from the parameter store.
17448    ///
17449    /// Called after a parameter service actually handled something, which is
17450    /// what makes a runtime `ros2 param set <node> use_sim_time true` work
17451    /// without a per-spin scan of the store.
17452    ///
17453    /// phase-430 W3 / issue 1202 — the declaration path calls it too, through
17454    /// `note_reserved_parameter`, once the store has accepted the write. It
17455    /// used to record the value directly from the caller's argument, which is
17456    /// the same thing only while the store never refuses. One read for both
17457    /// paths; a second spelling is the drift this function exists to prevent.
17458    /// `pub(crate)` for the same reason: a test that changes the store directly
17459    /// needs the entry point a parameter service uses, not a copy of it.
17460    #[cfg(all(feature = "sim-time", feature = "param-store", any(has_rmw, test)))]
17461    pub(crate) fn refresh_use_sim_time_from_store(&mut self) {
17462        // phase-426 W1 — the store is keyed by NODE, and the simulated clock is
17463        // not. This function is where those two facts meet, so the rule is
17464        // stated here rather than left to whichever call site got there first.
17465        //
17466        // `use_sim_time` is a per-node parameter in ROS 2, and rclcpp means it:
17467        // every `Node` builds its own `TimeSource` over its own `Clock`, so two
17468        // nodes composed into one process CAN be configured differently and
17469        // stamp messages from different clocks at the same instant. This tree
17470        // deliberately does not follow that (phase-425): "the override is
17471        // process-global — one simulated clock per image, the model
17472        // `nros_core::Clock` already documents — so there is nothing to attach
17473        // to and nothing to fan out." That is the right trade for an image with
17474        // a fixed RAM budget, and it makes "whose `use_sim_time`?" a question
17475        // rclcpp never has to answer and this executor now does.
17476        //
17477        // ANY node asking arms the one source. Two reasons, and the second is
17478        // the binding one:
17479        //
17480        //   * it is the only rule the CALLERS can implement. `note_param_
17481        //     services_ran` fires after a runtime `ros2 param set` and knows
17482        //     only that N requests were handled — not which node was written.
17483        //     A last-writer-wins rule would need a writer identity that does
17484        //     not reach here, so it would silently degrade to "whatever node 0
17485        //     says", which is the default-to-primary bug phase-426 W1 exists to
17486        //     remove.
17487        //   * one node still wanting simulated time is a reason to keep the
17488        //     source attached, and detaching is the destructive direction:
17489        //     phase-425 already records that turning it off "stops SAMPLES, not
17490        //     the subscription", precisely so a mid-run change does not read as
17491        //     a clock jump.
17492        //
17493        // The scan is over the executor's own node table, which is the same
17494        // `u8` index space `NodeKey` wraps, so there is one node identity here
17495        // and not two.
17496        let Some(params) = self.params.as_deref() else {
17497            return;
17498        };
17499        let mut stated = false;
17500        let mut enable = false;
17501        // A `NodeId` IS the index into this table (`NodeId::index`), so the
17502        // enumeration is the key space — no second derivation of the number.
17503        //
17504        // `.max(1)` because `NodeId::PRIMARY` is IMPLICIT: the executor's node
17505        // table starts empty and a parameter can be declared on the primary
17506        // node before any `create_node` puts a record in it. Scanning only the
17507        // table would then find nothing and leave `sim_time_stated` false, so a
17508        // `use_sim_time` declared before the first node would never arm the
17509        // source — which `use_sim_time_attaches_and_detaches_the_clock_source`
17510        // catches, since its whole first half runs while "there is still no
17511        // node". Slot 0 is always a legitimate key; at worst the lookup misses.
17512        for idx in 0..self.nodes.len().max(1) {
17513            let key = nros_params::NodeKey::new(idx as u8);
17514            if let Some(v) = params
17515                .server
17516                .get_bool(key, crate::time_source::USE_SIM_TIME_PARAM)
17517            {
17518                stated = true;
17519                enable |= v;
17520            }
17521        }
17522        if stated {
17523            self.sim_time_requested = enable;
17524            self.sim_time_stated = true;
17525        }
17526    }
17527
17528    /// phase-425 W3b — the installed `/clock` subscription, if any.
17529    ///
17530    /// The HANDLE rather than a bool, because "did we subscribe twice" is the
17531    /// question a reconciliation loop has to be able to answer: a second
17532    /// registration takes a new slot, so a stable handle across spins is the
17533    /// evidence that the loop is idempotent.
17534    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17535    pub fn ros_time_source_handle(&self) -> Option<HandleId> {
17536        self.sim_time_source
17537    }
17538
17539    /// phase-425 W3b — whether a `/clock` subscription is currently installed.
17540    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17541    pub fn ros_time_source_installed(&self) -> bool {
17542        self.sim_time_source.is_some()
17543    }
17544
17545    /// phase-425 W3 — subscribe `topic` and install every sample as this
17546    /// image's ROS time. The registration behind
17547    /// [`Node::install_ros_time_source`](super::node::Node::install_ros_time_source)
17548    /// and behind the `use_sim_time` reconciliation, so both spell the QoS and
17549    /// the conversion exactly once.
17550    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17551    pub(crate) fn install_ros_time_source(
17552        &mut self,
17553        node_id: super::node_record::NodeId,
17554        topic: &str,
17555    ) -> Result<HandleId, NodeError> {
17556        self.register_subscription_buffered_on::<
17557            nros_rosgraph_msgs::msg::Clock,
17558            _,
17559            { crate::config::DEFAULT_RX_BUF_SIZE },
17560        >(
17561            node_id,
17562            topic,
17563            QoSProfile::clock_default(),
17564            |msg: &nros_rosgraph_msgs::msg::Clock| {
17565                // The `use_sim_time` gate is read HERE rather than by removing
17566                // the subscription, because there is no entity removal. An
17567                // image that never touches `use_sim_time` and installs the
17568                // source explicitly is active by default.
17569                if !crate::time_source::is_active() {
17570                    return;
17571                }
17572                if let Some(nanos) =
17573                    crate::time_source::override_nanos(msg.clock.sec, msg.clock.nanosec)
17574                {
17575                    nros_core::clock::Clock::set_ros_time_override(nanos);
17576                }
17577            },
17578            None, // no group — node default
17579            None, // the configured default RX buffer, unchanged
17580        )
17581    }
17582}
17583
17584// ============================================================================
17585// phase-417 W4.c — cancel / is_spinning
17586// ============================================================================
17587//
17588// Same gate as `executor::tests`, exactly: `ConcreteSession` is `MockSession`
17589// only while no rmw-* feature is unified in (see `executor/mod.rs`), and
17590// `from_session_with` / `halt_flag` are `alloc`. No `std` gate — `cfg(test)`
17591// already means a hosted harness, which is why `lib.rs` brings `std` in under
17592// `test` as well. (It said "exactly" while missing `alloc`, and `std` was not in
17593// scope: 20 errors, hidden by feature unification under `--workspace`.)
17594#[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
17595mod cancel_tests {
17596    use super::Executor;
17597    use crate::{executor::types::ExecutorConfig, mock::MockSession};
17598
17599    /// The core has no default clock and says so (issue 0709), so a test that
17600    /// measures elapsed time brings its own — the same seam a board uses.
17601    fn test_clock_us() -> u64 {
17602        use std::{sync::OnceLock, time::Instant};
17603        static EPOCH: OnceLock<Instant> = OnceLock::new();
17604        EPOCH.get_or_init(Instant::now).elapsed().as_micros() as u64
17605    }
17606
17607    fn executor() -> Executor<'static> {
17608        let cfg = ExecutorConfig::default().clock_us(test_clock_us);
17609        Executor::from_session_with(MockSession::new(), &cfg)
17610    }
17611
17612    /// A fresh executor is neither cancelled nor spinning — the two questions
17613    /// are independent, and this is the state where both answers are `false`.
17614    #[test]
17615    fn a_fresh_executor_is_neither_cancelled_nor_spinning() {
17616        let exec = executor();
17617        assert!(!exec.is_halted(), "nothing has asked it to stop");
17618        assert!(!exec.is_spinning(), "no spin loop is running");
17619    }
17620
17621    /// `cancel()` raises the request bit without starting or stopping anything.
17622    #[test]
17623    fn cancel_requests_without_spinning() {
17624        let exec = executor();
17625        exec.cancel();
17626        assert!(exec.is_halted(), "cancel() must be observable immediately");
17627        assert!(
17628            !exec.is_spinning(),
17629            "cancel() must not make a non-spinning executor claim to spin"
17630        );
17631    }
17632
17633    /// The load-bearing one: `cancel()` from a peer thread TERMINATES a running
17634    /// `spin`, `is_spinning()` is true while the loop runs, and false
17635    /// once it has returned.
17636    ///
17637    /// The peer asserts `is_spinning()` from OUTSIDE the spinning thread, which
17638    /// is the whole point of the observable — it is what a signal handler or a
17639    /// supervisor sees.
17640    #[test]
17641    fn cancel_terminates_a_spin_and_is_spinning_tracks_it() {
17642        use std::{
17643            sync::{
17644                Arc,
17645                atomic::{AtomicBool, Ordering},
17646            },
17647            thread,
17648            time::{Duration, Instant},
17649        };
17650
17651        let mut exec = executor();
17652        assert!(!exec.is_spinning());
17653
17654        // `Executor` is not `Sync`, so the peer thread cannot hold a reference
17655        // to it. It observes and drives through the two flag handles instead —
17656        // which is the supported cross-thread surface (`halt_flag()`), and
17657        // keeps this test on the same seam a signal handler uses.
17658        let cancel_bit = exec.halt_flag();
17659        let saw_spinning = Arc::new(AtomicBool::new(false));
17660        let saw_spinning_peer = Arc::clone(&saw_spinning);
17661
17662        let peer = thread::spawn(move || {
17663            // Give the loop time to enter. Bounded, and the assertion below is
17664            // on what was OBSERVED, so a slow host cannot turn this green by
17665            // accident — it fails instead.
17666            thread::sleep(Duration::from_millis(50));
17667            saw_spinning_peer.store(true, Ordering::SeqCst);
17668            cancel_bit.store(true, Ordering::SeqCst);
17669        });
17670
17671        let started = Instant::now();
17672        exec.spin(crate::executor::types::SpinOptions::default())
17673            .expect("an untimed spin runs until cancelled");
17674        let elapsed = started.elapsed();
17675
17676        peer.join().expect("peer thread must not panic");
17677
17678        assert!(
17679            saw_spinning.load(Ordering::SeqCst),
17680            "the peer must have run before spin returned"
17681        );
17682        assert!(
17683            !exec.is_spinning(),
17684            "is_spinning() must be false once the loop has returned"
17685        );
17686        assert!(
17687            exec.is_halted(),
17688            "the cancel request survives the loop it ended"
17689        );
17690        // LOWER bound too: a spin that never waited would prove nothing about
17691        // termination. The peer sleeps 50 ms before cancelling.
17692        assert!(
17693            elapsed >= Duration::from_millis(40),
17694            "spin returned in {elapsed:?} — it cannot have spun"
17695        );
17696        // ...and an UPPER bound, because "terminated" is the claim: without the
17697        // cancel check this loop never returns and the test would hang rather
17698        // than fail. 10 s is far past any scheduling noise.
17699        assert!(
17700            elapsed < Duration::from_secs(10),
17701            "spin took {elapsed:?} to observe the cancel"
17702        );
17703    }
17704
17705    /// `is_spinning()` is true from INSIDE the loop. Read from a timer callback,
17706    /// which is the only place the executor is genuinely mid-spin, and asserted
17707    /// after the loop so a callback that never fired cannot pass silently.
17708    #[test]
17709    fn is_spinning_is_true_inside_the_loop() {
17710        use std::time::Duration;
17711
17712        let mut exec = executor();
17713        // `only_next` runs exactly one iteration, so this is a single poll
17714        // inside a spin scope — bounded, no thread, no clock dependency beyond
17715        // the one the fixture installs.
17716        assert!(!exec.is_spinning());
17717        exec.spin(crate::executor::types::SpinOptions::spin_once())
17718            .expect("a single-iteration spin must succeed");
17719        assert!(
17720            !exec.is_spinning(),
17721            "the scope must close when the loop returns"
17722        );
17723
17724        // The inside-the-loop reading needs a second thread to do it, because
17725        // the spinning thread is blocked in the loop and `Executor` is `!Sync`
17726        // so no `&Executor` may cross. What crosses is a pointer to the one
17727        // atomic, which is the same thing the C and C++ APIs hold: they observe
17728        // `is_spinning` through a raw executor handle, and that is the case this
17729        // flag exists to serve.
17730        struct FlagPtr(*const portable_atomic::AtomicBool);
17731        // SAFETY: the pointee is an `AtomicBool`, which is `Sync`; the only
17732        // operation performed through it is a relaxed-or-stronger load. The
17733        // executor outlives the thread — `peer.join()` below happens before
17734        // `exec` is dropped at the end of the test.
17735        unsafe impl Send for FlagPtr {}
17736
17737        let cancel_bit = exec.halt_flag();
17738        let observed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
17739        let spinning_bit_seen = std::sync::Arc::clone(&observed);
17740        let flag = FlagPtr(&exec.spinning as *const portable_atomic::AtomicBool);
17741        let peer = std::thread::spawn(move || {
17742            let flag = flag;
17743            for _ in 0..500 {
17744                // SAFETY: see the `unsafe impl Send` above.
17745                if unsafe { &*flag.0 }.load(core::sync::atomic::Ordering::SeqCst) {
17746                    spinning_bit_seen.store(true, std::sync::atomic::Ordering::SeqCst);
17747                    break;
17748                }
17749                std::thread::sleep(Duration::from_millis(2));
17750            }
17751            cancel_bit.store(true, std::sync::atomic::Ordering::SeqCst);
17752        });
17753
17754        exec.spin(crate::executor::types::SpinOptions::default())
17755            .expect("an untimed spin runs until cancelled");
17756        peer.join().expect("peer thread must not panic");
17757
17758        assert!(
17759            observed.load(std::sync::atomic::Ordering::SeqCst),
17760            "is_spinning() must read true while spin is running"
17761        );
17762    }
17763
17764    /// `spin_period` maintains the same scope, and a period this build cannot
17765    /// pace is an error that must NOT leave the executor claiming to spin.
17766    #[test]
17767    fn spin_period_with_no_clock_leaves_is_spinning_false() {
17768        let mut exec = executor();
17769        exec.clock_us_fn = None;
17770        let err = exec
17771            .spin_period(core::time::Duration::from_millis(10))
17772            .expect_err("a period with no clock must fail (issue 0709)");
17773        assert_eq!(err, crate::NodeError::NotInitialized);
17774        assert!(
17775            !exec.is_spinning(),
17776            "a spin that never started must not report that it did"
17777        );
17778    }
17779
17780    /// Entering a spin CLEARS a stale cancel: a cancel requested before this
17781    /// spin is not a cancel of this spin. Documents the ADOPT-BOUNDED envelope's
17782    /// one sharp edge.
17783    #[test]
17784    fn entering_a_spin_clears_a_stale_cancel() {
17785        let exec = executor();
17786        exec.cancel();
17787        assert!(exec.is_halted());
17788        exec.enter_spin_loop();
17789        assert!(
17790            !exec.is_halted(),
17791            "a cancel raised before the loop started must not end it instantly"
17792        );
17793        assert!(exec.is_spinning());
17794        exec.exit_spin_loop();
17795        assert!(!exec.is_spinning());
17796    }
17797}
17798
17799/// phase-509 P9b item 3 (RFC-0108 D7) -- the arrival path wakes only the
17800/// worker whose band holds the slot.
17801#[cfg(all(test, feature = "alloc", feature = "rmw-cffi"))]
17802mod band_wake_routing_tests {
17803    use super::{super::scheduler::BandWaiter, WakeCtx};
17804    use core::ffi::c_void;
17805    use portable_atomic::{AtomicUsize, Ordering};
17806
17807    static WOKEN: [AtomicUsize; 2] = [AtomicUsize::new(0), AtomicUsize::new(0)];
17808
17809    unsafe extern "C" fn wake(ctx: *mut c_void) {
17810        WOKEN[ctx as usize].fetch_add(1, Ordering::SeqCst);
17811    }
17812
17813    unsafe extern "C" fn wait(_ctx: *mut c_void, _us: u64) -> i32 {
17814        0
17815    }
17816
17817    #[test]
17818    fn an_attributed_arrival_wakes_its_band_and_an_unattributed_one_wakes_all() {
17819        // SAFETY: the contexts are plain indices `wake` decodes.
17820        static A: BandWaiter = unsafe { BandWaiter::new(wake, wait, core::ptr::null_mut()) };
17821        static B: BandWaiter =
17822            unsafe { BandWaiter::new(wake, wait, core::ptr::without_provenance_mut(1)) };
17823        A.slots.store(0b0011, Ordering::SeqCst);
17824        B.slots.store(0b0100, Ordering::SeqCst);
17825        let flag = portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false));
17826        let ctx = WakeCtx::new(flag.clone(), None);
17827        B.next.store(&A as *const _ as *mut _, Ordering::SeqCst);
17828        ctx.band_waiters
17829            .store(&B as *const _ as *mut _, Ordering::SeqCst);
17830
17831        ctx.mark_ready(2);
17832        assert_eq!(WOKEN[1].load(Ordering::SeqCst), 1, "slot 2 is B's");
17833        assert_eq!(WOKEN[0].load(Ordering::SeqCst), 0);
17834        assert!(
17835            !flag.load(Ordering::SeqCst),
17836            "the executor's own worker sleeps on"
17837        );
17838
17839        ctx.mark_ready(1);
17840        assert_eq!(WOKEN[0].load(Ordering::SeqCst), 1, "slot 1 is A's");
17841
17842        ctx.mark_ready(5);
17843        assert!(
17844            flag.load(Ordering::SeqCst),
17845            "no band holds slot 5: own worker"
17846        );
17847        assert_eq!(
17848            WOKEN[0].load(Ordering::SeqCst) + WOKEN[1].load(Ordering::SeqCst),
17849            2
17850        );
17851
17852        ctx.signal();
17853        assert_eq!(
17854            WOKEN[0].load(Ordering::SeqCst),
17855            2,
17856            "unattributed: every band"
17857        );
17858        assert_eq!(WOKEN[1].load(Ordering::SeqCst), 2);
17859        assert_eq!(ctx.ready.load(Ordering::SeqCst), 0b100110);
17860    }
17861}