nros_node/executor/spin.rs
1//! Executor struct and core spin methods.
2
3use core::{marker::PhantomData, mem::MaybeUninit};
4
5use nros_core::{RosMessage, RosService, ViewableMessage};
6use nros_rmw::{QoSProfile, ServiceInfo, Session, TopicInfo, TransportError};
7
8use crate::{session, timer::TimerDuration};
9
10#[cfg(feature = "safety-e2e")]
11use super::arena::{
12 SubSafetyEntry, sub_safety_has_data, sub_safety_pre_sample, sub_safety_try_process,
13};
14#[cfg(feature = "rmw-cffi")]
15use super::types::ExecutorConfig;
16// phase-427 — unconditional: `spin_forever` takes `SpinOptions` and is not
17// `alloc`-gated (a bare-metal entry is exactly the caller that needs it).
18use super::{
19 arena::{
20 BufferStrategy, CallbackMeta, EntryKind, GuardConditionEntry, ServiceClientCallbackEntry,
21 ServiceClientRawArenaEntry, ServiceClientSendHeader, SrvEntry, SrvRawEntry,
22 SubBufferedEntry, SubBufferedRawCEntry, SubBufferedRawEntry, SubBufferedRawInfoCEntry,
23 SubBufferedRawInfoEntry, SubBufferedTypedCEntry, SubBufferedViewEntry, SubInfoEntry,
24 SubInplaceEntry, TimerClockSource, TimerEntry, TimerHeader, TimerOverrunPolicy, TraceName,
25 always_ready, buffered_region_size, drop_entry, guard_has_data, guard_try_process,
26 no_pre_sample, service_client_callback_try_process, service_client_raw_try_process,
27 srv_has_data, srv_raw_has_data, srv_raw_try_process, srv_try_process,
28 sub_buffered_has_data, sub_buffered_raw_c_has_data, sub_buffered_raw_c_try_process,
29 sub_buffered_raw_has_data, sub_buffered_raw_info_c_has_data,
30 sub_buffered_raw_info_c_try_process, sub_buffered_raw_info_has_data,
31 sub_buffered_raw_info_try_process, sub_buffered_raw_try_process, sub_buffered_try_process,
32 sub_buffered_typed_c_has_data, sub_buffered_typed_c_try_process,
33 sub_buffered_view_has_data, sub_buffered_view_try_process, sub_info_has_data,
34 sub_info_pre_sample, sub_info_try_process, sub_inplace_has_data, sub_inplace_try_process,
35 timer_try_process,
36 },
37 node::NodeHandle,
38 spsc_ring::SpscRing,
39 triple_buffer::TripleBuffer,
40 types::{
41 ExecutorSemantics, GuardCondition, HandleId, InvocationMode, NodeError,
42 RawMessageDeserializeFn, RawResponseCallback, RawServiceCallback, RawSubscriptionCallback,
43 RawSubscriptionInfoCallback, ReadinessSnapshot, SpinOnceResult, SpinOptions,
44 SpinPeriodPollingResult, Trigger, TypedSubscriptionCallback,
45 },
46};
47
48// ============================================================================
49// phase-456 W8 — one subscription registration, stated as one value
50// ============================================================================
51
52/// What a subscription registration STATES — phase-456 W8.
53///
54/// The thirteen `register_subscription_*` / `add_arena_subscription_*` entry
55/// points differ in their DELIVERY SHAPE (typed, raw, borrowed, with-info,
56/// validated) and used to differ in their LANGUAGE, and each re-derived the
57/// same prologue from positional parameters: node identity, topic info, the
58/// declared-depth check, the backend call. Twelve copies of it, and exactly
59/// ONE of them asked the backend whether it dispatches in place.
60///
61/// This is that prologue's argument, as one value. A struct rather than nine
62/// parameters because the fields are independently optional and a caller must
63/// be able to say "I state nothing here" without reordering a call — the same
64/// reason `DescriptorInputs` is a struct one layer up.
65///
66/// Two of its fields are the axes phase-456 W8 found under issue 1319's five
67/// rows: [`backend_hint`](Self::backend_hint) is "does this site know the
68/// type's bound", and [`in_place_capable`](Self::in_place_capable) is what
69/// lets `Executor::open_subscription` ask the third axis once, for every
70/// caller, in every language.
71pub(crate) struct SubscriptionRequest<'a> {
72 /// `None` is the legacy single-node path: identity comes from the
73 /// executor's own name and namespace and the session is slot 0.
74 pub(crate) node_id: Option<super::node_record::NodeId>,
75 pub(crate) topic_name: &'a str,
76 pub(crate) type_name: &'a str,
77 pub(crate) type_hash: &'a str,
78 pub(crate) qos: QoSProfile,
79 /// Bytes the CALL SITE says it expects to receive, for the BACKEND's
80 /// payload size class (phase-231 / RFC-0038, phase-402 W2).
81 ///
82 /// `None` — and, for a C caller whose ABI spells it `0`, a zero — means
83 /// "this site stated nothing", which is a different claim from "zero
84 /// bytes": `with_rx_buffer_hint(0)` would be the second.
85 pub(crate) backend_hint: Option<usize>,
86 /// Bytes ONE receive slot claims from the arena, already derived by the
87 /// caller that has the type.
88 ///
89 /// Not the same number as [`Self::backend_hint`] — the typed Rust path
90 /// hints the type's bound and sizes the slot from
91 /// `default_subscription_rx_bytes`, which may differ — so both travel.
92 /// Ignored entirely when [`SubscriptionOpen::in_place`] comes back true.
93 pub(crate) slot_bytes: usize,
94 /// Can THIS delivery shape dispatch out of the backend's own receive slot?
95 ///
96 /// A property of the shape, not of the backend: `process_raw_in_place`
97 /// hands the callback borrowed BYTES and nothing else, so a shape that
98 /// must also deliver a wire attachment (`_info`), an integrity status
99 /// (`_validated` / `_safety`) or a `MessageInfo` cannot use it however
100 /// capable the backend is. Stated per call site so an omission is a line
101 /// someone wrote rather than a question nobody asked — which is exactly
102 /// what the C path was before W8.
103 pub(crate) in_place_capable: bool,
104}
105
106/// The result of `Executor::open_subscription`: a claimed slot, a live backend
107/// subscriber, and the ONE answer to the in-place question.
108pub(crate) struct SubscriptionOpen {
109 /// The claimed entry slot. Claimed BEFORE the fallible work, per
110 /// `Executor::emplace_entry`'s rule.
111 pub(crate) slot: usize,
112 pub(crate) handle: session::RmwSubscriber,
113 /// **The only place in this crate that
114 /// `Subscription::supports_process_in_place` is read.**
115 ///
116 /// Before phase-456 W8 the read lived inside
117 /// `register_subscription_buffered_on`, which is the Rust typed path and
118 /// only that: every C and C++ subscription on zenoh or XRCE therefore
119 /// allocated a receive region the backend does not need, at the same order
120 /// as issue 1340's ~9.7 KiB per subscription. The irony phase-456 W8
121 /// measured is that the C callback is the BETTER candidate —
122 /// `RawSubscriptionCallback` is already `(const uint8_t*, size_t, void*)`,
123 /// a borrowed-bytes signature, where the Rust typed path has to produce an
124 /// owned `&M` from somewhere.
125 pub(crate) in_place: bool,
126 /// [`SubscriptionRequest::slot_bytes`], carried through so a caller's arena
127 /// arithmetic reads ONE local rather than re-deriving it.
128 pub(crate) slot_bytes: usize,
129 /// The QoS the backend was actually given — phase-454 W13 through
130 /// phase-456 W8.
131 ///
132 /// NOT the caller's `req.qos`. `declared_qos::honour` REWRITES the depth
133 /// when the system declared one, and the twelve prologues this function
134 /// replaced each passed that rewritten value on to the entry they built.
135 /// Returning it is what keeps that true with one honouring site: a caller
136 /// that stored `req.qos` instead would size its entry from a depth the
137 /// backend never saw, and nothing would fail — the subscription would
138 /// simply hold a different number of samples than the build reserved for
139 /// it.
140 pub(crate) qos: QoSProfile,
141}
142
143// ============================================================================
144// Phase 8 — callback registration event (paired stubs)
145// ============================================================================
146//
147// `docs/design/callback_tracing.rst`. Paired stubs (the `entry_tiers.rs`
148// idiom) so `emplace_entry` reads identically whether or not the feature is
149// on, and the `#[cfg]` lives in exactly one place.
150
151/// Emit `nros_callback_register(handle, kind, name)` for a newly installed
152/// executor entry.
153#[cfg(feature = "trace-callbacks")]
154#[inline]
155fn trace_register(slot: usize, kind: EntryKind, name: TraceName<'_>) {
156 super::callback_trace::register(slot, kind, name);
157}
158
159#[cfg(not(feature = "trace-callbacks"))]
160#[inline]
161fn trace_register(_slot: usize, _kind: EntryKind, _name: TraceName<'_>) {}
162
163// ============================================================================
164// Executor::open() factory method
165// ============================================================================
166
167/// phase-271 / phase-392 W6 — the `'static` `u64` backing the `alloc`
168/// convenience constructors borrow, sized to `sizing`.
169///
170/// **The named static first, the heap second.** phase-271 made this a
171/// `Box::leak`, and a leak has no symbol: `mem-report` reads an ELF's symbol
172/// table, so the largest single RAM consumer of every Rust image was the one
173/// thing the memory instrument could not see (RFC-0002 § 4.4b). It is served
174/// from `executor::backing::EXECUTOR_BACKING` now — same bytes, same
175/// executor-lifetime reservation, in `.bss` where the linker can print them and
176/// out of the allocator arena that used to pay for them.
177///
178/// The heap arm is still reached, and both cases are legitimate rather than
179/// degraded: a SECOND executor opened through an `alloc` convenience
180/// constructor, and an entry sized past the build-time default. A tiered boot's
181/// spawned tiers are NOT that second executor any more: they open over the
182/// entry's `TierExecutorBacking` slots (`open_with_session_slot`, issue 1571). Neither is an error, so neither
183/// warns — `report_arena_headroom` is where an over-provisioned executor gets
184/// told about itself, and a duplicate line here would just be noise.
185///
186/// `alloc`-only — no_std-no-alloc entries supply their own `static`/stack
187/// backing via `open_in` / `from_session_in`.
188#[cfg(feature = "alloc")]
189fn default_backing(sizing: super::storage::ExecutorSizing) -> &'static mut [MaybeUninit<u64>] {
190 let words = sizing.u64_len();
191 match super::backing::take(words) {
192 Some(backing) => backing,
193 None => alloc::boxed::Box::leak(alloc::boxed::Box::new_uninit_slice(words)),
194 }
195}
196
197#[cfg(feature = "rmw-cffi")]
198impl<'s> Executor<'s> {
199 /// phase-271 — open a new executor session over caller-supplied `backing`,
200 /// sized by `sizing` (per-entry sizing). The core, non-generic sized entry
201 /// point: the `alloc` [`open`](Self::open) convenience leaks a default
202 /// backing and delegates here, and the `nros::main!` macro emits a backing
203 /// sized to the entry's own entity count.
204 ///
205 /// Phase 115.M.4 — auto-registers the cffi vtable for whichever
206 /// backend the build was configured for, mirroring the C++ side's
207 /// `#ifdef NROS_RMW_<NAME>` fan-out in `<rclcpp/node.hpp>`. The
208 /// runtime's atomic vtable slot is idempotent: a re-call of any
209 /// backend's `register()` is a no-op, so the fan-out below is safe
210 /// to invoke on every `Executor::open` (cheaper than a `Once` and
211 /// doesn't pull in `std::sync` for no_std targets).
212 ///
213 /// Connects to the middleware at the locator specified in `config`.
214 ///
215 /// # Safety
216 /// `backing` must be ≥ `sizing.u64_len()` words, live for `'s`, and be
217 /// otherwise untouched while the executor lives (see
218 /// [`from_session_in`](Self::from_session_in)).
219 pub unsafe fn open_in(
220 config: &ExecutorConfig<'_>,
221 backing: &'s mut [MaybeUninit<u64>],
222 sizing: super::storage::ExecutorSizing,
223 ) -> Result<Self, NodeError> {
224 use nros_rmw::Rmw;
225
226 // Phase 128.A.3 / 249 P4b.1 — manifest-driven backend selection.
227 //
228 // Every linked backend self-registered via its `.init_array`
229 // ctor before `main` (RFC-0042 §D3.3), so the registry is
230 // already populated — no runtime section walk.
231 //
232 // 1. Honour `config.rmw` — the caller's explicit backend selection,
233 // mirroring ROS 2's `RMW_IMPLEMENTATION`. On a hosted build
234 // `nros::ExecutorConfigEnvExt::from_env` / `nros::env::resolve_hosted`
235 // fill it from `$NROS_RMW`; issue 0687 moved that read to the edge,
236 // because reading a process environment is what kept `std` in this
237 // crate and an RTOS image has no environment to read.
238 // 2. With no selector, pick the unique registered backend.
239 // Zero registered → `NoBackend`; more than one →
240 // `Ambiguous` (user must select one, or use
241 // `Executor::open_multi`).
242 let selector = config.rmw;
243 match nros_rmw_cffi::resolve_backend(selector.map(str::as_bytes)) {
244 nros_rmw_cffi::BackendResolution::Single(_) => {}
245 // Issue 0436 — these are SELECTION outcomes, not transport failures,
246 // and calling them `ConnectionFailed` actively misleads: a PX4 bridge
247 // that registered two backends reported "connection failed", which
248 // reads as a router/network problem and was chased as one. Nothing is
249 // connected at this point — the resolver has not chosen a backend yet.
250 //
251 // The variant still collapses into `NodeError::Transport` (the enum has
252 // no selection arm, and adding one is an ABI change across the C/C++
253 // seams), but `InvalidConfig` at least says "your configuration is
254 // unresolvable", and the std-gated line below names WHICH outcome.
255 other => {
256 {
257 let why: &str = match other {
258 nros_rmw_cffi::BackendResolution::NoBackend => {
259 "no RMW backend is registered"
260 }
261 nros_rmw_cffi::BackendResolution::Ambiguous => {
262 "more than one RMW backend is registered and no \
263 $NROS_RMW selector was set — name one (e.g. \
264 NROS_RMW=uorb), or open per-backend sessions"
265 }
266 nros_rmw_cffi::BackendResolution::Unknown => {
267 "$NROS_RMW names a backend that is not registered"
268 }
269 nros_rmw_cffi::BackendResolution::Single(_) => unreachable!(),
270 };
271 nros_log::log_error!(
272 nros_log::get_logger("nros"),
273 "cannot select an RMW backend — {why}"
274 );
275 }
276 let _ = other;
277 return Err(NodeError::Transport(TransportError::InvalidConfig));
278 }
279 }
280
281 let rmw_config = nros_rmw::RmwConfig {
282 locator: config.locator,
283 mode: config.mode,
284 domain_id: config.domain_id,
285 node_name: config.node_name,
286 namespace: config.namespace,
287 properties: &[],
288 };
289 let session = if let Some(name) = selector {
290 // Selector path: route to the specific named backend so
291 // the env-var-disambiguated outcome matches what the
292 // resolver above identified.
293 nros_rmw_cffi::CffiRmw::open_with_rmw(name, &rmw_config)
294 } else {
295 nros_rmw_cffi::CffiRmw.open(&rmw_config)
296 }
297 .map_err(|e| {
298 // Issue 0465 — do not relabel. This used to discard the backend's
299 // error and report `ConnectionFailed` for every open failure, so an
300 // exhausted session pool (`InvalidConfig`) and a router that is not
301 // there produced the same sentence. Same lesson as the selection
302 // arm above: say which failure happened.
303 nros_log::log_error!(
304 nros_log::get_logger("nros"),
305 "RMW session open failed — {e:?}"
306 );
307 NodeError::Transport(e)
308 })?;
309 // SAFETY: forwarded from this fn's contract — `backing`/`sizing` sized
310 // + alive for `'s`.
311 let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
312 {
313 // `config.clock_us` overrides the constructor's platform default
314 // (issue: assigning `None` here clobbered it and re-enabled the
315 // credit-the-requested-timeout fallback).
316 if let Some(clock) = config.clock_us {
317 executor.clock_us_fn = Some(clock);
318 executor.last_spin_end_us = Some(clock());
319 }
320 // issue 0671 — the SAME rule as `clock_us` directly above, which is
321 // why that one is guarded: a config that does not SPECIFY an epoch
322 // must not be read as "this target HAS no epoch". Assigning `None`
323 // here clobbered the constructor's platform default
324 // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
325 // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
326 // so EVERY hosted node built that way silently lost its wall clock.
327 // With no epoch, `Node::subscription` never attaches the age cell
328 // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
329 // `max_age_ms` contract becomes a silently-dead monitor — the exact
330 // outcome RFC-0052 says must never happen. The rate monitor rides
331 // the GUARDED `clock_us_fn` and kept working, which is why only the
332 // age half went quiet.
333 if let Some(epoch) = config.epoch_us {
334 executor.epoch_us_fn = Some(epoch);
335 }
336 }
337 executor.set_node_identity(config.node_name, config.namespace);
338 // Issue 0656 — beside the identity, for the same reason: an entity needs
339 // both to be addressable, and this half used to be dropped here.
340 executor.domain_id = config.domain_id;
341 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
342 executor.install_wake_signal_on_primary();
343 // Phase 277 W2.c — readiness marker for E2E harnesses. This is the
344 // single call-through `open()`/`open_sized()` share, so it fires on
345 // every platform that reaches here (native, freertos, zephyr,
346 // threadx, …) regardless of which RMW backend or board owns the
347 // boot path. It replaces the per-example synthetic
348 // `log::info!("Publishing messages")` markers W4 removes — those
349 // only proved a callback had fired at least once; this line proves
350 // the session itself is up, before any node/callback exists.
351 //
352 // STABILITY CONTRACT: the leading `"nros: session open"` text is
353 // load-bearing — test harnesses grep for it verbatim. Keep it
354 // stable even if the trailing `(rmw=...)` detail changes.
355 #[cfg(feature = "log")]
356 {
357 if let Some(name) = selector {
358 log::info!("nros: session open (rmw={name})");
359 } else {
360 log::info!("nros: session open");
361 }
362 }
363 Ok(executor)
364 }
365}
366
367#[cfg(all(feature = "rmw-cffi", feature = "alloc"))]
368impl Executor<'static> {
369 /// Open a new executor session using the active RMW backend, at the
370 /// build-time default sizing. Convenience over
371 /// [`open_in`](Self::open_in): leaks a default-sized backing (executor-
372 /// lifetime) so existing callers keep the zero-storage-arg signature.
373 /// Per-entry sizing goes through `open_in` / the `nros::main!` macro.
374 ///
375 /// # Example
376 ///
377 /// ```ignore
378 /// let config = ExecutorConfig::from_env().node_name("my_node");
379 /// let mut executor = Executor::open(&config)?;
380 /// ```
381 pub fn open(config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
382 Self::open_sized(config, super::storage::ExecutorSizing::DEFAULT)
383 }
384
385 /// phase-271 — like [`open`](Self::open) but sized to a caller-supplied
386 /// `sizing` (its own declared topology) instead of the build-time default.
387 /// The `alloc` entry point the `nros::main!` macro's native board path uses
388 /// to size a fat entry (>default `MAX_CBS` callbacks) without a
389 /// workspace-global `NROS_EXECUTOR_MAX_CBS`. Leaks a `sizing`-sized backing
390 /// (executor-lifetime); no-alloc entries use `open_in` with their own static.
391 pub fn open_sized(
392 config: &ExecutorConfig<'_>,
393 sizing: super::storage::ExecutorSizing,
394 ) -> Result<Self, NodeError> {
395 // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`,
396 // uniquely owned by the returned executor.
397 unsafe { Self::open_in(config, default_backing(sizing), sizing) }
398 }
399
400 /// Phase 128.F.1 — explicit per-backend session declaration for
401 /// bridge mode. `specs[0]` becomes the primary session; `specs[1..]`
402 /// open as extras keyed by RMW name. After construction, every
403 /// `create_node_on(name, rmw)` call dispatches to whichever
404 /// session was opened under that RMW name (or, when the rmw name
405 /// matches the primary, the primary session itself).
406 ///
407 /// Single-backend callers should keep using
408 /// [`open`](Self::open) — this entry costs an extra
409 /// `open_with_rmw` per spec and adds no value when only one
410 /// backend is linked.
411 ///
412 /// `$NROS_RMW` env is ignored: bridge mode wants explicit names.
413 ///
414 /// Default-sized `alloc` convenience over
415 /// [`open_multi_in`](Self::open_multi_in) (leaks a default backing).
416 #[cfg(feature = "rmw-cffi")]
417 pub fn open_multi(specs: &[SessionSpec<'_>]) -> Result<Self, NodeError> {
418 let sizing = super::storage::ExecutorSizing::DEFAULT;
419 // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`.
420 unsafe { Self::open_multi_in(specs, default_backing(sizing), sizing) }
421 }
422
423 /// Phase 104.C.1 — open the Executor against a specific RMW
424 /// backend by name. Selects from the named registry (Phase
425 /// 104.B.2). `rmw_name` must match one of the names a backend
426 /// registered under (`"zenoh"`, `"cyclonedds"`, `"xrce"`, …).
427 ///
428 /// Equivalent to [`Executor::open`] when the registry has exactly
429 /// one backend (the default-backend fast path). Use this entry
430 /// point in multi-backend builds where `Executor::open` would
431 /// pick the first-registered slot.
432 ///
433 /// Single-Executor multi-Node multi-RMW (the long-term Design X
434 /// from `docs/roadmap/phase-104-multi-backend-bridges.md`) is
435 /// follow-up work — Phase 104.C.2 + C.3.
436 ///
437 /// Default-sized `alloc` convenience over
438 /// [`open_with_rmw_in`](Self::open_with_rmw_in) (leaks a default backing).
439 #[cfg(feature = "rmw-cffi")]
440 pub fn open_with_rmw(rmw_name: &str, config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
441 let sizing = super::storage::ExecutorSizing::DEFAULT;
442 // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`.
443 unsafe { Self::open_with_rmw_in(rmw_name, config, default_backing(sizing), sizing) }
444 }
445}
446
447// phase-271 — no-alloc sized cores for the bridge/named open paths. In
448// `impl<'s>` (not the `'static` alloc block) so they stay available in
449// `rmw-cffi`-without-`alloc` builds (e.g. the `nros-bridge` no_std default),
450// which is where `open_multi`/`open_with_rmw` lived before.
451#[cfg(feature = "rmw-cffi")]
452impl<'s> Executor<'s> {
453 /// Per-entry-sized [`open_multi`](Self::open_multi): carves `backing` for
454 /// the executor's tables instead of leaking a default one.
455 ///
456 /// # Safety
457 /// `backing`/`sizing` as in [`from_session_in`](Self::from_session_in).
458 pub unsafe fn open_multi_in(
459 specs: &[SessionSpec<'_>],
460 backing: &'s mut [MaybeUninit<u64>],
461 sizing: super::storage::ExecutorSizing,
462 ) -> Result<Self, NodeError> {
463 // Phase 249 P4b.1 — backends self-registered via their
464 // `.init_array` ctor before `main`; no runtime section walk.
465 let primary = specs
466 .first()
467 .ok_or(NodeError::Transport(TransportError::ConnectionFailed))?;
468 let primary_session =
469 nros_rmw_cffi::CffiRmw::open_with_rmw(primary.rmw, &primary.to_rmw_config())
470 .map_err(NodeError::Transport)?;
471 // SAFETY: forwarded from this fn's contract.
472 let mut executor = unsafe { Self::from_session_in(primary_session, backing, sizing) };
473 executor.set_node_identity("", "/");
474 // Phase 156 — see `Executor::open` for primary-identity
475 // recording rationale.
476 let _ = executor.primary_rmw_name.push_str(primary.rmw);
477 let _ = executor.primary_locator.push_str(primary.locator);
478 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
479 executor.install_wake_signal_on_primary();
480
481 for spec in specs.iter().skip(1) {
482 let session = nros_rmw_cffi::CffiRmw::open_with_rmw(spec.rmw, &spec.to_rmw_config())
483 .map_err(NodeError::Transport)?;
484 executor
485 .extra_sessions
486 .push(session)
487 .map_err(|_| NodeError::NodeTableFull)?;
488 // Issue 0436 — record WHICH backend/locator this extra is, so
489 // `NodeBuilder::rmw(name)` can find it instead of opening a second
490 // session against the same (singleton) backend.
491 {
492 let mut rmw_s = heapless::String::<32>::new();
493 let _ = rmw_s.push_str(spec.rmw);
494 let mut loc_s = heapless::String::<128>::new();
495 let _ = loc_s.push_str(spec.locator);
496 let _ = executor.extra_session_ids.push((rmw_s, loc_s));
497 }
498 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
499 {
500 let idx = executor.extra_sessions.len() - 1;
501 executor.install_wake_signal_on_extra(idx);
502 }
503 }
504
505 Ok(executor)
506 }
507
508 /// Per-entry-sized [`open_with_rmw`](Self::open_with_rmw): carves `backing`
509 /// for the executor's tables instead of leaking a default one.
510 ///
511 /// # Safety
512 /// `backing`/`sizing` as in [`from_session_in`](Self::from_session_in).
513 pub unsafe fn open_with_rmw_in(
514 rmw_name: &str,
515 config: &ExecutorConfig<'_>,
516 backing: &'s mut [MaybeUninit<u64>],
517 sizing: super::storage::ExecutorSizing,
518 ) -> Result<Self, NodeError> {
519 if !nros_rmw_cffi::backend_registered() {
520 return Err(NodeError::Transport(TransportError::ConnectionFailed));
521 }
522
523 let rmw_config = nros_rmw::RmwConfig {
524 locator: config.locator,
525 mode: config.mode,
526 domain_id: config.domain_id,
527 node_name: config.node_name,
528 namespace: config.namespace,
529 properties: &[],
530 };
531 let session = nros_rmw_cffi::CffiRmw::open_with_rmw(rmw_name, &rmw_config)
532 .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed))?;
533 // SAFETY: forwarded from this fn's contract.
534 let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
535 {
536 // `config.clock_us` overrides the constructor's platform default
537 // (issue: assigning `None` here clobbered it and re-enabled the
538 // credit-the-requested-timeout fallback).
539 if let Some(clock) = config.clock_us {
540 executor.clock_us_fn = Some(clock);
541 executor.last_spin_end_us = Some(clock());
542 }
543 // issue 0671 — the SAME rule as `clock_us` directly above, which is
544 // why that one is guarded: a config that does not SPECIFY an epoch
545 // must not be read as "this target HAS no epoch". Assigning `None`
546 // here clobbered the constructor's platform default
547 // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
548 // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
549 // so EVERY hosted node built that way silently lost its wall clock.
550 // With no epoch, `Node::subscription` never attaches the age cell
551 // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
552 // `max_age_ms` contract becomes a silently-dead monitor — the exact
553 // outcome RFC-0052 says must never happen. The rate monitor rides
554 // the GUARDED `clock_us_fn` and kept working, which is why only the
555 // age half went quiet.
556 if let Some(epoch) = config.epoch_us {
557 executor.epoch_us_fn = Some(epoch);
558 }
559 }
560 executor.set_node_identity(config.node_name, config.namespace);
561 // Phase 156 — record primary identity for the session-
562 // cache hit path. See `Executor::open` for the rationale.
563 let _ = executor.primary_rmw_name.push_str(rmw_name);
564 let _ = executor.primary_locator.push_str(config.locator);
565 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
566 executor.install_wake_signal_on_primary();
567 Ok(executor)
568 }
569}
570
571/// Phase 128.F.1 — per-backend session declaration for
572/// [`Executor::open_multi`]. Each spec names an RMW backend (must
573/// match one a backend registered under via
574/// `nros_rmw_cffi_register_named` / the `RMW_INIT_ENTRIES` linker
575/// section) and the locator + domain id to open against it.
576#[cfg(feature = "rmw-cffi")]
577#[derive(Clone, Copy)]
578pub struct SessionSpec<'cfg> {
579 pub rmw: &'cfg str,
580 pub locator: &'cfg str,
581 pub domain_id: u32,
582 pub node_name: &'cfg str,
583 pub namespace: &'cfg str,
584}
585
586#[cfg(feature = "rmw-cffi")]
587impl<'cfg> SessionSpec<'cfg> {
588 /// Minimal spec — just RMW name + locator. Domain id defaults to
589 /// 0; node name and namespace are empty.
590 pub const fn new(rmw: &'cfg str, locator: &'cfg str) -> Self {
591 Self {
592 rmw,
593 locator,
594 domain_id: 0,
595 node_name: "",
596 namespace: "",
597 }
598 }
599
600 pub const fn domain_id(mut self, domain_id: u32) -> Self {
601 self.domain_id = domain_id;
602 self
603 }
604
605 pub const fn node_name(mut self, name: &'cfg str) -> Self {
606 self.node_name = name;
607 self
608 }
609
610 pub const fn namespace(mut self, ns: &'cfg str) -> Self {
611 self.namespace = ns;
612 self
613 }
614
615 fn to_rmw_config(self) -> nros_rmw::RmwConfig<'cfg> {
616 nros_rmw::RmwConfig {
617 locator: self.locator,
618 mode: nros_rmw::SessionMode::Client,
619 domain_id: self.domain_id,
620 node_name: self.node_name,
621 namespace: self.namespace,
622 properties: &[],
623 }
624 }
625}
626
627// Phase 128.A.3 — selector for the single-backend resolution path.
628//
629// On hosted (`std`) builds, read `$NROS_RMW`; mirrors ROS 2's
630// `RMW_IMPLEMENTATION`. Returns the name as a byte vector so the
631// caller can pass it to `nros_rmw_cffi::resolve_backend` and (when
632// `Some`) to `CffiRmw::open_with_rmw`.
633//
634// On `no_std` / bare-metal builds, environment variables are not
635// available; resolution always falls through to the single-backend
636// or ambiguous path. Embedded users with multiple backends use the
637// bridge surface `Executor::open_multi` instead.
638// phase-359 W10 / issue 0687 — the private reader that stood here is gone, and
639// so is the shared one that briefly replaced it. `ExecutorConfig::rmw` carries
640// the selection instead: `nros::rmw_selector` reads the variable ONCE, at the
641// hosted edge, and every consumer takes the value.
642
643// ============================================================================
644// SessionStore — owned or borrowed session
645// ============================================================================
646
647/// Session storage: owned or borrowed via raw pointer.
648///
649/// The C API creates a session in `rclc_support_init()` before the
650/// executor. `Borrowed` lets the executor use that session without owning it.
651#[allow(clippy::large_enum_variant)]
652pub(crate) enum SessionStore {
653 Owned(session::ConcreteSession),
654 Borrowed(*mut session::ConcreteSession),
655}
656
657impl core::ops::Deref for SessionStore {
658 type Target = session::ConcreteSession;
659 fn deref(&self) -> &session::ConcreteSession {
660 match self {
661 SessionStore::Owned(s) => s,
662 SessionStore::Borrowed(ptr) => unsafe { &**ptr },
663 }
664 }
665}
666
667impl core::ops::DerefMut for SessionStore {
668 fn deref_mut(&mut self) -> &mut session::ConcreteSession {
669 match self {
670 SessionStore::Owned(s) => s,
671 SessionStore::Borrowed(ptr) => unsafe { &mut **ptr },
672 }
673 }
674}
675
676/// Phase 228.E — an opaque, `Send` handle to an [`Executor`]'s RMW session.
677///
678/// In the per-tier model the boot executor opens the one session and hands each
679/// spawned tier task a handle (not a borrow) so the task opens its own
680/// [`Executor`] over that *same* session across the RTOS task boundary. Wrapping
681/// the `pub(crate)` session pointer lets board crates (`nros-board-linux`,
682/// `nros-board-freertos`, …) name + move the handle without naming the session
683/// type. Obtain via [`Executor::session_handle`]; consume via
684/// [`Executor::open_with_session_handle`].
685#[cfg(any(has_rmw, test))]
686pub struct SessionHandle(*mut session::ConcreteSession);
687
688// SAFETY: the per-tier model deliberately shares one session across RTOS tasks;
689// concurrent access is serialized by the RMW backend's internal locks (the RTOS
690// targets build zenoh-pico `Z_FEATURE_MULTI_THREAD=1` — RFC-0032 §5.0). The
691// boot executor owns the session and outlives every tier task.
692#[cfg(any(has_rmw, test))]
693unsafe impl Send for SessionHandle {}
694
695#[cfg(any(has_rmw, test))]
696impl SessionHandle {
697 /// Phase 274.W1 — convert to an opaque `*mut c_void` for C/C++ FFI.
698 ///
699 /// The returned pointer encodes the session address and is valid as long as
700 /// the owning executor lives. Reconstruct via [`Self::from_raw`].
701 pub fn into_raw(self) -> *mut core::ffi::c_void {
702 self.0 as *mut core::ffi::c_void
703 }
704
705 /// Phase 274.W1 — reconstruct a `SessionHandle` from an opaque pointer
706 /// returned by [`Self::into_raw`].
707 ///
708 /// # Safety
709 /// `ptr` must be a pointer obtained from `into_raw()` on a `SessionHandle`
710 /// whose underlying session is still live and owned by its original executor.
711 pub unsafe fn from_raw(ptr: *mut core::ffi::c_void) -> Self {
712 Self(ptr as *mut session::ConcreteSession)
713 }
714}
715
716/// Phase 228.C — pure callback-group filter decision. `None` = wildcard (accept
717/// every group); `Some` = accept only listed groups. Backs
718/// [`Executor::group_active`]; split out so the logic is unit-testable without a
719/// live session.
720pub(crate) fn group_filter_accepts(
721 active: Option<&[super::storage::ActiveGroup]>,
722 node: u64,
723 group: &str,
724) -> bool {
725 match active {
726 None => true,
727 Some(v) => v.iter().any(|(n, g)| *n == node && g.as_str() == group),
728 }
729}
730
731/// Bytes the parameter store asks the platform heap for, in ONE allocation.
732///
733/// Issue 1706 -- the size of THIS build's store, which folds every knob that
734/// shapes it (`MAX_PARAMETERS`, which a contract's `params:` derives, and the
735/// board's string/array capacities). No formula and no measured constant: the
736/// compiler's own answer, so the number reported below cannot drift from the
737/// allocation it describes.
738#[cfg(all(feature = "param-store", feature = "alloc"))]
739pub const PARAMETER_STORE_BYTES: usize = core::mem::size_of::<nros_params::ParameterStorage>();
740
741/// Issue 1706 -- a TEST seam standing in for a platform heap that returns NULL
742/// for the store's request, on the current thread only (tests run in
743/// parallel). Not a test `#[global_allocator]`: `check-feature-contract`
744/// clause (e) allows exactly one, `nros-platform`'s.
745#[cfg(all(test, feature = "std", feature = "param-store"))]
746pub(crate) struct RefuseParameterStore;
747
748#[cfg(all(test, feature = "std", feature = "param-store"))]
749std::thread_local! {
750 static REFUSE_PARAMETER_STORE: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
751}
752
753#[cfg(all(test, feature = "std", feature = "param-store"))]
754impl RefuseParameterStore {
755 // Only `executor::tests` arms it, and that module is mock-session only.
756 #[cfg(not(feature = "rmw-cffi"))]
757 pub(crate) fn arm() -> Self {
758 REFUSE_PARAMETER_STORE.with(|c| c.set(true));
759 Self
760 }
761
762 fn armed() -> bool {
763 REFUSE_PARAMETER_STORE.with(core::cell::Cell::get)
764 }
765}
766
767#[cfg(all(test, feature = "std", feature = "param-store"))]
768impl Drop for RefuseParameterStore {
769 fn drop(&mut self) {
770 REFUSE_PARAMETER_STORE.with(|c| c.set(false));
771 }
772}
773
774/// Issue 1706 -- say, once, that the parameter store did not fit.
775///
776/// The RFC-0100 D11 shape, one layer up from Cyclone's boot check: the
777/// failure is named where it is decided, with the number a reader needs to
778/// size the heap, instead of surfacing as an anonymous halt in the allocator.
779/// What it can name is the REQUEST; the heap's own report (Zephyr's `HEAP
780/// EXHAUSTED` line, the board's `nros: heap peak` line) carries the capacity.
781#[cfg(feature = "param-store")]
782#[cold]
783fn report_parameter_store_refused() {
784 // Issue 1812 -- with no heap there was no allocation to refuse: the
785 // backing carved no store, and that is the whole story.
786 #[cfg(not(feature = "alloc"))]
787 {
788 let logger = nros_log::get_logger("nros");
789 nros_log::log_error!(
790 logger,
791 "parameter store refused: this executor's backing carves none and the build has no heap"
792 );
793 nros_log::log_error!(
794 logger,
795 "every parameter declaration on this executor is refused (issue 1812)"
796 );
797 nros_log::log_error!(
798 logger,
799 "open it with ExecutorSizing::params > 0 (NROS_PARAM_STORE=1, NROS_MAX_PARAMETERS)"
800 );
801 }
802 #[cfg(feature = "alloc")]
803 report_parameter_heap_store_refused();
804}
805
806/// The `alloc` half of [`report_parameter_store_refused`]: the heap refused
807/// the store's one allocation (issue 1706).
808#[cfg(all(feature = "param-store", feature = "alloc"))]
809#[cold]
810fn report_parameter_heap_store_refused() {
811 let slots = nros_params::MAX_PARAMETERS;
812 // THREE short lines, not one sentence: a log backend truncates a long
813 // line (Zephyr's cut the first draft at its slot count), and the number
814 // is the one part that must survive.
815 let logger = nros_log::get_logger("nros");
816 nros_log::log_error!(
817 logger,
818 "parameter store refused: needs {} bytes in one allocation ({} slots x {})",
819 PARAMETER_STORE_BYTES,
820 slots,
821 PARAMETER_STORE_BYTES / if slots == 0 { 1 } else { slots }
822 );
823 nros_log::log_error!(
824 logger,
825 "every parameter declaration on this executor is refused (issue 1706)"
826 );
827 // phase-382 W3' -- the remedy that takes the store OUT of the heap comes
828 // first: a build that knows the image builds a store carves it from the
829 // executor backing instead (`nros_params::IMPLIED_STORE_SLOTS`).
830 nros_log::log_error!(
831 logger,
832 "declare the parameters in the contract (params:) or state NROS_PARAM_STORE=1"
833 );
834 nros_log::log_error!(
835 logger,
836 "and the store is carved from the executor backing; or raise the image heap"
837 );
838}
839
840/// `text` typed as `declared`'s type — phase-482 W5. `None` when it does not
841/// parse as that type, or the type takes no override yet (arrays).
842///
843/// A `NotSet` declaration has no type to follow, so the text decides: `true` /
844/// `false`, then an integer, then a double, then a string — the order rcl's
845/// YAML reading gives an unquoted scalar.
846#[cfg(all(feature = "param-store", feature = "alloc"))]
847fn typed_param_override(
848 text: &str,
849 declared: &nros_params::ParameterValue,
850) -> Option<nros_params::ParameterValue> {
851 use nros_params::ParameterValue as V;
852 let boolean = |t: &str| match t {
853 "true" | "True" | "TRUE" => Some(true),
854 "false" | "False" | "FALSE" => Some(false),
855 _ => None,
856 };
857 let string = |t: &str| heapless::String::try_from(t).ok().map(V::String);
858 match declared {
859 V::Bool(_) => boolean(text).map(V::Bool),
860 V::Integer(_) => text.parse::<i64>().ok().map(V::Integer),
861 V::Double(_) => text.parse::<f64>().ok().map(V::Double),
862 V::String(_) => string(text),
863 V::NotSet => boolean(text)
864 .map(V::Bool)
865 .or_else(|| text.parse::<i64>().ok().map(V::Integer))
866 .or_else(|| text.parse::<f64>().ok().map(V::Double))
867 .or_else(|| string(text)),
868 _ => None,
869 }
870}
871
872#[cfg(test)]
873mod group_filter_tests {
874 use super::group_filter_accepts;
875 use crate::executor::storage::{ActiveGroup, node_identity_hash};
876
877 type Group = heapless::String<32>;
878
879 fn entry(node: &str, ns: &str, g: &str) -> ActiveGroup {
880 let mut s = Group::new();
881 s.push_str(g).unwrap();
882 (node_identity_hash(node, ns), s)
883 }
884
885 #[test]
886 fn wildcard_accepts_all() {
887 assert!(group_filter_accepts(
888 None,
889 node_identity_hash("n", "/"),
890 "anything"
891 ));
892 }
893
894 #[test]
895 fn set_accepts_only_listed_groups() {
896 let active = [entry("n", "/", "ctrl")];
897 let n = node_identity_hash("n", "/");
898 assert!(group_filter_accepts(Some(&active[..]), n, "ctrl"));
899 assert!(!group_filter_accepts(Some(&active[..]), n, "telem"));
900 }
901
902 /// issue 1172 — the SAME group id on a DIFFERENT node is not this tier's.
903 ///
904 /// This is the case the whole change exists for: two nodes may legitimately
905 /// declare `ctrl`, pinned to different tiers, and the old filter — keyed on
906 /// the group name alone — admitted both onto whichever tier asked first.
907 #[test]
908 fn the_same_group_on_another_node_is_not_accepted() {
909 let active = [entry("talker", "/", "ctrl")];
910 assert!(group_filter_accepts(
911 Some(&active[..]),
912 node_identity_hash("talker", "/"),
913 "ctrl"
914 ));
915 assert!(
916 !group_filter_accepts(
917 Some(&active[..]),
918 node_identity_hash("listener", "/"),
919 "ctrl"
920 ),
921 "another node's identically-named group must NOT be admitted"
922 );
923 }
924
925 /// The namespace is part of the identity, not decoration: two nodes may
926 /// share a bare name under different namespaces, which is legal in ROS.
927 #[test]
928 fn the_same_name_under_another_namespace_is_a_different_node() {
929 let active = [entry("talker", "/left", "ctrl")];
930 assert!(!group_filter_accepts(
931 Some(&active[..]),
932 node_identity_hash("talker", "/right"),
933 "ctrl"
934 ));
935 }
936
937 /// An empty namespace and `/` are the SAME node. Both ends normalise, and
938 /// if only one did, a filter would never match what it named.
939 #[test]
940 fn an_empty_namespace_normalises_to_root() {
941 assert_eq!(
942 node_identity_hash("talker", ""),
943 node_identity_hash("talker", "/")
944 );
945 }
946
947 /// The separator is load-bearing: without it `("ab","c")` and `("a","bc")`
948 /// would hash alike, and a node name may contain any namespace character.
949 #[test]
950 fn the_name_namespace_split_cannot_be_forged() {
951 assert_ne!(
952 node_identity_hash("ab", "/c"),
953 node_identity_hash("a", "b/c")
954 );
955 }
956
957 /// phase-409 — the wildcard and an EMPTY filter are different answers, and
958 /// the carved table cannot tell them apart on its own (the old
959 /// `Option<Vec>` could). `Executor::active_groups_filtering` is what keeps
960 /// them apart; this pins the distinction at the decision itself.
961 #[test]
962 fn an_empty_filter_is_not_the_wildcard() {
963 let empty: [ActiveGroup; 0] = [];
964 let n = node_identity_hash("n", "/");
965 assert!(!group_filter_accepts(Some(&empty[..]), n, "anything"));
966 assert!(group_filter_accepts(None, n, "anything"));
967 }
968}
969
970// ============================================================================
971// Executor
972// ============================================================================
973
974/// Backend-agnostic executor that owns a session.
975///
976/// Provides `create_node()` for entity creation and `drive_io()` for polling.
977///
978/// # Callback Mode
979///
980/// The executor supports arena-based callback registration via the
981/// `node_mut(id).subscription(t)` builder and
982/// [`register_service()`](Self::register_service), with dispatch via
983/// [`spin_once()`](Self::spin_once). No heap allocation is needed.
984///
985/// The sizes are set via `NROS_EXECUTOR_MAX_CBS` (default 4) and
986/// `NROS_EXECUTOR_ARENA_SIZE` (default 4096) environment variables at build time.
987///
988/// Phase 124.B.2 — opaque context handed to the runtime wake
989/// callback. Backends store the raw pointer + invoke the callback;
990/// the callback decodes back to `&WakeCtx`.
991#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
992pub(crate) struct WakeCtx {
993 pub(crate) flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
994 /// The wake primitive. Phase 130.3 added it beside a `std::sync::Condvar`
995 /// pair and noted that "a future migration to a single primitive flips one
996 /// branch instead of two"; phase-359 W10 is that migration, and this is the
997 /// one that survived.
998 pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
999}
1000
1001/// Phase 124.B.2 — runtime wake callback.
1002///
1003/// RT-context contract:
1004///
1005/// * **Thread-safe**: callable from any thread. The cb is lock-free
1006/// on the cv path — no mutex held during `notify_all`. Lost-wakeup
1007/// is prevented by the waiter checking `wake_flag` under
1008/// `wake_mu` via the `wait_timeout_while` predicate.
1009/// * **NOT async-signal-safe on POSIX**: `pthread_cond_signal`
1010/// isn't on the POSIX async-signal-safe function list. For POSIX
1011/// signal handler wake, use a `signalfd` + select pattern in a
1012/// thread that owns the wake duty — that pattern is Linux-only
1013/// (`signalfd`/`eventfd` are Linux syscalls, not POSIX), which is
1014/// why the `WakeSignalFd` worker is gated `target_os = "linux"`.
1015/// * **RTOS ISR**: per-RTOS platform layer wraps the cv with an
1016/// ISR-safe primitive (`xSemaphoreGiveFromISR`,
1017/// `tx_event_flags_set` from ISR, `k_sem_give` from ISR on
1018/// Zephyr). Backend's ISR caller routes through the platform's
1019/// `signal_from_isr` API instead of this cb directly.
1020/// * **Bounded execution time**: O(1) — atomic store + cv notify.
1021/// No allocation, no contended lock.
1022///
1023/// The cb is the symbol backends invoke from their async wake path
1024/// (datagram arrival, worker-thread enqueue, etc.). It does
1025/// flag-write + condvar-signal in that order, lock-free.
1026#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1027pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb(ctx: *mut core::ffi::c_void) {
1028 if ctx.is_null() {
1029 return;
1030 }
1031 // Phase 141.B.2 — capture T0 at cb entry. No-op when the
1032 // probe feature is off or no cycle reader is installed.
1033 #[cfg(feature = "wake-latency-probe")]
1034 super::wake_probe::on_wake();
1035 // SAFETY: ctx points at a `WakeCtx` owned by an Executor still
1036 // alive at the time of the call. `Executor::clear_wake_signal`
1037 // discharges that obligation — `set_wake_callback(None, _)` on the
1038 // primary and every extra session — from `Executor::close` (before
1039 // the session closes) and from the end of `Executor::drop` (before
1040 // `wake_ctx` is dropped). Issue 1385: until then this comment named
1041 // an "`install_wake_*` teardown path" that did not exist, and
1042 // `set_wake_callback(None` occurred nowhere in the tree but here.
1043 let wake = unsafe { &*(ctx as *const WakeCtx) };
1044 // Lock-free: the `flag` store is SeqCst and therefore happens-before any
1045 // subsequent acquire in the waiter, so a wake cannot be missed even though
1046 // nothing is held here.
1047 wake.flag.store(true, core::sync::atomic::Ordering::SeqCst);
1048 // phase-359 W10 — ONE primitive. This used to signal both a
1049 // `std::sync::Condvar` and (since phase 130.3) the `NodeWake`, "so the cb
1050 // keeps working whichever wait primitive spin_once is using". `spin_once`
1051 // now has only one, so this has only one to signal.
1052 if let Some(nw) = wake.node_wake.as_ref() {
1053 nw.signal();
1054 }
1055}
1056
1057/// phase-436 — the callback a backend holds when several executors share its
1058/// session: signal every member of the [`WakeFanout`](super::wake_fanout)
1059/// whose address is `ctx`. The entry is a `static`, so `ctx` never dangles.
1060#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1061pub(crate) unsafe extern "C" fn nros_rmw_fanout_wake_cb(ctx: *mut core::ffi::c_void) {
1062 if ctx.is_null() {
1063 return;
1064 }
1065 // SAFETY: `ctx` is `WakeFanout::as_ctx` of a `static` pool entry.
1066 let fanout = unsafe { &*(ctx as *const super::wake_fanout::WakeFanout) };
1067 fanout.fire(nros_rmw_runtime_wake_cb);
1068}
1069
1070/// phase-467 Row 8 — the symbol a backend invokes when the ROS GRAPH changes.
1071///
1072/// `rmw_event_callback_t`'s shape, so it fits the ABI slot unchanged:
1073/// `user_data` plus a coalesced count, and no payload. The count is IGNORED on
1074/// purpose — a guard condition is a level, not a queue, so N changes observed
1075/// before the executor spins are one dispatch. That is the same collapse
1076/// `guard_try_process` already performs on a flag several `trigger()`s set.
1077///
1078/// RT-context contract: identical to [`nros_rmw_runtime_wake_cb`] and for the
1079/// same reason — a relaxed-store-plus-wake, O(1), no allocation, no lock. Both
1080/// are called from a transport or worker thread.
1081///
1082/// # Safety
1083///
1084/// `user_data` must be the pointer `Executor::install_graph_change_guard`
1085/// passed: a `GuardCondition` in a box that executor owns. The obligation is
1086/// discharged by `clear_graph_change_signal`, which retires the pointer from
1087/// the backend BEFORE the box is freed, from `close()` and from `drop`. Issue
1088/// 1385 is what the other ordering costs.
1089#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1090pub(crate) unsafe extern "C" fn nros_rmw_runtime_graph_change_cb(
1091 user_data: *const core::ffi::c_void,
1092 _count: usize,
1093) {
1094 if user_data.is_null() {
1095 return;
1096 }
1097 // SAFETY: see this function's `# Safety`.
1098 let guard = unsafe { &*(user_data as *const GuardCondition) };
1099 guard.trigger();
1100}
1101
1102/// Phase 124.B.7.c — Linux signalfd worker.
1103///
1104/// Owns a Linux `eventfd` plus a worker task that `read()`s the
1105/// fd and forwards it as a wake. The eventfd
1106/// write side is async-signal-safe per the kernel contract
1107/// (`write(2)` to an eventfd is permitted from signal handlers),
1108/// closing the gap that `pthread_cond_signal` leaves open on POSIX.
1109///
1110/// Lifecycle:
1111/// * Constructed lazily in `Executor::signal_fd()` on first
1112/// caller request.
1113/// * `Drop` writes a shutdown sentinel + joins the worker.
1114///
1115/// Caller flow (signal handler):
1116/// 1. Get fd via `Executor::signal_fd()` before installing the
1117/// handler.
1118/// 2. Handler does `eventfd_write(fd, 1)` (equivalently,
1119/// `write(fd, &1u64, 8)`).
1120/// 3. Worker thread reads the fd, signals wake_cv. spin_once
1121/// blocked in cv.wait_timeout_while sees flag=true and exits.
1122///
1123/// phase-359 W10 — the worker is a PLATFORM TASK, not a `std::thread`, and it
1124/// forwards through the same [`NodeWake`](super::node_wake::NodeWake) the
1125/// runtime wake callback uses rather than a `std::sync::Condvar`. The eventfd
1126/// itself is still Linux — that is what makes the write async-signal-safe — so
1127/// this stays `target_os = "linux"`; what it no longer is, is std-only.
1128#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1129pub struct WakeSignalFd {
1130 fd: core::ffi::c_int,
1131 ctx: portable_atomic_util::Arc<SignalFdCtx>,
1132 task: Option<nros_platform_api::task::PlatformTask>,
1133}
1134
1135/// What the signalfd worker task needs, reachable through one pointer.
1136#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1137struct SignalFdCtx {
1138 fd: core::ffi::c_int,
1139 shutdown: portable_atomic::AtomicBool,
1140 /// The executor's wake state — the same `WakeCtx` the runtime callback
1141 /// decodes, reached as an address so the context is plainly `Send`.
1142 wake_ctx: usize,
1143}
1144
1145/// Worker entry: read the eventfd, forward as a wake, until shut down.
1146///
1147/// # Safety
1148/// `arg` must point at a live [`SignalFdCtx`] whose `wake_ctx` addresses a
1149/// `WakeCtx` that outlives this task.
1150#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1151unsafe extern "C" fn signal_fd_worker(arg: *mut core::ffi::c_void) -> *mut core::ffi::c_void {
1152 // SAFETY: the spawn site passes `Arc::as_ptr` of a ctx it keeps alive until
1153 // after the join in `Drop`.
1154 let ctx = unsafe { &*(arg as *const SignalFdCtx) };
1155 loop {
1156 let mut buf = [0u8; 8];
1157 // SAFETY: reading 8 bytes from an eventfd into an 8-byte buffer.
1158 let n = unsafe { libc::read(ctx.fd, buf.as_mut_ptr() as *mut core::ffi::c_void, 8) };
1159 if ctx.shutdown.load(portable_atomic::Ordering::Acquire) {
1160 return core::ptr::null_mut();
1161 }
1162 if n <= 0 {
1163 // EINTR / EOF — shutdown was re-checked above, so loop.
1164 continue;
1165 }
1166 // Same effect as `nros_rmw_runtime_wake_cb`, reached directly because
1167 // the shutdown check above plus `Drop`'s join is what guarantees the
1168 // `WakeCtx` is still alive.
1169 // SAFETY: `wake_ctx` addresses the executor's `WakeCtx`, which outlives
1170 // this task by that same guarantee.
1171 unsafe {
1172 let w = &*(ctx.wake_ctx as *const WakeCtx);
1173 w.flag.store(true, portable_atomic::Ordering::SeqCst);
1174 if let Some(wake) = w.node_wake.as_ref() {
1175 wake.signal();
1176 }
1177 }
1178 }
1179}
1180
1181#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1182impl WakeSignalFd {
1183 /// Spawn the worker. `wake_ctx_ptr` is the `*const WakeCtx`
1184 /// produced by `Executor::wake_ctx_ptr` — same value the
1185 /// runtime wake cb decodes.
1186 ///
1187 /// phase-359 W10 — returns `NodeError` rather than `std::io::Error`. The
1188 /// only caller is `Executor::signal_fd`, and the errno detail it used to
1189 /// carry had no consumer: the one failure a caller can act on is "this
1190 /// platform would not give me the worker", which is what
1191 /// `NotInitialized` says — the eventfd and the task are both subsystems
1192 /// this capability requires and neither is guaranteed.
1193 fn new(wake_ctx_ptr: *const WakeCtx) -> Result<Self, NodeError> {
1194 // SAFETY: `eventfd(2)` with a valid flag; returns -1 on failure.
1195 let fd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) };
1196 if fd < 0 {
1197 return Err(NodeError::NotInitialized);
1198 }
1199 let ctx = portable_atomic_util::Arc::new(SignalFdCtx {
1200 fd,
1201 shutdown: portable_atomic::AtomicBool::new(false),
1202 wake_ctx: wake_ctx_ptr as usize,
1203 });
1204 let arg = portable_atomic_util::Arc::as_ptr(&ctx) as *mut core::ffi::c_void;
1205 // SAFETY: `arg` points at a ctx this struct owns and keeps alive until
1206 // after the join in `Drop`.
1207 let Some(task) = (unsafe {
1208 nros_platform_api::task::PlatformTask::spawn(
1209 signal_fd_worker,
1210 arg,
1211 // A read(2) loop and one atomic store — the smallest stack any
1212 // port will honour is plenty.
1213 8192,
1214 c"nros-wakefd".as_ptr(),
1215 )
1216 }) else {
1217 // SAFETY: nothing else holds the fd — the task never started.
1218 unsafe { libc::close(fd) };
1219 return Err(NodeError::NotInitialized);
1220 };
1221 Ok(Self {
1222 fd,
1223 ctx,
1224 task: Some(task),
1225 })
1226 }
1227
1228 /// Returns the writable eventfd. The caller (typically a POSIX
1229 /// signal handler) writes any non-zero 8-byte value to trigger
1230 /// a wake. `write(2)` on an eventfd is async-signal-safe per
1231 /// `eventfd(2)` man page.
1232 pub fn fd(&self) -> core::ffi::c_int {
1233 self.fd
1234 }
1235}
1236
1237#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1238impl Drop for WakeSignalFd {
1239 fn drop(&mut self) {
1240 self.ctx
1241 .shutdown
1242 .store(true, portable_atomic::Ordering::Release);
1243 // Wake the worker so it re-checks shutdown: it is blocked in `read`,
1244 // which only this write can release.
1245 let one: u64 = 1;
1246 // SAFETY: an 8-byte write to our own eventfd.
1247 unsafe {
1248 libc::write(self.fd, &one as *const u64 as *const core::ffi::c_void, 8);
1249 }
1250 if let Some(task) = self.task.take() {
1251 task.join();
1252 }
1253 // SAFETY: the worker has exited, so nothing else touches the fd.
1254 unsafe { libc::close(self.fd) };
1255 }
1256}
1257
1258/// Phase 124.B.7.b — ISR / interrupt-context wake callback.
1259///
1260/// Same semantics as [`nros_rmw_runtime_wake_cb`] but constrained to
1261/// async-signal-safe / ISR-safe primitives.
1262///
1263/// Per-platform routing:
1264///
1265/// * **POSIX (std)**: `pthread_cond_signal` is NOT on the POSIX
1266/// async-signal-safe function list. Calling from a SIGUSR1
1267/// handler is technically UB. Real fix (Phase 124.B.7.c) routes
1268/// via `signalfd`/`eventfd` + a runtime worker thread — Linux
1269/// only, since neither syscall is POSIX; until that lands,
1270/// signal-handler callers MUST use
1271/// `rclc_guard_condition_trigger` from a **separate thread** (not
1272/// from the handler itself), OR set the wake_flag and rely on
1273/// the next poll deadline. This cb currently aliases the regular
1274/// `wake_cb` and is safe only from non-signal-handler ISR-like
1275/// contexts (e.g. timer thread, kernel callback).
1276///
1277/// * **RTOS no_std (Zephyr/FreeRTOS/ThreadX)**: routes through the
1278/// platform-cffi `condvar_signal_from_isr` slot. Each backend
1279/// uses its ISR-safe variant — `xSemaphoreGiveFromISR`,
1280/// `tx_semaphore_put`, `k_condvar_signal`.
1281///
1282/// `ctx` semantics identical to [`nros_rmw_runtime_wake_cb`].
1283#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1284#[allow(dead_code)] // Public exposure pending B.7.c signalfd worker.
1285pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb_from_isr(ctx: *mut core::ffi::c_void) {
1286 // Today: alias regular wake_cb. POSIX signal-handler safety
1287 // pending B.7.c (signalfd worker-thread forward). Documented in
1288 // the contract above so callers know the boundary.
1289 unsafe { nros_rmw_runtime_wake_cb(ctx) };
1290}
1291
1292/// Phase 216 follow-up — per-Node dispatch trampoline registered with
1293/// [`Executor::register_dispatch_slot`].
1294///
1295/// The board-side dispatch task (RTIC `__nros_run` / Embassy
1296/// `__nros_run_task`) dequeues a `nros_platform::SignaledCallback`
1297/// envelope and forwards `(cb_id, ctx_ptr)` into
1298/// [`Executor::dispatch_callback`]; that method linear-scans this
1299/// slot table and invokes every registered `on_callback` with the
1300/// owning Node's per-Node `state` blob. Each Node's
1301/// `__nros_node_<pkg>_on_callback` self-filters on its own
1302/// `CallbackId` tag set, so a slot whose Node doesn't own this
1303/// callback is a cheap no-op string compare.
1304///
1305/// The shape mirrors the per-pkg `__nros_node_<pkg>_on_callback`
1306/// extern "C" trampoline emitted by the `nros::node!()` macro
1307/// (see `packages/core/nros-macros/src/lib.rs` Phase 216.A.5).
1308///
1309/// # Why not `linkme`
1310///
1311/// `linkme::distributed_slice` hangs on bare-metal Cortex-M /
1312/// RISC-V because `cortex_m_rt`'s link script doesn't provide the
1313/// `__start_/__stop_` section anchors in a shape that lets the
1314/// iterator terminate (see
1315/// `packages/rmw/cffi/src/section.rs` Phase 142). Since
1316/// stm32f4 RTIC / Embassy boards are the whole point of Phase 216,
1317/// the registry uses the explicit `register()` pattern from Phase
1318/// 104.A.
1319#[derive(Clone, Copy)]
1320pub struct DispatchSlot {
1321 /// Owning Node's `State` blob — produced by the macro-emitted
1322 /// `i()` and round-tripped through
1323 /// `nros::__private_node_state_into_raw`. Opaque to the
1324 /// executor.
1325 pub state: *mut core::ffi::c_void,
1326 /// Per-Node `extern "C"` trampoline; signature matches the
1327 /// `__nros_node_<pkg>_on_callback` symbol the `nros::node!()`
1328 /// macro emits.
1329 pub on_callback: unsafe extern "C" fn(
1330 state: *mut core::ffi::c_void,
1331 cb_id_ptr: *const u8,
1332 cb_id_len: usize,
1333 ctx: *mut core::ffi::c_void,
1334 ),
1335}
1336
1337// SAFETY: `DispatchSlot` carries two raw pointers (`state` + an
1338// extern "C" fn pointer). The fn pointer is `Send`/`Sync` by
1339// definition; the `state` pointer's `Send`/`Sync` story matches the
1340// owning `Executor` (which is `unsafe impl Send`). Treating the
1341// slot itself as `Send` keeps the existing `Executor` Send impl
1342// intact — see `unsafe impl Send for Executor {}` later in this
1343// file.
1344unsafe impl Send for DispatchSlot {}
1345unsafe impl Sync for DispatchSlot {}
1346
1347/// Phase 258 (Track 2, 2a) — executor-owned component tick slot.
1348///
1349/// The layering-clean half of the W0-B `install` seam's tick fix
1350/// (phase-257 D2). A `nros`-layer `install`/`register_node_borrowed`
1351/// builds an `Arc<ComponentCell>` (the typed/poll-driven component
1352/// state) and enrolls it here via [`Executor::enroll_component`]; the
1353/// executor then drives `tick` on every enrolled slot at the tail of
1354/// each [`spin_once`](Executor::spin_once) — so `install`'d nodes
1355/// (C, C++, **and Rust owned-spin**) tick, closing the
1356/// service-client/action poll gap that the callback-`Arc`-only
1357/// lifetime left open.
1358///
1359/// Like [`DispatchSlot`] the executor only sees raw pointers + `extern
1360/// "C"` fn pointers (no `nros` dep — `nros-node` is the lower layer):
1361///
1362/// * `state` — a *leaked* `Arc<ComponentCell>` (via `Arc::into_raw`),
1363/// re-borrowed by the `nros`-side `tick`/`drop` fns. Unlike a
1364/// pub/sub/timer component (kept alive by the executor's per-entity
1365/// callback `Arc` clones), a poll-only component has no callbacks, so
1366/// the slot must own a clone of the cell — hence the paired `drop`.
1367/// * `tick` — `nros`-side `extern "C"` fn that casts `state` back to
1368/// `&ComponentCell`, casts `exec_ctx` back to `*mut Executor`, and
1369/// runs that one cell's tick (mirrors `ExecutorNodeRuntime::run_ticks`).
1370/// * `drop` — `nros`-side `extern "C"` fn run on `Executor::drop` that
1371/// reconstitutes + drops the leaked `Arc`, so the executor owns the
1372/// cell's lifetime.
1373///
1374/// Kept a SEPARATE registry from [`DispatchSlot`] on purpose: framework
1375/// dispatch (RTIC / Embassy) is interrupt-driven, name-keyed, and has no
1376/// tick/own concern — mixing the two risks that path.
1377#[derive(Clone, Copy)]
1378pub struct ComponentSlot {
1379 /// Leaked `Arc<ComponentCell>` (opaque to the executor). Owned by
1380 /// this slot — dropped via `drop` on `Executor::drop`.
1381 pub state: *mut core::ffi::c_void,
1382 /// `nros`-side tick trampoline: `(state, exec_ctx)` where `exec_ctx`
1383 /// is `*mut Executor`. Drives one component's `tick`.
1384 pub tick: unsafe extern "C" fn(state: *mut core::ffi::c_void, exec_ctx: *mut core::ffi::c_void),
1385 /// `nros`-side drop trampoline: reconstitutes + drops the leaked
1386 /// `Arc<ComponentCell>` at `state`. Run once on `Executor::drop`.
1387 pub drop: unsafe extern "C" fn(state: *mut core::ffi::c_void),
1388}
1389
1390// SAFETY: same story as `DispatchSlot` — two raw pointers + two extern
1391// "C" fn pointers. The `state` pointer's Send/Sync matches the owning
1392// `Executor` (`unsafe impl Send for Executor`); the fn pointers are
1393// Send/Sync by definition.
1394unsafe impl Send for ComponentSlot {}
1395unsafe impl Sync for ComponentSlot {}
1396
1397/// phase-271 — fixed capacity of the per-spin ready-sets (FIFO bitmap + EDF
1398/// heap) and the dispatch loop's upper bound. The executor's callback index is
1399/// carried in a `u64` active-mask (`1u64 << i`), so a callback table can hold at
1400/// most 64 entries regardless of per-entry sizing; the ready-sets are sized to
1401/// this ceiling (stack-transient) so any entry slice up to 64 dispatches
1402/// correctly. `EdfReadySet`'s presence bitmap independently asserts `N <= 64`.
1403pub(crate) const MAX_CALLBACK_SLOTS: usize = 64;
1404
1405/// Issue 1667 — the arena's allocation grain. Every region the arena hands out
1406/// starts on it and is a multiple of it, so a released region is always big
1407/// enough, and aligned enough, to hold its own free-list node.
1408pub(crate) const ARENA_GRAIN: usize = core::mem::align_of::<u64>();
1409
1410/// Issue 1667 — the smallest region the arena hands out: one free-list node
1411/// (`next`, `len`), rounded to the grain.
1412pub(crate) const ARENA_MIN_REGION: usize =
1413 (2 * core::mem::size_of::<usize>()).next_multiple_of(ARENA_GRAIN);
1414
1415/// Issue 1667 — the end of the free list.
1416const ARENA_FREE_NONE: usize = usize::MAX;
1417
1418/// Issue 1496 — one arena region, as an allocation hands it out.
1419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1420pub(crate) struct FreedRegion {
1421 pub(crate) offset: usize,
1422 pub(crate) len: usize,
1423}
1424
1425/// Phase 305 W3 (issue 0255) — upper bound on launch remap rules held by one
1426/// executor (across all its nodes). Launch files carry a handful per node;
1427/// raise here if a plan legitimately outgrows it.
1428pub const MAX_REMAPS: usize = 16;
1429
1430/// Which of [`crate::names::resolve_name_layered`]'s two tiers a stored rule
1431/// belongs to.
1432#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1433pub(crate) enum RemapTier {
1434 /// Projected from the launch file (`declare_remap`, which the generated
1435 /// entry calls). AUTHORITATIVE, scoped to one `(name, namespace)`.
1436 Launch,
1437 /// Parsed from `--ros-args` (`crate::ros_args::install_argv_remaps`).
1438 /// FALLBACK — consulted only when no launch rule matched. Scoped to every
1439 /// node, or to the nodes of one NAME (rcl matches a `node:` prefix
1440 /// against the name alone).
1441 Argv,
1442}
1443
1444/// Phase 305 W3 (issue 0255) — one remap rule. `from`/`to` are stored RAW (as
1445/// written); the lookup in [`Executor::resolve_entity_name_for`] expands both
1446/// sides against the owning node's identity via `crate::names` (exact-FQN
1447/// match, no wildcards).
1448///
1449/// The table holds BOTH tiers, so each rule records which one it belongs to.
1450/// The tier is still decided at resolution, by
1451/// [`crate::names::resolve_name_layered`]'s two parameters, never by where a
1452/// rule sits in this table — `tier_rules` splits the table into those two
1453/// arguments. That is what keeps declaration order from deciding between the
1454/// channels: an argv rule installed before a launch rule still loses to it.
1455pub(crate) struct RemapRule {
1456 /// For [`RemapTier::Launch`]: the owning node. For [`RemapTier::Argv`]:
1457 /// the node name a `name:from:=to` rule was restricted to, or EMPTY for a
1458 /// rule that applies to every node.
1459 pub(crate) node_name: heapless::String<64>,
1460 /// For [`RemapTier::Launch`] only (normalised, `/` for root). Unused by
1461 /// argv rules, whose node prefix carries no namespace.
1462 pub(crate) namespace: heapless::String<64>,
1463 pub(crate) from: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1464 pub(crate) to: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1465 pub(crate) tier: RemapTier,
1466}
1467
1468impl RemapRule {
1469 /// Does this rule belong to `tier` and apply to the node `(node_name, ns)`?
1470 /// `ns` must already be normalised (`/` for root).
1471 fn applies(&self, tier: RemapTier, node_name: &str, ns: &str) -> bool {
1472 self.tier == tier
1473 && match tier {
1474 RemapTier::Launch => {
1475 self.node_name.as_str() == node_name && self.namespace.as_str() == ns
1476 }
1477 RemapTier::Argv => {
1478 self.node_name.is_empty() || self.node_name.as_str() == node_name
1479 }
1480 }
1481 }
1482}
1483
1484/// The rules of one tier that apply to one node, in declaration order, as the
1485/// `(from, to)` pairs [`crate::names::resolve_name_layered`] takes. The ONE
1486/// place a stored table is split into the two tiers — the executor's resolver,
1487/// `NodeHandle`, and the Rust component road (through
1488/// `crate::ros_args::argv_fallback`) all read through it.
1489pub(crate) fn tier_rules<'r>(
1490 table: &'r [Option<RemapRule>],
1491 tier: RemapTier,
1492 node_name: &'r str,
1493 namespace: &'r str,
1494) -> impl Iterator<Item = (&'r str, &'r str)> + 'r {
1495 let ns = if namespace.is_empty() { "/" } else { namespace };
1496 table
1497 .iter()
1498 .flatten()
1499 .filter(move |r| r.applies(tier, node_name, ns))
1500 .map(|r| (r.from.as_str(), r.to.as_str()))
1501}
1502
1503/// The executor's halt flag, in the one shape each configuration can hold.
1504///
1505/// Issue 1177. With an allocator it is refcounted, because `halt_flag()` hands
1506/// a caller its own handle to set from a signal handler or another thread.
1507/// Without one there is no handle to hand out -- a C caller reaches the
1508/// executor through its own pointer and calls `cancel()` directly -- so a plain
1509/// atomic is the whole requirement.
1510///
1511/// `Arc<AtomicBool>` derefs to `AtomicBool`, so every `load`/`store` below is
1512/// written once and only construction and the getter are cfg'd.
1513#[cfg(feature = "alloc")]
1514pub(crate) type HaltFlag = portable_atomic_util::Arc<portable_atomic::AtomicBool>;
1515/// See [`HaltFlag`] above -- the no-allocator shape.
1516#[cfg(not(feature = "alloc"))]
1517pub(crate) type HaltFlag = portable_atomic::AtomicBool;
1518
1519pub struct Executor<'s> {
1520 /// Issue 0656 — the ROS domain this executor's entities belong to.
1521 ///
1522 /// Retained because it was NOT: `open_in` passed `config.domain_id` into
1523 /// `RmwConfig` and dropped it, so every entity built from the executor
1524 /// (rather than from a `Node`, which keeps its own) declared on domain 0
1525 /// whatever `ROS_DOMAIN_ID` said. The value was read, printed, and then not
1526 /// used where it counts — issue 0161's shape.
1527 pub(crate) domain_id: u32,
1528 pub(crate) session: SessionStore,
1529 /// phase-271 (issue 0110) — the six sized tables are no longer inline
1530 /// arrays baked to `nros-node`'s build-time consts; they borrow
1531 /// caller-owned, per-entry-sized storage (`&'s mut` slices carved from a
1532 /// raw `[MaybeUninit<u64>]` backing by [`super::storage::carve`]). Lets a
1533 /// fat native entry and a lean embedded entry in one shared-target
1534 /// workspace each size to its own topology. `Executor` stays non-generic
1535 /// (lifetime only) so the C/C++ FFI keeps wrapping one concrete type.
1536 pub(crate) arena: &'s mut [MaybeUninit<u8>],
1537 pub(crate) arena_used: usize,
1538 /// Issue 1496 / 1667 — the head of the FREE LIST: arena regions given back
1539 /// by a released entry, reused first-fit by the next allocation that fits.
1540 ///
1541 /// The list is INTRUSIVE — each free region holds its own node
1542 /// (`next`, `len`) in its first bytes — and kept in address order, so a
1543 /// release coalesces with both neighbours and nothing bounds how many holes
1544 /// there are. 1496's form was a fixed table of 8 that dropped a region when
1545 /// full and dropped any split tail under 64 B, and those bytes were gone
1546 /// for good: measured under a churning workload (issue 1667), three live
1547 /// entries leaked the whole 74 KiB arena inside 20 000 create/release
1548 /// rounds. A hole that ends at `arena_used` is given back to the bump
1549 /// pointer instead of being listed.
1550 pub(crate) arena_free_head: usize,
1551 /// Issue 1631 — the region the most recent ENTRY allocation handed out,
1552 /// waiting for `emplace_entry` to record it in the entry's
1553 /// `CallbackMeta::arena_len`. Every registration allocates its entry and
1554 /// then emplaces it, so the pair is adjacent; a registration that fails
1555 /// between them leaves a stale value that the next allocation overwrites.
1556 pub(crate) pending_region: Option<FreedRegion>,
1557 pub(crate) entries: &'s mut [Option<CallbackMeta>],
1558 /// Phase 110.B — registered scheduling contexts. Slot 0 is
1559 /// auto-populated with a `Fifo` SC at construction; every entry
1560 /// without an explicit binding maps to it via
1561 /// `sched_context_bindings`.
1562 pub(crate) sched_contexts: &'s mut [Option<super::sched_context::SchedContext>],
1563 /// Per-entry SC binding parallel to `entries`. Defaults to
1564 /// `SchedContextId(0)` (the auto-created Fifo SC).
1565 pub(crate) sched_context_bindings: &'s mut [super::sched_context::SchedContextId],
1566 /// Phase 110.E — user-space sporadic-server budget state per
1567 /// Sporadic-class SC. Slot indices match `sched_contexts`; non-
1568 /// Sporadic slots stay `None`.
1569 pub(crate) sporadic_states: &'s mut [Option<super::sched_context::SporadicState>],
1570 /// Phase 110.E.b — atomic sporadic state + opaque platform-timer
1571 /// handle for ISR-driven refill. Populated by
1572 /// `register_sporadic_timer`; dropped on Executor `Drop` via the
1573 /// stored `destroy_fn`.
1574 #[cfg(feature = "alloc")]
1575 pub(crate) sporadic_atomic_states: &'s mut [Option<(
1576 portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
1577 OpaqueTimerHandle,
1578 )>],
1579 /// Phase 110.G — major-frame length for time-triggered dispatch.
1580 /// `0` (default) disables the TT gate entirely; non-zero enables
1581 /// gating per
1582 /// `SchedContext.tt_window_offset_us / tt_window_duration_us`.
1583 pub(crate) major_frame_us: u32,
1584 /// Phase 110.F — per-OS-priority worker pool. Lazily populated
1585 /// on first dispatch routing to a non-zero `os_pri`.
1586 ///
1587 /// phase-359 W10 — was `std::collections::HashMap<u8, OsPriorityWorker>`
1588 /// behind `feature = "std"`, because the workers were `std::thread` +
1589 /// `mpsc`. They are platform tasks now (`super::os_priority`), so the pool
1590 /// needs only `alloc` and this capability is no longer std-only.
1591 #[cfg(all(
1592 feature = "alloc",
1593 feature = "rmw-cffi",
1594 feature = "scheduler-os-priority"
1595 ))]
1596 pub(crate) os_priority_pool: super::os_priority::OsPriorityPool,
1597 /// Phase 110.F — caller-supplied `apply_policy` function pointer
1598 /// each worker invokes at startup to elevate its OS priority.
1599 /// `None` = the worker pool is disabled; entries bound to non-
1600 /// zero `os_pri` SCs fall back to the cooperative path.
1601 /// Mirrors `Executor::open_threaded`'s `apply_policy: fn(...)`
1602 /// shape — keeps Executor non-generic over Platform.
1603 // phase-359 W10 — the POLICY is separable from the POOL. Registering it
1604 // needs nothing but the feature; hosting workers needs a platform
1605 // (`rmw-cffi`, the same proxy `node_wake` uses). A build that can register
1606 // but not host falls back to cooperative dispatch, so the public
1607 // `register_os_priority_dispatcher` keeps its old availability.
1608 #[cfg(all(feature = "alloc", feature = "scheduler-os-priority"))]
1609 pub(crate) os_priority_apply_policy:
1610 Option<fn(nros_platform_api::SchedPolicy) -> Result<(), nros_platform_api::SchedError>>,
1611 pub(crate) trigger: Trigger,
1612 pub(crate) semantics: ExecutorSemantics,
1613 /// Node name for entities created via `register_subscription`/`register_service`.
1614 /// Empty means unset — no liveliness tokens will be declared.
1615 pub(crate) node_name: heapless::String<64>,
1616 /// Phase 228.C — per-tier callback-group filter. Wildcard (register every
1617 /// callback — the single-tier degenerate case + today's behaviour) until
1618 /// [`set_active_groups`](Self::set_active_groups) names a non-empty set;
1619 /// after that this tier's executor accepts only callbacks whose
1620 /// `.callback_group()` is in it, and skips the others at registration.
1621 ///
1622 /// phase-409 — CARVED, and the wildcard is a separate flag rather than an
1623 /// `Option` around the table, because the table itself no longer lives in
1624 /// the value. "Filtering with an empty set" and "not filtering" stay
1625 /// distinguishable, which is the whole content of the old `Option`.
1626 pub(crate) active_groups: super::storage::CarvedVec<'s, super::storage::ActiveGroup>,
1627 /// Whether [`active_groups`](Self::active_groups) is a filter at all.
1628 /// `false` = wildcard (the old `None`).
1629 pub(crate) active_groups_filtering: bool,
1630 /// Node namespace (default: "/").
1631 pub(crate) namespace: heapless::String<64>,
1632 /// Phase 104.C.2 — rclcpp-style `add_node` table. Holds the
1633 /// per-Node metadata (name, namespace, rmw, locator, default
1634 /// SchedContext) for every Node attached to this Executor. The
1635 /// implicit "primary" Node (NodeId(0)) mirrors `node_name` +
1636 /// `namespace` above and is auto-populated on first use.
1637 ///
1638 /// phase-409 — CARVED. `NodeId` IS an index into this table, so the
1639 /// carved vector must keep push order and never gain a `swap_remove`.
1640 pub(crate) nodes: super::storage::CarvedVec<'s, super::node_record::NodeRecord>,
1641 /// Phase 272 (RFC-0047) — config-seeded node → sched-context bindings, keyed by the node's
1642 /// fully-qualified `(name, namespace)` pair. `NodeBuilder::build` consults this table to set a
1643 /// node's `default_sched` when no explicit `.sched()` was given. Empty ⇒ every node stays
1644 /// `SchedContextId(0)` (byte-identical to pre-272 behaviour). Sized by `MAX_NODES` (at most
1645 /// one tier per node — RFC-0047 OQ1).
1646 ///
1647 /// phase-409 — CARVED.
1648 pub(crate) node_sched_table: super::storage::CarvedVec<'s, super::storage::NodeSchedEntry>,
1649 /// Phase 273 (RFC-0047) — config-seeded per-callback-group sched bindings, keyed by the node's
1650 /// fully-qualified `(name, namespace)` pair PLUS the callback-group name. Overrides the node
1651 /// default for a callback created in that group. Empty ⇒ no per-group binding (node default
1652 /// stands). Sized by `MAX_CBS` — an upper bound on distinct callback-group bindings (you can
1653 /// never have more distinct group bindings than max callbacks).
1654 ///
1655 /// phase-409 (issue 0961) — CARVED, and it is the reason the phase exists:
1656 /// at ~168 B per slot this was `MAX_CBS` * 168 bytes INSIDE the value, so
1657 /// raising the handle limit from 14 to 36 — a fix for an unrelated failure —
1658 /// added ~3.7 KiB to the stack frame of every function that moves an
1659 /// `Executor`, and overflowed a 320 KiB part's main thread.
1660 pub(crate) group_sched_table: super::storage::CarvedVec<'s, super::storage::GroupSchedEntry>,
1661 /// Phase 305 W3 (issue 0255) — launch-baked per-node remap rules, keyed by
1662 /// the declaring node's `(name, namespace)` identity. Entries store the
1663 /// rule RAW (as written in launch); [`Self::resolve_entity_name`] expands
1664 /// both sides against the owning node's identity at lookup, via the shared
1665 /// `crate::names` seam (the same semantics the Rust `ExecutorSink` path
1666 /// applies). Declaration order is match order (first rule wins).
1667 /// Issue 0563 — CARVED, not inline. As a `heapless::Vec<RemapRule,
1668 /// MAX_REMAPS>` this one field was 6664 bytes of an 11632-byte `Executor`
1669 /// (57%), which is what made building an executor a ~9.3 KB stack
1670 /// temporary and overflowed the Zephyr Cortex-M main stack (issue 0552).
1671 /// It is the seventh sized table; phase-271 moved the other six here.
1672 /// `remap_len` is the fill cursor — occupied slots are `[0, remap_len)`.
1673 pub(crate) remap_table: &'s mut [Option<RemapRule>],
1674 pub(crate) remap_len: usize,
1675 /// Phase 216 follow-up — per-Node dispatch trampoline registry.
1676 ///
1677 /// Populated by [`Executor::register_dispatch_slot`]; walked by
1678 /// [`Executor::dispatch_callback`] each time the board-side
1679 /// dispatch task hands off a `SignaledCallback` envelope.
1680 /// Sized by `MAX_NODES` because the upper-bound is one slot per
1681 /// Node pkg deployed on this executor (the same upper bound used
1682 /// by `nodes` and `extra_sessions`). `MAX_NODES` is driven by the
1683 /// `NROS_EXECUTOR_MAX_NODES` build-script env var (default 4);
1684 /// boards that deploy more Node pkgs raise it at build time.
1685 ///
1686 /// Default is `heapless::Vec::new()` (empty) — Nodes register
1687 /// themselves explicitly via the `register_dispatch_slot` API.
1688 /// The fallback shape avoids the `linkme` hazard on bare-metal
1689 /// Cortex-M / RISC-V (see `DispatchSlot` doc).
1690 ///
1691 /// phase-409 — CARVED (sized by `ExecutorSizing::nodes`, which is what
1692 /// `MAX_NODES` now seeds).
1693 pub(crate) dispatch_slots: super::storage::CarvedVec<'s, DispatchSlot>,
1694 /// Phase 258 (Track 2, 2a) — executor-owned component tick registry.
1695 /// Enrolled by [`Executor::enroll_component`] (from `nros`'s
1696 /// `install`/`register_node_borrowed`); each slot's `tick` runs at the
1697 /// tail of [`spin_once`](Self::spin_once); each slot's `drop` runs on
1698 /// `Executor::drop`. Bounded `MAX_NODES` (matches `dispatch_slots` /
1699 /// `nodes`). See [`ComponentSlot`] for why it's separate from
1700 /// `dispatch_slots`.
1701 ///
1702 /// phase-409 — CARVED.
1703 pub(crate) component_slots: super::storage::CarvedVec<'s, ComponentSlot>,
1704 /// Phase 104.C.3 — extra sessions opened by `node_builder.rmw()`
1705 /// calls that named a backend different from the Executor's
1706 /// primary session. Indexed by `NodeRecord.session_idx`
1707 /// (1..=N maps to `extra_sessions[N-1]`; idx 0 is the primary
1708 /// `self.session`). Sized by `NROS_EXECUTOR_MAX_NODES` since one
1709 /// extra session per Node is the worst case.
1710 ///
1711 /// phase-409 — CARVED, and the biggest single win: `ConcreteSession` is
1712 /// 524 B on the island, so this table alone was ~3.1 KiB of the value.
1713 /// Declared AFTER `session` so field-order drop still closes the primary
1714 /// session before the extras (`CarvedVec` owns its elements' drop).
1715 pub(crate) extra_sessions: super::storage::CarvedVec<'s, session::ConcreteSession>,
1716 /// Issue 0436 — `(rmw_name, locator)` for each entry of `extra_sessions`,
1717 /// the extras' equivalent of `primary_rmw_name` / `primary_locator`.
1718 ///
1719 /// Without it an extra session is ANONYMOUS, and
1720 /// `NodeBuilder::resolve_session_slot` could only recognise one by finding a
1721 /// previously-registered `NodeRecord` bound to it. Sessions opened by
1722 /// `open_multi*` have no such Node yet, so the FIRST `.rmw("zenoh")` node fell
1723 /// through to "open a new session" — a SECOND zenoh session, with an empty
1724 /// locator, which fails (see `primary_rmw_name`'s note: zenoh-pico's global
1725 /// state is a process singleton). That is why the PX4 bridge could open both
1726 /// sessions and then fail to bind its outward Node.
1727 ///
1728 /// Written unconditionally by `open_multi*`, but the only READER is
1729 /// `NodeBuilder::resolve_session_slot`, which is `rmw-cffi`-gated — so
1730 /// without that feature the field is genuinely unread and the
1731 /// workspace's `-D dead_code` is right to say so. Allow it exactly
1732 /// there rather than blanket-allowing a field that must stay live in
1733 /// every configuration that can reach the reader.
1734 ///
1735 /// phase-409 — CARVED.
1736 #[cfg_attr(not(feature = "rmw-cffi"), allow(dead_code))]
1737 pub(crate) extra_session_ids: super::storage::CarvedVec<'s, super::storage::ExtraSessionId>,
1738 /// Phase 156 — primary session's rmw name + locator, captured
1739 /// at `open*` time so `NodeBuilder::resolve_session_slot`'s
1740 /// cache lookup can detect when a `.rmw(name).locator(loc)`
1741 /// matches the primary (slot 0) instead of falling through to
1742 /// `CffiRmw::open_with_rmw` and trying to open a SECOND
1743 /// session against the same backend. zenoh-pico's global state
1744 /// is a process singleton; opening twice fails. Empty when
1745 /// constructed via `from_session(_ptr)` without `open*`
1746 /// recording the metadata; in that case the cache check
1747 /// degrades to "always miss" (today's behaviour).
1748 pub(crate) primary_rmw_name: heapless::String<32>,
1749 pub(crate) primary_locator: heapless::String<128>,
1750 // phase-359 W3 — `portable_atomic_util::Arc`, not `std::sync::Arc`. Same
1751 // atomically-refcounted pointer, available without `std`, and it compiles on
1752 // std too. W3 left the GATE on `std` because the public `halt_flag()` getter
1753 // was std-gated; W10 split that impl, so the field joined `wake_flag` on
1754 // `alloc`.
1755 //
1756 // Issue 1177 — the ALLOCATOR was never the requirement of the FLAG, only of
1757 // SHARING it. The flag is an `AtomicBool`; what needs an allocator is
1758 // `halt_flag()`, the getter that hands a caller its own refcounted handle to
1759 // set from a signal handler or another thread. Gating the flag on `alloc`
1760 // therefore gated the ability to STOP a no_std executor on a feature it does
1761 // not need -- and `nros-c`'s spin loop calls `cancel`/`is_halted`/
1762 // `enter_spin_loop`/`exit_spin_loop`/`is_spinning` unconditionally, so an
1763 // embedded image did not merely lose cancellation, it failed to compile.
1764 //
1765 // `HaltFlag` is the one type name that differs, so every reader below is
1766 // written once: `Arc<AtomicBool>` derefs to `AtomicBool`, so `load`/`store`
1767 // are identical on both sides and only construction and the getter are
1768 // cfg'd.
1769 pub(crate) halt_flag: HaltFlag,
1770 /// Is a spin loop running right now? — the observable behind
1771 /// `is_spinning()` (phase-417 W4.c, rclcpp's `Executor::is_spinning`).
1772 ///
1773 /// A plain atomic, not refcounted like `halt_flag`: nothing hands this one
1774 /// out. `halt_flag()` is a public getter that gives a caller its own
1775 /// handle to set from another thread; `spinning` is only ever set by the
1776 /// executor's own loops through `&self`, so it needs no shared ownership.
1777 ///
1778 /// Issue 1177 — this used to ride `alloc` "for the same reason the impl
1779 /// that reads it does". It never needed an allocator itself; it was gated
1780 /// by ASSOCIATION with `halt_flag`, and when that association turned out to
1781 /// be wrong this went with it.
1782 pub(crate) spinning: portable_atomic::AtomicBool,
1783 /// Phase 104.C.6 — shared executor wake flag. Any source of work
1784 /// (foreign thread handing off a callback, signal handler, future
1785 /// per-session vtable wake hook) sets this; `spin_once` swaps it to
1786 /// `false` on entry and, if it was `true`, polls every session with
1787 /// a 0-ms timeout instead of blocking. Lets one notification wake
1788 /// the executor regardless of which session the user is currently
1789 /// blocked on (the multi-RMW bridge case).
1790 // phase-359 W3 — ONE wake flag, both flavours. W2 had to leave this pair
1791 // alone because `wake_handle()` hands it out as a `std::sync::Arc`;
1792 // converting that signature is what lets the two collapse. Gated on
1793 // `alloc` (which `std` implies) because the Arc needs an allocator.
1794 #[cfg(feature = "alloc")]
1795 pub(crate) wake_flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
1796 /// Phase 124.B.2 — wake condvar paired with `wake_flag`. The
1797 /// runtime-supplied wake callback (`nros_rmw_runtime_wake_cb` in
1798 /// nros-rmw-cffi) writes `wake_flag = true` AND signals
1799 /// `wake_cv` atomically under `wake_mu`. `spin_once` blocks on
1800 /// the cv with a deadline instead of calling `drive_io` with the
1801 /// user's timeout — sub-poll-period wake latency.
1802 ///
1803 /// Poll-only backends (NULL `set_wake_callback` slot) leave the
1804 /// cb uninstalled; the cv wait still fires on its deadline,
1805 /// then drive_io(0) drains whatever the backend's internal
1806 /// poll has buffered.
1807 /// Phase 130.3 — Zephyr+std uses `nros_platform_wake_*` (k_sem)
1808 /// instead of `std::sync::Condvar` because Zephyr's libc
1809 /// `pthread_cond_timedwait` hangs past its deadline. `None`
1810 /// when the platform provider didn't link a wake primitive
1811 /// (e.g. test builds with `rmw-cffi` but no `platform-*`
1812 /// feature); spin_once falls back to driving the transport
1813 /// for the full timeout in that case.
1814 // phase-359 W2 — ONE field, not one per flavour. `portable_atomic_util::Arc`
1815 // compiles on std too, and `std` implies `alloc`, so the std and alloc arms
1816 // were two spellings of the same thing. The inner `NodeWake` was already
1817 // shared; only the Arc differed.
1818 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1819 pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
1820 /// Phase 130.4 — true when at least one session's backend
1821 /// installed the wake callback. Drives whether `spin_once`
1822 /// uses the wake-primitive wait (`NodeWake` / `Condvar`) or
1823 /// just `drive_io(timeout_ms)`. Poll-only backends
1824 /// (XRCE-DDS-Client, the dust-DDS shim) leave this `false`;
1825 /// Cyclone sets it — it fills the slot and backs it with a real
1826 /// `dds_set_listener` listener; the wait then becomes a no-op sleep that
1827 /// starves reliable retransmission (Phase 127.C.4 root
1828 /// cause: server's `send_response` flushes 100 ms once, then a
1829 /// blind `wait_ms(100)` sleeps with zero session activity, so
1830 /// the agent's ACK arrives into a stalled session and reliable
1831 /// redelivery never fires).
1832 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1833 pub(crate) has_async_wake: bool,
1834 /// Phase 124.B.2 — opaque context Arc handed to backends via
1835 /// `set_wake_callback`. Lazy-allocated on first install; stays
1836 /// alive for the Executor's lifetime so the raw pointer stored
1837 /// in backends remains valid.
1838 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1839 pub(crate) wake_ctx: Option<portable_atomic_util::Arc<WakeCtx>>,
1840 /// Issue 1385 — has THIS executor handed `wake_ctx`'s pointer to a
1841 /// backend? The predicate `clear_wake_signal` reads at teardown.
1842 ///
1843 /// Not `wake_ctx.is_some()`: the ctx is also allocated by `signal_fd()`
1844 /// and by every guard condition, neither of which installs anything on a
1845 /// session. Clearing on those would clear a callback this executor never
1846 /// owned. (Since phase-436 a tier over a BORROWED session does install: it
1847 /// joins the session's wake fan-out, and its teardown is leaving it.)
1848 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1849 pub(crate) wake_cb_installed: bool,
1850 /// phase-436 (RFC-0106 D5) — something other than the backend can signal
1851 /// this executor's wake object: a guard condition, triggered from another
1852 /// thread. A poll-only backend waits inside its own `recv`, which that
1853 /// signal cannot end, so the wait is driven in slices of
1854 /// [`POLL_ONLY_WAKE_SLICE_MS`] while this is set. Never cleared: a guard
1855 /// outliving its registration costs slicing, never a missed wake.
1856 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1857 pub(crate) has_cross_thread_waker: bool,
1858 /// phase-436 — this executor's place in the wake fan-out of its PRIMARY
1859 /// session, when it has one. Every executor over a session joins the same
1860 /// entry, so an arrival wakes all of them, not only the one that happened
1861 /// to install into the backend's single slot. See `wake_fanout`.
1862 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1863 pub(crate) wake_membership: Option<super::wake_fanout::Membership>,
1864 /// phase-467 Row 8 — the graph-change target the BACKEND holds a pointer
1865 /// to, owned here.
1866 ///
1867 /// This box is the whole answer to issue 1385 on this path. The backend
1868 /// fires the graph-change edge from a transport or worker thread, so
1869 /// whatever it dereferences must outlive every such call — and the thing
1870 /// the caller has in hand is a `rcl_guard_condition_t` in ITS storage,
1871 /// which the caller may free or reuse at any time. Handing that pointer
1872 /// over would BE 1385. So the executor keeps its own handle to the same
1873 /// arena flag, at a heap address that does not move when the executor
1874 /// does, and [`clear_graph_change_signal`] retires the backend's pointer
1875 /// BEFORE this box drops.
1876 ///
1877 /// [`clear_graph_change_signal`]: Self::clear_graph_change_signal
1878 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1879 pub(crate) graph_change_target: Option<alloc::boxed::Box<GuardCondition>>,
1880 /// Phase 124.B.7.c — lazily-allocated Linux signalfd worker.
1881 /// Owned by the Executor; spawned on first `signal_fd()` call.
1882 /// Drop joins the worker thread and closes the fd.
1883 #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1884 pub(crate) signal_fd: Option<WakeSignalFd>,
1885 /// Issue 1812 — CARVED from the backing's [`ParamHome`](super::storage::ParamHome)
1886 /// when the sizing carves a store; with `alloc`, one heap allocation
1887 /// otherwise. Without `alloc` there is no heap arm, and an executor whose
1888 /// backing carves no home refuses parameters by name instead.
1889 #[cfg(feature = "param-store")]
1890 pub(crate) params: Option<crate::param_store::Placed<'s, crate::param_store::ParamState<'s>>>,
1891 /// phase-382 W3' — the parameter store's slots, CARVED from this
1892 /// executor's backing and still uninitialised. Taken (left empty) the
1893 /// first time something builds the store; empty from the start when the
1894 /// sizing carved none, in which case the store comes from the heap.
1895 #[cfg(feature = "param-store")]
1896 pub(crate) param_slots: &'s mut [MaybeUninit<nros_params::ParameterSlot>],
1897 /// Issue 1812 — the services' home, CARVED beside the store's slots and
1898 /// still uninitialised. Taken with them; empty when the sizing carved no
1899 /// store.
1900 #[cfg(feature = "param-store")]
1901 pub(crate) param_home: &'s mut [MaybeUninit<super::storage::ParamHome>],
1902 /// Issue 1706 -- the parameter store's one allocation was refused by the
1903 /// platform heap. Recorded so the refusal is REPORTED once and not retried
1904 /// on every declaration: a retry cannot succeed on a heap that only shrinks
1905 /// from here, and on Zephyr each attempt prints its own `HEAP EXHAUSTED`.
1906 #[cfg(feature = "param-store")]
1907 pub(crate) param_store_refused: bool,
1908 /// phase-446 W6 -- the parameters each node's contract DECLARES, baked by
1909 /// the entry from the SystemModel. Empty (nothing checked) until an entry
1910 /// hands one over with [`Self::set_declared_params`].
1911 #[cfg(feature = "param-store")]
1912 pub(crate) declared_params: nros_params::DeclaredParams,
1913 /// phase-425 W3b — the `/clock` subscription this image installed, if any.
1914 /// `None` means no time source; the value is the handle so it can be
1915 /// cancelled when `use_sim_time` goes false.
1916 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1917 pub(crate) sim_time_source: Option<HandleId>,
1918 /// phase-425 W3b — the LAST REQUESTED state of `use_sim_time`, which is not
1919 /// the same thing as the installed state: a request can arrive before any
1920 /// node exists (`nros::main!` declares parameters BEFORE it registers
1921 /// components), and there is no node to hang a subscription on until then.
1922 /// `reconcile_ros_time_source` closes the gap on each spin.
1923 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1924 pub(crate) sim_time_requested: bool,
1925 /// Whether `use_sim_time` was ever stated to THIS executor.
1926 ///
1927 /// `sim_time_requested` alone cannot answer it: `false` is both "told to
1928 /// turn it off" and "never told anything", and the two license opposite
1929 /// actions on a PROCESS-GLOBAL gate. Without this, every executor that
1930 /// never heard of the parameter wrote `set_active(false)` on its first
1931 /// spin -- because the gate defaults to TRUE, so `false != is_active()`
1932 /// held -- and switched off a simulated clock somebody else installed.
1933 /// One `cargo test` process is exactly that situation: 344 sibling tests
1934 /// spin an executor each, and the sim-time test failed 3 runs of 3 while
1935 /// passing alone.
1936 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1937 pub(crate) sim_time_stated: bool,
1938 /// phase-430 W1 — the one-shot that says "`use_sim_time` is on and `/clock`
1939 /// has never spoken", so a MISCONFIGURED image is distinguishable from a
1940 /// merely PAUSED one. Armed when the source attaches, disarmed by the first
1941 /// sample or by the report. The policy, and the argument for its unit, are
1942 /// in [`crate::time_source::SilenceWatch`].
1943 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1944 pub(crate) sim_time_silence: crate::time_source::SilenceWatch,
1945 #[cfg(feature = "lifecycle-services")]
1946 pub(crate) lifecycle:
1947 Option<alloc::boxed::Box<crate::lifecycle_services::LifecycleRuntimeState>>,
1948 /// Wall-clock instant at which the previous `spin_once` exited. The
1949 /// timer delta on the next call is measured from this point so any
1950 /// time the caller spent between `spin_once` invocations (e.g. an
1951 /// explicit `thread::sleep`) counts toward timer accumulation just
1952 /// like time spent inside `drive_io`.
1953
1954 /// Monotonic clock endpoint for no_std timer accounting.
1955 // phase-359 W4 — ONE field. The std twin held an `Instant`; both now hold
1956 // µs from the single `now_us()` read.
1957 pub(crate) last_spin_end_us: Option<u64>,
1958 /// The executor's monotonic µs clock: `ExecutorConfig::clock_us` when the
1959 /// caller supplied one, else [`default_clock_us_fn`].
1960 ///
1961 /// phase-359 W10 — no longer `no_std`-only. The std build read an
1962 /// `Instant` through a separate field instead, which is how "what time is
1963 /// it" had two answers in one crate.
1964 pub(crate) clock_us_fn: Option<fn() -> u64>,
1965
1966 /// Consecutive `drive_io` failures on the PRIMARY session (issue 0324).
1967 ///
1968 /// Reset to 0 by any successful drive. A session that has died — router
1969 /// gone, lease expired, socket closed — used to keep returning `Ok(())`
1970 /// from `spin()` forever: the node looked alive, publishes went nowhere,
1971 /// and no callback fired. Nothing in the crate could report it; a
1972 /// `git grep` for a health surface found none, so every such
1973 /// investigation started from packet captures (issue 0268 burned days
1974 /// this way).
1975 ///
1976 /// A COUNTER rather than propagating the error out of `spin_once`,
1977 /// deliberately: `drive_io` returns `Err` for any non-OK backend code, and
1978 /// whether a benign poll timeout maps to one is backend-specific. Aborting
1979 /// the spin on a single failure would risk turning a transient into a dead
1980 /// node. A counter cannot regress behaviour and still makes the condition
1981 /// observable via [`Executor::session_io_failures`].
1982 pub(crate) consecutive_io_failures: u32,
1983 /// RFC-0052 W3b.2 — wall-clock (epoch µs) source for age monitors.
1984 pub(crate) epoch_us_fn: Option<fn() -> u64>,
1985 /// phase-483 W3 — the component registration frame the `nros` facade
1986 /// installs for the duration of a component's `register`, so the
1987 /// declarative entity constructors on [`Node`](super::node::Node) can reach
1988 /// the sink that owns the component's cell. Opaque here: nros-node sits
1989 /// below the crate that defines the frame. `None` outside a registration.
1990 pub(crate) component_frame: Option<core::ptr::NonNull<()>>,
1991 /// RFC-0052 W3b.4 — baked contract-monitor table (empty = uncontracted
1992 /// image; every monitor path below folds away).
1993 pub(crate) monitor_table: &'static [super::monitor::MonitorSpec],
1994 pub(crate) monitor_states: [super::monitor::MonitorState; super::monitor::MAX_MONITORS],
1995 /// Alive supervision, one record per SchedContext: dispatches are counted
1996 /// in the dispatch path where the SC index is already resolved, and read
1997 /// once per monitor tick. CARVED from the backing like `sporadic_states`.
1998 /// It began as an inline `[_; MAX_SC]` pair on the claim that `MAX_SC` is a
1999 /// build-time constant; it is a SIZING KNOB (`ExecutorSizing::sc`), so the
2000 /// header grew with it and `the_executor_value_does_not_scale_with_the_knobs`
2001 /// caught it (issue 0961).
2002 pub(crate) alive_slots: &'s mut [super::monitor::AliveSlot],
2003 /// phase-476 W0 — per-callback-slot lifetime tags, parallel to
2004 /// `entries`: the generation a handle must match, and the owning node.
2005 pub(crate) slot_tags: &'s mut [super::types::SlotTag],
2006 /// W3b.5 — baked subscriber age-contract table (empty = none).
2007 pub(crate) age_table: &'static [super::monitor::AgeMonitorSpec],
2008 pub(crate) age_states: [super::monitor::AgeState; super::monitor::MAX_AGE_MONITORS],
2009 /// phase-462 W3b — baked hazard table (empty = no contracted hazard). Its
2010 /// state lives in the rows' own `static` cells, so a hazard costs this
2011 /// value nothing.
2012 pub(crate) hazard_table: &'static [super::monitor::HazardSpec],
2013 /// W3b.5 — hook invoked on `DeadlineAction::Fault` (panic when unset).
2014 pub(crate) fault_fn: Option<fn(&super::monitor::Violation)>,
2015 /// phase-503 — the reaction hook and its context: called when a
2016 /// supervised route passes its deadline (`route-deadline-runtime`), and on
2017 /// `DeadlineAction::Fault` when no `fault_fn` is set.
2018 pub(crate) reaction_hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
2019 /// Issue #515 — the spin cadence audit runs once, on the first spin
2020 /// that carries a non-zero timeout (which is where the tier's
2021 /// declared spin period becomes visible to the executor).
2022 pub(crate) spin_quantization_checked: bool,
2023 /// phase-436 W1 — the cadence the quantization audit (issue 0515) actually
2024 /// ran against. Recorded because the audit only LOGS: without this a
2025 /// regression that fed it the wrong number is invisible to a test, and
2026 /// capping the park by the timer deadline is exactly such a regression
2027 /// waiting to happen.
2028 pub(crate) spin_quantization_us: u64,
2029 /// Release jitter: worst observed lateness of a `spin_period` wake
2030 /// against its own nominal schedule, in microseconds.
2031 ///
2032 /// Issue #515 added a STATIC audit for one cause of cadence error --
2033 /// a period that is not a multiple of the spin period. Nothing measured
2034 /// the actual release instants, and `audit_spin_quantization` says so in
2035 /// as many words: "the rate is preserved and no activation is dropped,
2036 /// so every runtime rule is (correctly) silent -- the jitter stays
2037 /// invisible until someone measures cadence on target."
2038 ///
2039 /// This is that measurement, and it is the one `cyclictest` exists to
2040 /// report: the deviation between a timer's programmed wake-up and the
2041 /// instant the task actually resumed, whose MAXIMUM is the figure of
2042 /// merit for a real-time system.
2043 ///
2044 /// `spin_period` already holds both numbers -- `next_us` is the nominal
2045 /// deadline and `now_us()` the actual -- and when the loop is late it
2046 /// skips the sleep and discards the difference. That difference is the
2047 /// jitter.
2048 pub(crate) max_release_jitter_us: u64,
2049 /// Clock reading at the previous `spin_once` entry, for the interval the
2050 /// jitter is measured over. `None` until the first spin, and reset by
2051 /// `clear_release_jitter_stats` so a window starts clean.
2052 pub(crate) last_spin_entry_us: Option<u64>,
2053 /// phase-474 I11 -- the release grid of a `spin_once(timeout)` loop with
2054 /// no declared cadence. See [`ReleaseGrid`].
2055 pub(crate) grid: ReleaseGrid,
2056 /// Jitter high-water at the previous `release-jitter-runtime` check, so
2057 /// the rule reports the DELTA. Same shape as `overruns_reported` on a
2058 /// timer header, and for the same reason: a maximum that has not moved
2059 /// is the fault already reported, not a new one.
2060 pub(crate) jitter_reported_us: u64,
2061 /// Declared minimum stack headroom in bytes for the thread this executor
2062 /// spins on; `0` (default) disables the rule.
2063 ///
2064 /// An executor-level install rather than a contract field, because the
2065 /// bound cannot be derived from anything already declared -- see
2066 /// `check_stack_headroom`. The entry that spawned the thread is the one
2067 /// that knows what it gave it, so that is where the number comes from.
2068 pub(crate) min_stack_headroom_bytes: usize,
2069 /// Lowest headroom already reported, so the rule fires on new lows only.
2070 /// `usize::MAX` = nothing reported yet.
2071 pub(crate) stack_headroom_reported: usize,
2072 /// phase-436 E3 — a minimum is set but the port reports no stack data,
2073 /// so `check_stack_headroom_rule` will skip every tick. Recorded when the
2074 /// bound is set; see `stack_headroom_blind`.
2075 pub(crate) stack_headroom_blind: bool,
2076 /// phase-436 E4 — when the headroom query last ran (`None` = never; a
2077 /// clockless build stores `Some(0)` to mean "has run"), spins since, and
2078 /// how many queries actually reached the port.
2079 pub(crate) stack_headroom_last_check_us: Option<u64>,
2080 pub(crate) stack_headroom_spins_since_check: u32,
2081 pub(crate) stack_headroom_checks: u32,
2082 /// The pacing quantum the spin loop was last driven at, in microseconds.
2083 /// This is the bound the jitter rule judges against -- the caller's own
2084 /// declared cadence, so nothing further has to be declared.
2085 pub(crate) spin_nominal_us: u64,
2086 /// Cadence DECLARED by the driver, in microseconds; `0` = not declared.
2087 ///
2088 /// Takes precedence over the `spin_once` timeout when judging release
2089 /// jitter, because the two are not the same quantity. The timeout is how
2090 /// long a spin may BLOCK waiting for work; the cadence is how often the
2091 /// loop intends to come round. A driver that blocks up to 10 ms and then
2092 /// sleeps 30 ms has a 30 ms cadence and a 10 ms timeout, and judging it
2093 /// against the timeout reports a loop that is late on every single wake.
2094 pub(crate) spin_nominal_declared_us: u64,
2095 /// phase-436 A2b -- the declared cadence is met by the `spin_once` WAIT
2096 /// itself (`set_spin_nominal_wait_paced_us`), not by a sleep the caller
2097 /// adds between spins (`set_spin_nominal_us`). A wait-paced cadence runs
2098 /// on the release grid and is worth one park granule; a self-paced one is
2099 /// judged from entry to entry and is worth a microsecond.
2100 pub(crate) spin_nominal_wait_paced: bool,
2101 /// phase-436 — the bound the last park was computed with, in us, and the
2102 /// source that won it. See `WakeSourceId`.
2103 pub(crate) last_park_bound_us: u64,
2104 pub(crate) last_park_source: WakeSourceId,
2105 /// phase-436 W2 — the park bound after rounding UP to what the platform's
2106 /// wake primitive can express. Kept beside the request rather than
2107 /// replacing it: reporting both is what makes the rounding auditable
2108 /// instead of silent (issue 1194).
2109 pub(crate) last_park_achieved_us: u64,
2110 /// phase-436 W6 — registered platform deadline sources, and how many are
2111 /// live. Inline table: no allocator on the targets this exists for.
2112 pub(crate) wake_sources: [Option<WakeSourceSlot>; MAX_WAKE_SOURCES],
2113 pub(crate) wake_source_count: usize,
2114 /// The single thing that actually blocks. Many sources say WHEN; one
2115 /// primitive does the waiting.
2116 pub(crate) park_primitive: Option<(ParkUntilFn, *mut core::ffi::c_void)>,
2117 /// phase-436 W7.b (issue 1242) — the finest park the INSTALLED primitive
2118 /// can actually express, in microseconds. `0` = none installed, fall back
2119 /// to the `wake_wait_ms` ABI floor.
2120 pub(crate) park_granularity_declared_us: u64,
2121 /// Wakes that were already past their nominal deadline, and wakes total.
2122 ///
2123 /// The maximum alone cannot distinguish one bad wake from a loop that is
2124 /// late every single cycle, and those are different faults: the first is
2125 /// a glitch, the second means the period cannot be met at all.
2126 pub(crate) late_wakes: u32,
2127 pub(crate) total_wakes: u32,
2128 /// phase-436 A2c -- late wakes whose lateness exceeds the granularity the
2129 /// measurement was made at (`release_jitter_granularity_us`). On a loop
2130 /// paced by a tick-granular park nearly every wake is late by a fraction
2131 /// of a tick, by design; `late_wakes` counts those, this does not.
2132 pub(crate) late_wakes_beyond_granule: u32,
2133 /// Issue 0514 / 1635 / phase-474 I1 -- what happens to a detected contract
2134 /// violation: the log switch, the drain-and-report hook, the image's sink,
2135 /// and the ring (CARVED, phase-409) with its total and dropped counters.
2136 /// See [`super::monitor::ViolationChannel`].
2137 pub(crate) violations: super::monitor::ViolationChannel<'s>,
2138 /// Issue 0790 — hooks that run BEFORE the session is closed, while every
2139 /// entity still works. The load-bearing half: a device releasing a bus or
2140 /// parking an actuator has to publish its final state / answer its last
2141 /// request from HERE, because after teardown it cannot.
2142 ///
2143 /// `[Option<_>; N]` rather than a `heapless::Vec`, deliberately: the handle
2144 /// a caller holds IS the slot index, so a removal must leave every other
2145 /// index where it was. A `Vec`'s `swap_remove` would silently re-point one
2146 /// live handle at a different callback, and its `remove` would re-point all
2147 /// of them. Clearing a slot to `None` also lets the next registration reuse
2148 /// it, which a fill cursor could not.
2149 pub(crate) pre_shutdown_hooks:
2150 [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
2151 /// Issue 0790 — hooks that run AFTER the session is closed. rclcpp's
2152 /// `add_on_shutdown_callback` / `rclcpp::on_shutdown`. See
2153 /// [`Self::pre_shutdown_hooks`] for why this is an array.
2154 pub(crate) on_shutdown_hooks:
2155 [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
2156}
2157
2158impl<'s> Executor<'s> {
2159 /// phase-271 — assemble an executor over already-carved, caller-owned
2160 /// storage (`slices`, from [`super::storage::carve`]). Fills every
2161 /// non-storage field and reserves SC slot 0 for the default Fifo SC (carve
2162 /// left it `None`). The single builder shared by every constructor path.
2163 fn assemble(session: SessionStore, slices: super::storage::ExecutorSlices<'s>) -> Self {
2164 let super::storage::ExecutorSlices {
2165 arena,
2166 entries,
2167 sched_contexts,
2168 sched_context_bindings,
2169 sporadic_states,
2170 #[cfg(feature = "alloc")]
2171 sporadic_atomic_states,
2172 remaps,
2173 nodes,
2174 extra_sessions,
2175 extra_session_ids,
2176 node_sched_table,
2177 dispatch_slots,
2178 component_slots,
2179 active_groups,
2180 group_sched_table,
2181 monitor_violations,
2182 alive_slots,
2183 slot_tags,
2184 params: param_slots,
2185 param_home,
2186 } = slices;
2187 // phase-382 W3' — only an executor that can build a store keeps the
2188 // carved slots; without `param-services` the region (sized by the
2189 // build, not by a feature, so the layout never depends on one) is
2190 // simply never initialised. The services' home (issue 1812) is
2191 // zero-sized without the feature.
2192 #[cfg(not(feature = "param-store"))]
2193 let _ = (param_slots, param_home);
2194 // Slot 0 = the auto-created default Fifo SC (see field doc). carve
2195 // initialised the whole table to `None`; populate the reserved slot.
2196 if let Some(slot0) = sched_contexts.first_mut() {
2197 *slot0 = Some(super::sched_context::SchedContext::default());
2198 }
2199 // phase-412 -- stamp the self-report before anything can fail. `init`
2200 // is idempotent, so an image with two executors need not decide which
2201 // one owns the record, and `note_arena_capacity` records the slice this
2202 // executor was actually handed rather than the compiled constant: the
2203 // arena's placement is the caller's choice (issue 0900), so the two can
2204 // legitimately differ and the difference is worth seeing.
2205 crate::boot_report::init();
2206 crate::boot_report::note_arena_capacity(arena.len());
2207 crate::boot_report::checkpoint(crate::boot_report::Stage::ExecutorReady);
2208 Self {
2209 // `assemble` is reached from session-only entry points that have no
2210 // config, so 0 is the floor rather than a choice; `open_in` and any
2211 // binding that knows better overwrite it via `set_domain_id`.
2212 domain_id: 0,
2213 session,
2214 arena,
2215 arena_used: 0,
2216 arena_free_head: ARENA_FREE_NONE,
2217 pending_region: None,
2218 entries,
2219 sched_contexts,
2220 sched_context_bindings,
2221 sporadic_states,
2222 #[cfg(feature = "alloc")]
2223 sporadic_atomic_states,
2224 major_frame_us: 0,
2225 #[cfg(all(
2226 feature = "alloc",
2227 feature = "rmw-cffi",
2228 feature = "scheduler-os-priority"
2229 ))]
2230 os_priority_pool: super::os_priority::OsPriorityPool::new(),
2231 #[cfg(all(feature = "alloc", feature = "scheduler-os-priority"))]
2232 os_priority_apply_policy: None,
2233 trigger: Trigger::Any,
2234 semantics: ExecutorSemantics::RclcppExecutor,
2235 node_name: heapless::String::new(),
2236 active_groups,
2237 active_groups_filtering: false,
2238 nodes,
2239 node_sched_table,
2240 group_sched_table,
2241 remap_table: remaps,
2242 remap_len: 0,
2243 dispatch_slots,
2244 component_slots,
2245 extra_sessions,
2246 extra_session_ids,
2247 primary_rmw_name: heapless::String::new(),
2248 primary_locator: heapless::String::new(),
2249 namespace: {
2250 let mut ns = heapless::String::new();
2251 let _ = ns.push_str("/");
2252 ns
2253 },
2254 // Issue 1177 -- the only cfg the halt flag still needs. `HaltFlag`
2255 // is refcounted with an allocator and plain without one, and
2256 // nothing below this line can tell the difference.
2257 #[cfg(feature = "alloc")]
2258 halt_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
2259 #[cfg(not(feature = "alloc"))]
2260 halt_flag: portable_atomic::AtomicBool::new(false),
2261 spinning: portable_atomic::AtomicBool::new(false),
2262 #[cfg(feature = "alloc")]
2263 wake_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
2264 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2265 node_wake: super::node_wake::NodeWake::new().map(portable_atomic_util::Arc::new),
2266 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2267 wake_ctx: None,
2268 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2269 wake_cb_installed: false,
2270 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2271 has_cross_thread_waker: false,
2272 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2273 wake_membership: None,
2274 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2275 graph_change_target: None,
2276 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2277 has_async_wake: false,
2278 // Phase 141.A.3 — alloc-mode wake state init. Constructed
2279 // eagerly (NodeWake allocation) so the runtime cb can be
2280 // installed lazily on first session without a fallible
2281 // alloc inside spin_once. `None` when the platform
2282 // provider reports the primitive unavailable (matches
2283 // the std-RTOS path's `node_wake: Option<...>`).
2284 #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
2285 signal_fd: None,
2286 #[cfg(feature = "param-store")]
2287 params: None,
2288 #[cfg(feature = "param-store")]
2289 param_slots,
2290 #[cfg(feature = "param-store")]
2291 param_home,
2292 #[cfg(feature = "param-store")]
2293 param_store_refused: false,
2294 #[cfg(feature = "param-store")]
2295 declared_params: nros_params::DeclaredParams::EMPTY,
2296 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2297 sim_time_source: None,
2298 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2299 sim_time_requested: false,
2300 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2301 sim_time_stated: false,
2302 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2303 sim_time_silence: crate::time_source::SilenceWatch::new(),
2304 #[cfg(feature = "lifecycle-services")]
2305 lifecycle: None,
2306 // Initialise the spin endpoint to construction time so the
2307 // very first `spin_once` credits time the caller spent
2308 // *before* it (e.g. setup, an explicit pre-spin sleep) just
2309 // like time spent between later calls.
2310 // One field, one seed now (phase-359 W10): the clock is absolute
2311 // on every flavour, so it is READ here rather than assumed to be
2312 // zero at construction. The std arm used to seed `Some(0)` because
2313 // its epoch WAS construction; with one provider that special case
2314 // is gone.
2315 last_spin_end_us: default_clock_us_fn().map(|clock| clock()),
2316 clock_us_fn: default_clock_us_fn(),
2317 consecutive_io_failures: 0,
2318 // RFC-0052 W3b.5 — a build with a wall clock gets one by default so
2319 // age monitors activate without extra wiring; a build with neither a
2320 // platform port nor a host gets `None`, and its board installs
2321 // `config.epoch_us` in `from_session_in`/`open`.
2322 //
2323 // phase-359 W10 — the cfg pair that used to be here moved into
2324 // `default_epoch_us_fn`, beside the `default_clock_us_fn` it
2325 // mirrors. (The old comment pointed at "the `not(std)` blocks
2326 // above" for the board install; those blocks are no longer
2327 // cfg-gated — the config override applies on every flavour now.)
2328 epoch_us_fn: super::types::default_epoch_us_fn(),
2329 component_frame: None,
2330 monitor_table: &[],
2331 monitor_states: [super::monitor::MonitorState::default(); super::monitor::MAX_MONITORS],
2332 age_table: &[],
2333 age_states: [super::monitor::AgeState::default(); super::monitor::MAX_AGE_MONITORS],
2334 hazard_table: &[],
2335 fault_fn: None,
2336 reaction_hook: None,
2337 spin_quantization_checked: false,
2338 spin_quantization_us: 0,
2339 max_release_jitter_us: 0,
2340 last_spin_entry_us: None,
2341 grid: ReleaseGrid {
2342 period_us: 0,
2343 next_us: 0,
2344 skipped: 0,
2345 },
2346 jitter_reported_us: 0,
2347 min_stack_headroom_bytes: 0,
2348 stack_headroom_reported: usize::MAX,
2349 stack_headroom_blind: false,
2350 stack_headroom_last_check_us: None,
2351 stack_headroom_spins_since_check: 0,
2352 stack_headroom_checks: 0,
2353 spin_nominal_us: 0,
2354 spin_nominal_declared_us: 0,
2355 spin_nominal_wait_paced: false,
2356 last_park_bound_us: 0,
2357 last_park_source: WakeSourceId::CallerBudget,
2358 last_park_achieved_us: 0,
2359 wake_sources: [None; MAX_WAKE_SOURCES],
2360 wake_source_count: 0,
2361 park_primitive: None,
2362 park_granularity_declared_us: 0,
2363 late_wakes: 0,
2364 total_wakes: 0,
2365 late_wakes_beyond_granule: 0,
2366 violations: super::monitor::ViolationChannel::new(monitor_violations),
2367 alive_slots,
2368 slot_tags,
2369 // Issue 0790 — both phase tables start empty. An image that
2370 // registers nothing pays these `None`s and a two-slot scan at
2371 // teardown, and nothing else.
2372 pre_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
2373 on_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
2374 }
2375 }
2376
2377 /// Create an owning executor over caller-supplied `backing`, sized by
2378 /// `sizing`. The core, non-generic, per-entry entry point (the `alloc`
2379 /// [`from_session`](Self::from_session) convenience leaks a default backing
2380 /// and calls this; the macro / C FFI pass an entry-sized backing).
2381 ///
2382 /// # Safety
2383 /// `backing` must be ≥ `sizing.u64_len()` words, stay alive for `'s`, and
2384 /// not be otherwise accessed while the executor lives (it aliases it).
2385 /// `sizing.cbs` must be ≤ 64 (the `u64` ready-set bitmask ceiling).
2386 pub unsafe fn from_session_in(
2387 session: session::ConcreteSession,
2388 backing: &'s mut [MaybeUninit<u64>],
2389 sizing: super::storage::ExecutorSizing,
2390 ) -> Self {
2391 let slices = unsafe { super::storage::carve(backing, sizing) };
2392 Self::assemble(SessionStore::Owned(session), slices)
2393 }
2394
2395 /// Create a borrowing executor over caller-supplied `backing`, sized by
2396 /// `sizing`. Counterpart to [`from_session_in`](Self::from_session_in) for
2397 /// the per-tier / C model (the session is borrowed, not owned).
2398 ///
2399 /// # Safety
2400 /// - `session_ptr` must point to a valid session that outlives the executor
2401 /// and is not moved/dropped while it exists.
2402 /// - `backing` obligations as in [`from_session_in`](Self::from_session_in).
2403 pub unsafe fn from_session_ptr_in(
2404 session_ptr: *mut session::ConcreteSession,
2405 backing: &'s mut [MaybeUninit<u64>],
2406 sizing: super::storage::ExecutorSizing,
2407 ) -> Self {
2408 let slices = unsafe { super::storage::carve(backing, sizing) };
2409 Self::assemble(SessionStore::Borrowed(session_ptr), slices)
2410 }
2411}
2412
2413impl Executor<'static> {
2414 /// Create an executor from an already-opened session, using the build-time
2415 /// default sizing (`MAX_CBS`/`MAX_SC`/`ARENA_SIZE`). Convenience for
2416 /// std/alloc callers that don't size per-entry: it leaks a default-sized
2417 /// backing (executor-lifetime, one-time) and calls
2418 /// [`from_session_in`](Self::from_session_in). Per-entry sizing goes through
2419 /// the macro / `open_in` instead.
2420 #[cfg(feature = "alloc")]
2421 pub fn from_session(session: session::ConcreteSession) -> Self {
2422 let sizing = super::storage::ExecutorSizing::DEFAULT;
2423 // SAFETY: the backing is exactly `sizing.u64_len()` words,
2424 // `'static`, and uniquely owned by this executor.
2425 unsafe { Self::from_session_in(session, default_backing(sizing), sizing) }
2426 }
2427
2428 /// [`from_session`](Self::from_session) with an [`ExecutorConfig`], so a
2429 /// caller that brings its own session can also bring its own clock.
2430 ///
2431 /// issue 0709 / issue 0687 — `from_session` takes no config, and that is
2432 /// the path the no-port population uses: it accepts any `Session`, so a
2433 /// consumer with a non-cffi backend reaches the executor through it and had
2434 /// NO way to install `clock_us`. phase-359 W10 argued the `std`-without-a-
2435 /// port clock fallbacks could go because "a caller with a clock installs it
2436 /// through `ExecutorConfig::clock_us`" — true for `open`, false here, which
2437 /// is half of why that deletion was reverted.
2438 ///
2439 /// Only the timing sources are read from `config`; identity (locator,
2440 /// domain, names) belongs to the session the caller already opened. As in
2441 /// [`open_in`](Self::open_in), a `None` field does NOT clobber the
2442 /// platform default — it means "not specified" (the bug issue 0671
2443 /// records).
2444 #[cfg(feature = "alloc")]
2445 pub fn from_session_with(
2446 session: session::ConcreteSession,
2447 config: &super::types::ExecutorConfig<'_>,
2448 ) -> Self {
2449 let mut executor = Self::from_session(session);
2450 if let Some(clock) = config.clock_us {
2451 executor.clock_us_fn = Some(clock);
2452 executor.last_spin_end_us = Some(clock());
2453 }
2454 if let Some(epoch) = config.epoch_us {
2455 executor.epoch_us_fn = Some(epoch);
2456 }
2457 executor
2458 }
2459
2460 /// Create an executor from a borrowed session pointer, default-sized. The
2461 /// `alloc` convenience wrapper over
2462 /// [`from_session_ptr_in`](Self::from_session_ptr_in) — leaks a default
2463 /// backing so existing callers keep the zero-storage-arg signature.
2464 ///
2465 /// # Safety
2466 /// - `session_ptr` must point to a valid, initialized session that lives at
2467 /// least as long as this executor.
2468 /// - The caller must not move or drop the session while the executor exists.
2469 #[cfg(feature = "alloc")]
2470 pub unsafe fn from_session_ptr(session_ptr: *mut session::ConcreteSession) -> Self {
2471 let sizing = super::storage::ExecutorSizing::DEFAULT;
2472 // SAFETY: the backing as in `from_session`; session_ptr contract
2473 // forwarded to `from_session_ptr_in`.
2474 unsafe { Self::from_session_ptr_in(session_ptr, default_backing(sizing), sizing) }
2475 }
2476}
2477
2478/// phase-474 I11 -- the release grid of a `spin_once(timeout)` loop with no
2479/// declared cadence: the period it was built for (`0` = no grid yet), the next
2480/// scheduled release, and the releases skipped because the loop came back a
2481/// whole period or more after they were due (the timer `Skip` policy: the
2482/// latest due point is served once, the older ones are dropped and counted,
2483/// never released in a burst). The park is bounded by `next_us`, so a loop is
2484/// released on `anchor + k * period` instead of `period` after its previous
2485/// spin ended. Scales with no knob; named in the header-size budget.
2486#[derive(Clone, Copy, Debug, Default)]
2487pub(crate) struct ReleaseGrid {
2488 pub(crate) period_us: u64,
2489 pub(crate) next_us: u64,
2490 pub(crate) skipped: u32,
2491}
2492
2493/// The two cadences a `period_us` timer alternates between under a `spin_us`
2494/// spin period, or `None` when the audit (issue #515) has nothing to say.
2495///
2496/// Split out of `audit_spin_quantization` so the decision can be asserted:
2497/// the audit only LOGS, so a regression in what it decides is otherwise
2498/// invisible to a test.
2499///
2500/// `None` for a period of zero, for a period that divides the spin period —
2501/// and, since issue 1321, for a timer whose activations are NOT paced by the
2502/// spin period at all. The warning's arithmetic is wall microseconds on both
2503/// sides; a `Ros` timer's activations are quantised by the `/clock` STEP, so
2504/// the sentence it would print ("activations will alternate between X and Y
2505/// us") is about nothing.
2506pub(crate) fn spin_quantization_span(
2507 clock_source: TimerClockSource,
2508 period_us: u64,
2509 spin_us: u64,
2510) -> Option<(u64, u64)> {
2511 if spin_us == 0 || period_us == 0 || period_us.is_multiple_of(spin_us) {
2512 return None;
2513 }
2514 if !clock_source.remaining_is_wall_time() {
2515 return None;
2516 }
2517 let early_us = (period_us / spin_us) * spin_us;
2518 Some((early_us, early_us.saturating_add(spin_us)))
2519}
2520
2521/// phase-436 — which deadline source bounded the last park.
2522///
2523/// Attribution is what turns "why did we wake" from a guess into a number.
2524/// Recording it costs one byte per spin and is the difference between a
2525/// schedulability argument you can audit and one you have to trust.
2526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2527#[repr(u8)]
2528pub enum WakeSourceId {
2529 /// The `spin_once` caller's own timeout — always a member of the source
2530 /// set, which is why the park is never unbounded.
2531 CallerBudget,
2532 /// A registered timer's next expiry (issue 1192).
2533 Timer,
2534 /// The backend's next internal event (lease keepalive, heartbeat,
2535 /// ACK-NACK). Folded into the same `min` as everything else rather than
2536 /// capping the budget separately upstream, so one rule decides the park
2537 /// and one attribution explains it.
2538 Session,
2539 /// A source the PLATFORM registered: a `k_poll` event set, an `epoll`
2540 /// group, a FreeRTOS queue set, a ThreadX event-flags group, or a
2541 /// bare-metal device ISR. The core never names an RTOS; it only asks each
2542 /// registered source when it will next need the executor.
2543 Platform(u8),
2544 /// phase-503 — the earliest deadline of a pending supervised route: the
2545 /// park ends there so `route-deadline-runtime` judges it on time.
2546 RouteDeadline,
2547}
2548
2549/// How many platform sources one executor can carry. A plain const, not a
2550/// sizing knob: the table is inline, so no allocator is required on the no_std
2551/// targets this seam mainly exists for.
2552pub const MAX_WAKE_SOURCES: usize = 8;
2553
2554/// phase-436 (RFC-0106 D5) — the slice a poll-only backend's transport wait is
2555/// cut into while a guard condition can signal the executor from another
2556/// thread: the worst-case latency of such a trigger, and the period at which
2557/// an otherwise idle executor re-enters the backend's `recv`.
2558#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2559pub(crate) const POLL_ONLY_WAKE_SLICE_MS: i32 = 10;
2560
2561/// When this source will next need the executor, in the executor's clock,
2562/// as microseconds FROM NOW.
2563///
2564/// `u64::MAX` means "nothing pending" — the source does not shorten the park.
2565/// That is how an ASYNC source spells itself: a socket or a DDS listener
2566/// contributes no deadline and instead breaks the park by signalling the wake
2567/// primitive, so async and deadline sources compose without a second
2568/// mechanism.
2569pub type NextDeadlineFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void) -> u64;
2570
2571/// Park until `deadline_us` from now, or until something signals, whichever
2572/// comes first.
2573///
2574/// Returns `0` when woken by an event, `1` when the deadline expired, and a
2575/// negative value when this build cannot park at all.
2576///
2577/// A DEADLINE, not a duration, and that distinction is what makes bare metal
2578/// work: `nros-baremetal-common`'s own note says `wfi` with no pending
2579/// interrupt deadlocks, so a board must arm a timer compare BEFORE waiting.
2580/// A `sleep(duration)` seam cannot express that — the sleeper does not know
2581/// whether anyone armed an interrupt. `park_until(deadline)` does, because the
2582/// deadline is exactly what the compare is armed to.
2583pub type ParkUntilFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void, deadline_us: u64) -> i8;
2584
2585/// A registered deadline contributor.
2586#[derive(Clone, Copy)]
2587pub(crate) struct WakeSourceSlot {
2588 pub(crate) next: NextDeadlineFn,
2589 pub(crate) ctx: *mut core::ffi::c_void,
2590}
2591
2592impl<'s> Executor<'s> {
2593 /// Phase 228.B (RFC-0015) — construct a tier task's executor that **shares**
2594 /// a session opened once by the orchestration `main()`.
2595 ///
2596 /// In the per-tier execution model `main()` opens one RMW session, then
2597 /// spawns one RTOS task per priority tier; each task calls this to get an
2598 /// [`Executor`] over the *same* session (the `Borrowed` session store — this
2599 /// executor neither owns nor closes it), registers its tier's callback
2600 /// groups, and spins. Thin alias over [`Executor::from_session_ptr`].
2601 ///
2602 /// # Safety
2603 /// `session` must outlive every executor/task built from it (the
2604 /// orchestration `main()` holds it and never returns / WFIs), and must not
2605 /// be mutated except through these executors' spin calls.
2606 #[cfg(feature = "alloc")]
2607 pub unsafe fn open_with_session(session: *mut session::ConcreteSession) -> Executor<'static> {
2608 #[allow(unused_mut)]
2609 let mut exec = unsafe { Executor::<'static>::from_session_ptr(session) };
2610 // phase-436 — join the session's wake fan-out, so this tier is woken
2611 // by its arrivals instead of sleeping out its whole bound.
2612 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2613 exec.install_wake_signal_on_primary();
2614 exec
2615 }
2616
2617 /// phase-271 — per-tier borrowed-session constructor over caller-supplied,
2618 /// per-tier-sized `backing`. The sized counterpart to
2619 /// [`open_with_session`](Self::open_with_session): each RTOS tier task owns
2620 /// its own backing so tiers size independently.
2621 ///
2622 /// # Safety
2623 /// `session` obligations as in [`open_with_session`](Self::open_with_session);
2624 /// `backing`/`sizing` as in [`from_session_ptr_in`](Self::from_session_ptr_in).
2625 pub unsafe fn open_with_session_in(
2626 session: *mut session::ConcreteSession,
2627 backing: &'s mut [MaybeUninit<u64>],
2628 sizing: super::storage::ExecutorSizing,
2629 ) -> Self {
2630 #[allow(unused_mut)]
2631 let mut exec = unsafe { Self::from_session_ptr_in(session, backing, sizing) };
2632 // phase-436 — as `open_with_session`.
2633 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2634 exec.install_wake_signal_on_primary();
2635 exec
2636 }
2637
2638 /// Raw pointer to this executor's RMW session, for the per-tier model:
2639 /// the boot task opens the one session via [`Executor::open`] (the RMW
2640 /// session is a process-wide singleton — opening twice fails), then hands
2641 /// this pointer to each spawned tier task's
2642 /// [`Executor::open_with_session`]. The boot task's executor owns the
2643 /// session and outlives every borrower, so the pointer stays valid for the
2644 /// program's life. Works for both `Owned` and `Borrowed` stores.
2645 ///
2646 /// # Safety
2647 /// The returned pointer aliases `self.session`. Callers must keep `self`
2648 /// alive (not moved/dropped) for as long as any tier executor uses the
2649 /// pointer, and must only touch the session through executor spin calls
2650 /// (the RMW backend serializes concurrent access through its own locks).
2651 pub fn session_ptr(&mut self) -> *mut session::ConcreteSession {
2652 let ptr = &mut *self.session as *mut session::ConcreteSession;
2653 // phase-436 — the owner of an OWNED session claimed its wake fan-out
2654 // while this executor value could still move (inside `open*`). The
2655 // address handed out here is the one every tier is opened over, so
2656 // the entry is keyed by it from now on.
2657 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2658 if let (SessionStore::Owned(_), Some(m)) = (&self.session, self.wake_membership) {
2659 m.fanout().rekey(ptr as *mut core::ffi::c_void);
2660 }
2661 ptr
2662 }
2663
2664 /// Opaque, `Send` form of [`session_ptr`](Self::session_ptr) — the per-tier
2665 /// model hands this to each spawned tier task (it can cross the RTOS task /
2666 /// thread boundary, which a bare `*mut` cannot). See [`SessionHandle`].
2667 ///
2668 /// # Safety
2669 /// Same contract as [`session_ptr`](Self::session_ptr): `self` (the session
2670 /// owner) must outlive every executor built from the handle.
2671 pub fn session_handle(&mut self) -> SessionHandle {
2672 SessionHandle(self.session_ptr())
2673 }
2674
2675 /// Open an [`Executor`] over the session a [`SessionHandle`] refers to (the
2676 /// `Borrowed` store — neither owns nor closes it). The tier-task counterpart
2677 /// to [`session_handle`](Self::session_handle).
2678 ///
2679 /// # Safety
2680 /// The handle's session must still be alive (its owning executor not moved
2681 /// or dropped); access only through executor spin calls.
2682 #[cfg(feature = "alloc")]
2683 pub unsafe fn open_with_session_handle(handle: SessionHandle) -> Executor<'static> {
2684 unsafe { Executor::<'static>::open_with_session(handle.0) }
2685 }
2686
2687 /// phase-271 — sized counterpart to
2688 /// [`open_with_session_handle`](Self::open_with_session_handle) (per-tier
2689 /// backing).
2690 ///
2691 /// # Safety
2692 /// As [`open_with_session_handle`](Self::open_with_session_handle) +
2693 /// [`from_session_ptr_in`](Self::from_session_ptr_in).
2694 pub unsafe fn open_with_session_handle_in(
2695 handle: SessionHandle,
2696 backing: &'s mut [MaybeUninit<u64>],
2697 sizing: super::storage::ExecutorSizing,
2698 ) -> Self {
2699 unsafe { Self::open_with_session_in(handle.0, backing, sizing) }
2700 }
2701
2702 /// issue 1571 — a SPAWNED tier's executor over its slot of the entry's
2703 /// [`TierExecutorBacking`](super::TierExecutorBacking) — the road every
2704 /// board's `run_tiers` takes for each tier but the boot one.
2705 ///
2706 /// The slot's TYPE is the size: it is `ExecutorSizing::DEFAULT.u64_len()`
2707 /// words and this opens with `ExecutorSizing::DEFAULT`, so the pairing is
2708 /// exact by construction and lives here, once, rather than at five boards.
2709 /// [`open_with_session`](Self::open_with_session) is the heap twin this
2710 /// replaced on those roads.
2711 ///
2712 /// # Safety
2713 /// `session` obligations as in [`open_with_session`](Self::open_with_session).
2714 #[cfg(feature = "alloc")]
2715 pub unsafe fn open_with_session_slot(
2716 session: *mut session::ConcreteSession,
2717 slot: &'s mut super::TierExecutorBackingSlot,
2718 ) -> Self {
2719 unsafe {
2720 Self::open_with_session_in(
2721 session,
2722 &mut slot[..],
2723 super::storage::ExecutorSizing::DEFAULT,
2724 )
2725 }
2726 }
2727
2728 /// issue 1571 — [`open_with_session_slot`](Self::open_with_session_slot)
2729 /// over a [`SessionHandle`], for the boards whose tier tasks carry one.
2730 ///
2731 /// # Safety
2732 /// As [`open_with_session_handle`](Self::open_with_session_handle).
2733 #[cfg(feature = "alloc")]
2734 pub unsafe fn open_with_session_handle_slot(
2735 handle: SessionHandle,
2736 slot: &'s mut super::TierExecutorBackingSlot,
2737 ) -> Self {
2738 unsafe { Self::open_with_session_slot(handle.0, slot) }
2739 }
2740
2741 /// Phase 228.C — set this tier executor's active callback-group filter. The
2742 /// generated per-tier task calls this before registering nodes; afterwards
2743 /// only callbacks whose `.callback_group()` is in `groups` register here.
2744 /// An empty slice (or never calling it) leaves the wildcard — register all
2745 /// callbacks (the single-tier degenerate case + today's behaviour).
2746 pub fn set_active_groups(
2747 &mut self,
2748 groups: &[(&str, &str, &str)],
2749 ) -> Result<(), super::types::BindError> {
2750 // phase-409 — the table is CARVED and reused, so clear before refilling;
2751 // the old `Option<heapless::Vec>` got a fresh empty vector each call.
2752 self.active_groups.clear();
2753 if groups.is_empty() {
2754 self.active_groups_filtering = false;
2755 return Ok(());
2756 }
2757 for (name, ns, group) in groups {
2758 // issue 1172 — this used to be `if s.push_str(g).is_ok()`, which
2759 // DROPPED an oversized group name and left the tier filtering on a
2760 // set that silently lacked it. It fails closed (the entity is never
2761 // created), so the symptom is a missing entity with nothing to read
2762 // that says why. Same contract as `declare_remap` below: too long,
2763 // or table full, is the caller's error.
2764 let key = super::storage::node_identity_hash(name, ns);
2765 let mut s = heapless::String::new();
2766 let why = if s.push_str(group).is_err() {
2767 Some(super::types::BindError::NameTooLong)
2768 } else if self.active_groups.push((key, s)).is_err() {
2769 Some(super::types::BindError::TableFull)
2770 } else {
2771 None
2772 };
2773 if let Some(why) = why {
2774 // FAIL CLOSED, and say so. Returning here with a PARTIAL table
2775 // would leave the tier filtering on a subset — the same silent
2776 // narrowing, one step later. An empty table with filtering ON
2777 // accepts NOTHING (`group_filter_accepts` matches on an empty
2778 // slice), so the tier registers no entities at all: visible in
2779 // behaviour, where a missing entry is not. The `Err` is for the
2780 // caller that can report it; the state is safe for the one that
2781 // cannot.
2782 self.active_groups.clear();
2783 self.active_groups_filtering = true;
2784 return Err(why);
2785 }
2786 }
2787 self.active_groups_filtering = true;
2788 Ok(())
2789 }
2790
2791 /// The current callback-group filter, or `None` for the wildcard.
2792 fn active_group_filter(&self) -> Option<&[super::storage::ActiveGroup]> {
2793 self.active_groups_filtering
2794 .then(|| self.active_groups.as_slice())
2795 }
2796
2797 /// Phase 228.C — whether a callback in `group` should register in this
2798 /// executor under the current filter. The wildcard accepts everything.
2799 pub fn group_active(&self, node_name: &str, node_namespace: &str, group: &str) -> bool {
2800 group_filter_accepts(
2801 self.active_group_filter(),
2802 super::storage::node_identity_hash(node_name, node_namespace),
2803 group,
2804 )
2805 }
2806
2807 /// Set the node name and namespace used for liveliness tokens.
2808 ///
2809 /// Called by `open()` to propagate config values. When `register_subscription`
2810 /// or `register_service` creates entities, these values are attached to the
2811 /// Phase 156 — record the primary session's backend identity
2812 /// (rmw name + locator) so `NodeBuilder::resolve_session_slot`
2813 /// can detect when a `.rmw(name)` matches the primary instead
2814 /// of opening a SECOND backend session against the same
2815 /// singleton (zenoh-pico's `g_session` is process-wide;
2816 /// opening twice fails). `Executor::open*` calls this
2817 /// automatically; the C surface (`rclc_executor_init`) calls
2818 /// it manually because it constructs via `from_session_ptr`
2819 /// which doesn't know the open metadata. Empty strings = "no
2820 /// primary identity tracked"; the cache check degrades to
2821 /// always-miss.
2822 pub fn set_primary_identity(&mut self, rmw_name: &str, locator: &str) {
2823 self.primary_rmw_name.clear();
2824 let _ = self.primary_rmw_name.push_str(rmw_name);
2825 self.primary_locator.clear();
2826 let _ = self.primary_locator.push_str(locator);
2827 }
2828
2829 /// `TopicInfo`/`ServiceInfo` so the zenoh backend can declare liveliness.
2830 /// Issue 0656 — set the ROS domain for entities this executor declares.
2831 ///
2832 /// For bindings that build an executor from an existing session
2833 /// (`from_session_ptr_in`), where no `ExecutorConfig` is available and the
2834 /// domain would otherwise stay at its 0 floor.
2835 pub fn set_domain_id(&mut self, domain_id: u32) {
2836 self.domain_id = domain_id;
2837 }
2838
2839 /// The ROS domain this executor declares entities on.
2840 pub fn domain_id(&self) -> u32 {
2841 self.domain_id
2842 }
2843
2844 pub fn set_node_identity(&mut self, node_name: &str, namespace: &str) {
2845 self.node_name.clear();
2846 let _ = self.node_name.push_str(node_name);
2847 if !namespace.is_empty() {
2848 self.namespace.clear();
2849 let _ = self.namespace.push_str(namespace);
2850 }
2851 }
2852
2853 // =========================================================================
2854 // Phase 305 W3 (issue 0255) — per-node launch remap table
2855 // =========================================================================
2856
2857 /// Record one launch `<remap from= to=/>` rule for the node identified by
2858 /// `(node_name, namespace)`. Rules are matched in declaration order (first
2859 /// wins) by [`Self::resolve_entity_name_for`]. Errors when a string
2860 /// overflows its slot or the table is at `MAX_REMAPS` — callers surface
2861 /// this rather than silently dropping a routing rule.
2862 ///
2863 /// **This declares an AUTHORITATIVE rule.** Declaration order settles
2864 /// which of two launch rules for one name wins; it is not how an argv rule
2865 /// is ranked against a launch rule. The `--ros-args` rules share this table
2866 /// but are tagged as the fallback tier, and resolution reads both tiers
2867 /// through [`crate::names::resolve_name_layered`] — so an argv rule
2868 /// installed BEFORE a launch rule still loses to it.
2869 #[allow(clippy::result_unit_err)]
2870 pub fn declare_remap(
2871 &mut self,
2872 node_name: &str,
2873 namespace: &str,
2874 from: &str,
2875 to: &str,
2876 ) -> Result<(), ()> {
2877 let mut rule = RemapRule {
2878 node_name: heapless::String::new(),
2879 namespace: heapless::String::new(),
2880 from: heapless::String::new(),
2881 to: heapless::String::new(),
2882 tier: RemapTier::Launch,
2883 };
2884 rule.node_name.push_str(node_name)?;
2885 let ns = if namespace.is_empty() { "/" } else { namespace };
2886 rule.namespace.push_str(ns)?;
2887 rule.from.push_str(from)?;
2888 rule.to.push_str(to)?;
2889 // Same contract as the `heapless::Vec::push` this replaces: full table
2890 // is an error the caller surfaces, never a silently dropped rule.
2891 let slot = self.remap_table.get_mut(self.remap_len).ok_or(())?;
2892 *slot = Some(rule);
2893 self.remap_len += 1;
2894 Ok(())
2895 }
2896
2897 /// Resolve a source-level entity name for the node identified by
2898 /// `(node_name, namespace)`: ROS 2 name expansion (`~`/relative → FQN)
2899 /// plus this node's declared remap rules (exact-FQN match, first rule
2900 /// wins). Nodes with no rules still get expansion. Errors on an
2901 /// unexpandable name (see `crate::names::expand_name`).
2902 ///
2903 /// Two tiers, through [`crate::names::resolve_name_layered`]: the rules
2904 /// [`Self::declare_remap`] recorded for this node are AUTHORITATIVE (the
2905 /// generated entry declares the launch projection there), and the
2906 /// `--ros-args` rules `crate::ros_args::install_argv_remaps` put in the
2907 /// same table are the FALLBACK, consulted only when no launch rule
2908 /// matched. rcl does the same with its local and global arguments
2909 /// (`rcl_remap_topic_name`, Humble `rcl/remap.h`).
2910 #[allow(clippy::result_unit_err)]
2911 pub fn resolve_entity_name_for(
2912 &self,
2913 node_name: &str,
2914 namespace: &str,
2915 source: &str,
2916 ) -> Result<crate::names::ResolvedName, ()> {
2917 let ns = if namespace.is_empty() { "/" } else { namespace };
2918 let table = &self.remap_table[..self.remap_len];
2919 crate::names::resolve_name_layered(
2920 source,
2921 node_name,
2922 ns,
2923 tier_rules(table, RemapTier::Launch, node_name, ns),
2924 tier_rules(table, RemapTier::Argv, node_name, ns),
2925 )
2926 }
2927
2928 /// [`Self::resolve_entity_name_for`] against the executor's CURRENT node
2929 /// identity (`set_node_identity`) — the nros-c registration sites set that
2930 /// identity per node immediately before registering each entity.
2931 #[allow(clippy::result_unit_err)]
2932 pub fn resolve_entity_name(&self, source: &str) -> Result<crate::names::ResolvedName, ()> {
2933 self.resolve_entity_name_for(self.node_name.as_str(), self.namespace.as_str(), source)
2934 }
2935
2936 // =========================================================================
2937 // Phase 272 (RFC-0047) — node-name → sched-context table
2938 // =========================================================================
2939
2940 /// Seed a config-resolved tier binding by `(name, namespace)` before the
2941 /// node is built. `NodeBuilder::build` consults this table when no
2942 /// explicit `.sched()` override is given — the table entry then wins over
2943 /// the `SchedContextId(0)` default (precedence: explicit > table > 0).
2944 ///
2945 /// Call BEFORE `node_builder(name).build()`. An existing entry for the
2946 /// same `(name, namespace)` key is overwritten (last-write wins). Overflow
2947 /// past `MAX_NODES` is an `Err`, never a dropped binding — issue 1172. An
2948 /// empty `namespace` is normalised
2949 /// to `"/"` to match what `NodeBuilder::build` computes for a root-NS node.
2950 pub fn bind_node_name_sched(
2951 &mut self,
2952 name: &str,
2953 namespace: &str,
2954 sc: super::sched_context::SchedContextId,
2955 ) -> Result<(), super::types::BindError> {
2956 let norm_ns = if namespace.is_empty() { "/" } else { namespace };
2957 // Overwrite if there is already an entry for this (name, ns) pair.
2958 for entry in self.node_sched_table.iter_mut() {
2959 if entry.0.as_str() == name && entry.1.as_str() == norm_ns {
2960 entry.2 = sc;
2961 return Ok(());
2962 }
2963 }
2964 // New entry. Too long, or table full, is an ERROR the caller surfaces
2965 // — never a dropped binding. A binding that vanishes leaves the node on
2966 // the wrong sched context with nothing to read that says so.
2967 let mut name_s = heapless::String::<64>::new();
2968 let mut ns_s = heapless::String::<64>::new();
2969 name_s
2970 .push_str(name)
2971 .map_err(|_| super::types::BindError::NameTooLong)?;
2972 ns_s.push_str(norm_ns)
2973 .map_err(|_| super::types::BindError::NameTooLong)?;
2974 self.node_sched_table
2975 .push((name_s, ns_s, sc))
2976 .map_err(|_| super::types::BindError::TableFull)?;
2977 Ok(())
2978 }
2979
2980 /// Look up the seeded sched-context for `(name, namespace)`. Returns
2981 /// `None` when the table has no entry for this pair (unseed → default 0).
2982 /// `pub(super)` — visible only within the `executor` module (sibling
2983 /// `node_record` calls it from `NodeBuilder::build`).
2984 pub(super) fn lookup_node_sched(
2985 &self,
2986 name: &str,
2987 namespace: &str,
2988 ) -> Option<super::sched_context::SchedContextId> {
2989 for entry in self.node_sched_table.iter() {
2990 if entry.0.as_str() == name && entry.1.as_str() == namespace {
2991 return Some(entry.2);
2992 }
2993 }
2994 None
2995 }
2996
2997 // =========================================================================
2998 // Phase 273 (RFC-0047) — per-callback-group → sched-context table
2999 // =========================================================================
3000
3001 /// Seed a config-resolved tier binding by `(name, namespace, group)` before
3002 /// entities are registered. `apply_node_default_sched` consults this table
3003 /// first (group table > node default > `SchedContextId(0)`).
3004 ///
3005 /// Call BEFORE entity creation. An existing entry for the same
3006 /// `(name, namespace, group)` key is overwritten (last-write wins). Overflow
3007 /// past `MAX_CBS` is an `Err`, never a dropped binding — issue 1172. An
3008 /// empty `namespace` is normalised to
3009 /// `"/"` to match `NodeBuilder::build`. Mirror of `bind_node_name_sched`.
3010 pub fn bind_group_sched(
3011 &mut self,
3012 name: &str,
3013 namespace: &str,
3014 group: &str,
3015 sc: super::sched_context::SchedContextId,
3016 ) -> Result<(), super::types::BindError> {
3017 let norm_ns = if namespace.is_empty() { "/" } else { namespace };
3018 // Overwrite if there is already an entry for this (name, ns, group).
3019 for entry in self.group_sched_table.iter_mut() {
3020 if entry.0.as_str() == name && entry.1.as_str() == norm_ns && entry.2.as_str() == group
3021 {
3022 entry.3 = sc;
3023 return Ok(());
3024 }
3025 }
3026 // Same contract as the two above.
3027 let mut name_s = heapless::String::<64>::new();
3028 let mut ns_s = heapless::String::<64>::new();
3029 let mut grp_s = heapless::String::<32>::new();
3030 name_s
3031 .push_str(name)
3032 .map_err(|_| super::types::BindError::NameTooLong)?;
3033 ns_s.push_str(norm_ns)
3034 .map_err(|_| super::types::BindError::NameTooLong)?;
3035 grp_s
3036 .push_str(group)
3037 .map_err(|_| super::types::BindError::NameTooLong)?;
3038 self.group_sched_table
3039 .push((name_s, ns_s, grp_s, sc))
3040 .map_err(|_| super::types::BindError::TableFull)?;
3041 Ok(())
3042 }
3043
3044 /// Look up the seeded sched-context for `(name, namespace, group)`. Returns
3045 /// `None` when the table has no entry for this triple.
3046 fn lookup_group_sched(
3047 &self,
3048 name: &str,
3049 namespace: &str,
3050 group: &str,
3051 ) -> Option<super::sched_context::SchedContextId> {
3052 for entry in self.group_sched_table.iter() {
3053 if entry.0.as_str() == name
3054 && entry.1.as_str() == namespace
3055 && entry.2.as_str() == group
3056 {
3057 return Some(entry.3);
3058 }
3059 }
3060 None
3061 }
3062
3063 // =========================================================================
3064 // Phase 110.B — SchedContext API
3065 // =========================================================================
3066
3067 /// Identifier of the auto-created default `Fifo`-class scheduling
3068 /// context. Every callback registered without an explicit
3069 /// [`Self::bind_handle_to_sched_context`] binds to this SC.
3070 pub fn default_sched_context_id(&self) -> super::sched_context::SchedContextId {
3071 super::sched_context::SchedContextId(0)
3072 }
3073
3074 /// Register a new scheduling context. Returns a [`SchedContextId`](crate::executor::sched_context::SchedContextId)
3075 /// callers pass to [`Self::bind_handle_to_sched_context`] to attach
3076 /// callbacks. Phase 110.B.
3077 pub fn create_sched_context(
3078 &mut self,
3079 sc: super::sched_context::SchedContext,
3080 ) -> Result<super::sched_context::SchedContextId, NodeError> {
3081 // Slot 0 is reserved for the default Fifo SC; search 1..MAX_SC.
3082 for (i, slot) in self.sched_contexts.iter_mut().enumerate().skip(1) {
3083 if slot.is_none() {
3084 *slot = Some(sc);
3085 // Phase 110.E — Sporadic-class SCs get a sibling
3086 // `SporadicState` entry that the spin_once dispatch
3087 // path consults each cycle to refill the budget at
3088 // period boundaries and skip dispatch when budget
3089 // is exhausted.
3090 if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
3091 let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
3092 let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
3093 self.sporadic_states[i] =
3094 Some(super::sched_context::SporadicState::new(budget, period));
3095 }
3096 return Ok(super::sched_context::SchedContextId(i as u8));
3097 }
3098 }
3099 Err(NodeError::NoSchedContextSlot)
3100 }
3101
3102 /// RFC-0052 W3b.2 — wall-clock µs since the UNIX epoch, when this
3103 /// target has an epoch source (config `epoch_us`, defaulted from
3104 /// `SystemTime` on hosted configs). `None` = no wall clock; age
3105 /// monitors must not have been baked (the emitter refuses).
3106 pub fn epoch_now_us(&self) -> Option<u64> {
3107 self.epoch_us_fn.map(|f| f())
3108 }
3109
3110 /// RFC-0052 W3b.4 — install the baked contract-monitor table. Call
3111 /// BEFORE entity creation so `create_publisher` can attach each
3112 /// contracted endpoint's counter cell. Mirrors `set_qos_overrides`:
3113 /// `&'static`, codegen-baked, empty by default.
3114 ///
3115 /// phase-496 W1 -- the executor watches at most
3116 /// [`MAX_MONITORS`](super::monitor::MAX_MONITORS) rows
3117 /// (`NROS_EXECUTOR_MAX_MONITORS`). A generated entry cannot reach this with
3118 /// more: its table carries a compile-time assertion that names the knob,
3119 /// and the C++ installer refuses at runtime. A hand-written longer table is
3120 /// a debug-build panic naming the knob, because in release the spin loop
3121 /// would inspect only the first `MAX_MONITORS` and leave the rest silently
3122 /// unwatched.
3123 pub fn set_monitor_table(&mut self, table: &'static [super::monitor::MonitorSpec]) {
3124 if let Err(full) = super::monitor::check_table_capacity(table.len(), 0)
3125 && cfg!(debug_assertions)
3126 {
3127 panic!("set_monitor_table: {full}");
3128 }
3129 self.monitor_table = table;
3130 }
3131
3132 /// phase-496 W1 -- install BOTH baked monitor tables, or neither.
3133 ///
3134 /// The refusing installer: a table longer than this executor watches
3135 /// (`MAX_MONITORS` / `MAX_AGE_MONITORS`) is `Err`, and its `Display` names
3136 /// the knob to raise, before anything is installed. Nothing is truncated:
3137 /// the spin loop inspects only the first `MAX_*` specs, so accepting a
3138 /// longer table would boot with its tail silently unwatched. The C ABI's
3139 /// `nros_cpp_install_monitors` refuses on the same check.
3140 pub fn try_set_monitor_tables(
3141 &mut self,
3142 table: &'static [super::monitor::MonitorSpec],
3143 ages: &'static [super::monitor::AgeMonitorSpec],
3144 ) -> Result<(), super::monitor::MonitorTableFull> {
3145 super::monitor::check_table_capacity(table.len(), ages.len())?;
3146 self.monitor_table = table;
3147 self.age_table = ages;
3148 Ok(())
3149 }
3150
3151 /// The installed monitor table (empty unless the entry set one).
3152 #[must_use]
3153 pub fn monitor_table(&self) -> &'static [super::monitor::MonitorSpec] {
3154 self.monitor_table
3155 }
3156
3157 /// Issue 1676 — the counter cell a publisher on `topic_name` bumps, by
3158 /// exact topic match against the installed table (`None` = uncontracted).
3159 pub(crate) fn publisher_monitor_cell(
3160 &self,
3161 topic_name: &str,
3162 ) -> Option<&'static super::monitor::PubMonitorCell> {
3163 self.monitor_table
3164 .iter()
3165 .find(|m| m.topic == topic_name)
3166 .map(|m| m.cell)
3167 }
3168
3169 /// W3b.5 — install the baked subscriber age-contract table. Call
3170 /// BEFORE entity creation so `create_subscription` can attach each
3171 /// contracted endpoint's age cell (needs an epoch source — see
3172 /// `ExecutorConfig::epoch_us`; without one the take path records
3173 /// nothing and age monitors stay silent).
3174 ///
3175 /// phase-496 W1 -- at most
3176 /// [`MAX_AGE_MONITORS`](super::monitor::MAX_AGE_MONITORS) rows
3177 /// (`NROS_EXECUTOR_MAX_AGE_MONITORS`), on the same terms as
3178 /// [`Self::set_monitor_table`].
3179 pub fn set_age_table(&mut self, table: &'static [super::monitor::AgeMonitorSpec]) {
3180 if let Err(full) = super::monitor::check_table_capacity(0, table.len())
3181 && cfg!(debug_assertions)
3182 {
3183 panic!("set_age_table: {full}");
3184 }
3185 self.age_table = table;
3186 }
3187
3188 /// The installed age table (empty unless the entry set one).
3189 #[must_use]
3190 pub fn age_table(&self) -> &'static [super::monitor::AgeMonitorSpec] {
3191 self.age_table
3192 }
3193
3194 /// phase-462 W3b — install the baked hazard table. Each row's guards name
3195 /// rows of the tables [`Self::set_monitor_table`] and
3196 /// [`Self::set_age_table`] install, so install those first; a guard whose
3197 /// row is absent never faults. The observer runs after the monitor rows on
3198 /// every monitor tick and reports `hazard-detected`, `hazard-reaction` and
3199 /// `hazard-recovered` on the same drain.
3200 pub fn set_hazard_table(&mut self, table: &'static [super::monitor::HazardSpec]) {
3201 self.hazard_table = table;
3202 }
3203
3204 /// The installed hazard table (empty unless the entry set one).
3205 #[must_use]
3206 pub fn hazard_table(&self) -> &'static [super::monitor::HazardSpec] {
3207 self.hazard_table
3208 }
3209
3210 /// W3b.5 — override the wall-clock (epoch µs) source age monitors take
3211 /// message stamps against. Hosted builds default to `SystemTime`; a
3212 /// board with a synced RTC installs its own here (or via
3213 /// `ExecutorConfig::epoch_us`). Call BEFORE entity creation — the age
3214 /// hook captures this at `create_subscription` time.
3215 pub fn set_epoch_clock(&mut self, epoch_us: fn() -> u64) {
3216 self.epoch_us_fn = Some(epoch_us);
3217 }
3218
3219 /// W3b.5 — resolve a subscription's age hook at registration time:
3220 /// exact topic match against the baked age table, only for stamped
3221 /// types (`M::STAMP_OFFSET`) and only when an epoch source exists.
3222 fn age_lookup<M: RosMessage>(&self, topic: &str) -> Option<super::arena::AgeMon> {
3223 M::STAMP_OFFSET?;
3224 let epoch = self.epoch_us_fn?;
3225 self.age_table
3226 .iter()
3227 .find(|a| a.topic == topic)
3228 .map(|a| (a.cell, epoch))
3229 }
3230
3231 /// W3b.5 — install the `DeadlineAction::Fault` hook. Without one a
3232 /// fault-class deadline miss panics (watchdog-visible stop on
3233 /// embedded targets).
3234 /// Plan one `spin_once` against the caller's intended cadence, and return
3235 /// the park budget in microseconds.
3236 ///
3237 /// phase-474 I11 -- two cases:
3238 ///
3239 /// * **No declared cadence, non-zero timeout** (the generated entries'
3240 /// `spin_once(10)` loop, `spin`, `spin_period`): the timeout is the
3241 /// period and the loop runs on an absolute RELEASE GRID. The budget is
3242 /// the time to the next grid point, not a whole period from now, so the
3243 /// previous spin's work no longer pushes every later release back. A
3244 /// loop that comes back after its next point was due is released at
3245 /// once, late by the difference; one that comes back a whole period or
3246 /// more after it is released once for the LATEST due point and the
3247 /// older ones are skipped and counted -- the timer `Skip` policy, never
3248 /// a burst. Lateness is judged at the release (`record_grid_release`).
3249 /// A change of timeout re-anchors the grid at this entry.
3250 /// * **A declared cadence** (`set_spin_nominal_us`, the tier loops that
3251 /// sleep between spins themselves): unchanged. Late is
3252 /// `(now - last_entry) - cadence`, clamped at zero, and the budget is
3253 /// the timeout.
3254 ///
3255 /// A zero timeout claims no cadence and is skipped entirely, which keeps
3256 /// `Future::wait`-style busy spins out of the statistic and off the grid.
3257 fn record_release_jitter(&mut self, timeout: core::time::Duration) -> u64 {
3258 let timeout_us = timeout.as_micros().min(u64::MAX as u128) as u64;
3259 if !self.spin_nominal_self_paced() {
3260 if timeout_us == 0 {
3261 return 0;
3262 }
3263 let Some(now) = self.now_us() else {
3264 return timeout_us;
3265 };
3266 // phase-436 A2b -- a wait-paced declaration supplies the grid's
3267 // period; otherwise the timeout stands in for it.
3268 let period_us = if self.spin_nominal_declared_us != 0 {
3269 self.spin_nominal_declared_us
3270 } else {
3271 timeout_us
3272 };
3273 self.spin_nominal_us = period_us;
3274 if self.grid.period_us != period_us {
3275 // First periodic spin, or a new period: anchor here, so the
3276 // first park is a whole period exactly as before.
3277 self.grid.period_us = period_us;
3278 self.grid.next_us = now.saturating_add(period_us);
3279 } else if now >= self.grid.next_us.saturating_add(period_us) {
3280 // The loop came back a whole period or more after its next
3281 // release was due: serve the latest due point once and drop
3282 // the ones before it. The first dropped point is a release
3283 // that never happened, so its lateness at this moment (a
3284 // period or more) is what the jitter rule judges.
3285 let missed = (now - self.grid.next_us) / period_us;
3286 self.note_dropped_release(now - self.grid.next_us);
3287 self.grid.skipped = self
3288 .grid
3289 .skipped
3290 .saturating_add(missed.min(u32::MAX as u64) as u32);
3291 self.grid.next_us = self
3292 .grid
3293 .next_us
3294 .saturating_add(missed.saturating_mul(period_us));
3295 }
3296 // The timeout still bounds the wait. With a declared period it
3297 // may be shorter than the period (a caller that passes whole
3298 // milliseconds for a 1.5 ms cadence); a wake before the grid
3299 // point is then not a release, and the next spin parks again.
3300 return self.grid.next_us.saturating_sub(now).min(timeout_us);
3301 }
3302 // phase-474 I7 -- the violation reporter's own time since the last
3303 // sample is not a late release (see `flush_violation_reports`). Taken
3304 // first, so a spin that judges nothing does not carry it forward.
3305 let report_us = core::mem::take(&mut self.violations.reports.debt_us);
3306 let nominal_us = self.spin_nominal_declared_us;
3307 let Some(now) = self.now_us() else {
3308 return timeout_us;
3309 };
3310 self.spin_nominal_us = nominal_us;
3311 if let Some(last) = self.last_spin_entry_us {
3312 let interval = now.saturating_sub(last).saturating_sub(report_us);
3313 self.total_wakes = self.total_wakes.saturating_add(1);
3314 if let Some(late) = interval.checked_sub(nominal_us)
3315 && late > 0
3316 {
3317 self.count_late_wake(late);
3318 }
3319 }
3320 self.last_spin_entry_us = Some(now);
3321 timeout_us
3322 }
3323
3324 /// phase-474 I11 -- judge a grid release, at the clock read after the
3325 /// park. A spin that wakes before its grid point (data arrived, a timer
3326 /// or another source was due first) is not a release: it dispatches and
3327 /// leaves the grid where it was. One that wakes at or after it is THE
3328 /// release of that point, late by `now - grid.next_us` (less the
3329 /// violation reporter's own time, phase-474 I7), and the grid advances by
3330 /// one period. Points the park itself overslept by a whole period are
3331 /// skipped at the next entry, not served in a burst.
3332 fn record_grid_release(&mut self, now: u64) {
3333 if self.spin_nominal_self_paced() || self.grid.period_us == 0 {
3334 return;
3335 }
3336 if now < self.grid.next_us {
3337 return;
3338 }
3339 let report_us = core::mem::take(&mut self.violations.reports.debt_us);
3340 let late = now
3341 .saturating_sub(self.grid.next_us)
3342 .saturating_sub(report_us);
3343 self.total_wakes = self.total_wakes.saturating_add(1);
3344 if late > 0 {
3345 self.count_late_wake(late);
3346 }
3347 self.grid.next_us = self.grid.next_us.saturating_add(self.grid.period_us);
3348 if now >= self.grid.next_us {
3349 // The park overslept the following point(s) too. This wake serves
3350 // one release; the points it ran past are skipped, not released
3351 // back to back on the next spins.
3352 let missed = (now - self.grid.next_us) / self.grid.period_us + 1;
3353 self.grid.skipped = self
3354 .grid
3355 .skipped
3356 .saturating_add(missed.min(u32::MAX as u64) as u32);
3357 self.grid.next_us = self
3358 .grid
3359 .next_us
3360 .saturating_add(missed.saturating_mul(self.grid.period_us));
3361 }
3362 }
3363
3364 /// One wake that came back `late_us` (> 0) after its release was due.
3365 fn count_late_wake(&mut self, late_us: u64) {
3366 self.late_wakes = self.late_wakes.saturating_add(1);
3367 if late_us > self.release_jitter_granularity_us() {
3368 self.late_wakes_beyond_granule = self.late_wakes_beyond_granule.saturating_add(1);
3369 }
3370 if late_us > self.max_release_jitter_us {
3371 self.max_release_jitter_us = late_us;
3372 }
3373 }
3374
3375 /// A cadence the caller meets with its own sleep between spins.
3376 fn spin_nominal_self_paced(&self) -> bool {
3377 self.spin_nominal_declared_us != 0 && !self.spin_nominal_wait_paced
3378 }
3379
3380 /// The lateness of a grid release that was dropped rather than served,
3381 /// into the high-water the jitter rule reads (not into the wake counts:
3382 /// no wake happened).
3383 fn note_dropped_release(&mut self, late_us: u64) {
3384 let late = late_us.saturating_sub(core::mem::take(&mut self.violations.reports.debt_us));
3385 if late > self.max_release_jitter_us {
3386 self.max_release_jitter_us = late;
3387 }
3388 }
3389
3390 /// phase-474 I11 -- grid releases skipped since the last
3391 /// `clear_release_jitter_stats`, because the loop came back after they
3392 /// were due. Zero for a declared-cadence or zero-timeout caller.
3393 pub fn skipped_releases(&self) -> u32 {
3394 self.grid.skipped
3395 }
3396
3397 /// Release-jitter statistics from the spin loop: worst lateness in
3398 /// microseconds, the number of wakes that were already late, and the
3399 /// number of wakes total.
3400 ///
3401 /// The maximum is the figure of merit, and the ratio is what tells the
3402 /// two failures apart: one late wake in ten thousand is a glitch, ten
3403 /// thousand in ten thousand means the period cannot be met at all.
3404 ///
3405 /// Zero on a build with no clock -- `spin_period` refuses to run at all
3406 /// there (issue 0709), so there is nothing to have measured.
3407 pub fn release_jitter(&self) -> (u64, u32, u32) {
3408 (
3409 self.max_release_jitter_us,
3410 self.late_wakes,
3411 self.total_wakes,
3412 )
3413 }
3414
3415 /// phase-436 A2c -- of the `late` wakes `release_jitter` counts, those
3416 /// late by more than [`Self::release_jitter_granularity_us`].
3417 ///
3418 /// A wake is late at one microsecond past its release. A loop paced by a
3419 /// tick-granular park comes back up to a tick past it on every wake, by
3420 /// design, so `late` approaches `total` on a loop that is keeping its
3421 /// period perfectly well. This count leaves that rounding out: it is the
3422 /// lateness the measurement can actually resolve. Cleared with the rest.
3423 pub fn late_wakes_beyond_granularity(&self) -> u32 {
3424 self.late_wakes_beyond_granule
3425 }
3426
3427 /// Reset the release-jitter statistics. For monitoring code that logs
3428 /// and clears per window, so a single early outlier does not pin the
3429 /// maximum for the life of the process.
3430 pub fn clear_release_jitter_stats(&mut self) {
3431 self.max_release_jitter_us = 0;
3432 self.late_wakes = 0;
3433 self.late_wakes_beyond_granule = 0;
3434 self.total_wakes = 0;
3435 self.last_spin_entry_us = None;
3436 self.jitter_reported_us = 0;
3437 self.grid.skipped = 0;
3438 }
3439
3440 pub fn set_fault_handler(&mut self, f: fn(&super::monitor::Violation)) {
3441 self.fault_fn = Some(f);
3442 }
3443
3444 /// phase-503 — install the REACTION hook: `f(event, ctx)` is called on the
3445 /// spin thread when a supervised route passes its deadline
3446 /// (`route-deadline-runtime`), with the row's reaction id, row index and
3447 /// mechanism. It also stands in for [`Self::set_fault_handler`] on a
3448 /// `DeadlineAction::Fault` when no fault handler is set (reaction id 0,
3449 /// the sched-context slot as the row), so a C or C++ image has a fault
3450 /// path that does not panic. Replaces any earlier hook; `None` removes it.
3451 ///
3452 /// The hook must not block and must not re-enter this executor: it starts
3453 /// the fallback, or hands it to another thread, and returns.
3454 pub fn set_reaction_hook(
3455 &mut self,
3456 hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
3457 ) {
3458 self.reaction_hook = hook;
3459 }
3460
3461 /// phase-503 — microseconds from now to the earliest deadline of a pending
3462 /// supervised route (0 when one is due), or `None` when none is waiting or
3463 /// the executor has no clock. A port that supervises with its own timer
3464 /// (`MECHANISM_TIMER`) re-arms it from this after each spin.
3465 pub fn route_next_due_us(&self) -> Option<u64> {
3466 // No table, no clock read: this runs on every park.
3467 if self.monitor_table.is_empty() {
3468 return None;
3469 }
3470 let clock = self.clock_us_fn?;
3471 super::monitor::route_next_due_us(self.monitor_table, clock())
3472 }
3473
3474 /// Issue #515 — warn about periods the spin cadence cannot express.
3475 ///
3476 /// A timer fires on the first spin at or after its period elapses, so
3477 /// a period that is not an integer multiple of the executor's spin
3478 /// period lands on the spin grid instead of the declared cadence: a
3479 /// 33 ms timer on a 5 ms spin alternates 35 ms / 30 ms, mean 33.0.
3480 /// The rate is preserved and no activation is dropped, so every
3481 /// runtime rule is (correctly) silent — the jitter stays invisible
3482 /// until someone measures cadence on target.
3483 ///
3484 /// Runs ONCE, on the first spin carrying a non-zero timeout, because
3485 /// that timeout is where the tier's declared spin period becomes
3486 /// visible down here. A resolve-time diagnostic would be better
3487 /// still — the toolchain holds both numbers before the image is
3488 /// built — but this backstop needs no board or codegen change and
3489 /// catches hand-written spin loops too.
3490 /// phase-436 W1 (issue 1192) — how long the executor may park, in
3491 /// microseconds, given the caller's budget.
3492 ///
3493 /// The park used to be bounded by the caller's timeout narrowed by exactly
3494 /// one input, the BACKEND's `next_deadline_ms`. A registered timer is a
3495 /// deadline the EXECUTOR owns, and nothing let it shorten the sleep — so
3496 /// `spin_default`'s 50 ms slept straight past a 10 ms timer, and the
3497 /// activations then arrived in the burst issue 0505 describes.
3498 ///
3499 /// This is the `min` over the deadline sources phase-436 names. The
3500 /// caller's budget is always a member, so the result is never unbounded
3501 /// and never zero-by-omission: with no timers registered the budget
3502 /// stands, which keeps a long idle wait a wait rather than a poll.
3503 ///
3504 /// Distinct from issue 0515's audit, which warns that a period does not
3505 /// divide the spin grid. That takes the grid as given; this moves it.
3506 ///
3507 /// `#[cfg(test)]`-gated (matching `mod tests`'s own gate in `mod.rs`):
3508 /// production code calls `next_wake_bound_attributed_us` directly to get
3509 /// the source alongside the bound, so this `.0`-only convenience has no
3510 /// caller outside the test module and is dead code in any build that
3511 /// does not compile it (every `cargo check`/`cargo doc`, and `cargo test
3512 /// --features rmw-cffi`, which excludes `mod tests` too).
3513 #[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
3514 pub(crate) fn next_wake_bound_us(&self, budget_us: u64) -> u64 {
3515 self.next_wake_bound_attributed_us(budget_us, None).0
3516 }
3517
3518 /// phase-436 W6 — contribute a deadline source.
3519 ///
3520 /// The core never names an RTOS: a port registers `k_poll`, `epoll`, a
3521 /// queue set or a device ISR here, and the executor only ever asks "when
3522 /// will you next need me?".
3523 ///
3524 /// Refuses past capacity rather than dropping silently. A source that was
3525 /// quietly discarded would leave the executor sleeping past a deadline it
3526 /// had been told about, which is the failure this whole phase exists to
3527 /// remove.
3528 pub fn register_wake_source(
3529 &mut self,
3530 next: NextDeadlineFn,
3531 ctx: *mut core::ffi::c_void,
3532 ) -> Result<WakeSourceId, NodeError> {
3533 if self.wake_source_count >= MAX_WAKE_SOURCES {
3534 return Err(NodeError::ExecutorFull);
3535 }
3536 let idx = self.wake_source_count;
3537 self.wake_sources[idx] = Some(WakeSourceSlot { next, ctx });
3538 self.wake_source_count += 1;
3539 Ok(WakeSourceId::Platform(idx as u8))
3540 }
3541
3542 /// Install THE thing that blocks. Singular by nature — only one primitive
3543 /// can actually wait — so this replaces whatever was there.
3544 /// `granularity_us` is the finest park this primitive can actually
3545 /// express. The port states it because only the port knows: the ABI
3546 /// signature is not the limit — an RTOS tick is a second, coarser one, and
3547 /// a timespec primitive is a finer one.
3548 pub fn set_park_primitive(
3549 &mut self,
3550 park: ParkUntilFn,
3551 ctx: *mut core::ffi::c_void,
3552 granularity_us: u64,
3553 ) {
3554 self.park_primitive = Some((park, ctx));
3555 self.park_granularity_declared_us = granularity_us;
3556 }
3557
3558 /// The wake primitive a port must hand its `ParkUntilFn` as `ctx`, or NULL
3559 /// when this build has none.
3560 ///
3561 /// phase-436 W7 — a park that waits on its own fresh object is NOT
3562 /// breakable by the backend's listener, and installing one makes latency
3563 /// worse: once a port has parked, the transport drain drops to
3564 /// non-blocking, so an unbreakable park sleeps out its full deadline with
3565 /// data already waiting. Waiting on THIS object keeps the async break.
3566 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3567 pub fn wake_raw_ptr(&self) -> *mut core::ffi::c_void {
3568 match self.node_wake.as_ref() {
3569 Some(w) => w.raw_ptr(),
3570 None => core::ptr::null_mut(),
3571 }
3572 }
3573
3574 /// Whether a platform park primitive is installed.
3575 pub fn has_park_primitive(&self) -> bool {
3576 self.park_primitive.is_some()
3577 }
3578
3579 /// The bound and the source that produced it: the `min` over every
3580 /// deadline source, with the caller's budget as an ordinary member.
3581 ///
3582 /// The budget being a member is what makes the result never unbounded and
3583 /// never zero-by-omission, and it is why "no sources registered" needs no
3584 /// special case.
3585 pub(crate) fn next_wake_bound_attributed_us(
3586 &self,
3587 budget_us: u64,
3588 session_us: Option<u64>,
3589 ) -> (u64, WakeSourceId) {
3590 let mut best = (budget_us, WakeSourceId::CallerBudget);
3591
3592 // The backend's next internal event is a MEMBER, not a pre-cap. It was
3593 // folded into the budget upstream, which meant a park the session
3594 // actually won reported `CallerBudget` — the attribution named the
3595 // wrong source, and "why did we wake" is the question attribution
3596 // exists to answer.
3597 if let Some(s) = session_us
3598 && s < best.0
3599 {
3600 best = (s, WakeSourceId::Session);
3601 }
3602
3603 if let Some(timer_us) = self.next_timer_deadline_us()
3604 && timer_us < best.0
3605 {
3606 best = (timer_us, WakeSourceId::Timer);
3607 }
3608
3609 // phase-503 — a supervised route's deadline is the executor's own, like
3610 // a timer's: the park must end there, or a route whose output never
3611 // comes is judged only when something else wakes the loop.
3612 if let Some(due_us) = self.route_next_due_us()
3613 && due_us < best.0
3614 {
3615 best = (due_us, WakeSourceId::RouteDeadline);
3616 }
3617
3618 for (i, slot) in self.wake_sources.iter().enumerate() {
3619 let Some(slot) = slot else { continue };
3620 // SAFETY: the callback and ctx were supplied together by the port
3621 // through `register_wake_source` and outlive the executor by its
3622 // contract; the call takes no ownership.
3623 let due = unsafe { (slot.next)(slot.ctx) };
3624 // `u64::MAX` is "nothing pending" — an async source spells itself
3625 // this way and breaks the park by signalling instead.
3626 if due != u64::MAX && due < best.0 {
3627 best = (due, WakeSourceId::Platform(i as u8));
3628 }
3629 }
3630
3631 best
3632 }
3633
3634 /// phase-436 — the bound the last park used, and which source won it.
3635 pub fn last_park(&self) -> (u64, WakeSourceId) {
3636 (self.last_park_bound_us, self.last_park_source)
3637 }
3638
3639 /// phase-436 W2 — the park the platform was actually asked for, after
3640 /// rounding the request up to an expressible value.
3641 ///
3642 /// Differs from `last_park().0` exactly when the request did not land on
3643 /// the platform's grid. Both are reported because a cadence that cannot be
3644 /// waited in must not be presented as if it were met.
3645 pub fn last_park_achieved_us(&self) -> u64 {
3646 self.last_park_achieved_us
3647 }
3648
3649 /// phase-436 W3 (issue 1194) — the granularity the release-jitter
3650 /// measurement was actually made at, in microseconds.
3651 ///
3652 /// The jitter statistic is reported in microseconds, but the mechanism
3653 /// that PACES the loop decides how fine it can honestly be, and there are
3654 /// two mechanisms:
3655 ///
3656 /// * **No declared cadence** — the loop is paced by the blocking wait
3657 /// itself, so it cannot come round more precisely than one park granule.
3658 /// Reporting µs there is precision the wait cannot deliver.
3659 /// * **A declared cadence** (`set_spin_nominal_us`) — the caller paces
3660 /// itself, as nros-cpp's tiers do with `platform_sleep_us` between
3661 /// spins. That sleep is finer than the wait, so the measurement really
3662 /// is good to a microsecond and coarsening it would understate it.
3663 ///
3664 /// Reported rather than applied: this does not change what the monitor
3665 /// judges, because silently coarsening a bound could mask real lateness.
3666 /// It says what the number is worth.
3667 pub fn release_jitter_granularity_us(&self) -> u64 {
3668 if self.spin_nominal_self_paced() {
3669 1
3670 } else {
3671 self.park_granularity_us()
3672 }
3673 }
3674
3675 /// The finest park this build can actually express, in microseconds.
3676 ///
3677 /// Comes from the INSTALLED primitive, because only the port knows. The
3678 /// exported ABI signature is not the limit in either direction (issue
3679 /// 1242):
3680 ///
3681 /// * an RTOS tick is a COARSER limit — ThreadX pins
3682 /// `TX_TIMER_TICKS_PER_SECOND = 100`, a 10 ms tick, so a 1 ms park is not
3683 /// achievable there however the ABI is spelled;
3684 /// * a timespec primitive is a FINER one — POSIX's `sem_timedwait` and
3685 /// `pthread_cond_timedwait` are nanosecond-native, and only the
3686 /// `uint32_t timeout_ms` signature floors them at a millisecond.
3687 ///
3688 /// This was a hardcoded `1_000` justified by "every `nros_platform_wake_*`
3689 /// port takes `uint32_t timeout_ms`". That was right for FreeRTOS by
3690 /// coincidence and wrong for the other two, and it made the W3 jitter
3691 /// report over-claim on ThreadX — the failure W3 exists to prevent,
3692 /// reproduced one layer below W3 in its own dependency.
3693 ///
3694 /// With nothing installed the fallback is 1 ms, which IS the honest floor
3695 /// of the `wake_wait_ms` ABI every port exports today.
3696 ///
3697 /// The declared granularity counts only while the park is USABLE. An
3698 /// executor whose backend has no live wake (XRCE, or a session whose
3699 /// callback is gone) never parks: `spin_once` waits in the transport's
3700 /// own receive instead, whose timeout is whole milliseconds. Claiming a
3701 /// POSIX park's 1 us there over-claimed the jitter report, and worse,
3702 /// rounded the bound to microseconds that the millisecond wait then
3703 /// truncated, so a sub-millisecond deadline became a non-blocking receive
3704 /// and the loop spun hot until it passed.
3705 pub fn park_granularity_us(&self) -> u64 {
3706 if self.park_granularity_declared_us != 0
3707 && self.park_primitive.is_some()
3708 && self.park_can_be_woken()
3709 {
3710 self.park_granularity_declared_us
3711 } else {
3712 1_000
3713 }
3714 }
3715
3716 /// phase-436 — whether a park could be ended early by data arriving.
3717 ///
3718 /// A park ends on its bound or on the wake object. With a poll-only
3719 /// backend (XRCE), or a session whose wake callback is gone, nothing
3720 /// signals that object, so a park would be a blind sleep and data
3721 /// arriving mid-park would wait out the whole bound — the Phase 127.C.4
3722 /// stall (a reliable stream whose ACK lands in a session nobody drives).
3723 /// Builds without `alloc` + `rmw-cffi` have no wake callback at all and
3724 /// park as they always did.
3725 pub(crate) fn park_can_be_woken(&self) -> bool {
3726 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3727 {
3728 self.async_wake_live()
3729 }
3730 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
3731 {
3732 true
3733 }
3734 }
3735
3736 /// Drive the primary session for `timeout_ms`, the transport-side half of
3737 /// the wait.
3738 ///
3739 /// phase-436 (RFC-0106 D5) — on a poll-only backend this wait IS the
3740 /// executor's park, and it waits inside the backend's own `recv`, which a
3741 /// guard condition triggered from another thread cannot end: the trigger
3742 /// signals the wake object and nobody is waiting on it. Measured on XRCE
3743 /// before this: a trigger 20 ms into a 100 / 500 / 1000 ms spin was
3744 /// dispatched 79 / 480 / 979 ms later, i.e. it waited out the bound. So
3745 /// when such a waker exists and nothing can wake the park, the drive is
3746 /// cut into slices of [`POLL_ONLY_WAKE_SLICE_MS`] and stops at the first
3747 /// slice that ends with the wake flag set. The latency becomes one slice;
3748 /// an executor with no cross-thread waker keeps the single blocking drive.
3749 fn drive_primary(&mut self, timeout_ms: i32) -> Result<(), ()> {
3750 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3751 if self.has_cross_thread_waker
3752 && !self.async_wake_live()
3753 && timeout_ms > POLL_ONLY_WAKE_SLICE_MS
3754 {
3755 let mut remaining = timeout_ms;
3756 while remaining > 0 {
3757 let slice = remaining.min(POLL_ONLY_WAKE_SLICE_MS);
3758 self.session.drive_io(slice).map_err(|_| ())?;
3759 remaining -= slice;
3760 if self
3761 .wake_flag
3762 .swap(false, portable_atomic::Ordering::SeqCst)
3763 {
3764 // Issue 1385 — consume the primitive too, or the next
3765 // spin's wait returns at once on a signal already acted on.
3766 if let Some(wake) = self.node_wake.as_ref() {
3767 let _ = wake.wait_ms(0);
3768 }
3769 break;
3770 }
3771 }
3772 return Ok(());
3773 }
3774 self.session.drive_io(timeout_ms).map_err(|_| ())
3775 }
3776
3777 pub(crate) fn round_park_up_us(&self, requested_us: u64) -> u64 {
3778 if requested_us == 0 {
3779 return 0;
3780 }
3781 let g = self.park_granularity_us();
3782 if g <= 1 {
3783 return requested_us;
3784 }
3785 requested_us.div_ceil(g).saturating_mul(g)
3786 }
3787
3788 /// Microseconds until the soonest live timer is due, or `None` when no
3789 /// timer is pending.
3790 ///
3791 /// A timer that is cancelled, or a one-shot that already fired, owes
3792 /// nothing and must not hold the park short — otherwise the executor keeps
3793 /// waking on a deadline nobody is waiting for.
3794 ///
3795 /// So does a timer whose remainder is not WALL microseconds
3796 /// ([`TimerClockSource::remaining_is_wall_time`], issue 1321): the number
3797 /// this returns is handed to the port's park primitive, so a ROS-time
3798 /// timer's simulated remainder would be a units error — and on a paused
3799 /// `/clock` a frozen one, waking the image once per timer period forever.
3800 /// Its wake source is a `/clock` message, which reaches `drive_io` like
3801 /// any sample.
3802 pub(crate) fn next_timer_deadline_us(&self) -> Option<u64> {
3803 let arena_ptr = self.arena.as_ptr() as *const u8;
3804 let mut soonest: Option<u64> = None;
3805 for meta in self.entries.iter().flatten() {
3806 if !matches!(meta.kind, EntryKind::Timer) {
3807 continue;
3808 }
3809 // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`, whose
3810 // leading layout is `TimerHeader` — the same projection
3811 // `audit_spin_quantization` relies on.
3812 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
3813 if header.cancelled || header.period_us == 0 {
3814 continue;
3815 }
3816 if header.oneshot && header.fired {
3817 continue;
3818 }
3819 // Issue 1321 — the park is WALL microseconds, so only a timer
3820 // whose own clock runs in them may bound it.
3821 if !header.clock_source.remaining_is_wall_time() {
3822 continue;
3823 }
3824 // Already due: the bound is zero, and no other timer can beat it.
3825 let remaining = header.period_us.saturating_sub(header.elapsed_us);
3826 soonest = Some(match soonest {
3827 Some(cur) => cur.min(remaining),
3828 None => remaining,
3829 });
3830 if remaining == 0 {
3831 break;
3832 }
3833 }
3834 soonest
3835 }
3836
3837 fn audit_spin_quantization(&mut self, spin_us: u64) {
3838 if spin_us == 0 {
3839 return;
3840 }
3841 let arena_ptr = self.arena.as_ptr() as *const u8;
3842 for i in 0..self.entries.len() {
3843 let Some(meta) = self.entries[i].as_ref() else {
3844 continue;
3845 };
3846 if !matches!(meta.kind, EntryKind::Timer) {
3847 continue;
3848 }
3849 // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
3850 // whose leading layout is `TimerHeader`.
3851 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
3852 let period_us = header.period_us;
3853 let Some((early_us, late_us)) =
3854 spin_quantization_span(header.clock_source, period_us, spin_us)
3855 else {
3856 continue;
3857 };
3858 nros_log::log_warn!(
3859 nros_log::get_logger("nros"),
3860 "timer period {} us is not a multiple of the {} us spin period: activations will alternate between {} us and {} us (mean cadence preserved)",
3861 period_us,
3862 spin_us,
3863 early_us,
3864 late_us
3865 );
3866 }
3867 }
3868
3869 /// Issue #514 — whether the executor logs each violation as it is
3870 /// detected (the default).
3871 ///
3872 /// Turn this off in an application that reports violations its own
3873 /// way via [`Self::drain_violations`]; the ring is unaffected
3874 /// either way.
3875 pub fn set_report_violations(&mut self, enabled: bool) {
3876 self.violations.report = enabled;
3877 }
3878
3879 /// phase-474 I1 -- the drain-and-report hook: at the end of every spin,
3880 /// drain the ring and log each entry with its sequence number and the
3881 /// `total` / `dropped` counters (`contract violation #N: <rule> <fqn>
3882 /// measured=.. declared=.. (total=.. dropped=..)`).
3883 ///
3884 /// While on it REPLACES the log line at detection, so one verdict is one
3885 /// line, and an application that wanted to drain the ring itself sees an
3886 /// empty one. The generated C/C++ entries turn it on when the image sets
3887 /// `CONFIG_NROS_VIOLATION_DRAIN_REPORT` (off by default). The trace marker
3888 /// (`callback_trace::MARKER_VIOLATION`) and the SWD record do not depend
3889 /// on it: both are written at detection.
3890 pub fn set_violation_drain_report(&mut self, enabled: bool) {
3891 self.violations.drain_report = enabled;
3892 }
3893
3894 /// Issue #514 / phase-474 I1 -- violations evicted from the full ring
3895 /// before any drain took them.
3896 ///
3897 /// The ring keeps the LATEST `MAX_VIOLATIONS`, so non-zero means the
3898 /// OLDEST verdicts were lost, never the newest. (Before phase-474 a full
3899 /// ring refused the push, which kept the first verdicts since boot and
3900 /// lost every later one.)
3901 pub fn violations_dropped(&self) -> u32 {
3902 self.violations.counts.dropped
3903 }
3904
3905 /// phase-474 I1 -- contract violations this executor stored since boot
3906 /// (wrapping). The newest entry in the ring is number `violations_total()`;
3907 /// `total - dropped - pending` is how many drains have taken.
3908 pub fn violations_total(&self) -> u32 {
3909 self.violations.counts.total
3910 }
3911
3912 /// RFC-0052 W3b.4 -- drain pending contract violations, OLDEST first.
3913 /// Draining clears the ring. Call between spins, never from a callback.
3914 pub fn drain_violations(&mut self, mut f: impl FnMut(&super::monitor::Violation)) {
3915 self.violations.drain(|_, v| f(v));
3916 }
3917
3918 /// [`Self::drain_violations`] with each entry's sequence number (1-based,
3919 /// per executor; the same number the drain-and-report line and the trace
3920 /// marker carry), so a reader can tell a gap from a quiet interval.
3921 pub fn drain_violations_numbered(&mut self, f: impl FnMut(u32, &super::monitor::Violation)) {
3922 self.violations.drain(f);
3923 }
3924
3925 /// phase-474 I2 -- choose when this executor's contract monitors arm,
3926 /// overriding the build default (`NROS_MONITOR_ARM_ON_CALL`,
3927 /// `NROS_MONITOR_ARM_GRACE_MS`). Call before the first spin (an
3928 /// `arm_monitors` made before it is undone by this); after the first spin
3929 /// an executor that already armed stays armed.
3930 ///
3931 /// `on_call`: wait for [`Self::arm_monitors`] or
3932 /// [`super::monitor::request_monitor_arming`] (`rclc_monitors_arm()`).
3933 /// `grace_ms`: arm anyway that long after the first spin (0 = never by
3934 /// deadline; with `on_call == false`, 0 means "at the first spin").
3935 pub fn set_monitor_arming(&mut self, on_call: bool, grace_ms: u32) {
3936 let v = &mut self.violations.counts;
3937 v.arm_on_call = on_call;
3938 v.arm_grace_ms = grace_ms;
3939 if !v.spun {
3940 // Before the first spin, "armed" is just the policy's answer (no
3941 // rule has run yet); the first spin counts the arming once.
3942 v.armed = super::monitor::MonitorArming { on_call, grace_ms }.armed_at_first_spin();
3943 }
3944 }
3945
3946 /// phase-474 I2 -- the application says its start-up is over: this
3947 /// executor's monitors start judging now. Idempotent.
3948 ///
3949 /// Judgement restarts at arming: every rate/age/silence window reopens,
3950 /// a latency or age measured before arming is discarded, the release-
3951 /// jitter statistics are cleared (so the figure after RUN is RUN's), and a
3952 /// stack low-water mark already past the declared minimum is reported
3953 /// again, because stack depth reached at start-up is a real fault, not
3954 /// start-up noise.
3955 pub fn arm_monitors(&mut self) {
3956 self.arm_now("the application armed them");
3957 }
3958
3959 /// phase-474 I2 -- whether this executor's monitors are armed.
3960 pub fn monitors_armed(&self) -> bool {
3961 self.violations.counts.armed
3962 }
3963
3964 /// phase-474 I2 -- verdicts reached before the monitors armed: counted,
3965 /// never stored, logged, sunk or traced.
3966 pub fn violations_suppressed_before_arm(&self) -> u32 {
3967 self.violations.counts.suppressed_before_arm
3968 }
3969
3970 fn arm_now(&mut self, why: &str) {
3971 if self.violations.counts.armed {
3972 return;
3973 }
3974 self.violations.counts.armed = true;
3975 for st in self.monitor_states.iter_mut() {
3976 *st = super::monitor::MonitorState::default();
3977 }
3978 for st in self.age_states.iter_mut() {
3979 *st = super::monitor::AgeState::default();
3980 }
3981 for spec in self.monitor_table.iter() {
3982 spec.cell
3983 .max_latency_us
3984 .store(0, core::sync::atomic::Ordering::Relaxed);
3985 }
3986 // phase10-B3 -- a take waiting since start-up is not a route of RUN's.
3987 super::monitor::route_reset(self.monitor_table);
3988 for spec in self.age_table.iter() {
3989 spec.cell
3990 .max_age_ms
3991 .store(0, core::sync::atomic::Ordering::Relaxed);
3992 spec.cell
3993 .takes
3994 .store(0, core::sync::atomic::Ordering::Relaxed);
3995 }
3996 self.clear_release_jitter_stats();
3997 self.stack_headroom_reported = usize::MAX;
3998 self.stack_headroom_last_check_us = None;
3999 // Counted once per executor: here once it has spun, else by the first
4000 // spin (which counts an executor that is armed by then either way).
4001 #[cfg(nros_boot_report)]
4002 if self.violations.counts.spun {
4003 super::monitor::NROS_VIOLATION_RECORD
4004 .armed
4005 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
4006 }
4007 nros_log::log_info!(
4008 nros_log::get_logger("nros"),
4009 "contract monitors armed ({}); {} verdict(s) before arming suppressed",
4010 why,
4011 self.violations.counts.suppressed_before_arm
4012 );
4013 }
4014
4015 /// phase-474 I2 -- once per spin, before any rule runs: note the first
4016 /// spin, and arm when the policy says so.
4017 fn update_monitor_arming(&mut self, now_us: Option<u64>) {
4018 // Milliseconds in a wrapping `u32`, like the silence rule's lease
4019 // (`AgeState::last_take_ms`): this state is inline in the `Executor`
4020 // value, whose size is budgeted (issue 0961).
4021 let now_ms = now_us.map(|us| (us / 1_000) as u32);
4022 let v = &mut self.violations.counts;
4023 if !v.spun {
4024 v.spun = true;
4025 v.first_spin_ms = now_ms.unwrap_or(0);
4026 if v.armed {
4027 // Armed from the start (the default), or by a call made before
4028 // the first spin: count it in the record so a reader sees how
4029 // many executors are judging.
4030 #[cfg(nros_boot_report)]
4031 super::monitor::NROS_VIOLATION_RECORD
4032 .armed
4033 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
4034 return;
4035 }
4036 if (super::monitor::MonitorArming {
4037 on_call: v.arm_on_call,
4038 grace_ms: v.arm_grace_ms,
4039 })
4040 .armed_at_first_spin()
4041 {
4042 self.arm_now("at the first spin");
4043 return;
4044 }
4045 }
4046 if v.armed {
4047 return;
4048 }
4049 if super::monitor::arm_requests() != v.arm_requests_seen {
4050 self.arm_now("the application asked: rclc_monitors_arm");
4051 return;
4052 }
4053 let grace_ms = v.arm_grace_ms;
4054 if grace_ms == 0 {
4055 return;
4056 }
4057 match now_ms {
4058 Some(now) if now.wrapping_sub(v.first_spin_ms) >= grace_ms => {
4059 self.arm_now("the grace period after the first spin elapsed");
4060 }
4061 // No clock: a grace period cannot elapse, and waiting forever would
4062 // leave the image unmonitored. Arm rather than guess a duration.
4063 None if !v.arm_on_call => {
4064 self.arm_now("no clock to time the grace period");
4065 }
4066 _ => {}
4067 }
4068 }
4069
4070 /// Issue 1635 -- hand every stored violation to `sink`, beside the log line
4071 /// (issue 0514's floor) and the ring push.
4072 ///
4073 /// phase-474 I7 -- not at detection any more: the verdict is queued and
4074 /// leaves from `Executor::flush_violation_reports` at the end of a spin with
4075 /// slack, so the report never costs the dispatch it judges.
4076 ///
4077 /// Before this nothing in a generated image drained the ring, so the log
4078 /// line was the only output and `/diagnostics` stayed silent on every C
4079 /// and C++ road. A report point the RUNTIME owns reaches every road at
4080 /// once — every board's spin loop goes through the executor — where one
4081 /// per entry template would have to be rebuilt for each board runner. The
4082 /// ring is still filled, so an application (or a fixture) that drains it
4083 /// with [`Self::drain_violations`] sees exactly what it did before.
4084 /// `None` removes the sink.
4085 ///
4086 /// # Safety
4087 /// `ctx` must stay valid for every call of the sink until it is replaced
4088 /// or this executor is dropped (see [`super::monitor::ViolationSink`]).
4089 pub unsafe fn set_violation_sink(
4090 &mut self,
4091 sink: Option<(super::monitor::ViolationSink, *mut core::ffi::c_void)>,
4092 ) {
4093 self.violations.sink = sink.map(|(f, ctx)| (f, ctx as usize));
4094 if self.violations.sink.is_none() {
4095 // Nothing is left to send them to.
4096 self.violations.reports.pending.clear();
4097 self.violations.reports.since_us = None;
4098 }
4099 }
4100
4101 /// phase-474 I7 -- hand the queued verdicts to the sink, if this is a slot
4102 /// for it. Called at the end of every spin, after its dispatches and its
4103 /// rules, so the report never runs inside the tick being judged.
4104 ///
4105 /// A slot is a spin end whose next timer is due no sooner than the last
4106 /// report took (`ReportQueue::last_cost_us`; 0 before the first report, so
4107 /// the first goes at the first spin end with no timer already due), or
4108 /// any spin end once the oldest pending verdict has waited
4109 /// [`REPORT_DEFER_MAX_US`](super::monitor::REPORT_DEFER_MAX_US). Without a
4110 /// clock there is nothing to measure slack with, so every spin end is a
4111 /// slot. The sink takes as many as it coalesces into one message; the rest
4112 /// wait for the next slot.
4113 ///
4114 /// The time the sink takes is the reporter's own and is stated by
4115 /// [`Self::violation_report_cost_us`]; the release-jitter rule does not
4116 /// charge it to the spin's cadence (it is not a late release of any
4117 /// application work), which is what used to turn one verdict into a
4118 /// `release-jitter-runtime` verdict of its own on the safety island.
4119 pub(crate) fn flush_violation_reports(&mut self) {
4120 let Some((sink, ctx)) = self.violations.sink else {
4121 return;
4122 };
4123 if self.violations.reports.pending.is_empty() {
4124 return;
4125 }
4126 let now = self.now_us();
4127 if let Some(now) = now {
4128 let since = *self.violations.reports.since_us.get_or_insert(now);
4129 let overdue = now.saturating_sub(since) >= super::monitor::REPORT_DEFER_MAX_US;
4130 let cost = self.violations.reports.last_cost_us as u64;
4131 let slack = match self.next_timer_deadline_us() {
4132 None => true,
4133 Some(remaining) => remaining > 0 && remaining >= cost,
4134 };
4135 if !slack && !overdue {
4136 return;
4137 }
4138 }
4139 // SAFETY: `Executor::set_violation_sink`'s contract -- `ctx` is valid
4140 // for every call until the sink is replaced or the executor dropped.
4141 let taken = unsafe {
4142 sink(
4143 ctx as *mut core::ffi::c_void,
4144 &self.violations.reports.pending[..],
4145 )
4146 };
4147 let q = &mut self.violations.reports;
4148 if taken == 0 {
4149 // The sink counted its failure; drop the oldest so a report that
4150 // can never be sent does not stall the queue. It stays in the ring
4151 // and the record.
4152 q.pending.remove(0);
4153 q.failed = q.failed.saturating_add(1);
4154 } else {
4155 let n = taken.min(q.pending.len());
4156 for _ in 0..n {
4157 q.pending.remove(0);
4158 }
4159 }
4160 q.since_us = None;
4161 if let (Some(t0), Some(t1)) = (now, self.now_us()) {
4162 let q = &mut self.violations.reports;
4163 let cost = t1.saturating_sub(t0);
4164 q.last_cost_us = cost.min(u32::MAX as u64) as u32;
4165 q.max_cost_us = q.max_cost_us.max(q.last_cost_us);
4166 q.debt_us = q.debt_us.saturating_add(cost);
4167 }
4168 }
4169
4170 /// phase-474 I7 -- what handing verdicts to the violation sink has cost
4171 /// this executor: `(last_us, max_us, pending, overflowed)`. `overflowed`
4172 /// counts verdicts evicted from the report queue by newer ones before a
4173 /// slot came (they are still in the ring and the SWD record).
4174 pub fn violation_report_cost_us(&self) -> (u32, u32, usize, u32) {
4175 let q = &self.violations.reports;
4176 (q.last_cost_us, q.max_cost_us, q.pending.len(), q.overflowed)
4177 }
4178
4179 /// THE monotonic-µs read. phase-359 W4 — every consumer goes through here.
4180 ///
4181 /// Before this there were FIVE spellings of "what time is it": this one,
4182 /// `last_spin_end: Instant`, and two ad-hoc `static EPOCH: OnceLock<Instant>`
4183 /// blocks — plus `PlatformClock`, which nothing called because `Executor` is
4184 /// deliberately non-generic and the trait's methods are associated fns.
4185 ///
4186 /// `None` means no clock is available (no_std with no injected hook).
4187 /// Callers must degrade, not guess: a missing clock is why the sporadic
4188 /// refill and the major-frame phase behaved differently on no_std.
4189 fn now_us(&mut self) -> Option<u64> {
4190 self.clock_us_fn.map(|clock| clock())
4191 }
4192
4193 /// Run the rate/latency/age checks over the baked tables (single
4194 /// branch each when empty).
4195 fn run_contract_monitors(&mut self) {
4196 if !self.monitor_table.is_empty()
4197 && let Some(now_us) = self.now_us()
4198 {
4199 {
4200 for (i, spec) in self
4201 .monitor_table
4202 .iter()
4203 .take(super::monitor::MAX_MONITORS)
4204 .enumerate()
4205 {
4206 if let Some(v) =
4207 super::monitor::check_rate(spec, &mut self.monitor_states[i], now_us)
4208 {
4209 self.violations.record(v);
4210 }
4211 if let Some(v) =
4212 super::monitor::check_latency(spec, &mut self.monitor_states[i])
4213 {
4214 self.violations.record(v);
4215 }
4216 }
4217 }
4218 }
4219 if !self.age_table.is_empty() {
4220 // phase-462 W2 -- the age loop needs the clock now: `silence-runtime`
4221 // judges how long an endpoint has taken NOTHING, and `None` (a
4222 // build with no clock) makes that rule abstain rather than guess.
4223 // Read before the loop because the borrow of `age_states` below
4224 // outlives the `&mut self` the reader wants.
4225 let now_us = self.now_us();
4226 for (i, spec) in self
4227 .age_table
4228 .iter()
4229 .take(super::monitor::MAX_AGE_MONITORS)
4230 .enumerate()
4231 {
4232 if let Some(v) = super::monitor::check_age(spec, &mut self.age_states[i], now_us) {
4233 self.violations.record(v);
4234 }
4235 }
4236 }
4237 // phase-462 W3b -- the hazard observer reads the verdict STATE the rows
4238 // above just updated, so it runs after them and on the same tick.
4239 if !self.hazard_table.is_empty()
4240 && let Some(now_us) = self.now_us()
4241 {
4242 let now_ms = (now_us / 1_000) as u32;
4243 let violations = &mut self.violations;
4244 for spec in self.hazard_table {
4245 super::monitor::check_hazard(
4246 spec,
4247 self.monitor_table,
4248 &self.monitor_states,
4249 &self.age_states,
4250 now_ms,
4251 |v| violations.record(v),
4252 );
4253 }
4254 }
4255 self.check_route_deadlines();
4256 }
4257
4258 /// phase-503 — `route-deadline-runtime`: every pending supervised route
4259 /// past `take + route_deadline_ms` is reported (once until a route is
4260 /// within budget again) and its reaction requested through the hook.
4261 /// Runs right after the park, whose bound includes the earliest due time
4262 /// ([`WakeSourceId::RouteDeadline`]), so a route whose output never comes
4263 /// is judged within one park granule of its deadline. A callback that does
4264 /// not return holds this check too: that is `MECHANISM_TIMER`'s case.
4265 fn check_route_deadlines(&mut self) {
4266 let table = self.monitor_table;
4267 if table.is_empty() || !super::monitor::route_deadline_active(table) {
4268 return;
4269 }
4270 let Some(now_us) = self.now_us() else {
4271 return;
4272 };
4273 // Start-up is not RUN (phase-474 I2): before arming a verdict is only
4274 // counted, and no reaction is requested for it either.
4275 let hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
4276 let violations = &mut self.violations;
4277 super::monitor::route_overdue(table, now_us, |row, v, first| {
4278 call_route_reaction(hook, table, row, &v);
4279 if first {
4280 violations.record(v);
4281 }
4282 });
4283 }
4284
4285 /// Issue #505 — report activations dropped by
4286 /// [`TimerOverrunPolicy::Skip`](super::arena::TimerOverrunPolicy)
4287 /// since the last check.
4288 ///
4289 /// Unlike the rate/age/latency rules this needs no baked spec table:
4290 /// every periodic timer counts its own overruns, and a dropped
4291 /// activation is a contract failure for any declared period. It runs
4292 /// on the same tick so violations land in the same ring the entry
4293 /// glue drains.
4294 /// Declare the minimum stack headroom this executor's thread must keep,
4295 /// in bytes. `0` (the default) disables the `stack-headroom-runtime`
4296 /// rule.
4297 ///
4298 /// Set by the entry that spawned the thread, because it is the only
4299 /// party that knows what stack it handed over: the executor never sees
4300 /// `stack_bytes`, and no portable query returns a task's total stack, so
4301 /// neither an absolute floor nor a percentage can be inferred here.
4302 /// Declare how often this executor's loop intends to come round, in
4303 /// microseconds. `0` (the default) falls back to the `spin_once` timeout.
4304 ///
4305 /// Needed because a driver's pacing and its blocking bound are separate
4306 /// numbers. nros-cpp's tier loops sleep `spin_period_us` between spins but
4307 /// pass a fixed 10 ms timeout, so the release-jitter rule judged every
4308 /// tier against 10 ms regardless of what the contract declared -- too lax
4309 /// for a 1 ms tier and far too strict for a 30 ms one.
4310 ///
4311 /// This also says the CALLER paces the loop (a sleep finer than the
4312 /// park), so the measurement is reported at 1 us. A loop paced by the
4313 /// `spin_once` wait itself declares with
4314 /// [`Self::set_spin_nominal_wait_paced_us`] instead.
4315 pub fn set_spin_nominal_us(&mut self, us: u64) {
4316 self.spin_nominal_declared_us = us;
4317 self.spin_nominal_wait_paced = false;
4318 }
4319
4320 /// phase-436 A2b -- declare the loop's cadence, in microseconds, for a
4321 /// loop paced by the `spin_once` wait itself. `0` withdraws it.
4322 ///
4323 /// [`Self::set_spin_nominal_us`] couples two facts: the period to judge
4324 /// against, and that the caller paces the loop with its own sleep. A loop
4325 /// like `for (;;) spin_once(period_ms)` has the first without the second.
4326 /// Declaring through the coupled setter would report its jitter at 1 us
4327 /// when the park it waits on is tick-granular (issue 1194's failure); not
4328 /// declaring at all makes the timeout the period, which is wrong whenever
4329 /// the timeout is not the period (a cadence that is not whole
4330 /// milliseconds, or one raised to a floor).
4331 ///
4332 /// Here the cadence is the period of the release grid that a
4333 /// `spin_once(timeout)` loop already runs on, and the timeout stays a
4334 /// bound on each wait. The jitter is judged at grid releases and reported
4335 /// at the park granularity.
4336 pub fn set_spin_nominal_wait_paced_us(&mut self, us: u64) {
4337 self.spin_nominal_declared_us = us;
4338 self.spin_nominal_wait_paced = us != 0;
4339 }
4340
4341 pub fn set_min_stack_headroom_bytes(&mut self, bytes: usize) {
4342 self.min_stack_headroom_bytes = bytes;
4343 // phase-436 E4 — a new bound is checked on the next spin, not up to an
4344 // interval later.
4345 self.stack_headroom_last_check_us = None;
4346 // phase-436 E3 — `check_stack_headroom_rule` treats a 0 from the port as
4347 // "not instrumented" and skips, which is right for the rule and wrong
4348 // for the reader: an armed rule that can never judge reports nothing,
4349 // exactly like one that judged and found the stack healthy. Ask the
4350 // port once, here. Entries set the bound at boot on the thread it
4351 // describes, so the answer is about the right stack and the warning
4352 // lands once per tier, next to the tier's other boot lines.
4353 // phase-436 E5 -- "does the port answer at all?" needs one byte, not
4354 // the whole unused region the full query would scan.
4355 self.stack_headroom_blind =
4356 bytes != 0 && nros_platform_api::stack_unused_bytes_upto(1) == 0;
4357 if self.stack_headroom_blind {
4358 nros_log::log_warn!(
4359 nros_log::get_logger("nros"),
4360 "stack-headroom-runtime: a {} B minimum is set but this port reports no stack \
4361 data, so the rule will never check (Zephyr: enable CONFIG_INIT_STACKS)",
4362 bytes
4363 );
4364 }
4365 }
4366
4367 /// phase-436 E3 — whether `stack-headroom-runtime` is armed on a port that
4368 /// cannot measure, and so will never report a violation.
4369 ///
4370 /// `true` means the rule's silence is not evidence of headroom. Zephyr
4371 /// answers only with `CONFIG_INIT_STACKS && CONFIG_THREAD_STACK_INFO`;
4372 /// POSIX never does.
4373 pub fn stack_headroom_blind(&self) -> bool {
4374 self.stack_headroom_blind
4375 }
4376
4377 /// Report a spin thread that has come closer to the end of its stack
4378 /// than `set_min_stack_headroom_bytes` allows.
4379 ///
4380 /// Runs on the same tick as the other rules and feeds the same ring.
4381 /// Queries the port at most once per `STACK_HEADROOM_CHECK_INTERVAL_US`
4382 /// (phase-436 E4): on a painted Zephyr stack the full query scans every
4383 /// unused byte, so an every-spin query put that scan inside the control
4384 /// loop. Since E5 the query is capped at the declared minimum, so a scan
4385 /// reads at most that many bytes.
4386 /// Nothing at all when no minimum was declared.
4387 fn check_stack_headroom_rule(&mut self) {
4388 if self.min_stack_headroom_bytes == 0 {
4389 return;
4390 }
4391 self.stack_headroom_spins_since_check =
4392 self.stack_headroom_spins_since_check.saturating_add(1);
4393 let now_us = self.now_us();
4394 if !super::monitor::stack_headroom_check_due(
4395 now_us,
4396 self.stack_headroom_last_check_us,
4397 self.stack_headroom_spins_since_check,
4398 ) {
4399 return;
4400 }
4401 self.stack_headroom_last_check_us = Some(now_us.unwrap_or(0));
4402 self.stack_headroom_spins_since_check = 0;
4403 self.stack_headroom_checks = self.stack_headroom_checks.saturating_add(1);
4404 // phase-436 E5 -- the CAPPED query. The rule only asks whether
4405 // `min_stack_headroom_bytes` are unused, and a port that scans for
4406 // paint answers that by reading those bytes alone. Below the cap the
4407 // answer is the exact headroom, which is what a breach reports, so
4408 // no second, full scan is needed. E4's throttle still applies.
4409 let unused = nros_platform_api::stack_unused_bytes_upto(self.min_stack_headroom_bytes);
4410 // 0 means the port does not instrument stacks, not that the stack is
4411 // full. Reporting a violation there would be a fault invented from an
4412 // absence of data.
4413 if unused == 0 {
4414 return;
4415 }
4416 if let Some(v) = super::monitor::check_stack_headroom(
4417 unused,
4418 self.min_stack_headroom_bytes,
4419 &mut self.stack_headroom_reported,
4420 ) {
4421 self.violations.record(v);
4422 }
4423 }
4424
4425 /// AUTOSAR-style ALIVE supervision: report a SchedContext that produced
4426 /// no dispatch at all over a full window while declaring a period.
4427 ///
4428 /// Every other rule here fires when something happens and is wrong. This
4429 /// one fires when nothing happens, which is the failure a watchdog
4430 /// exists for and the one `rate-hierarchy-runtime` cannot see: that rule
4431 /// counts publishes on an endpoint, so a callback that stops firing while
4432 /// its topic is published from elsewhere leaves it satisfied.
4433 fn check_alive_supervision(&mut self) {
4434 let Some(now_us) = self.now_us() else {
4435 // No clock, no windows. Silence here is honest: the rule cannot
4436 // tell a stalled context from a fast one without time.
4437 return;
4438 };
4439 let n = self.alive_slots.len().min(self.sched_contexts.len());
4440 for i in 0..n {
4441 let period_us = match self.sched_contexts[i].as_ref() {
4442 Some(sc) => sc.period_us.get().map(|nz| nz.get() as u64).unwrap_or(0),
4443 None => continue,
4444 };
4445 let slot = &mut self.alive_slots[i];
4446 if let Some(v) =
4447 super::monitor::check_alive(period_us, slot.dispatches, &mut slot.state, now_us)
4448 {
4449 self.violations.record(v);
4450 }
4451 }
4452 }
4453
4454 /// Issue #515 — report a spin wake a whole period late.
4455 ///
4456 /// Runs on the same tick as `check_timer_overruns` and feeds the same
4457 /// ring, so the entry glue drains it with the other rules and no caller
4458 /// needs to know this one exists. Like that rule it needs no spec table:
4459 /// the bound is the spin period the caller already passes in.
4460 fn check_release_jitter_rule(&mut self) {
4461 let (max_us, _late, _total) = self.release_jitter();
4462 let period_us = self.spin_nominal_us;
4463 if let Some(v) =
4464 super::monitor::check_release_jitter(max_us, &mut self.jitter_reported_us, period_us)
4465 {
4466 self.violations.record(v);
4467 }
4468 }
4469
4470 fn check_timer_overruns(&mut self) {
4471 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
4472 for i in 0..self.entries.len() {
4473 let Some(meta) = self.entries[i].as_ref() else {
4474 continue;
4475 };
4476 if !matches!(meta.kind, EntryKind::Timer) {
4477 continue;
4478 }
4479 // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
4480 // which shares its leading layout with `TimerHeader`. The
4481 // baseline lives in the header too, so this needs no state
4482 // parallel to `entries` (whose capacity is a runtime slice
4483 // length, not a const).
4484 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
4485 if let Some(v) = super::monitor::check_timer_overrun(
4486 header.overruns,
4487 &mut header.overruns_reported,
4488 0,
4489 ) {
4490 self.violations.record(v);
4491 }
4492 }
4493 }
4494
4495 /// RFC-0052 / phase-296 W3a — replace the DEFAULT scheduling context
4496 /// (slot 0, the SC every unbound callback dispatches through).
4497 ///
4498 /// The run_tiers model runs one Executor per tier, so a tier-wide
4499 /// scheduling policy (`[tiers.<t>] class/budget_us/period_us` and the
4500 /// TT window) is exactly "this executor's default SC". Boards call
4501 /// this once, before entity creation; explicit per-handle/per-group
4502 /// bindings still take precedence (they never resolve to slot 0).
4503 ///
4504 /// Sporadic-class SCs get the same sibling `SporadicState` the
4505 /// `create_sched_context` path builds, so budget refill/exhaustion
4506 /// applies to the default queue too.
4507 pub fn set_default_sched_context(&mut self, sc: super::sched_context::SchedContext) {
4508 self.sched_contexts[0] = Some(sc);
4509 if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
4510 let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
4511 let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
4512 self.sporadic_states[0] =
4513 Some(super::sched_context::SporadicState::new(budget, period));
4514 } else {
4515 self.sporadic_states[0] = None;
4516 }
4517 }
4518
4519 /// Bind a registered callback to a scheduling context. The next
4520 /// `spin_once` cycle dispatches the callback through that SC's
4521 /// queue (FIFO bitmap or EDF heap). Phase 110.B.
4522 pub fn bind_handle_to_sched_context(
4523 &mut self,
4524 handle: HandleId,
4525 sc_id: super::sched_context::SchedContextId,
4526 ) -> Result<(), NodeError> {
4527 let i = self.live_slot(handle);
4528 if i >= self.entries.len() {
4529 return Err(NodeError::InvalidSchedContextBinding);
4530 }
4531 if self.entries[i].is_none() {
4532 return Err(NodeError::InvalidSchedContextBinding);
4533 }
4534 let sc_idx = sc_id.0 as usize;
4535 if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
4536 return Err(NodeError::InvalidSchedContextBinding);
4537 }
4538 self.sched_context_bindings[i] = sc_id;
4539 Ok(())
4540 }
4541
4542 /// Phase 110.F — opt in to per-callback OS-priority dispatch.
4543 /// Once registered, every `spin_once` cycle routes ready entries
4544 /// whose bound SC has `os_pri > 0` onto a worker thread the OS
4545 /// scheduler has elevated to that numeric priority. Workers are
4546 /// spawned lazily on first use and self-halt when the Executor
4547 /// drops.
4548 ///
4549 /// `apply_policy` is the same `fn(SchedPolicy) -> Result<(),
4550 /// SchedError>` shape `open_threaded` takes — keeps the
4551 /// Executor non-generic over Platform.
4552 ///
4553 /// Calling this with `apply_policy = noop` is fine for testing
4554 /// (workers spawn but don't actually elevate priority); real
4555 /// hard-RT use needs `CAP_SYS_NICE` on Linux or the equivalent
4556 /// kernel config on RTOSes.
4557 #[cfg(all(feature = "alloc", feature = "scheduler-os-priority"))]
4558 pub fn register_os_priority_dispatcher(
4559 &mut self,
4560 apply_policy: fn(
4561 nros_platform_api::SchedPolicy,
4562 ) -> Result<(), nros_platform_api::SchedError>,
4563 ) {
4564 self.os_priority_apply_policy = Some(apply_policy);
4565 }
4566
4567 /// Phase 110.G — enable time-triggered dispatch by setting the
4568 /// executor's major-frame length. Once set, every `spin_once`
4569 /// cycle gates dispatch through each entry's bound SC's
4570 /// `tt_window_offset_us` / `tt_window_duration_us` fields:
4571 /// dispatch only fires when the current monotonic time falls
4572 /// inside the window `[off, off + duration) mod major_frame`.
4573 ///
4574 /// `major_frame_us = 0` disables the TT gate (default state).
4575 /// Setting a non-zero major frame after callbacks are already
4576 /// registered is allowed — TT gates take effect on the next
4577 /// `spin_once` cycle.
4578 pub fn register_time_triggered_dispatcher(&mut self, major_frame_us: u32) {
4579 self.major_frame_us = major_frame_us;
4580 }
4581
4582 /// Phase 110.G — apply a declarative cyclic schedule.
4583 ///
4584 /// One-shot helper that wraps the underlying primitives:
4585 /// validates the schedule (`major_frame > 0`, no overlapping
4586 /// windows, every window fits inside the major frame), sets the
4587 /// executor's major-frame length, then materialises one
4588 /// `SchedContext` per window with `class = TimeTriggered` +
4589 /// the window's offset / duration. Returns the per-window
4590 /// [`SchedContextId`](crate::executor::sched_context::SchedContextId) array so callers can immediately
4591 /// `bind_handle_to_sched_context(handle, sc_id)` for their
4592 /// subscription / timer handles.
4593 ///
4594 /// `N` is the schedule's *declared* maximum window count;
4595 /// `schedule.window_count` gates how many SCs are actually
4596 /// created. Unused trailing slots return
4597 /// `SchedContextId::default()` (sentinel — callers must respect
4598 /// `window_count`).
4599 pub fn apply_time_triggered_schedule<const N: usize>(
4600 &mut self,
4601 schedule: &super::sched_context::TimeTriggeredSchedule<N>,
4602 ) -> Result<
4603 [super::sched_context::SchedContextId; N],
4604 super::sched_context::TimeTriggeredScheduleError,
4605 > {
4606 schedule.validate()?;
4607 self.major_frame_us = schedule.major_frame_us;
4608 // SC slot 0 is the auto-created default; reusing it as a
4609 // sentinel for unused trailing slots is safe because the
4610 // caller respects `schedule.window_count`.
4611 let mut ids: [super::sched_context::SchedContextId; N] =
4612 [super::sched_context::SchedContextId(0); N];
4613 for (i, window) in schedule.windows[..schedule.window_count].iter().enumerate() {
4614 // TT is not a scheduling class (phase-482 W6 deleted the
4615 // `SchedClass::TimeTriggered` variant): it is a per-SC *window gate* on top of
4616 // the existing class-based dispatch (Fifo here keeps
4617 // the EDF / Sporadic budgets out of the picture for
4618 // pure cyclic schedules). The window-gate fields set
4619 // below are what `spin_once`'s 110.G runtime gate
4620 // actually reads.
4621 let sc = super::sched_context::SchedContext {
4622 tt_window_offset_us: super::sched_context::OptUs::from_us(window.offset_us),
4623 tt_window_duration_us: super::sched_context::OptUs::from_us(window.duration_us),
4624 ..super::sched_context::SchedContext::new_fifo()
4625 };
4626 ids[i] = self.create_sched_context(sc).map_err(|_| {
4627 super::sched_context::TimeTriggeredScheduleError::WindowCountOverflow
4628 })?;
4629 }
4630 Ok(ids)
4631 }
4632
4633 /// Phase 110.E.b — register an ISR-driven refill timer for an
4634 /// already-created Sporadic SC. The caller invokes their
4635 /// platform's `PlatformTimer::create_periodic` with the returned
4636 /// `Arc<AtomicSporadicState>` as `user_data` and the
4637 /// `atomic_sporadic_refill_thunk` as the callback, then hands
4638 /// the resulting platform handle to this method via
4639 /// `OpaqueTimerHandle::new(handle, destroy_fn)`.
4640 ///
4641 /// The Executor stores both the Arc and the handle so Drop can
4642 /// clean them up. Calling this on a non-Sporadic SC returns
4643 /// `Err(InvalidSchedContextBinding)`.
4644 #[cfg(feature = "alloc")]
4645 pub fn register_sporadic_timer(
4646 &mut self,
4647 sc_id: super::sched_context::SchedContextId,
4648 timer: OpaqueTimerHandle,
4649 ) -> Result<portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>, NodeError>
4650 {
4651 let i = sc_id.0 as usize;
4652 if i >= self.sched_contexts.len() {
4653 return Err(NodeError::InvalidSchedContextBinding);
4654 }
4655 let sc = self.sched_contexts[i]
4656 .as_ref()
4657 .ok_or(NodeError::InvalidSchedContextBinding)?;
4658 if !matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
4659 return Err(NodeError::InvalidSchedContextBinding);
4660 }
4661 let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
4662 let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
4663 let state = portable_atomic_util::Arc::new(super::sched_context::AtomicSporadicState::new(
4664 budget, period,
4665 ));
4666 self.sporadic_atomic_states[i] = Some((portable_atomic_util::Arc::clone(&state), timer));
4667 Ok(state)
4668 }
4669
4670 /// Inspect a registered scheduling context. Phase 110.B.
4671 pub fn sched_context(
4672 &self,
4673 sc_id: super::sched_context::SchedContextId,
4674 ) -> Option<&super::sched_context::SchedContext> {
4675 self.sched_contexts.get(sc_id.0 as usize)?.as_ref()
4676 }
4677
4678 /// Phase 104.C.2 — start a rclcpp-style Node builder for this
4679 /// Executor. The returned [`NodeBuilder`](super::node_record::NodeBuilder)
4680 /// is chainable:
4681 ///
4682 /// ```ignore
4683 /// let id = exec.node_builder("ingress")
4684 /// .rmw("zenoh")
4685 /// .locator("tcp/127.0.0.1:7447")
4686 /// .sched(my_sc_id)
4687 /// .build()?;
4688 /// ```
4689 ///
4690 /// In Phase 104.C.2 the Node table is storage-only — all
4691 /// registered Nodes share the Executor's primary session. Per-
4692 /// Node session binding (the bridge feature) lands in Phase
4693 /// 104.C.3 when the session cache is wired.
4694 pub fn node_builder<'a, 'cfg>(
4695 &'a mut self,
4696 name: &'cfg str,
4697 ) -> super::node_record::NodeBuilder<'a, 'cfg, 's> {
4698 super::node_record::NodeBuilder {
4699 executor: self,
4700 name,
4701 namespace: None,
4702 rmw_name: None,
4703 locator: None,
4704 domain_id: None,
4705 sched: None,
4706 session_idx: None,
4707 }
4708 }
4709
4710 /// Return the Node table — Phase 104.C.2 read accessor.
4711 pub fn nodes(&self) -> &[super::node_record::NodeRecord] {
4712 &self.nodes
4713 }
4714
4715 /// Borrow a Node's metadata by id, returning `None` if the id
4716 /// is out of range.
4717 pub fn node(&self, id: super::node_record::NodeId) -> Option<&super::node_record::NodeRecord> {
4718 self.nodes.get(id.index())
4719 }
4720
4721 /// Phase 189.M1 — an executor-borrowing node handle for the entity builders
4722 /// (`exec.node_mut(id).subscription(t)...` / `.create_subscription(...)`).
4723 /// A short-lived `&mut Executor` borrow — use one at a time; entity handles
4724 /// are owned and outlive it (see `Node`).
4725 /// phase-483 W3 — the component registration frame, for the `nros`
4726 /// facade's declarative constructors. Not a user API.
4727 #[doc(hidden)]
4728 pub fn __component_frame(&self) -> Option<core::ptr::NonNull<()>> {
4729 self.component_frame
4730 }
4731
4732 /// phase-483 W3 — install or clear the component registration frame.
4733 /// The caller guarantees the pointee outlives the installation.
4734 #[doc(hidden)]
4735 pub fn __set_component_frame(&mut self, frame: Option<core::ptr::NonNull<()>>) {
4736 self.component_frame = frame;
4737 }
4738
4739 pub fn node_mut(&mut self, id: super::node_record::NodeId) -> super::node::Node<'_, 's> {
4740 super::node::Node::new(self, id)
4741 }
4742
4743 /// Issue #52 — install the baked QoS-override table on one node. Every
4744 /// entity created on it AFTERWARDS folds the matching `(topic, role)`
4745 /// entries into its QoS, before the backend-compat check — so an override
4746 /// the active RMW cannot honour still errors loudly rather than silently
4747 /// downgrading.
4748 ///
4749 /// Called by the generated entry (`nros::main!` → the register seam) right
4750 /// after the node is created and before the component declares entities.
4751 /// Unknown `node_id` is a no-op.
4752 pub fn set_node_qos_overrides(
4753 &mut self,
4754 node_id: super::node_record::NodeId,
4755 overrides: &'static [super::node_record::QoSOverrideCode],
4756 ) {
4757 if let Some(r) = self.nodes.get_mut(node_id.index()) {
4758 r.qos_overrides = overrides;
4759 }
4760 }
4761
4762 /// Phase 104.C.3 — resolve a session-slot index to a mutable
4763 /// session reference. Slot 0 = the Executor's primary session;
4764 /// slots 1..=N = the `extra_sessions` vec opened by
4765 /// `node_builder.rmw(name)` calls that named a backend
4766 /// different from the primary.
4767 pub(crate) fn session_at_mut(&mut self, idx: u8) -> Option<&mut session::ConcreteSession> {
4768 if idx == 0 {
4769 Some(&mut *self.session)
4770 } else {
4771 self.extra_sessions.get_mut((idx - 1) as usize)
4772 }
4773 }
4774
4775 /// [`Self::session_at_mut`] plus a shared borrow of the remap table, for
4776 /// building a [`NodeHandle`] — which needs both at once, and gets them
4777 /// here as two disjoint field borrows rather than through two `&mut self`
4778 /// calls the borrow checker could not split.
4779 pub(crate) fn session_and_remaps_at_mut(
4780 &mut self,
4781 idx: u8,
4782 ) -> Option<(&mut session::ConcreteSession, &[Option<RemapRule>])> {
4783 let remaps = &self.remap_table[..self.remap_len];
4784 let session = if idx == 0 {
4785 Some(&mut *self.session)
4786 } else {
4787 self.extra_sessions.get_mut((idx - 1) as usize)
4788 };
4789 session.map(|s| (s, remaps))
4790 }
4791
4792 /// Phase 104.C.9.b — resolve the per-Node session for direct
4793 /// entity creation paths (C++ FFI publisher / subscription /
4794 /// service that bypass the `register_*_on` arena dispatch).
4795 /// Returns `None` when `node_id` is out of range or the Node's
4796 /// `session_idx` lands outside the executor's session table.
4797 pub fn node_session_mut(
4798 &mut self,
4799 node_id: super::node_record::NodeId,
4800 ) -> Option<&mut session::ConcreteSession> {
4801 let session_idx = self.nodes.get(node_id.index())?.session_idx;
4802 self.session_at_mut(session_idx)
4803 }
4804
4805 /// Phase 189.M1 — create a typed publisher bound to a node's session.
4806 /// Backs `node.publisher(t).typed::<M>().build()` on the
4807 /// executor-borrowing [`Node`](super::node::Node); the returned
4808 /// handle is owned and outlives the `Node`.
4809 pub fn create_publisher_on<M: crate::rmw_type_registry::MessageForRmw>(
4810 &mut self,
4811 node_id: super::node_record::NodeId,
4812 topic_name: &str,
4813 qos: QoSProfile,
4814 ) -> Result<crate::executor::handles::EmbeddedPublisher<M>, NodeError> {
4815 // RFC-0088 / phase-421 W1 — the message's declared format must be the
4816 // one the linked backend speaks. Universal: the const lives on
4817 // `RosMessage`, which `MessageForRmw` requires under every backend.
4818 crate::format_check::assert_message_format::<M>();
4819 // Phase 212.K.7.6.b — register `M`'s cyclonedds descriptor before
4820 // creating the underlying publisher handle. No-op for other RMWs.
4821 crate::rmw_type_registry::register_type::<M>()?;
4822 let handle = self.create_raw_publisher_handle_on(
4823 node_id,
4824 topic_name,
4825 <M as RosMessage>::TYPE_NAME,
4826 <M as RosMessage>::TYPE_HASH,
4827 qos,
4828 )?;
4829 // RFC-0052 W3b.4 — attach the contracted endpoint's counter cell.
4830 let monitor = self
4831 .monitor_table
4832 .iter()
4833 .find(|m| m.topic == topic_name)
4834 .map(|m| m.cell);
4835 Ok(crate::executor::handles::EmbeddedPublisher {
4836 handle,
4837 event_regs: crate::executor::handles::empty_event_regs(),
4838 monitor,
4839 epoch: self.epoch_us_fn,
4840 _phantom: PhantomData,
4841 })
4842 }
4843
4844 /// Phase 189.M1 — create a generic (type-erased) publisher bound to a
4845 /// node's session. Backs `node.publisher(t).generic(ty, hash).build()`;
4846 /// the bridge re-publishes through this handle on the dest session.
4847 pub fn create_publisher_raw_on(
4848 &mut self,
4849 node_id: super::node_record::NodeId,
4850 topic_name: &str,
4851 type_name: &str,
4852 type_hash: &str,
4853 qos: QoSProfile,
4854 ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
4855 let handle =
4856 self.create_raw_publisher_handle_on(node_id, topic_name, type_name, type_hash, qos)?;
4857 Ok(crate::executor::handles::EmbeddedRawPublisher {
4858 handle,
4859 arena: crate::executor::handles::TxArena::new(),
4860 event_regs: crate::executor::handles::empty_event_regs(),
4861 // Issue 1676 — attach the contracted endpoint's counter cell by
4862 // exact topic match against the installed table, the rule
4863 // `NodeHandle::create_publisher` applies. This is the publisher
4864 // the Rust component road creates, so without it no contracted
4865 // Rust publisher was ever counted.
4866 monitor: self.publisher_monitor_cell(topic_name),
4867 })
4868 }
4869
4870 /// Shared prelude for the publisher-on-node paths: resolve the node's
4871 /// identity + session slot, build the [`TopicInfo`], validate QoS, and
4872 /// create the backend publisher handle. Mirrors
4873 /// `register_subscription_buffered_raw_on`'s session resolution so a
4874 /// bridge's source sub + dest pub agree on topic construction.
4875 fn create_raw_publisher_handle_on(
4876 &mut self,
4877 node_id: super::node_record::NodeId,
4878 topic_name: &str,
4879 type_name: &str,
4880 type_hash: &str,
4881 qos: QoSProfile,
4882 ) -> Result<session::RmwPublisher, NodeError> {
4883 let (node_name, ns, session_idx, overrides) = {
4884 let r = self
4885 .nodes
4886 .get(node_id.index())
4887 .ok_or(NodeError::InvalidSchedContextBinding)?;
4888 (
4889 r.name.clone(),
4890 r.namespace.clone(),
4891 r.session_idx,
4892 r.qos_overrides,
4893 )
4894 };
4895 // Issue #52 — fold the node's baked overrides for this topic BEFORE
4896 // `validate_against`, so an override the backend cannot honour errors
4897 // loudly instead of being silently dropped.
4898 let qos = super::node_record::apply_qos_override_codes(
4899 qos,
4900 topic_name,
4901 nros_rmw::QoSOverrideRole::Publisher,
4902 overrides,
4903 );
4904 let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
4905 .with_domain(self.domain_id)
4906 .with_namespace(&ns);
4907 if !node_name.is_empty() {
4908 topic = topic.with_node_name(&node_name);
4909 }
4910 // issue 1608 -- the publisher half of the declared-QoS check, before
4911 // validation so a policy the contract raises this publisher to is one
4912 // the backend is asked about. See `declared_qos::honour_publisher`.
4913 let qos = crate::declared_qos::honour_publisher(topic.type_name, topic.name, qos)?;
4914 let session = self
4915 .session_at_mut(session_idx)
4916 .ok_or(NodeError::BackendMismatch)?;
4917 super::node::validate_qos_or_report(
4918 &qos,
4919 Session::supported_qos_policies(session),
4920 "publisher",
4921 topic_name,
4922 )?;
4923 session
4924 .create_publisher(&topic, qos)
4925 .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))
4926 }
4927
4928 /// Phase 124.B.1 — install the executor's wake callback onto the
4929 /// primary session. Best-effort: backends that don't override
4930 /// `Session::set_wake_callback` (poll-only XRCE, bare-metal)
4931 /// ignore the call and continue to be drained on the executor's
4932 /// deadline-bound cv-wait boundary.
4933 ///
4934 /// `pub` for issue 1385, and for the same reason
4935 /// [`set_primary_identity`](Self::set_primary_identity) is: the three
4936 /// `Executor::open*` paths call this automatically, and a binding that
4937 /// builds its executor over a session it BORROWS
4938 /// ([`from_session_ptr_in`](Self::from_session_ptr_in)) reaches none of
4939 /// them, so it has to call it itself. `rclc_executor_init` is that
4940 /// binding, and until 1385 it did not — leaving `has_async_wake` false
4941 /// for the life of every C executor on every backend.
4942 ///
4943 /// Pairs with `clear_wake_signal`, which the
4944 /// executor runs from `close()` and from `drop`. A caller that installs
4945 /// owes nothing further: the teardown is the executor's.
4946 ///
4947 /// phase-436 — the backend's slot holds a `WakeFanout`
4948 /// and this executor JOINS it, so every executor over one session is woken
4949 /// by an arrival: the boot executor and each spawned tier
4950 /// (`open_with_session*` now installs too), or several C executors over
4951 /// one support context. Idempotent. If the fan-out pool is exhausted the
4952 /// executor installs its own context directly, as before; if the entry's
4953 /// member table is full it joins nothing and reads as having no async
4954 /// wake, so it never parks on a wake nobody will signal.
4955 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4956 pub fn install_wake_signal_on_primary(&mut self) {
4957 use nros_rmw::Session as _;
4958 if self.wake_membership.is_some() {
4959 return;
4960 }
4961 let ctx = self.wake_ctx_ptr();
4962 let key = self.session_ptr() as *mut core::ffi::c_void;
4963 let supports = self.session.supports_wake_callback();
4964 let fanout = match super::wake_fanout::find(key) {
4965 Some(f) => Some(f),
4966 None => super::wake_fanout::claim(key).inspect(|f| {
4967 f.set_live(supports);
4968 // SAFETY: the context is a `static` pool entry, valid for the
4969 // program's life; `clear_wake_signal` takes the callback off
4970 // before returning the entry to the pool.
4971 unsafe {
4972 self.session
4973 .set_wake_callback(Some(nros_rmw_fanout_wake_cb), f.as_ctx());
4974 }
4975 }),
4976 };
4977 match fanout {
4978 Some(f) => {
4979 if let Some(m) = f.join(ctx) {
4980 self.wake_membership = Some(m);
4981 // Issue 1385 — this executor now owes a teardown: leaving
4982 // the fan-out (and, for the session's owner or its last
4983 // member, taking the callback off the backend).
4984 self.wake_cb_installed = true;
4985 }
4986 }
4987 None => {
4988 // SAFETY: `ctx` points at executor-owned wake state that
4989 // outlives the installation and is cleared by
4990 // `clear_wake_signal` on `close()`/drop.
4991 unsafe {
4992 self.session
4993 .set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
4994 }
4995 self.wake_cb_installed = true;
4996 if supports {
4997 self.has_async_wake = true;
4998 }
4999 }
5000 }
5001 }
5002
5003 /// phase-436 — does an arrival on any of this executor's sessions signal
5004 /// its wake object? Through the primary's fan-out (live until the
5005 /// session's owner takes the callback down), or a direct install on the
5006 /// primary or an extra session.
5007 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5008 pub(crate) fn async_wake_live(&self) -> bool {
5009 self.has_async_wake || self.wake_membership.is_some_and(|m| m.is_live())
5010 }
5011
5012 /// Phase 124.B.1 — install the wake callback onto an extra
5013 /// session opened by `node_builder.rmw(...)`. Called from
5014 /// `NodeBuilder::build()` right after `extra_sessions.push(...)`.
5015 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5016 pub(crate) fn install_wake_signal_on_extra(&mut self, idx: usize) {
5017 use nros_rmw::Session as _;
5018 let ctx = self.wake_ctx_ptr();
5019 if let Some(s) = self.extra_sessions.get_mut(idx) {
5020 // SAFETY: same executor-owned wake state as the primary session;
5021 // the extra session is owned by this executor.
5022 unsafe {
5023 s.set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
5024 }
5025 // Issue 1385 — as on the primary.
5026 self.wake_cb_installed = true;
5027 if s.supports_wake_callback() {
5028 self.has_async_wake = true;
5029 }
5030 }
5031 }
5032
5033 /// Issue 1385 — the teardown half of `install_wake_signal_on_*`, and the
5034 /// obligation `nros_rmw_runtime_wake_cb`'s own SAFETY comment has asserted
5035 /// since phase 124: *"Executor::drop must clear the callback via
5036 /// `set_wake_callback(None, _)` on all sessions before dropping
5037 /// wake_ctx"*. Nothing did. The string `set_wake_callback(None` occurred
5038 /// exactly once in the tree — in that comment.
5039 ///
5040 /// The context the backend holds is `Arc::as_ptr(&self.wake_ctx)`, freed
5041 /// with this executor. Leaving it installed is a dangling callback the
5042 /// backend may invoke from a worker thread or an ISR. On the Rust `open*`
5043 /// paths the session is `SessionStore::Owned` and dies in the same drop,
5044 /// so the window is narrow; on the C path it is arbitrary —
5045 /// `rclc_executor_fini` drops the executor and zero-fills `_opaque` while
5046 /// the session lives on in `rclc_support_t` until `rclc_support_fini`.
5047 ///
5048 /// Clears the primary and every extra session, then latches off, so
5049 /// `close()` followed by drop clears once.
5050 ///
5051 /// Only fires when THIS executor installed (see
5052 /// `install_wake_signal_on_primary`). Executors sharing one session were
5053 /// last-writer-wins on the backend's single slot until phase-436: the first
5054 /// to tear down cleared the other's wake. The slot now holds a wake
5055 /// fan-out, so a member's teardown is leaving it, and the callback comes
5056 /// off the backend only with the session's owner or its last member.
5057 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5058 pub(crate) fn clear_wake_signal(&mut self) {
5059 use nros_rmw::Session as _;
5060 if !self.wake_cb_installed {
5061 return;
5062 }
5063 self.wake_cb_installed = false;
5064 // phase-436 — a fan-out member leaves first; the callback comes off
5065 // the backend only when the session's OWNER leaves (the session dies
5066 // with it) or the last member does. A membership from an entry the
5067 // owner already released is a no-op, and must not touch the entry's
5068 // next tenant.
5069 let take_off_primary = match self.wake_membership.take() {
5070 Some(m) => {
5071 let fanout = m.fanout();
5072 let current = m.is_current();
5073 m.leave(nros_platform_api::yield_now);
5074 let owned = matches!(self.session, SessionStore::Owned(_));
5075 if current && (owned || fanout.member_count() == 0) {
5076 // SAFETY: as below; the entry is released only after the
5077 // backend can no longer start a callback on it.
5078 unsafe { self.session.set_wake_callback(None, core::ptr::null_mut()) };
5079 fanout.release(nros_platform_api::yield_now);
5080 }
5081 false
5082 }
5083 None => true,
5084 };
5085 // SAFETY: clearing takes no context, and both sessions are still live
5086 // here — this runs BEFORE `Session::close` in `close()` and before the
5087 // session field is dropped in `Drop`.
5088 unsafe {
5089 if take_off_primary {
5090 self.session.set_wake_callback(None, core::ptr::null_mut());
5091 }
5092 for s in self.extra_sessions.iter_mut() {
5093 s.set_wake_callback(None, core::ptr::null_mut());
5094 }
5095 }
5096 self.has_async_wake = false;
5097 }
5098
5099 /// phase-467 Row 8 — hand the PRIMARY session a graph-change target it may
5100 /// trigger from a worker thread, and take ownership of that target.
5101 ///
5102 /// The install half of the pair; [`clear_graph_change_signal`] is the
5103 /// other and the executor owes it. `guard` is CLONED, so the caller keeps
5104 /// its own handle to the same arena flag — that is the whole point: the
5105 /// pointer the backend receives is the executor's box, never the caller's
5106 /// storage. Issue 1385 is the same hazard on the wake slot, found after
5107 /// the fact; this is the shape that cannot reach it.
5108 ///
5109 /// PRIMARY only, deliberately. A graph is a property of a session, and the
5110 /// extra sessions of `node_builder.rmw(...)` each have their own — feeding
5111 /// several into one guard would make "the graph changed" unattributable
5112 /// while costing the caller the ability to ask which. A caller wanting a
5113 /// second backend's graph asks that session for its own.
5114 ///
5115 /// Returns `Err(Unsupported)` from a backend with no graph. A previously
5116 /// installed target is retired first, so an executor never has two.
5117 ///
5118 /// [`clear_graph_change_signal`]: Self::clear_graph_change_signal
5119 ///
5120 /// `pub(crate)`, not `pub`: the public verb is
5121 /// [`register_graph_change_guard_on`], which does the probe, the
5122 /// registration and this in the one order that cannot hand back a guard
5123 /// nothing fires. Exposing the install alone would let a caller reach the
5124 /// second step without the first.
5125 ///
5126 /// [`register_graph_change_guard_on`]: Self::register_graph_change_guard_on
5127 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5128 pub(crate) fn install_graph_change_guard(
5129 &mut self,
5130 guard: &GuardCondition,
5131 ) -> Result<(), NodeError> {
5132 use nros_rmw::Session as _;
5133 self.clear_graph_change_signal();
5134 let boxed = alloc::boxed::Box::new(guard.clone());
5135 let ptr = (&*boxed) as *const GuardCondition as *const core::ffi::c_void;
5136 // SAFETY: `ptr` names heap storage this executor owns and which
5137 // `clear_graph_change_signal` retires from the backend before it is
5138 // freed. `GuardCondition` is `Send + Sync` and its `trigger()` is a
5139 // relaxed store plus the runtime wake hook, which is what a foreign
5140 // thread is allowed to do here.
5141 let rc = unsafe {
5142 self.session
5143 .set_graph_change_callback(Some(nros_rmw_runtime_graph_change_cb), ptr)
5144 };
5145 match rc {
5146 Ok(()) => {
5147 self.graph_change_target = Some(boxed);
5148 Ok(())
5149 }
5150 // `boxed` drops here, and nothing holds its pointer: the backend
5151 // either refused or failed, so there is no installed callback to
5152 // retire first.
5153 Err(e) => Err(NodeError::Transport(e)),
5154 }
5155 }
5156
5157 /// phase-467 Row 8 — can the primary session deliver a graph-change edge?
5158 ///
5159 /// Asked by CLEARING, which is the one probe that costs nothing and cannot
5160 /// lie: a backend with no graph answers `Unsupported` to a `None` install
5161 /// just as it does to a real one, and an idempotent clear on a backend
5162 /// that HAS a graph is a no-op. Nullity of the vtable slot cannot answer
5163 /// it — `rust_adapter.rs` fills the slot for every Rust backend whether or
5164 /// not that backend overrides the trait method, which
5165 /// `check-rmw-slot-producers`'s own report says in as many words.
5166 ///
5167 /// The caller is `register_graph_change_guard_on`, which asks BEFORE it
5168 /// consumes an executor handle slot, so an unsupported backend costs
5169 /// nothing and the caller is never handed a guard nothing fires.
5170 /// `pub(crate)` for the same reason the install is: asking costs a CLEAR,
5171 /// so a caller that asks and then does nothing has silently retired a live
5172 /// target. The public verb asks once, on the caller's behalf.
5173 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5174 pub(crate) fn supports_graph_change_callback(&mut self) -> bool {
5175 use nros_rmw::Session as _;
5176 // Retire whatever is installed first — the probe IS a clear, so doing
5177 // it without this would silently drop a live target.
5178 self.clear_graph_change_signal();
5179 // SAFETY: a `None` install takes no context and installs nothing.
5180 unsafe {
5181 self.session
5182 .set_graph_change_callback(None, core::ptr::null())
5183 .is_ok()
5184 }
5185 }
5186
5187 /// phase-467 Row 8 — the teardown half of
5188 /// [`install_graph_change_guard`](Self::install_graph_change_guard), and
5189 /// the reason that install cannot recreate issue 1385.
5190 ///
5191 /// Order is the whole content: tell the backend to stop FIRST, then drop
5192 /// the box it was pointing at. Reversed, the window between the free and
5193 /// the clear is exactly 1385's dangling callback — and on the C path that
5194 /// window is arbitrary, because the session outlives the executor.
5195 ///
5196 /// Latched on `graph_change_target.is_some()`, not on a separate flag: the
5197 /// box IS the record of having installed, so `close()` followed by drop
5198 /// clears once and an executor that never installed clears nothing. That
5199 /// is the same predicate `clear_wake_signal` needs a bool for only because
5200 /// its context is shared with `signal_fd()` and every guard condition.
5201 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5202 pub(crate) fn clear_graph_change_signal(&mut self) {
5203 use nros_rmw::Session as _;
5204 if self.graph_change_target.is_none() {
5205 return;
5206 }
5207 // SAFETY: clearing takes no context, and the session is still live
5208 // here — this runs BEFORE `Session::close` in `close()` and before the
5209 // session field is dropped in `Drop`.
5210 unsafe {
5211 let _ = self
5212 .session
5213 .set_graph_change_callback(None, core::ptr::null());
5214 }
5215 // Only now. The backend has been told, so nothing can be holding this
5216 // pointer when it is freed.
5217 self.graph_change_target = None;
5218 }
5219
5220 /// Phase 124.B.2 — opaque context pointer the runtime wake
5221 /// callback receives. Encodes `(flag, mu, cv)` as a borrowed
5222 /// `&WakeCtx` reference; the callback decodes via
5223 /// `*const WakeCtx`.
5224 ///
5225 /// Lifetime: tied to the Executor instance. WakeCtx storage
5226 /// lives inside Executor (lazy-allocated on first install), so
5227 /// the pointer stays valid as long as the Executor is.
5228 /// Phase 124.B.7.c — POSIX signal-handler-safe wake fd.
5229 ///
5230 /// Returns a Linux `eventfd` that callers (typically POSIX
5231 /// signal handlers) can `write(fd, &1u64, 8)` to from any
5232 /// context, including signal handlers. A runtime-owned worker
5233 /// thread reads the fd and signals `wake_cv`, unblocking
5234 /// `spin_once`.
5235 ///
5236 /// The worker thread is spawned lazily on first call and
5237 /// joined on Executor drop. Linux-only and gated behind
5238 /// `feature = "signal-fd-wake"`; binaries that don't install
5239 /// signal handlers shouldn't enable it.
5240 ///
5241 /// Returns the raw fd. The Executor retains ownership; do not
5242 /// `close()` it from the caller.
5243 #[cfg(all(feature = "signal-fd-wake", feature = "rmw-cffi", target_os = "linux"))]
5244 /// phase-359 W10 — was `std::io::Result`. Same values, an error type that
5245 /// does not require `std`.
5246 pub fn signal_fd(&mut self) -> Result<core::ffi::c_int, NodeError> {
5247 let ctx_ptr = self.wake_ctx_ptr() as *const WakeCtx;
5248 if self.signal_fd.is_none() {
5249 self.signal_fd = Some(WakeSignalFd::new(ctx_ptr)?);
5250 }
5251 Ok(self.signal_fd.as_ref().expect("just set").fd())
5252 }
5253
5254 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5255 fn wake_ctx_ptr(&mut self) -> *mut core::ffi::c_void {
5256 if self.wake_ctx.is_none() {
5257 self.wake_ctx = Some(portable_atomic_util::Arc::new(WakeCtx {
5258 flag: self.wake_flag.clone(),
5259 node_wake: self.node_wake.clone(),
5260 }));
5261 }
5262 let arc = self.wake_ctx.as_ref().expect("just set");
5263 portable_atomic_util::Arc::as_ptr(arc) as *mut core::ffi::c_void
5264 }
5265
5266 /// Phase 104.C.4 — apply a Node's default SchedContext to a
5267 /// freshly-registered handle. Called from every `_inner`
5268 /// register variant after the entry slot is committed. No-op
5269 /// when `node_id` is None (legacy path), when the Node is
5270 /// out of range, or when the Node's `default_sched` is the
5271 /// auto-created Fifo slot (0) which matches the executor's
5272 /// default binding already.
5273 ///
5274 /// Phase 273 (RFC-0047) — extends with an optional `group` name.
5275 /// Precedence: **group table > node default > no binding** (SC 0).
5276 /// When `group` is `Some(g)`, consults `group_sched_table` first;
5277 /// if no entry exists for `(name, namespace, g)` falls back to the
5278 /// node's `default_sched`. When `group` is `None` the group table
5279 /// is not consulted (unchanged phase-272 path).
5280 ///
5281 /// Handles can still override per-call via
5282 /// `bind_handle_to_sched_context(handle, sc_id)` post-register.
5283 pub(crate) fn apply_node_default_sched(
5284 &mut self,
5285 slot: usize,
5286 node_id: Option<super::node_record::NodeId>,
5287 group: Option<&str>,
5288 ) {
5289 let Some(id) = node_id else { return };
5290 // Copy name, namespace, and default_sched out so the borrow on
5291 // `self.nodes` is released before the immutable `lookup_group_sched`
5292 // borrow and the mutable `sched_context_bindings` write below.
5293 let (name, namespace, node_sc) = {
5294 let Some(rec) = self.nodes.get(id.index()) else {
5295 return;
5296 };
5297 (rec.name.clone(), rec.namespace.clone(), rec.default_sched)
5298 };
5299 // Phase 273: group table > node default.
5300 let sc = match group {
5301 Some(g) => self
5302 .lookup_group_sched(name.as_str(), namespace.as_str(), g)
5303 .unwrap_or(node_sc),
5304 None => node_sc,
5305 };
5306 if sc.0 == 0 {
5307 return;
5308 }
5309 if slot >= self.entries.len() {
5310 return;
5311 }
5312 let sc_idx = sc.0 as usize;
5313 if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
5314 return;
5315 }
5316 self.sched_context_bindings[slot] = sc;
5317 }
5318
5319 /// Phase 104.C.3.2 — scoped Node-handle access. The closure
5320 /// receives a [`NodeHandle`](crate::executor::NodeHandle) bound to the
5321 /// requested [`NodeId`](crate::executor::NodeId)'s
5322 /// session + identity. Use the standard `Node::create_publisher`,
5323 /// `create_subscription`, etc. APIs inside.
5324 ///
5325 /// rclcpp-aligned bridge pattern:
5326 ///
5327 /// ```ignore
5328 /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
5329 /// let node_out = exec.node_builder("egress").rmw("xrce").build()?;
5330 ///
5331 /// let pub_out = exec.with_node(node_out, |n| {
5332 /// n.create_publisher::<Int32>("/fwd")
5333 /// })??;
5334 ///
5335 /// exec.with_node(node_in, |n| {
5336 /// n.create_subscription_buffered::<Int32, _, 1024>(
5337 /// "/src", qos(), move |m| { let _ = pub_out.publish(m); }
5338 /// )
5339 /// })??;
5340 /// ```
5341 ///
5342 /// The closure can return any type; double-`?` unwraps the
5343 /// outer `Result<R, NodeError>` from `with_node` and the inner
5344 /// result returned by the closure.
5345 /// Phase 104.C.3.3.d — flat-Result variant of
5346 /// [`with_node`](Self::with_node). When the closure already
5347 /// returns `Result<R, NodeError>`, this avoids the double-`?`:
5348 ///
5349 /// ```ignore
5350 /// // Without `with_node_try`:
5351 /// let pub_ = exec.with_node(id, |n| n.create_publisher(...))??;
5352 ///
5353 /// // With `with_node_try`:
5354 /// let pub_ = exec.with_node_try(id, |n| n.create_publisher(...))?;
5355 /// ```
5356 pub fn with_node_try<R>(
5357 &mut self,
5358 id: super::node_record::NodeId,
5359 f: impl FnOnce(&mut NodeHandle<'_>) -> Result<R, NodeError>,
5360 ) -> Result<R, NodeError> {
5361 self.with_node(id, f)?
5362 }
5363
5364 pub fn with_node<R>(
5365 &mut self,
5366 id: super::node_record::NodeId,
5367 f: impl FnOnce(&mut NodeHandle<'_>) -> R,
5368 ) -> Result<R, NodeError> {
5369 let (name, ns, session_idx) = {
5370 let r = self
5371 .nodes
5372 .get(id.index())
5373 .ok_or(NodeError::InvalidSchedContextBinding)?;
5374 (r.name.clone(), r.namespace.clone(), r.session_idx)
5375 };
5376 let monitors = self.monitor_table;
5377 let age_monitors = self.age_table;
5378 let epoch = self.epoch_us_fn;
5379 let domain_id = self.domain_id;
5380 let (session, remaps) = self
5381 .session_and_remaps_at_mut(session_idx)
5382 .ok_or(NodeError::BackendMismatch)?;
5383 // SAFETY: short-lived scoped reference. `Node::new` takes
5384 // `&mut ConcreteSession`; lifetime is bound to this fn's
5385 // body via the closure's borrow of `node`.
5386 // issue 0801 (second half) — the executor's domain, NOT a literal 0.
5387 // 429d5a581 fixed the ELEVEN arena `TopicInfo`s and left the THREE
5388 // `NodeHandle::new` sites, so an entity created through a node HANDLE
5389 // (`with_node`, `node`, `node_on`) still declared on domain 0 while the
5390 // arena path declared on the configured one. Same split the issue is
5391 // about, one constructor over: `loan_e2e` publishes through a handle and
5392 // subscribes through the arena, so it delivered on domain 0 and on
5393 // nothing else.
5394 let mut node = NodeHandle::new(name, ns, session, remaps, domain_id);
5395 // RFC-0052 W3b.4/.5 — seed the baked monitor tables so contracted
5396 // publishers/subscribers attach their cells without entry glue.
5397 node.set_monitors(monitors);
5398 node.set_age_monitors(age_monitors, epoch);
5399 Ok(f(&mut node))
5400 }
5401
5402 /// Find a registered executor node by final name and namespace.
5403 pub fn node_id_by_name(
5404 &self,
5405 name: &str,
5406 namespace: &str,
5407 ) -> Option<super::node_record::NodeId> {
5408 self.nodes
5409 .iter()
5410 .enumerate()
5411 .find(|(_, node)| node.name.as_str() == name && node.namespace.as_str() == namespace)
5412 .map(|(index, _)| super::node_record::NodeId::from_raw(index as u8))
5413 }
5414
5415 /// Create a node on this executor.
5416 ///
5417 /// Registers the node in the executor's table, deduplicating on
5418 /// `(name, namespace)` — phase-376 W5/B1.
5419 ///
5420 /// # This is `rclrs::Executor::create_node`
5421 ///
5422 /// phase-427 W10 (RFC-0089 "Context and `init`, settled"). Same name, same
5423 /// place in the sequence — `Context` → executor → node — so a ported rclrs
5424 /// `main` lands here unchanged:
5425 ///
5426 /// ```ignore
5427 /// let context = nros::Context::default_from_env()?;
5428 /// let mut executor = context.create_executor()?;
5429 /// let mut node = executor.create_node("talker")?;
5430 /// ```
5431 ///
5432 /// Two differences from upstream, both structural and both named in the
5433 /// ledger row (`rust:Executor::create_node`): ours takes `&mut self`,
5434 /// because the node is registered in the executor's compile-time-sized
5435 /// table rather than allocated, and it hands back a borrowing
5436 /// [`NodeHandle`] rather than an `Arc<Node>`, because there is no
5437 /// allocator. Neither is silent — the compiler names both.
5438 ///
5439 /// The name comes from HERE, not from
5440 /// [`ExecutorConfig::node_name`](crate::ExecutorConfig::node_name), whose
5441 /// builder spelling is deprecated for exactly this reason: one executor
5442 /// holds several named nodes (RFC-0047), so a name on the session config
5443 /// can only ever describe the first of them.
5444 ///
5445 /// Until 2026-08-24 this path registered NOTHING. It built a `NodeHandle`
5446 /// and returned it, so `node_id_by_name` could not find a node the caller
5447 /// had just created, and two calls with one name handed out two handles the
5448 /// executor had never heard of. `create_node_on_with_domain` had the dedup
5449 /// (phase-267 added it there when N bridge endpoints overflowed the table);
5450 /// the plain path never got it.
5451 ///
5452 /// That is a prerequisite for the `create_node` vtable slot, whose contract
5453 /// is that the runtime calls it ONCE per distinct `(name, namespace)`:
5454 /// without a registry to check, the runtime would call it once per
5455 /// `create_node` and every backend would need its own dedup — which is the
5456 /// registry the slot exists to delete (zenoh's `ensure_node_liveliness`
5457 /// linear-scans `per_node_liveliness` for exactly this reason).
5458 ///
5459 /// The table is bounded by `MAX_NODES` (`NROS_EXECUTOR_MAX_NODES`, default
5460 /// 4), so a caller creating a fifth DISTINCT node now gets
5461 /// `NodeError::NodeTableFull` where it previously got a handle. That is the
5462 /// bound doing its job: a node the executor does not know about cannot
5463 /// carry a sched context, a QoS override, or a graph identity. Repeated
5464 /// calls with the SAME name are free.
5465 pub fn create_node(&mut self, name: &str) -> Result<super::node::Node<'_, 's>, NodeError> {
5466 if name.len() > 64 {
5467 return Err(NodeError::NameTooLong);
5468 }
5469 // Dedup against the executor's own namespace, which is the namespace
5470 // `node_builder` gives a node when none is named. phase-483 W2: this
5471 // returns THE node type (`Node`, re-exported as `nros::Node`), the
5472 // same handle `node_mut` gives a component, where it used to return a
5473 // session-borrowing `NodeHandle` with a different method set.
5474 let id = match self.node_id_by_name(name, self.namespace.clone().as_str()) {
5475 Some(id) => id,
5476 None => self.node_builder(name).build()?,
5477 };
5478 Ok(super::node::Node::new(self, id))
5479 }
5480
5481 /// Phase 128.F.2 — bridge-mode node factory. Registers a Node
5482 /// bound to the named RMW backend by opening (or reusing) an
5483 /// extra session via `node_builder().rmw(rmw).build()`, then
5484 /// returns a [`NodeHandle`](crate::executor::NodeHandle) borrowing that
5485 /// session. Use when the
5486 /// binary intentionally links more than one backend and a Node
5487 /// must speak a specific one.
5488 ///
5489 /// The single-backend common case should keep using
5490 /// [`create_node`](Self::create_node) — this entry costs an
5491 /// extra session lookup and serves no purpose when only one
5492 /// backend is registered.
5493 #[cfg(feature = "rmw-cffi")]
5494 pub fn create_node_on(
5495 &mut self,
5496 name: &str,
5497 rmw: &str,
5498 ) -> Result<super::node::Node<'_, 's>, NodeError> {
5499 self.create_node_on_with_domain(name, rmw, None, None)
5500 }
5501
5502 /// Like [`create_node_on`](Self::create_node_on) but pins the extra
5503 /// session's domain id. Required for a multi-domain config-driven bridge:
5504 /// an extra RMW session's participant domain follows the **node builder's**
5505 /// `domain_id` (`resolve_session_slot` → `domain_id.unwrap_or(0)`), NOT the
5506 /// `SessionSpec`'s — so without this an egress on a non-zero domain silently
5507 /// opens on domain 0 and never matches its receiver (phase-267 issue 0109).
5508 /// `None` domain preserves the legacy domain-0 default. `locator` pins the
5509 /// extra session's address — REQUIRED for an agent-based backend (xrce: the
5510 /// Micro-XRCE-DDS Agent addr) whose session can't be opened locator-less;
5511 /// `None` keeps the rmw-default (cyclonedds is domain-discovered, no locator).
5512 pub fn create_node_on_with_domain(
5513 &mut self,
5514 name: &str,
5515 rmw: &str,
5516 domain_id: Option<u32>,
5517 locator: Option<&str>,
5518 ) -> Result<super::node::Node<'_, 's>, NodeError> {
5519 if name.len() > 64 {
5520 return Err(NodeError::NameTooLong);
5521 }
5522 // Reuse an existing Node of this name rather than growing the node table
5523 // (phase-267 non-flat): a config-driven bridge calls this once per bridge
5524 // ENDPOINT, and the same session node (`s0`/`s1`) recurs across every
5525 // `[[bridge]]`. Without dedup, N bridges push 2N records and overflow
5526 // `MAX_NODES`. Names are unique per session in a generated bridge config,
5527 // so matching by name is unambiguous.
5528 let id = if let Some(i) = self.nodes.iter().position(|n| n.name.as_str() == name) {
5529 super::node_record::NodeId::from_raw(i as u8)
5530 } else {
5531 let mut builder = self.node_builder(name).rmw(rmw);
5532 if let Some(d) = domain_id {
5533 builder = builder.domain_id(d);
5534 }
5535 if let Some(loc) = locator {
5536 builder = builder.locator(loc);
5537 }
5538 builder.build()?
5539 };
5540 Ok(super::node::Node::new(self, id))
5541 }
5542
5543 /// Drive transport I/O (poll network, dispatch callbacks).
5544 #[allow(dead_code)]
5545 pub(crate) fn drive_io(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
5546 self.session
5547 .drive_io(timeout_ms)
5548 .map_err(|_| NodeError::Transport(TransportError::PollFailed))
5549 }
5550
5551 /// Close the underlying session, running the shutdown hooks around it.
5552 ///
5553 /// Issue 0790. The order is the feature:
5554 ///
5555 /// 1. every registered PRE-shutdown hook, while the session is still open
5556 /// and every entity still works — this is where a node publishes a final
5557 /// state, answers a last request, parks an actuator or releases a bus;
5558 /// 2. the session close;
5559 /// 3. every registered ON-shutdown hook.
5560 ///
5561 /// A hook runs EXACTLY ONCE: each phase table is emptied before its first
5562 /// hook is invoked, so a second `close()` — or the [`Drop`] sweep after one
5563 /// — finds nothing left to run. Step 2 runs even
5564 /// if a pre-shutdown hook was registered and step 3 even if the close
5565 /// failed — a hook cannot strand the session, and a dead session must not
5566 /// strand the hooks.
5567 ///
5568 /// # This is a CLEAN-STOP facility and nothing more
5569 ///
5570 /// A watchdog reset, a hard fault or a panic does not come through here, so
5571 /// these hooks do not run then. Nothing in a fixed static table can promise
5572 /// otherwise, and an API that implied it would be worse than none: hardware
5573 /// that must be safe across an abnormal stop needs a hardware answer (a
5574 /// pull-down, a watchdog-driven output disable), not a callback.
5575 pub fn close(&mut self) -> Result<(), NodeError> {
5576 self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
5577 // Issue 1385 — hand the callback back BEFORE closing the session that
5578 // holds it. `close()` leaves the executor alive, so without this a
5579 // later `set_wake_callback` from the drop sweep would reach a session
5580 // the backend has already torn down.
5581 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5582 self.clear_wake_signal();
5583 // phase-467 Row 8 — the same obligation on the graph-change slot, and
5584 // it has to be discharged in the same place for the same reason.
5585 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
5586 self.clear_graph_change_signal();
5587 let result = self
5588 .session
5589 .close()
5590 .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed));
5591 self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
5592 result
5593 }
5594
5595 /// Register a hook to run BEFORE the session is closed (issue 0790).
5596 ///
5597 /// rclcpp's `Context::add_pre_shutdown_callback`. Returns the handle
5598 /// [`Self::remove_pre_shutdown_callback`] takes, or
5599 /// [`NodeError::ShutdownCallbacksFull`] when the phase table is full —
5600 /// raise `NROS_EXECUTOR_MAX_SHUTDOWN_CBS` (default 2) at build time.
5601 ///
5602 /// # Safety
5603 /// `callback` must be safe to invoke exactly once with `context`, and
5604 /// `context` must stay valid until the hook runs or is removed. The hook
5605 /// runs on whichever task calls [`Self::close`] (or drops the executor),
5606 /// which is not necessarily the task that registered it.
5607 pub unsafe fn add_pre_shutdown_callback(
5608 &mut self,
5609 callback: super::types::ShutdownCallbackFn,
5610 context: *mut core::ffi::c_void,
5611 ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
5612 Self::claim_shutdown_slot(
5613 &mut self.pre_shutdown_hooks,
5614 super::types::ShutdownPhase::Pre,
5615 callback,
5616 context,
5617 )
5618 }
5619
5620 /// Register a hook to run AFTER the session is closed (issue 0790).
5621 ///
5622 /// rclcpp's `Context::add_on_shutdown_callback` / `rclcpp::on_shutdown`.
5623 /// Entities are gone by the time it runs, so anything that needs the wire
5624 /// belongs in [`Self::add_pre_shutdown_callback`] instead.
5625 ///
5626 /// # Safety
5627 /// Same contract as [`Self::add_pre_shutdown_callback`].
5628 pub unsafe fn add_on_shutdown_callback(
5629 &mut self,
5630 callback: super::types::ShutdownCallbackFn,
5631 context: *mut core::ffi::c_void,
5632 ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
5633 Self::claim_shutdown_slot(
5634 &mut self.on_shutdown_hooks,
5635 super::types::ShutdownPhase::Post,
5636 callback,
5637 context,
5638 )
5639 }
5640
5641 /// Remove a pre-shutdown hook. `true` if `handle` named a live one.
5642 ///
5643 /// rclcpp's `Context::remove_pre_shutdown_callback`, and `bool` for the
5644 /// same reason: "it was not there" is an ordinary answer (the hook may
5645 /// already have run), not an error. A handle issued for the OTHER phase
5646 /// returns `false` and removes nothing — that is what the phase tag in
5647 /// [`ShutdownCallbackHandle`] buys.
5648 ///
5649 /// [`ShutdownCallbackHandle`]: super::types::ShutdownCallbackHandle
5650 pub fn remove_pre_shutdown_callback(
5651 &mut self,
5652 handle: super::types::ShutdownCallbackHandle,
5653 ) -> bool {
5654 Self::release_shutdown_slot(
5655 &mut self.pre_shutdown_hooks,
5656 super::types::ShutdownPhase::Pre,
5657 handle,
5658 )
5659 }
5660
5661 /// Remove an on-shutdown hook. See [`Self::remove_pre_shutdown_callback`].
5662 pub fn remove_on_shutdown_callback(
5663 &mut self,
5664 handle: super::types::ShutdownCallbackHandle,
5665 ) -> bool {
5666 Self::release_shutdown_slot(
5667 &mut self.on_shutdown_hooks,
5668 super::types::ShutdownPhase::Post,
5669 handle,
5670 )
5671 }
5672
5673 /// How many hooks are currently registered for `phase`. Diagnostic /
5674 /// test surface, and what a "did my registration land?" assertion reads.
5675 pub fn shutdown_callback_count(&self, phase: super::types::ShutdownPhase) -> usize {
5676 let table = match phase {
5677 super::types::ShutdownPhase::Pre => &self.pre_shutdown_hooks,
5678 super::types::ShutdownPhase::Post => &self.on_shutdown_hooks,
5679 };
5680 table.iter().flatten().count()
5681 }
5682
5683 /// Claim the first free slot of a phase table.
5684 fn claim_shutdown_slot(
5685 table: &mut [Option<super::types::ShutdownHook>],
5686 phase: super::types::ShutdownPhase,
5687 callback: super::types::ShutdownCallbackFn,
5688 context: *mut core::ffi::c_void,
5689 ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
5690 for (index, slot) in table.iter_mut().enumerate() {
5691 if slot.is_some() {
5692 continue;
5693 }
5694 let handle = super::types::ShutdownCallbackHandle::new(phase, index)
5695 .ok_or(NodeError::ShutdownCallbacksFull)?;
5696 *slot = Some(super::types::ShutdownHook { callback, context });
5697 return Ok(handle);
5698 }
5699 Err(NodeError::ShutdownCallbacksFull)
5700 }
5701
5702 /// Clear a slot of a phase table, rejecting a handle from the other phase.
5703 fn release_shutdown_slot(
5704 table: &mut [Option<super::types::ShutdownHook>],
5705 phase: super::types::ShutdownPhase,
5706 handle: super::types::ShutdownCallbackHandle,
5707 ) -> bool {
5708 if handle.phase() != Some(phase) {
5709 return false;
5710 }
5711 match table.get_mut(handle.index()) {
5712 Some(slot) => slot.take().is_some(),
5713 None => false,
5714 }
5715 }
5716
5717 /// Run — and consume — every hook registered for `phase`, in registration
5718 /// order.
5719 ///
5720 /// The table is EMPTIED BEFORE the first callback runs, not slot by slot as
5721 /// the loop walks it, and both halves of that matter:
5722 ///
5723 /// * "exactly once" becomes a property of the table rather than of the
5724 /// caller — a second `close()`, or the `Drop` sweep after one, finds
5725 /// nothing left to run;
5726 /// * the `&mut self` borrow ENDS before any foreign code is invoked. A hook
5727 /// is an `extern "C" fn` that may hold a raw pointer back to this
5728 /// executor (the C and C++ shims hand out exactly that), so a hook that
5729 /// registers or removes another one must not be running inside a live
5730 /// `&mut` into the table it is touching.
5731 pub(crate) fn run_shutdown_hooks(&mut self, phase: super::types::ShutdownPhase) {
5732 let table = match phase {
5733 super::types::ShutdownPhase::Pre => &mut self.pre_shutdown_hooks,
5734 super::types::ShutdownPhase::Post => &mut self.on_shutdown_hooks,
5735 };
5736 // `ShutdownHook` is `Copy` (a fn pointer and a raw pointer), so this is
5737 // a register-width move of a table whose default size is two slots —
5738 // not a reason to keep the borrow open across the calls.
5739 let hooks = *table;
5740 *table = [None; crate::config::MAX_SHUTDOWN_CBS];
5741 for hook in hooks.iter().flatten() {
5742 // SAFETY: the `add_*_shutdown_callback` caller promised `callback`
5743 // is safe to invoke once with `context`, and that `context` stays
5744 // valid until the hook runs or is removed. This is that one call,
5745 // and the table is already cleared so it cannot happen again.
5746 unsafe {
5747 (hook.callback)(hook.context);
5748 }
5749 }
5750 }
5751
5752 /// Phase 216 follow-up — register a per-Node dispatch trampoline.
5753 ///
5754 /// The board-side Entry pkg (or the macro-emitted
5755 /// `register_dispatch(executor)` wrapper, once wired) calls this
5756 /// once per deployed Node pkg, handing in the
5757 /// `__nros_node_<pkg>_on_callback` symbol + the Node's per-pkg
5758 /// `state` blob. [`Executor::dispatch_callback`] then linear-scans
5759 /// the registered slots when the dispatch task hands off a
5760 /// `SignaledCallback`.
5761 ///
5762 /// Returns `Err(())` when the registry is full (`MAX_NODES`
5763 /// entries — raise via `NROS_EXECUTOR_MAX_NODES` at build time).
5764 ///
5765 /// # Safety
5766 ///
5767 /// `state` must outlive the executor (the typical shape is a
5768 /// `*mut State` produced by
5769 /// `nros::__private_node_state_into_raw` from the
5770 /// macro-emitted `i()`; that pointer's lifetime IS the
5771 /// `Executor`'s by construction). `on_callback` must be safe to
5772 /// invoke with `(state, cb_id_ptr, cb_id_len, ctx)` matching the
5773 /// per-Node `__nros_node_<pkg>_on_callback` ABI emitted by the
5774 /// `nros::node!()` macro (Phase 216.A.5).
5775 #[allow(clippy::result_unit_err)]
5776 pub fn register_dispatch_slot(
5777 &mut self,
5778 state: *mut core::ffi::c_void,
5779 on_callback: unsafe extern "C" fn(
5780 *mut core::ffi::c_void,
5781 *const u8,
5782 usize,
5783 *mut core::ffi::c_void,
5784 ),
5785 ) -> Result<(), ()> {
5786 self.dispatch_slots
5787 .push(DispatchSlot { state, on_callback })
5788 .map_err(|_| ())
5789 }
5790
5791 /// Phase 216 follow-up — current registered dispatch-slot count.
5792 /// Diagnostic / test surface.
5793 pub fn dispatch_slot_count(&self) -> usize {
5794 self.dispatch_slots.len()
5795 }
5796
5797 /// Phase 258 (Track 2, 2a) — enroll a component into the executor-owned
5798 /// tick registry. Called by `nros`'s `install`/`register_node_borrowed`
5799 /// after it builds the `Arc<ComponentCell>`: `state` is the leaked
5800 /// `Arc<ComponentCell>` (the slot takes ownership), `tick`/`drop` are the
5801 /// `nros`-side trampolines (see `ComponentSlot`). The slot's `tick`
5802 /// runs at the tail of every [`spin_once`](Self::spin_once); its `drop`
5803 /// runs once on `Executor::drop`.
5804 ///
5805 /// Returns `Err(())` when the registry is full (`MAX_NODES` — raise via
5806 /// `NROS_EXECUTOR_MAX_NODES` at build time). On error the caller still
5807 /// owns `state` (the slot was not stored) and must drop it.
5808 ///
5809 /// # Safety
5810 /// `state` must be a `*mut` produced by leaking the component cell the
5811 /// `tick`/`drop` trampolines expect (an `Arc<ComponentCell>` via
5812 /// `Arc::into_raw` in the canonical `nros` caller), and must remain valid
5813 /// until the matching `drop` runs. `tick` must be safe to invoke with
5814 /// `(state, exec_ctx = *mut Executor)` each spin; `drop` must be safe to
5815 /// invoke exactly once with `state`.
5816 #[allow(clippy::result_unit_err)]
5817 pub unsafe fn enroll_component(
5818 &mut self,
5819 state: *mut core::ffi::c_void,
5820 tick: unsafe extern "C" fn(*mut core::ffi::c_void, *mut core::ffi::c_void),
5821 drop: unsafe extern "C" fn(*mut core::ffi::c_void),
5822 ) -> Result<(), ()> {
5823 self.component_slots
5824 .push(ComponentSlot { state, tick, drop })
5825 .map_err(|_| ())
5826 }
5827
5828 /// Phase 258 (Track 2, 2a) — current enrolled component-slot count.
5829 /// Diagnostic / test surface.
5830 pub fn component_slot_count(&self) -> usize {
5831 self.component_slots.len()
5832 }
5833
5834 /// issue #140 — the enrolled components' opaque `state` pointers, in enroll
5835 /// order. Each is the *leaked* `Arc<ComponentCell>` `enroll_component` was
5836 /// handed (see `ComponentSlot::state`); the `nros` layer re-borrows them
5837 /// to fold per-component dispatch counters into
5838 /// `observed_callback_counts` — install-seam components
5839 /// (`register_node_borrowed`) live ONLY here, not in
5840 /// `ExecutorNodeRuntime::components`, so the hosted-spin counts read zero
5841 /// without this surface.
5842 pub fn enrolled_component_states(&self) -> impl Iterator<Item = *mut core::ffi::c_void> + '_ {
5843 self.component_slots.iter().map(|slot| slot.state)
5844 }
5845
5846 /// Phase 216 final dispatch hook — stable entry point the
5847 /// framework's dispatch task (RTIC `__nros_run` /
5848 /// Embassy `__nros_run_task`) calls for each `SignaledCallback`
5849 /// envelope it dequeues from the board-side SPSC / Embassy
5850 /// channel.
5851 ///
5852 /// ## Signature shape
5853 ///
5854 /// `nros-node` sits below `nros` in the dep graph, so the typed
5855 /// `nros::CallbackId<'_>` / `nros::CallbackCtx<'_>` types
5856 /// referenced in the Phase 216 design notes cannot appear in the
5857 /// signature here. The macro emit translates the dequeued
5858 /// envelope to the layer-clean `(cb_id: &str, ctx: *mut c_void)`
5859 /// pair before calling this method; the per-Node `on_callback`
5860 /// trampoline ABI (Phase 216.A.5,
5861 /// `__nros_node_<pkg>_on_callback(state, cb_id_ptr, cb_id_len,
5862 /// ctx)`) uses the same untyped shape on the other side of the
5863 /// fence, so the round-trip stays type-consistent.
5864 ///
5865 /// ## Body — linear scan of the dispatch registry
5866 ///
5867 /// Each registered `DispatchSlot` holds an
5868 /// `__nros_node_<pkg>_on_callback` fn pointer + the owning Node's
5869 /// `state` blob. The macro-emitted trampoline body
5870 /// `match`es on `CallbackId` tags the Node declared and is a
5871 /// no-op for non-matching `cb_id`s — at most one Node per
5872 /// `cb_id` actually acts, the rest are cheap string-compare
5873 /// no-ops. This mirrors the strategy
5874 /// `ExecutorNodeRuntime::dispatch_callback` uses in
5875 /// `packages/api/nros/src/node_runtime.rs:470`.
5876 ///
5877 /// ## What's NOT auto-wired today
5878 ///
5879 /// The `nros::node!()` macro doesn't yet emit a
5880 /// `register_dispatch(executor)` wrapper that pushes the per-pkg
5881 /// `(state, on_callback)` into this registry. Until that wiring
5882 /// lands (Phase 216 follow-up — see commit msg), downstream
5883 /// consumers (board's `init_hardware`, or the codegen-emitted
5884 /// `run_plan`) must call
5885 /// [`Executor::register_dispatch_slot`] explicitly with the
5886 /// `__nros_node_<pkg>_on_callback` symbol + a `state` blob from
5887 /// the macro-emitted `i()`.
5888 //
5889 // `ctx` is an opaque FFI cookie forwarded verbatim to each slot's
5890 // `on_callback`; this fn never dereferences it (the registered callback
5891 // does, under the `register_dispatch_slot` safety contract), so it is sound
5892 // to call from safe code.
5893 #[allow(clippy::not_unsafe_ptr_arg_deref)]
5894 pub fn dispatch_callback(&mut self, cb_id: &str, ctx: *mut core::ffi::c_void) {
5895 let cb_id_ptr = cb_id.as_ptr();
5896 let cb_id_len = cb_id.len();
5897 // Snapshot pointer + length to avoid an outstanding borrow
5898 // across the unsafe fn calls below; each `DispatchSlot` is
5899 // `Copy`, so iterating by value sidesteps any aliasing
5900 // worry the borrow checker would flag if a slot's
5901 // `on_callback` re-entered the executor.
5902 for slot in self.dispatch_slots.iter().copied() {
5903 // SAFETY: caller of `register_dispatch_slot` guaranteed
5904 // `state` outlives the executor + `on_callback` matches
5905 // the per-Node `__nros_node_<pkg>_on_callback` ABI;
5906 // `cb_id_ptr`/`cb_id_len` describe the live `&str` the
5907 // caller passed in.
5908 unsafe {
5909 (slot.on_callback)(slot.state, cb_id_ptr, cb_id_len, ctx);
5910 }
5911 }
5912 }
5913
5914 /// Get a reference to the underlying session.
5915 pub fn session(&self) -> &session::ConcreteSession {
5916 &self.session
5917 }
5918
5919 /// Get a mutable reference to the underlying session.
5920 pub fn session_mut(&mut self) -> &mut session::ConcreteSession {
5921 &mut self.session
5922 }
5923
5924 /// Phase 124.F.3 — session-level connectivity probe. Wire-level
5925 /// round-trip "is the peer / agent / router still reachable?"
5926 /// — cheaper than the service-availability probe (no discovery
5927 /// state required).
5928 ///
5929 /// Returns `Ok(())` on reply within `timeout_ms`,
5930 /// `Err(NodeError::Transport(Timeout))` on no reply,
5931 /// `Err(NodeError::Transport(Unsupported))` when the active
5932 /// backend can't probe.
5933 ///
5934 /// Mirrors micro-ROS's `rmw_uros_ping_agent`. Useful for
5935 /// reconnect-on-link-loss patterns: bare-metal code can call
5936 /// `ping(100)` periodically and tear down / re-open the session
5937 /// on timeout.
5938 pub fn ping(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
5939 use nros_rmw::Session;
5940 self.session
5941 .ping_session(timeout_ms)
5942 .map_err(NodeError::Transport)
5943 }
5944
5945 /// phase-381 W4 — every node on the graph, with its namespace.
5946 ///
5947 /// `visit(name, namespace, enclave)` is called once per node and returns
5948 /// `false` to stop early. A VISITOR rather than a returned collection
5949 /// because there is no allocator at this layer and the graph has no bound
5950 /// the caller can know; peak extra memory is one entry. Every string is
5951 /// BORROWED for the duration of the call.
5952 ///
5953 /// `enclave` is `None` where the backend does not track one, which is what
5954 /// lets one call answer both `rmw_get_node_names` and
5955 /// `rmw_get_node_names_with_enclaves`.
5956 ///
5957 /// **This reports what has already been DISCOVERED, and never blocks.** The
5958 /// first call after startup legitimately sees a partial graph — the backend
5959 /// keeps a standing query fed by `spin`, so the view fills in over
5960 /// successive calls. Code that waits for a peer should poll, not call once
5961 /// and conclude. An empty result is "nobody seen yet", never "nobody
5962 /// exists".
5963 ///
5964 /// `Err(Transport(Unsupported))` from a backend with no graph — distinct
5965 /// from an empty graph, deliberately.
5966 pub fn get_node_names(
5967 &mut self,
5968 visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
5969 ) -> Result<(), NodeError> {
5970 use nros_rmw::Session;
5971 self.session
5972 .get_node_names(visit)
5973 .map_err(NodeError::Transport)
5974 }
5975
5976 /// phase-381 W4 — every topic on the graph, with the types on it.
5977 ///
5978 /// `visit(topic_name, types)` is called once per distinct TOPIC — a topic
5979 /// carrying two types is one call with two entries, not two calls. `types`
5980 /// may legitimately be empty on a partially discovered graph: reporting the
5981 /// name without a type beats dropping it.
5982 ///
5983 /// Same discovery caveat as [`Self::get_node_names`].
5984 pub fn get_topic_names_and_types(
5985 &mut self,
5986 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
5987 ) -> Result<(), NodeError> {
5988 use nros_rmw::Session;
5989 self.session
5990 .get_topic_names_and_types(visit)
5991 .map_err(NodeError::Transport)
5992 }
5993
5994 /// phase-381 W4 — every service on the graph, with its types.
5995 /// As [`Self::get_topic_names_and_types`], over servers and clients.
5996 pub fn get_service_names_and_types(
5997 &mut self,
5998 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
5999 ) -> Result<(), NodeError> {
6000 use nros_rmw::Session;
6001 self.session
6002 .get_service_names_and_types(visit)
6003 .map_err(NodeError::Transport)
6004 }
6005
6006 /// phase-381 W4 — how many publishers are visible on `topic_name`.
6007 ///
6008 /// `topic_name` is a ROS name (`"/chatter"`). A count reflects what has
6009 /// been DISCOVERED, so it can be low right after startup and is never a
6010 /// proof of absence — see [`Self::get_node_names`].
6011 pub fn count_publishers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
6012 use nros_rmw::Session;
6013 self.session
6014 .count_publishers(topic_name)
6015 .map_err(NodeError::Transport)
6016 }
6017
6018 /// phase-381 W4 — how many subscribers are visible on `topic_name`.
6019 /// See [`Self::count_publishers`] for the caveats.
6020 pub fn count_subscribers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
6021 use nros_rmw::Session;
6022 self.session
6023 .count_subscribers(topic_name)
6024 .map_err(NodeError::Transport)
6025 }
6026
6027 /// phase-381 W4 — what one named node PUBLISHES, with the types.
6028 ///
6029 /// `visit(topic_name, types)` per distinct topic. A node the graph has not
6030 /// discovered yields no visits, which is not an error — see
6031 /// [`Self::get_node_names`] for why an empty answer means "not seen yet".
6032 pub fn get_publisher_names_and_types_by_node(
6033 &mut self,
6034 node_name: &str,
6035 node_namespace: &str,
6036 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6037 ) -> Result<(), NodeError> {
6038 use nros_rmw::{GraphEntityKind, Session};
6039 self.session
6040 .get_names_and_types_by_node(
6041 GraphEntityKind::Publisher,
6042 node_name,
6043 node_namespace,
6044 visit,
6045 )
6046 .map_err(NodeError::Transport)
6047 }
6048
6049 /// phase-381 W4 — what one named node SUBSCRIBES to, with the types.
6050 ///
6051 /// **`subscription`, not `subscriber`** — this is rclrs's spelling
6052 /// (`get_subscription_names_and_types_by_node`), and the Rust surface takes
6053 /// its vocabulary from rclrs so a user porting Rust ROS 2 code types what
6054 /// they already know. The C surface says `subscriber` because rcl does, and
6055 /// the vtable slot says `subscriber` because upstream rmw does. Three
6056 /// layers, three upstreams, one word each — not drift. Issue 0788 owns the
6057 /// wider verb sweep.
6058 pub fn get_subscription_names_and_types_by_node(
6059 &mut self,
6060 node_name: &str,
6061 node_namespace: &str,
6062 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6063 ) -> Result<(), NodeError> {
6064 use nros_rmw::{GraphEntityKind, Session};
6065 self.session
6066 .get_names_and_types_by_node(
6067 GraphEntityKind::Subscriber,
6068 node_name,
6069 node_namespace,
6070 visit,
6071 )
6072 .map_err(NodeError::Transport)
6073 }
6074
6075 /// phase-381 W4 — what services one named node SERVES, with the types.
6076 pub fn get_service_names_and_types_by_node(
6077 &mut self,
6078 node_name: &str,
6079 node_namespace: &str,
6080 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6081 ) -> Result<(), NodeError> {
6082 use nros_rmw::{GraphEntityKind, Session};
6083 self.session
6084 .get_names_and_types_by_node(GraphEntityKind::Service, node_name, node_namespace, visit)
6085 .map_err(NodeError::Transport)
6086 }
6087
6088 /// phase-381 W4 — what services one named node CALLS, with the types.
6089 pub fn get_client_names_and_types_by_node(
6090 &mut self,
6091 node_name: &str,
6092 node_namespace: &str,
6093 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6094 ) -> Result<(), NodeError> {
6095 use nros_rmw::{GraphEntityKind, Session};
6096 self.session
6097 .get_names_and_types_by_node(GraphEntityKind::Client, node_name, node_namespace, visit)
6098 .map_err(NodeError::Transport)
6099 }
6100
6101 /// phase-381 W4 — the publishers on `topic_name`, one visit each.
6102 ///
6103 /// Each `GraphEndpointInfo` BORROWS its strings for the duration of the
6104 /// visit; copy anything kept.
6105 ///
6106 /// It carries no QoS. The granted profile is what would answer "why is
6107 /// nothing arriving", and no backend can read one back yet — reporting the
6108 /// remote's DECLARED profile instead would be a confident wrong answer, so
6109 /// the field is absent rather than misleading.
6110 pub fn get_publishers_info_by_topic(
6111 &mut self,
6112 topic_name: &str,
6113 visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
6114 ) -> Result<(), NodeError> {
6115 use nros_rmw::Session;
6116 self.session
6117 .get_endpoint_info_by_topic(true, topic_name, visit)
6118 .map_err(NodeError::Transport)
6119 }
6120
6121 /// phase-381 W4 — the subscriptions on `topic_name`, one visit each.
6122 /// See [`Self::get_publishers_info_by_topic`].
6123 pub fn get_subscriptions_info_by_topic(
6124 &mut self,
6125 topic_name: &str,
6126 visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
6127 ) -> Result<(), NodeError> {
6128 use nros_rmw::Session;
6129 self.session
6130 .get_endpoint_info_by_topic(false, topic_name, visit)
6131 .map_err(NodeError::Transport)
6132 }
6133
6134 /// phase-444 — block until at least `count` publishers are visible on
6135 /// `topic_name`, or `timeout` elapses. Upstream's `rcl_wait_for_publishers`.
6136 ///
6137 /// Returns `Ok(true)` when the graph showed at least `count` inside the
6138 /// budget, `Ok(false)` on timeout. The startup-ordering primitive one
6139 /// entity kind over from [`EmbeddedServiceClient::wait_for_service`](super::handles::EmbeddedServiceClient::wait_for_service),
6140 /// and built the same way: a spin on a discovery read, so other
6141 /// subscriptions and timers keep making progress while it waits.
6142 ///
6143 /// **This is the one blocking member of the graph family, and it does not
6144 /// weaken the family's contract — it composes it.** The query underneath
6145 /// ([`count_publishers`](Self::count_publishers)) still reports only what
6146 /// has been DISCOVERED and still never blocks; what waits is this loop,
6147 /// and it waits by POLLING that query. RFC-0021 is the rule that governs
6148 /// it, and that rule does not forbid blocking: it requires a blocking
6149 /// helper to take an executor and drive it, which is what makes the
6150 /// timeout reliable on a single-threaded transport and what makes the
6151 /// reentrancy visible at the call site.
6152 ///
6153 /// **`Unsupported` is propagated IMMEDIATELY, not waited out** — and this
6154 /// is the one place it deliberately differs from `wait_for_service`, which
6155 /// treats a backend that cannot answer as "keep waiting" and reports
6156 /// `Ok(false)` at the deadline (issue 1087). That is right for a service
6157 /// probe, where not-yet-answerable is a transient. It is wrong here: a
6158 /// backend with no graph (XRCE) can never answer, so spending the budget
6159 /// to return `Ok(false)` would report "no publishers appeared" for a
6160 /// backend that cannot see publishers at all — collapsing "cannot tell
6161 /// you" into "not there", which is exactly the distinction RFC-0036 exists
6162 /// to keep and which every other method in this family promises to keep.
6163 ///
6164 /// `count == 0` is satisfied immediately and without asking the backend —
6165 /// including on a backend with no graph, because "at least zero" is true
6166 /// of every graph and needs no discovery to establish.
6167 pub fn wait_for_publishers(
6168 &mut self,
6169 topic_name: &str,
6170 count: usize,
6171 timeout: core::time::Duration,
6172 ) -> Result<bool, NodeError> {
6173 self.wait_for_endpoints(true, topic_name, count, timeout)
6174 }
6175
6176 /// phase-444 — block until at least `count` subscribers are visible on
6177 /// `topic_name`. Upstream's `rcl_wait_for_subscribers`. See
6178 /// [`wait_for_publishers`](Self::wait_for_publishers) for the contract,
6179 /// and for why `Unsupported` returns rather than waits.
6180 pub fn wait_for_subscribers(
6181 &mut self,
6182 topic_name: &str,
6183 count: usize,
6184 timeout: core::time::Duration,
6185 ) -> Result<bool, NodeError> {
6186 self.wait_for_endpoints(false, topic_name, count, timeout)
6187 }
6188
6189 /// The one loop both `wait_for_*` verbs run — a shared helper rather than
6190 /// a second spelling, because the pair differs only in which counter it
6191 /// polls and a copied loop is how the two drift.
6192 fn wait_for_endpoints(
6193 &mut self,
6194 publishers: bool,
6195 topic_name: &str,
6196 count: usize,
6197 timeout: core::time::Duration,
6198 ) -> Result<bool, NodeError> {
6199 if count == 0 {
6200 return Ok(true);
6201 }
6202 // `?`, not a swallow: a backend that cannot see the graph says so now.
6203 if self.count_endpoints(publishers, topic_name)? >= count {
6204 return Ok(true);
6205 }
6206 let spin_interval =
6207 core::time::Duration::from_millis(super::handles::DEFAULT_SPIN_INTERVAL_MS);
6208 let max_spins =
6209 (timeout.as_millis() as u64 / super::handles::DEFAULT_SPIN_INTERVAL_MS).max(1);
6210 let mut budget = super::handles::WaitBudget::new(max_spins, timeout);
6211 loop {
6212 self.spin_once(budget.next_spin_interval(spin_interval));
6213 if self.count_endpoints(publishers, topic_name)? >= count {
6214 return Ok(true);
6215 }
6216 if !budget.tick() {
6217 return Ok(false);
6218 }
6219 }
6220 }
6221
6222 /// Which counter a `wait_for_*` polls. Keeps the boolean's meaning in ONE
6223 /// place instead of at both call sites.
6224 fn count_endpoints(&mut self, publishers: bool, topic_name: &str) -> Result<usize, NodeError> {
6225 if publishers {
6226 self.count_publishers(topic_name)
6227 } else {
6228 self.count_subscribers(topic_name)
6229 }
6230 }
6231
6232 /// Get a mutable reference to an action client core in the arena by entry index.
6233 ///
6234 /// # Safety
6235 /// The caller must ensure that `entry_index` refers to an `ActionClientRawArenaEntry`.
6236 pub unsafe fn action_client_core_mut(
6237 &mut self,
6238 entry_index: usize,
6239 ) -> Option<&mut super::action_core::ActionClientCore> {
6240 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6241 if !matches!(meta.kind, EntryKind::ActionClient) {
6242 return None;
6243 }
6244 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
6245 unsafe {
6246 let entry_ptr = arena_ptr.add(meta.offset)
6247 as *mut super::arena::ActionClientRawArenaEntry<
6248 { crate::config::DEFAULT_RX_BUF_SIZE },
6249 { crate::config::DEFAULT_RX_BUF_SIZE },
6250 { crate::config::DEFAULT_RX_BUF_SIZE },
6251 >;
6252 Some(&mut (*entry_ptr).core)
6253 }
6254 }
6255
6256 /// Get a mutable reference to a service-client arena entry (Phase 82).
6257 ///
6258 /// Returns `None` if `entry_index` doesn't refer to a service client
6259 /// entry. The default reply buffer size is assumed because the C API
6260 /// always uses the default — the entry was registered via
6261 /// `register_service_client_raw_sized::<DEFAULT_RX_BUF_SIZE>`.
6262 ///
6263 /// # Safety
6264 /// `entry_index` must refer to a `ServiceClientRawArenaEntry`.
6265 pub unsafe fn service_client_entry_mut(
6266 &mut self,
6267 entry_index: usize,
6268 ) -> Option<&mut super::arena::ServiceClientRawArenaEntry<{ crate::config::DEFAULT_RX_BUF_SIZE }>>
6269 {
6270 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6271 if !matches!(meta.kind, EntryKind::ServiceClient) {
6272 return None;
6273 }
6274 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
6275 unsafe {
6276 let entry_ptr = arena_ptr.add(meta.offset)
6277 as *mut super::arena::ServiceClientRawArenaEntry<
6278 { crate::config::DEFAULT_RX_BUF_SIZE },
6279 >;
6280 Some(&mut *entry_ptr)
6281 }
6282 }
6283
6284 // ------------------------------------------------------------------
6285 // issue 1437 / phase-444 — the ARENA half of the granted-QoS read-back.
6286 //
6287 // An entity registered with a callback lives in the executor arena, and
6288 // the C / C++ handle the user holds is then bookkeeping: its inline
6289 // storage is untouched and the only identity it carries is the entry
6290 // index. So an accessor that reads the user's own storage answers for
6291 // the POLL road and returns an absence on the CALLBACK road — which is
6292 // the road a ported `rclcpp` node takes, i.e. the common one. These
6293 // three close that reach.
6294 //
6295 // Each reads the entry's HANDLE and nothing else, which is why one
6296 // function per kind serves every entry SHAPE of that kind: every arena
6297 // entry is `#[repr(C)]` with its handle as the first field (12
6298 // subscription shapes, 2 service-server, 2 service-client), so
6299 // `arena_base + offset` IS the handle pointer whatever buffer sizes and
6300 // callback types the shape was monomorphised with.
6301 // `service_client_entry_mut` above has to name ONE shape because it
6302 // reaches past the handle; these do not, and that is the whole
6303 // difference. `arena::tests::every_arena_entry_begins_with_its_handle`
6304 // pins the invariant.
6305
6306 /// The `RmwSubscriber` of an arena-registered subscription, by entry index.
6307 ///
6308 /// `None` when `entry_index` names no entry, or names one that is not a
6309 /// subscription.
6310 ///
6311 /// # Safety
6312 /// `entry_index` must be an index this executor handed out. The borrow is
6313 /// valid only while the arena is not re-entered.
6314 pub unsafe fn subscription_handle(
6315 &self,
6316 entry_index: usize,
6317 ) -> Option<&session::RmwSubscriber> {
6318 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6319 if !matches!(meta.kind, EntryKind::Subscription) {
6320 return None;
6321 }
6322 let arena_ptr = self.arena.as_ptr() as *const u8;
6323 // SAFETY: `meta.offset` is inside the arena by construction, and every
6324 // subscription entry shape begins with its `RmwSubscriber`.
6325 Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwSubscriber) })
6326 }
6327
6328 /// The `RmwServiceServer` of an arena-registered service, by entry index.
6329 ///
6330 /// # Safety
6331 /// See [`Executor::subscription_handle`].
6332 pub unsafe fn service_server_handle(
6333 &self,
6334 entry_index: usize,
6335 ) -> Option<&session::RmwServiceServer> {
6336 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6337 if !matches!(meta.kind, EntryKind::Service) {
6338 return None;
6339 }
6340 let arena_ptr = self.arena.as_ptr() as *const u8;
6341 // SAFETY: as above; every service-server entry shape begins with its
6342 // `RmwServiceServer`.
6343 Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwServiceServer) })
6344 }
6345
6346 /// The `RmwServiceClient` of an arena-registered client, by entry index.
6347 ///
6348 /// # Safety
6349 /// See [`Executor::subscription_handle`].
6350 pub unsafe fn service_client_handle(
6351 &self,
6352 entry_index: usize,
6353 ) -> Option<&session::RmwServiceClient> {
6354 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
6355 if !matches!(meta.kind, EntryKind::ServiceClient) {
6356 return None;
6357 }
6358 let arena_ptr = self.arena.as_ptr() as *const u8;
6359 // SAFETY: as above; both service-client entry shapes begin with their
6360 // `RmwServiceClient` — the typed one through `ServiceClientSendHeader`,
6361 // itself `#[repr(C)]` and handle-first.
6362 Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwServiceClient) })
6363 }
6364
6365 /// Set the executor-level trigger condition.
6366 ///
6367 /// Controls which handles must be ready before `spin_once` dispatches
6368 /// callbacks. Defaults to [`Trigger::AnyReady`](crate::Trigger).
6369 pub fn set_trigger(&mut self, trigger: Trigger) {
6370 self.trigger = trigger;
6371 }
6372
6373 /// Set the executor data communication semantics.
6374 ///
6375 /// Choose between `Direct` (process in place) and `LET`
6376 /// (snapshot-then-process) semantics. See [`ExecutorSemantics`].
6377 pub fn set_semantics(&mut self, semantics: ExecutorSemantics) {
6378 self.semantics = semantics;
6379 }
6380
6381 /// Set the invocation mode for a specific handle.
6382 ///
6383 /// Controls whether the callback fires on every spin
6384 /// ([`Always`](InvocationMode::Always)) or only when new data
6385 /// arrives ([`OnNewData`](InvocationMode::OnNewData), the default).
6386 pub fn set_invocation(&mut self, id: HandleId, mode: InvocationMode) {
6387 let slot = self.live_slot(id);
6388 if let Some(Some(meta)) = self.entries.get_mut(slot) {
6389 meta.invocation = mode;
6390 }
6391 }
6392
6393 // ========================================================================
6394 // Arena-based callback registration
6395 // ========================================================================
6396
6397 /// Arena bytes claimed by registered entities so far.
6398 ///
6399 /// EXACT, not a worst case: the arena is a bump allocator and every
6400 /// `arena_alloc` charges `size_of::<T>()`, so nothing is reserved per slot.
6401 /// `ARENA_SIZE` itself is derived the other way — every slot budgeted at
6402 /// the ActionClient worst case — which is issue 0900, and this accessor is
6403 /// how an image can find out what it actually needs.
6404 pub fn arena_used(&self) -> usize {
6405 self.arena_used
6406 }
6407
6408 /// Total arena bytes this executor was given.
6409 ///
6410 /// The arena is a borrowed slice; where its storage lives is the caller's
6411 /// choice, not a property of this type. Three answers are real — a `.bss`
6412 /// static (the C/C++ entries, and the `alloc` constructors since phase-392
6413 /// W6), the heap (those constructors' fallback), and a stack-declared
6414 /// `rclc_executor_t`. See `super::backing` and `report_arena_headroom`.
6415 pub fn arena_capacity(&self) -> usize {
6416 self.arena.len()
6417 }
6418
6419 /// Bump-allocate space for `T` in the arena. Returns the byte offset.
6420 pub(crate) fn arena_alloc<T>(&mut self) -> Result<usize, NodeError> {
6421 // Issue 1667 — every region is grain-aligned and at least one free-list
6422 // node long, so it can be listed when it is released.
6423 let align = core::mem::align_of::<T>().max(ARENA_GRAIN);
6424 let size = Self::arena_region_len(core::mem::size_of::<T>());
6425 if let Some((offset, granted)) = self.arena_take_freed(size, align) {
6426 crate::boot_report::note_alloc(size, self.arena_used);
6427 self.note_entry_region(offset, granted);
6428 return Ok(offset);
6429 }
6430 let aligned_offset = (self.arena_used + align - 1) & !(align - 1);
6431 let new_used = aligned_offset + size;
6432 if new_used > self.arena.len() {
6433 // issue 0900 — `BufferTooSmall` is returned by a dozen other paths,
6434 // so on a target where a return code is all you get, arena
6435 // exhaustion is indistinguishable from a message that did not fit.
6436 return Err(self.arena_exhausted(
6437 core::any::type_name::<T>(),
6438 size,
6439 new_used - self.arena.len(),
6440 ));
6441 }
6442 self.arena_used = new_used;
6443 crate::boot_report::note_alloc(size, new_used);
6444 self.note_entry_region(aligned_offset, size);
6445 Ok(aligned_offset)
6446 }
6447
6448 /// Issue 1631 — remember the region an ENTRY allocation handed out, for
6449 /// `emplace_entry` to record. Only the two entry allocators call this; a
6450 /// callback capture rides inside the entry's trailing region (phase-476
6451 /// W0), so it is part of what they record.
6452 fn note_entry_region(&mut self, offset: usize, len: usize) {
6453 self.pending_region = Some(FreedRegion { offset, len });
6454 }
6455
6456 /// Issue 1667 — the length a region of `len` requested bytes occupies:
6457 /// rounded up to the grain, and never shorter than a free-list node.
6458 const fn arena_region_len(len: usize) -> usize {
6459 let len = len.next_multiple_of(ARENA_GRAIN);
6460 if len < ARENA_MIN_REGION {
6461 ARENA_MIN_REGION
6462 } else {
6463 len
6464 }
6465 }
6466
6467 /// Bytes a callback CAPTURE takes in an entry's trailing region: its length
6468 /// rounded up to the arena's `u64` grain. 0 for no capture.
6469 ///
6470 /// phase-476 W0 — a capture used to be a SEPARATE arena allocation
6471 /// (`stow_capture` over `arena_alloc_bytes`), which `release_entry` never
6472 /// gave back: it frees exactly the region `emplace_entry` recorded, and that
6473 /// was the entry's. Placing the capture at the END of the entry's own
6474 /// trailing region puts it inside the recorded region, so releasing the
6475 /// entry releases its capture.
6476 pub(crate) fn capture_trailing_len(capture: Option<&[u8]>) -> usize {
6477 match capture {
6478 Some(b) if !b.is_empty() => b.len().next_multiple_of(core::mem::align_of::<u64>()),
6479 _ => 0,
6480 }
6481 }
6482
6483 /// Copy a caller's callback CAPTURE to arena offset `at` and return the
6484 /// context pointer the entry should dispatch with — phase-456 W1, made a
6485 /// runtime length by W8, made part of the entry's region by phase-476 W0.
6486 ///
6487 /// `context` is one pointer. That carries a C++ `[this]` capture and not a
6488 /// `[this, state]` (two) or a `[obj, method]` (three, because a
6489 /// pointer-to-member-function is two words on the Itanium ABI) — the
6490 /// distribution phase-442 W0 measured over this tree and the porting corpus.
6491 /// So the capture lives in the arena beside the registration that uses it:
6492 /// arena bytes never move once allocated, and the returned pointer is into
6493 /// them rather than into anything of the caller's, so the C++ side keeps
6494 /// nothing.
6495 ///
6496 /// `at` must start [`capture_trailing_len`](Self::capture_trailing_len)
6497 /// bytes the caller's entry allocation reserved for it. `None` and an empty
6498 /// slice both mean "no capture", and both give the caller's own `context`
6499 /// straight back, which is every pre-W1 caller.
6500 pub(crate) fn place_capture(
6501 &mut self,
6502 at: usize,
6503 capture: Option<&[u8]>,
6504 context: *mut core::ffi::c_void,
6505 ) -> *mut core::ffi::c_void {
6506 let bytes = match capture {
6507 Some(b) if !b.is_empty() => b,
6508 _ => return context,
6509 };
6510 debug_assert!(at + bytes.len() <= self.arena.len());
6511 // SAFETY: the caller's entry allocation reserved
6512 // `capture_trailing_len(capture)` bytes at `at` for exactly this, and
6513 // `bytes` is a caller slice that cannot alias the arena (the arena is
6514 // borrowed mutably by `self` for the whole call).
6515 unsafe {
6516 let dst = (self.arena.as_mut_ptr() as *mut u8).add(at);
6517 core::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len());
6518 dst as *mut core::ffi::c_void
6519 }
6520 }
6521
6522 /// Bump-allocate space for `T` plus `trailing_bytes` extra bytes.
6523 ///
6524 /// Returns `(entry_offset, trailing_offset)`. The trailing region starts
6525 /// immediately after `T` (aligned to 8 bytes).
6526 pub(crate) fn arena_alloc_with_trailing<T>(
6527 &mut self,
6528 trailing_bytes: usize,
6529 ) -> Result<(usize, usize), NodeError> {
6530 let align = core::mem::align_of::<T>().max(ARENA_GRAIN);
6531 let entry_size = core::mem::size_of::<T>();
6532 // Issue 1496 — a released region that holds the entry AND its trailing
6533 // region is reused whole. The trailing region keeps its rule: it starts
6534 // on a `u64` boundary after the entry, so the request is sized by that.
6535 let u64_align = core::mem::align_of::<u64>();
6536 let whole = Self::arena_region_len(entry_size.next_multiple_of(u64_align) + trailing_bytes);
6537 if let Some((entry_offset, granted)) = self.arena_take_freed(whole, align) {
6538 let trailing_offset = (entry_offset + entry_size).next_multiple_of(u64_align);
6539 crate::boot_report::note_alloc(whole, self.arena_used);
6540 self.note_entry_region(entry_offset, granted);
6541 return Ok((entry_offset, trailing_offset));
6542 }
6543 let entry_offset = self.arena_used.next_multiple_of(align);
6544 // Trailing region starts on an 8-byte (u64) boundary after the entry.
6545 let trailing_offset =
6546 (entry_offset + entry_size).next_multiple_of(core::mem::align_of::<u64>());
6547 // Issue 1667 — the region ends on the grain and is at least a node.
6548 let new_used = entry_offset + whole;
6549 debug_assert!(new_used >= trailing_offset + trailing_bytes);
6550 if new_used > self.arena.len() {
6551 // This path reported NOTHING until phase-412's self-report went in,
6552 // while its sibling `arena_alloc` has named the knob since issue
6553 // 0900. Half of arena exhaustion was therefore silent -- and it is
6554 // the half carrying buffered subscriptions and action entries,
6555 // which is what an island image actually allocates.
6556 return Err(self.arena_exhausted(
6557 core::any::type_name::<T>(),
6558 new_used - entry_offset,
6559 new_used - self.arena.len(),
6560 ));
6561 }
6562 self.arena_used = new_used;
6563 crate::boot_report::note_alloc(new_used - entry_offset, new_used);
6564 self.note_entry_region(entry_offset, new_used - entry_offset);
6565 Ok((entry_offset, trailing_offset))
6566 }
6567
6568 /// The ONE exhaustion path every arena allocator goes through — issues
6569 /// 0900, 1036, 1496.
6570 ///
6571 /// It names the knob (`report_arena_exhausted`), writes the first failure
6572 /// and its shortfall to the boot record (the only channel a console-less
6573 /// board has), says whether the failure is a SHAPE problem rather than a
6574 /// size one (released regions that together hold the request, none of
6575 /// which alone does), and — where the image asked for it — halts through
6576 /// the platform's fatal hook, so the record survives instead of the board
6577 /// running on with an entity missing. Three spellings of this existed, and
6578 /// the `_with_trailing` one reported nothing until phase-412.
6579 fn arena_exhausted(&self, entity: &'static str, request: usize, shortfall: usize) -> NodeError {
6580 super::arena::report_arena_exhausted(
6581 entity,
6582 shortfall,
6583 self.arena_used,
6584 self.arena.len(),
6585 self.arena_released(),
6586 request,
6587 );
6588 crate::boot_report::note_alloc_failed(request, shortfall);
6589 super::arena::arena_exhaustion_is_fatal();
6590 NodeError::BufferTooSmall
6591 }
6592
6593 /// Issue 1667 — read the free-list node at `offset`: `(next, len)`.
6594 fn arena_hole(&self, offset: usize) -> (usize, usize) {
6595 // SAFETY: `offset` is a listed free region: in bounds, grain-aligned and
6596 // at least `ARENA_MIN_REGION` long, written by `arena_set_hole`.
6597 unsafe {
6598 let p = (self.arena.as_ptr() as *const u8).add(offset) as *const usize;
6599 (p.read_unaligned(), p.add(1).read_unaligned())
6600 }
6601 }
6602
6603 /// Issue 1667 — write the free-list node at `offset`.
6604 fn arena_set_hole(&mut self, offset: usize, next: usize, len: usize) {
6605 debug_assert!(len >= ARENA_MIN_REGION && offset + len <= self.arena.len());
6606 // SAFETY: as `arena_hole`; the region is free, so nothing else reads it.
6607 unsafe {
6608 let p = (self.arena.as_mut_ptr() as *mut u8).add(offset) as *mut usize;
6609 p.write_unaligned(next);
6610 p.add(1).write_unaligned(len);
6611 }
6612 }
6613
6614 /// Point `prev`'s node (or the head, for `ARENA_FREE_NONE`) at `next`.
6615 fn arena_link(&mut self, prev: usize, next: usize) {
6616 if prev == ARENA_FREE_NONE {
6617 self.arena_free_head = next;
6618 } else {
6619 let (_, len) = self.arena_hole(prev);
6620 self.arena_set_hole(prev, next, len);
6621 }
6622 }
6623
6624 /// Issue 1496 / 1667 — first-fit over the free list. Returns the offset and
6625 /// the length GRANTED, which is the request plus any tail too short to list
6626 /// on its own: that tail belongs to the allocation, so its release gives it
6627 /// back. A hole whose alignment slack would be too short to list is skipped
6628 /// rather than split, for the same reason — no byte is ever orphaned.
6629 fn arena_take_freed(&mut self, size: usize, align: usize) -> Option<(usize, usize)> {
6630 let mut prev = ARENA_FREE_NONE;
6631 let mut cur = self.arena_free_head;
6632 while cur != ARENA_FREE_NONE {
6633 let (next, len) = self.arena_hole(cur);
6634 let start = cur.next_multiple_of(align);
6635 let head = start - cur;
6636 let fits = start + size <= cur + len;
6637 if fits && (head == 0 || head >= ARENA_MIN_REGION) {
6638 let tail = cur + len - (start + size);
6639 let granted = if tail < ARENA_MIN_REGION {
6640 size + tail
6641 } else {
6642 size
6643 };
6644 // What stays listed: the head slack, the tail, both, or neither.
6645 let mut after = next;
6646 if tail >= ARENA_MIN_REGION {
6647 let t = start + size;
6648 self.arena_set_hole(t, after, tail);
6649 after = t;
6650 }
6651 if head > 0 {
6652 self.arena_set_hole(cur, after, head);
6653 } else {
6654 self.arena_link(prev, after);
6655 }
6656 return Some((start, granted));
6657 }
6658 prev = cur;
6659 cur = next;
6660 }
6661 None
6662 }
6663
6664 /// Issue 1496 / 1667 — give a region back. It is inserted in address order
6665 /// and coalesced with both neighbours, so a loop that frees what it
6666 /// allocated returns the arena to the shape it had; a hole that then ends
6667 /// at `arena_used` is handed back to the bump pointer.
6668 pub(crate) fn arena_release(&mut self, offset: usize, len: usize) {
6669 debug_assert!(len >= ARENA_MIN_REGION && len.is_multiple_of(ARENA_GRAIN));
6670 // `pprev` -> `prev` -> [offset] -> `cur`, in address order.
6671 let mut pprev = ARENA_FREE_NONE;
6672 let mut prev = ARENA_FREE_NONE;
6673 let mut cur = self.arena_free_head;
6674 while cur != ARENA_FREE_NONE && cur < offset {
6675 pprev = prev;
6676 prev = cur;
6677 cur = self.arena_hole(cur).0;
6678 }
6679 let (mut start, mut size) = (offset, len);
6680 let mut next = cur;
6681 if next != ARENA_FREE_NONE && start + size == next {
6682 let (after, next_len) = self.arena_hole(next);
6683 size += next_len;
6684 next = after;
6685 }
6686 if prev != ARENA_FREE_NONE {
6687 let (_, prev_len) = self.arena_hole(prev);
6688 if prev + prev_len == start {
6689 start = prev;
6690 size += prev_len;
6691 // `prev` is absorbed: link from ITS predecessor.
6692 prev = pprev;
6693 }
6694 }
6695 if start + size == self.arena_used {
6696 // The top of the arena: give it back to the bump pointer.
6697 self.arena_used = start;
6698 self.arena_link(prev, next);
6699 return;
6700 }
6701 self.arena_set_hole(start, next, size);
6702 self.arena_link(prev, start);
6703 }
6704
6705 /// Issue 1496 — bytes released back to the arena and not yet reused.
6706 /// Bytes handed back to the bump pointer are not counted: `arena_used`
6707 /// already went down by them.
6708 pub fn arena_released(&self) -> usize {
6709 let mut total = 0;
6710 let mut cur = self.arena_free_head;
6711 while cur != ARENA_FREE_NONE {
6712 let (next, len) = self.arena_hole(cur);
6713 total += len;
6714 cur = next;
6715 }
6716 total
6717 }
6718
6719 /// Issue 1667 — how many separate holes the free list holds. For tests and
6720 /// diagnostics.
6721 pub fn arena_holes(&self) -> usize {
6722 let mut count = 0;
6723 let mut cur = self.arena_free_head;
6724 while cur != ARENA_FREE_NONE {
6725 count += 1;
6726 cur = self.arena_hole(cur).0;
6727 }
6728 count
6729 }
6730
6731 /// Issue 1496 — RELEASE one callback entry: drop the entry in place (its
6732 /// RMW handles' `Drop`s destroy the backend entities, so nothing stays
6733 /// advertised), free its callback slot, and give its arena bytes back for
6734 /// reuse. `false`, touching nothing, when `index` is not a live entry of
6735 /// `kind`.
6736 ///
6737 /// Issue 1631 — the bytes released are the ones `emplace_entry` RECORDED
6738 /// from the allocator (`CallbackMeta::arena_len`), entry and trailing
6739 /// region together. 1496's form took the size from the caller, which had
6740 /// to restate the concrete entry type — impossible for a buffered
6741 /// subscription (its trailing region is sized at runtime) or a timer (its
6742 /// closure type is the registering crate's).
6743 ///
6744 /// # Safety
6745 /// No handle naming `index` may be used afterwards: the slot and its bytes
6746 /// go to the next registration.
6747 pub(crate) unsafe fn release_entry(&mut self, index: usize, kind: EntryKind) -> bool {
6748 let live = self
6749 .entries
6750 .get(index)
6751 .and_then(|e| e.as_ref())
6752 .is_some_and(|m| m.kind == kind);
6753 if !live {
6754 return false;
6755 }
6756 // Issue 1667 — the entry's own callback is running (it, or something it
6757 // called, asked for this release). Mark it and let `finish_dispatch` do
6758 // the release when the callback returns. The handle stops resolving
6759 // now, so a second request answers `false` and nothing dispatches it.
6760 //
6761 // Issue 1705 — the same for an entry an OS-priority WORKER holds
6762 // (`WORKER_QUEUED`): its callback may be running on the worker task, or
6763 // its item may still be queued. The worker skips an item whose release
6764 // is pending, and `reap_deferred_releases` completes the release once
6765 // the worker has let go. This is also the whole of what a release
6766 // requested FROM the worker thread does: one atomic flag, nothing the
6767 // spin thread is touching.
6768 if let Some(tag) = self.slot_tags.get(index)
6769 && tag.flags() & super::types::SlotTag::IN_FLIGHT != 0
6770 {
6771 let was = tag.set(super::types::SlotTag::RELEASE_PENDING);
6772 return was & super::types::SlotTag::RELEASE_PENDING == 0;
6773 }
6774 let Some(meta) = self.entries[index].take() else {
6775 return false;
6776 };
6777 // SAFETY: the entry was written by its registration and not yet
6778 // dropped; `drop_fn` matches its concrete type.
6779 unsafe {
6780 let data_ptr = (self.arena.as_mut_ptr() as *mut u8).add(meta.offset);
6781 (meta.drop_fn)(data_ptr);
6782 }
6783 self.sched_context_bindings[index] = super::sched_context::SchedContextId(0);
6784 if let Some(st) = self.sporadic_states.get_mut(index) {
6785 *st = None;
6786 }
6787 // phase10-B3 -- a released trigger subscription stops starting routes.
6788 if matches!(kind, EntryKind::Subscription) {
6789 super::monitor::route_unbind(self.monitor_table, index);
6790 }
6791 if let Some(len) = meta.arena_len.get() {
6792 self.arena_release(meta.offset, len);
6793 }
6794 if let Some(tag) = self.slot_tags.get_mut(index) {
6795 tag.owner = 0;
6796 tag.reset_flags();
6797 }
6798 true
6799 }
6800
6801 /// Issue 1667 — mark `slot`'s callback as running, so a release it requests
6802 /// is deferred rather than dropping the entry under it.
6803 #[inline]
6804 fn begin_dispatch(slot_tags: &[super::types::SlotTag], slot: usize) {
6805 if let Some(tag) = slot_tags.get(slot) {
6806 tag.set(super::types::SlotTag::IN_DISPATCH);
6807 }
6808 }
6809
6810 /// Issue 1705 — `slot`'s entry is not to be touched by the spin thread
6811 /// this pass: an OS-priority worker holds it (`WORKER_QUEUED` — its item
6812 /// is queued or its callback is running, so even READING its state for
6813 /// readiness races the worker), or its release is pending (it is never
6814 /// dispatched again).
6815 #[inline]
6816 fn slot_held(slot_tags: &[super::types::SlotTag], slot: usize) -> bool {
6817 slot_tags.get(slot).is_some_and(|t| {
6818 t.flags()
6819 & (super::types::SlotTag::WORKER_QUEUED | super::types::SlotTag::RELEASE_PENDING)
6820 != 0
6821 })
6822 }
6823
6824 /// Issue 1667 — `slot`'s callback has returned. Complete a release it
6825 /// requested while running.
6826 fn finish_dispatch(&mut self, slot: usize) {
6827 let Some(tag) = self.slot_tags.get(slot) else {
6828 return;
6829 };
6830 let was = tag.clear(super::types::SlotTag::IN_DISPATCH);
6831 if was & super::types::SlotTag::RELEASE_PENDING != 0 {
6832 self.complete_deferred_release(slot);
6833 }
6834 }
6835
6836 /// Issue 1667 / 1705 — finish a release that was deferred while a dispatch
6837 /// of the entry was in flight, if none is any more. A no-op while one still
6838 /// is (a worker holding the entry): `reap_deferred_releases` retries.
6839 fn complete_deferred_release(&mut self, slot: usize) {
6840 let Some(tag) = self.slot_tags.get(slot) else {
6841 return;
6842 };
6843 let f = tag.flags();
6844 if f & super::types::SlotTag::RELEASE_PENDING == 0
6845 || f & super::types::SlotTag::IN_FLIGHT != 0
6846 {
6847 return;
6848 }
6849 if let Some(kind) = self
6850 .entries
6851 .get(slot)
6852 .and_then(|e| e.as_ref())
6853 .map(|m| m.kind)
6854 {
6855 // SAFETY: the release was requested through a generation-checked
6856 // handle, and `resolve_handle` has refused the slot since; no
6857 // dispatch holds the entry (`IN_FLIGHT` is clear, read Acquire, so
6858 // a worker's writes before it let go are visible).
6859 let _ = unsafe { self.release_entry(slot, kind) };
6860 }
6861 }
6862
6863 /// Issue 1705 — complete every deferred release whose last dispatch has
6864 /// finished. A release deferred behind an OS-priority WORKER cannot be
6865 /// completed by the worker (it may not touch the executor), so the spin
6866 /// thread picks it up here, at the top of every `spin_once`.
6867 pub(crate) fn reap_deferred_releases(&mut self) {
6868 for slot in 0..self.slot_tags.len() {
6869 if self.slot_tags[slot].flags() & super::types::SlotTag::RELEASE_PENDING != 0 {
6870 self.complete_deferred_release(slot);
6871 }
6872 }
6873 }
6874
6875 /// Issue 1631 — release a SUBSCRIPTION entry, whatever registration shape
6876 /// made it (typed, raw, in-place, buffered, with-info). See
6877 /// `release_entry` (crate-private): the entry is dropped, so its
6878 /// subscriber leaves the graph; its slot and arena bytes are reused by the
6879 /// next registration. `false`, touching nothing, when `handle` is not a
6880 /// live subscription.
6881 ///
6882 /// # Safety
6883 /// No copy of `handle` may be used afterwards — the slot is handed to the
6884 /// next registration, which may be of any kind.
6885 pub unsafe fn release_subscription(&mut self, handle: HandleId) -> bool {
6886 match self.resolve_handle(handle) {
6887 Some(slot) => unsafe { self.release_entry(slot, EntryKind::Subscription) },
6888 None => false,
6889 }
6890 }
6891
6892 /// Issue 1631 — release a TIMER entry; the timer's callback (and anything
6893 /// its closure captured) is dropped and never fires again. As
6894 /// [`release_subscription`](Self::release_subscription).
6895 ///
6896 /// # Safety
6897 /// As [`release_subscription`](Self::release_subscription).
6898 pub unsafe fn release_timer(&mut self, handle: HandleId) -> bool {
6899 match self.resolve_handle(handle) {
6900 Some(slot) => unsafe { self.release_entry(slot, EntryKind::Timer) },
6901 None => false,
6902 }
6903 }
6904
6905 /// Issue 1631 — release a SERVICE SERVER entry; the server leaves the
6906 /// graph. As [`release_subscription`](Self::release_subscription).
6907 ///
6908 /// # Safety
6909 /// As [`release_subscription`](Self::release_subscription).
6910 pub unsafe fn release_service(&mut self, handle: HandleId) -> bool {
6911 match self.resolve_handle(handle) {
6912 Some(slot) => unsafe { self.release_entry(slot, EntryKind::Service) },
6913 None => false,
6914 }
6915 }
6916
6917 /// Issue 1631 — release a SERVICE CLIENT entry; the client leaves the
6918 /// graph and any reply it was waiting for is abandoned with it. As
6919 /// [`release_subscription`](Self::release_subscription).
6920 ///
6921 /// # Safety
6922 /// As [`release_subscription`](Self::release_subscription).
6923 pub unsafe fn release_service_client(&mut self, handle: HandleId) -> bool {
6924 match self.resolve_handle(handle) {
6925 Some(slot) => unsafe { self.release_entry(slot, EntryKind::ServiceClient) },
6926 None => false,
6927 }
6928 }
6929
6930 /// Issue 1667 — release a GUARD CONDITION entry: its callback is dropped and
6931 /// `spin_once` stops polling it; the slot and its arena bytes go to the next
6932 /// registration.
6933 ///
6934 /// Unlike the other release verbs this one has a contract a generation
6935 /// cannot discharge. The [`GuardCondition`] trigger handle holds a pointer
6936 /// to the flag INSIDE the entry, and it is `Clone` + `Send`, so a clone on
6937 /// another thread (an ISR, a worker) would write into whatever reuses those
6938 /// bytes. The C++ `GuardCondition` is non-copyable and the one holder of its
6939 /// trigger handle, which is why its destructor can call this.
6940 ///
6941 /// # Safety
6942 /// No [`GuardCondition`] trigger handle for this entry — the one
6943 /// registration returned, or any clone of it — may be used afterwards.
6944 pub unsafe fn release_guard_condition(&mut self, handle: HandleId) -> bool {
6945 match self.resolve_handle(handle) {
6946 Some(slot) => unsafe { self.release_entry(slot, EntryKind::GuardCondition) },
6947 None => false,
6948 }
6949 }
6950
6951 /// Find the next free entry slot index.
6952 pub(crate) fn next_entry_slot(&self) -> Result<usize, NodeError> {
6953 self.entries
6954 .iter()
6955 .position(|e| e.is_none())
6956 // Issue 0095 — the callback-entry table (`NROS_EXECUTOR_MAX_CBS`,
6957 // default 4) is full. Distinct from `BufferTooSmall` so the register
6958 // seam can tell the user to raise the knob.
6959 .ok_or(NodeError::ExecutorFull)
6960 }
6961
6962 /// Install a finished [`CallbackMeta`] into its slot.
6963 ///
6964 /// phase-8 (`docs/design/callback_tracing.rst`) — the ONE choke point
6965 /// every registration site funnels through, so the
6966 /// `nros_callback_register(handle, kind, name)` event is emitted once,
6967 /// here, rather than at the 25 sites that build a `CallbackMeta`. The
6968 /// design's registration half is deliberately EXHAUSTIVE across every
6969 /// `EntryKind` even though the leaf hooks are staged: a callback that was
6970 /// registered but never observed then prints as "registered, not
6971 /// instrumented" instead of being silently absent, so an incomplete hook
6972 /// set announces itself in the output rather than looking like a
6973 /// measurement.
6974 ///
6975 /// It is the assignment — not [`next_entry_slot`](Self::next_entry_slot)
6976 /// — because a slot is claimed BEFORE the fallible work (session lookup,
6977 /// arena allocation, handle creation) that can still return `Err`.
6978 /// Emitting at slot-claim time would announce callbacks that do not
6979 /// exist.
6980 pub(crate) fn emplace_entry(
6981 &mut self,
6982 slot: usize,
6983 mut meta: CallbackMeta,
6984 name: TraceName<'_>,
6985 ) {
6986 // Issue 1631 — record the region the entry's allocation handed out,
6987 // so `release_entry` gives back exactly that. Matched on the OFFSET so
6988 // a stale region (a registration that failed after allocating) is
6989 // never attributed to a different entry.
6990 meta.arena_len = match self.pending_region.take() {
6991 Some(r) if r.offset == meta.offset => super::arena::ArenaLen::new(r.len),
6992 _ => super::arena::ArenaLen::UNRECORDED,
6993 };
6994 trace_register(slot, meta.kind, name);
6995 // phase10-B3 -- a subscription on a route's trigger topic is the
6996 // route's trigger. The table is installed before any node exists.
6997 if let (EntryKind::Subscription, TraceName::Text(topic)) = (meta.kind, &name) {
6998 super::monitor::route_bind(self.monitor_table, topic, slot);
6999 }
7000 // phase-463 W7 — the slot's identity for the host profile, keyed on the
7001 // arena base the dispatch sees (stable for the executor's life).
7002 #[cfg(feature = "profile-mode")]
7003 super::profile::on_register(self.arena.as_ptr() as usize, slot, meta.kind, name);
7004 self.entries[slot] = Some(meta);
7005 // phase-476 W0 — a new occupant gets a new generation, so every handle
7006 // issued for the slot's previous occupant stops resolving. Owner is
7007 // cleared; a registration made for a node sets it after this
7008 // (`set_entry_owner`).
7009 if let Some(tag) = self.slot_tags.get_mut(slot) {
7010 tag.generation = tag.next_generation();
7011 tag.owner = 0;
7012 tag.reset_flags();
7013 }
7014 }
7015
7016 /// phase-476 W0 — the handle for the CURRENT occupant of `slot`. Every
7017 /// registration returns this, after `emplace_entry` has stamped the slot.
7018 pub(crate) fn handle_for(&self, slot: usize) -> HandleId {
7019 let generation = self.slot_tags.get(slot).map_or(0, |t| t.generation);
7020 HandleId::new(slot, generation)
7021 }
7022
7023 /// phase-476 W0 (issue 1667) — the slot `id` names, if and only if `id` was
7024 /// issued for the entry that occupies it NOW. A handle whose entry was
7025 /// released, or whose slot has since been taken by another registration of
7026 /// any kind, answers `None`, never the next occupant.
7027 pub fn resolve_handle(&self, id: HandleId) -> Option<usize> {
7028 let slot = id.slot();
7029 let live = self.entries.get(slot).is_some_and(|e| e.is_some())
7030 && self
7031 .slot_tags
7032 .get(slot)
7033 .is_none_or(|t| t.flags() & super::types::SlotTag::RELEASE_PENDING == 0);
7034 // Generation 0 is `HandleId::for_owned_slot`: a uniquely-owned holder
7035 // naming its own live slot. Every ISSUED handle carries >= 1.
7036 let current = id.generation() == 0
7037 || self
7038 .slot_tags
7039 .get(slot)
7040 .is_some_and(|t| t.generation == id.generation());
7041 (live && current).then_some(slot)
7042 }
7043
7044 /// Issue 1703 — the slot behind a by-index accessor's `entry_index`
7045 /// argument, which callers hand in in TWO spellings: a bare slot (nros-c's
7046 /// stored `arena_entry_index`, `node_runtime`'s resolved slot, an action's
7047 /// owned slot) or the PACKED handle an FFI issued (`HandleId::to_raw`,
7048 /// what nros-cpp's `handle_id_` and nros-c's `subscription.handle_id`
7049 /// hold since phase-476 W0). A bare slot has generation 0, so both decode
7050 /// through [`HandleId::from_raw`] and resolve here: the slot for a live
7051 /// entry, generation-checked when one was issued, `None` for a stale or
7052 /// foreign handle.
7053 ///
7054 /// The five accessors indexed `entries` with the argument as given. W0 made
7055 /// the FFI value a packed `(generation << 16) | slot`, so every packed
7056 /// caller read past the table and got `None` — `async_send_request` on a
7057 /// C++ callback client returned `INVALID_ARGUMENT` on every call, and the
7058 /// granted-QoS read-backs answered for no entity.
7059 fn slot_of_raw(&self, entry_index: usize) -> Option<usize> {
7060 self.resolve_handle(HandleId::from_raw(entry_index))
7061 }
7062
7063 /// phase-476 W0 — [`resolve_handle`](Self::resolve_handle) as an index that
7064 /// is OUT OF RANGE for a stale handle, so an existing `entries.get(..)`
7065 /// chain answers `None` for it with no further change.
7066 pub(crate) fn live_slot(&self, id: HandleId) -> usize {
7067 self.resolve_handle(id).unwrap_or(usize::MAX)
7068 }
7069
7070 /// phase-476 W0 — record that `node` registered the entry `id` names, so
7071 /// [`release_node`](Self::release_node) can find it. `false` when `id` is
7072 /// stale.
7073 pub fn set_entry_owner(&mut self, id: HandleId, node: crate::executor::NodeId) -> bool {
7074 let Some(slot) = self.resolve_handle(id) else {
7075 return false;
7076 };
7077 let Ok(biased) = u8::try_from(node.index() + 1) else {
7078 return false;
7079 };
7080 self.slot_tags[slot].owner = biased;
7081 true
7082 }
7083
7084 /// phase-476 W0 — release every entry `node` registered, whatever its kind.
7085 /// Returns how many were released.
7086 ///
7087 /// This is what a node's destruction calls. A C++ callback's capture is
7088 /// copied into the arena and commonly holds the node's `this`, so an entry
7089 /// that outlived its node would dispatch into freed memory.
7090 ///
7091 /// # Safety
7092 /// No handle to any of those entries may be used afterwards except through
7093 /// a lookup that checks the generation ([`resolve_handle`](Self::resolve_handle)),
7094 /// which every public lookup on this type does.
7095 pub unsafe fn release_node(&mut self, node: crate::executor::NodeId) -> usize {
7096 let Ok(biased) = u8::try_from(node.index() + 1) else {
7097 return 0;
7098 };
7099 let mut released = 0;
7100 for slot in 0..self.entries.len() {
7101 if self.slot_tags.get(slot).is_some_and(|t| t.owner == biased) {
7102 let kind = match self.entries[slot].as_ref() {
7103 Some(m) => m.kind,
7104 None => continue,
7105 };
7106 // SAFETY: forwarded from this function's contract.
7107 if unsafe { self.release_entry(slot, kind) } {
7108 released += 1;
7109 }
7110 }
7111 }
7112 released
7113 }
7114
7115 /// Open a subscription: claim a slot, resolve the node's identity, create
7116 /// the backend subscriber, and answer the in-place question ONCE.
7117 ///
7118 /// **phase-456 W8 — the one prologue.** Every registration that creates its
7119 /// own subscriber goes through here, in every language. The two callers
7120 /// that do not are the two that cannot: `add_arena_subscription_callback`
7121 /// is handed a subscriber someone else built (uORB needs a
7122 /// `&'static orb_metadata`, which no `TopicInfo` can express), so there is
7123 /// no topic to resolve and no session to reach — a fourth axis under issue
7124 /// 1319's rows that W8's three-axis decomposition did not predict: WHO
7125 /// CREATED THE SUBSCRIBER. It is a real axis, it is orthogonal to the other
7126 /// three, and it has exactly one member.
7127 ///
7128 /// Everything downstream of this function differs per delivery shape: the
7129 /// arena entry's type, its `try_process`, and whether it has a receive
7130 /// region at all. That part is irreducible and stays at the call site.
7131 pub(crate) fn open_subscription(
7132 &mut self,
7133 req: &SubscriptionRequest<'_>,
7134 ) -> Result<SubscriptionOpen, NodeError> {
7135 let slot = self.next_entry_slot()?;
7136 let (node_name, ns, session_idx) = match req.node_id {
7137 Some(id) => {
7138 let r = self
7139 .nodes
7140 .get(id.index())
7141 .ok_or(NodeError::InvalidSchedContextBinding)?;
7142 (r.name.clone(), r.namespace.clone(), r.session_idx)
7143 }
7144 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
7145 };
7146 let mut topic = TopicInfo::new(req.topic_name, req.type_name, req.type_hash)
7147 .with_domain(self.domain_id)
7148 .with_namespace(&ns);
7149 if !node_name.is_empty() {
7150 topic = topic.with_node_name(&node_name);
7151 }
7152 // Phase 231 (RFC-0038) / phase-402 W2 — hand the backend a
7153 // receive-buffer size so it can size-class its receive storage
7154 // (zenoh-pico: small vs large). Only when the caller actually stated
7155 // one: `with_rx_buffer_hint(0)` would be a claim of "zero bytes", not
7156 // "no opinion".
7157 if let Some(hint) = req.backend_hint {
7158 if hint != 0 {
7159 topic = topic.with_rx_buffer_hint(hint);
7160 }
7161 }
7162 // phase-454 W10/W13 — the depth this registration asks for against the
7163 // depth this system DECLARED for the topic. Before the backend call AND
7164 // before the arena claim, so the region this registration takes and the
7165 // one the build reserved are the same number; unchanged when nothing
7166 // was declared, which is every image with no contract sidecar. See
7167 // `declared_qos::honour` for why Rust TAKES the declared depth where C
7168 // and C++ assert it at the call site.
7169 //
7170 // phase-456 W8 — this used to be `declared_qos::check` here and
7171 // `honour` in each of the twelve hand-rolled prologues this function
7172 // replaced. Collapsing them onto `check` would have silently reverted
7173 // W13 for every Rust registration: `check` validates and returns, while
7174 // `honour` REWRITES the depth the backend is then given. One consulting
7175 // site is the point of this function, and it has to be the honouring
7176 // one.
7177 let qos = crate::declared_qos::honour(topic.type_name, topic.name, req.qos)?;
7178 let handle = {
7179 let session = self
7180 .session_at_mut(session_idx)
7181 .ok_or(NodeError::BackendMismatch)?;
7182 session
7183 .create_subscription(&topic, qos)
7184 .map_err(NodeError::Transport)?
7185 };
7186 // Phase 231 Wave 0.2 (RFC-0038), phase-456 W8 — THE consulting site.
7187 // Deliberately `&&`-guarded by the delivery shape rather than by the
7188 // language: a shape that must hand the callback more than borrowed
7189 // bytes cannot use the capability however capable the backend is.
7190 let in_place = req.in_place_capable && {
7191 use nros_rmw::Subscription as _;
7192 handle.supports_process_in_place()
7193 };
7194 // phase-457 W3 — tell a metadata probe what this CALL SITE answered.
7195 //
7196 // `req.in_place_capable` and NOT `in_place`: the conjunction above
7197 // includes the backend a probe links, which is the RECORDING backend and
7198 // not the one the image ships. The sizing descriptor knows the shipped
7199 // backend from the image's `rmw` name and composes the two halves
7200 // itself; what it cannot see is this half, which is why nine of the
7201 // eleven registration entry points were previously credited with a
7202 // capability they do not have (issue 1340).
7203 //
7204 // AFTER the backend call, so a registration that failed reports
7205 // nothing — a row for a subscription that does not exist would raise the
7206 // probe's count, and an over-count of subscriptions is a real cost in
7207 // every pool that derives from it.
7208 #[cfg(feature = "registration-observer")]
7209 super::registration_observer::observe(super::registration_observer::ObservedRegistration {
7210 topic: topic.name,
7211 type_name: topic.type_name,
7212 in_place_capable: req.in_place_capable,
7213 // Issue 1648 -- the slot this registration is about to claim, read
7214 // off the request rather than inferred: `DEFAULT_RX_BUF_SIZE`
7215 // (`RX_BUF`) is the closure buffer (`unbounded`), anything else a
7216 // stated bound.
7217 claims_closure_buffer: req.slot_bytes == crate::config::DEFAULT_RX_BUF_SIZE,
7218 });
7219 Ok(SubscriptionOpen {
7220 slot,
7221 handle,
7222 qos,
7223 in_place,
7224 slot_bytes: req.slot_bytes,
7225 })
7226 }
7227
7228 /// Typed buffered subscription core (the `node_mut(id).subscription(t)
7229 /// .typed::<M>()` builder lowers here). Routes the typed subscription
7230 /// through the [`NodeId`]'s session + identity (rclcpp `add_node` pattern).
7231 ///
7232 /// Phase 403 W2 -- `rx_bytes` says how many bytes each buffer slot claims
7233 /// from the arena.
7234 ///
7235 /// phase-392 W3c -- `Some(n)` is verbatim; **`None` now means DERIVE from
7236 /// `M`**, falling back to `RX_BUF` only where no bound is reachable (an
7237 /// unbounded type, or a backend whose `MessageForRmw` carries no schema).
7238 /// It used to mean `RX_BUF` unconditionally, so a subscription that named no
7239 /// number was charged the global for every slot and a consumer who did not
7240 /// know `.rx_buffer_from_type()` existed paid it silently. The opt-out is
7241 /// now the explicit one: `.rx_buffer::<N>()` passes `Some(N)`.
7242 ///
7243 /// It is a RUNTIME `usize` and not a second const generic because on THIS
7244 /// path `RX_BUF` was never an array length -- it reaches
7245 /// `buffered_region_size`, `TripleBuffer::init` and `SpscRing::init`, all of
7246 /// which take a plain `usize`. That is the only reason W2's per-type sizing
7247 /// is expressible here at all: `Sub<{ M::BOUND }>` is
7248 /// `error: generic parameters may not be used in const operations` on stable
7249 /// (rustc 1.97.1), so the sibling `register_subscription_with_info_sized_inner`
7250 /// / `..._with_safety_sized_inner`, whose entries really do hold
7251 /// `[u8; RX_BUF]`, still need `nros::rx_buffer_for!` at the call site.
7252 pub(crate) fn register_subscription_buffered_on<M, F, const RX_BUF: usize>(
7253 &mut self,
7254 node_id: super::node_record::NodeId,
7255 topic_name: &str,
7256 qos: QoSProfile,
7257 callback: F,
7258 group: Option<&str>,
7259 rx_bytes: Option<usize>,
7260 ) -> Result<HandleId, NodeError>
7261 where
7262 M: crate::rmw_type_registry::MessageForRmw + 'static,
7263 F: FnMut(&M) + 'static,
7264 {
7265 type Entry<M, F> = SubBufferedEntry<M, F>;
7266
7267 // RFC-0088 / phase-421 W1 — the message's declared format must be the
7268 // one the linked backend speaks. Universal: the const lives on
7269 // `RosMessage`, which `MessageForRmw` requires under every backend.
7270 crate::format_check::assert_message_format::<M>();
7271 // Phase 212.K.7.6.b — see `create_publisher_on`.
7272 crate::rmw_type_registry::register_type::<M>()?;
7273
7274 // W3b.5 — contracted-endpoint age hook (None = free).
7275 let age_mon = self.age_lookup::<M>(topic_name);
7276
7277 // Phase 403 W2 -- one number for the whole allocation. The region size,
7278 // the strategy's slot size and the pointer arithmetic must agree, so
7279 // they read the same local rather than each spelling `RX_BUF`.
7280 //
7281 // phase-392 W3c -- and `None` no longer means `RX_BUF`, it means DERIVE.
7282 // This is the one choke point every default registration funnels
7283 // through, so putting the derivation here is what makes it the default
7284 // at all four of them (`create_subscription`, `create_subscription_in_group`,
7285 // the builder's `build()`, and the `/clock` time source) instead of at
7286 // whichever one a wave happened to touch. An explicit `Some(n)` -- what
7287 // `.rx_buffer::<N>()` and `.rx_buffer_from_type()` pass -- is still
7288 // verbatim, so the opt-out and the opt-in both bypass this line.
7289 let slot_bytes = crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF);
7290
7291 let open = self.open_subscription(&SubscriptionRequest {
7292 node_id: Some(node_id),
7293 topic_name,
7294 type_name: <M as RosMessage>::TYPE_NAME,
7295 type_hash: <M as RosMessage>::TYPE_HASH,
7296 qos,
7297 // Phase 392 W3a — the TYPE's bound, not `RX_BUF`. The arena slot
7298 // size says nothing about the message: a 64-byte type and a 4 KiB
7299 // type both hinted the same number, so the class was chosen from a
7300 // value unrelated to what arrives. Falls back to `RX_BUF` for an
7301 // unbounded type, where no bound exists to state (phase 380).
7302 backend_hint: Some(crate::rmw_type_registry::subscription_rx_hint::<M>(RX_BUF)),
7303 slot_bytes,
7304 // A typed Rust callback takes `&M` and nothing else, so borrowed
7305 // bytes are enough: deserialize straight from the backend's slot.
7306 in_place_capable: true,
7307 })?;
7308 let SubscriptionOpen {
7309 slot,
7310 handle,
7311 qos,
7312 in_place,
7313 slot_bytes,
7314 } = open;
7315
7316 // Phase 231 Wave 0.2 (RFC-0038) — in-place dispatch when the backend
7317 // advertises it: deserialize straight from the borrowed receive slot,
7318 // no arena buffer (copy #1 removed). Else the buffered path below.
7319 if in_place {
7320 let entry_offset = self.arena_alloc::<SubInplaceEntry<M, F>>()?;
7321 unsafe {
7322 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7323 let entry_ptr = arena_ptr.add(entry_offset) as *mut SubInplaceEntry<M, F>;
7324 core::ptr::write(
7325 entry_ptr,
7326 SubInplaceEntry {
7327 handle,
7328 callback,
7329 age_mon,
7330 _phantom: PhantomData,
7331 },
7332 );
7333 }
7334 let meta = CallbackMeta {
7335 offset: entry_offset,
7336 kind: EntryKind::Subscription,
7337 arena_len: super::arena::ArenaLen::UNRECORDED,
7338 try_process: sub_inplace_try_process::<M, F>,
7339 has_data: sub_inplace_has_data::<M, F>,
7340 pre_sample: no_pre_sample,
7341 invocation: InvocationMode::OnNewData,
7342 drop_fn: drop_entry::<SubInplaceEntry<M, F>>,
7343 };
7344 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7345 self.apply_node_default_sched(slot, Some(node_id), group);
7346 return Ok(self.handle_for(slot));
7347 }
7348
7349 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
7350
7351 let (entry_offset, trailing_offset) =
7352 self.arena_alloc_with_trailing::<Entry<M, F>>(trailing_bytes)?;
7353
7354 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
7355
7356 let buffer = if qos.depth <= 1 {
7357 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
7358 } else {
7359 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
7360 };
7361
7362 unsafe {
7363 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7364 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<M, F>;
7365 core::ptr::write(
7366 entry_ptr,
7367 Entry {
7368 handle,
7369 buffer,
7370 callback,
7371 age_mon,
7372 _phantom: PhantomData,
7373 },
7374 );
7375 }
7376
7377 let meta = CallbackMeta {
7378 offset: entry_offset,
7379 kind: EntryKind::Subscription,
7380 arena_len: super::arena::ArenaLen::UNRECORDED,
7381 try_process: sub_buffered_try_process::<M, F>,
7382 has_data: sub_buffered_has_data::<M, F>,
7383 pre_sample: no_pre_sample,
7384 invocation: InvocationMode::OnNewData,
7385 drop_fn: drop_entry::<Entry<M, F>>,
7386 };
7387 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7388 // Phase 104.C.4 — apply Node's default SchedContext.
7389 self.apply_node_default_sched(slot, Some(node_id), group);
7390 Ok(self.handle_for(slot))
7391 }
7392
7393 /// Generic (type-erased) buffered subscription core (the
7394 /// `node_mut(id).subscription(t).generic(ty, hash)` builder lowers here).
7395 /// Routes the subscriber creation through the [`NodeId`]'s
7396 /// session + identity (rclcpp `add_node` pattern).
7397 ///
7398 /// Use this in bridge code where two Nodes bind to different RMW
7399 /// backends:
7400 ///
7401 /// ```ignore
7402 /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
7403 /// let pub_out = exec.with_node(node_out, |n| {
7404 /// n.create_publisher_raw("/fwd", TYPE, HASH)
7405 /// })??;
7406 /// exec.register_subscription_buffered_raw_on::<_, 1024>(
7407 /// node_in, "/src", TYPE, HASH, qos(),
7408 /// move |bytes: &[u8]| { let _ = pub_out.publish_raw(bytes); },
7409 /// )?;
7410 /// ```
7411 pub(crate) fn register_subscription_buffered_raw_on<F, const RX_BUF: usize>(
7412 &mut self,
7413 node_id: super::node_record::NodeId,
7414 topic_name: &str,
7415 type_name: &str,
7416 type_hash: &str,
7417 qos: QoSProfile,
7418 callback: F,
7419 ) -> Result<HandleId, NodeError>
7420 where
7421 F: FnMut(&[u8]) + 'static,
7422 {
7423 // Issue #52 — fold the node's baked overrides for this topic before the
7424 // backend-compat check runs inside `create_subscription`.
7425 //
7426 // **This is the one registration that does it**, which phase-456 W8
7427 // noticed while collapsing the twelve prologues into one and did NOT
7428 // change: the other eleven reach `create_subscription` with the QoS
7429 // their caller passed. Whether that is a defect or a deliberate limit
7430 // of the generic bridge path is a question with a measurable answer and
7431 // no answer here, so the asymmetry stays where it was, stated.
7432 let overrides = self
7433 .nodes
7434 .get(node_id.index())
7435 .ok_or(NodeError::InvalidSchedContextBinding)?
7436 .qos_overrides;
7437 let qos = super::node_record::apply_qos_override_codes(
7438 qos,
7439 topic_name,
7440 nros_rmw::QoSOverrideRole::Subscription,
7441 overrides,
7442 );
7443
7444 let SubscriptionOpen {
7445 slot,
7446 handle,
7447 qos,
7448 in_place,
7449 slot_bytes,
7450 } = self.open_subscription(&SubscriptionRequest {
7451 node_id: Some(node_id),
7452 topic_name,
7453 type_name,
7454 type_hash,
7455 qos,
7456 backend_hint: None,
7457 slot_bytes: RX_BUF,
7458 // Issue 1340 — `FnMut(&[u8])` IS a borrowed-bytes callback, which is
7459 // exactly what `process_raw_in_place` hands over, so this shape takes
7460 // the in-place row the way the C raw path does.
7461 //
7462 // This used to be `false` because the buffered entry is shared with
7463 // `Self::add_arena_subscription_callback` and "an in-place twin would
7464 // have to serve both". It does not: that caller supplies its own
7465 // subscriber and never reaches `open_subscription`, so no in-place
7466 // report can arrive there. The branch below is local to this entry
7467 // point, and the shared buffered emplace is untouched.
7468 //
7469 // phase-457 W5 (issue 1522) — spelled as the SHAPE rather than as a
7470 // literal, because the declarative registrar lowers here and the
7471 // metadata probe must state the same bool without registering. One
7472 // definition (`DeclaredSubscriptionShape::in_place_capable`), two
7473 // readers, so the row the build prices and the claim made here
7474 // moved together when it flipped.
7475 in_place_capable: super::declared_shape::DeclaredSubscriptionShape::BufferedRaw
7476 .in_place_capable(),
7477 })?;
7478
7479 if in_place {
7480 // No receive region: the backend's slot is the buffer for the
7481 // duration of the callback. This is the whole of issue 1340's saving
7482 // on the generic path — `buffered_region_size(depth, RX_BUF)` is
7483 // never reserved.
7484 type Entry<F> = super::arena::SubInplaceRawEntry<F>;
7485 let entry_offset = self.arena_alloc::<Entry<F>>()?;
7486 unsafe {
7487 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7488 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<F>;
7489 core::ptr::write(entry_ptr, Entry { handle, callback });
7490 }
7491 let meta = CallbackMeta {
7492 offset: entry_offset,
7493 kind: EntryKind::Subscription,
7494 arena_len: super::arena::ArenaLen::UNRECORDED,
7495 try_process: super::arena::sub_inplace_raw_try_process::<F>,
7496 has_data: super::arena::sub_inplace_raw_has_data::<F>,
7497 pre_sample: no_pre_sample,
7498 invocation: InvocationMode::OnNewData,
7499 drop_fn: drop_entry::<Entry<F>>,
7500 };
7501 // Same trace name as the buffered arm, so a trace reads the same
7502 // whichever dispatch the backend chose.
7503 self.emplace_entry(slot, meta, TraceName::Slot("sub", slot));
7504 } else {
7505 self.emplace_raw_buffered_subscription(slot, handle, qos, slot_bytes, callback)?;
7506 }
7507 // Phase 104.C.4 — apply Node's default SchedContext.
7508 self.apply_node_default_sched(slot, Some(node_id), None);
7509 Ok(self.handle_for(slot))
7510 }
7511
7512 /// Register a borrowed (zero-copy) buffered subscription (Phase 229.6,
7513 /// issue 0007 / RFC-0033 `borrowed` mode).
7514 ///
7515 /// `B` is the code-generated borrowed-message marker (e.g. `ImageViewable`)
7516 /// implementing [`ViewableMessage`](nros_core::ViewableMessage); the
7517 /// callback receives `&B::View<'a>` — a lifetime-carrying message whose
7518 /// unbounded sequence/string fields borrow directly from the receive buffer
7519 /// (no `heapless::Vec` copy). The view is valid only for the callback's
7520 /// duration.
7521 ///
7522 /// **Triple-buffer only.** A borrowed view must reference exactly one
7523 /// well-defined buffer slot for the callback's duration; an SPSC ring
7524 /// (`qos.depth > 1`) keeps several samples in flight with no single such
7525 /// slot. `qos.depth > 1` is therefore rejected with
7526 /// [`TransportError::Unsupported`].
7527 pub(crate) fn register_subscription_buffered_borrowed_on<B, F, const RX_BUF: usize>(
7528 &mut self,
7529 node_id: super::node_record::NodeId,
7530 topic_name: &str,
7531 qos: QoSProfile,
7532 callback: F,
7533 ) -> Result<HandleId, NodeError>
7534 where
7535 B: nros_core::ViewableMessage + 'static,
7536 F: for<'a> FnMut(&B::View<'a>) + 'static,
7537 {
7538 type Entry<B, F> = SubBufferedViewEntry<B, F>;
7539
7540 // Borrowed views require a single well-defined slot (triple buffer).
7541 if qos.depth > 1 {
7542 return Err(NodeError::Transport(TransportError::Unsupported));
7543 }
7544
7545 let SubscriptionOpen { slot, handle, .. } =
7546 self.open_subscription(&SubscriptionRequest {
7547 node_id: Some(node_id),
7548 topic_name,
7549 type_name: <B as ViewableMessage>::TYPE_NAME,
7550 type_hash: <B as ViewableMessage>::TYPE_HASH,
7551 qos,
7552 backend_hint: None,
7553 slot_bytes: RX_BUF,
7554 // The view borrows the arena's own triple-buffer slot for the
7555 // callback's duration and `ViewableMessage::deserialize_view` is
7556 // what produces it, so there is no `FnMut(&[u8])` here to hand a
7557 // backend slot to. Making this shape in-place is a real option and
7558 // a separate change: it would move the borrow from the arena to the
7559 // backend's ring, which is a lifetime argument this item did not
7560 // make.
7561 in_place_capable: false,
7562 })?;
7563
7564 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, RX_BUF);
7565 let (entry_offset, trailing_offset) =
7566 self.arena_alloc_with_trailing::<Entry<B, F>>(trailing_bytes)?;
7567 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
7568
7569 // depth <= 1 guaranteed above → always triple buffer.
7570 let buffer = BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, RX_BUF) });
7571
7572 unsafe {
7573 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7574 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<B, F>;
7575 core::ptr::write(
7576 entry_ptr,
7577 Entry {
7578 handle,
7579 buffer,
7580 callback,
7581 _phantom: PhantomData,
7582 },
7583 );
7584 }
7585
7586 let meta = CallbackMeta {
7587 offset: entry_offset,
7588 kind: EntryKind::Subscription,
7589 arena_len: super::arena::ArenaLen::UNRECORDED,
7590 try_process: sub_buffered_view_try_process::<B, F>,
7591 has_data: sub_buffered_view_has_data::<B, F>,
7592 pre_sample: no_pre_sample,
7593 invocation: InvocationMode::OnNewData,
7594 drop_fn: drop_entry::<Entry<B, F>>,
7595 };
7596 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7597 self.apply_node_default_sched(slot, Some(node_id), None);
7598 Ok(self.handle_for(slot))
7599 }
7600
7601 /// Register a raw (type-erased) buffered subscription whose callback
7602 /// also receives a [`RawMessageInfo`](nros_core::RawMessageInfo)
7603 /// carrying the sample's wire **attachment** (Phase 189.M1).
7604 ///
7605 /// Backs the `node.subscription(t).generic(..).message_info().build(cb)`
7606 /// builder — the cross-RMW bridge reads the `bridge_origin` tag from
7607 /// `info.attachment()` for echo suppression. One sample per
7608 /// `spin_once`; the attachment is staged in a flat per-entry buffer
7609 /// (cap `RAW_INFO_ATT_CAP`).
7610 pub fn register_subscription_buffered_raw_info_on<F, const RX_BUF: usize>(
7611 &mut self,
7612 node_id: super::node_record::NodeId,
7613 topic_name: &str,
7614 type_name: &str,
7615 type_hash: &str,
7616 qos: QoSProfile,
7617 callback: F,
7618 ) -> Result<HandleId, NodeError>
7619 where
7620 F: FnMut(&[u8], &nros_core::RawMessageInfo) + 'static,
7621 {
7622 type Entry<F> = SubBufferedRawInfoEntry<F>;
7623
7624 let SubscriptionOpen {
7625 slot,
7626 handle,
7627 slot_bytes,
7628 ..
7629 } = self.open_subscription(&SubscriptionRequest {
7630 node_id: Some(node_id),
7631 topic_name,
7632 type_name,
7633 type_hash,
7634 qos,
7635 backend_hint: None,
7636 slot_bytes: RX_BUF,
7637 // The callback also receives the sample's wire ATTACHMENT, which
7638 // `process_raw_in_place` does not carry: it hands over borrowed
7639 // payload bytes and nothing else. Not a limitation of the backend.
7640 in_place_capable: false,
7641 })?;
7642
7643 // Issue 1860 -- in the trailing region, as the C twin. A raw subscription
7644 // has no type to derive from, so the size is still `RX_BUF`.
7645 let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<F>>(slot_bytes)?;
7646 unsafe {
7647 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7648 let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
7649 let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
7650 core::ptr::write(
7651 entry_ptr,
7652 Entry {
7653 handle,
7654 buffer,
7655 att: [0u8; super::arena::RAW_INFO_ATT_CAP],
7656 callback,
7657 },
7658 );
7659 }
7660
7661 let meta = CallbackMeta {
7662 offset,
7663 kind: EntryKind::Subscription,
7664 arena_len: super::arena::ArenaLen::UNRECORDED,
7665 try_process: sub_buffered_raw_info_try_process::<F>,
7666 has_data: sub_buffered_raw_info_has_data::<F>,
7667 pre_sample: no_pre_sample,
7668 invocation: InvocationMode::OnNewData,
7669 drop_fn: drop_entry::<Entry<F>>,
7670 };
7671 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7672 self.apply_node_default_sched(slot, Some(node_id), None);
7673 Ok(self.handle_for(slot))
7674 }
7675
7676 /// Phase 250 (Wave 2) — register a generic (type-erased) raw subscription
7677 /// that surfaces E2E [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC +
7678 /// sequence gap/dup) alongside the raw CDR bytes
7679 /// (`FnMut(&[u8], &IntegrityStatus)`). The type-erased analog of
7680 /// `register_subscription_with_safety_sized_inner`: the validator lives in
7681 /// the `RmwSubscriber` (`take_validated`), so the subscriber is created
7682 /// plainly and no `register_type::<M>()` is needed (the declarative `Node`
7683 /// path is generic). Used by the declarative runtime's `.safety()` opt-in.
7684 #[cfg(feature = "safety-e2e")]
7685 pub fn register_subscription_buffered_raw_safety_on<F, const RX_BUF: usize>(
7686 &mut self,
7687 node_id: super::node_record::NodeId,
7688 topic_name: &str,
7689 type_name: &str,
7690 type_hash: &str,
7691 qos: QoSProfile,
7692 callback: F,
7693 ) -> Result<HandleId, NodeError>
7694 where
7695 F: FnMut(&[u8], &nros_rmw::IntegrityStatus) + 'static,
7696 {
7697 use super::arena::{
7698 SubBufferedRawSafetyEntry, sub_buffered_raw_safety_has_data,
7699 sub_buffered_raw_safety_try_process,
7700 };
7701 type Entry<F> = SubBufferedRawSafetyEntry<F>;
7702
7703 let SubscriptionOpen {
7704 slot,
7705 handle,
7706 slot_bytes,
7707 ..
7708 } = self.open_subscription(&SubscriptionRequest {
7709 node_id: Some(node_id),
7710 topic_name,
7711 type_name,
7712 type_hash,
7713 qos,
7714 backend_hint: None,
7715 slot_bytes: RX_BUF,
7716 // The sample is taken through `take_validated`, which computes the
7717 // integrity status as it copies. `process_raw_in_place` has no
7718 // validating form, so this shape cannot use it.
7719 //
7720 // phase-457 W5 (issue 1522) — as the SHAPE, for the reason its
7721 // sibling in `register_subscription_buffered_raw_on` gives.
7722 in_place_capable: super::declared_shape::DeclaredSubscriptionShape::BufferedRawSafety
7723 .in_place_capable(),
7724 })?;
7725
7726 // Issue 1860 -- in the trailing region; untyped, so still `RX_BUF` bytes.
7727 let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<F>>(slot_bytes)?;
7728 unsafe {
7729 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7730 let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
7731 let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
7732 core::ptr::write(
7733 entry_ptr,
7734 Entry {
7735 handle,
7736 buffer,
7737 callback,
7738 },
7739 );
7740 }
7741
7742 let meta = CallbackMeta {
7743 offset,
7744 kind: EntryKind::Subscription,
7745 arena_len: super::arena::ArenaLen::UNRECORDED,
7746 try_process: sub_buffered_raw_safety_try_process::<F>,
7747 has_data: sub_buffered_raw_safety_has_data::<F>,
7748 pre_sample: no_pre_sample,
7749 invocation: InvocationMode::OnNewData,
7750 drop_fn: drop_entry::<Entry<F>>,
7751 };
7752 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7753 self.apply_node_default_sched(slot, Some(node_id), None);
7754 Ok(self.handle_for(slot))
7755 }
7756
7757 /// Register a raw byte-shaped callback against a pre-built
7758 /// `RmwSubscriber` handle.
7759 ///
7760 /// Backend-agnostic primitive — the caller is responsible for
7761 /// obtaining the handle by whatever route the active backend
7762 /// supports:
7763 ///
7764 /// - **Generic ROS-typed flow**: call `Session::create_subscription`
7765 /// on `self.session_mut()` with a [`TopicInfo`]. The
7766 /// `node_mut(id).subscription(t).generic(ty, hash)` builder is the
7767 /// convenience wrapper for this path.
7768 /// - **Backend-specific flow** (e.g. uORB needs `&'static orb_metadata`):
7769 /// reach into the concrete session via [`Self::session_mut`] and
7770 /// call its backend-specific create method, then hand the handle
7771 /// here. `nros-px4::uorb::create_subscription_with_callback` is
7772 /// the example.
7773 ///
7774 /// The arena-store + vtable wiring is identical to
7775 /// `register_subscription_buffered_raw`; the only thing that varies is
7776 /// where the handle came from. Callback fires on every message
7777 /// delivery during [`spin_once`](Self::spin_once); bytes are
7778 /// passed as `&[u8]`.
7779 pub fn add_arena_subscription_callback<F, const RX_BUF: usize>(
7780 &mut self,
7781 handle: session::RmwSubscriber,
7782 qos: QoSProfile,
7783 callback: F,
7784 ) -> Result<HandleId, NodeError>
7785 where
7786 F: FnMut(&[u8]) + 'static,
7787 {
7788 let slot = self.next_entry_slot()?;
7789 self.emplace_raw_buffered_subscription(slot, handle, qos, RX_BUF, callback)?;
7790 Ok(self.handle_for(slot))
7791 }
7792
7793 /// Write a raw buffered subscription entry into an already-claimed slot —
7794 /// phase-456 W8.
7795 ///
7796 /// The shared tail of [`Self::add_arena_subscription_callback`] and
7797 /// `register_subscription_buffered_raw_on`, which differ only in where the
7798 /// subscriber came from: one is handed a live `RmwSubscriber`, the other
7799 /// resolves a topic through [`Self::open_subscription`] and gets one back.
7800 /// Both then build exactly this entry, and they used to build it twice —
7801 /// the bridge path by CALLING the other, which meant it claimed a slot,
7802 /// resolved a topic, and then let the callee claim a second slot it did not
7803 /// need.
7804 fn emplace_raw_buffered_subscription<F>(
7805 &mut self,
7806 slot: usize,
7807 handle: session::RmwSubscriber,
7808 qos: QoSProfile,
7809 slot_bytes: usize,
7810 callback: F,
7811 ) -> Result<(), NodeError>
7812 where
7813 F: FnMut(&[u8]) + 'static,
7814 {
7815 type Entry<F> = SubBufferedRawEntry<F>;
7816
7817 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
7818
7819 let (entry_offset, trailing_offset) =
7820 self.arena_alloc_with_trailing::<Entry<F>>(trailing_bytes)?;
7821
7822 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
7823
7824 let buffer = if qos.depth <= 1 {
7825 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
7826 } else {
7827 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
7828 };
7829
7830 unsafe {
7831 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7832 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<F>;
7833 core::ptr::write(
7834 entry_ptr,
7835 Entry {
7836 handle,
7837 buffer,
7838 callback,
7839 },
7840 );
7841 }
7842
7843 let meta = CallbackMeta {
7844 offset: entry_offset,
7845 kind: EntryKind::Subscription,
7846 arena_len: super::arena::ArenaLen::UNRECORDED,
7847 try_process: sub_buffered_raw_try_process::<F>,
7848 has_data: sub_buffered_raw_has_data::<F>,
7849 pre_sample: no_pre_sample,
7850 invocation: InvocationMode::OnNewData,
7851 drop_fn: drop_entry::<Entry<F>>,
7852 };
7853 self.emplace_entry(slot, meta, TraceName::Slot("sub", slot));
7854 Ok(())
7855 }
7856
7857 pub(crate) fn register_subscription_with_info_sized_inner<M, F, const RX_BUF: usize>(
7858 &mut self,
7859 node_id: Option<super::node_record::NodeId>,
7860 topic_name: &str,
7861 qos: QoSProfile,
7862 callback: F,
7863 // Issue 1860 -- `Some(n)` is a size the caller NAMED (`.rx_buffer::<N>()`),
7864 // used verbatim; `None` derives it from `M` exactly as the buffered path
7865 // does, falling back to `RX_BUF` only where `M` states no bound.
7866 rx_bytes: Option<usize>,
7867 ) -> Result<HandleId, NodeError>
7868 where
7869 M: crate::rmw_type_registry::MessageForRmw + 'static,
7870 F: FnMut(&M, Option<&nros_core::MessageInfo>) + 'static,
7871 {
7872 type Entry<M, F> = SubInfoEntry<M, F>;
7873
7874 // Phase 212.K.7.6.b — see `create_publisher_on`.
7875 crate::rmw_type_registry::register_type::<M>()?;
7876
7877 let SubscriptionOpen {
7878 slot,
7879 handle,
7880 slot_bytes,
7881 ..
7882 } = self.open_subscription(&SubscriptionRequest {
7883 node_id,
7884 topic_name,
7885 type_name: <M as RosMessage>::TYPE_NAME,
7886 type_hash: <M as RosMessage>::TYPE_HASH,
7887 qos,
7888 backend_hint: None,
7889 slot_bytes: crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF),
7890 // The entry's `pre_sample` hook stages a `MessageInfo` beside the
7891 // payload before the callback runs; an in-place drain has no
7892 // pre-sample step to hang it on.
7893 in_place_capable: false,
7894 })?;
7895
7896 let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<M, F>>(slot_bytes)?;
7897
7898 unsafe {
7899 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7900 let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
7901 let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F>;
7902 core::ptr::write(
7903 entry_ptr,
7904 Entry {
7905 handle,
7906 buffer,
7907 sampled_len: 0,
7908 callback,
7909 _phantom: PhantomData,
7910 },
7911 );
7912 }
7913
7914 let meta = CallbackMeta {
7915 offset,
7916 kind: EntryKind::Subscription,
7917 arena_len: super::arena::ArenaLen::UNRECORDED,
7918 try_process: sub_info_try_process::<M, F>,
7919 has_data: sub_info_has_data::<M, F>,
7920 pre_sample: sub_info_pre_sample::<M, F>,
7921 invocation: InvocationMode::OnNewData,
7922 drop_fn: drop_entry::<Entry<M, F>>,
7923 };
7924 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7925 self.apply_node_default_sched(slot, node_id, None);
7926 Ok(self.handle_for(slot))
7927 }
7928
7929 #[cfg(feature = "safety-e2e")]
7930 pub(crate) fn register_subscription_with_safety_sized_inner<M, F, const RX_BUF: usize>(
7931 &mut self,
7932 node_id: Option<super::node_record::NodeId>,
7933 topic_name: &str,
7934 qos: QoSProfile,
7935 callback: F,
7936 // Issue 1860 -- as `register_subscription_with_info_sized_inner`.
7937 rx_bytes: Option<usize>,
7938 ) -> Result<HandleId, NodeError>
7939 where
7940 M: crate::rmw_type_registry::MessageForRmw + 'static,
7941 F: FnMut(&M, &nros_rmw::IntegrityStatus) + 'static,
7942 {
7943 type Entry<M, F> = SubSafetyEntry<M, F>;
7944
7945 // Phase 212.K.7.6.b — see `create_publisher_on`.
7946 crate::rmw_type_registry::register_type::<M>()?;
7947
7948 let SubscriptionOpen {
7949 slot,
7950 handle,
7951 slot_bytes,
7952 ..
7953 } = self.open_subscription(&SubscriptionRequest {
7954 node_id,
7955 topic_name,
7956 type_name: <M as RosMessage>::TYPE_NAME,
7957 type_hash: <M as RosMessage>::TYPE_HASH,
7958 qos,
7959 backend_hint: None,
7960 slot_bytes: crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF),
7961 // Same as the raw safety sibling: the status comes out of
7962 // `take_validated`, which is a copying take.
7963 in_place_capable: false,
7964 })?;
7965
7966 let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<M, F>>(slot_bytes)?;
7967
7968 unsafe {
7969 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7970 let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
7971 let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F>;
7972 core::ptr::write(
7973 entry_ptr,
7974 Entry {
7975 handle,
7976 buffer,
7977 sampled_len: 0,
7978 callback,
7979 _phantom: PhantomData,
7980 },
7981 );
7982 }
7983
7984 let meta = CallbackMeta {
7985 offset,
7986 kind: EntryKind::Subscription,
7987 arena_len: super::arena::ArenaLen::UNRECORDED,
7988 try_process: sub_safety_try_process::<M, F>,
7989 has_data: sub_safety_has_data::<M, F>,
7990 pre_sample: sub_safety_pre_sample::<M, F>,
7991 invocation: InvocationMode::OnNewData,
7992 drop_fn: drop_entry::<Entry<M, F>>,
7993 };
7994 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
7995 self.apply_node_default_sched(slot, node_id, None);
7996 Ok(self.handle_for(slot))
7997 }
7998
7999 /// Register a service callback with the default buffer size.
8000 ///
8001 /// The callback is stored in the arena and invoked during [`spin_once()`](Self::spin_once).
8002 pub fn register_service<Svc, F>(
8003 &mut self,
8004 service_name: &str,
8005 callback: F,
8006 ) -> Result<HandleId, NodeError>
8007 where
8008 Svc: RosService + 'static,
8009 Svc::Request: crate::rmw_type_registry::MessageForRmw,
8010 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
8011 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
8012 {
8013 self.register_service_sized::<Svc, F, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, callback)
8014 }
8015
8016 /// Register a service callback with custom request/reply buffer sizes.
8017 pub fn register_service_sized<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
8018 &mut self,
8019 service_name: &str,
8020 callback: F,
8021 ) -> Result<HandleId, NodeError>
8022 where
8023 Svc: RosService + 'static,
8024 Svc::Request: crate::rmw_type_registry::MessageForRmw,
8025 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
8026 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
8027 {
8028 type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
8029
8030 // Phase 212.K.7.7.b — register both halves of the service round-trip
8031 // under cyclonedds. No-op for other RMWs. Mirrors the K.7.6.b hook
8032 // on `Node::create_service_sized`.
8033 crate::rmw_type_registry::register_type::<Svc::Request>()?;
8034 crate::rmw_type_registry::register_type::<Svc::Reply>()?;
8035
8036 let slot = self.next_entry_slot()?;
8037 let node_name: heapless::String<64> = self.node_name.clone();
8038 let ns: heapless::String<64> = self.namespace.clone();
8039 let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
8040 .with_domain(self.domain_id)
8041 .with_namespace(&ns);
8042 if !node_name.is_empty() {
8043 info = info.with_node_name(&node_name);
8044 }
8045 let handle = self
8046 .session
8047 .create_service(&info, QoSProfile::services_default())
8048 .map_err(NodeError::Transport)?;
8049
8050 let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
8051
8052 // SAFETY: same guarantees as register_subscription_sized.
8053 unsafe {
8054 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8055 let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
8056 core::ptr::write(
8057 entry_ptr,
8058 Entry {
8059 handle,
8060 req_buffer: [0u8; REQ_BUF],
8061 reply_buffer: [0u8; REPLY_BUF],
8062 callback,
8063 _phantom: PhantomData,
8064 },
8065 );
8066 }
8067
8068 let meta = CallbackMeta {
8069 offset,
8070 kind: EntryKind::Service,
8071 arena_len: super::arena::ArenaLen::UNRECORDED,
8072 try_process: srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
8073 has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
8074 pre_sample: no_pre_sample,
8075 invocation: InvocationMode::OnNewData,
8076 drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
8077 };
8078 self.emplace_entry(slot, meta, TraceName::Text(service_name));
8079 Ok(self.handle_for(slot))
8080 }
8081
8082 /// Phase 104.C.3.3.a — Node-aware variant of
8083 /// [`register_service_sized`](Self::register_service_sized).
8084 pub fn register_service_sized_on<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
8085 &mut self,
8086 node_id: super::node_record::NodeId,
8087 service_name: &str,
8088 qos: QoSProfile,
8089 callback: F,
8090 ) -> Result<HandleId, NodeError>
8091 where
8092 Svc: RosService + 'static,
8093 Svc::Request: crate::rmw_type_registry::MessageForRmw,
8094 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
8095 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
8096 {
8097 type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
8098
8099 // Phase 212.K.7.7.b — see `register_service_sized`.
8100 crate::rmw_type_registry::register_type::<Svc::Request>()?;
8101 crate::rmw_type_registry::register_type::<Svc::Reply>()?;
8102
8103 let slot = self.next_entry_slot()?;
8104 let (node_name, ns, session_idx) = {
8105 let r = self
8106 .nodes
8107 .get(node_id.index())
8108 .ok_or(NodeError::InvalidSchedContextBinding)?;
8109 (r.name.clone(), r.namespace.clone(), r.session_idx)
8110 };
8111 let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
8112 .with_domain(self.domain_id)
8113 .with_namespace(&ns);
8114 if !node_name.is_empty() {
8115 info = info.with_node_name(&node_name);
8116 }
8117 let handle = {
8118 let session = self
8119 .session_at_mut(session_idx)
8120 .ok_or(NodeError::BackendMismatch)?;
8121 // Phase 193.5 — validate against the backend's supported policies
8122 // (no silent downgrade); request/reply effectively requires RELIABLE.
8123 super::node::validate_qos_or_report(
8124 &qos,
8125 session.supported_qos_policies(),
8126 "service",
8127 service_name,
8128 )?;
8129 session
8130 .create_service(&info, qos)
8131 .map_err(NodeError::Transport)?
8132 };
8133
8134 let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
8135 unsafe {
8136 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8137 let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
8138 core::ptr::write(
8139 entry_ptr,
8140 Entry {
8141 handle,
8142 req_buffer: [0u8; REQ_BUF],
8143 reply_buffer: [0u8; REPLY_BUF],
8144 callback,
8145 _phantom: PhantomData,
8146 },
8147 );
8148 }
8149
8150 let meta = CallbackMeta {
8151 offset,
8152 kind: EntryKind::Service,
8153 arena_len: super::arena::ArenaLen::UNRECORDED,
8154 try_process: srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
8155 has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
8156 pre_sample: no_pre_sample,
8157 invocation: InvocationMode::OnNewData,
8158 drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
8159 };
8160 self.emplace_entry(slot, meta, TraceName::Text(service_name));
8161 self.apply_node_default_sched(slot, Some(node_id), None);
8162 Ok(self.handle_for(slot))
8163 }
8164
8165 /// Phase 104.C.3.3.a — Node-aware variant of
8166 /// [`register_service`](Self::register_service).
8167 pub fn register_service_on<Svc, F>(
8168 &mut self,
8169 node_id: super::node_record::NodeId,
8170 service_name: &str,
8171 callback: F,
8172 ) -> Result<HandleId, NodeError>
8173 where
8174 Svc: RosService + 'static,
8175 Svc::Request: crate::rmw_type_registry::MessageForRmw,
8176 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
8177 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
8178 {
8179 self.register_service_sized_on::<
8180 Svc,
8181 F,
8182 { crate::config::DEFAULT_RX_BUF_SIZE },
8183 { crate::config::DEFAULT_RX_BUF_SIZE },
8184 >(node_id, service_name, QoSProfile::services_default(), callback)
8185 }
8186
8187 // ========================================================================
8188 // Timer registration
8189 // ========================================================================
8190
8191 /// The ONE timer registration in the tree (phase-430 W4).
8192 ///
8193 /// Every timer verb — the four `Executor::register_timer*` entry points
8194 /// below, `Node::create_timer_in_group` / `create_timer_on_clock*`, the
8195 /// declarative `nros::Node` timer, and the C/C++ FFI that lower to them —
8196 /// constructs its arena entry HERE. Until W4 there were four copies of this
8197 /// body differing only in `oneshot`, `clock_source` and whether the sched
8198 /// binding was applied, and phase-425's clock axis reached exactly one of
8199 /// them; that is how a `nros::main!` component could declare a timer but
8200 /// not a ROS-time one.
8201 ///
8202 /// `node_id` / `group` feed `apply_node_default_sched` (phase 273: group
8203 /// table > node default > SC 0). `node_id == None` is the legacy unscoped
8204 /// path and makes that call a no-op.
8205 pub(crate) fn register_timer_entry<F>(
8206 &mut self,
8207 node_id: Option<super::node_record::NodeId>,
8208 period: TimerDuration,
8209 source: TimerClockSource,
8210 oneshot: bool,
8211 group: Option<&str>,
8212 callback: F,
8213 ) -> Result<HandleId, NodeError>
8214 where
8215 F: FnMut() + 'static,
8216 {
8217 self.register_timer_entry_with(
8218 node_id,
8219 period,
8220 source,
8221 oneshot,
8222 group,
8223 None,
8224 core::ptr::null_mut(),
8225 move |_| callback,
8226 )
8227 }
8228
8229 /// [`register_timer_entry`](Self::register_timer_entry), with a CAPTURE
8230 /// the arena holds — phase-476 W2.
8231 ///
8232 /// The callback is BUILT after the allocation, by `make`, from the context
8233 /// pointer the entry should dispatch with: `place_capture`'s copy of
8234 /// `capture` when there is one, otherwise `context` unchanged. That is what
8235 /// lets the capture ride in the timer entry's own trailing region (so a
8236 /// release frees it) while the closure that reads it is still a plain
8237 /// `TimerEntry<F>`.
8238 #[allow(clippy::too_many_arguments)]
8239 pub(crate) fn register_timer_entry_with<F, M>(
8240 &mut self,
8241 node_id: Option<super::node_record::NodeId>,
8242 period: TimerDuration,
8243 source: TimerClockSource,
8244 oneshot: bool,
8245 group: Option<&str>,
8246 capture: Option<&[u8]>,
8247 context: *mut core::ffi::c_void,
8248 make: M,
8249 ) -> Result<HandleId, NodeError>
8250 where
8251 F: FnMut() + 'static,
8252 M: FnOnce(*mut core::ffi::c_void) -> F,
8253 {
8254 let slot = self.next_entry_slot()?;
8255 let capture_len = Self::capture_trailing_len(capture);
8256 // No capture keeps the exact pre-W2 allocation, so a timer that captures
8257 // nothing costs what it always did.
8258 let (offset, context) = if capture_len == 0 {
8259 (self.arena_alloc::<TimerEntry<F>>()?, context)
8260 } else {
8261 let (offset, at) = self.arena_alloc_with_trailing::<TimerEntry<F>>(capture_len)?;
8262 (offset, self.place_capture(at, capture, context))
8263 };
8264 let callback = make(context);
8265
8266 unsafe {
8267 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8268 let entry_ptr = arena_ptr.add(offset) as *mut TimerEntry<F>;
8269 core::ptr::write(
8270 entry_ptr,
8271 TimerEntry {
8272 period_us: period.as_micros(),
8273 elapsed_us: 0,
8274 overruns: 0,
8275 overruns_reported: 0,
8276 oneshot,
8277 fired: false,
8278 cancelled: false,
8279 overrun_policy: TimerOverrunPolicy::default(),
8280 clock_source: source,
8281 // Seeded HERE rather than on the first poll: a zero would
8282 // make the first delta the whole epoch, which `Skip` would
8283 // then coalesce into one immediate activation and `CatchUp`
8284 // into a replay burst of ~10^9 periods. `Steady` reads no
8285 // clock at all, so its seed is 0 and is never read.
8286 last_clock_ns: source.now_ns(),
8287 callback,
8288 },
8289 );
8290 }
8291
8292 let meta = CallbackMeta {
8293 offset,
8294 kind: EntryKind::Timer,
8295 arena_len: super::arena::ArenaLen::UNRECORDED,
8296 try_process: timer_try_process::<F>,
8297 has_data: always_ready,
8298 pre_sample: no_pre_sample,
8299 invocation: InvocationMode::Always,
8300 drop_fn: drop_entry::<TimerEntry<F>>,
8301 };
8302 self.emplace_entry(slot, meta, TraceName::TimerPeriod(period.as_micros()));
8303 // Phase 273 — apply group sched binding (group > node default > SC 0).
8304 self.apply_node_default_sched(slot, node_id, group);
8305 Ok(self.handle_for(slot))
8306 }
8307
8308 /// Register a repeating timer callback.
8309 ///
8310 /// The callback fires every `period` milliseconds during [`spin_once()`](Self::spin_once).
8311 /// The timer delta is approximated by the `timeout_ms` argument to `spin_once`.
8312 pub fn register_timer<F>(
8313 &mut self,
8314 period: TimerDuration,
8315 callback: F,
8316 ) -> Result<HandleId, NodeError>
8317 where
8318 F: FnMut() + 'static,
8319 {
8320 self.register_timer_entry(
8321 None,
8322 period,
8323 TimerClockSource::Steady,
8324 false,
8325 None,
8326 callback,
8327 )
8328 }
8329
8330 /// Register a repeating timer driven by a CLOCK rather than by the spin
8331 /// delta — phase-425 W4, the shape rclcpp spells
8332 /// `create_timer(node, clock, period, cb)`.
8333 ///
8334 /// [`register_timer`](Self::register_timer) is the wall timer: it consumes
8335 /// the executor's monotonic spin delta and no simulator can slow it down.
8336 /// This one reads `source` on every poll and advances by the difference, so
8337 /// a [`TimerClockSource::Ros`] timer follows `/clock` — it stops while the
8338 /// simulator is paused, halves with a bag replayed at 0.5x, and restarts
8339 /// its period on a backwards jump instead of stalling for the length of it.
8340 ///
8341 /// With no `/clock` source installed, a `Ros` timer reads system time and
8342 /// behaves like a wall timer with NTP steps, which is the same fallback
8343 /// `rclcpp::Clock` has: a node written for simulation still runs standalone.
8344 ///
8345 /// The node-level spellings of this are `Node::create_timer_on_clock`
8346 /// and the declarative `DeclaredNode::create_timer_on_clock` (phase-430 W4);
8347 /// all of them reach `register_timer_entry`, which is the only place a
8348 /// timer entry is built.
8349 pub fn register_timer_on_clock<F>(
8350 &mut self,
8351 period: TimerDuration,
8352 source: TimerClockSource,
8353 callback: F,
8354 ) -> Result<HandleId, NodeError>
8355 where
8356 F: FnMut() + 'static,
8357 {
8358 self.register_timer_entry(None, period, source, false, None, callback)
8359 }
8360
8361 /// Register a one-shot timer callback.
8362 ///
8363 /// The callback fires once after `delay` milliseconds, then becomes inert.
8364 pub fn register_timer_oneshot<F>(
8365 &mut self,
8366 delay: TimerDuration,
8367 callback: F,
8368 ) -> Result<HandleId, NodeError>
8369 where
8370 F: FnMut() + 'static,
8371 {
8372 self.register_timer_entry(None, delay, TimerClockSource::Steady, true, None, callback)
8373 }
8374
8375 /// phase-476 W2 — register a timer whose callback is a C function pointer
8376 /// and whose CAPTURE the arena holds: the `nros-cpp` road for a C++
8377 /// lambda, the timer twin of
8378 /// [`add_arena_subscription_c_callback_with_capture`](Self::add_arena_subscription_c_callback_with_capture).
8379 ///
8380 /// `capture`'s bytes are copied into the timer entry's trailing region and
8381 /// `callback` is called with a pointer to that copy, so the caller keeps
8382 /// nothing — which is what lets the C++ side hand back a two-word handle
8383 /// instead of a heap cell the node had to keep alive. Releasing the timer
8384 /// releases the capture with it.
8385 ///
8386 /// `source` picks the clock: [`TimerClockSource::Steady`] is the wall timer
8387 /// that advances with the spin delta.
8388 ///
8389 /// `capture_drop`, when given, is called with the same pointer when the
8390 /// entry is dropped (released, or the executor torn down), BEFORE its bytes
8391 /// go back to the arena. A C++ capture is a callable that may own something
8392 /// (a `std::shared_ptr`, a `std::string`), and its bytes were MOVED here by
8393 /// copy, so this is the only place its destructor can run.
8394 pub fn register_timer_c_capturing(
8395 &mut self,
8396 node_id: Option<super::node_record::NodeId>,
8397 period: TimerDuration,
8398 source: TimerClockSource,
8399 callback: unsafe extern "C" fn(*mut core::ffi::c_void),
8400 capture: Option<&[u8]>,
8401 capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
8402 ) -> Result<HandleId, NodeError> {
8403 self.register_timer_entry_with(
8404 node_id,
8405 period,
8406 source,
8407 false,
8408 None,
8409 capture,
8410 core::ptr::null_mut(),
8411 move |ctx| {
8412 let held = super::arena::CapturedCallback {
8413 callback,
8414 guard: super::arena::CaptureGuard {
8415 ctx,
8416 drop: capture_drop,
8417 },
8418 };
8419 move || held.call()
8420 },
8421 )
8422 }
8423
8424 /// Phase 273 (RFC-0047) — register a repeating timer callback bound to a
8425 /// specific node and optional callback group. The group name is threaded to
8426 /// `apply_node_default_sched` so the seeded `group_sched_table` assigns
8427 /// the timer's callback to the group's `SchedContext`. When `group` is
8428 /// `None` the node's `default_sched` applies (phase-272 behavior).
8429 ///
8430 /// This is the executor-level primitive called by the Rust `_in_group` API
8431 /// (`Node::create_timer_in_group`) and the C/C++ group-aware timer FFI.
8432 pub fn register_timer_on<F>(
8433 &mut self,
8434 node_id: Option<super::node_record::NodeId>,
8435 period: TimerDuration,
8436 callback: F,
8437 group: Option<&str>,
8438 ) -> Result<HandleId, NodeError>
8439 where
8440 F: FnMut() + 'static,
8441 {
8442 self.register_timer_entry(
8443 node_id,
8444 period,
8445 TimerClockSource::Steady,
8446 false,
8447 group,
8448 callback,
8449 )
8450 }
8451
8452 // ========================================================================
8453 // Raw callback registration (for C API)
8454 // ========================================================================
8455
8456 /// The kept C-FFI subscription core (Phase 189.M2.b): registers a
8457 /// raw `RawSubscriptionCallback` fn-ptr + `context` against an
8458 /// optional node's session. The Rust ergonomic surface is the
8459 /// `node.subscription(t)` builder (closures); this is the single
8460 /// primitive the `nros-c` thin wrapper lowers to. `node_id == None`
8461 /// is the legacy single-node path.
8462 #[allow(clippy::too_many_arguments)]
8463 pub fn add_arena_subscription_c_callback<const RX_BUF: usize>(
8464 &mut self,
8465 node_id: Option<super::node_record::NodeId>,
8466 topic_name: &str,
8467 type_name: &str,
8468 type_hash: &str,
8469 qos: QoSProfile,
8470 callback: RawSubscriptionCallback,
8471 context: *mut core::ffi::c_void,
8472 group: Option<&str>,
8473 rx_buffer_hint: usize,
8474 ) -> Result<HandleId, NodeError> {
8475 self.add_arena_subscription_c_callback_with_capture::<RX_BUF>(
8476 node_id,
8477 topic_name,
8478 type_name,
8479 type_hash,
8480 qos,
8481 callback,
8482 context,
8483 group,
8484 rx_buffer_hint,
8485 None,
8486 None,
8487 )
8488 }
8489
8490 /// The same registration, with the caller's CAPTURE held by the arena —
8491 /// phase-456 W1.
8492 ///
8493 /// `context` is one pointer, which carries a C++ `[this]` capture and not a
8494 /// `[this, state]` or a `[obj, method]`. When `capture` is `Some`, its
8495 /// bytes are copied into the arena and `context` is repointed at that copy,
8496 /// so the caller keeps nothing and nothing the caller owns is referenced
8497 /// after this returns. When it is `None` the behaviour is exactly the
8498 /// pre-existing one, which is why the plain entry point above is a wrapper
8499 /// rather than a second implementation.
8500 ///
8501 /// **phase-456 W8 changed two things here.** The capture is a RUNTIME
8502 /// length — see `Self::place_capture` — so there is no constant the C++
8503 /// side has to match and no `BufferTooSmall` reachable only by drifting
8504 /// from it. And this path now reaches the in-place dispatch that the Rust
8505 /// typed path has had since phase-231: `RawSubscriptionCallback` is
8506 /// `(const uint8_t*, size_t, void*)`, so the backend's borrowed receive
8507 /// slot IS the argument, and a C or C++ subscription on zenoh or XRCE
8508 /// claims no receive region at all.
8509 #[allow(clippy::too_many_arguments)]
8510 pub fn add_arena_subscription_c_callback_with_capture<const RX_BUF: usize>(
8511 &mut self,
8512 node_id: Option<super::node_record::NodeId>,
8513 topic_name: &str,
8514 type_name: &str,
8515 type_hash: &str,
8516 qos: QoSProfile,
8517 callback: RawSubscriptionCallback,
8518 context: *mut core::ffi::c_void,
8519 group: Option<&str>,
8520 // phase-402 W2 / issue 0896 — bytes the caller expects to receive; 0 =
8521 // no opinion. A size-classing backend (zenoh-pico) routes on this, and
8522 // a subscription that states nothing takes the SMALL class whatever its
8523 // message type. The C path had no way to say it until the options
8524 // struct existed.
8525 rx_buffer_hint: usize,
8526 capture: Option<&[u8]>,
8527 // phase-476 W2 — called on the arena's copy of `capture` when the entry
8528 // is dropped; see `CaptureGuard`. `None` when the capture owns nothing.
8529 capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
8530 ) -> Result<HandleId, NodeError> {
8531 // phase-403 W3/W5 -- size the arena slot from the TYPE, not from the
8532 // image-wide default. `rx_buffer_hint` already arrives here (phase-402
8533 // routed it to the backend's payload class and stopped); spending it on
8534 // the allocation as well is what makes the buffer per-type.
8535 //
8536 // This is the whole saving on the raw path. `RX_BUF` is
8537 // DEFAULT_RX_BUF_SIZE, so every slot -- publishers and timers included --
8538 // was charged the largest subscription's buffer. Measured on
8539 // mr-canhubk344: 36 handles at the correct 2052-byte bound wanted a
8540 // 242096-byte arena of a 77968-byte region; only 13 of those handles
8541 // receive anything.
8542 //
8543 // 0 keeps the old behaviour, and it means "this CALLER stated nothing"
8544 // rather than "this type is unbounded" -- an unbounded type is a build
8545 // error now, so a zero here is a caller that did not ask, never a type
8546 // that could not answer.
8547 let slot_bytes = if rx_buffer_hint != 0 {
8548 rx_buffer_hint
8549 } else {
8550 RX_BUF
8551 };
8552
8553 let SubscriptionOpen {
8554 slot,
8555 handle,
8556 qos,
8557 in_place,
8558 slot_bytes,
8559 } = self.open_subscription(&SubscriptionRequest {
8560 node_id,
8561 topic_name,
8562 type_name,
8563 type_hash,
8564 qos,
8565 backend_hint: Some(rx_buffer_hint),
8566 slot_bytes,
8567 // phase-456 W8 — `RawSubscriptionCallback` takes borrowed bytes and
8568 // a context, which is exactly what `process_raw_in_place` hands it.
8569 in_place_capable: true,
8570 })?;
8571
8572 // The capture is placed AFTER the backend call and BEFORE either entry
8573 // is written, so both dispatch shapes get the same context and a failed
8574 // `create_subscription` costs no arena bytes. phase-476 W0 — it rides
8575 // at the end of the entry's own trailing region, so releasing the entry
8576 // gives it back.
8577 let capture_len = Self::capture_trailing_len(capture);
8578
8579 if in_place {
8580 let (entry_offset, capture_at) =
8581 self.arena_alloc_with_trailing::<super::arena::SubInplaceRawCEntry>(capture_len)?;
8582 let context = self.place_capture(capture_at, capture, context);
8583 unsafe {
8584 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8585 let entry_ptr =
8586 arena_ptr.add(entry_offset) as *mut super::arena::SubInplaceRawCEntry;
8587 core::ptr::write(
8588 entry_ptr,
8589 super::arena::SubInplaceRawCEntry {
8590 handle,
8591 callback,
8592 context,
8593 capture: super::arena::CaptureGuard {
8594 ctx: context,
8595 drop: capture_drop,
8596 },
8597 },
8598 );
8599 }
8600 let meta = CallbackMeta {
8601 offset: entry_offset,
8602 kind: EntryKind::Subscription,
8603 arena_len: super::arena::ArenaLen::UNRECORDED,
8604 try_process: super::arena::sub_inplace_raw_c_try_process,
8605 has_data: super::arena::sub_inplace_raw_c_has_data,
8606 pre_sample: no_pre_sample,
8607 invocation: InvocationMode::OnNewData,
8608 drop_fn: drop_entry::<super::arena::SubInplaceRawCEntry>,
8609 };
8610 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8611 self.apply_node_default_sched(slot, node_id, group);
8612 return Ok(self.handle_for(slot));
8613 }
8614
8615 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
8616
8617 // The ring first, then the capture on the next `u64` boundary after it.
8618 let ring_len = trailing_bytes.next_multiple_of(core::mem::align_of::<u64>());
8619 let (entry_offset, trailing_offset) =
8620 self.arena_alloc_with_trailing::<SubBufferedRawCEntry>(ring_len + capture_len)?;
8621 let context = self.place_capture(trailing_offset + ring_len, capture, context);
8622
8623 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
8624
8625 let buffer = if qos.depth <= 1 {
8626 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
8627 } else {
8628 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
8629 };
8630
8631 unsafe {
8632 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8633 let entry_ptr = arena_ptr.add(entry_offset) as *mut SubBufferedRawCEntry;
8634 core::ptr::write(
8635 entry_ptr,
8636 SubBufferedRawCEntry {
8637 handle,
8638 buffer,
8639 callback,
8640 context,
8641 capture: super::arena::CaptureGuard {
8642 ctx: context,
8643 drop: capture_drop,
8644 },
8645 },
8646 );
8647 }
8648
8649 let meta = CallbackMeta {
8650 offset: entry_offset,
8651 kind: EntryKind::Subscription,
8652 arena_len: super::arena::ArenaLen::UNRECORDED,
8653 try_process: sub_buffered_raw_c_try_process,
8654 has_data: sub_buffered_raw_c_has_data,
8655 pre_sample: no_pre_sample,
8656 invocation: InvocationMode::OnNewData,
8657 drop_fn: drop_entry::<SubBufferedRawCEntry>,
8658 };
8659 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8660 self.apply_node_default_sched(slot, node_id, group);
8661 Ok(self.handle_for(slot))
8662 }
8663
8664 /// phase-417 W5.a (RFC-0089 stage 5) — the TYPED C-FFI subscription core:
8665 /// the same registration as [`Self::add_arena_subscription_c_callback`],
8666 /// plus caller-owned message storage and the erased deserialiser for its
8667 /// type, so the callback receives a DESERIALISED message instead of CDR
8668 /// bytes.
8669 ///
8670 /// This is the Rust half of rclc's
8671 /// `rclc_executor_add_subscription(executor, subscription, msg, callback,
8672 /// invocation)`. rclc has no allocator on that path either — it makes the
8673 /// CALLER own the storage, which is exactly what `msg` is here. The C
8674 /// ledger recorded our byte-oriented delivery as forced by "no allocator";
8675 /// the compared surface operates under the same constraint, so the reason
8676 /// was false and the divergence was avoidable (issue 1022 / W-C3).
8677 ///
8678 /// The raw path is untouched and stays: a byte-oriented subscriber is a
8679 /// legitimate thing to want, and this is additive.
8680 ///
8681 /// # Contract on failure
8682 /// If `deserialize` returns non-zero the callback is **not** invoked, the
8683 /// sample is dropped, `try_process` reports
8684 /// `TransportError::DeserializationError` (so it lands in
8685 /// `SpinOnceResult::subscription_errors`), and a rate-limited `nros_log`
8686 /// error names the length and the return code. A message that does not fit
8687 /// the caller's storage arrives here as exactly that failure: both the
8688 /// bounded-string and bounded-sequence decoders refuse rather than
8689 /// truncate, so there is no silent short read.
8690 ///
8691 /// # Safety
8692 /// `msg` must point to storage of the type `deserialize` writes, valid for
8693 /// as long as the subscription is registered. Nothing here drops it.
8694 #[allow(clippy::too_many_arguments)]
8695 pub unsafe fn add_arena_subscription_c_typed_callback<const RX_BUF: usize>(
8696 &mut self,
8697 node_id: Option<super::node_record::NodeId>,
8698 topic_name: &str,
8699 type_name: &str,
8700 type_hash: &str,
8701 qos: QoSProfile,
8702 msg: core::ptr::NonNull<core::ffi::c_void>,
8703 deserialize: RawMessageDeserializeFn,
8704 callback: TypedSubscriptionCallback,
8705 context: *mut core::ffi::c_void,
8706 group: Option<&str>,
8707 // Same meaning as on the raw path: bytes the caller expects to receive,
8708 // 0 = no opinion. A typed caller normally HAS an answer — the generated
8709 // `<Msg>_RX_MAX_SERIALIZED_SIZE` — which is why the C entry point takes
8710 // it rather than defaulting.
8711 rx_buffer_hint: usize,
8712 ) -> Result<HandleId, NodeError> {
8713 let SubscriptionOpen {
8714 slot,
8715 handle,
8716 qos,
8717 slot_bytes: rx_bytes,
8718 ..
8719 } = self.open_subscription(&SubscriptionRequest {
8720 node_id,
8721 topic_name,
8722 type_name,
8723 type_hash,
8724 qos,
8725 backend_hint: Some(rx_buffer_hint),
8726 // phase-403 W3/W5, unchanged: the hint sizes the arena slot, and 0
8727 // means "this caller stated nothing", never "this type is zero
8728 // bytes".
8729 slot_bytes: if rx_buffer_hint != 0 {
8730 rx_buffer_hint
8731 } else {
8732 RX_BUF
8733 },
8734 // The callback receives the caller's DESERIALISED storage, written
8735 // by `deserialize` from the sample's bytes, so borrowed bytes would
8736 // be enough and this shape could take the in-place row. It does not
8737 // yet: `SubBufferedTypedCEntry` also owns the `msg` pointer and the
8738 // deserialiser, so an in-place twin is a second entry type rather
8739 // than a flag, and phase-456 W8's acceptance is the RAW C path.
8740 // Stated here rather than left blank because a `false` nobody wrote
8741 // down is what this whole item is about.
8742 in_place_capable: false,
8743 })?;
8744
8745 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, rx_bytes);
8746
8747 let (entry_offset, trailing_offset) =
8748 self.arena_alloc_with_trailing::<SubBufferedTypedCEntry>(trailing_bytes)?;
8749
8750 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
8751
8752 let buffer = if qos.depth <= 1 {
8753 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, rx_bytes) })
8754 } else {
8755 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, rx_bytes, qos.depth as usize) })
8756 };
8757
8758 unsafe {
8759 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8760 let entry_ptr = arena_ptr.add(entry_offset) as *mut SubBufferedTypedCEntry;
8761 core::ptr::write(
8762 entry_ptr,
8763 SubBufferedTypedCEntry {
8764 handle,
8765 buffer,
8766 msg: msg.as_ptr(),
8767 deserialize,
8768 callback,
8769 context,
8770 },
8771 );
8772 }
8773
8774 let meta = CallbackMeta {
8775 offset: entry_offset,
8776 kind: EntryKind::Subscription,
8777 arena_len: super::arena::ArenaLen::UNRECORDED,
8778 try_process: sub_buffered_typed_c_try_process,
8779 has_data: sub_buffered_typed_c_has_data,
8780 pre_sample: no_pre_sample,
8781 invocation: InvocationMode::OnNewData,
8782 drop_fn: drop_entry::<SubBufferedTypedCEntry>,
8783 };
8784 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8785 self.apply_node_default_sched(slot, node_id, group);
8786 Ok(self.handle_for(slot))
8787 }
8788
8789 /// Phase 189.M3.4 — register a raw C-fn-ptr subscription whose callback
8790 /// also receives the sample's wire **attachment**
8791 /// ([`RawSubscriptionInfoCallback`]: `(data, len, attachment, att_len,
8792 /// context)`) — the C analog of the Rust
8793 /// `node.subscription(t).generic(..).message_info()` builder. Backs the C
8794 /// FFI `rclc_executor_add_subscription_raw_with_info`. Flat per-entry
8795 /// payload + attachment buffers (cap `RAW_INFO_ATT_CAP`);
8796 /// one sample per `spin_once`.
8797 #[allow(clippy::too_many_arguments)]
8798 pub fn add_arena_subscription_c_info_callback<const RX_BUF: usize>(
8799 &mut self,
8800 node_id: Option<super::node_record::NodeId>,
8801 topic_name: &str,
8802 type_name: &str,
8803 type_hash: &str,
8804 qos: QoSProfile,
8805 callback: RawSubscriptionInfoCallback,
8806 context: *mut core::ffi::c_void,
8807 // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
8808 // opinion. It now buys BOTH halves: the BACKEND's payload size-class
8809 // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
8810 // survives only as the fallback for a caller that states nothing, which
8811 // is exactly its role on the plain C path.
8812 rx_buffer_hint: usize,
8813 ) -> Result<HandleId, NodeError> {
8814 type Entry = SubBufferedRawInfoCEntry;
8815
8816 let SubscriptionOpen {
8817 slot,
8818 handle,
8819 slot_bytes: rx_bytes,
8820 ..
8821 } = self.open_subscription(&SubscriptionRequest {
8822 node_id,
8823 topic_name,
8824 type_name,
8825 type_hash,
8826 qos,
8827 backend_hint: Some(rx_buffer_hint),
8828 // phase-408 W5b — the hint sizes the ARENA too, not just the
8829 // backend's size class. 0 still means "this caller stated nothing",
8830 // so `RX_BUF` -- `DEFAULT_RX_BUF_SIZE` at every call site -- is what
8831 // it falls back to, and an unhinted registration claims the same
8832 // bytes it always did.
8833 slot_bytes: if rx_buffer_hint != 0 {
8834 rx_buffer_hint
8835 } else {
8836 RX_BUF
8837 },
8838 // Carries the wire attachment — see the Rust `_raw_info_on` twin.
8839 in_place_capable: false,
8840 })?;
8841
8842 let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
8843 unsafe {
8844 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8845 let buf = super::arena::TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
8846 let entry_ptr = arena_ptr.add(offset) as *mut Entry;
8847 core::ptr::write(
8848 entry_ptr,
8849 Entry {
8850 handle,
8851 buffer: buf,
8852 att: [0u8; super::arena::RAW_INFO_ATT_CAP],
8853 callback,
8854 context,
8855 },
8856 );
8857 }
8858
8859 let meta = CallbackMeta {
8860 offset,
8861 kind: EntryKind::Subscription,
8862 arena_len: super::arena::ArenaLen::UNRECORDED,
8863 try_process: sub_buffered_raw_info_c_try_process,
8864 has_data: sub_buffered_raw_info_c_has_data,
8865 pre_sample: no_pre_sample,
8866 invocation: InvocationMode::OnNewData,
8867 drop_fn: drop_entry::<Entry>,
8868 };
8869 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8870 self.apply_node_default_sched(slot, node_id, None);
8871 Ok(self.handle_for(slot))
8872 }
8873
8874 /// Phase 269 W3 — register a raw C-fn-ptr subscription whose callback
8875 /// ALSO surfaces the sample's E2E integrity status (CRC + sequence gap/dup)
8876 /// alongside the CDR bytes — the C/C++ component-callback analog of Rust's
8877 /// `register_subscription_buffered_raw_safety_on` (`FnMut(&[u8], &IntegrityStatus)`).
8878 ///
8879 /// The executor validates the sample via `take_validated` and unpacks the
8880 /// [`nros_rmw::IntegrityStatus`] into three plain scalars (gap, duplicate,
8881 /// crc_valid) before calling `callback`. This avoids introducing a
8882 /// cbindgen-visible struct at the executor layer; the C/C++ headers pack them
8883 /// back into their local integrity-status typedef.
8884 ///
8885 /// Requires the `safety-e2e` feature. Backed by `SubBufferedRawSafetyCEntry`.
8886 #[cfg(feature = "safety-e2e")]
8887 #[allow(clippy::too_many_arguments)]
8888 pub fn add_arena_subscription_c_validated_callback<const RX_BUF: usize>(
8889 &mut self,
8890 node_id: Option<super::node_record::NodeId>,
8891 topic_name: &str,
8892 type_name: &str,
8893 type_hash: &str,
8894 qos: QoSProfile,
8895 callback: super::types::RawSubscriptionSafetyCallback,
8896 context: *mut core::ffi::c_void,
8897 // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
8898 // opinion. It now buys BOTH halves: the BACKEND's payload size-class
8899 // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
8900 // survives only as the fallback for a caller that states nothing, which
8901 // is exactly its role on the plain C path.
8902 rx_buffer_hint: usize,
8903 ) -> Result<HandleId, NodeError> {
8904 use super::arena::{
8905 SubBufferedRawSafetyCEntry, TrailingBuf, sub_buffered_raw_safety_c_has_data,
8906 sub_buffered_raw_safety_c_try_process,
8907 };
8908 type Entry = SubBufferedRawSafetyCEntry;
8909
8910 let SubscriptionOpen {
8911 slot,
8912 handle,
8913 slot_bytes: rx_bytes,
8914 ..
8915 } = self.open_subscription(&SubscriptionRequest {
8916 node_id,
8917 topic_name,
8918 type_name,
8919 type_hash,
8920 qos,
8921 backend_hint: Some(rx_buffer_hint),
8922 // phase-408 W5b — same as the info sibling: one flat slot in the
8923 // trailing region, sized from the hint, `RX_BUF` only as the
8924 // stated-nothing fallback.
8925 slot_bytes: if rx_buffer_hint != 0 {
8926 rx_buffer_hint
8927 } else {
8928 RX_BUF
8929 },
8930 // Carries the integrity status, which comes from `take_validated`.
8931 in_place_capable: false,
8932 })?;
8933
8934 let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
8935 unsafe {
8936 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8937 let buf = TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
8938 let entry_ptr = arena_ptr.add(offset) as *mut Entry;
8939 core::ptr::write(
8940 entry_ptr,
8941 Entry {
8942 handle,
8943 buffer: buf,
8944 callback,
8945 context,
8946 },
8947 );
8948 }
8949
8950 let meta = CallbackMeta {
8951 offset,
8952 kind: EntryKind::Subscription,
8953 arena_len: super::arena::ArenaLen::UNRECORDED,
8954 try_process: sub_buffered_raw_safety_c_try_process,
8955 has_data: sub_buffered_raw_safety_c_has_data,
8956 pre_sample: no_pre_sample,
8957 invocation: InvocationMode::OnNewData,
8958 drop_fn: drop_entry::<Entry>,
8959 };
8960 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8961 self.apply_node_default_sched(slot, node_id, None);
8962 Ok(self.handle_for(slot))
8963 }
8964
8965 /// Register a raw (untyped) service callback.
8966 ///
8967 /// Register a raw (untyped) service callback with the default buffer size.
8968 ///
8969 /// The callback receives and produces CDR bytes without typed
8970 /// deserialization/serialization. Used by the C API wrapper.
8971 pub fn register_service_raw(
8972 &mut self,
8973 service_name: &str,
8974 service_type: &str,
8975 service_hash: &str,
8976 callback: RawServiceCallback,
8977 context: *mut core::ffi::c_void,
8978 ) -> Result<HandleId, NodeError> {
8979 self.register_service_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(
8980 service_name,
8981 service_type,
8982 service_hash,
8983 QoSProfile::services_default(),
8984 callback,
8985 context,
8986 )
8987 }
8988
8989 /// Register a raw (untyped) service callback with custom buffer sizes + QoS.
8990 ///
8991 /// `REQ_BUF` and `REPLY_BUF` set the stack-allocated CDR buffers
8992 /// for the request and reply respectively. Increase for services
8993 /// with large payloads (e.g., parameter services). `qos` applies to both
8994 /// the request + reply endpoints (Phase 193.2c).
8995 #[allow(clippy::too_many_arguments)]
8996 pub fn register_service_raw_sized<const REQ_BUF: usize, const REPLY_BUF: usize>(
8997 &mut self,
8998 service_name: &str,
8999 service_type: &str,
9000 service_hash: &str,
9001 qos: QoSProfile,
9002 callback: RawServiceCallback,
9003 context: *mut core::ffi::c_void,
9004 ) -> Result<HandleId, NodeError> {
9005 self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF>(
9006 None,
9007 service_name,
9008 service_type,
9009 service_hash,
9010 qos,
9011 callback,
9012 context,
9013 )
9014 }
9015
9016 /// Phase 104.C.3.3.a — Node-aware variant of
9017 /// [`Self::register_service_raw_sized`]. C-FFI path.
9018 #[allow(clippy::too_many_arguments)]
9019 pub fn register_service_raw_sized_on<const REQ_BUF: usize, const REPLY_BUF: usize>(
9020 &mut self,
9021 node_id: super::node_record::NodeId,
9022 service_name: &str,
9023 service_type: &str,
9024 service_hash: &str,
9025 qos: QoSProfile,
9026 callback: RawServiceCallback,
9027 context: *mut core::ffi::c_void,
9028 ) -> Result<HandleId, NodeError> {
9029 self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF>(
9030 Some(node_id),
9031 service_name,
9032 service_type,
9033 service_hash,
9034 qos,
9035 callback,
9036 context,
9037 )
9038 }
9039
9040 #[allow(clippy::too_many_arguments)]
9041 fn register_service_raw_sized_inner<const REQ_BUF: usize, const REPLY_BUF: usize>(
9042 &mut self,
9043 node_id: Option<super::node_record::NodeId>,
9044 service_name: &str,
9045 service_type: &str,
9046 service_hash: &str,
9047 qos: QoSProfile,
9048 callback: RawServiceCallback,
9049 context: *mut core::ffi::c_void,
9050 ) -> Result<HandleId, NodeError> {
9051 let slot = self.next_entry_slot()?;
9052 let (node_name, ns, session_idx) = match node_id {
9053 Some(id) => {
9054 let r = self
9055 .nodes
9056 .get(id.index())
9057 .ok_or(NodeError::InvalidSchedContextBinding)?;
9058 (r.name.clone(), r.namespace.clone(), r.session_idx)
9059 }
9060 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
9061 };
9062 let mut info = ServiceInfo::new(service_name, service_type, service_hash)
9063 .with_domain(self.domain_id)
9064 .with_namespace(&ns);
9065 if !node_name.is_empty() {
9066 info = info.with_node_name(&node_name);
9067 }
9068 let handle = {
9069 let session = self
9070 .session_at_mut(session_idx)
9071 .ok_or(NodeError::BackendMismatch)?;
9072 // Phase 193.5 — validate against the backend's supported policies
9073 // (no silent downgrade); request/reply effectively requires RELIABLE.
9074 super::node::validate_qos_or_report(
9075 &qos,
9076 session.supported_qos_policies(),
9077 "service",
9078 service_name,
9079 )?;
9080 session
9081 .create_service(&info, qos)
9082 .map_err(NodeError::Transport)?
9083 };
9084
9085 let offset = self.arena_alloc::<SrvRawEntry<REQ_BUF, REPLY_BUF>>()?;
9086
9087 unsafe {
9088 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9089 let entry_ptr = arena_ptr.add(offset) as *mut SrvRawEntry<REQ_BUF, REPLY_BUF>;
9090 core::ptr::write(
9091 entry_ptr,
9092 SrvRawEntry {
9093 handle,
9094 req_buffer: [0u8; REQ_BUF],
9095 reply_buffer: [0u8; REPLY_BUF],
9096 callback,
9097 context,
9098 },
9099 );
9100 }
9101
9102 let meta = CallbackMeta {
9103 offset,
9104 kind: EntryKind::Service,
9105 arena_len: super::arena::ArenaLen::UNRECORDED,
9106 try_process: srv_raw_try_process::<REQ_BUF, REPLY_BUF>,
9107 has_data: srv_raw_has_data::<REQ_BUF, REPLY_BUF>,
9108 pre_sample: no_pre_sample,
9109 invocation: InvocationMode::OnNewData,
9110 drop_fn: drop_entry::<SrvRawEntry<REQ_BUF, REPLY_BUF>>,
9111 };
9112 self.emplace_entry(slot, meta, TraceName::Text(service_name));
9113 self.apply_node_default_sched(slot, node_id, None);
9114 Ok(self.handle_for(slot))
9115 }
9116
9117 // ========================================================================
9118 // Raw service client registration (Phase 82)
9119 // ========================================================================
9120
9121 /// Register a raw (untyped) service client with the default reply
9122 /// buffer size.
9123 ///
9124 /// The client is owned by the executor's arena. Each `spin_once`
9125 /// dispatch polls the in-flight reply slot via `take_response_raw`
9126 /// and fires the registered callback when the response arrives.
9127 /// Used by the C API thin wrapper — see Phase 82.
9128 pub fn register_service_client_raw(
9129 &mut self,
9130 service_name: &str,
9131 service_type: &str,
9132 service_hash: &str,
9133 callback: Option<RawResponseCallback>,
9134 context: *mut core::ffi::c_void,
9135 ) -> Result<HandleId, NodeError> {
9136 self.register_service_client_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }>(
9137 service_name,
9138 service_type,
9139 service_hash,
9140 QoSProfile::services_default(),
9141 callback,
9142 context,
9143 )
9144 }
9145
9146 /// Register a raw service client with a custom reply buffer size + QoS.
9147 ///
9148 /// `qos` applies to the client's request + reply endpoints (Phase 193.3b);
9149 /// defaults to [`QoSProfile::services_default`] via the convenience
9150 /// wrapper.
9151 #[allow(clippy::too_many_arguments)]
9152 pub fn register_service_client_raw_sized<const REPLY_BUF: usize>(
9153 &mut self,
9154 service_name: &str,
9155 service_type: &str,
9156 service_hash: &str,
9157 qos: QoSProfile,
9158 callback: Option<RawResponseCallback>,
9159 context: *mut core::ffi::c_void,
9160 ) -> Result<HandleId, NodeError> {
9161 self.register_service_client_raw_sized_inner::<REPLY_BUF>(
9162 None,
9163 service_name,
9164 service_type,
9165 service_hash,
9166 qos,
9167 callback,
9168 context,
9169 )
9170 }
9171
9172 /// Phase 104.C.3.3.a — Node-aware variant of
9173 /// [`Self::register_service_client_raw_sized`]. Routes the client
9174 /// creation through the named Node's session.
9175 #[allow(clippy::too_many_arguments)]
9176 pub fn register_service_client_raw_sized_on<const REPLY_BUF: usize>(
9177 &mut self,
9178 node_id: super::node_record::NodeId,
9179 service_name: &str,
9180 service_type: &str,
9181 service_hash: &str,
9182 qos: QoSProfile,
9183 callback: Option<RawResponseCallback>,
9184 context: *mut core::ffi::c_void,
9185 ) -> Result<HandleId, NodeError> {
9186 self.register_service_client_raw_sized_inner::<REPLY_BUF>(
9187 Some(node_id),
9188 service_name,
9189 service_type,
9190 service_hash,
9191 qos,
9192 callback,
9193 context,
9194 )
9195 }
9196
9197 #[allow(clippy::too_many_arguments)]
9198 fn register_service_client_raw_sized_inner<const REPLY_BUF: usize>(
9199 &mut self,
9200 node_id: Option<super::node_record::NodeId>,
9201 service_name: &str,
9202 service_type: &str,
9203 service_hash: &str,
9204 qos: QoSProfile,
9205 callback: Option<RawResponseCallback>,
9206 context: *mut core::ffi::c_void,
9207 ) -> Result<HandleId, NodeError> {
9208 let slot = self.next_entry_slot()?;
9209 let (node_name, ns, session_idx) = match node_id {
9210 Some(id) => {
9211 let r = self
9212 .nodes
9213 .get(id.index())
9214 .ok_or(NodeError::InvalidSchedContextBinding)?;
9215 (r.name.clone(), r.namespace.clone(), r.session_idx)
9216 }
9217 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
9218 };
9219 let mut info = ServiceInfo::new(service_name, service_type, service_hash)
9220 .with_domain(self.domain_id)
9221 .with_namespace(&ns);
9222 if !node_name.is_empty() {
9223 info = info.with_node_name(&node_name);
9224 }
9225 let handle = {
9226 let session = self
9227 .session_at_mut(session_idx)
9228 .ok_or(NodeError::BackendMismatch)?;
9229 // Phase 193.5 — validate against the backend's supported policies
9230 // (no silent downgrade); request/reply effectively requires RELIABLE.
9231 super::node::validate_qos_or_report(
9232 &qos,
9233 session.supported_qos_policies(),
9234 "client",
9235 service_name,
9236 )?;
9237 session
9238 .create_client(&info, qos)
9239 .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
9240 };
9241
9242 let offset = self.arena_alloc::<ServiceClientRawArenaEntry<REPLY_BUF>>()?;
9243 unsafe {
9244 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9245 let entry_ptr = arena_ptr.add(offset) as *mut ServiceClientRawArenaEntry<REPLY_BUF>;
9246 core::ptr::write(
9247 entry_ptr,
9248 ServiceClientRawArenaEntry {
9249 handle,
9250 reply_buffer: [0u8; REPLY_BUF],
9251 pending: false,
9252 reply_ready: core::sync::atomic::AtomicBool::new(false),
9253 callback,
9254 context,
9255 },
9256 );
9257 }
9258
9259 let meta = CallbackMeta {
9260 offset,
9261 kind: EntryKind::ServiceClient,
9262 arena_len: super::arena::ArenaLen::UNRECORDED,
9263 try_process: service_client_raw_try_process::<REPLY_BUF>,
9264 has_data: always_ready,
9265 pre_sample: no_pre_sample,
9266 invocation: InvocationMode::Always,
9267 drop_fn: drop_entry::<ServiceClientRawArenaEntry<REPLY_BUF>>,
9268 };
9269 self.emplace_entry(slot, meta, TraceName::Text(service_name));
9270 self.apply_node_default_sched(slot, node_id, None);
9271 Ok(self.handle_for(slot))
9272 }
9273
9274 /// RFC-0041 / Phase 239.1 — register a **typed callback** service client.
9275 /// The reply is eager-drained at `spin_once` and dispatched to `callback` as
9276 /// a deserialized `Svc::Reply`. Returns the scheduling [`HandleId`] and a
9277 /// `*mut` to the arena entry's send header (used to build the typed
9278 /// [`ServiceClientCallback`](super::handles::ServiceClientCallback)).
9279 #[allow(clippy::too_many_arguments)]
9280 pub(crate) fn register_service_client_callback<Svc, F, const REPLY_BUF: usize>(
9281 &mut self,
9282 node_id: Option<super::node_record::NodeId>,
9283 service_name: &str,
9284 service_type: &str,
9285 service_hash: &str,
9286 qos: QoSProfile,
9287 callback: F,
9288 ) -> Result<(HandleId, *mut ServiceClientSendHeader<REPLY_BUF>), NodeError>
9289 where
9290 Svc: nros_core::RosService + 'static,
9291 F: FnMut(&Svc::Reply) + 'static,
9292 {
9293 let slot = self.next_entry_slot()?;
9294 let (node_name, ns, session_idx) = match node_id {
9295 Some(id) => {
9296 let r = self
9297 .nodes
9298 .get(id.index())
9299 .ok_or(NodeError::InvalidSchedContextBinding)?;
9300 (r.name.clone(), r.namespace.clone(), r.session_idx)
9301 }
9302 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
9303 };
9304 let mut info = ServiceInfo::new(service_name, service_type, service_hash)
9305 .with_domain(self.domain_id)
9306 .with_namespace(&ns);
9307 if !node_name.is_empty() {
9308 info = info.with_node_name(&node_name);
9309 }
9310 let handle = {
9311 let session = self
9312 .session_at_mut(session_idx)
9313 .ok_or(NodeError::BackendMismatch)?;
9314 super::node::validate_qos_or_report(
9315 &qos,
9316 session.supported_qos_policies(),
9317 "client",
9318 service_name,
9319 )?;
9320 session
9321 .create_client(&info, qos)
9322 .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
9323 };
9324
9325 let offset = self.arena_alloc::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>()?;
9326 let hdr_ptr = unsafe {
9327 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9328 let entry_ptr =
9329 arena_ptr.add(offset) as *mut ServiceClientCallbackEntry<Svc, F, REPLY_BUF>;
9330 core::ptr::write(
9331 entry_ptr,
9332 ServiceClientCallbackEntry {
9333 hdr: ServiceClientSendHeader {
9334 handle,
9335 reply_buffer: [0u8; REPLY_BUF],
9336 pending: false,
9337 reply_ready: core::sync::atomic::AtomicBool::new(false),
9338 },
9339 callback,
9340 _phantom: core::marker::PhantomData,
9341 },
9342 );
9343 &mut (*entry_ptr).hdr as *mut ServiceClientSendHeader<REPLY_BUF>
9344 };
9345
9346 let meta = CallbackMeta {
9347 offset,
9348 kind: EntryKind::ServiceClient,
9349 arena_len: super::arena::ArenaLen::UNRECORDED,
9350 try_process: service_client_callback_try_process::<Svc, F, REPLY_BUF>,
9351 has_data: always_ready,
9352 pre_sample: no_pre_sample,
9353 invocation: InvocationMode::Always,
9354 drop_fn: drop_entry::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>,
9355 };
9356 self.emplace_entry(slot, meta, TraceName::Text(service_name));
9357 self.apply_node_default_sched(slot, node_id, None);
9358 Ok((self.handle_for(slot), hdr_ptr))
9359 }
9360
9361 // ========================================================================
9362 // Guard condition registration
9363 // ========================================================================
9364
9365 /// Register a guard condition with a callback, on no particular node.
9366 ///
9367 /// Returns both the [`HandleId`] for trigger configuration and a
9368 /// [`GuardCondition`] for triggering from other threads.
9369 ///
9370 /// **This is the ours-only PRIMITIVE, not the verb a user reaches for.**
9371 /// phase-417 W4.e settled that the NODE owns a guard condition in all three
9372 /// languages, so the spelling an application writes is
9373 /// [`Node::create_guard_condition`](crate::executor::Node::create_guard_condition)
9374 /// — which is this function with a node bound, so the guard inherits that
9375 /// node's `SchedContext` like every other entity the node creates. This one
9376 /// stays because the executor is what owns the arena the flag lives in, and
9377 /// because an image with no node at all (a bare `Executor::open` plus a
9378 /// wake source) still has to be able to make one.
9379 pub fn register_guard_condition<F>(
9380 &mut self,
9381 callback: F,
9382 ) -> Result<(HandleId, GuardCondition), NodeError>
9383 where
9384 F: FnMut() + 'static,
9385 {
9386 self.register_guard_condition_on(None, callback)
9387 }
9388
9389 /// The node-bound form of [`register_guard_condition`](Self::register_guard_condition)
9390 /// — phase-417 W4.e.
9391 ///
9392 /// `node_id == None` is the legacy nodeless path and behaves exactly as
9393 /// `register_guard_condition` always did. With a node, the registered slot
9394 /// takes that node's default `SchedContext`, which is what "the node owns
9395 /// it" has to mean for a callback that shares one executor with every other
9396 /// tier in the image (RFC-0047).
9397 pub fn register_guard_condition_on<F>(
9398 &mut self,
9399 node_id: Option<super::node_record::NodeId>,
9400 callback: F,
9401 ) -> Result<(HandleId, GuardCondition), NodeError>
9402 where
9403 F: FnMut() + 'static,
9404 {
9405 let slot = self.next_entry_slot()?;
9406 let offset = self.arena_alloc::<GuardConditionEntry<F>>()?;
9407
9408 unsafe {
9409 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9410 let entry_ptr = arena_ptr.add(offset) as *mut GuardConditionEntry<F>;
9411 core::ptr::write(
9412 entry_ptr,
9413 GuardConditionEntry {
9414 flag: portable_atomic::AtomicBool::new(false),
9415 callback,
9416 },
9417 );
9418
9419 // Create a handle pointing to the flag in the arena
9420 let flag_ptr = &(*entry_ptr).flag as *const portable_atomic::AtomicBool;
9421 #[allow(unused_mut)]
9422 let mut guard_handle = GuardCondition::new(flag_ptr);
9423 // Phase 124.B.5 — wire the wake callback so trigger()
9424 // also signals the executor's wake_cv.
9425 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
9426 {
9427 let ctx = self.wake_ctx_ptr();
9428 guard_handle.set_wake_cb(nros_rmw_runtime_wake_cb, ctx);
9429 self.has_cross_thread_waker = true;
9430 }
9431
9432 let meta = CallbackMeta {
9433 offset,
9434 kind: EntryKind::GuardCondition,
9435 arena_len: super::arena::ArenaLen::UNRECORDED,
9436 try_process: guard_try_process::<F>,
9437 has_data: guard_has_data::<F>,
9438 pre_sample: no_pre_sample,
9439 invocation: InvocationMode::OnNewData,
9440 drop_fn: drop_entry::<GuardConditionEntry<F>>,
9441 };
9442 self.emplace_entry(slot, meta, TraceName::Slot("guard", slot));
9443 self.apply_node_default_sched(slot, node_id, None);
9444
9445 Ok((self.handle_for(slot), guard_handle))
9446 }
9447 }
9448
9449 /// phase-467 Row 8 — a guard condition the BACKEND triggers when the ROS
9450 /// graph changes. Upstream's `rcl_node_get_graph_guard_condition`, under
9451 /// this project's guard-condition shape.
9452 ///
9453 /// **The verb is upstream's, the shape is ours, and the difference is
9454 /// stated rather than silent.** `rcl_node_get_graph_guard_condition`
9455 /// RETURNS a borrowed handle to a guard the node owns, which the caller
9456 /// then adds to a wait set. RFC-0018/0019 forbids returning an owned
9457 /// handle across the C seam, we decline wait sets (RFC-0002: one executor
9458 /// per RTOS task), and a guard condition here is created into CALLER
9459 /// storage with its callback bound at creation. So this CREATES one —
9460 /// exactly as [`register_guard_condition_on`] does — and differs from it
9461 /// only in who pulls the trigger.
9462 ///
9463 /// Order matters and is the reason this is one verb rather than two. The
9464 /// backend is asked whether it HAS a graph before a handle slot is
9465 /// consumed, so a caller on XRCE or uORB gets `Unsupported` and an
9466 /// unchanged executor, never a guard condition nothing will ever fire.
9467 ///
9468 /// [`register_guard_condition_on`]: Self::register_guard_condition_on
9469 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
9470 pub fn register_graph_change_guard_on<F>(
9471 &mut self,
9472 node_id: Option<super::node_record::NodeId>,
9473 callback: F,
9474 ) -> Result<(HandleId, GuardCondition), NodeError>
9475 where
9476 F: FnMut() + 'static,
9477 {
9478 // The probe FIRST — see the doc comment. `supports_*` asks by
9479 // clearing, which is idempotent and costs nothing.
9480 if !self.supports_graph_change_callback() {
9481 return Err(NodeError::Transport(TransportError::Unsupported));
9482 }
9483 let (handle_id, guard) = self.register_guard_condition_on(node_id, callback)?;
9484 self.install_graph_change_guard(&guard)?;
9485 Ok((handle_id, guard))
9486 }
9487
9488 /// The nodeless form of
9489 /// [`register_graph_change_guard_on`](Self::register_graph_change_guard_on),
9490 /// for an image with a session and no node — the same pairing
9491 /// [`register_guard_condition`](Self::register_guard_condition) has.
9492 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
9493 pub fn register_graph_change_guard<F>(
9494 &mut self,
9495 callback: F,
9496 ) -> Result<(HandleId, GuardCondition), NodeError>
9497 where
9498 F: FnMut() + 'static,
9499 {
9500 self.register_graph_change_guard_on(None, callback)
9501 }
9502
9503 /// The same two verbs on a build with no `rmw-cffi` backend seam, or no
9504 /// allocator — they REFUSE rather than disappear.
9505 ///
9506 /// The cfg'd-out spelling was the first shape and it was wrong in a way
9507 /// `just check fast` cannot see: `rclc_node_get_graph_guard_condition` is
9508 /// a C ABI symbol and calls this unconditionally, so a feature
9509 /// combination without the seam stopped `nros-c` from COMPILING
9510 /// (`check-build`'s `workspace-features`, which is the lane that unifies
9511 /// nothing and therefore sees each combination as a user would).
9512 ///
9513 /// Refusing is also the honest answer rather than a compile fix. The
9514 /// graph-change edge is installed on a SESSION through the backend seam;
9515 /// without that seam there is no backend to install it on, and the
9516 /// executor-owned target needs an allocator for the box the backend holds
9517 /// a pointer to. `Unsupported` is what a caller gets from a backend with
9518 /// no graph too, so the C entry point answers `RCL_RET_UNSUPPORTED` in
9519 /// both cases and a ported program sees one behaviour, not two.
9520 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
9521 pub fn register_graph_change_guard_on<F>(
9522 &mut self,
9523 node_id: Option<super::node_record::NodeId>,
9524 callback: F,
9525 ) -> Result<(HandleId, GuardCondition), NodeError>
9526 where
9527 F: FnMut() + 'static,
9528 {
9529 let _ = (node_id, callback);
9530 Err(NodeError::Transport(TransportError::Unsupported))
9531 }
9532
9533 /// The nodeless form on a build with no backend seam — see
9534 /// [`register_graph_change_guard_on`](Self::register_graph_change_guard_on).
9535 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
9536 pub fn register_graph_change_guard<F>(
9537 &mut self,
9538 callback: F,
9539 ) -> Result<(HandleId, GuardCondition), NodeError>
9540 where
9541 F: FnMut() + 'static,
9542 {
9543 self.register_graph_change_guard_on(None, callback)
9544 }
9545
9546 // ========================================================================
9547 // Timer control methods
9548 // ========================================================================
9549
9550 /// Cancel a timer. A cancelled timer will not fire but still accumulates
9551 /// elapsed time. The timer can be restarted with [`reset_timer()`](Self::reset_timer).
9552 pub fn cancel_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
9553 let meta = self
9554 .entries
9555 .get(self.live_slot(id))
9556 .and_then(|e| e.as_ref())
9557 .ok_or(NodeError::BufferTooSmall)?;
9558 if !matches!(meta.kind, EntryKind::Timer) {
9559 return Err(NodeError::BufferTooSmall);
9560 }
9561 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9562 // SAFETY: meta.offset points to a valid TimerEntry<F> which shares
9563 // layout with TimerHeader for its initial fields (both #[repr(C)]).
9564 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
9565 header.cancelled = true;
9566 Ok(())
9567 }
9568
9569 /// Reset a timer. Clears the cancelled state and resets the elapsed time
9570 /// to zero, so the timer starts a fresh period.
9571 pub fn reset_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
9572 let meta = self
9573 .entries
9574 .get(self.live_slot(id))
9575 .and_then(|e| e.as_ref())
9576 .ok_or(NodeError::BufferTooSmall)?;
9577 if !matches!(meta.kind, EntryKind::Timer) {
9578 return Err(NodeError::BufferTooSmall);
9579 }
9580 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9581 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
9582 header.cancelled = false;
9583 header.elapsed_us = 0;
9584 Ok(())
9585 }
9586
9587 /// Check if a timer is cancelled.
9588 pub fn timer_is_canceled(&self, id: HandleId) -> bool {
9589 let meta = match self
9590 .entries
9591 .get(self.live_slot(id))
9592 .and_then(|e| e.as_ref())
9593 {
9594 Some(m) if matches!(m.kind, EntryKind::Timer) => m,
9595 _ => return false,
9596 };
9597 let arena_ptr = self.arena.as_ptr() as *const u8;
9598 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9599 header.cancelled
9600 }
9601
9602 /// Get the period of a timer in milliseconds (truncated — see
9603 /// [`Self::timer_period_us`]), or `None` if the handle is not a
9604 /// valid timer.
9605 pub fn timer_period_ms(&self, id: HandleId) -> Option<u64> {
9606 let meta = self
9607 .entries
9608 .get(self.live_slot(id))
9609 .and_then(|e| e.as_ref())
9610 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9611 let arena_ptr = self.arena.as_ptr() as *const u8;
9612 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9613 Some(header.period_us / 1000)
9614 }
9615
9616 /// Get the period of a timer in microseconds, or `None` if the
9617 /// handle is not a valid timer.
9618 pub fn timer_period_us(&self, id: HandleId) -> Option<u64> {
9619 let meta = self
9620 .entries
9621 .get(self.live_slot(id))
9622 .and_then(|e| e.as_ref())
9623 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9624 let arena_ptr = self.arena.as_ptr() as *const u8;
9625 // SAFETY: same layout invariant as `timer_period_ms`.
9626 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9627 Some(header.period_us)
9628 }
9629
9630 /// Swap a registered timer's period, in microseconds, returning the one
9631 /// it had — or `None` if the handle is not a valid timer.
9632 ///
9633 /// `TimerHeader::period_us` is the number `arena::timer_try_process`
9634 /// compares `elapsed_us` against, so this writes the period the
9635 /// DISPATCHER honours rather than a second copy of it (RFC-0019). The C
9636 /// entry point `rcl_timer_exchange_period` also keeps
9637 /// `rcl_timer_t::period_ns` in step, because that field is the
9638 /// registration input a later `rclc_executor_add_timer` would re-read.
9639 ///
9640 /// `elapsed_us` is deliberately NOT rewound, which is rcl's own behaviour:
9641 /// `rcl_timer_exchange_period` swaps the period and touches nothing else,
9642 /// so a timer shortened below its accumulated elapsed time becomes ready
9643 /// at once and one lengthened past it simply waits longer. A caller that
9644 /// wants the new period to start fresh calls
9645 /// [`reset_timer`](Self::reset_timer) after it.
9646 ///
9647 /// phase-417 W5.c — the Rust half of `c:timer_exchange_period`, the last
9648 /// timer row the C ledger filed as `gap`. The answer lives here and only
9649 /// the spelling lives in the wrapper, the same split
9650 /// [`timer_elapsed_us`](Self::timer_elapsed_us) made.
9651 pub fn exchange_timer_period_us(&mut self, id: HandleId, new_period_us: u64) -> Option<u64> {
9652 let offset = self
9653 .entries
9654 .get(self.live_slot(id))
9655 .and_then(|e| e.as_ref())
9656 .filter(|m| matches!(m.kind, EntryKind::Timer))?
9657 .offset;
9658 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9659 // SAFETY: same layout invariant as `timer_period_us`.
9660 let header = unsafe { &mut *(arena_ptr.add(offset) as *mut TimerHeader) };
9661 let old_period_us = header.period_us;
9662 header.period_us = new_period_us;
9663 Some(old_period_us)
9664 }
9665
9666 /// Set a timer's overrun policy (issue #505). Timers default to
9667 /// [`TimerOverrunPolicy::Skip`]; switch to
9668 /// [`TimerOverrunPolicy::CatchUp`] for timers whose every activation
9669 /// is a unit of work that must not be lost.
9670 pub fn set_timer_overrun_policy(
9671 &mut self,
9672 id: HandleId,
9673 policy: TimerOverrunPolicy,
9674 ) -> Result<(), NodeError> {
9675 let meta = self
9676 .entries
9677 .get(self.live_slot(id))
9678 .and_then(|e| e.as_ref())
9679 .ok_or(NodeError::BufferTooSmall)?;
9680 if !matches!(meta.kind, EntryKind::Timer) {
9681 return Err(NodeError::BufferTooSmall);
9682 }
9683 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9684 // SAFETY: same layout invariant as `cancel_timer`.
9685 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
9686 header.overrun_policy = policy;
9687 Ok(())
9688 }
9689
9690 /// Periods this timer missed and dropped under
9691 /// [`TimerOverrunPolicy::Skip`] (issue #505), or `None` if the handle
9692 /// is not a valid timer.
9693 ///
9694 /// Monotonic and saturating. A growing count is the on-target signal
9695 /// that a tier is not keeping up with its declared cadence — the
9696 /// symptom an external observer would otherwise have to infer by
9697 /// differencing timestamps.
9698 pub fn timer_overruns(&self, id: HandleId) -> Option<u32> {
9699 let meta = self
9700 .entries
9701 .get(self.live_slot(id))
9702 .and_then(|e| e.as_ref())
9703 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9704 let arena_ptr = self.arena.as_ptr() as *const u8;
9705 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9706 Some(header.overruns)
9707 }
9708
9709 /// Microseconds accumulated since this timer last fired, or `None` if the
9710 /// handle is not a valid timer.
9711 ///
9712 /// This is `TimerHeader::elapsed_us` — the SAME counter
9713 /// `arena::timer_try_process` compares against the period and rewinds on
9714 /// every activation, so it is the executor's own answer rather than a
9715 /// second one derived from a clock. `None` is not "zero elapsed": a caller
9716 /// that cannot tell them apart reports a timer that has just fired and a
9717 /// handle that is not a timer identically.
9718 ///
9719 /// phase-417 W5.c — added because the state was already here and nothing
9720 /// exposed it, which is why `c:timer_get_time_since_last_call` and
9721 /// `c:timer_is_ready` were both filed as gaps. The C forwarders
9722 /// (`rcl_timer_get_time_since_last_call`, `nros_timer_is_ready`) read
9723 /// this; RFC-0019 puts the answer in Rust and only the spelling in the
9724 /// wrapper.
9725 pub fn timer_elapsed_us(&self, id: HandleId) -> Option<u64> {
9726 let meta = self
9727 .entries
9728 .get(self.live_slot(id))
9729 .and_then(|e| e.as_ref())
9730 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9731 let arena_ptr = self.arena.as_ptr() as *const u8;
9732 // SAFETY: same layout invariant as `timer_period_us`.
9733 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9734 Some(header.elapsed_us)
9735 }
9736
9737 /// Would this timer fire on the next dispatch pass? `None` if the handle
9738 /// is not a valid timer.
9739 ///
9740 /// The predicate is `arena::timer_try_process`'s own guard, in the same
9741 /// order: a cancelled timer is never ready, a one-shot that already fired
9742 /// is never ready again, and otherwise readiness is `elapsed >= period`.
9743 /// Keeping the two in one shape is the point — a readiness answer that
9744 /// disagrees with the dispatcher is worse than no answer at all.
9745 pub fn timer_is_ready(&self, id: HandleId) -> Option<bool> {
9746 let meta = self
9747 .entries
9748 .get(self.live_slot(id))
9749 .and_then(|e| e.as_ref())
9750 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
9751 let arena_ptr = self.arena.as_ptr() as *const u8;
9752 // SAFETY: same layout invariant as `timer_period_us`.
9753 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
9754 if header.cancelled || (header.oneshot && header.fired) {
9755 return Some(false);
9756 }
9757 Some(header.elapsed_us >= header.period_us)
9758 }
9759
9760 /// Nanoseconds until this timer next fires — NEGATIVE when it is overdue.
9761 /// `None` if the handle is not a valid timer.
9762 ///
9763 /// phase-417 G6. THE computation, and it has to be: rcl's own pair are
9764 /// `next_call_time` and `next_call_time - now`, so upstream cannot have
9765 /// them disagree either. Before this it lived in `nros-c`
9766 /// (`remaining_period_ns` + `time_until_next_call_ns_of`), and the C++
9767 /// half of `cpp:Timer::time_until_trigger` would have been a second
9768 /// derivation of the same subtraction in a second crate — the shape
9769 /// CLAUDE.md's "fix the CLASS, add ONE shared helper rather than a second
9770 /// spelling" rule names. `nros-c` forwards here now; so does `nros-cpp`.
9771 ///
9772 /// **The unit is nanoseconds and the RESOLUTION is microseconds.** The
9773 /// arena's timer accounting is microsecond-based (issue #505), so this is a
9774 /// microsecond quantity scaled by 1000. The unit is rcl's; the resolution
9775 /// is ours.
9776 ///
9777 /// Saturating rather than wrapping: `period_us` and `elapsed_us` are both
9778 /// `u64`, so their difference in nanoseconds can leave `i64` in either
9779 /// direction, and a wrap would turn "overdue by a lot" into "due in a
9780 /// long time" — the sign is the whole answer here.
9781 pub fn timer_time_until_next_call_ns(&self, id: HandleId) -> Option<i64> {
9782 let period_us = self.timer_period_us(id)?;
9783 let elapsed_us = self.timer_elapsed_us(id)?;
9784 let delta_ns = (period_us as i128 - elapsed_us as i128).saturating_mul(1_000);
9785 Some(if delta_ns > i64::MAX as i128 {
9786 i64::MAX
9787 } else if delta_ns < i64::MIN as i128 {
9788 i64::MIN
9789 } else {
9790 delta_ns as i64
9791 })
9792 }
9793
9794 // ========================================================================
9795 // spin_once (three-phase: readiness -> trigger -> dispatch)
9796 // ========================================================================
9797
9798 /// Drive I/O and dispatch registered callbacks once.
9799 ///
9800 /// Three-phase execution:
9801 /// 1. **Readiness scan** — query each handle's `has_data()`.
9802 /// 2. **Trigger evaluation** — check if the executor-level trigger passes.
9803 /// 3. **Dispatch** — invoke callbacks according to their `InvocationMode`.
9804 ///
9805 /// Returns a [`SpinOnceResult`] with counts of processed items and errors.
9806 ///
9807 /// # Arguments
9808 /// * `timeout` — upper bound on the I/O wait. Saturated at
9809 /// `i32::MAX` ms (~24 days) for the underlying transport call.
9810 ///
9811 /// Phase 84.D7: unified on `core::time::Duration`. The previous
9812 /// `timeout_ms: i32` signature had a latent footgun where
9813 /// `spin_once(-1)` silently froze timers while still polling I/O;
9814 /// `Duration` has no negative sentinel.
9815 /// Consecutive `drive_io` failures on the primary session (issue 0324).
9816 ///
9817 /// `0` means the last drive succeeded. A value that keeps climbing across
9818 /// spins is a session that is no longer doing I/O — router gone, lease
9819 /// expired, socket closed — which otherwise presents as a node that spins
9820 /// `Ok(())` forever while publishing nowhere and firing no callbacks.
9821 ///
9822 /// Transient failures happen, so a single non-zero reading is not a fault;
9823 /// a threshold (say, "more than a few consecutive spins") is the useful
9824 /// signal. Extra (bridge / multi-domain) sessions are best-effort and are
9825 /// deliberately NOT counted here.
9826 pub fn session_io_failures(&self) -> u32 {
9827 self.consecutive_io_failures
9828 }
9829
9830 /// Whether the primary session drove I/O successfully on the last spin.
9831 ///
9832 /// Convenience over [`Self::session_io_failures`] for the common
9833 /// "is my transport alive?" check.
9834 pub fn session_io_healthy(&self) -> bool {
9835 self.consecutive_io_failures == 0
9836 }
9837
9838 /// Drive I/O once, then dispatch EVERY callback that is ready — the tick
9839 /// primitive the rest of the family is built from.
9840 ///
9841 /// OURS-ONLY (RFC-0089): rclcpp's `spin_once` executes the NEXT ready item
9842 /// and has no timeout; rclpy's is `spin_once(node, timeout_sec)`. Ours is a
9843 /// blocking wait with a budget, which an RTOS task that must not spin-poll
9844 /// needs and which neither upstream has a verb for. What it DOES when work
9845 /// is ready is rclcpp's `spin_some` — the whole ready set, one pass
9846 /// (RFC-0002 §3) — so [`spin_some`](Self::spin_some) is the drain verb and
9847 /// this is the waiting one.
9848 ///
9849 /// `timeout` is the upper bound on the I/O wait. Since phase-436 W2 it is
9850 /// carried in MICROSECONDS all the way to the park (issue 1193 — an
9851 /// `as_millis()` here truncated `from_micros(500)` to `0`, and a zero
9852 /// timeout selects the non-blocking path, so a sub-millisecond `spin()`
9853 /// was a 100 % CPU loop with no warning); the conversion to whatever the
9854 /// platform primitive accepts happens once, at the park, and rounds UP.
9855 /// `Duration` has no negative sentinel, which is what the pre-phase-84
9856 /// `timeout_ms: i32` signature used to silently mean "freeze the timers
9857 /// and poll I/O forever".
9858 pub fn spin_once(&mut self, timeout: core::time::Duration) -> SpinOnceResult {
9859 let mut discarded = None;
9860 self.spin_once_capturing(timeout, &mut discarded)
9861 }
9862
9863 /// [`spin_once`](Self::spin_once), plus the FIRST failing callback's
9864 /// error.
9865 ///
9866 /// phase-427 — [`SpinOnceResult`] counts errors and drops their values,
9867 /// and `SpinOnceResult` is `Copy`, so widening it to carry a
9868 /// (non-`Copy`, `alloc`-variant-bearing) [`TransportError`] would change a
9869 /// public type every consumer copies. The value is needed in exactly one
9870 /// place — `SpinOptions::stop_on_first_error`, where `spin` has to return
9871 /// the first error rather than a count — so it is threaded out through an
9872 /// out-parameter that lives in the caller's frame. Nothing is added to
9873 /// `Executor`, so a no-alloc image pays nothing for it.
9874 ///
9875 /// `first_error` is written at most once: subsequent failures in the same
9876 /// pass are counted in the result and their values dropped, exactly as
9877 /// before.
9878 fn spin_once_capturing(
9879 &mut self,
9880 timeout: core::time::Duration,
9881 first_error: &mut Option<TransportError>,
9882 ) -> SpinOnceResult {
9883 // Issue 1705 — releases deferred behind a dispatch that has since
9884 // finished (an OS-priority worker's) are completed here, before this
9885 // pass looks at the entries.
9886 self.reap_deferred_releases();
9887 // phase-436 W2 (issue 1193) — the budget is carried in MICROSECONDS.
9888 // This was `as_millis()`, which truncates: `from_micros(500)` became
9889 // `0`, and a zero timeout selects the non-blocking path — so a
9890 // sub-millisecond `spin()` was a 100 % CPU loop with no warning. The
9891 // conversion to whatever the platform primitive accepts happens once,
9892 // at the park, and rounds UP. The budget itself is `grid_budget_us`
9893 // below (phase-474 I11).
9894
9895 // phase-425 W3b — bring the `/clock` subscription in line with
9896 // `use_sim_time`. One bool comparison in the settled case; the work only
9897 // happens on a transition, or while a request is waiting for its first
9898 // node to exist.
9899 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
9900 self.reconcile_ros_time_source();
9901
9902 // phase-426 W3 — attach the six parameter services to any node that
9903 // does not have them yet. Same reason as the reconcile above: the
9904 // request routinely arrives before there is a node to publish under
9905 // (`nros::main!` emits `apply_param_services` ahead of its per-node
9906 // `register` calls, by design). One length comparison once settled.
9907 //
9908 // Issue 1271 -- a failure is LOGGED, once, rather than discarded. On
9909 // Cyclone this fails on every spin with `Transport(Unsupported)`
9910 // (issue 1268), and with the result dropped "this node has no
9911 // parameter services" was a fact nothing on the image stated.
9912 #[cfg(feature = "param-services")]
9913 if self.parameter_services_pending()
9914 && let Err(e) = self.reconcile_parameter_services()
9915 {
9916 self.report_parameter_service_reconcile_failure(e);
9917 }
9918
9919 // Release jitter, measured HERE rather than in `spin_period`, because
9920 // this is the function every driver goes through.
9921 //
9922 // The first version instrumented `spin_period` only, and that was the
9923 // same mistake issue 0736 records one layer down: a measurement placed
9924 // on a path no shipped image takes. `nros-cpp`'s tier trampoline paces
9925 // itself with a `spin_once` loop at `spin_period_us` (see its
9926 // `run_components` docs, step 4), so `spin_period` is not what the C++
9927 // entry -- and therefore not what the Zephyr lane -- actually calls.
9928 // The field recorded zero forever while claiming to measure cadence.
9929 //
9930 // `timeout` is the caller's intended pacing quantum: `spin_period`
9931 // passes its period, and the generated entries pass 10 ms. phase-474
9932 // I11 -- with no declared cadence it is the period of an absolute
9933 // release grid, and the park budget is the time to the next grid
9934 // point (see `record_release_jitter`); the release is judged after the
9935 // park, against the grid (`record_grid_release`). A zero timeout means
9936 // "poll, no cadence claimed", so it is neither judged nor gridded.
9937 let grid_budget_us = self.record_release_jitter(timeout);
9938
9939 // issue 0900 — one-shot advisory when the arena is far larger than what
9940 // registered. First spin rather than "end of registration", because
9941 // there is no such point: an app may register lazily.
9942 super::arena::maybe_report_arena_headroom(self.arena_used, self.arena.len());
9943
9944 // phase-412 -- the same moment, recorded where a board with no log sink
9945 // can still be asked. Reaching this stage is the positive result the
9946 // advisory above cannot deliver on the island: registration completed.
9947 crate::boot_report::checkpoint(crate::boot_report::Stage::FirstSpin);
9948
9949 // Phase 110.0 — the backend's next internal-event deadline (lease
9950 // keepalive, heartbeat, ACK-NACK timeout, ...). Default backend impl
9951 // returns `None`, so this contributes nothing unless the active
9952 // backend opts in.
9953 //
9954 // phase-436 W6 — a CANDIDATE now, not a pre-cap. Folding it into the
9955 // budget here meant a park the session won was attributed to
9956 // `CallerBudget`, so the one number that explains a wake named the
9957 // wrong source.
9958 let session_us = self
9959 .session
9960 .next_deadline_ms()
9961 .map(|next| (next as u64).saturating_mul(1000));
9962
9963 // phase-436 W1 (issue 1192) — and by the next TIMER deadline, which is
9964 // the one class of deadline the executor itself owns. Before this the
9965 // park was bounded by the caller's budget narrowed only by the
9966 // BACKEND's next event, so `spin_default`'s 50 ms slept straight past
9967 // a registered 10 ms timer.
9968 //
9969 // Attribution is recorded here rather than derived later: this is the
9970 // only point where the losing candidates are still in hand.
9971 let (park_bound_us, park_source) =
9972 self.next_wake_bound_attributed_us(grid_budget_us, session_us);
9973 self.last_park_bound_us = park_bound_us;
9974 self.last_park_source = park_source;
9975 // phase-436 W2 (issue 1193) — round UP to what the primitive can
9976 // express. The old `as_millis()` truncated, so a sub-millisecond
9977 // request became a zero park: the non-blocking path, a busy loop.
9978 let park_achieved_us = self.round_park_up_us(park_bound_us);
9979 self.last_park_achieved_us = park_achieved_us;
9980
9981 // phase-436 W6 — a port that installed a park primitive does its
9982 // waiting HERE, on the bound every source agreed on, and the transport
9983 // is then drained non-blockingly.
9984 //
9985 // This is the path bare metal needs: `park_until(deadline)` can arm a
9986 // timer compare before `wfi`, which `sleep(duration)` cannot express —
9987 // and `nros-baremetal-common` notes that `wfi` with no pending
9988 // interrupt deadlocks, so arming first is not optional there.
9989 //
9990 // Additive by construction: with no primitive installed this is a
9991 // no-op and every existing port keeps the behaviour it has.
9992 // phase-436 — park only when something can END the park early. A
9993 // park ends on the bound or on the wake object; with a poll-only
9994 // backend (XRCE), or a session whose wake callback is gone, nothing
9995 // signals that object, so the park would be a blind sleep and data
9996 // arriving mid-park would wait out the whole bound — the Phase
9997 // 127.C.4 stall (a reliable stream whose ACK lands in a session nobody
9998 // drives). Those executors keep the receive-blocking wait below.
9999 let park_can_be_woken = self.park_can_be_woken();
10000 let parked = match self.park_primitive {
10001 Some((park, ctx)) if park_achieved_us > 0 && park_can_be_woken => {
10002 // SAFETY: `park` and `ctx` were supplied together by the port
10003 // through `set_park_primitive` and outlive the executor by its
10004 // contract.
10005 //
10006 // phase-436 — a negative answer is "cannot park": the ABI's
10007 // own contract, and what a Rust-implemented port's trait
10008 // default returns at once. Counting that as a park would
10009 // drop the drain below to non-blocking and spin hot, so the
10010 // spin falls back to the wait it would have had uninstalled.
10011 (unsafe { park(ctx, park_achieved_us) }) >= 0
10012 }
10013 _ => false,
10014 };
10015
10016 // Having already waited, the transport drain must not block again —
10017 // otherwise the park and the drive would sum, and the cadence would be
10018 // up to twice what was asked. Same trap as passing a period to
10019 // `spin_once` instead of declaring it (#648).
10020 let timeout_ms = if parked {
10021 0
10022 } else {
10023 (park_achieved_us / 1000).min(i32::MAX as u64) as i32
10024 };
10025
10026 // Wall-clock-accurate timer accumulation. Measure real time
10027 // since the previous `spin_once` exited (or, on the first call,
10028 // since `drive_io` started). Two failure modes the requested
10029 // `timeout_ms` doesn't capture:
10030 // 1. `drive_io` returns early — e.g. zenoh-pico's condvar wakes
10031 // on data arrival, well under 1 ms.
10032 // 2. The caller spends time outside `spin_once` (explicit sleep,
10033 // ROS-2 cooperative scheduling, etc.) and that time should
10034 // still count toward timers.
10035 // Crediting the requested timeout to timers in either case ticks
10036 // them faster than wall-clock — observed as a 30 Hz control loop
10037 // overshooting to >200 Hz under sustained traffic. Carry the
10038 // sub-ms remainder across calls so precision is preserved.
10039 // phase-359 W4 — the spin-ENTRY read, one spelling. Kept distinct from
10040 // the post-IO read below because the first spin's delta is measured from
10041 // entry; collapsing them would make that delta 0 on the first call only.
10042 let spin_start_us = self.now_us();
10043
10044 // phase-474 I2 -- arm the monitors when the policy says start-up is over,
10045 // before any rule of this spin can judge.
10046 self.update_monitor_arming(spin_start_us);
10047
10048 // RFC-0052 W3b.4 — contract monitors tick once per spin (window
10049 // logic inside; single branch when the baked table is empty).
10050 self.run_contract_monitors();
10051
10052 // Phase 104.C.6 — shared executor wake. Swap-and-clear the
10053 // wake flag; if it was set before this `spin_once` entered,
10054 // skip the blocking wait on the primary session and poll
10055 // every session non-blockingly. Lets a wake signal from any
10056 // thread (or, post-104.C.6.b, any backend's vtable hook)
10057 // pre-empt whichever session the executor would otherwise
10058 // sleep on. Cost on the no-wake path is one atomic swap.
10059 // phase-359 W10 — ONE swap, at `alloc` scope.
10060 //
10061 // This was `#[cfg(feature = "std")]` while the alloc wait arm did its
10062 // own swap further down. Merging the two arms made that two swaps of
10063 // one flag per spin, the first consuming what the second tested, so the
10064 // arm would have read `was_woken == false` forever. Deleting the outer
10065 // one instead broke a different thing: `wake_flag` is `alloc`-gated and
10066 // "spin_once consumes the wake flag" is an invariant of every alloc
10067 // build, but the merged arm only runs with `rmw-cffi` — so a
10068 // `std`-without-`rmw-cffi` build stopped clearing it, which
10069 // `test_wake_cleared_each_spin` catches. It belongs here, at the scope
10070 // of the field it reads.
10071 #[cfg(feature = "alloc")]
10072 #[allow(unused_variables)]
10073 let was_woken = self
10074 .wake_flag
10075 .swap(false, portable_atomic::Ordering::SeqCst);
10076
10077 // Phase 124.B.4 — condvar-blocked wait.
10078 //
10079 // RT contract:
10080 // * cv.wait_timeout_while: bounded by `timeout_ms`.
10081 // Predicate is O(1) — one atomic swap + Instant::now.
10082 // No allocation. PI-mutex consideration: wake_mu held
10083 // only during predicate check (microseconds);
10084 // contended worst-case = notify_all execution time
10085 // (~10s of µs).
10086 // * Backend's `set_wake_callback`-installed cb is called
10087 // on async data arrival from its transport-notify path
10088 // (worker thread, ISR-safe variant via 124.B.7). The
10089 // runtime cb writes wake_flag + signals wake_cv,
10090 // unblocking this loop sub-poll-period.
10091 // * Poll-only backends (XRCE, bare-metal) leave the slot
10092 // NULL; the cv wait still fires on its deadline, then
10093 // drive_io(0) drains whatever the backend's internal
10094 // poll has buffered. Equivalent to their pre-124
10095 // behaviour minus the blocking wait inside drive_io.
10096 //
10097 // Lost-wakeup safe: SeqCst flag write happens-before
10098 // notify, and the waiter checks the flag under wake_mu in
10099 // the predicate. If wake fires between drain and cv.wait
10100 // entry, the predicate sees flag=true on first eval and
10101 // exits immediately.
10102 // Phase 130.4 — only sleep in the wake wait when a backend actually
10103 // installed `set_wake_callback`. Poll-only backends (XRCE) leave the
10104 // vtable slot NULL → `has_async_wake == false` →
10105 // drive_io for the caller's full timeout instead of sleeping in a
10106 // never-signaled wait that starves reliable retransmission (Phase
10107 // 127.C.4 root cause: the server's `send_response` flushes 100 ms once,
10108 // then a blind `wait_ms(100)` sleeps with zero session activity, so the
10109 // agent's ACK arrives into a stalled session and reliable redelivery
10110 // never fires).
10111 //
10112 // Phase 248 (C2) — which primitive is a RUNTIME choice from the
10113 // platform vtable's wake probe, not a compile-time per-RTOS `cfg`:
10114 // `node_wake.is_some()` means `nros_platform_wake_*` is linked and
10115 // `nros_rmw_runtime_wake_cb` signals it on transport arrival.
10116 // phase-359 W10 — ONE arm. This was a `std` arm and an `alloc` arm with
10117 // the same body: block on the platform wake primitive when a backend
10118 // installed the callback and nothing has woken us yet, else drive the
10119 // transport for the caller's full timeout. They stopped differing when
10120 // the condvar fallback was deleted — the std arm's `else` branch had
10121 // already become "drive for the full timeout", which is what the alloc
10122 // arm always did — so keeping two was keeping a fork that agreed.
10123 // phase-417 W4.e — the FIRST arm is what the swap-and-clear above says
10124 // it is for, and it was missing: a wake that landed BETWEEN two spins
10125 // set the flag, and this expression then took the `else` and drove the
10126 // transport for the caller's FULL timeout, so the pending work waited
10127 // out the very budget the wake exists to cut short. Measured with a
10128 // guard condition triggered from another thread just before a 400 ms
10129 // spin: 400 ms to dispatch, against 50 ms for the same trigger issued
10130 // while the spin was already parked in `wait_ms`. Being woken means
10131 // there IS work, so the right timeout is 0 — poll every session
10132 // non-blockingly and go dispatch it, which is what the comment at the
10133 // swap has claimed since Phase 104.C.6.
10134 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
10135 let primary_drive_timeout_ms = {
10136 if was_woken {
10137 // Issue 1385 — drain the wake PRIMITIVE too.
10138 //
10139 // `nros_rmw_runtime_wake_cb` sets both halves of one signal:
10140 // the flag, and the platform primitive. The swap above
10141 // consumed the flag and this arm returns without entering the
10142 // wait, so a primitive left posted makes the NEXT spin's
10143 // `wait_ms` return instantly on a signal already acted on —
10144 // that spin does not wait at all, and whatever it should have
10145 // waited for is dispatched a whole spin late.
10146 //
10147 // `wait_ms(0)` is the non-blocking take: every port's wait is a
10148 // deadline (`sem_timedwait`, `k_sem_take`, `xSemaphoreTake`),
10149 // so zero means "take it if it is there", never "wait
10150 // forever".
10151 //
10152 // Measured, not reasoned: with the C executor installing a
10153 // callback (the other half of 1385), an arrival taken by this
10154 // arm left the spin after it returning in 0 ms where it owed
10155 // 60 ms of waiting — which is what `node_guard_condition.c`'s
10156 // mid-spin case caught, and what `executor_backend_wake.c`'s
10157 // second lower bound now pins. Latent until then only because
10158 // no C spin had ever entered the wait arm.
10159 if let Some(wake) = self.node_wake.as_ref() {
10160 let _ = wake.wait_ms(0);
10161 }
10162 0
10163 } else if self.async_wake_live()
10164 && let Some(wake) = self.node_wake.as_ref()
10165 {
10166 // phase-436 — after a park this wait has a zero timeout and
10167 // the park has already waited on this same object, so it is a
10168 // second syscall that can only time out. Measured on a native
10169 // tier: one extra futex wait per iteration, doubling the idle
10170 // context switches. A signal that lands between the park's
10171 // return and here stays posted and ends the next park at once,
10172 // which costs one spin and loses nothing.
10173 if !parked {
10174 let _ = wake.wait_ms(timeout_ms as u32);
10175 }
10176 // Drain any flag the cb set while we were waiting.
10177 let _ = self
10178 .wake_flag
10179 .swap(false, portable_atomic::Ordering::SeqCst);
10180 0
10181 } else {
10182 // No wake primitive linked, or a poll-only backend: drive the
10183 // transport for the full timeout. It still BLOCKS — in the
10184 // transport's own recv — which is where every flavour ended up
10185 // anyway.
10186 timeout_ms
10187 }
10188 };
10189
10190 // std builds without rmw-cffi (mock-session tests, future alternative
10191 // backends) keep the original "drive_io is non-blocking" assumption.
10192 // `std` implies `alloc`, so this is the only std configuration the arm
10193 // above does not cover.
10194 #[cfg(all(feature = "std", not(feature = "rmw-cffi")))]
10195 let primary_drive_timeout_ms = 0;
10196
10197 // no_std without (alloc + rmw-cffi) keeps the legacy
10198 // full-timeout drive_io call.
10199 #[cfg(all(
10200 not(feature = "std"),
10201 not(all(feature = "alloc", feature = "rmw-cffi"))
10202 ))]
10203 let primary_drive_timeout_ms = timeout_ms;
10204
10205 // issue 0324 — the primary session's I/O result is TRACKED, not
10206 // discarded. See `consecutive_io_failures` for why this is a counter
10207 // rather than an early return.
10208 match self.drive_primary(primary_drive_timeout_ms) {
10209 Ok(()) => self.consecutive_io_failures = 0,
10210 Err(_) => self.consecutive_io_failures = self.consecutive_io_failures.saturating_add(1),
10211 }
10212 for extra in self.extra_sessions.iter_mut() {
10213 // Best-effort BY DESIGN: extra sessions are bridge / multi-domain
10214 // attachments, and one of them failing must not stall the primary
10215 // spin. The old code expressed this the same way it expressed the
10216 // primary's discarded error — identically — so the intent was
10217 // invisible. It is now the only `let _ =` of the two.
10218 let _ = extra.drive_io(0);
10219 }
10220
10221 // phase-359 W4 — ONE clock read per spin, shared by the delta below and
10222 // every consumer further down. Replaces the std/no_std delta pair AND
10223 // two ad-hoc `static EPOCH: OnceLock<Instant>` blocks that each kept
10224 // their own std-only epoch.
10225 let now_us_this_spin = self.now_us();
10226 // phase-474 I11 -- the release instant is the end of the park, so it
10227 // is judged on this read, before any dispatch of this spin.
10228 if let Some(now) = now_us_this_spin {
10229 self.record_grid_release(now);
10230 }
10231
10232 // Same rule on both flavours: measure elapsed when a clock exists, else
10233 // credit the REQUESTED timeout. That fallback was previously reachable
10234 // only on no_std; on std `Instant` always answered. It stays unreachable
10235 // on std for the same reason — `now_us()` is infallible there — so this
10236 // is one expression, not a behaviour change.
10237 let delta_us = match now_us_this_spin {
10238 Some(now) => {
10239 let prev = self
10240 .last_spin_end_us
10241 .unwrap_or_else(|| spin_start_us.unwrap_or(now));
10242 self.last_spin_end_us = Some(now);
10243 now.saturating_sub(prev)
10244 }
10245 None => (timeout_ms as u64).saturating_mul(1000),
10246 };
10247
10248 // phase-430 W1 — the silence watch is credited the SAME `delta_us` the
10249 // wall timers below are, which is the whole of its unit argument: the
10250 // diagnostic arrives after as much time as this image's own wall timers
10251 // believe has passed, on whatever clock it actually has.
10252 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
10253 self.note_sim_time_silence(delta_us);
10254
10255 if !self.spin_quantization_checked && timeout_ms > 0 {
10256 self.spin_quantization_checked = true;
10257 // The TIER's declared cadence, not the capped park bound. Since
10258 // phase-436 W1 the two differ whenever a timer is due sooner than
10259 // the budget, and auditing the cap would compare the timer period
10260 // against itself — an exact multiple every time, so issue 0515's
10261 // warning would go silent on precisely the images that have a
10262 // timer to quantize. `spin_nominal_us` is the declared cadence
10263 // (`set_spin_nominal_us`, else the caller's timeout), recorded at
10264 // the top of this spin before any capping.
10265 self.spin_quantization_us = self.spin_nominal_us;
10266 self.audit_spin_quantization(self.spin_quantization_us);
10267 }
10268
10269 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10270
10271 // Phase 1: Readiness scan (Phase 110.A.b — backed by FifoReadySet).
10272 //
10273 // `bits` carries data-readiness only (used by trigger eval +
10274 // by `InvocationMode::OnNewData`). `always_mask` carries the
10275 // `InvocationMode::Always` entries that fire regardless of
10276 // data presence. The dispatcher drains
10277 // `FifoReadySet(bits | always_mask)` after the trigger
10278 // passes; `pop_next` yields registration order (lowest bit
10279 // first) so behavior is bit-identical to the pre-refactor
10280 // `for (i, meta) in entries.iter().enumerate()` loop.
10281 let mut bits: u64 = 0;
10282 let mut count: usize = 0;
10283 let mut non_timer_mask: u64 = 0;
10284 let mut always_mask: u64 = 0;
10285
10286 for (i, meta) in self.entries.iter().enumerate() {
10287 if let Some(meta) = meta
10288 && !Self::slot_held(self.slot_tags, i)
10289 {
10290 let data_ptr = unsafe { arena_ptr.add(meta.offset) as *const u8 };
10291 if unsafe { (meta.has_data)(data_ptr) } {
10292 bits |= 1u64 << i;
10293 }
10294 if !matches!(meta.kind, EntryKind::Timer | EntryKind::GuardCondition) {
10295 non_timer_mask |= 1u64 << i;
10296 }
10297 if matches!(meta.invocation, InvocationMode::Always) {
10298 always_mask |= 1u64 << i;
10299 }
10300 count += 1;
10301 }
10302 }
10303
10304 let snapshot = ReadinessSnapshot { bits, count };
10305
10306 // Phase 2: Trigger evaluation
10307 let trigger_passes = match &self.trigger {
10308 Trigger::Any => bits & non_timer_mask != 0 || non_timer_mask == 0,
10309 Trigger::All => bits & non_timer_mask == non_timer_mask,
10310 Trigger::One(id) => snapshot.is_ready(*id),
10311 Trigger::AllOf(set) => snapshot.all_ready(*set),
10312 Trigger::AnyOf(set) => snapshot.any_ready(*set),
10313 Trigger::Always => true,
10314 Trigger::Predicate(f) => f(&snapshot),
10315 Trigger::RawPredicate { callback, context } => {
10316 // Convert ReadinessSnapshot bitmask to a bool array for the C callback
10317 let mut ready_array = [false; 64];
10318 for (i, slot) in ready_array
10319 .iter_mut()
10320 .enumerate()
10321 .take(snapshot.count.min(64))
10322 {
10323 *slot = snapshot.bits & (1u64 << i) != 0;
10324 }
10325 // SAFETY: The callback and context are provided by the C API caller.
10326 // The ready_array is valid for snapshot.count elements.
10327 unsafe { callback(ready_array.as_ptr(), snapshot.count, *context) }
10328 }
10329 };
10330
10331 if !trigger_passes {
10332 // Timers still need delta accumulation even when trigger doesn't pass
10333 //
10334 // phase-8 — `.enumerate()` (rather than a bare `.flatten()`) because
10335 // `try_process` now carries the entry's slot index for the callback
10336 // trace hooks. This sweep FIRES timer callbacks, so it is a real
10337 // dispatch path and must attribute them like the drain below does.
10338 //
10339 // phase-474 I6 -- and it must TIME them like the drain does. This
10340 // is the path every timer takes on a node that also subscribes
10341 // (the default `Trigger::Any` fails whenever no subscription has
10342 // a sample), and it measured nothing, so `max-latency-runtime`
10343 // never judged a timer path: the safety island's 250 ms overrun of
10344 // a 206 ms path stored no verdict. One clock read before and one
10345 // after each timer's `try_process`, only when a latency contract
10346 // exists and a clock is injected; the publish-count snapshot is a
10347 // fixed array on the stack, nothing allocates. The measured span
10348 // is callback entry to exit, as in the drains; release jitter is
10349 // `release-jitter-runtime`'s.
10350 let mon_table = self.monitor_table;
10351 // phase10-B3 -- the route rule reads the same clock and snapshot:
10352 // a timer tick is where a sampled route's output is published.
10353 let lat_clock = self.clock_us_fn.filter(|_| {
10354 mon_table.iter().any(|m| m.max_latency_ms > 0)
10355 || super::monitor::route_active(mon_table)
10356 });
10357 for i in 0..self.entries.len() {
10358 let Some(meta) = self.entries[i].as_ref() else {
10359 continue;
10360 };
10361 if matches!(meta.kind, EntryKind::Timer) && !Self::slot_held(self.slot_tags, i) {
10362 let data_ptr = unsafe { arena_ptr.add(meta.offset) };
10363 let try_process = meta.try_process;
10364 let counts_before = snapshot_pub_counts(mon_table, lat_clock.is_some());
10365 let start_us = lat_clock.map(|c| c());
10366 Self::begin_dispatch(self.slot_tags, i);
10367 // phase-463 W7 -- the host profile times this sweep too:
10368 // it is the path every timer of a node that also
10369 // subscribes takes (see above), so missing it measured
10370 // the talker's timer at zero invocations.
10371 //
10372 // The OFF arm is the original `let _ =`, untouched, and
10373 // that is MEASURED rather than tidy: every binding tried
10374 // in its place (`let _p`, `let p; drop(p)`) moved
10375 // `spin_once_capturing` by 49-64 B in the Zephyr image
10376 // (phase-463 W5 I3c). Hence the second `unsafe` block,
10377 // recorded in the unsafe census.
10378 #[cfg(not(feature = "profile-mode"))]
10379 let _ = unsafe { try_process(data_ptr, delta_us, i as u8) };
10380 #[cfg(feature = "profile-mode")]
10381 {
10382 super::profile::dispatch_begin(arena_ptr as usize, i);
10383 let processed = unsafe { try_process(data_ptr, delta_us, i as u8) };
10384 super::profile::dispatch_end(matches!(processed, Ok(true)));
10385 }
10386 if let (Some(clock), Some(t0)) = (lat_clock, start_us) {
10387 let end_us = clock();
10388 let elapsed_us = end_us.saturating_sub(t0).min(u32::MAX as u64) as u32;
10389 attribute_latency(mon_table, true, &counts_before, elapsed_us);
10390 let hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
10391 let violations = &mut self.violations;
10392 super::monitor::route_judge(
10393 mon_table,
10394 &counts_before,
10395 end_us,
10396 |row, v, record, react| {
10397 if react {
10398 call_route_reaction(hook, mon_table, row, &v);
10399 }
10400 if record {
10401 violations.record(v)
10402 }
10403 },
10404 );
10405 }
10406 self.finish_dispatch(i);
10407 }
10408 }
10409
10410 // Parameter services live outside the arena and must be processed
10411 // regardless of trigger state, otherwise ROS 2 param queries time out.
10412 #[cfg(feature = "param-store")]
10413 {
10414 #[allow(unused_mut)] // only the services add to it
10415 let mut handled = 0usize;
10416 #[cfg(feature = "param-services")]
10417 if let Some(params) = &mut self.params {
10418 {
10419 let crate::param_store::ParamState {
10420 server,
10421 services,
10422 buffers,
10423 staged,
10424 ..
10425 } = &mut **params;
10426 // phase-426 W3 — one set per node; every set is pumped,
10427 // because `ros2 param list` addresses each node's own
10428 // six and a set nobody polls times out.
10429 //
10430 // Issue 1270 -- all of them through ONE buffer pair.
10431 // Issue 1271 -- a request that got no reply is logged
10432 // inside, once per service; there is no error to drop.
10433 if let Some(buffers) = buffers {
10434 for set in services.iter_mut() {
10435 handled += set.process(server, staged, buffers);
10436 }
10437 }
10438 }
10439 }
10440 // phase-425 W3b — a `ros2 param set … use_sim_time true` arrives
10441 // through this path.
10442 self.note_param_services_ran(handled);
10443 }
10444
10445 // Same treatment for lifecycle services — `ros2 lifecycle get`
10446 // must succeed even when no callbacks fired this tick.
10447 //
10448 // Issue 1587 — `process` also drains the state machine's
10449 // announcement queue onto `~/transition_event`, so a transition
10450 // triggered from application code between two spins (autostart is
10451 // the common one) reaches a watching supervisor here.
10452 // SAFETY: see the matching invariant on the later call site.
10453 #[cfg(feature = "lifecycle-services")]
10454 if let Some(lc) = &mut self.lifecycle {
10455 let _ = unsafe { lc.process() };
10456 }
10457
10458 // phase-474 I7 -- an idle spin is the natural slot for the queued
10459 // reports.
10460 self.flush_violation_reports();
10461
10462 return SpinOnceResult::new();
10463 }
10464
10465 // Phase 2.5: LET pre-sample (only when LogicalExecutionTime)
10466 //
10467 // Sample all subscription data into entry buffers BEFORE dispatching
10468 // any callbacks. This ensures all callbacks in this cycle see a
10469 // consistent snapshot of data from the same point in time.
10470 // Services are NOT pre-sampled (request-reply is sequential).
10471 if matches!(self.semantics, ExecutorSemantics::LogicalExecutionTime) {
10472 for (i, meta) in self.entries.iter().enumerate() {
10473 let Some(meta) = meta else { continue };
10474 if matches!(meta.kind, EntryKind::Subscription)
10475 && !Self::slot_held(self.slot_tags, i)
10476 {
10477 let data_ptr = unsafe { arena_ptr.add(meta.offset) };
10478 unsafe { (meta.pre_sample)(data_ptr) };
10479 }
10480 }
10481 }
10482
10483 // Phase 3: Dispatch (Phase 110.C — bucketed by SC.priority).
10484 //
10485 // Two ready-set families, each split across `Priority::COUNT`
10486 // buckets (Critical / Normal / BestEffort). Per-entry SC
10487 // `class` selects FIFO bitmap vs EDF heap; SC `priority`
10488 // selects the bucket within. Drain order:
10489 // for each bucket in priority order (Critical first):
10490 // drain EDF heap (deadline-priority), then FIFO bitmap
10491 // (registration-order)
10492 // Default workloads — every entry on the auto-default Fifo SC
10493 // (Normal priority) — populate only `fifo[Normal]`, so
10494 // dispatch order is bit-identical to 110.B.b for those.
10495 const NB: usize = super::sched_context::Priority::COUNT;
10496 let mut result = SpinOnceResult::new();
10497 let mut fifo: super::ready_set::BucketedFifoSet<NB, { MAX_CALLBACK_SLOTS }> =
10498 super::ready_set::BucketedFifoSet::new();
10499 let mut edf: super::ready_set::BucketedEdfSet<NB, { MAX_CALLBACK_SLOTS }> =
10500 super::ready_set::BucketedEdfSet::new();
10501 let active_mask = bits | always_mask;
10502
10503 // Phase 110.E — refill any Sporadic SC budgets at period
10504 // boundaries before deciding what to dispatch this cycle.
10505 // Refill is polled (not ISR-driven) — coarse but correct
10506 // upper-bound bandwidth limiter.
10507 // phase-359 W4 — was `#[cfg(feature = "std")]` with NO no_std arm, so
10508 // polled Sporadic budgets never refilled on embedded: they exhausted
10509 // once and stayed exhausted. That was a consequence of "no clock on
10510 // no_std", which is false whenever a `clock_us` hook is injected. Now it
10511 // runs on either flavour when a clock exists, and is skipped when none
10512 // does — identical to today's behaviour in that case.
10513 if let Some(now_us) = now_us_this_spin {
10514 let now_ms = now_us / 1000;
10515 // issue 0736 — REFILL only. This used to also charge the cycle's
10516 // `delta_us` as the per-SC consumption estimate ("worst-case
10517 // attribution", pending a higher-precision clock hook). That hook
10518 // landed, and the attribution was not merely coarse: `delta_us` is
10519 // the wall-clock gap between spins, not CPU the callbacks spent, so
10520 // wherever the gap exceeds the budget the SC is exhausted on every
10521 // spin regardless of what it ran. Consumption is charged from
10522 // measured callback runtime in `consume_dispatch_runtime_us`.
10523 for slot in self.sporadic_states.iter_mut().flatten() {
10524 let _ = slot.refill(now_ms);
10525 }
10526 }
10527
10528 for i in 0..self.entries.len() {
10529 if active_mask & (1u64 << i) == 0 {
10530 continue;
10531 }
10532 let sc_idx = self.sched_context_bindings[i].0 as usize;
10533 let sc_class_priority_deadline = self
10534 .sched_contexts
10535 .get(sc_idx)
10536 .and_then(|s| s.as_ref())
10537 .map(|sc| {
10538 (
10539 sc.class,
10540 sc.priority.index(),
10541 sc.deadline_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX),
10542 )
10543 });
10544 let (sc_class, bucket, deadline_us) = sc_class_priority_deadline.unwrap_or((
10545 super::sched_context::SchedClass::Fifo,
10546 super::sched_context::Priority::Normal.index(),
10547 u32::MAX,
10548 ));
10549 // Phase 110.E — Sporadic SC dispatch is suppressed when
10550 // its budget is exhausted. Atomic path (110.E.b PlatformTimer
10551 // refill) takes precedence when registered; polled path
10552 // (cycle-level delta_us attribution) handles the unregistered
10553 // case. Either way, exhausted budget skips dispatch.
10554 if matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
10555 #[cfg(feature = "alloc")]
10556 let atomic_has_budget = self
10557 .sporadic_atomic_states
10558 .get(sc_idx)
10559 .and_then(|s| s.as_ref())
10560 .map(|(state, _)| state.has_budget());
10561 #[cfg(not(feature = "alloc"))]
10562 let atomic_has_budget: Option<bool> = None;
10563 let has_budget = match atomic_has_budget {
10564 Some(b) => b,
10565 None => self
10566 .sporadic_states
10567 .get(sc_idx)
10568 .and_then(|s| s.as_ref())
10569 .map(|s| s.budget_remaining_us > 0)
10570 .unwrap_or(true),
10571 };
10572 if !has_budget {
10573 // issue 0736 — keep the TIMER'S CLOCK honest even while the
10574 // budget gates its dispatch.
10575 //
10576 // A timer's `elapsed_us` only advances inside
10577 // `timer_try_process`, which is reached only when the entry
10578 // is DISPATCHED, and each dispatch is handed just THIS
10579 // cycle's `delta_us`. So every cycle skipped here was
10580 // dropped from the timer's sense of time: it did not fire,
10581 // it did not learn that its period had passed, and — because
10582 // the overrun counter is driven by that same `elapsed_us` —
10583 // it did not count the activation it missed.
10584 //
10585 // That is why the throttle was invisible. Measured on
10586 // nuttx-arm/rust: ~238 activations lost against ~40
10587 // reported, so five in six missed firings were unaccounted
10588 // ANYWHERE. The tier ran at a quarter of its declared rate
10589 // and the only honest signal was the delivery count itself.
10590 //
10591 // Advancing the clock here does not dispatch anything and
10592 // does not defeat the budget. It makes the entry's own
10593 // accounting independent of whether the executor CHOSE to
10594 // run it, which is the property every "did this meet its
10595 // declaration?" question needs, and it lets the existing
10596 // `Skip` policy count the backlog and the existing
10597 // `timer-overrun-runtime` rule report it.
10598 if let Some(meta) = self.entries[i].as_ref()
10599 && matches!(meta.kind, EntryKind::Timer)
10600 {
10601 // SAFETY: a Timer entry's arena slot holds a
10602 // `TimerEntry<F>`, whose leading layout IS
10603 // `TimerHeader` — the same cast the overrun reporter
10604 // one screen up already relies on.
10605 let header =
10606 unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
10607 // Issue 1321 — on the TIMER'S clock, not the
10608 // executor's. This line read
10609 // `header.elapsed_us += delta_us`, which is wall
10610 // microseconds: for a `Ros` timer that advanced a
10611 // simulated-time counter by wall time, so a paused
10612 // `/clock` no longer paused the timer — the one
10613 // property a ROS-time timer has. Unbounded, unlike
10614 // 1321's park bound, because it can fire a callback
10615 // that no clock step earned.
10616 super::arena::timer_advance_without_dispatch(header, delta_us);
10617 }
10618 // issue 0736 — a budget skip used to be a bare `continue`,
10619 // which is the silent-drop shape 0737 gated one layer out:
10620 // the entry is simply not dispatched and nothing anywhere
10621 // says so, so a permanently starved tier is
10622 // indistinguishable from an idle one. It took a hand-run
10623 // 45 s image and a per-executor probe to learn that this
10624 // line was skipping 96 % of one tier's spins.
10625 // The rolling window catches a budget that THROTTLES; the
10626 // streak below catches one that starves outright. issue
10627 // 0736 needed both: the measured case skipped ~3 spins in
10628 // 4 without ever reaching a streak, so the tier ran at a
10629 // quarter of its declared rate in silence.
10630 if let Some((skips, total)) = self
10631 .sporadic_states
10632 .get_mut(sc_idx)
10633 .and_then(|st| st.as_mut())
10634 .and_then(|st| st.take_budget_window())
10635 {
10636 nros_log::log_warn!(
10637 nros_log::get_logger("nros"),
10638 "sporadic budget throttled sched context {}: {} of the last {} dispatch opportunities were skipped for want of budget. The declared budget_us/period_us cannot sustain this tier's callbacks on this target.",
10639 sc_idx,
10640 skips,
10641 total
10642 );
10643 }
10644 if let Some(streak) = self
10645 .sporadic_states
10646 .get_mut(sc_idx)
10647 .and_then(|st| st.as_mut())
10648 .and_then(|st| st.note_budget_skip())
10649 {
10650 nros_log::log_warn!(
10651 nros_log::get_logger("nros"),
10652 "sporadic budget exhausted for {} consecutive spins (sched context {}): its callbacks are not being dispatched. Either the callback runtime exceeds the declared budget_us per period_us, or the declaration is unsatisfiable on this target.",
10653 streak,
10654 sc_idx
10655 );
10656 }
10657 continue;
10658 }
10659 // Phase 110.E.b follow-up — per-callback runtime
10660 // accounting (replaces this cycle-level attribution)
10661 // is applied at dispatch time below via
10662 // `consume_dispatch_runtime_us`. We only update the
10663 // polled-path `SporadicState` (no_std fallback) here
10664 // because the atomic path now records actual
10665 // wall-clock per-callback runtime. The
10666 // `delta_us` over-attribution that previously hit the
10667 // atomic state was a worst-case bandwidth limiter;
10668 // per-callback measurement is strictly tighter.
10669 #[cfg(not(feature = "alloc"))]
10670 {
10671 let _ = sc_idx; // polled-state path lives in
10672 // `sporadic_states`; this branch is a no-op when
10673 // the atomic path is enabled.
10674 }
10675 }
10676 // Phase 110.F — per-callback OS priority routing. Entries
10677 // bound to an SC with `os_pri > 0` dispatch onto a worker
10678 // thread the OS has elevated to that priority; the
10679 // cooperative path is skipped for those entries. Workers
10680 // are spawned lazily.
10681 #[cfg(all(
10682 feature = "alloc",
10683 feature = "rmw-cffi",
10684 feature = "scheduler-os-priority"
10685 ))]
10686 {
10687 let os_pri = self
10688 .sched_contexts
10689 .get(sc_idx)
10690 .and_then(|s| s.as_ref())
10691 .map(|sc| sc.os_pri)
10692 .unwrap_or(0);
10693 if os_pri > 0
10694 && let Some(apply_policy) = self.os_priority_apply_policy
10695 && let Some(meta) = self.entries[i].as_ref()
10696 {
10697 // Issue 1705 — a worker still holds this entry (its item is
10698 // queued or its callback is running). Sending it again, or
10699 // running it here, would put two threads in one
10700 // `try_process`; it gets its next turn once the worker lets
10701 // go.
10702 let tag = &self.slot_tags[i];
10703 if tag.set(super::types::SlotTag::WORKER_QUEUED)
10704 & super::types::SlotTag::WORKER_QUEUED
10705 != 0
10706 {
10707 continue;
10708 }
10709 let item = super::os_priority::WorkItem {
10710 arena_base: arena_ptr as usize,
10711 arena_offset: meta.offset,
10712 try_process: meta.try_process,
10713 delta_us,
10714 // phase-8 — the worker runs the leaf on a DIFFERENT
10715 // thread, so it has to carry the slot index with it;
10716 // nothing on the far side can recover it from the
10717 // arena address alone.
10718 desc_idx: i as u8,
10719 tag: tag as *const super::types::SlotTag as usize,
10720 };
10721 // phase-359 W10 — `try_dispatch` now reports whether the
10722 // entry was actually handed to a worker. It can decline:
10723 // the mailbox is bounded, the pool is capacity-limited, and
10724 // a platform without a wake primitive hosts no workers at
10725 // all. Previously the spawn was infallible (`.expect`) and
10726 // an unbounded `mpsc` never refused, so this branch always
10727 // `continue`d. Falling through to the cooperative path is
10728 // the honest answer to a decline — the callback still runs,
10729 // just without the OS priority guarantee, which is exactly
10730 // what an entry got before this feature existed.
10731 if self
10732 .os_priority_pool
10733 .try_dispatch(os_pri, apply_policy, item)
10734 {
10735 continue;
10736 }
10737 // Declined: no worker took it, so the bit set above is
10738 // released and the entry runs cooperatively.
10739 self.slot_tags[i].clear(super::types::SlotTag::WORKER_QUEUED);
10740 }
10741 }
10742 // Phase 110.G — TT window gate, orthogonal to class.
10743 // Skips dispatch when the SC has a TT window AND the
10744 // current monotonic time is outside it. Both gates apply
10745 // independently — a Sporadic SC with a TT window must
10746 // pass both.
10747 if self.major_frame_us > 0 {
10748 let sc_opt = self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref());
10749 if let Some(sc) = sc_opt {
10750 let off = sc.tt_window_offset_us.get().map(|nz| nz.get()).unwrap_or(0);
10751 let dur = sc
10752 .tt_window_duration_us
10753 .get()
10754 .map(|nz| nz.get())
10755 .unwrap_or(0);
10756 if dur > 0 {
10757 // Compute current phase within the major
10758 // frame using the accumulated `delta_us` clock
10759 // (std-only precise; no_std uses `delta_us`
10760 // approximation from spin cadence).
10761 // phase-359 W4 — the shared read. The no_std arm used
10762 // to be `now_us = delta_us`, i.e. a per-spin INTERVAL
10763 // used as an absolute phase clock, which the comment
10764 // above called an approximation. It is now the real
10765 // clock when one is injected, and falls back to the old
10766 // approximation only when none is.
10767 let now_us = now_us_this_spin.unwrap_or(delta_us);
10768 let phase = (now_us % self.major_frame_us as u64) as u32;
10769 let in_window = if off + dur <= self.major_frame_us {
10770 phase >= off && phase < off + dur
10771 } else {
10772 // Window wraps the major frame boundary.
10773 let end = (off as u64 + dur as u64) % self.major_frame_us as u64;
10774 phase >= off || (phase as u64) < end
10775 };
10776 if !in_window {
10777 continue;
10778 }
10779 }
10780 }
10781 }
10782 let is_edf = matches!(sc_class, super::sched_context::SchedClass::Edf);
10783 let job = super::types::ActiveJob {
10784 sort_key: if is_edf { deadline_us } else { i as u32 },
10785 desc_idx: i as super::types::DescIdx,
10786 };
10787 if is_edf {
10788 let _ = edf.insert_into(bucket, job);
10789 } else {
10790 let _ = fifo.insert_into(bucket, job);
10791 }
10792 }
10793
10794 // SAFETY: each `desc_idx` we pop was set above only when the
10795 // corresponding `entries[i]` slot was `Some`; no Executor
10796 // mutation happens between that scan and this dispatch.
10797 let mut dispatch_one = |meta: &CallbackMeta,
10798 desc_idx: usize,
10799 arena_ptr: *mut u8,
10800 delta_us: u64,
10801 result: &mut SpinOnceResult| {
10802 // Phase 141.B.2 — capture T1 at subscription dispatch
10803 // entry. Probe pairs it with the most recent T0 from
10804 // `nros_rmw_runtime_wake_cb` (std + alloc variants)
10805 // and pushes `T1 - T0` onto the ring buffer 141.C
10806 // drains. No-op when the probe feature is off or
10807 // no cycle reader is installed. Other entry kinds
10808 // (Service / Timer / GuardCondition) skip the probe
10809 // because the 141 acceptance is specifically
10810 // wake-to-subscription-dispatch latency.
10811 #[cfg(feature = "wake-latency-probe")]
10812 if matches!(meta.kind, EntryKind::Subscription) {
10813 super::wake_probe::on_dispatch();
10814 }
10815 let data_ptr = unsafe { arena_ptr.add(meta.offset) };
10816 // phase-8 — `desc_idx` is the entry slot index, threaded through so
10817 // the leaf hooks in `arena.rs` can name the callback they bracket.
10818 // `MAX_CALLBACK_SLOTS` is 64 (enforced by the `u64` ready-set
10819 // bitmask), so the `as u8` cannot truncate a live slot.
10820 // phase-463 W7 — time the dispatch and attribute its publishes
10821 // (host `profile-mode` only). ONE call site for both builds, so no
10822 // second `unsafe` block; the binding is the only change an RTOS
10823 // image sees, and its loadable bytes measured identical (phase-463
10824 // W5 I3c, Zephyr cpp/talker).
10825 #[cfg(feature = "profile-mode")]
10826 super::profile::dispatch_begin(arena_ptr as usize, desc_idx);
10827 let processed = unsafe { (meta.try_process)(data_ptr, delta_us, desc_idx as u8) };
10828 #[cfg(feature = "profile-mode")]
10829 super::profile::dispatch_end(matches!(processed, Ok(true)));
10830 match processed {
10831 Ok(true) => match meta.kind {
10832 EntryKind::Subscription => result.subscriptions_processed += 1,
10833 EntryKind::Service
10834 | EntryKind::ServiceClient
10835 | EntryKind::ActionServer
10836 | EntryKind::ActionClient => result.services_handled += 1,
10837 EntryKind::Timer => result.timers_fired += 1,
10838 EntryKind::GuardCondition => {}
10839 },
10840 Ok(false) => {}
10841 Err(e) => {
10842 // phase-427 — the first one is kept, the rest are counted.
10843 // `stop_on_first_error` is what reads it; when nobody
10844 // asked, the `Option` is a local the optimiser drops.
10845 if first_error.is_none() {
10846 *first_error = Some(e);
10847 }
10848 match meta.kind {
10849 EntryKind::Subscription => result.subscription_errors += 1,
10850 EntryKind::Service
10851 | EntryKind::ServiceClient
10852 | EntryKind::ActionServer
10853 | EntryKind::ActionClient => result.service_errors += 1,
10854 EntryKind::Timer | EntryKind::GuardCondition => {}
10855 }
10856 }
10857 }
10858 };
10859
10860 // Phase 110.E.b follow-up — per-callback runtime accounting
10861 // for Sporadic SCs. Wall-clock-measure each dispatch and
10862 // consume the elapsed microseconds from the bound SC's
10863 // atomic budget. This replaces the cycle-level over-
10864 // attribution that previously charged the FULL `delta_us`
10865 // against every Sporadic SC regardless of which entries
10866 // actually fired — accurate per-callback measurement is the
10867 // shape the design doc's per-callback runtime acceptance
10868 // calls out.
10869 //
10870 // phase-359 W10 — this was `feature = "std"`-gated, because it "needs a
10871 // `core::time::Instant`-equivalent monotonic clock ... until a
10872 // board-side monotonic-microsecond accessor lands". That accessor
10873 // landed: `now_us()` reads the platform monotonic counter on every
10874 // flavour. So per-callback sporadic accounting is no longer something
10875 // only a hosted build gets, and the no_std fallback to polled
10876 // `SporadicState` cycle deltas is no longer the only option on target.
10877 let consume_dispatch_runtime_us =
10878 |desc_idx: usize,
10879 elapsed_us: u32,
10880 sched_context_bindings: &[super::sched_context::SchedContextId],
10881 sched_contexts: &[Option<super::sched_context::SchedContext>],
10882 sporadic_states: &mut [Option<super::sched_context::SporadicState>],
10883 alive_slots: &mut [super::monitor::AliveSlot],
10884 #[cfg(feature = "alloc")] sporadic_atomic_states: &[Option<(
10885 portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
10886 OpaqueTimerHandle,
10887 )>]| {
10888 let sc_idx = sched_context_bindings[desc_idx].0 as usize;
10889 // Alive supervision counts EVERY dispatch, before the class
10890 // gate below: a Fifo or time-triggered context can fall
10891 // silent exactly as a sporadic one can, and the rule that
10892 // notices must not be scoped to the class that happens to
10893 // carry a budget.
10894 if let Some(slot) = alive_slots.get_mut(sc_idx) {
10895 slot.dispatches = slot.dispatches.wrapping_add(1);
10896 }
10897 let sc_class = sched_contexts
10898 .get(sc_idx)
10899 .and_then(|s| s.as_ref())
10900 .map(|sc| sc.class)
10901 .unwrap_or(super::sched_context::SchedClass::Fifo);
10902 if !matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
10903 return;
10904 }
10905 #[cfg(feature = "alloc")]
10906 if let Some((state, _)) =
10907 sporadic_atomic_states.get(sc_idx).and_then(|s| s.as_ref())
10908 {
10909 state.consume(elapsed_us);
10910 // Unconditional, unlike the overrun record below: the
10911 // budget is sized FROM this number, so it has to exist
10912 // before the budget is right rather than only after it
10913 // is wrong.
10914 state.record_exec(elapsed_us);
10915 // Phase 110.E.b — overrun detection. Cooperative
10916 // single-thread can't preempt a runaway callback,
10917 // so post-dispatch wall-clock comparison delivers
10918 // the same observable signal as the design's
10919 // oneshot-IRQ-and-cancel pattern, without needing
10920 // a separate timer per SC. `budget_capacity_us` is
10921 // the per-period budget the SC was sized against;
10922 // any callback exceeding that has run past its
10923 // bandwidth allotment.
10924 if elapsed_us > state.budget_capacity_us {
10925 state.record_overrun(elapsed_us - state.budget_capacity_us);
10926 }
10927 }
10928 // issue 0736 — the POLLED state is charged unconditionally,
10929 // and it is the one that matters: `has_budget` prefers the
10930 // atomic state only when a refill timer was registered, which
10931 // no board or entry does. Before this, the alloc arm charged a
10932 // state nothing populated and the no_std arm discarded the
10933 // measurement outright (`let _ = (sc_idx, elapsed_us)`), so no
10934 // shipped image recorded per-callback runtime anywhere — while
10935 // the comment above claimed it did.
10936 if let Some(state) = sporadic_states.get_mut(sc_idx).and_then(|s| s.as_mut()) {
10937 state.consume(elapsed_us);
10938 }
10939 };
10940
10941 // W3b.5 — post-dispatch contract checks. `lat_active` gates the
10942 // per-dispatch publish-count snapshot (attribution of dispatch
10943 // elapsed time to monitored publishers whose counter advanced);
10944 // `dl_active` gates elapsed measurement for deadline actions. The third
10945 // term used to be `cfg!(feature = "std")` — "std measures anyway for
10946 // the sporadic path" — which made a COMPILE-TIME flavour stand in for
10947 // "is there a clock". It is now the runtime question it always was.
10948 let mon_table = self.monitor_table;
10949 let lat_active = mon_table.iter().any(|m| m.max_latency_ms > 0);
10950 let dl_active = self.sched_contexts.iter().flatten().any(|sc| {
10951 sc.deadline_us.is_some()
10952 && !matches!(
10953 sc.deadline_action,
10954 super::sched_context::DeadlineAction::Ignore
10955 )
10956 });
10957 let mon_clock = self.clock_us_fn;
10958 // phase10-B3 -- the route rule needs the clock and the publish-count
10959 // snapshot, and nothing else; without a clock it judges nothing.
10960 let route_on = mon_clock.is_some() && super::monitor::route_active(mon_table);
10961 // phase-503 -- a late supervised route reacts at completion too; not
10962 // before the monitors are armed (phase-474 I2), as after the park.
10963 let route_hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
10964 let counts_on = lat_active || route_on;
10965 // phase-359 W4 — one predicate, no cfg block. `cfg!` is an expression,
10966 // so the flavour difference stays a value instead of a branch: std
10967 // measures unconditionally because the sporadic runtime accounting
10968 // below (itself std-only) consumes the result, which is exactly what
10969 // the comment above described and what the four-arm sites encoded.
10970 let measure_us = counts_on || dl_active || mon_clock.is_some();
10971 // phase-359 W4 — ONE hoisted µs reader for the per-dispatch latency
10972 // measurement below. Hoisted because `now_us()` takes `&mut self` and
10973 // the dispatch loop already holds borrows; the std arm copies the epoch
10974 // (a `Copy` `Instant`) so reading inside the loop touches no `self`.
10975 // This is the last cfg pair in the timing path: the two call sites it
10976 // serves each had FOUR arms (start + elapsed, per flavour).
10977 let read_us = move || mon_clock.map(|c| c()).unwrap_or(0);
10978 // Deferred deadline-miss violations (the loop body holds an
10979 // immutable borrow of `self.entries`, so the ring is fed after).
10980 let mut deadline_misses: heapless::Vec<
10981 super::monitor::Violation,
10982 { super::monitor::MAX_VIOLATIONS },
10983 > = heapless::Vec::new();
10984 // SCs whose remaining callbacks this cycle are skipped
10985 // (`DeadlineAction::Skip`) — bitmask over SC slots.
10986 let mut skipped_scs: u64 = 0;
10987
10988 // For each priority bucket (Critical → Normal → BestEffort),
10989 // drain EDF first then FIFO so an EDF callback in this bucket
10990 // beats a FIFO peer at the same priority, but no lower-priority
10991 // entry runs while a higher-priority bucket has work pending.
10992 // Strict static priority across buckets; non-preemptive within
10993 // an in-flight callback (see Phase 110.D).
10994 for bucket in 0..NB {
10995 while let Some(job) = edf.pop_from(bucket) {
10996 let i = job.desc_idx as usize;
10997 let sc_idx = self.sched_context_bindings[i].0 as usize;
10998 if sc_idx < 64 && skipped_scs & (1u64 << sc_idx) != 0 {
10999 continue; // W3b.5 DeadlineAction::Skip containment
11000 }
11001 if let Some(meta) = self.entries[i].as_ref()
11002 && !Self::slot_held(self.slot_tags, i)
11003 {
11004 let counts_before = snapshot_pub_counts(mon_table, counts_on);
11005 // Measured only when a consumer wants it, matching the
11006 // no_std arm this replaces — the std arm used to measure
11007 // unconditionally.
11008 let start_us = measure_us.then(read_us);
11009 let took_before = result.subscriptions_processed;
11010 Self::begin_dispatch(self.slot_tags, i);
11011 dispatch_one(meta, i, arena_ptr, delta_us, &mut result);
11012 let end_us = start_us.map(|_| read_us());
11013 let elapsed_us: Option<u32> = start_us
11014 .zip(end_us)
11015 .map(|(t0, t1)| t1.saturating_sub(t0))
11016 .map(|d| d.min(u32::MAX as u64) as u32);
11017 // phase10-B3 -- a subscription dispatch takes its sample
11018 // first, so the dispatch start is the take.
11019 if route_on && let (Some(t0), Some(t1)) = (start_us, end_us) {
11020 if result.subscriptions_processed != took_before {
11021 super::monitor::route_take(mon_table, i, t0);
11022 }
11023 super::monitor::route_judge(
11024 mon_table,
11025 &counts_before,
11026 t1,
11027 |row, v, record, react| {
11028 if react {
11029 call_route_reaction(route_hook, mon_table, row, &v);
11030 }
11031 if record {
11032 let _ = deadline_misses.push(v);
11033 }
11034 },
11035 );
11036 }
11037 if let Some(elapsed_us) = elapsed_us {
11038 consume_dispatch_runtime_us(
11039 i,
11040 elapsed_us,
11041 &self.sched_context_bindings[..],
11042 &self.sched_contexts[..],
11043 &mut self.sporadic_states[..],
11044 &mut self.alive_slots[..],
11045 #[cfg(feature = "alloc")]
11046 &self.sporadic_atomic_states[..],
11047 );
11048 }
11049 if let Some(elapsed_us) = elapsed_us {
11050 attribute_latency(mon_table, lat_active, &counts_before, elapsed_us);
11051 check_deadline_miss(
11052 self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref()),
11053 sc_idx,
11054 elapsed_us,
11055 &mut deadline_misses,
11056 &mut skipped_scs,
11057 self.fault_fn,
11058 self.reaction_hook,
11059 );
11060 }
11061 self.finish_dispatch(i);
11062 }
11063 }
11064 while let Some(job) = fifo.pop_from(bucket) {
11065 let i = job.desc_idx as usize;
11066 let sc_idx = self.sched_context_bindings[i].0 as usize;
11067 if sc_idx < 64 && skipped_scs & (1u64 << sc_idx) != 0 {
11068 continue; // W3b.5 DeadlineAction::Skip containment
11069 }
11070 if let Some(meta) = self.entries[i].as_ref()
11071 && !Self::slot_held(self.slot_tags, i)
11072 {
11073 let counts_before = snapshot_pub_counts(mon_table, counts_on);
11074 // Measured only when a consumer wants it, matching the
11075 // no_std arm this replaces — the std arm used to measure
11076 // unconditionally.
11077 let start_us = measure_us.then(read_us);
11078 let took_before = result.subscriptions_processed;
11079 Self::begin_dispatch(self.slot_tags, i);
11080 dispatch_one(meta, i, arena_ptr, delta_us, &mut result);
11081 let end_us = start_us.map(|_| read_us());
11082 let elapsed_us: Option<u32> = start_us
11083 .zip(end_us)
11084 .map(|(t0, t1)| t1.saturating_sub(t0))
11085 .map(|d| d.min(u32::MAX as u64) as u32);
11086 // phase10-B3 -- a subscription dispatch takes its sample
11087 // first, so the dispatch start is the take.
11088 if route_on && let (Some(t0), Some(t1)) = (start_us, end_us) {
11089 if result.subscriptions_processed != took_before {
11090 super::monitor::route_take(mon_table, i, t0);
11091 }
11092 super::monitor::route_judge(
11093 mon_table,
11094 &counts_before,
11095 t1,
11096 |row, v, record, react| {
11097 if react {
11098 call_route_reaction(route_hook, mon_table, row, &v);
11099 }
11100 if record {
11101 let _ = deadline_misses.push(v);
11102 }
11103 },
11104 );
11105 }
11106 if let Some(elapsed_us) = elapsed_us {
11107 consume_dispatch_runtime_us(
11108 i,
11109 elapsed_us,
11110 &self.sched_context_bindings[..],
11111 &self.sched_contexts[..],
11112 &mut self.sporadic_states[..],
11113 &mut self.alive_slots[..],
11114 #[cfg(feature = "alloc")]
11115 &self.sporadic_atomic_states[..],
11116 );
11117 }
11118 if let Some(elapsed_us) = elapsed_us {
11119 attribute_latency(mon_table, lat_active, &counts_before, elapsed_us);
11120 check_deadline_miss(
11121 self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref()),
11122 sc_idx,
11123 elapsed_us,
11124 &mut deadline_misses,
11125 &mut skipped_scs,
11126 self.fault_fn,
11127 self.reaction_hook,
11128 );
11129 }
11130 self.finish_dispatch(i);
11131 }
11132 }
11133 }
11134
11135 // W3b.5 — feed deferred deadline misses into the violation ring.
11136 for v in deadline_misses {
11137 self.violations.record(v);
11138 }
11139
11140 // Issue #505 — same ring, same cycle. This runs AFTER dispatch,
11141 // unlike the windowed rate/age/latency rules at the top of the
11142 // spin: the activations it reports were dropped by the dispatch
11143 // that just happened, and a tier that is stalling should not have
11144 // to wait for the next spin to say so.
11145 self.check_timer_overruns();
11146 self.check_release_jitter_rule();
11147 self.check_stack_headroom_rule();
11148 self.check_alive_supervision();
11149 // phase-474 I7 -- the queued /diagnostics reports, after this spin's
11150 // dispatches and rules, when the next timer leaves room for them.
11151 self.flush_violation_reports();
11152 // phase-474 I1 -- the opt-in drain-and-report hook, after every rule of
11153 // this spin has had its say.
11154 self.violations.drain_and_report();
11155
11156 // Process parameter services (outside the arena)
11157 #[cfg(feature = "param-store")]
11158 #[allow(unused_mut)] // only the services add to it
11159 let mut handled = 0usize;
11160 #[cfg(feature = "param-services")]
11161 if let Some(params) = &mut self.params {
11162 {
11163 let crate::param_store::ParamState {
11164 server,
11165 services,
11166 buffers,
11167 staged,
11168 ..
11169 } = &mut **params;
11170 // phase-426 W3 -- one set per node; see the sibling site above,
11171 // including why nothing here can discard an error (1270/1271).
11172 if let Some(buffers) = buffers {
11173 for set in services.iter_mut() {
11174 let n = set.process(server, staged, buffers);
11175 result.services_handled += n;
11176 handled += n;
11177 }
11178 }
11179 }
11180 }
11181 #[cfg(feature = "param-store")]
11182 self.note_param_services_ran(handled);
11183
11184 // Process lifecycle services (outside the arena).
11185 //
11186 // SAFETY: `change_state` dispatches a user-supplied C callback through a
11187 // raw function pointer stored in `LifecyclePollingNodeCtx`. The caller
11188 // of `register_lifecycle_services` guarantees the callback/context pair
11189 // stays live for as long as the executor (see that method's docs).
11190 #[cfg(feature = "lifecycle-services")]
11191 if let Some(lc) = &mut self.lifecycle {
11192 if let Ok(n) = unsafe { lc.process() } {
11193 result.services_handled += n;
11194 }
11195 }
11196
11197 // Phase 258 (Track 2, 2a) — executor-owned component tick pass.
11198 // Mirrors `ExecutorNodeRuntime::run_ticks`: after the transport +
11199 // callbacks have been pumped, drive each enrolled component's `tick`
11200 // (service-client/action poll, etc.). `exec_ctx` hands the component
11201 // the whole executor as a raw `*mut Executor` so its tick can
11202 // reborrow it (the same disjoint-field raw-ptr pattern run_ticks
11203 // uses). Index-iterate over `Copy` slots so no borrow of
11204 // `self.component_slots` is held while `tick` runs (which aliases
11205 // `self` through `exec_ctx`).
11206 let exec_ctx = self as *mut Executor as *mut core::ffi::c_void;
11207 let slot_count = self.component_slots.len();
11208 for i in 0..slot_count {
11209 let slot = self.component_slots[i];
11210 // SAFETY: `slot.state` is the leaked cell the matching `tick`
11211 // expects (enrolled via `enroll_component`); `exec_ctx` is a live
11212 // `*mut Executor` for `self`. The slot was copied out, so no
11213 // borrow of `component_slots` is outstanding during the call.
11214 unsafe {
11215 (slot.tick)(slot.state, exec_ctx);
11216 }
11217 }
11218
11219 result
11220 }
11221
11222 /// Drive I/O and dispatch callbacks forever — the body of an RTOS task.
11223 ///
11224 /// EXTENSION (RFC-0089). Not a rename of upstream's `spin`: the `!` is the
11225 /// point. A bare-metal entry has no shutdown source to return TO, and
11226 /// declaring that lets the compiler drop the epilogue and lets the caller
11227 /// write it as the last expression of a `-> !` task body with no
11228 /// `unreachable!()`. [`spin`](Self::spin) is the verb that can end, and it
11229 /// is upstream's.
11230 ///
11231 /// phase-427 — was `spin(Duration) -> !`, which squatted on rclrs's name
11232 /// with a different contract (a ported `executor.spin(SpinOptions)` line
11233 /// had to be renamed by hand, the one edit on the tutorial's happy path
11234 /// that no real difference forced). `spin_default()` went with it: it named
11235 /// a 50 ms poll interval and had zero call sites, and the interval is now
11236 /// [`SpinOptions::poll_interval`], defaulting to
11237 /// [`DEFAULT_SPIN_POLL_INTERVAL`](super::types::DEFAULT_SPIN_POLL_INTERVAL).
11238 ///
11239 /// **Envelope.** Only `poll_interval` and `stop_on_first_error` can mean
11240 /// anything here; `timeout`, `only_next` and `max_callbacks` are ENDING
11241 /// conditions and this verb does not end. Passing one is a caller error and
11242 /// says so at `LOG_ERR` on entry rather than being quietly dropped — a
11243 /// discarded ending condition is precisely the "compiles and differs" case
11244 /// RFC-0089 exists to make loud, and a `-> !` signature has no other
11245 /// channel. `stop_on_first_error` likewise cannot RETURN the error; it
11246 /// logs it and keeps spinning, because a task body has nowhere to put it.
11247 ///
11248 /// ```ignore
11249 /// let mut executor = Executor::open(&config)?;
11250 /// executor.register_subscription::<Int32, _>("/topic", |msg| { /* ... */ })?;
11251 /// executor.spin_forever(SpinOptions::default()); // never returns
11252 /// ```
11253 pub fn spin_forever(&mut self, opts: SpinOptions) -> ! {
11254 if opts.timeout.is_some() || opts.only_next || opts.max_callbacks.is_some() {
11255 nros_log::log_error!(
11256 nros_log::get_logger("nros"),
11257 "spin_forever: an ending condition (timeout / only_next / max_callbacks) \
11258 was supplied to a verb that never returns and is being IGNORED \
11259 -- use `Executor::spin` for a loop that can end"
11260 );
11261 }
11262 let poll = opts.poll_interval;
11263 let report_errors = opts.stop_on_first_error;
11264 loop {
11265 let mut first_error = None;
11266 self.spin_once_capturing(poll, &mut first_error);
11267 if report_errors && let Some(e) = first_error {
11268 nros_log::log_error!(
11269 nros_log::get_logger("nros"),
11270 "spin_forever: a callback failed ({:?}); `stop_on_first_error` cannot \
11271 end a `-> !` loop, so this is a report, not a stop",
11272 e
11273 );
11274 }
11275 }
11276 }
11277
11278 /// Drive I/O and dispatch callbacks asynchronously.
11279 ///
11280 /// Runs forever, yielding between poll cycles so that other async tasks
11281 /// (e.g., [`Promise`](super::handles::Promise)) can make progress.
11282 ///
11283 /// Uses only `core::future` — no external async runtime dependency.
11284 ///
11285 /// ADOPT-BOUNDED against rclrs's
11286 /// `spin_async(SpinOptions) -> impl Future<Output = Vec<RclrsError>>`:
11287 ///
11288 /// * **It never returns and reports no errors** — the output is `!`, and it
11289 /// takes no `SpinOptions`. To stop, `select` it against the thing you are
11290 /// waiting for (pattern 1 below).
11291 /// * **It does not sleep on RMW I/O.** Each cycle is `spin_once(1 ms)` and then
11292 /// a yield that wakes itself before returning `Pending`, so any runtime that
11293 /// honours wakes polls it again at once. It is CPU-bound by construction;
11294 /// the 1 ms `spin_once` budget is its only wait. Do not read `async` as idle.
11295 ///
11296 /// # Usage patterns
11297 ///
11298 /// ```ignore
11299 /// // Pattern 1: select with a promise (embassy-futures)
11300 /// use embassy_futures::select::{select, Either};
11301 /// let promise = client.call(&req)?;
11302 /// let Either::Second(reply) = select(executor.spin_async(), promise).await
11303 /// else { unreachable!() };
11304 ///
11305 /// // Pattern 2: manual polling (no async runtime)
11306 /// let mut promise = client.call(&req)?;
11307 /// loop {
11308 /// executor.spin_once(core::time::Duration::from_millis(10));
11309 /// if let Ok(Some(r)) = promise.take() { break r; }
11310 /// }
11311 /// ```
11312 pub async fn spin_async(&mut self) -> ! {
11313 loop {
11314 self.spin_once(core::time::Duration::from_millis(1));
11315 core::future::poll_fn::<(), _>(|cx| {
11316 cx.waker().wake_by_ref();
11317 core::task::Poll::Pending
11318 })
11319 .await;
11320 }
11321 }
11322
11323 /// Execute the work that is ready NOW and return — rclcpp's
11324 /// `Executor::spin_some(max_duration)`.
11325 ///
11326 /// ADOPT-BOUNDED (RFC-0089). Upstream's drain verb: collect what is ready,
11327 /// execute it, come back. It never waits for new work — that is
11328 /// [`spin_once`](Self::spin_once), which takes a budget and blocks on the
11329 /// transport until it expires.
11330 ///
11331 /// **Envelope, two ways it is weaker than rclcpp's:**
11332 ///
11333 /// * rclcpp collects the ready set ONCE and executes it. One
11334 /// `spin_once(0)` here is already that whole set (RFC-0002 §3), so this
11335 /// would be a single call — except a callback may enqueue work for
11336 /// another entity, which rclcpp's `spin_all` re-collects and its
11337 /// `spin_some` leaves for the next call. Ours re-drains until a pass does
11338 /// NO work, which is `spin_all`'s answer, because the alternative is
11339 /// work sitting in the arena until someone spins again. Bounded by
11340 /// `max`, so a callback that feeds itself cannot wedge the caller.
11341 /// * `max` is checked BETWEEN passes, never inside one, so it is a floor on
11342 /// when this returns and not a ceiling: a single long callback overruns
11343 /// it. There is no preemption here to make it a ceiling.
11344 ///
11345 /// `max` of [`Duration::ZERO`](core::time::Duration::ZERO) means no limit —
11346 /// rclcpp's own default — and is the spelling a build with no clock must
11347 /// use. A non-zero `max` on a clockless build is an ERROR rather than an
11348 /// unbounded drain (issue 0709, the same rule as `spin`'s `timeout`).
11349 ///
11350 /// The `Result` is about that PRECONDITION and nothing else. A failed
11351 /// callback is counted in each pass's `SpinOnceResult` and does not end the
11352 /// drain, matching `spin`'s default (`SpinOptions::stop_on_first_error` is
11353 /// `false`); rclcpp's `spin_some` returns `void` and reports callback
11354 /// failures nowhere at all.
11355 ///
11356 /// # Example
11357 ///
11358 /// ```ignore
11359 /// // Drain the ready set, then get on with the caller's own work.
11360 /// executor.spin_some(core::time::Duration::ZERO)?;
11361 /// ```
11362 pub fn spin_some(&mut self, max: core::time::Duration) -> Result<(), NodeError> {
11363 let bounded = !max.is_zero();
11364 let start_us = self.now_us();
11365 if bounded && start_us.is_none() {
11366 nros_log::log_error!(
11367 nros_log::get_logger("nros"),
11368 "spin_some: a max duration was requested but this build has no clock \
11369 - install one with `ExecutorConfig::clock_us`, or pass \
11370 `Duration::ZERO` for rclcpp's unbounded default (issue 0709)"
11371 );
11372 return Err(NodeError::NotInitialized);
11373 }
11374 let max_us = max.as_micros().min(u64::MAX as u128) as u64;
11375
11376 // No `enter_spin_loop` here, deliberately: `is_spinning` answers "a
11377 // blocking loop is running that a peer's `cancel()` has to reach", and
11378 // this verb returns on its own. Raising the flag for the length of a
11379 // drain would make a `cancel()` from another task look acted-upon when
11380 // nothing was waiting for it.
11381 loop {
11382 let result = self.spin_once(core::time::Duration::ZERO);
11383 if !result.any_work() {
11384 break;
11385 }
11386 if bounded
11387 && let (Some(start), Some(now)) = (start_us, self.now_us())
11388 && now.saturating_sub(start) >= max_us
11389 {
11390 break;
11391 }
11392 }
11393 Ok(())
11394 }
11395
11396 // ========================================================================
11397 // spin_one_period (no_std)
11398 // ========================================================================
11399
11400 /// Process one iteration and return remaining sleep time.
11401 ///
11402 /// This is `no_std` compatible — the caller is responsible for the actual
11403 /// delay using platform-specific sleep.
11404 ///
11405 /// # Arguments
11406 /// * `period_ms` - Target period in milliseconds
11407 /// * `elapsed_ms` - Time elapsed since last call (used for timer ticking)
11408 ///
11409 /// # Example
11410 ///
11411 /// ```ignore
11412 /// loop {
11413 /// let r = executor.spin_one_period(10, elapsed_ms);
11414 /// platform_sleep_ms(r.remaining_ms);
11415 /// }
11416 /// ```
11417 pub fn spin_one_period(&mut self, period_ms: u64, elapsed_ms: u64) -> SpinPeriodPollingResult {
11418 let result = self.spin_once(core::time::Duration::from_millis(elapsed_ms));
11419 SpinPeriodPollingResult {
11420 work: result,
11421 remaining_ms: period_ms.saturating_sub(elapsed_ms),
11422 }
11423 }
11424}
11425
11426// ============================================================================
11427// Parameter services (cfg param-services)
11428// ============================================================================
11429
11430#[cfg(feature = "param-services")]
11431impl<'s> Executor<'s> {
11432 /// Register the 6 ROS 2 parameter services for this node.
11433 ///
11434 /// Creates service servers for `get_parameters`, `set_parameters`,
11435 /// `set_parameters_atomically`, `list_parameters`, `describe_parameters`,
11436 /// and `get_parameter_types`.
11437 ///
11438 /// The service names follow the ROS 2 convention: `/{namespace}/{node_name}/{suffix}`.
11439 /// For the default namespace `/`, this becomes `/{node_name}/{suffix}` (e.g.
11440 /// `/sentinel/list_parameters`).
11441 ///
11442 /// Parameter services are stored outside the arena and don't consume
11443 /// callback slots.
11444 ///
11445 /// # Example
11446 ///
11447 /// ```ignore
11448 /// let config = ExecutorConfig::from_env().node_name("talker");
11449 /// let mut executor = Executor::open(&config)?;
11450 /// executor.register_parameter_services()?;
11451 /// executor.declare_parameter("start_value", ParameterValue::Integer(0));
11452 /// ```
11453 pub fn register_parameter_services(&mut self) -> Result<(), NodeError> {
11454 // Issue 1706 -- six services over a store that does not exist would
11455 // answer every request with "not declared"; say what went wrong
11456 // instead (`report_parameter_store_refused` has named the size).
11457 if !self.ensure_parameter_store() {
11458 return Err(NodeError::Transport(TransportError::BadAlloc));
11459 }
11460 if let Some(params) = &mut self.params {
11461 params.requested = true;
11462 }
11463 // phase-426 W3 — with no node yet, RECORD the request and stop. The
11464 // reconcile's no-node fallback publishes under the executor's own
11465 // identity, and taking it here would pin node 0 to that name before the
11466 // node that IS node 0 has been built — which is the pre-W3 behaviour
11467 // this work item exists to remove. Every generated entry arrives in
11468 // exactly this state (`apply_param_services` runs ahead of the per-node
11469 // `register` calls, by design), so the fallback belongs on the spin
11470 // path, where "no node" has stopped being merely "not yet".
11471 if self.nodes.is_empty() {
11472 return Ok(());
11473 }
11474 self.reconcile_parameter_services()
11475 }
11476
11477 /// phase-426 W3 — is there a node whose six services are not up yet?
11478 ///
11479 /// The cheap half of the reconcile, kept separate so `spin_once` can test
11480 /// it without carrying the reconcile's frame (see below).
11481 #[inline]
11482 pub(crate) fn parameter_services_pending(&self) -> bool {
11483 self.params.as_deref().is_some_and(|p| {
11484 p.requested
11485 && p.services.len() < self.nodes.len().max(1)
11486 // Issue 1268 — a failure that cannot change is not pending
11487 // work. This used to retry six `create_service` calls on EVERY
11488 // spin, for the life of an image that would never serve them,
11489 // and the result was discarded; 1271 made it say so once, which
11490 // left the retrying. An explicit
11491 // `register_parameter_services()` still tries, because that is
11492 // a caller asking rather than the spin guessing.
11493 && p.reconcile_failure.as_ref().is_none_or(|f| !f.permanent)
11494 })
11495 }
11496
11497 /// Issue 1271 -- say, once, that the parameter services could not be
11498 /// created, instead of discarding the result on every spin.
11499 ///
11500 /// The spin keeps retrying (a node registered later, or a transport that
11501 /// comes up late, may still succeed); this only decides whether anyone is
11502 /// told. Cold and out of line: it formats, and `spin_once`'s frame must
11503 /// not carry that.
11504 #[cold]
11505 #[inline(never)]
11506 fn report_parameter_service_reconcile_failure(&mut self, e: NodeError) {
11507 let Some(params) = self.params.as_deref_mut() else {
11508 return;
11509 };
11510 if params.reconcile_failure_reported {
11511 return;
11512 }
11513 params.reconcile_failure_reported = true;
11514 // Issue 1268 — name the node and the service. "could not be created
11515 // ({:?})" named neither, so a reader could not tell a name too long for
11516 // one node from a backend that will serve none of the six.
11517 let (node_fqn, service, permanent) = match params.reconcile_failure.as_ref() {
11518 Some(f) => (f.node_fqn.as_str(), f.service, f.permanent),
11519 None => ("<unknown node>", "<unknown service>", false),
11520 };
11521 nros_log::log_error!(
11522 nros_log::get_logger("nros"),
11523 "parameter services could not be created for node {} at service {}: {:?}. \
11524 `ros2 param` cannot reach {} of this image's {} node(s). {} (issues 1271, 1268).",
11525 node_fqn,
11526 service,
11527 e,
11528 self.nodes.len().max(1) - params.services.len(),
11529 self.nodes.len().max(1),
11530 if permanent {
11531 "Not retrying: this failure cannot change without a rebuild."
11532 } else {
11533 "Retrying on every spin; logged once."
11534 }
11535 );
11536 }
11537
11538 /// phase-426 W3 — publish one set of six under every node that does not
11539 /// have one yet.
11540 ///
11541 /// **Why a reconcile and not a registration.** `nros::main!` emits
11542 /// `apply_param_services` BEFORE its per-node `register` calls, by design:
11543 /// the store has to exist when each component cell captures it. So at the
11544 /// moment the request is made the node table is EMPTY and there is no FQN
11545 /// to publish under — registering there is how the six ended up pinned to
11546 /// the executor's own identity in the first place. The request is recorded
11547 /// and satisfied here, at the head of every spin, exactly as
11548 /// `reconcile_ros_time_source` does for `use_sim_time` and for the same
11549 /// reason.
11550 ///
11551 /// **An executor with no registered node still gets one set**, under its
11552 /// own `node_name`/`namespace` and keyed `NodeId::PRIMARY` — the identity
11553 /// [`declare_parameter`](Self::declare_parameter) writes to. That is the
11554 /// pre-W3 behaviour, preserved for the direct-API images that never call
11555 /// `node_builder` (`param-chatter-talker` is one), and it is keyed the same
11556 /// as the first node record, so a node registered later inherits the set
11557 /// rather than getting a second one under a different name.
11558 ///
11559 /// Cheap in the settled case: one length comparison — but the FRAME is not
11560 /// cheap, so it is `inline(never)` and the spin site tests
11561 /// [`parameter_services_pending`](Self::parameter_services_pending) first.
11562 /// Building one set used to materialise six `EmbeddedServiceServer`s by
11563 /// value (2 x `PARAM_SERVICE_BUFFER_SIZE` each, ~48 KiB at the 4096
11564 /// default) before they were boxed; inlined into `spin_once` that landed
11565 /// on every spin frame whether or not the branch was taken. Issue 1270
11566 /// took the buffers out of the set -- one pair per executor, allocated on
11567 /// the heap directly -- but the set still carries six backend handles and
11568 /// a name, so the frame stays out of `spin_once`. Issue 0756's rule one
11569 /// call up — a parameter-service frame does not fit an embedded thread
11570 /// stack, and `test_open_threaded_spawn_and_halt` died with a bare SIGSEGV
11571 /// the first time this was reachable from the spin path.
11572 #[inline(never)]
11573 pub(crate) fn reconcile_parameter_services(&mut self) -> Result<(), NodeError> {
11574 let Some(params) = self.params.as_deref() else {
11575 return Ok(());
11576 };
11577 if !params.requested {
11578 return Ok(());
11579 }
11580 // The nodes wanted, minus the ones already served. Collected first
11581 // because building a set borrows `self.session` mutably while
11582 // `self.nodes` / `self.params` are still borrowed by the scan.
11583 let want = self.nodes.len().max(1);
11584 if params.services.len() >= want {
11585 return Ok(());
11586 }
11587 let mut todo: heapless::Vec<
11588 (u8, heapless::String<64>, heapless::String<64>),
11589 { crate::param_sizing::MAX_SERVICE_SETS },
11590 > = heapless::Vec::new();
11591 for index in 0..want {
11592 let key = nros_params::NodeKey::new(index as u8);
11593 if params.services.iter().any(|set| set.param_node() == key) {
11594 continue;
11595 }
11596 let (name, namespace) = match self.nodes.get(index) {
11597 Some(record) => (record.name.clone(), record.namespace.clone()),
11598 // No node table entry: the executor's own implicit primary
11599 // identity, which is what `NodeId::PRIMARY` means.
11600 None => (self.node_name.clone(), self.namespace.clone()),
11601 };
11602 if todo.push((index as u8, name, namespace)).is_err() {
11603 return Err(NodeError::NodeTableFull);
11604 }
11605 }
11606 // Issue 1270 -- the ONE buffer pair every set is answered through,
11607 // allocated with the first set so an image that declares parameters
11608 // and serves none pays nothing for it. `params` is `Some` here: the
11609 // scan above returned early otherwise.
11610 // Issue 1812 -- a carved home hands its pair over with the state, so
11611 // this is reached only on the heap fallback.
11612 if let Some(params) = self.params.as_deref_mut()
11613 && params.buffers.is_none()
11614 {
11615 // Issue 1551 — an exhausted heap is an error this fn returns, not
11616 // an abort through libstd's OOM hook.
11617 #[cfg(feature = "alloc")]
11618 {
11619 params.buffers = Some(
11620 crate::parameter_services::ParamServiceBuffers::try_with_capacity(
11621 crate::parameter_services::param_service_buffer_bytes(),
11622 )
11623 .ok_or(NodeError::Transport(TransportError::BadAlloc))?,
11624 );
11625 }
11626 #[cfg(not(feature = "alloc"))]
11627 return Err(NodeError::Transport(TransportError::BadAlloc));
11628 }
11629 for (index, name, namespace) in todo {
11630 let servers = match self.build_parameter_service_set(
11631 nros_params::NodeKey::new(index),
11632 &name,
11633 &namespace,
11634 ) {
11635 Ok(servers) => servers,
11636 Err((e, service)) => {
11637 // Issue 1268 — keep WHAT failed and WHERE, for the one log
11638 // line, and decide once whether retrying could ever help.
11639 // `Unsupported` from the transport is a property of the
11640 // build (the backend has no descriptor for the
11641 // `rcl_interfaces` types), so a later spin asks the same
11642 // question and gets the same answer.
11643 let permanent = matches!(e, NodeError::Transport(TransportError::Unsupported));
11644 if let Some(params) = self.params.as_deref_mut() {
11645 let mut node_fqn = heapless::String::<256>::new();
11646 if let Ok(fqn) = crate::names::fully_qualified_name(&name, &namespace) {
11647 let _ = node_fqn.push_str(fqn.as_str());
11648 }
11649 params.reconcile_failure =
11650 Some(crate::parameter_services::ParamServiceReconcileFailure {
11651 node_fqn,
11652 service,
11653 permanent,
11654 });
11655 }
11656 return Err(e);
11657 }
11658 };
11659 // Issue 1812 -- the set lives INLINE in the (carved) state now, not
11660 // in a box of its own.
11661 // Issue 0745 — PRESERVE an already-initialized store: launch-param
11662 // seeding may have run before any node existed (services can't be
11663 // registered that early). Overwriting here would wipe the seeds.
11664 match &mut self.params {
11665 Some(params) => params
11666 .services
11667 .push(servers)
11668 .map_err(|_| NodeError::NodeTableFull)?,
11669 None => {
11670 // Issue 0756 — see new_param_state: never build a ParamState
11671 // by value, it does not fit an embedded thread stack.
11672 // Issue 1706 -- the store is fallible; the refusal is
11673 // reported once by `ensure_parameter_store`'s spelling.
11674 if self.param_store_refused {
11675 return Err(NodeError::Transport(TransportError::BadAlloc));
11676 }
11677 let Some(mut state) = Self::new_param_state(
11678 core::mem::take(&mut self.param_slots),
11679 core::mem::take(&mut self.param_home),
11680 ) else {
11681 self.param_store_refused = true;
11682 report_parameter_store_refused();
11683 return Err(NodeError::Transport(TransportError::BadAlloc));
11684 };
11685 state.requested = true;
11686 let _ = state.services.push(servers);
11687 self.params = Some(state);
11688 }
11689 }
11690 // phase-430 W2 — "declare `use_sim_time` on EVERY node, as rclcpp
11691 // does", said where the nodes finally exist. This is the only place
11692 // that knows the full set: `ensure_parameter_store` runs before any
11693 // node record does, so it can only seed the implicit PRIMARY, and
11694 // the further nodes an image composes onto one executor arrive
11695 // later. Seeding as each set of six goes up keeps the two in step —
11696 // the parameter is declared on exactly the nodes a
11697 // `ros2 param set <node> use_sim_time true` can reach.
11698 //
11699 // Idempotent by construction: `declare` refuses a name the node
11700 // already holds and the placeholder flag is only raised on
11701 // acceptance, so re-seeding PRIMARY here neither duplicates the
11702 // entry nor re-marks an application's own declaration as ours.
11703 self.seed_use_sim_time_default(super::node_record::NodeId::from_raw(index));
11704 }
11705 // Issue 1271 -- every node is served now, so a later failure (a node
11706 // registered after this one) is news again and earns its own line.
11707 if let Some(params) = self.params.as_deref_mut() {
11708 params.reconcile_failure_reported = false;
11709 // Issue 1268 -- and drop the record with it, so a later failure is
11710 // reported against the node it actually happened on.
11711 params.reconcile_failure = None;
11712 }
11713 Ok(())
11714 }
11715
11716 /// phase-426 W3 — create the six service servers for ONE node.
11717 ///
11718 /// Split out of [`register_parameter_services`](Self::register_parameter_services)
11719 /// because there are now N of these per image rather than one, and because
11720 /// the six `create_param_srv` call sites are what
11721 /// `check-infra-queryable-counts` reads to hold `PARAM_SERVICE_QUERYABLES`
11722 /// to the number of queryables a set actually claims.
11723 #[inline(never)]
11724 fn build_parameter_service_set(
11725 &mut self,
11726 node: nros_params::NodeKey,
11727 node_name: &str,
11728 namespace: &str,
11729 ) -> Result<crate::parameter_services::ParameterServiceServers, (NodeError, &'static str)> {
11730 use crate::parameter_services::{
11731 DescribeParameters, GetParameterTypes, GetParameters, ListParameters,
11732 ParamServiceHandle as PSrv, ParameterServiceServers, SetParameters,
11733 SetParametersAtomically,
11734 };
11735 use nros_core::RosService;
11736
11737 // The node FQN, from the one implementation. This was the third
11738 // hand-rolled copy of the same join (`crate::names::fully_qualified_name`
11739 // is the first, `Node::fully_qualified_name` was the second) — and the
11740 // three did not agree: this one absolutised a bare namespace, that one
11741 // did not. A join with three spellings has no answer to "what does the
11742 // tree do with `my_ns`".
11743 let node_fqn = crate::names::fully_qualified_name(node_name, namespace)
11744 .map_err(|()| (NodeError::NameTooLong, "get_parameters"))?;
11745 // The service names below still take the two halves separately.
11746 let ns: &str = namespace;
11747 let nn: &str = node_name;
11748
11749 /// Build a service name like `{node_fqn}/{suffix}` and create the server handle.
11750 ///
11751 /// Issue 1268 — the error carries the SUFFIX, so a failure can name the
11752 /// service a tool would have called instead of "one of six".
11753 fn create_param_srv<Svc>(
11754 session: &mut session::ConcreteSession,
11755 domain_id: u32,
11756 node_fqn: &str,
11757 namespace: &str,
11758 node_name: &str,
11759 suffix: &'static str,
11760 ) -> Result<session::RmwServiceServer, (NodeError, &'static str)>
11761 where
11762 Svc: RosService,
11763 Svc::Request: crate::rmw_type_registry::MessageForRmw,
11764 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
11765 {
11766 // Issue 1268 — REGISTER THE TYPES FIRST, exactly as a typed user
11767 // service does (`register_service_sized`). A backend that keys
11768 // topics on a registered type descriptor — Cyclone does — fails
11769 // `create_service` with `Unsupported` for a type it has never been
11770 // told about, and nothing else in the image registers the
11771 // `rcl_interfaces` service types: the generated typesupport of a
11772 // downstream covers ITS OWN message packages, and the backend bakes
11773 // in only `ParticipantEntitiesInfo` for the graph. So the six
11774 // parameter services failed on every spin while the image's own
11775 // services worked, which is what made the cause look like "no
11776 // service support" for two phases (the issue-0745 comment).
11777 //
11778 // A no-op on backends that need no descriptors (zenoh, xrce), which
11779 // is why zenoh served this same image all along.
11780 let register = |e: NodeError| (e, suffix);
11781 crate::rmw_type_registry::register_type::<Svc::Request>().map_err(register)?;
11782 crate::rmw_type_registry::register_type::<Svc::Reply>().map_err(register)?;
11783 let mut name = heapless::String::<256>::new();
11784 name.push_str(node_fqn)
11785 .map_err(|_| (NodeError::NameTooLong, suffix))?;
11786 name.push_str("/")
11787 .map_err(|_| (NodeError::NameTooLong, suffix))?;
11788 name.push_str(suffix)
11789 .map_err(|_| (NodeError::NameTooLong, suffix))?;
11790 let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
11791 // issue 0824 follow-up — `domain_id` is a PARAMETER, not `self.domain_id`:
11792 // this is a nested `fn`, not a method, so `self` is not in scope and
11793 // the original spelling was E0434. It only fails under feature sets
11794 // that compile this arm, which is why `--all-features` caught it and
11795 // the narrower lanes did not.
11796 .with_domain(domain_id)
11797 .with_namespace(namespace);
11798 if !node_name.is_empty() {
11799 info = info.with_node_name(node_name);
11800 }
11801 // issue 0793 — the parameter services get the PARAMETER profile
11802 // (`rmw_qos_profile_parameters`: KEEP_LAST(1000), reliable,
11803 // volatile), not the generic services one. `QOS_PROFILE_PARAMETERS`
11804 // existed with the right depth and had no caller, so every parameter
11805 // server ran on a depth-10 queue while ROS 2 gives them 1000 — which
11806 // matters exactly when a tool sets many parameters at once, the case
11807 // the deep queue is for.
11808 session
11809 .create_service(&info, QoSProfile::parameter_services_default())
11810 .map_err(|e| (NodeError::Transport(e), suffix))
11811 }
11812
11813 let get_handle = create_param_srv::<GetParameters>(
11814 &mut self.session,
11815 self.domain_id,
11816 &node_fqn,
11817 ns,
11818 nn,
11819 "get_parameters",
11820 )?;
11821 let set_handle = create_param_srv::<SetParameters>(
11822 &mut self.session,
11823 self.domain_id,
11824 &node_fqn,
11825 ns,
11826 nn,
11827 "set_parameters",
11828 )?;
11829 let set_atomic_handle = create_param_srv::<SetParametersAtomically>(
11830 &mut self.session,
11831 self.domain_id,
11832 &node_fqn,
11833 ns,
11834 nn,
11835 "set_parameters_atomically",
11836 )?;
11837 let list_handle = create_param_srv::<ListParameters>(
11838 &mut self.session,
11839 self.domain_id,
11840 &node_fqn,
11841 ns,
11842 nn,
11843 "list_parameters",
11844 )?;
11845 let desc_handle = create_param_srv::<DescribeParameters>(
11846 &mut self.session,
11847 self.domain_id,
11848 &node_fqn,
11849 ns,
11850 nn,
11851 "describe_parameters",
11852 )?;
11853 let types_handle = create_param_srv::<GetParameterTypes>(
11854 &mut self.session,
11855 self.domain_id,
11856 &node_fqn,
11857 ns,
11858 nn,
11859 "get_parameter_types",
11860 )?;
11861
11862 Ok(ParameterServiceServers::new(
11863 // phase-426 W3 — the set carries the node whose parameters it
11864 // serves, so a handler can never read the store without saying
11865 // whose parameters it means.
11866 node,
11867 // Issue 1271 -- carried so a dropped request is reported against
11868 // the service a tool actually called.
11869 node_fqn,
11870 // Issue 1270 -- handles only. The buffers are the executor's one
11871 // shared pair (`ParamState::buffers`), not six per node.
11872 PSrv::<GetParameters>::new(get_handle),
11873 PSrv::<SetParameters>::new(set_handle),
11874 PSrv::<SetParametersAtomically>::new(set_atomic_handle),
11875 PSrv::<ListParameters>::new(list_handle),
11876 PSrv::<DescribeParameters>::new(desc_handle),
11877 PSrv::<GetParameterTypes>::new(types_handle),
11878 ))
11879 }
11880
11881 /// phase-426 W3 — the node FQNs this executor publishes parameter services
11882 /// under, in registration order.
11883 ///
11884 /// The acceptance question for W3 ("does a two-node image expose both
11885 /// FQNs?") answered without a wire: the six services are created from
11886 /// exactly this list, so asserting it asserts what `ros2 param list` will
11887 /// enumerate. The wire assertion is W6.
11888 pub fn parameter_service_node_names(
11889 &self,
11890 ) -> heapless::Vec<crate::names::ResolvedName, { crate::param_sizing::MAX_SERVICE_SETS }> {
11891 let mut out = heapless::Vec::new();
11892 let Some(params) = self.params.as_deref() else {
11893 return out;
11894 };
11895 for set in params.services.iter() {
11896 let index = set.param_node().raw() as usize;
11897 let (name, namespace) = match self.nodes.get(index) {
11898 Some(record) => (record.name.as_str(), record.namespace.as_str()),
11899 None => (self.node_name.as_str(), self.namespace.as_str()),
11900 };
11901 if let Ok(fqn) = crate::names::fully_qualified_name(name, namespace) {
11902 let _ = out.push(fqn);
11903 }
11904 }
11905 out
11906 }
11907
11908 // phase-359 W10 / issue 0080 — `enable_parameter_persistence{,_with}` are
11909 // GONE with the seam they attached. Issue 0080 ruled on-device parameter
11910 // persistence a non-goal in July and listed both by name; nothing in the
11911 // tree ever called them, and the only backend that could be passed
11912 // (`FileParamStore`) is deleted. Runtime get/set/describe stay — it is the
11913 // PERSISTENCE half that was dropped.
11914}
11915
11916// ============================================================================
11917// Lifecycle services (cfg lifecycle-services)
11918// ============================================================================
11919
11920#[cfg(feature = "lifecycle-services")]
11921impl<'s> Executor<'s> {
11922 /// Register the five REP-2002 lifecycle services on this executor.
11923 ///
11924 /// After this call, `ros2 lifecycle set|get|list|nodes` can drive the
11925 /// stored [`LifecyclePollingNodeCtx`](crate::lifecycle::LifecyclePollingNodeCtx)
11926 /// through the node's lifecycle. The state machine is created fresh
11927 /// (starting in `Unconfigured`); callers register their transition
11928 /// callbacks via [`Executor::lifecycle_state_machine_mut`].
11929 ///
11930 /// # Safety
11931 /// Registered callbacks on the state machine are C FFI function pointers.
11932 /// The caller must keep the callback code and any context it captures
11933 /// valid for as long as the executor processes services.
11934 pub fn register_lifecycle_services(&mut self) -> Result<(), NodeError> {
11935 use crate::{
11936 lifecycle::LifecyclePollingNodeCtx,
11937 lifecycle_services::{
11938 ChangeState, GetAvailableStates, GetAvailableTransitions, GetState,
11939 LIFECYCLE_SERVICE_BUFFER_SIZE, LifecycleRuntimeState, LifecycleServiceServers,
11940 },
11941 };
11942 use nros_core::RosService;
11943
11944 type LcSrv<Svc> = super::handles::EmbeddedServiceServer<
11945 Svc,
11946 LIFECYCLE_SERVICE_BUFFER_SIZE,
11947 LIFECYCLE_SERVICE_BUFFER_SIZE,
11948 >;
11949
11950 // Build the node FQN from namespace + node_name (same convention as
11951 // register_parameter_services).
11952 let mut node_fqn = heapless::String::<256>::new();
11953 let ns: &str = &self.namespace;
11954 let nn: &str = &self.node_name;
11955 if ns.is_empty() || ns == "/" {
11956 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
11957 node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
11958 } else {
11959 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
11960 node_fqn
11961 .push_str(ns.trim_matches('/'))
11962 .map_err(|_| NodeError::NameTooLong)?;
11963 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
11964 node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
11965 }
11966
11967 fn create_lc_srv<Svc>(
11968 session: &mut session::ConcreteSession,
11969 domain_id: u32,
11970 node_fqn: &str,
11971 namespace: &str,
11972 node_name: &str,
11973 suffix: &str,
11974 ) -> Result<session::RmwServiceServer, NodeError>
11975 where
11976 Svc: RosService,
11977 Svc::Request: crate::rmw_type_registry::MessageForRmw,
11978 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
11979 {
11980 // Issue 1293 — register both halves first, as `create_param_srv`
11981 // does since issue 1268: Cyclone creates a topic only for a type it
11982 // holds a descriptor for. Three of the five request types are
11983 // EMPTY, and an empty schema had no descriptor until codegen gave
11984 // it rosidl's `structure_needs_at_least_one_member` byte. A no-op
11985 // on backends that need no descriptors (zenoh, xrce).
11986 crate::rmw_type_registry::register_type::<Svc::Request>()?;
11987 crate::rmw_type_registry::register_type::<Svc::Reply>()?;
11988 let mut name = heapless::String::<256>::new();
11989 name.push_str(node_fqn)
11990 .map_err(|_| NodeError::NameTooLong)?;
11991 name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
11992 name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
11993 let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
11994 .with_domain(domain_id)
11995 .with_namespace(namespace);
11996 if !node_name.is_empty() {
11997 info = info.with_node_name(node_name);
11998 }
11999 session
12000 .create_service(&info, QoSProfile::services_default())
12001 .map_err(NodeError::Transport)
12002 }
12003
12004 /// Issue 1587 — the ONE publisher the REP-2002 family claims,
12005 /// `~/transition_event`. Shaped like `create_lc_srv` beside it both
12006 /// because the naming rule is the same (node FQN + suffix) and
12007 /// because `check-infra-queryable-counts` counts the creation sites
12008 /// to hold `LIFECYCLE_SERVICE_PUBLISHERS` to them, exactly as it
12009 /// already holds `LIFECYCLE_SERVICE_QUERYABLES` to the five above.
12010 fn create_lc_pub<Msg: crate::rmw_type_registry::MessageForRmw>(
12011 session: &mut session::ConcreteSession,
12012 domain_id: u32,
12013 node_fqn: &str,
12014 namespace: &str,
12015 node_name: &str,
12016 suffix: &str,
12017 qos: QoSProfile,
12018 ) -> Result<session::RmwPublisher, NodeError> {
12019 // Issue 1293 — same rule as `create_lc_srv`: Cyclone needs the
12020 // `TransitionEvent` descriptor before it can create the topic.
12021 crate::rmw_type_registry::register_type::<Msg>()?;
12022 let mut name = heapless::String::<256>::new();
12023 name.push_str(node_fqn)
12024 .map_err(|_| NodeError::NameTooLong)?;
12025 name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
12026 name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
12027 let mut info = TopicInfo::new(
12028 &name,
12029 <Msg as nros_core::RosMessage>::TYPE_NAME,
12030 Msg::TYPE_HASH,
12031 )
12032 .with_domain(domain_id)
12033 .with_namespace(namespace);
12034 if !node_name.is_empty() {
12035 info = info.with_node_name(node_name);
12036 }
12037 session
12038 .create_publisher(&info, qos)
12039 .map_err(NodeError::Transport)
12040 }
12041
12042 let cs_handle = create_lc_srv::<ChangeState>(
12043 &mut self.session,
12044 self.domain_id,
12045 &node_fqn,
12046 ns,
12047 nn,
12048 "change_state",
12049 )?;
12050 let gs_handle = create_lc_srv::<GetState>(
12051 &mut self.session,
12052 self.domain_id,
12053 &node_fqn,
12054 ns,
12055 nn,
12056 "get_state",
12057 )?;
12058 let gas_handle = create_lc_srv::<GetAvailableStates>(
12059 &mut self.session,
12060 self.domain_id,
12061 &node_fqn,
12062 ns,
12063 nn,
12064 "get_available_states",
12065 )?;
12066 let gat_handle = create_lc_srv::<GetAvailableTransitions>(
12067 &mut self.session,
12068 self.domain_id,
12069 &node_fqn,
12070 ns,
12071 nn,
12072 "get_available_transitions",
12073 )?;
12074 let gtg_handle = create_lc_srv::<GetAvailableTransitions>(
12075 &mut self.session,
12076 self.domain_id,
12077 &node_fqn,
12078 ns,
12079 nn,
12080 "get_transition_graph",
12081 )?;
12082
12083 // Issue 1587 — REP-2002's communication interface is the five
12084 // services PLUS this. VOLATILE, so it costs a publisher slot and a
12085 // liveliness token and NO queryable; see
12086 // `LIFECYCLE_SERVICE_PUBLISHERS` for the measurement that settled it.
12087 let te_handle = create_lc_pub::<nros_lifecycle_msgs::msg::TransitionEvent>(
12088 &mut self.session,
12089 self.domain_id,
12090 &node_fqn,
12091 ns,
12092 nn,
12093 crate::lifecycle_services::TRANSITION_EVENT_SUFFIX,
12094 crate::lifecycle_services::transition_event_qos(),
12095 )?;
12096
12097 let servers = LifecycleServiceServers::new(
12098 LcSrv::<ChangeState> {
12099 handle: cs_handle,
12100 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12101 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12102 _phantom: core::marker::PhantomData,
12103 },
12104 LcSrv::<GetState> {
12105 handle: gs_handle,
12106 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12107 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12108 _phantom: core::marker::PhantomData,
12109 },
12110 LcSrv::<GetAvailableStates> {
12111 handle: gas_handle,
12112 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12113 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12114 _phantom: core::marker::PhantomData,
12115 },
12116 LcSrv::<GetAvailableTransitions> {
12117 handle: gat_handle,
12118 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12119 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12120 _phantom: core::marker::PhantomData,
12121 },
12122 LcSrv::<GetAvailableTransitions> {
12123 handle: gtg_handle,
12124 req_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12125 reply_buffer: [0u8; LIFECYCLE_SERVICE_BUFFER_SIZE],
12126 _phantom: core::marker::PhantomData,
12127 },
12128 );
12129
12130 self.lifecycle = Some(alloc::boxed::Box::new(LifecycleRuntimeState {
12131 state_machine: LifecyclePollingNodeCtx::new(),
12132 services: alloc::boxed::Box::new(servers),
12133 transition_event: te_handle,
12134 }));
12135
12136 Ok(())
12137 }
12138
12139 /// The `~/transition_event` publisher, for the tests that assert what
12140 /// reached the wire. Not a public capability: the executor announces, and
12141 /// a second publisher on the same topic would be a second source of truth.
12142 /// Its only callers are the mock-session tests, which a `rmw-cffi` build
12143 /// does not compile.
12144 #[cfg(all(test, not(feature = "rmw-cffi")))]
12145 pub(crate) fn lifecycle_transition_event_publisher(&self) -> Option<&session::RmwPublisher> {
12146 self.lifecycle.as_ref().map(|lc| &lc.transition_event)
12147 }
12148
12149 /// Mutable access to the lifecycle state machine, if registered.
12150 ///
12151 /// Used to register transition callbacks before spinning and to read the
12152 /// current state from application code.
12153 pub fn lifecycle_state_machine_mut(
12154 &mut self,
12155 ) -> Option<&mut crate::lifecycle::LifecyclePollingNodeCtx> {
12156 self.lifecycle.as_mut().map(|lc| &mut lc.state_machine)
12157 }
12158
12159 /// Immutable access to the lifecycle state machine, if registered.
12160 pub fn lifecycle_state_machine(&self) -> Option<&crate::lifecycle::LifecyclePollingNodeCtx> {
12161 self.lifecycle.as_ref().map(|lc| &lc.state_machine)
12162 }
12163
12164 /// Register a safe lifecycle node (issue 0335 / phase-317).
12165 ///
12166 /// Binds the five REP-2002 lifecycle services and wires each transition to
12167 /// the [`LifecycleCallbacks`](crate::lifecycle::LifecycleCallbacks) trait
12168 /// impl — the safe Rust counterpart to the C++ `nros::LifecycleNode`. No
12169 /// `unsafe` in user code; alloc-free (monomorphized trampolines).
12170 ///
12171 /// `node` must outlive this executor's lifecycle registration: the context
12172 /// pointer stored here is dereferenced on every transition until the
12173 /// executor is finalized. The executor spins single-threaded, so the
12174 /// `&mut node` reconstituted inside a callback is never aliased.
12175 pub fn register_lifecycle_node<T: crate::lifecycle::LifecycleCallbacks>(
12176 &mut self,
12177 node: &mut T,
12178 ) -> Result<(), NodeError> {
12179 use crate::lifecycle::{LifecycleCallbackSlot as Slot, trampolines};
12180
12181 self.register_lifecycle_services()?;
12182 let sm = self
12183 .lifecycle_state_machine_mut()
12184 .ok_or(NodeError::NotInitialized)?;
12185 sm.set_context(node as *mut T as *mut core::ffi::c_void);
12186 sm.register(Slot::Configure, Some(trampolines::on_configure::<T>));
12187 sm.register(Slot::Activate, Some(trampolines::on_activate::<T>));
12188 sm.register(Slot::Deactivate, Some(trampolines::on_deactivate::<T>));
12189 sm.register(Slot::Cleanup, Some(trampolines::on_cleanup::<T>));
12190 sm.register(Slot::Shutdown, Some(trampolines::on_shutdown::<T>));
12191 sm.register(Slot::Error, Some(trampolines::on_error::<T>));
12192 Ok(())
12193 }
12194}
12195
12196// ============================================================================
12197// phase-446 W6 -- the contract's declared parameters (cfg param-services)
12198// ============================================================================
12199
12200#[cfg(feature = "param-store")]
12201impl<'s> Executor<'s> {
12202 /// Hand the executor the parameters each node's contract declares.
12203 ///
12204 /// The entry calls this before any node registers. It only RECORDS the
12205 /// table: the check runs where an application declares a parameter (the
12206 /// `nros` node runtime), not in [`Self::declare_parameter_on`], because
12207 /// that funnel also carries the launch seed, whose value type is still
12208 /// inferred from text and was already held to the contract by the
12209 /// resolver.
12210 pub fn set_declared_params(&mut self, declared: nros_params::DeclaredParams) {
12211 self.declared_params = declared;
12212 }
12213
12214 /// The table [`Self::set_declared_params`] recorded (empty by default).
12215 pub fn declared_params(&self) -> nros_params::DeclaredParams {
12216 self.declared_params
12217 }
12218}
12219
12220// ============================================================================
12221// Parameter declaration API (cfg param-services)
12222// ============================================================================
12223
12224#[cfg(feature = "param-store")]
12225impl<'s> Executor<'s> {
12226 /// phase-461 W6 — build the parameter STORE and register no service.
12227 ///
12228 /// What an image declaring the `params` axis (and not `param_services`)
12229 /// calls ahead of its nodes: launch seeds and `declare_parameter` land in
12230 /// the store, and `ros2 param` reaches none of it, because no queryable
12231 /// was created. The primary node's `use_sim_time` placeholder is seeded
12232 /// as it is for a served store; nothing can SET it here, which is the
12233 /// axis' stated limit.
12234 ///
12235 /// Idempotent, and a later [`Self::register_parameter_services`] keeps
12236 /// this store and adds the six services on top of it.
12237 pub fn register_parameter_store(&mut self) -> Result<(), NodeError> {
12238 if self.ensure_parameter_store() {
12239 Ok(())
12240 } else {
12241 Err(NodeError::Transport(TransportError::BadAlloc))
12242 }
12243 }
12244
12245 /// Issue 0745 — lazily create the parameter STORE (no services yet).
12246 /// Launch-param seeding runs before any node is constructed; the six
12247 /// service servers attach later in `register_parameter_services`,
12248 /// which preserves this store.
12249 /// `true` when the store exists afterwards. `false` only when the platform
12250 /// heap refused its allocation (issue 1706), which is reported once, here,
12251 /// with the size it asked for.
12252 fn ensure_parameter_store(&mut self) -> bool {
12253 if self.params.is_none() {
12254 if self.param_store_refused {
12255 return false;
12256 }
12257 let Some(state) = Self::new_param_state(
12258 core::mem::take(&mut self.param_slots),
12259 core::mem::take(&mut self.param_home),
12260 ) else {
12261 self.param_store_refused = true;
12262 report_parameter_store_refused();
12263 return false;
12264 };
12265 self.params = Some(state);
12266 // phase-430 W2 — the executor's implicit PRIMARY node exists the
12267 // moment the store does, whether or not a node record has been
12268 // built yet, so it is seeded here. The other node keys are seeded
12269 // in `reconcile_parameter_services`, as each one's set of six goes
12270 // up.
12271 self.seed_use_sim_time_default(super::node_record::NodeId::PRIMARY);
12272 }
12273 true
12274 }
12275
12276 /// phase-430 W2 — declare `use_sim_time = false`, as rclcpp does on EVERY
12277 /// node.
12278 ///
12279 /// Upstream auto-declares the parameter in `rclcpp::Node`'s constructor, so
12280 /// `ros2 param list` shows it and `ros2 param set <node> use_sim_time true`
12281 /// reaches a node whose author never wrote a line about simulated time.
12282 /// Ours honoured the parameter only if the APP had declared it: the store
12283 /// refuses an undeclared set (`allow_undeclared` is false), so the runtime
12284 /// switch worked on exactly the nodes that least needed it.
12285 ///
12286 /// The seed is a PLACEHOLDER, tracked by `ParamState::sim_time_seeded`,
12287 /// because the store refuses a duplicate name: without that flag, seeding
12288 /// here would turn an application's own `declare_parameter("use_sim_time",
12289 /// Bool(true))` into a refusal and the auto-declared default would silently
12290 /// beat the app. `yield_seeded_use_sim_time` steps the placeholder aside
12291 /// for the first real declaration, whatever value or descriptor it carries.
12292 ///
12293 /// PER NODE, both here and in the flag. phase-426 keyed the store and the
12294 /// six services by node, so "every node" is now literally expressible and
12295 /// this is where it is said: `ensure_parameter_store` seeds the executor's
12296 /// implicit PRIMARY, and `reconcile_parameter_services` seeds each further
12297 /// node key as its set of six goes up — which is exactly the set of nodes a
12298 /// `ros2 param set <node> use_sim_time true` can reach.
12299 ///
12300 /// It deliberately does NOT go through `note_reserved_parameter`, so
12301 /// `sim_time_stated` stays false: an auto-declared default is not a
12302 /// STATEMENT by anyone. Recording it as one would make every executor with
12303 /// parameter services write `set_active(false)` onto a PROCESS-GLOBAL gate
12304 /// on its first spin — switching off a simulated clock another executor in
12305 /// the same image installed, which is exactly the collision
12306 /// `sim_time_stated` was added for (issue 1104). That property is per node
12307 /// too: seeding N nodes must still be N statements by nobody.
12308 ///
12309 /// Gated on `sim-time` as well as `param-services`: an image that cannot
12310 /// act on the parameter should not advertise it, because a settable knob
12311 /// that does nothing is worse than an absent one.
12312 fn seed_use_sim_time_default(&mut self, node: super::node_record::NodeId) {
12313 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
12314 if let Some(params) = self.params.as_deref_mut()
12315 && let Some(seeded) = params.sim_time_seeded.get_mut(node.index())
12316 && params.server.declare(
12317 node.into(),
12318 crate::time_source::USE_SIM_TIME_PARAM,
12319 nros_params::ParameterValue::Bool(false),
12320 )
12321 {
12322 *seeded = true;
12323 }
12324 let _ = node;
12325 }
12326
12327 /// phase-430 W2 — step the seeded `use_sim_time` aside for an application
12328 /// that declares it itself on `node`. Returns whether it did.
12329 ///
12330 /// The app's declaration must WIN — its own default, its own descriptor —
12331 /// and a declaration is refused when the name is already taken, so the
12332 /// placeholder has to leave before the real one arrives. Only ever the
12333 /// placeholder: once a real declaration has landed the flag is false and a
12334 /// second declaration is refused exactly as it was before W2.
12335 ///
12336 /// Only ever THIS node's placeholder, too: an app that names
12337 /// `use_sim_time` on one node of an image has said nothing about the
12338 /// others, and their seeds stay placeholders for whoever declares next.
12339 fn yield_seeded_use_sim_time(&mut self, node: super::node_record::NodeId, name: &str) -> bool {
12340 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
12341 if name == crate::time_source::USE_SIM_TIME_PARAM
12342 && let Some(params) = self.params.as_deref_mut()
12343 && let Some(seeded) = params.sim_time_seeded.get_mut(node.index())
12344 && core::mem::replace(seeded, false)
12345 {
12346 return params.server.remove(node.into(), name);
12347 }
12348 let _ = (node, name);
12349 false
12350 }
12351
12352 /// The HEAP home for the parameter slot table, used only when the
12353 /// executor's backing carved none.
12354 ///
12355 /// phase-382 W2' — `ParameterServer` no longer OWNS its slots; it borrows
12356 /// a caller-placed [`nros_params::ParameterTable`]. phase-382 W3' carves
12357 /// that table out of the caller's executor backing whenever the build
12358 /// KNOWS the image builds a store ([`ExecutorSizing::params`](super::storage::ExecutorSizing::params), from
12359 /// `nros_params::IMPLIED_STORE_SLOTS`); this function is what remains for a
12360 /// store the build could not see — one reached only from application
12361 /// code — and for an executor opened with a sizing that carves none. It
12362 /// makes a single heap allocation, leaked, one per such executor that
12363 /// touches parameters. The leak is what buys the `'static`
12364 /// the borrow needs without making `ParamState` self-referential (a
12365 /// `ParamState` that owned the storage AND a server borrowing it is not
12366 /// expressible), and it is bounded — `ensure_parameter_store` runs this
12367 /// at most once per executor.
12368 ///
12369 /// Issue 0756 — the placement dance is still here because the SIZE is
12370 /// still here; it only moved off `ParameterServer` and onto
12371 /// `ParameterStorage`. That storage is 285,184 bytes at the default
12372 /// `MAX_PARAMETERS=32` and 2,281,472 at 256 (`ParameterValue` is sized by
12373 /// its `StringArray` variant, so every slot costs ~8.5 KiB regardless of
12374 /// what it holds). `Box::new(ParameterStorage::new())` materialises all of
12375 /// that on the caller's stack before copying it into the allocation,
12376 /// because Rust has no placement-new. On the Zephyr lane that silently
12377 /// overran the thread stack: an image built with 256 slots boots to
12378 /// `dds_create_participant` and hangs with no fault and no output, while
12379 /// 32 — which fits the 512 KiB main stack the cyclonedds snippet asks for
12380 /// — runs clean. Initialising through the allocation bounds the largest
12381 /// stack temporary at one slot, so the knob no longer decides whether boot
12382 /// survives.
12383 ///
12384 /// Issue 1706 -- FALLIBLE. This is the one allocation every parameter
12385 /// declaration reaches, in every language and on every RTOS, and it is the
12386 /// largest single request most images make. `Box::new_uninit` sent a
12387 /// failure to the OOM handler: Zephyr printed `HEAP EXHAUSTED` and died in
12388 /// issue 0589's recursion, FreeRTOS heap_4 hung in `MALLOC FAILED`. Every
12389 /// caller already has a refusal to return (`declare_parameter` is a
12390 /// `bool`), so the failure is now one, named by
12391 /// [`report_parameter_store_refused`] with the size it asked for.
12392 ///
12393 /// Issue 1812 -- `alloc` only. Without a heap there is no fallback, and
12394 /// the store is the carved one or none (see `new_param_state`).
12395 #[cfg(feature = "alloc")]
12396 fn leak_parameter_storage() -> Option<nros_params::ParameterTable<'static>> {
12397 #[cfg(all(test, feature = "std"))]
12398 if RefuseParameterStore::armed() {
12399 return None;
12400 }
12401 let mut uninit = nros_rmw::fallible::try_box_uninit::<nros_params::ParameterStorage>()?;
12402 // Safety: `try_box_uninit` gives a correctly-sized, correctly-aligned
12403 // allocation for exactly this type, and `init_in_place` writes every
12404 // slot exactly once before `assume_init` observes it.
12405 unsafe {
12406 nros_params::ParameterStorage::init_in_place(uninit.as_mut_ptr());
12407 Some(alloc::boxed::Box::leak(uninit.assume_init()).as_table())
12408 }
12409 }
12410
12411 /// Build the executor's `ParamState`.
12412 ///
12413 /// phase-382 W3' — `carved` are the backing's store slots: non-empty, they
12414 /// ARE the store and the heap is not asked; empty, the store falls back to
12415 /// [`leak_parameter_storage`](Self::leak_parameter_storage).
12416 ///
12417 /// Issue 1812 — and `home` is the services' carved region, which the
12418 /// backing places on the same condition: non-empty, the state, its service
12419 /// sets, the buffer pair and the staging slot are built IN it and nothing
12420 /// here allocates. Otherwise the state is one heap allocation, initialised
12421 /// in place (`ParamState::try_boxed` — it is several KiB now, so never a
12422 /// by-value `Box::new`). Without `alloc` that fallback does not exist and
12423 /// an executor whose backing carved nothing gets `None`, which every caller
12424 /// reports as the named refusal (`report_parameter_store_refused`).
12425 fn new_param_state(
12426 carved: &'s mut [MaybeUninit<nros_params::ParameterSlot>],
12427 home: &'s mut [MaybeUninit<super::storage::ParamHome>],
12428 ) -> Option<crate::param_store::Placed<'s, crate::param_store::ParamState<'s>>> {
12429 let table = if carved.is_empty() {
12430 #[cfg(feature = "alloc")]
12431 {
12432 Self::leak_parameter_storage()?
12433 }
12434 #[cfg(not(feature = "alloc"))]
12435 {
12436 return None;
12437 }
12438 } else {
12439 // Said once per store, so a boot log answers "where did the store
12440 // come from" without a debugger: issue 1706's measurements were
12441 // all heap-peak arithmetic, and this line is the direct answer.
12442 nros_log::log_info!(
12443 nros_log::get_logger("nros"),
12444 "parameter store: {} slots ({} B) + {} ({} B) carved from the executor backing",
12445 carved.len(),
12446 core::mem::size_of_val(carved),
12447 // Issue 1897 -- the home is the store's state, plus the
12448 // services' sets and buffers only where they are compiled.
12449 if cfg!(feature = "param-services") {
12450 "state and services"
12451 } else {
12452 "state"
12453 },
12454 core::mem::size_of_val(home)
12455 );
12456 nros_params::ParameterTable::init_in(carved)
12457 };
12458 let server = nros_params::ParameterServer::new_in(table);
12459 match home.first_mut() {
12460 Some(home) => Some(crate::param_store::ParamHome::init_in(home, server)),
12461 #[cfg(feature = "alloc")]
12462 None => crate::param_store::ParamState::try_boxed(server),
12463 #[cfg(not(feature = "alloc"))]
12464 None => None,
12465 }
12466 }
12467
12468 /// Install `-p` / `--params-file` parameter overrides — phase-482 W5.
12469 ///
12470 /// They are applied when a parameter is DECLARED: the declaration's value
12471 /// is replaced by the override's, typed by it (`apply_argv_param_override`).
12472 /// So an override reaches a parameter on every road that declares through
12473 /// this executor — C, C++ and Rust — including the launch file's baked
12474 /// values, which the generated entry declares through the same seam.
12475 ///
12476 /// That makes argv WIN over a launch-baked parameter, the opposite of the
12477 /// remap tiers, deliberately: overriding a baked value at run time without
12478 /// a rebuild is what RFC-0015 §9 asks for ("a runtime override has
12479 /// identical effect to a `set_parameter` request after boot"), and upstream
12480 /// agrees — a launch file's parameters reach the node as command-line
12481 /// arguments themselves, where the later one wins.
12482 ///
12483 /// Overrides accumulate in argv order and the LAST one that matches a
12484 /// declaration wins, as rcl's do. A `node:` override matches the node of
12485 /// that NAME; one without matches every node.
12486 ///
12487 /// Fails only when the parameter store could not be allocated (issue 1706),
12488 /// in which case nothing is installed.
12489 #[cfg(feature = "alloc")]
12490 pub fn install_argv_params<'p, I>(&mut self, params: I) -> Result<usize, NodeError>
12491 where
12492 I: IntoIterator<Item = crate::ros_args::ParamArg<'p>>,
12493 {
12494 if !self.ensure_parameter_store() {
12495 return Err(NodeError::Transport(TransportError::BadAlloc));
12496 }
12497 let Some(state) = self.params.as_deref_mut() else {
12498 return Err(NodeError::Transport(TransportError::BadAlloc));
12499 };
12500 let mut n = 0;
12501 for p in params {
12502 state
12503 .argv_overrides
12504 .push(crate::param_store::ArgvParamOverride {
12505 node: p.node.map(alloc::string::String::from),
12506 name: alloc::string::String::from(p.name),
12507 value: alloc::string::String::from(p.value),
12508 });
12509 n += 1;
12510 }
12511 Ok(n)
12512 }
12513
12514 /// The value a declaration of `name` on `node` should take — the last
12515 /// matching argv override, typed by `declared`, or `declared` itself — and
12516 /// whether an override was applied.
12517 ///
12518 /// An override whose text does not parse as the declared type is NOT
12519 /// applied: it is logged at error severity, naming the parameter, the text
12520 /// and the type, and the declared default stands (RFC-0015 §9.6 — "log +
12521 /// ignore, never panic"). An array-typed declaration takes no override yet.
12522 #[cfg(not(feature = "alloc"))]
12523 fn apply_argv_param_override(
12524 &self,
12525 _node: super::node_record::NodeId,
12526 _name: &str,
12527 declared: nros_params::ParameterValue,
12528 ) -> (nros_params::ParameterValue, bool) {
12529 // Issue 1812 -- argv overrides are a hosted-edge input installed
12530 // through `install_argv_params`, which is `alloc`-only.
12531 (declared, false)
12532 }
12533
12534 #[cfg(feature = "alloc")]
12535 fn apply_argv_param_override(
12536 &self,
12537 node: super::node_record::NodeId,
12538 name: &str,
12539 declared: nros_params::ParameterValue,
12540 ) -> (nros_params::ParameterValue, bool) {
12541 let Some(state) = self.params.as_deref() else {
12542 return (declared, false);
12543 };
12544 if state.argv_overrides.is_empty() {
12545 return (declared, false);
12546 }
12547 let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
12548 let Some(o) = state
12549 .argv_overrides
12550 .iter()
12551 .rev()
12552 .find(|o| o.name == name && o.node.as_deref().is_none_or(|n| n == node_name))
12553 else {
12554 return (declared, false);
12555 };
12556 match typed_param_override(&o.value, &declared) {
12557 Some(v) => (v, true),
12558 None => {
12559 nros_log::log_error!(
12560 nros_log::get_logger("nros"),
12561 "parameter override `{}:={}` is not a {:?}; the declared default stands",
12562 name,
12563 o.value,
12564 declared.param_type()
12565 );
12566 (declared, false)
12567 }
12568 }
12569 }
12570
12571 /// Declare a parameter with a value on the executor's PRIMARY node.
12572 ///
12573 /// phase-426 W1 — the store is keyed by node, and this is the spelling for
12574 /// the executor's implicit primary node (`NodeId::PRIMARY`), which is the
12575 /// identity `register_parameter_services` publishes and the one every
12576 /// single-node entry means. Name a different node with
12577 /// [`declare_parameter_on`](Self::declare_parameter_on).
12578 ///
12579 /// phase-428 W6 — `#[must_use]`, for the reason `[[nodiscard]]` went on
12580 /// C++'s `Result`: the `else { false }` arm is silently discarded at a
12581 /// call site that drops the value, and `Node::logger` two files over
12582 /// already carries the attribute — so this was inconsistency, not house
12583 /// style. A declare that failed and was ignored is a parameter the program
12584 /// believes it has.
12585 #[must_use]
12586 pub fn declare_parameter(&mut self, name: &str, value: nros_params::ParameterValue) -> bool {
12587 self.declare_parameter_on(super::node_record::NodeId::PRIMARY, name, value)
12588 }
12589
12590 /// Declare a parameter with a value on a named node.
12591 ///
12592 /// `#[must_use]` for the reason [`declare_parameter`](Self::declare_parameter)
12593 /// carries it: issue 1257's two dropped results moved to THIS method when
12594 /// they were fixed, so the attribute on the wrapper alone guarded nothing
12595 /// a launch-seed site could still drop.
12596 #[must_use]
12597 pub fn declare_parameter_on(
12598 &mut self,
12599 node: super::node_record::NodeId,
12600 name: &str,
12601 value: nros_params::ParameterValue,
12602 ) -> bool {
12603 self.ensure_parameter_store();
12604 let (value, _) = self.apply_argv_param_override(node, name, value);
12605 // phase-430 W2 — an application declaring `use_sim_time` itself wins
12606 // over the auto-declared default, so the placeholder leaves first.
12607 let yielded = self.yield_seeded_use_sim_time(node, name);
12608 let accepted = match &mut self.params {
12609 Some(params) => params.server.declare(node.into(), name, value),
12610 None => false,
12611 };
12612 if yielded && !accepted {
12613 // The app's own declaration was refused on its own terms (a full
12614 // store, an ill-formed range). Put the rclcpp default back rather
12615 // than leaving the name undeclared — that is a state neither the
12616 // app nor upstream asked for, and it would silently un-do W2 for
12617 // this node.
12618 self.seed_use_sim_time_default(node);
12619 }
12620 // phase-425 W3b — `use_sim_time` is RESERVED, exactly as in ROS 2: its
12621 // value is not a value the app reads, it is the switch that attaches the
12622 // time source. This is the one seam every language funnels through
12623 // (`nros::main!`'s launch bakes via `apply_param_services`, nros-c's
12624 // the rclc parameter server's `declare`, nros-cpp's `params_shim`), so hooking it
12625 // here covers all of them instead of once per entry path.
12626 //
12627 // phase-430 W3 / issue 1202 — the hook runs AFTER the store's verdict
12628 // and only on acceptance. It used to run first and unconditionally, so
12629 // a re-declaration the store REFUSES (the name is already there) still
12630 // flipped the switch: the store kept `use_sim_time = true` while the
12631 // `/clock` source detached, leaving an executor with sim time recorded
12632 // as on and no source attached, which is neither of the two states the
12633 // parameter can name.
12634 if accepted {
12635 self.note_reserved_parameter(name);
12636 }
12637 accepted
12638 }
12639
12640 /// phase-425 W3b — record a reserved parameter's effect. Today that is
12641 /// `use_sim_time` and nothing else: it is the tree's only reserved name,
12642 /// and [`time_source::USE_SIM_TIME_PARAM`](crate::time_source::USE_SIM_TIME_PARAM)
12643 /// is the only such constant.
12644 ///
12645 /// Called only after the store ACCEPTED a declaration, and it reads the
12646 /// value back OUT of the store instead of taking the caller's: what the
12647 /// switch must follow is what the store holds, and the two are the same
12648 /// thing only while every write succeeds (issue 1202). The read is
12649 /// [`refresh_use_sim_time_from_store`](Self::refresh_use_sim_time_from_store),
12650 /// the same one the runtime `ros2 param set` path uses, so the declare path
12651 /// and the wire path cannot disagree about the value.
12652 ///
12653 /// A non-bool `use_sim_time` is IGNORED rather than rejected: parameter
12654 /// declaration has no channel to report a complaint on (it returns "did the
12655 /// store take it"), and refusing the declaration outright would fail a node
12656 /// for a parameter ROS 2 lets it declare. The time source simply does not
12657 /// attach, which is the same outcome as `false`.
12658 ///
12659 /// Not covered: [`Executor::parameter`](Self::parameter)'s
12660 /// [`ParameterBuilder`](nros_params::ParameterBuilder) borrows the server
12661 /// and declares through it, so the executor never sees the write and
12662 /// `use_sim_time` named that way attaches nothing — issue 1203.
12663 fn note_reserved_parameter(&mut self, name: &str) {
12664 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
12665 if name == crate::time_source::USE_SIM_TIME_PARAM {
12666 self.refresh_use_sim_time_from_store();
12667 }
12668 let _ = name;
12669 }
12670
12671 /// phase-425 W3b — a parameter service handled `n` requests this spin.
12672 ///
12673 /// The hook exists so `use_sim_time` can be re-read after a runtime
12674 /// `ros2 param set`, WITHOUT a per-spin scan of the store. It takes the
12675 /// count unconditionally and ignores it when `sim-time` is off, rather than
12676 /// the call sites carrying the feature test: `handled` was then assigned and
12677 /// never read in the `param-services`-without-`sim-time` combo, which is a
12678 /// `-D warnings` error `check-build` catches and the narrower per-crate
12679 /// clippy runs do not.
12680 #[inline]
12681 fn note_param_services_ran(&mut self, handled: usize) {
12682 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
12683 if handled > 0 {
12684 self.refresh_use_sim_time_from_store();
12685 }
12686 let _ = handled;
12687 self.report_truncated_descriptions();
12688 #[cfg(feature = "param-services")]
12689 self.flush_parameter_events();
12690 }
12691
12692 /// phase-446 F2 -- log each parameter whose description did not fit
12693 /// `NROS_MAX_PARAM_DESCRIPTION_LEN`, once, naming the knob.
12694 ///
12695 /// `nros-params` has no logger, so it truncates, records the truncation on
12696 /// the stored descriptor, and hands it over here. Asked on every spin
12697 /// because a declaration can arrive through builders this executor never
12698 /// sees the end of (`parameter::<T>()` returns the store's own builder);
12699 /// while nothing is pending the store answers from one flag, without
12700 /// walking its table.
12701 #[cold]
12702 fn report_truncated_descriptions(&mut self) {
12703 let Some(params) = self.params.as_deref_mut() else {
12704 return;
12705 };
12706 let nodes = &self.nodes;
12707 params.server.take_truncated_descriptions(|key, name| {
12708 let (ns, node) = nodes
12709 .get(key.index())
12710 .map_or(("", "?"), |r| (r.namespace.as_str(), r.name.as_str()));
12711 nros_log::log_warn!(
12712 nros_log::get_logger("nros"),
12713 "parameter `{}` on node {}/{}: its description is longer than \
12714 NROS_MAX_PARAM_DESCRIPTION_LEN ({} bytes), so `ros2 param describe` \
12715 answers it truncated at a character boundary. Raise \
12716 NROS_MAX_PARAM_DESCRIPTION_LEN to keep it whole; 0 stores no \
12717 descriptions. Logged once for this parameter (phase-446 F2).",
12718 name,
12719 ns.trim_end_matches('/'),
12720 node,
12721 nros_params::MAX_PARAM_DESCRIPTION_LEN
12722 );
12723 });
12724 }
12725
12726 /// Declare a parameter with a value and descriptor on the PRIMARY node.
12727 /// Returns `true` if successful.
12728 ///
12729 /// phase-428 W6 remainder — the sibling of [`Self::declare_parameter`],
12730 /// with the identical `else { false }` arm and no attribute. W6 marked the
12731 /// one method its audit named; this is the site three lines below it, which
12732 /// is the shape CLAUDE.md's "fix the CLASS, not the reported site" rule
12733 /// exists for.
12734 #[must_use]
12735 pub fn declare_parameter_with_descriptor(
12736 &mut self,
12737 name: &str,
12738 value: nros_params::ParameterValue,
12739 descriptor: nros_params::ParameterDescriptor,
12740 ) -> bool {
12741 self.declare_parameter_with_descriptor_on(
12742 super::node_record::NodeId::PRIMARY,
12743 name,
12744 value,
12745 descriptor,
12746 )
12747 }
12748
12749 /// Declare a parameter with a value and descriptor on a named node.
12750 pub fn declare_parameter_with_descriptor_on(
12751 &mut self,
12752 node: super::node_record::NodeId,
12753 name: &str,
12754 value: nros_params::ParameterValue,
12755 descriptor: nros_params::ParameterDescriptor,
12756 ) -> bool {
12757 self.ensure_parameter_store();
12758 // phase-430 W2 — same rule as the sibling above: the app's own
12759 // declaration, descriptor and all, replaces the auto-declared default.
12760 let yielded = self.yield_seeded_use_sim_time(node, name);
12761 // phase-482 W5 — an argv override that the DESCRIPTOR refuses (out of
12762 // range, or read-only with a different value) must not cost the
12763 // parameter its existence: log it, then declare the code's default.
12764 let (overridden, differs) = self.apply_argv_param_override(node, name, value.clone());
12765 let mut accepted = match &mut self.params {
12766 Some(params) => params.server.declare_with_descriptor(
12767 node.into(),
12768 name,
12769 overridden,
12770 Some(descriptor.clone()),
12771 ),
12772 None => false,
12773 };
12774 if !accepted && differs {
12775 nros_log::log_error!(
12776 nros_log::get_logger("nros"),
12777 "parameter override for `{}` was refused by its descriptor; the declared \
12778 default stands",
12779 name
12780 );
12781 accepted = match &mut self.params {
12782 Some(params) => params.server.declare_with_descriptor(
12783 node.into(),
12784 name,
12785 value,
12786 Some(descriptor),
12787 ),
12788 None => false,
12789 };
12790 }
12791 if yielded && !accepted {
12792 self.seed_use_sim_time_default(node);
12793 }
12794 // phase-430 W3 / issue 1202 — the sibling declare path, which had NO
12795 // reserved hook at all: `declare_parameter_with_descriptor` stored
12796 // `use_sim_time` and attached nothing, so the same declaration meant
12797 // two different things depending on whether the app passed a
12798 // descriptor. Same seam, same verdict ordering, one shared hook.
12799 if accepted {
12800 self.note_reserved_parameter(name);
12801 }
12802 accepted
12803 }
12804
12805 /// THE writer — phase-426 W2.
12806 ///
12807 /// Set an ALREADY-DECLARED parameter on the executor's primary node, with
12808 /// the same rules a remote `ros2 param set` gets: read-only, type, range
12809 /// (min/max/step), and issue 1151's refusal of a name the node never
12810 /// declared unless that node opted in through
12811 /// [`allow_undeclared_parameters_on`](Self::allow_undeclared_parameters_on).
12812 ///
12813 /// It routes through [`ParameterServer::apply`](nros_params::ParameterServer::apply),
12814 /// which is what makes the C and C++ facades, the by-value oracle, the
12815 /// streaming service path and the atomic pre-check agree — a facade that
12816 /// wrote the slot directly would be a second answer to "may this set
12817 /// happen", which is the class RFC-0019/0020 forbids. An in-image caller
12818 /// that genuinely wants declare-on-write names
12819 /// `params_mut()`/`set_or_declare` and has opted in by naming it.
12820 ///
12821 /// Returns [`SetParameterResult::NotFound`] when the store does not exist
12822 /// yet (no parameter has been declared and no services registered).
12823 pub fn set_parameter(
12824 &mut self,
12825 name: &str,
12826 value: nros_params::ParameterValue,
12827 ) -> nros_params::SetParameterResult {
12828 self.set_parameter_on(super::node_record::NodeId::PRIMARY, name, value)
12829 }
12830
12831 /// [`set_parameter`](Self::set_parameter) on a named node.
12832 pub fn set_parameter_on(
12833 &mut self,
12834 node: super::node_record::NodeId,
12835 name: &str,
12836 value: nros_params::ParameterValue,
12837 ) -> nros_params::SetParameterResult {
12838 // Reserved parameters (`use_sim_time`) take effect on a set exactly as
12839 // they do on a declare — this is the seam every language funnels
12840 // through, so hooking it once here covers all of them.
12841 //
12842 // AFTER the store's verdict and only on `Success`, for the reason issue
12843 // 1202 gives one function up: the hook used to run first and off the
12844 // caller's value, so a write the store REFUSED still moved the switch
12845 // and left sim time recorded as on with no source attached. `apply` has
12846 // more ways to refuse than `declare` does — read-only, type, range,
12847 // undeclared-without-the-node-option — so a writer that hooked first
12848 // would reach that state from four directions instead of one.
12849 let result = match &mut self.params {
12850 Some(params) => params.server.apply(node.into(), name, value),
12851 None => nros_params::SetParameterResult::NotFound,
12852 };
12853 if result == nros_params::SetParameterResult::Success {
12854 self.note_reserved_parameter(name);
12855 }
12856 result
12857 }
12858
12859 /// phase-491 W2 (RFC-0105 D7) — rclc's LOCAL typed set on `node`:
12860 /// type, the on-set hooks with `(old, new)`, the write. Read-only and
12861 /// range are NOT checked, because rclc's owner of a parameter is not bound
12862 /// by them; remote writes keep [`set_parameter_on`](Self::set_parameter_on)'s
12863 /// rules. The reserved-parameter hook runs on success, as on every writer.
12864 pub fn set_parameter_local_on(
12865 &mut self,
12866 node: super::node_record::NodeId,
12867 name: &str,
12868 value: nros_params::ParameterValue,
12869 ) -> nros_params::SetParameterResult {
12870 let result = match &mut self.params {
12871 Some(params) => params.server.set_local(node.into(), name, value),
12872 None => nros_params::SetParameterResult::NotFound,
12873 };
12874 if result == nros_params::SetParameterResult::Success {
12875 self.note_reserved_parameter(name);
12876 }
12877 result
12878 }
12879
12880 #[cfg(feature = "param-services")]
12881 /// phase-491 W3 (RFC-0105 D8) — publish `node`'s parameter changes on
12882 /// `/parameter_events` (rclc's `notify_changed_over_dds`).
12883 ///
12884 /// Creates the executor's one `/parameter_events` publisher on the first
12885 /// call (see `ParamState::event_publisher`), typed
12886 /// `rcl_interfaces/msg/ParameterEvent`, RELIABLE + VOLATILE as upstream's
12887 /// `parameter_events` profile. Its depth is upstream's 1000 on a hosted
12888 /// build and `NROS_PARAM_EVENTS_DEPTH` (default 1) otherwise, because a
12889 /// reliable DDS writer's history is memory the RMW reserves; depth is not
12890 /// a matching criterion, so an upstream subscriber still matches.
12891 /// Idempotent.
12892 pub fn enable_parameter_events_on(
12893 &mut self,
12894 node: super::node_record::NodeId,
12895 ) -> Result<(), NodeError> {
12896 use nros_core::RosMessage;
12897 type Event = nros_rcl_interfaces::msg::ParameterEvent;
12898 if !self.ensure_parameter_store() {
12899 return Err(NodeError::Transport(TransportError::BadAlloc));
12900 }
12901 let idx = node.index();
12902 if idx >= crate::param_sizing::MAX_SERVICE_SETS {
12903 return Err(NodeError::NodeTableFull);
12904 }
12905 if self
12906 .params
12907 .as_ref()
12908 .is_some_and(|p| p.event_publisher.is_some())
12909 {
12910 if let Some(params) = self.params.as_mut() {
12911 params.server.set_parameter_events(node.into(), true);
12912 }
12913 return Ok(());
12914 }
12915 // The event is encoded into the services' reply buffer; on the heap
12916 // road that pair is allocated with the first service set, which may
12917 // not exist yet.
12918 #[cfg(feature = "alloc")]
12919 if let Some(params) = self.params.as_mut()
12920 && params.buffers.is_none()
12921 {
12922 params.buffers = Some(
12923 crate::parameter_services::ParamServiceBuffers::try_with_capacity(
12924 crate::parameter_services::param_service_buffer_bytes(),
12925 )
12926 .ok_or(NodeError::Transport(TransportError::BadAlloc))?,
12927 );
12928 }
12929 crate::rmw_type_registry::register_type::<Event>()?;
12930 let qos = QoSProfile::parameter_events_default().depth(nros_params::PARAM_EVENTS_DEPTH);
12931 let handle = self.create_raw_publisher_handle_on(
12932 node,
12933 "/parameter_events",
12934 <Event as RosMessage>::TYPE_NAME,
12935 <Event as RosMessage>::TYPE_HASH,
12936 qos,
12937 )?;
12938 if let Some(params) = self.params.as_mut() {
12939 params.event_publisher = Some(handle);
12940 params.server.set_parameter_events(node.into(), true);
12941 }
12942 Ok(())
12943 }
12944
12945 #[cfg(feature = "param-services")]
12946 /// phase-491 W3 — publish every pending parameter change, one
12947 /// `ParameterEvent` each, oldest first. Runs after each spin's parameter
12948 /// services and after every C parameter call; nothing allocates.
12949 pub fn flush_parameter_events(&mut self) {
12950 let stamp_ns = self
12951 .epoch_us_fn
12952 .or(self.clock_us_fn)
12953 .map_or(0, |clock| clock().saturating_mul(1000));
12954 let mut lost: u32 = 0;
12955 loop {
12956 let Some(params) = self.params.as_mut() else {
12957 return;
12958 };
12959 let Some(event) = params.server.pop_parameter_event() else {
12960 break;
12961 };
12962 let idx = event.node.index();
12963 let mut fqn = heapless::String::<256>::new();
12964 match self.nodes.get(idx) {
12965 Some(r) => {
12966 if let Ok(f) = crate::names::fully_qualified_name(&r.name, &r.namespace) {
12967 let _ = fqn.push_str(f.as_str());
12968 }
12969 }
12970 None => {
12971 if let Ok(f) =
12972 crate::names::fully_qualified_name(&self.node_name, &self.namespace)
12973 {
12974 let _ = fqn.push_str(f.as_str());
12975 }
12976 }
12977 }
12978 let Some(params) = self.params.as_mut() else {
12979 return;
12980 };
12981 let crate::param_store::ParamState {
12982 server,
12983 buffers,
12984 event_publisher,
12985 ..
12986 } = &mut **params;
12987 let (Some(publisher), Some(buffers)) = (event_publisher.as_ref(), buffers.as_mut())
12988 else {
12989 continue;
12990 };
12991 let value = match event.kind {
12992 nros_params::ParameterEventKind::Deleted => None,
12993 _ => server.get(event.node, event.name.as_str()),
12994 };
12995 let encoded = crate::parameter_services::encode_parameter_event(
12996 buffers.reply_mut(),
12997 stamp_ns,
12998 fqn.as_str(),
12999 event.kind,
13000 event.name.as_str(),
13001 value,
13002 );
13003 match encoded {
13004 Some(n) => {
13005 use nros_rmw::Publisher as _;
13006 if publisher.publish_raw(&buffers.reply_mut()[..n]).is_err() {
13007 lost = lost.saturating_add(1);
13008 }
13009 }
13010 None => {
13011 lost = lost.saturating_add(1);
13012 }
13013 }
13014 }
13015 let dropped = self
13016 .params
13017 .as_mut()
13018 .map_or(0, |p| p.server.take_parameter_events_dropped());
13019 let lost = lost.saturating_add(dropped);
13020 if lost > 0 {
13021 Self::report_parameter_events_lost(lost);
13022 }
13023 }
13024
13025 #[cfg(feature = "param-services")]
13026 #[cold]
13027 fn report_parameter_events_lost(lost: u32) {
13028 nros_log::log_warn!(
13029 nros_log::get_logger("nros"),
13030 "{} /parameter_events message(s) were not published: the change log \
13031 (PARAMETER_EVENT_LOG_LEN) filled between two flushes, a value did not fit the \
13032 parameter-service reply buffer (NROS_PARAM_SERVICE_BUFFER_SIZE), or the \
13033 publish failed. The parameters themselves changed (phase-491 W3).",
13034 lost
13035 );
13036 }
13037
13038 /// phase-491 W2 — a remote `NOT_SET` on `node` deletes the parameter
13039 /// (rclc) instead of being refused (rclcpp). See
13040 /// [`ParameterServer::set_delete_on_not_set`](nros_params::ParameterServer::set_delete_on_not_set).
13041 pub fn delete_parameters_on_not_set_on(&mut self, node: super::node_record::NodeId, on: bool) {
13042 self.ensure_parameter_store();
13043 if let Some(params) = &mut self.params {
13044 params.server.set_delete_on_not_set(node.into(), on);
13045 }
13046 }
13047
13048 /// May a remote set DECLARE a name this node never declared? Upstream's
13049 /// `allow_undeclared_parameters` node option (issue 1151), per node since
13050 /// phase-426 W1: switching it on for one node leaves its siblings
13051 /// refusing.
13052 pub fn allow_undeclared_parameters_on(
13053 &mut self,
13054 node: super::node_record::NodeId,
13055 allow: bool,
13056 ) {
13057 self.ensure_parameter_store();
13058 if let Some(params) = &mut self.params {
13059 params.server.set_allow_undeclared(node.into(), allow);
13060 }
13061 }
13062
13063 /// [`allow_undeclared_parameters_on`](Self::allow_undeclared_parameters_on)
13064 /// for the primary node.
13065 pub fn allow_undeclared_parameters(&mut self, allow: bool) {
13066 self.allow_undeclared_parameters_on(super::node_record::NodeId::PRIMARY, allow);
13067 }
13068
13069 /// Get a parameter value by name from the primary node.
13070 pub fn get_parameter(&self, name: &str) -> Option<&nros_params::ParameterValue> {
13071 self.get_parameter_on(super::node_record::NodeId::PRIMARY, name)
13072 }
13073
13074 /// Get a parameter value by name from a named node.
13075 pub fn get_parameter_on(
13076 &self,
13077 node: super::node_record::NodeId,
13078 name: &str,
13079 ) -> Option<&nros_params::ParameterValue> {
13080 self.params.as_deref()?.server.get(node.into(), name)
13081 }
13082
13083 /// Get an integer parameter value by name from the primary node (convenience).
13084 pub fn get_parameter_integer(&self, name: &str) -> Option<i64> {
13085 self.params
13086 .as_ref()?
13087 .server
13088 .get_integer(super::node_record::NodeId::PRIMARY.into(), name)
13089 }
13090
13091 /// Get a reference to the parameter server (if registered).
13092 pub fn params(&self) -> Option<&nros_params::ParameterServer<'s>> {
13093 self.params.as_deref().map(|p| &p.server)
13094 }
13095
13096 /// Get a mutable reference to the parameter server (if registered).
13097 pub fn params_mut(&mut self) -> Option<&mut nros_params::ParameterServer<'s>> {
13098 self.params.as_deref_mut().map(|p| &mut p.server)
13099 }
13100
13101 /// Create a typed parameter builder (rclrs-compatible API).
13102 ///
13103 /// Returns a [`ParameterBuilder`] for fluent parameter declaration with
13104 /// `.default()`, `.description()`, `.range()`, and terminal methods
13105 /// `.mandatory()`, `.optional()`, or `.read_only()`.
13106 ///
13107 /// Returns [`NodeError::NotInitialized`] if parameter services have
13108 /// not been registered yet — call [`register_parameter_services`]
13109 /// first.
13110 ///
13111 /// # Example
13112 ///
13113 /// ```ignore
13114 /// let max_speed = executor.parameter::<f64>("max_speed")?
13115 /// .default(25.0)
13116 /// .description("Maximum velocity (m/s)")
13117 /// .read_only()?;
13118 /// ```
13119 ///
13120 /// [`ParameterBuilder`]: nros_params::ParameterBuilder
13121 /// [`register_parameter_services`]: Self::register_parameter_services
13122 pub fn parameter<'a, T: nros_params::ParameterVariant>(
13123 &'a mut self,
13124 name: &'a str,
13125 ) -> Result<nros_params::ParameterBuilder<'a, 's, T>, NodeError> {
13126 self.parameter_on(super::node_record::NodeId::PRIMARY, name)
13127 }
13128
13129 /// [`parameter`](Self::parameter) on a named node.
13130 pub fn parameter_on<'a, T: nros_params::ParameterVariant>(
13131 &'a mut self,
13132 node: super::node_record::NodeId,
13133 name: &'a str,
13134 ) -> Result<nros_params::ParameterBuilder<'a, 's, T>, NodeError> {
13135 let server = self
13136 .params
13137 .as_mut()
13138 .map(|p| &mut p.server)
13139 .ok_or(NodeError::NotInitialized)?;
13140 Ok(nros_params::ParameterBuilder::new(
13141 server,
13142 node.into(),
13143 name,
13144 ))
13145 }
13146
13147 // ========================================================================
13148 // phase-417 W4.a — the surface C and C++ forward to
13149 //
13150 // Every one of these is a forwarder onto `nros_params::ParameterServer`,
13151 // the ONE store. They exist on the executor because that is the object the
13152 // FFI holds: a C caller has an `rclc_executor_t*` and an `rcl_node_t*`,
13153 // and a C++ node has its `node_id`. Nothing here keeps state — a wrapper
13154 // that did would be the second store phase-426 removed.
13155 // ========================================================================
13156
13157 /// Attach a description and free-text constraints to a declared parameter
13158 /// on `node` — rclc's `rclc_add_parameter_description`.
13159 pub fn set_parameter_description_on(
13160 &mut self,
13161 node: super::node_record::NodeId,
13162 name: &str,
13163 description: &str,
13164 additional_constraints: &str,
13165 ) -> bool {
13166 match &mut self.params {
13167 Some(params) => params.server.set_parameter_description(
13168 node.into(),
13169 name,
13170 description,
13171 additional_constraints,
13172 ),
13173 None => false,
13174 }
13175 }
13176
13177 /// [`set_parameter_description_on`](Self::set_parameter_description_on) for
13178 /// the primary node.
13179 pub fn set_parameter_description(
13180 &mut self,
13181 name: &str,
13182 description: &str,
13183 additional_constraints: &str,
13184 ) -> bool {
13185 self.set_parameter_description_on(
13186 super::node_record::NodeId::PRIMARY,
13187 name,
13188 description,
13189 additional_constraints,
13190 )
13191 }
13192
13193 /// Mark a declared parameter read-only — rclc's
13194 /// `rclc_set_parameter_read_only`. Every later write, local or remote, is
13195 /// refused.
13196 pub fn set_parameter_read_only_on(
13197 &mut self,
13198 node: super::node_record::NodeId,
13199 name: &str,
13200 read_only: bool,
13201 ) -> bool {
13202 match &mut self.params {
13203 Some(params) => params
13204 .server
13205 .set_parameter_read_only(node.into(), name, read_only),
13206 None => false,
13207 }
13208 }
13209
13210 /// [`set_parameter_read_only_on`](Self::set_parameter_read_only_on) for the
13211 /// primary node.
13212 pub fn set_parameter_read_only(&mut self, name: &str, read_only: bool) -> bool {
13213 self.set_parameter_read_only_on(super::node_record::NodeId::PRIMARY, name, read_only)
13214 }
13215
13216 /// Attach an integer range — rclc's
13217 /// `rclc_add_parameter_constraint_integer`. Refuses an ill-formed range or
13218 /// one the current value does not satisfy.
13219 pub fn set_parameter_integer_range_on(
13220 &mut self,
13221 node: super::node_record::NodeId,
13222 name: &str,
13223 min: i64,
13224 max: i64,
13225 step: i64,
13226 ) -> bool {
13227 match &mut self.params {
13228 Some(params) => {
13229 params
13230 .server
13231 .set_parameter_integer_range(node.into(), name, min, max, step)
13232 }
13233 None => false,
13234 }
13235 }
13236
13237 /// [`set_parameter_integer_range_on`](Self::set_parameter_integer_range_on)
13238 /// for the primary node.
13239 pub fn set_parameter_integer_range(
13240 &mut self,
13241 name: &str,
13242 min: i64,
13243 max: i64,
13244 step: i64,
13245 ) -> bool {
13246 self.set_parameter_integer_range_on(
13247 super::node_record::NodeId::PRIMARY,
13248 name,
13249 min,
13250 max,
13251 step,
13252 )
13253 }
13254
13255 /// Attach a floating-point range — rclc's
13256 /// `rclc_add_parameter_constraint_double`.
13257 pub fn set_parameter_float_range_on(
13258 &mut self,
13259 node: super::node_record::NodeId,
13260 name: &str,
13261 min: f64,
13262 max: f64,
13263 step: f64,
13264 ) -> bool {
13265 match &mut self.params {
13266 Some(params) => {
13267 params
13268 .server
13269 .set_parameter_float_range(node.into(), name, min, max, step)
13270 }
13271 None => false,
13272 }
13273 }
13274
13275 /// [`set_parameter_float_range_on`](Self::set_parameter_float_range_on) for
13276 /// the primary node.
13277 pub fn set_parameter_float_range(&mut self, name: &str, min: f64, max: f64, step: f64) -> bool {
13278 self.set_parameter_float_range_on(super::node_record::NodeId::PRIMARY, name, min, max, step)
13279 }
13280
13281 /// The descriptor of a declared parameter — rclcpp's
13282 /// `describe_parameter`, rclrs's `ParameterBuilder` readback. `None` when
13283 /// the parameter is not declared on `node`, or was declared without one.
13284 pub fn describe_parameter_on(
13285 &self,
13286 node: super::node_record::NodeId,
13287 name: &str,
13288 ) -> Option<&nros_params::ParameterDescriptor> {
13289 self.params
13290 .as_ref()?
13291 .server
13292 .get_descriptor(node.into(), name)
13293 }
13294
13295 /// [`describe_parameter_on`](Self::describe_parameter_on) for the primary
13296 /// node.
13297 pub fn describe_parameter(&self, name: &str) -> Option<&nros_params::ParameterDescriptor> {
13298 self.describe_parameter_on(super::node_record::NodeId::PRIMARY, name)
13299 }
13300
13301 /// The declared TYPE of a parameter — rclcpp's `get_parameter_types`, and
13302 /// the local answer to the `~/get_parameter_types` service.
13303 pub fn get_parameter_type_on(
13304 &self,
13305 node: super::node_record::NodeId,
13306 name: &str,
13307 ) -> Option<nros_params::ParameterType> {
13308 self.params.as_deref()?.server.get_type(node.into(), name)
13309 }
13310
13311 /// [`get_parameter_type_on`](Self::get_parameter_type_on) for the primary
13312 /// node.
13313 pub fn get_parameter_type(&self, name: &str) -> Option<nros_params::ParameterType> {
13314 self.get_parameter_type_on(super::node_record::NodeId::PRIMARY, name)
13315 }
13316
13317 /// The names a node has declared, filtered by prefix — rclcpp's
13318 /// `list_parameters`, and the local answer to `~/list_parameters`. An
13319 /// empty prefix lists them all.
13320 pub fn list_parameters_on<'p>(
13321 &'p self,
13322 node: super::node_record::NodeId,
13323 prefix: &'p str,
13324 ) -> impl Iterator<Item = &'p str> {
13325 self.params
13326 .as_ref()
13327 .into_iter()
13328 .flat_map(move |p| p.server.list_with_prefix(node.into(), prefix))
13329 }
13330
13331 /// [`list_parameters_on`](Self::list_parameters_on) for the primary node.
13332 pub fn list_parameters<'p>(&'p self, prefix: &'p str) -> impl Iterator<Item = &'p str> {
13333 self.list_parameters_on(super::node_record::NodeId::PRIMARY, prefix)
13334 }
13335
13336 /// All-or-nothing multi-set on `node` — rclcpp's
13337 /// `set_parameters_atomically`.
13338 ///
13339 /// We SERVED `~/set_parameters_atomically` and offered it in no language,
13340 /// so the only way to reach our own atomic set was over the wire. This is
13341 /// the same call the service handler makes.
13342 ///
13343 /// `items` yields `None` for an element that could not be turned into a
13344 /// value at all; that refuses the batch like any failing check.
13345 pub fn set_parameters_atomically_on<'x, I>(
13346 &mut self,
13347 node: super::node_record::NodeId,
13348 items: I,
13349 ) -> nros_params::SetParameterResult
13350 where
13351 I: Iterator<Item = Option<(&'x str, nros_params::ParameterValue)>> + Clone,
13352 {
13353 match &mut self.params {
13354 Some(params) => params.server.apply_atomically(node.into(), items),
13355 None => nros_params::SetParameterResult::NotFound,
13356 }
13357 }
13358
13359 /// [`set_parameters_atomically_on`](Self::set_parameters_atomically_on) for
13360 /// the primary node.
13361 pub fn set_parameters_atomically<'x, I>(&mut self, items: I) -> nros_params::SetParameterResult
13362 where
13363 I: Iterator<Item = Option<(&'x str, nros_params::ParameterValue)>> + Clone,
13364 {
13365 self.set_parameters_atomically_on(super::node_record::NodeId::PRIMARY, items)
13366 }
13367
13368 /// Undeclare a parameter — rclcpp's `undeclare_parameter`, rclc's
13369 /// `rclc_delete_parameter`. The slot is freed for a later declaration.
13370 pub fn undeclare_parameter_on(&mut self, node: super::node_record::NodeId, name: &str) -> bool {
13371 match &mut self.params {
13372 Some(params) => params.server.remove(node.into(), name),
13373 None => false,
13374 }
13375 }
13376
13377 /// [`undeclare_parameter_on`](Self::undeclare_parameter_on) for the primary
13378 /// node.
13379 pub fn undeclare_parameter(&mut self, name: &str) -> bool {
13380 self.undeclare_parameter_on(super::node_record::NodeId::PRIMARY, name)
13381 }
13382
13383 /// Register an accept/reject hook for writes to `node` — rclcpp's
13384 /// `add_on_set_parameters_callback`.
13385 ///
13386 /// It runs on every write that reaches the store through
13387 /// [`set_parameter_on`](Self::set_parameter_on), which is the seam the six
13388 /// `rcl_interfaces/srv/*` servers and both wrapper languages share, so ONE
13389 /// registration covers a remote `ros2 param set` and a local one alike.
13390 ///
13391 /// `None` when all [`nros_params::MAX_ON_SET_CALLBACKS`] slots are taken.
13392 pub fn add_on_set_parameters_callback_on(
13393 &mut self,
13394 node: super::node_record::NodeId,
13395 callback: nros_params::OnSetParameterFn,
13396 ctx: nros_params::OnSetContext,
13397 ) -> Option<nros_params::OnSetParameterHandle> {
13398 self.ensure_parameter_store();
13399 self.params
13400 .as_mut()?
13401 .server
13402 .add_on_set_parameters_callback(node.into(), callback, ctx)
13403 }
13404
13405 /// [`add_on_set_parameters_callback_on`](Self::add_on_set_parameters_callback_on)
13406 /// for the primary node.
13407 pub fn add_on_set_parameters_callback(
13408 &mut self,
13409 callback: nros_params::OnSetParameterFn,
13410 ctx: nros_params::OnSetContext,
13411 ) -> Option<nros_params::OnSetParameterHandle> {
13412 self.add_on_set_parameters_callback_on(super::node_record::NodeId::PRIMARY, callback, ctx)
13413 }
13414
13415 /// Unregister a hook — rclcpp's `remove_on_set_parameters_callback`.
13416 pub fn remove_on_set_parameters_callback(
13417 &mut self,
13418 handle: nros_params::OnSetParameterHandle,
13419 ) -> bool {
13420 match &mut self.params {
13421 Some(params) => params.server.remove_on_set_parameters_callback(handle),
13422 None => false,
13423 }
13424 }
13425
13426 /// rclrs's `Node::use_undeclared_parameters` — a handle that reads and
13427 /// writes parameters this node never declared.
13428 ///
13429 /// The handle writes through `set_or_declare`, not through the wire-facing
13430 /// `apply`: `allow_undeclared_parameters` governs what a REMOTE set may
13431 /// create, and a caller who reached for this type has opted in by naming
13432 /// it. That is rclrs's split too.
13433 pub fn use_undeclared_parameters_on(
13434 &mut self,
13435 node: super::node_record::NodeId,
13436 ) -> Result<nros_params::UndeclaredParameters<'_, 's>, NodeError> {
13437 let server = self
13438 .params
13439 .as_mut()
13440 .map(|p| &mut p.server)
13441 .ok_or(NodeError::NotInitialized)?;
13442 Ok(nros_params::UndeclaredParameters::new(server, node.into()))
13443 }
13444
13445 /// [`use_undeclared_parameters_on`](Self::use_undeclared_parameters_on) for
13446 /// the primary node.
13447 pub fn use_undeclared_parameters(
13448 &mut self,
13449 ) -> Result<nros_params::UndeclaredParameters<'_, 's>, NodeError> {
13450 self.use_undeclared_parameters_on(super::node_record::NodeId::PRIMARY)
13451 }
13452}
13453
13454// ============================================================================
13455// Executor lifecycle — cancel / is_spinning (phase-417 W4.c)
13456// ============================================================================
13457
13458// Issue 1177 -- UN-GATED. This block was `#[cfg(feature = "alloc")]` because
13459// `halt_flag` and `spinning` were `alloc` fields, and neither needed to be: the
13460// allocator is required to SHARE the halt flag (`halt_flag()` hands out a
13461// refcounted handle), never to hold it.
13462//
13463// Gating it meant a no_std executor could not be stopped, and worse, that
13464// `nros-c`'s spin loop -- which calls `cancel`, `is_halted`, `is_spinning`,
13465// `enter_spin_loop` and `exit_spin_loop` with no cfg of its own -- did not
13466// COMPILE for an embedded image. The safety island found that; no
13467// merge-gating lane builds this crate without `alloc`.
13468impl<'s> Executor<'s> {
13469 /// Request the executor to stop spinning — `rclcpp::Executor::cancel`.
13470 ///
13471 /// # ADOPT-BOUNDED (RFC-0089)
13472 ///
13473 /// `cancel` sets a flag the spin loop observes at the NEXT POLL BOUNDARY,
13474 /// so it returns BEFORE spinning has actually stopped;
13475 /// [`is_spinning()`](Self::is_spinning) is the observable that tells you
13476 /// when it has. rclcpp makes the same promise in the same shape, but the
13477 /// boundary here is one `spin_once` timeout wide (a backend's `drive_io`
13478 /// blocks up to the poll interval), so "next boundary" is a duration a
13479 /// caller can measure rather than an implementation detail. The wake flag
13480 /// below shortens it; it does not remove it.
13481 ///
13482 /// Nothing here tears down the session: an executor that has been cancelled
13483 /// is still initialised, still owns its entities, and can be spun again.
13484 /// Teardown is a different verb (`fini` / `shutdown`), and collapsing the
13485 /// two costs a full discovery round on every mode change.
13486 ///
13487 /// Safe to call from another thread or a signal handler.
13488 ///
13489 /// Also raises the Phase 104.C.6 wake flag so a `spin_once` already blocked
13490 /// inside a backend's `drive_io` falls through to the cancel check on its
13491 /// next loop iteration instead of waiting out its full `timeout_ms` first.
13492 pub fn cancel(&self) {
13493 self.halt_flag
13494 .store(true, core::sync::atomic::Ordering::SeqCst);
13495 // The wake flag is the `Arc` half and still needs an allocator. A
13496 // core-only image simply waits out the poll interval, which is the
13497 // bounded behaviour the envelope above already promises.
13498 #[cfg(feature = "alloc")]
13499 self.wake_flag
13500 .store(true, core::sync::atomic::Ordering::SeqCst);
13501 }
13502
13503 /// Has a [`cancel()`](Self::cancel) been REQUESTED?
13504 ///
13505 /// Not the same question as [`is_spinning()`](Self::is_spinning), and both
13506 /// are kept because both have callers:
13507 ///
13508 /// * `is_halted()` — "someone asked this executor to stop." True the instant
13509 /// `cancel()` returns, and it stays true until the next spin loop clears
13510 /// it on entry. This is the flag a hand-rolled `while !exec.is_halted()`
13511 /// loop polls, which is what a BSP that drives `spin_once` itself writes.
13512 /// * `is_spinning()` — "a spin loop is running right now." Answers the other
13513 /// half of the envelope: whether the cancel has been ACTED on yet.
13514 ///
13515 /// The two are true together for as long as one poll takes, which is exactly
13516 /// the window the ADOPT-BOUNDED envelope on `cancel()` describes. Neither is
13517 /// derivable from the other: an executor that was never spun is neither
13518 /// halted nor spinning, and one cancelled mid-poll is both.
13519 ///
13520 /// The name is the pre-rename spelling and is deliberately NOT deprecated —
13521 /// `cancel`'s rclcpp pair is a `spinning` observable, not an
13522 /// `is_cancel_requested` one, so there is no ROS 2 name to adopt here and a
13523 /// third spelling would buy nothing.
13524 pub fn is_halted(&self) -> bool {
13525 self.halt_flag.load(core::sync::atomic::Ordering::SeqCst)
13526 }
13527
13528 /// Is a spin loop running on this executor right now?
13529 ///
13530 /// rclcpp spells this `Executor::is_spinning()`. True between the entry and
13531 /// the exit of [`spin`](Self::spin),
13532 /// [`spin_period`](Self::spin_period), the `open_threaded` worker loop, and
13533 /// the C / C++ API loops — every construct that owns its own iteration.
13534 ///
13535 /// A bare `spin_once` does NOT set it: one poll is not a spin, and a caller
13536 /// running its own `while` around `spin_once` is the one who knows it is
13537 /// looping. Such a caller can say so with
13538 /// [`enter_spin_loop`](Self::enter_spin_loop).
13539 ///
13540 /// See [`is_halted()`](Self::is_halted) for why this is a different question
13541 /// from "was cancel requested".
13542 pub fn is_spinning(&self) -> bool {
13543 self.spinning.load(core::sync::atomic::Ordering::SeqCst)
13544 }
13545
13546 /// Declare that the caller is entering a spin loop it drives itself.
13547 ///
13548 /// The seam the C and C++ APIs use so their loops answer
13549 /// [`is_spinning()`](Self::is_spinning) without keeping a flag of their own
13550 /// (RFC-0019: ergonomics may live in the wrapper, state may not). Also
13551 /// CLEARS any pending cancel, matching what `spin` does on entry:
13552 /// a cancel requested before a spin started is not a cancel of THIS spin.
13553 ///
13554 /// Pair it with [`exit_spin_loop`](Self::exit_spin_loop) on EVERY exit path,
13555 /// `break` and early `return` alike — a loop that leaves without it reports
13556 /// `is_spinning() == true` forever. It is a method pair rather than an RAII
13557 /// guard on purpose: a guard would have to borrow the executor for the whole
13558 /// loop, and every caller needs `&mut` inside the body to poll.
13559 ///
13560 /// Takes `&self` (the flags are atomics), so the borrow ends at the call.
13561 pub fn enter_spin_loop(&self) {
13562 self.halt_flag
13563 .store(false, core::sync::atomic::Ordering::SeqCst);
13564 self.spinning
13565 .store(true, core::sync::atomic::Ordering::SeqCst);
13566 }
13567
13568 /// End the scope opened by [`enter_spin_loop`](Self::enter_spin_loop).
13569 ///
13570 /// Idempotent. Leaves the cancel flag alone: "someone asked me to stop" is
13571 /// the caller's answer to keep or clear, and clearing it here would lose the
13572 /// reason the loop exited.
13573 pub fn exit_spin_loop(&self) {
13574 self.spinning
13575 .store(false, core::sync::atomic::Ordering::SeqCst);
13576 }
13577}
13578
13579// ============================================================================
13580// std-gated spin and halt methods
13581// ============================================================================
13582
13583// phase-359 W10 — this impl used to be ONE `#[cfg(feature = "std")]` block.
13584// Only the wall-clock spin loops in it need `std` (`Instant`, `thread::sleep`,
13585// `thread::spawn`); `halt` / `is_halted` / `wake` are plain atomic stores on
13586// flags the struct already owns, and `halt_flag` / `wake_handle` only need
13587// `Arc`. Gating the BLOCK rather than the items made a no_std image unable to
13588// stop its own executor — `ExecutorNodeRuntime::spin` carried a matching `std`
13589// gate for no reason but this one.
13590// phase-359 W10 — `alloc`, not `std`. The wall-clock spin loops were the
13591// reason this block was std-gated: `Instant` for the deadline and
13592// `thread::sleep` for the pacing. Both now go through the platform — the
13593// executor's own `now_us()` and `nros_platform_sleep_us` — so a no_std
13594// image can run the same blocking loops a hosted one does instead of
13595// hand-rolling them in its BSP.
13596#[cfg(feature = "alloc")]
13597impl<'s> Executor<'s> {
13598 /// Blocking spin loop with configurable exit conditions — rclrs's
13599 /// `Executor::spin(SpinOptions)`.
13600 ///
13601 /// ADOPT-BOUNDED (RFC-0089). Same name, same argument, same meaning: run
13602 /// until an exit condition in `opts` says stop. Two bounds, both visible in
13603 /// the signature:
13604 ///
13605 /// * **The error channel is one error, not all of them.** rclrs returns
13606 /// `Vec<RclrsError>` and the caller writes `.first_error()?`; there is no
13607 /// allocator here, so ours returns `Result<(), NodeError>` carrying the
13608 /// FIRST failing callback's error and drops the rest (they are still
13609 /// counted in each pass's [`SpinOnceResult`]). Ported line for line, the
13610 /// edit is `.first_error()?` -> `?`, which the compiler points at.
13611 /// * **The loop is a poll loop**, so `opts.poll_interval` is the quantum at
13612 /// which every exit condition is observed — a `cancel()` from another
13613 /// task, the `timeout` expiring, `max_callbacks` being reached. Requesting
13614 /// a `timeout` on a build with no clock is an ERROR, not a silent
13615 /// never-ending loop (issue 0709).
13616 ///
13617 /// phase-427 — this method was `spin_blocking`. `spin` was taken by the
13618 /// `-> !` task body, which is now [`spin_forever`](Self::spin_forever).
13619 ///
13620 /// Runs until one of:
13621 /// - [`cancel()`](Self::cancel) is called (from
13622 /// another thread or signal handler)
13623 /// - `opts.timeout` expires (if set)
13624 /// - `opts.max_callbacks` is reached (if set)
13625 /// - `opts.only_next` is true (single iteration)
13626 /// - a callback failed and `opts.stop_on_first_error` is set
13627 ///
13628 /// # Example
13629 ///
13630 /// ```ignore
13631 /// // Spin until cancelled
13632 /// executor.spin(SpinOptions::default())?;
13633 ///
13634 /// // Spin with 5-second timeout
13635 /// executor.spin(SpinOptions::new().timeout(core::time::Duration::from_secs(5)))?;
13636 ///
13637 /// // Single iteration
13638 /// executor.spin(SpinOptions::spin_once())?;
13639 /// ```
13640 pub fn spin(&mut self, opts: SpinOptions) -> Result<(), NodeError> {
13641 // phase-427 — was a private `POLL_INTERVAL` const; it is
13642 // `SpinOptions::poll_interval` now, defaulting to the same 10 ms, so
13643 // `spin` and `spin_forever` read one value instead of two literals.
13644 let poll_interval = opts.poll_interval;
13645
13646 // phase-359 W10 — the executor's own clock. `now_us()` reads the
13647 // PLATFORM's monotonic counter, and nothing else: the platform API is
13648 // the clock, so a build with a port has one and a build without has
13649 // none. There is no `std::time::Instant` third answer any more, which
13650 // is what used to make "what time is it" depend on whether some crate
13651 // in the graph happened to name `std`.
13652 //
13653 // `None` means this build has NO clock at all — and issue 0709 is what
13654 // the previous answer here cost. It read: "a timeout cannot be honoured
13655 // then, and pretending otherwise would exit immediately or never; the
13656 // honest reading is no deadline". It then picked NEVER. A caller that
13657 // asked for 50 ms got an infinite loop, silently, in the one API whose
13658 // whole contract is "returns after N ms" — ten hours of a CI lane
13659 // before anyone read it as stuck rather than slow.
13660 //
13661 // Neither of the two silent readings is honest. An unmet precondition
13662 // is an ERROR (repo rule, fail-loud): the caller supplied a time
13663 // quantity this build cannot measure, and only they can decide what to
13664 // do about it. An UNTIMED `spin` still runs until halt, which
13665 // is a promise this build can keep.
13666 let start_us = self.now_us();
13667 if opts.timeout.is_some() && start_us.is_none() {
13668 nros_log::log_error!(
13669 nros_log::get_logger("nros"),
13670 "spin: a timeout was requested but this build has no clock \
13671 — install one with `ExecutorConfig::clock_us` (issue 0709)"
13672 );
13673 return Err(NodeError::NotInitialized);
13674 }
13675 // `as_micros()` rather than a hand-rolled `ms * 1_000`: the multiply
13676 // was unchecked, and the unit conversion is `Duration`'s job.
13677 let timeout_us = opts.timeout.map(|d| d.as_micros() as u64);
13678 let mut total_callbacks = 0usize;
13679
13680 // phase-417 W4.c — clears the cancel flag AND raises `is_spinning`. The
13681 // clear is the pre-existing behaviour (a cancel requested before this
13682 // spin is not a cancel of this spin); the raise is what lets a peer
13683 // thread see that its `cancel()` has not been acted on yet. Every exit
13684 // below is a `break` to the single tail, which is where the scope ends.
13685 self.enter_spin_loop();
13686
13687 loop {
13688 if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
13689 break;
13690 }
13691
13692 if let (Some(start), Some(limit)) = (start_us, timeout_us)
13693 && self
13694 .now_us()
13695 .is_some_and(|now| now.saturating_sub(start) >= limit)
13696 {
13697 break;
13698 }
13699
13700 let mut callback_error = None;
13701 let result = self.spin_once_capturing(poll_interval, &mut callback_error);
13702 total_callbacks += result.total();
13703
13704 // phase-427 — the first error, and only the first: rclrs's
13705 // `Vec<RclrsError>` needs an allocator we do not have. The scope is
13706 // closed before returning so a caller that reads `is_spinning()`
13707 // after an error sees `false`, exactly as after a clean exit.
13708 if opts.stop_on_first_error
13709 && let Some(e) = callback_error
13710 {
13711 self.exit_spin_loop();
13712 return Err(NodeError::Transport(e));
13713 }
13714
13715 if opts.max_callbacks.is_some_and(|max| total_callbacks >= max) {
13716 break;
13717 }
13718
13719 if opts.only_next {
13720 break;
13721 }
13722 }
13723
13724 self.exit_spin_loop();
13725 Ok(())
13726 }
13727
13728 /// Execute one period with wall-clock overrun detection.
13729 ///
13730 /// Calls [`spin_once()`](Self::spin_once), measures wall-clock time, sleeps
13731 /// for the remainder if under budget.
13732 ///
13733 /// # Example
13734 ///
13735 /// ```ignore
13736 /// let period = core::time::Duration::from_millis(10);
13737 /// let result = executor.spin_one_period_timed(period);
13738 /// if result.overrun {
13739 /// log::warn!("Period overrun: {:?}", result.elapsed);
13740 /// }
13741 /// ```
13742 pub fn spin_one_period_timed(
13743 &mut self,
13744 period: core::time::Duration,
13745 ) -> super::types::SpinPeriodResult {
13746 let start_us = self.now_us();
13747 let result = self.spin_once(period);
13748 // With no clock there is nothing to measure and nothing to sleep off;
13749 // report zero elapsed and no overrun, which is what an unmeasured
13750 // period is.
13751 let elapsed = start_us
13752 .zip(self.now_us())
13753 .map(|(s, e)| core::time::Duration::from_micros(e.saturating_sub(s)))
13754 .unwrap_or_default();
13755 let overrun = elapsed > period;
13756 if !overrun && start_us.is_some() {
13757 platform_sleep(period - elapsed);
13758 }
13759 super::types::SpinPeriodResult {
13760 work: result,
13761 overrun,
13762 elapsed,
13763 }
13764 }
13765
13766 /// Spin at a fixed rate with drift compensation. Blocks until halted.
13767 ///
13768 /// Uses wall-clock time to maintain the target rate. The next invocation
13769 /// time is accumulated (not reset to `now + period`) to prevent cumulative
13770 /// drift.
13771 ///
13772 /// # Example
13773 ///
13774 /// ```ignore
13775 /// // 100Hz control loop — blocks until halt() is called
13776 /// executor.spin_period(core::time::Duration::from_millis(10))?;
13777 /// ```
13778 pub fn spin_period(&mut self, period: core::time::Duration) -> Result<(), NodeError> {
13779 let period_us = period.as_micros().min(u64::MAX as u128) as u64;
13780 // Absolute next-deadline in the executor's own clock. issue 0709 — the
13781 // sibling of `spin`'s guard above: with no clock there is no
13782 // pacing, and the loop would run as fast as `spin_once` returns while
13783 // the caller believes it is running at `period`. A requested period
13784 // this build cannot honour is a configuration error, not a silent
13785 // busy-loop.
13786 let Some(start_us) = self.now_us() else {
13787 nros_log::log_error!(
13788 nros_log::get_logger("nros"),
13789 "spin_period: a period was requested but this build has no clock \
13790 — install one with `ExecutorConfig::clock_us` (issue 0709)"
13791 );
13792 return Err(NodeError::NotInitialized);
13793 };
13794 // phase-417 W4.c — after the clock guard, not before it: a call that
13795 // returns an error never spun, and must not report that it did. (This
13796 // also moved the cancel-flag clear past the guard, which is the same
13797 // correction.)
13798 self.enter_spin_loop();
13799 let mut next_us = Some(start_us + period_us);
13800
13801 loop {
13802 if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
13803 break;
13804 }
13805
13806 self.spin_once(period);
13807
13808 if let Some(next) = next_us {
13809 // Jitter is recorded in `spin_once` above, which this loop
13810 // calls every iteration -- counting it here as well would
13811 // double every wake on this one driver.
13812 if let Some(now) = self.now_us()
13813 && now < next
13814 {
13815 platform_sleep(core::time::Duration::from_micros(next - now));
13816 }
13817 // Accumulate to prevent drift (not = now + period)
13818 next_us = Some(next + period_us);
13819 }
13820 }
13821 self.exit_spin_loop();
13822 Ok(())
13823 }
13824}
13825
13826#[cfg(feature = "alloc")]
13827impl<'s> Executor<'s> {
13828 /// Phase 110.D.b — move this Executor onto a fresh OS thread,
13829 /// apply a per-thread scheduling policy via the caller-supplied
13830 /// `apply_policy` function, and run the spin loop until
13831 /// `ThreadHandle::halt` fires.
13832 ///
13833 /// The function-pointer indirection on `apply_policy` lets the
13834 /// caller pass any platform's `PlatformScheduler::set_current_thread_policy`
13835 /// without forcing `Executor` to be generic over the platform —
13836 /// keeps the existing `Executor` type stable.
13837 ///
13838 /// Multi-executor preemption (the actual hard-RT win) comes from
13839 /// the OS scheduler — call `open_threaded` once per criticality
13840 /// tier, each with its own policy / priority. The kernel handles
13841 /// preemption across executors; within a single executor,
13842 /// dispatch remains non-preemptive (110.A–C bucketed sets).
13843 ///
13844 /// # Safety
13845 ///
13846 /// Moves `self` across thread boundaries. `Executor` contains a
13847 /// raw `*mut session::ConcreteSession` when constructed via
13848 /// `from_session_ptr`; the caller must ensure that pointer's
13849 /// referent stays valid across the lifetime of the spawned thread
13850 /// and that no other thread mutates the session concurrently.
13851 /// `from_session` (Owned) is safer — `ConcreteSession` ownership
13852 /// transfers cleanly into the thread.
13853 #[cfg(feature = "alloc")]
13854 pub unsafe fn open_threaded(
13855 self,
13856 policy: nros_platform_api::SchedPolicy,
13857 apply_policy: fn(
13858 nros_platform_api::SchedPolicy,
13859 ) -> Result<(), nros_platform_api::SchedError>,
13860 spin_period: core::time::Duration,
13861 ) -> ThreadHandle
13862 where
13863 // phase-271 — the spawned thread owns `self` for an unbounded lifetime,
13864 // so its borrowed storage must be `'static` (a leaked/`static` backing —
13865 // the `alloc` convenience constructors or a program-lifetime region).
13866 's: 'static,
13867 {
13868 let halt = self.halt_flag.clone();
13869 // phase-359 W10 — a platform task, not `std::thread`. The two other
13870 // executor-owned workers moved earlier; this is the third and last, and
13871 // it is the one the ABI fits best: `open_threaded` exists to give a
13872 // spin loop an OS SCHEDULING POLICY, which is not something a build
13873 // without an OS was ever going to get from `std`.
13874 //
13875 // The closure becomes a heap context because the entry point crosses C
13876 // as `*mut c_void`. It is reclaimed by the trampoline on exit rather
13877 // than leaked: unlike a tier task, this loop RETURNS — that is what
13878 // `halt` is for.
13879 let ctx = alloc::boxed::Box::into_raw(alloc::boxed::Box::new(ThreadedCtx {
13880 executor: self,
13881 policy,
13882 apply_policy,
13883 spin_period,
13884 }));
13885 // SAFETY: `ctx` stays live until the trampoline reclaims it, which
13886 // happens on the spawned task and only after the loop exits.
13887 let task = unsafe {
13888 nros_platform_api::task::PlatformTask::spawn(
13889 threaded_spin_trampoline,
13890 ctx as *mut core::ffi::c_void,
13891 OPEN_THREADED_STACK_BYTES,
13892 c"nros-exec".as_ptr(),
13893 )
13894 };
13895 let Some(task) = task else {
13896 // The platform refused (or cannot size) a task. Reclaim the
13897 // context and hand back a handle that owns nothing — `halt` and
13898 // `join` stay callable, which keeps the caller's shape identical to
13899 // the success path.
13900 //
13901 // SAFETY: nothing was spawned, so this pointer has no other owner.
13902 drop(unsafe { alloc::boxed::Box::from_raw(ctx) });
13903 return ThreadHandle { task: None, halt };
13904 };
13905 ThreadHandle {
13906 task: Some(task),
13907 halt,
13908 }
13909 }
13910
13911 /// Phase 104.C.6 — wake the executor from another thread / ISR /
13912 /// signal handler.
13913 ///
13914 /// Sets the shared `wake_flag`. The next `spin_once` swap-clears the
13915 /// flag, skips the blocking wait on the primary session, and polls
13916 /// every session non-blockingly so whatever queued the wake is
13917 /// observed in a single iteration. Idempotent — multiple `wake()`
13918 /// calls collapse into one observed wake per `spin_once`.
13919 pub fn wake(&self) {
13920 self.wake_flag
13921 .store(true, core::sync::atomic::Ordering::SeqCst);
13922 }
13923}
13924
13925#[cfg(feature = "alloc")]
13926impl<'s> Executor<'s> {
13927 /// Get a clone of the halt flag for use in signal handlers or other threads.
13928 ///
13929 /// # Example
13930 ///
13931 /// ```ignore
13932 /// let halt = executor.halt_flag();
13933 /// std::thread::spawn(move || {
13934 /// std::thread::sleep(Duration::from_secs(5));
13935 /// halt.store(true, Ordering::SeqCst);
13936 /// });
13937 /// executor.spin(SpinOptions::default())?;
13938 /// ```
13939 pub fn halt_flag(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
13940 self.halt_flag.clone()
13941 }
13942
13943 /// Phase 104.C.6 — clone of the shared wake flag for cross-thread
13944 /// use (signal handlers, foreign threads, future per-backend vtable
13945 /// wake hooks).
13946 ///
13947 /// # Example
13948 ///
13949 /// ```ignore
13950 /// let wake = executor.wake_handle();
13951 /// std::thread::spawn(move || {
13952 /// // ... compute something ...
13953 /// // hand off to executor by setting the flag.
13954 /// wake.store(true, Ordering::SeqCst);
13955 /// });
13956 /// loop { executor.spin_once(Duration::from_millis(100)); }
13957 /// ```
13958 pub fn wake_handle(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
13959 self.wake_flag.clone()
13960 }
13961}
13962
13963/// Phase 110.E.b — opaque per-platform timer handle. Stores the
13964/// raw platform handle (POSIX `timer_t` boxed via `PosixTimerHandle`,
13965/// FreeRTOS `TimerHandle_t`, etc.) plus a destroy thunk so the
13966/// Executor can clean up without being generic over the platform.
13967///
13968/// Caller of `register_sporadic_timer` builds this via
13969/// `OpaqueTimerHandle::new(handle, destroy_fn)` after their
13970/// `PlatformTimer::create_periodic` call returns.
13971#[cfg(feature = "alloc")]
13972pub struct OpaqueTimerHandle {
13973 handle: *mut core::ffi::c_void,
13974 destroy_fn: extern "C" fn(*mut core::ffi::c_void),
13975}
13976
13977#[cfg(feature = "alloc")]
13978unsafe impl Send for OpaqueTimerHandle {}
13979#[cfg(feature = "alloc")]
13980unsafe impl Sync for OpaqueTimerHandle {}
13981
13982#[cfg(feature = "alloc")]
13983impl OpaqueTimerHandle {
13984 /// # Safety
13985 /// `handle` must be a live platform-specific timer handle that
13986 /// `destroy_fn` knows how to drop. Caller surrenders ownership
13987 /// of the underlying handle to the Executor.
13988 pub unsafe fn new(
13989 handle: *mut core::ffi::c_void,
13990 destroy_fn: extern "C" fn(*mut core::ffi::c_void),
13991 ) -> Self {
13992 Self { handle, destroy_fn }
13993 }
13994}
13995
13996#[cfg(feature = "alloc")]
13997impl Drop for OpaqueTimerHandle {
13998 fn drop(&mut self) {
13999 if !self.handle.is_null() {
14000 (self.destroy_fn)(self.handle);
14001 self.handle = core::ptr::null_mut();
14002 }
14003 }
14004}
14005
14006/// Handle returned from [`Executor::open_threaded`]. Holds the
14007/// spawned thread's join handle and a clone of the executor's halt
14008/// flag. Drop runs `halt() + join()` so the thread can't outlive the
14009/// handle.
14010#[cfg(feature = "alloc")]
14011pub struct ThreadHandle {
14012 task: Option<nros_platform_api::task::PlatformTask>,
14013 halt: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
14014}
14015
14016/// Default stack for an `open_threaded` spin loop. `std::thread`'s default was
14017/// 2 MiB; the executor's own storage is caller-carved and lives elsewhere, so
14018/// this carries call frames only — the same reasoning (and size) as the NuttX
14019/// tier spawn, which measured this against a real RTOS default.
14020#[cfg(feature = "alloc")]
14021const OPEN_THREADED_STACK_BYTES: usize = 65536;
14022
14023/// What [`Executor::open_threaded`] hands its task, in place of a closure's
14024/// captures. Owned by the trampoline, which reclaims it when the loop exits.
14025#[cfg(feature = "alloc")]
14026struct ThreadedCtx {
14027 executor: Executor<'static>,
14028 policy: nros_platform_api::SchedPolicy,
14029 apply_policy: fn(nros_platform_api::SchedPolicy) -> Result<(), nros_platform_api::SchedError>,
14030 spin_period: core::time::Duration,
14031}
14032
14033/// The spawned spin loop.
14034///
14035/// # Safety
14036/// `arg` must be the `Box<ThreadedCtx>` raw pointer `open_threaded` created,
14037/// passed exactly once.
14038#[cfg(feature = "alloc")]
14039unsafe extern "C" fn threaded_spin_trampoline(
14040 arg: *mut core::ffi::c_void,
14041) -> *mut core::ffi::c_void {
14042 // SAFETY: the caller's contract — this is the pointer `open_threaded`
14043 // leaked, and the task is the only consumer of it.
14044 let mut ctx = unsafe { alloc::boxed::Box::from_raw(arg as *mut ThreadedCtx) };
14045 // Apply the requested OS scheduling policy to this fresh task. Failure is
14046 // reported but not propagated — a runtime that fails to lift to SCHED_FIFO
14047 // still spins correctly at SCHED_OTHER (just without RT guarantees).
14048 let _ = (ctx.apply_policy)(ctx.policy);
14049 // phase-417 W4.c — this loop owns its own iteration, so it is a spin and
14050 // must say so. NOT `enter_spin_loop()`: that clears the cancel flag, and
14051 // `open_threaded` hands the caller a `ThreadHandle` whose `halt()` may
14052 // already have fired before this task was scheduled. Raising `is_spinning`
14053 // without touching the cancel bit is the honest report here.
14054 ctx.executor
14055 .spinning
14056 .store(true, core::sync::atomic::Ordering::SeqCst);
14057 while !ctx.executor.is_halted() {
14058 ctx.executor.spin_once(ctx.spin_period);
14059 }
14060 ctx.executor.exit_spin_loop();
14061 core::ptr::null_mut()
14062}
14063
14064#[cfg(feature = "alloc")]
14065impl ThreadHandle {
14066 /// Signal the spawned executor thread to stop. The thread exits
14067 /// on its next `spin_once` iteration.
14068 pub fn halt(&self) {
14069 self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
14070 }
14071
14072 /// Wait for the spawned task to exit. After `join`, calling it again is a
14073 /// no-op.
14074 ///
14075 /// phase-359 W10 — was `std::thread::Result<()>`, which carried a panic
14076 /// payload this can no longer produce: a platform task has no unwinding
14077 /// join. Same two outcomes, an error type that does not require `std` —
14078 /// the trade `signal_fd` already made in this campaign. `NotInitialized`
14079 /// means the platform refused to host the task at spawn time — nothing was
14080 /// started, so there is nothing to wait for. (An existing variant rather
14081 /// than a new one: `NodeError` is mapped across the C and C++ FFI, so a new
14082 /// variant is a gate-checked ABI change and this needs no new meaning.)
14083 pub fn join(mut self) -> Result<(), NodeError> {
14084 self.halt();
14085 match self.task.take() {
14086 Some(t) => {
14087 t.join();
14088 Ok(())
14089 }
14090 None => Err(NodeError::NotInitialized),
14091 }
14092 }
14093}
14094
14095#[cfg(feature = "alloc")]
14096impl Drop for ThreadHandle {
14097 fn drop(&mut self) {
14098 self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
14099 if let Some(t) = self.task.take() {
14100 t.join();
14101 }
14102 }
14103}
14104
14105// SAFETY: Phase 110.D.b — `Executor` contains a raw `*mut
14106// session::ConcreteSession` only on the `from_session_ptr` (Borrowed)
14107// path; the `from_session` (Owned) path is plain Send-able. The
14108// `unsafe fn open_threaded` entry point documents the safety
14109// contract for Borrowed sessions; for Owned sessions the Send claim
14110// is unconditional.
14111// phase-359 W10 — `alloc`, not `std`: this assertion exists for
14112// `open_threaded`, which now hands the executor to a PLATFORM task. The
14113// crossing is the same one; only the thing doing the crossing changed.
14114#[cfg(feature = "alloc")]
14115unsafe impl<'s> Send for Executor<'s> {}
14116
14117// =============================================================================
14118// Phase 110.F — `OsPriorityWorker` + `WorkItem`
14119// =============================================================================
14120
14121// phase-359 W10 — `OsPriorityWorker` / `WorkItem` moved to
14122// `super::os_priority` and were rewritten off `std::thread` + `std::sync::mpsc`
14123// + `HashMap` onto the platform task ABI, a bounded `heapless` mailbox and a
14124// `NodeWake` doorbell. The capability is no longer std-only; see that module
14125// for what changed semantically (bounded mailbox, capacity-limited pool, and
14126// no pool on a platform without a wake primitive).
14127
14128impl<'s> Drop for Executor<'s> {
14129 fn drop(&mut self) {
14130 // Issue 0790 — a teardown that never went through `close()` still gets
14131 // its ordered shutdown hooks, and gets them in the SAME order. The C
14132 // API's `nros_executor_fini` is exactly that path: it drops the
14133 // executor in place and leaves the session to `nros_support_fini`, so
14134 // without this the whole facility would be silently inert for every C
14135 // entry. Entities are still live at the top of `drop` — the component
14136 // cells and the arena entries below are what tears them down — so this
14137 // is the last moment a pre-shutdown hook can do what it exists to do.
14138 //
14139 // After a normal `close()` both tables are already empty and these two
14140 // calls are a pair of `None` scans.
14141 self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
14142 // Phase 258 (Track 2, 2a) — release executor-owned component cells
14143 // first (before the arena entries), so a component's `drop`
14144 // trampoline can still touch its own (cell-owned) state. Each slot
14145 // owns a leaked `Arc<ComponentCell>`; its `drop` reconstitutes +
14146 // drops that Arc exactly once.
14147 for slot in self.component_slots.iter() {
14148 // SAFETY: `slot.state` is the leaked cell enrolled via
14149 // `enroll_component`; `slot.drop` is its matching trampoline, run
14150 // exactly once here (slots are not removed before Drop).
14151 unsafe {
14152 (slot.drop)(slot.state);
14153 }
14154 }
14155 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
14156 for meta in self.entries.iter().flatten() {
14157 // SAFETY: each entry was written by `ptr::write` in `add_*` and
14158 // has not been dropped yet. `drop_fn` matches the concrete type.
14159 unsafe {
14160 let data_ptr = arena_ptr.add(meta.offset);
14161 (meta.drop_fn)(data_ptr);
14162 }
14163 }
14164 // Issue 0790 — the post-teardown half, after the entities are gone.
14165 // Same "already empty after `close()`" note as the pre pass above.
14166 self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
14167 // Issue 1385 — LAST in the body, and that is the whole point: the
14168 // context the backend holds is `wake_ctx`, a field, so it is still
14169 // alive for every line above and is freed only after `drop` returns.
14170 // Clearing here is therefore the SHORTEST window in which a callback
14171 // can fire into live state, not the longest — entity teardown above
14172 // may still be talking to the backend.
14173 //
14174 // A no-op after `close()` (the latch), and a no-op for an executor
14175 // that never installed. For the C path this is the only clear there
14176 // is: `rclc_executor_fini` runs this `drop_in_place` and THEN
14177 // zero-fills the storage the callback pointed into.
14178 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
14179 self.clear_wake_signal();
14180 // phase-467 Row 8 — and the graph-change target, before the box it
14181 // names is freed with this executor.
14182 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
14183 self.clear_graph_change_signal();
14184 }
14185}
14186
14187#[cfg(all(test, feature = "std", not(feature = "rmw-cffi")))]
14188mod dispatch_registry_tests {
14189 //! Phase 216 follow-up — `Executor::register_dispatch_slot` +
14190 //! `Executor::dispatch_callback` round-trip.
14191 //!
14192 //! Uses `MockSession` (same pattern as
14193 //! `lifecycle_services::tests::mock_integration`) so the test
14194 //! doesn't need a live RMW backend. Gated `not(feature =
14195 //! "rmw-cffi")` because under `rmw-cffi` the `ConcreteSession`
14196 //! type alias resolves to the cffi session, which `MockSession`
14197 //! can't impersonate.
14198
14199 extern crate alloc;
14200
14201 use super::Executor;
14202 use crate::mock::MockSession;
14203 use std::sync::Mutex;
14204
14205 static CAPTURED: Mutex<alloc::vec::Vec<(usize, alloc::vec::Vec<u8>, usize)>> =
14206 Mutex::new(alloc::vec::Vec::new());
14207
14208 /// Test trampoline matching the per-Node
14209 /// `__nros_node_<pkg>_on_callback` ABI shape (Phase 216.A.5).
14210 unsafe extern "C" fn recording_on_callback(
14211 state: *mut core::ffi::c_void,
14212 cb_id_ptr: *const u8,
14213 cb_id_len: usize,
14214 ctx: *mut core::ffi::c_void,
14215 ) {
14216 // SAFETY: caller (test body below) holds storage live;
14217 // `cb_id_ptr..len` points into a `&str` literal.
14218 let cb_id_bytes = unsafe { core::slice::from_raw_parts(cb_id_ptr, cb_id_len).to_vec() };
14219 let mut guard = CAPTURED.lock().expect("CAPTURED poisoned");
14220 guard.push((state as usize, cb_id_bytes, ctx as usize));
14221 }
14222
14223 #[test]
14224 fn register_dispatch_slot_round_trip() {
14225 let session = MockSession::new();
14226 let mut executor: Executor = Executor::from_session(session);
14227
14228 // Pre-condition: empty registry.
14229 assert_eq!(executor.dispatch_slot_count(), 0);
14230
14231 // Two distinct "states" so we prove every slot gets called
14232 // with its OWN state.
14233 let mut state_blob_a: u32 = 0xABCD_0001;
14234 let mut state_blob_b: u32 = 0xABCD_0002;
14235 let state_a_ptr = &mut state_blob_a as *mut u32 as *mut core::ffi::c_void;
14236 let state_b_ptr = &mut state_blob_b as *mut u32 as *mut core::ffi::c_void;
14237
14238 executor
14239 .register_dispatch_slot(state_a_ptr, recording_on_callback)
14240 .expect("register slot A");
14241 executor
14242 .register_dispatch_slot(state_b_ptr, recording_on_callback)
14243 .expect("register slot B");
14244 assert_eq!(executor.dispatch_slot_count(), 2);
14245
14246 let mut ctx_blob: u32 = 0xFEED_BEEF;
14247 let ctx_ptr = &mut ctx_blob as *mut u32 as *mut core::ffi::c_void;
14248 let cb_id = "/talker/timer/publish";
14249
14250 CAPTURED.lock().expect("CAPTURED poisoned").clear();
14251 executor.dispatch_callback(cb_id, ctx_ptr);
14252
14253 let captured = CAPTURED.lock().expect("CAPTURED poisoned").clone();
14254 assert_eq!(
14255 captured.len(),
14256 2,
14257 "every registered slot must be invoked — linear scan, \
14258 no self-filter at the registry layer"
14259 );
14260 // The carved dispatch table iterates in insertion order.
14261 assert_eq!(captured[0].0, state_a_ptr as usize, "slot A's state");
14262 assert_eq!(captured[1].0, state_b_ptr as usize, "slot B's state");
14263 for (idx, capture) in captured.iter().enumerate() {
14264 assert_eq!(
14265 capture.1.as_slice(),
14266 cb_id.as_bytes(),
14267 "slot {idx} cb_id bytes round-trip"
14268 );
14269 assert_eq!(
14270 capture.2, ctx_ptr as usize,
14271 "slot {idx} ctx pointer round-trip"
14272 );
14273 }
14274 }
14275
14276 #[test]
14277 fn register_dispatch_slot_capacity_full() {
14278 let session = MockSession::new();
14279 let mut executor: Executor = Executor::from_session(session);
14280
14281 let mut state_blob: u32 = 0;
14282 let state_ptr = &mut state_blob as *mut u32 as *mut core::ffi::c_void;
14283
14284 // `MAX_NODES` slots fit; the next one must error.
14285 for _ in 0..crate::config::MAX_NODES {
14286 executor
14287 .register_dispatch_slot(state_ptr, recording_on_callback)
14288 .expect("under-capacity push must succeed");
14289 }
14290 assert_eq!(executor.dispatch_slot_count(), crate::config::MAX_NODES);
14291 let overflow = executor.register_dispatch_slot(state_ptr, recording_on_callback);
14292 assert!(
14293 overflow.is_err(),
14294 "over-capacity push must return Err(()) — raise \
14295 NROS_EXECUTOR_MAX_NODES at build time to grow the registry"
14296 );
14297 }
14298}
14299
14300/// Phase 274.W1 — borrowed-executor session sharing + active-groups gating.
14301///
14302/// Validates three primitives introduced for RFC-0015 Model 1:
14303/// - `session_handle` / `open_with_session_handle` (Borrowed session store —
14304/// the borrowed executor does not own or close the session on drop).
14305/// - `set_active_groups` + `group_active` (callback-group filter gating).
14306///
14307/// Uses `MockSession` (same pattern as `dispatch_registry_tests`); gated
14308/// `not(feature = "rmw-cffi")` for the same reason.
14309#[cfg(all(test, feature = "std", not(feature = "rmw-cffi")))]
14310mod p274_w1_tier_executor_tests {
14311 use super::Executor;
14312 use crate::mock::MockSession;
14313
14314 #[test]
14315 fn session_handle_borrowed_executor_shares_session_ptr() {
14316 // Open the primary executor (session owner).
14317 let session = MockSession::new();
14318 let mut primary = Executor::from_session(session);
14319
14320 // Record the primary's session pointer for later comparison.
14321 let primary_session_ptr = primary.session_ptr();
14322
14323 // Get the opaque session handle (into_raw for C FFI; here we keep it raw).
14324 let handle = primary.session_handle();
14325
14326 // Open a second executor over the SAME session (Borrowed — does not own it).
14327 // SAFETY: `primary` (the session owner) outlives `borrowed` in this scope.
14328 let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
14329
14330 // Both executors must expose the same session pointer.
14331 assert_eq!(
14332 primary.session_ptr(),
14333 borrowed.session_ptr(),
14334 "borrowed executor must share the primary's session pointer"
14335 );
14336 assert_eq!(
14337 borrowed.session_ptr(),
14338 primary_session_ptr,
14339 "session pointer must be stable across session_handle / open_with_session_handle"
14340 );
14341
14342 // Drop the borrowed executor — the primary's session must remain valid.
14343 drop(borrowed);
14344
14345 // Primary still exposes the same session pointer (session was NOT closed by drop).
14346 assert_eq!(
14347 primary.session_ptr(),
14348 primary_session_ptr,
14349 "primary session pointer must be unchanged after dropping the borrowed executor"
14350 );
14351 }
14352
14353 /// An oversized group name is REFUSED and fails CLOSED.
14354 ///
14355 /// issue 1172 — this used to be `if s.push_str(g).is_ok()`, which dropped
14356 /// the name and left the tier filtering on a set that silently lacked it.
14357 /// Two things are asserted because either alone would let the bug back:
14358 /// the call must REPORT the failure, and the resulting filter must accept
14359 /// NOTHING rather than fall back to the wildcard, which would be
14360 /// fail-open.
14361 #[test]
14362 fn an_oversized_group_name_is_refused_and_fails_closed() {
14363 let session = MockSession::new();
14364 let mut primary = Executor::from_session(session);
14365 let handle = primary.session_handle();
14366 // SAFETY: primary outlives borrowed.
14367 let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
14368
14369 // `GroupName` is `heapless::String<32>`.
14370 let too_long = "g".repeat(33);
14371 assert!(
14372 borrowed
14373 .set_active_groups(&[("n", "/", too_long.as_str())])
14374 .is_err(),
14375 "an oversized group name must be reported, not dropped"
14376 );
14377 assert!(
14378 !borrowed.group_active("n", "/", &too_long),
14379 "the name that did not fit must not be active"
14380 );
14381 assert!(
14382 !borrowed.group_active("n", "/", "anything-else"),
14383 "a refused filter must accept NOTHING — falling back to the \
14384 wildcard would run every callback on this tier"
14385 );
14386 }
14387
14388 #[test]
14389 fn set_active_groups_gates_group_active() {
14390 let session = MockSession::new();
14391 let mut primary = Executor::from_session(session);
14392 let handle = primary.session_handle();
14393
14394 // SAFETY: primary outlives borrowed.
14395 let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
14396
14397 // Before gating: wildcard — every group is accepted.
14398 assert!(
14399 borrowed.group_active("n", "/", "ctrl"),
14400 "default (wildcard) must accept every group"
14401 );
14402 assert!(
14403 borrowed.group_active("n", "/", "telem"),
14404 "default (wildcard) must accept every group"
14405 );
14406
14407 // Gate borrowed to only the "ctrl" group (one-tier filter).
14408 borrowed
14409 .set_active_groups(&[("n", "/", "ctrl")])
14410 .expect("fits");
14411
14412 assert!(
14413 borrowed.group_active("n", "/", "ctrl"),
14414 "\"ctrl\" must be active after set_active_groups([\"ctrl\"])"
14415 );
14416 assert!(
14417 !borrowed.group_active("n", "/", "telem"),
14418 "\"telem\" must NOT be active when only \"ctrl\" is gated"
14419 );
14420 assert!(
14421 !borrowed.group_active("n", "/", "planning"),
14422 "\"planning\" must NOT be active when only \"ctrl\" is gated"
14423 );
14424
14425 // Primary is unaffected (it still uses the wildcard).
14426 assert!(
14427 primary.group_active("n", "/", "telem"),
14428 "primary executor must remain unaffected (still wildcard)"
14429 );
14430
14431 // Clear the filter on borrowed — back to wildcard.
14432 borrowed
14433 .set_active_groups(&[])
14434 .expect("the wildcard always fits");
14435 assert!(
14436 borrowed.group_active("n", "/", "telem"),
14437 "after clearing, borrowed must accept all groups again"
14438 );
14439 }
14440
14441 #[test]
14442 fn session_handle_into_raw_from_raw_round_trip() {
14443 let session = MockSession::new();
14444 let mut primary = Executor::from_session(session);
14445 let session_ptr = primary.session_ptr();
14446
14447 let handle = primary.session_handle();
14448 let raw = handle.into_raw();
14449
14450 // into_raw must return a non-null pointer matching the session address.
14451 assert!(!raw.is_null());
14452 assert_eq!(raw as *mut _, session_ptr);
14453
14454 // from_raw must reconstruct a handle that opens the same borrowed executor.
14455 // SAFETY: primary still owns the session, raw is its address.
14456 let handle2 = unsafe { crate::executor::SessionHandle::from_raw(raw) };
14457 let mut borrowed = unsafe { Executor::open_with_session_handle(handle2) };
14458 assert_eq!(
14459 borrowed.session_ptr(),
14460 session_ptr,
14461 "from_raw reconstructed handle must open executor on the same session"
14462 );
14463 }
14464}
14465
14466/// W3b.5 — snapshot the monitored publishers' counters before a dispatch
14467/// (only when a latency contract exists; otherwise a zeroed array that
14468/// `attribute_latency` never reads).
14469fn snapshot_pub_counts(
14470 table: &'static [super::monitor::MonitorSpec],
14471 active: bool,
14472) -> [u32; super::monitor::MAX_MONITORS] {
14473 let mut counts = [0u32; super::monitor::MAX_MONITORS];
14474 if active {
14475 for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
14476 counts[k] = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
14477 }
14478 }
14479 counts
14480}
14481
14482/// W3b.5 — attribute one dispatch's elapsed time to every monitored
14483/// publisher whose counter advanced during it (an upper bound on the
14484/// node-path take → publish latency: the callback deserialized, ran, and
14485/// published within `elapsed_us`).
14486fn attribute_latency(
14487 table: &'static [super::monitor::MonitorSpec],
14488 active: bool,
14489 counts_before: &[u32; super::monitor::MAX_MONITORS],
14490 elapsed_us: u32,
14491) {
14492 if !active {
14493 return;
14494 }
14495 for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
14496 if spec.max_latency_ms == 0 {
14497 continue;
14498 }
14499 let now = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
14500 if now != counts_before[k] {
14501 spec.cell
14502 .max_latency_us
14503 .fetch_max(elapsed_us, core::sync::atomic::Ordering::Relaxed);
14504 }
14505 }
14506}
14507
14508/// W3b.5 — enforce the bound SC's deadline after a dispatch. A miss maps
14509/// through [`DeadlineAction`](super::sched_context::DeadlineAction):
14510/// `Warn`/`Skip`/`Fault` all report; `Skip` additionally masks the SC's
14511/// remaining callbacks for this cycle; `Fault` invokes the fault hook
14512/// (panic when none is registered).
14513/// phase-503 -- hand a route deadline verdict of `table[row]` to the reaction
14514/// hook, if one is installed (and the monitors are armed: the caller passes
14515/// `None` otherwise). Shared by the overdue check after the park and the
14516/// completion verdict of a late supervised route (`route_judge`).
14517fn call_route_reaction(
14518 hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
14519 table: &[super::monitor::MonitorSpec],
14520 row: usize,
14521 v: &super::monitor::Violation,
14522) {
14523 let Some((f, ctx)) = hook else { return };
14524 let spec = &table[row];
14525 let ev = super::monitor::ReactionEvent {
14526 reaction_id: spec.reaction_id as u32,
14527 row: row as u32,
14528 rule_code: super::monitor::rule_code(v.rule),
14529 measured_ms: v.measured,
14530 declared_ms: v.declared,
14531 mechanism: spec.mechanism,
14532 };
14533 // SAFETY: `f` and `ctx` were registered together through
14534 // `set_reaction_hook`, whose caller keeps `ctx` valid for the executor's
14535 // life; the event lives across the call.
14536 unsafe { f(&ev, ctx) };
14537}
14538
14539fn check_deadline_miss(
14540 sc: Option<&super::sched_context::SchedContext>,
14541 sc_idx: usize,
14542 elapsed_us: u32,
14543 misses: &mut heapless::Vec<super::monitor::Violation, { super::monitor::MAX_VIOLATIONS }>,
14544 skipped_scs: &mut u64,
14545 fault_fn: Option<fn(&super::monitor::Violation)>,
14546 reaction_hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
14547) {
14548 use super::sched_context::DeadlineAction;
14549 let Some(sc) = sc else { return };
14550 let Some(deadline_us) = sc.deadline_us.get().map(|nz| nz.get()) else {
14551 return;
14552 };
14553 if matches!(sc.deadline_action, DeadlineAction::Ignore) || elapsed_us <= deadline_us {
14554 return;
14555 }
14556 let v = super::monitor::Violation {
14557 rule: "deadline-miss-runtime",
14558 // Entries carry no name at this altitude; the SC slot stands in.
14559 fqn: "sched-context",
14560 measured: elapsed_us,
14561 declared: deadline_us,
14562 };
14563 match sc.deadline_action {
14564 DeadlineAction::Ignore => {}
14565 DeadlineAction::Warn => {
14566 let _ = misses.push(v);
14567 }
14568 DeadlineAction::Skip => {
14569 if sc_idx < 64 {
14570 *skipped_scs |= 1u64 << sc_idx;
14571 }
14572 let _ = misses.push(v);
14573 }
14574 DeadlineAction::Fault => {
14575 if let Some(f) = fault_fn {
14576 f(&v);
14577 let _ = misses.push(v);
14578 } else if let Some((f, ctx)) = reaction_hook {
14579 // phase-503 — the reaction hook is the fault path a C or C++
14580 // image can install; reaction id 0, the SC slot as the row.
14581 let ev = super::monitor::ReactionEvent {
14582 reaction_id: super::monitor::REACTION_NONE as u32,
14583 row: sc_idx as u32,
14584 rule_code: super::monitor::rule_code(v.rule),
14585 measured_ms: elapsed_us / 1_000,
14586 declared_ms: deadline_us / 1_000,
14587 mechanism: super::monitor::MECHANISM_EXECUTOR,
14588 };
14589 // SAFETY: as in `check_route_deadlines`.
14590 unsafe { f(&ev, ctx) };
14591 let _ = misses.push(v);
14592 } else {
14593 panic!(
14594 "nros: deadline fault — dispatch ran {elapsed_us} us past a {deadline_us} us deadline"
14595 );
14596 }
14597 }
14598 }
14599}
14600
14601// Timer-accounting clock default (issue: no_std tiers with no `clock_us`
14602// credited each spin the REQUESTED timeout — spin.rs `delta_us` fallback —
14603// so shared-session wakes made low-rate timers fire early (a 100 ms timer
14604// at ~67 ms on Zephyr native_sim) and tick rounding made fast tiers fire
14605// late. Measured on that native_sim image; mechanism documented at the
14606// fallback site.)
14607//
14608// Every platform port exports `nros_platform_clock_ns` through the same
14609// linkage contract the wake primitives rely on (`nros_platform_export_clock!`
14610// in nros-platform-cffi), so an rmw-cffi no_std build can safely default the
14611// executor clock to it; `ExecutorConfig::clock_us` stays as an override.
14612#[cfg(feature = "rmw-cffi")]
14613unsafe extern "C" {
14614 fn nros_platform_clock_ns() -> u64;
14615}
14616
14617#[cfg(feature = "rmw-cffi")]
14618fn default_platform_clock_us() -> u64 {
14619 // SAFETY: bare query of the platform's monotonic ns counter; the symbol
14620 // is guaranteed by whichever platform port linked the binary (the same
14621 // contract `nros_platform_wake_*` already depends on).
14622 //
14623 // RFC-0073 made the ABI nanoseconds; the executor's own accounting is
14624 // still microseconds (`clock_us_fn`, `delta_us`), so the division lives
14625 // here rather than being pushed into every port.
14626 unsafe { nros_platform_clock_ns() / 1_000 }
14627}
14628
14629/// Sleep, through the platform ABI.
14630///
14631/// phase-359 W10 — the spin loops used `std::thread::sleep`. Every port already
14632/// exports `nros_platform_sleep_us` (the ABI's own pacing primitive, the one an
14633/// RTOS build has always used), so a hosted loop and an embedded one now pace
14634/// the same way. The µs entry point is used rather than `_ms` because
14635/// `spin_one_period_timed` sleeps off a remainder, which rounds badly at
14636/// millisecond granularity on short periods.
14637#[cfg(feature = "alloc")]
14638pub(crate) fn platform_sleep(d: core::time::Duration) {
14639 unsafe extern "C" {
14640 fn nros_platform_sleep_us(us: usize);
14641 }
14642 let us = d.as_micros().min(usize::MAX as u128) as usize;
14643 if us == 0 {
14644 return;
14645 }
14646 // SAFETY: a bare pacing call with no pointer arguments, guaranteed by
14647 // whichever port linked the binary — the same contract the clock and wake
14648 // symbols in this file already rely on.
14649 unsafe { nros_platform_sleep_us(us) }
14650}
14651
14652/// A monotonic µs reader, or `None` when this build has no clock at all.
14653///
14654/// phase-359 W10 — one provider, chosen by what is LINKED rather than by which
14655/// flavour the crate was built in. W4 unified the executor's clock ACCESSOR and
14656/// said the provider was the last piece; this is it.
14657///
14658/// `rmw-cffi` means a platform port is linked, and every port exports
14659/// `nros_platform_clock_ns` under the same contract the wake primitives use —
14660/// so a hosted build reads the same counter an embedded one does, instead of
14661/// `Instant` on one and the platform on the other. `Instant` survives only
14662/// where there is no port to ask: `std` without `rmw-cffi`.
14663///
14664/// phase-359 W10 follow-up — this comment claimed the metadata PROBE was that
14665/// configuration, and **it is not**. The probe's generated manifest deps
14666/// `nros-platform-cffi` with `posix-c-port` (issue 0288 layer 5, so it links),
14667/// and its `nros` resolves with `rmw-cffi` ON — feature-unified from the
14668/// component's board crate. Measured on a real probe tree: `alloc default
14669/// macros rmw-cffi ros-humble std`. It takes the PORT arm.
14670///
14671/// What is real: `nros-rmw-metadata` and `nros-tests` do resolve `nros` with
14672/// `std` and no `rmw-cffi`, and have no port crate in their graphs. But nothing
14673/// in either uses this arm — deleting it and building the workspace
14674/// `--all-targets` is clean, and there is no in-tree caller of
14675/// `nros::time::now` at all. So the arm serves an OUT-OF-TREE contract only: a
14676/// consumer with `std`, no `rmw-cffi`, and its own `Session`.
14677pub(crate) fn default_clock_us_fn() -> Option<fn() -> u64> {
14678 #[cfg(feature = "rmw-cffi")]
14679 {
14680 Some(default_platform_clock_us)
14681 }
14682 #[cfg(not(feature = "rmw-cffi"))]
14683 {
14684 None
14685 }
14686}
14687
14688// =============================================================================
14689// phase-425 W3b — the `/clock` time source's executor half.
14690//
14691// Its OWN impl block, gated on `sim-time`, because the methods were first
14692// written next to `declare_parameter` — which lives in a
14693// `#[cfg(feature = "param-store")]` block, so `reconcile_ros_time_source`
14694// silently disappeared in every combo without parameter services, including the
14695// one `spin_once` calls it from.
14696// =============================================================================
14697#[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14698impl<'s> Executor<'s> {
14699 /// phase-425 W3b — bring the `/clock` subscription in line with the last
14700 /// requested `use_sim_time`.
14701 ///
14702 /// Called at the head of every spin, and cheap when there is nothing to do:
14703 /// the common case is one bool comparison. It is a RECONCILE rather than an
14704 /// action at the request site because the request routinely arrives before
14705 /// there is a node to hang the subscription on — `nros::main!` emits
14706 /// `apply_param_services` before its per-node `register` calls, by design,
14707 /// so the store exists when each cell is created.
14708 ///
14709 /// Turning it off stops SAMPLES from being installed; the subscription
14710 /// itself stays, because the executor has no entity removal and inventing
14711 /// one for this would be a much larger change than the switch is worth. The
14712 /// gate is `time_source::set_active`, which the subscription callback reads.
14713 ///
14714 /// Turning it off also does NOT clear the override: a node that stops
14715 /// listening keeps the last simulated time rather than jumping back to the
14716 /// wall clock, which every ROS-time timer would otherwise absorb as a
14717 /// backwards jump.
14718 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14719 pub(crate) fn reconcile_ros_time_source(&mut self) {
14720 // An executor nobody told about `use_sim_time` has NO opinion, and the
14721 // gate is process-global, so writing its default here is not a no-op --
14722 // it is one executor overruling another. Say nothing until asked.
14723 if !self.sim_time_stated {
14724 return;
14725 }
14726 if self.sim_time_requested == crate::time_source::is_active()
14727 && (!self.sim_time_requested || self.sim_time_source.is_some())
14728 {
14729 return;
14730 }
14731 crate::time_source::set_active(self.sim_time_requested);
14732 if !self.sim_time_requested {
14733 // phase-430 W1 — the source is no longer wanted, so its silence is
14734 // no longer a symptom. Disarming here (rather than only re-arming
14735 // on attach) is what makes a later re-attach start a FRESH
14736 // interval: the watch that was counting is put down, not paused.
14737 self.sim_time_silence.disarm();
14738 return;
14739 }
14740 if self.sim_time_source.is_none() {
14741 // No node yet — stay pending and try again next spin. Not an error:
14742 // it is the ordinary order of a generated entry, which declares
14743 // parameters before it registers components.
14744 if self.nodes.is_empty() {
14745 return;
14746 }
14747 if let Ok(handle) = self.install_ros_time_source(
14748 super::node_record::NodeId::PRIMARY,
14749 crate::time_source::CLOCK_TOPIC,
14750 ) {
14751 self.sim_time_source = Some(handle);
14752 // phase-430 W1 — the interval starts HERE, at attach, and not
14753 // at construction: before the subscription exists there is
14754 // nothing to be silent about, so counting from boot would
14755 // accuse an image that spent the interval bringing a transport
14756 // up.
14757 self.sim_time_silence.arm();
14758 }
14759 } else {
14760 // Re-attached: `use_sim_time` went false and back to true while the
14761 // subscription (which the executor cannot remove) stayed. W1 asks
14762 // for the one-shot to reset in exactly this case.
14763 self.sim_time_silence.arm();
14764 }
14765 }
14766
14767 /// phase-430 W1 — credit one spin's elapsed time to the silence watch and
14768 /// emit the diagnostic if this is the spin that crosses the threshold.
14769 ///
14770 /// `delta_us` is the executor's OWN per-spin elapsed measure — the same one
14771 /// every wall timer on this executor accumulates, including its "no clock,
14772 /// credit the requested timeout" fallback. See
14773 /// [`SILENCE_WARN_US`](crate::time_source::SILENCE_WARN_US) for why that is
14774 /// the unit and not a spin count or an independent wall clock.
14775 ///
14776 /// Scope: the source the EXECUTOR installed for `use_sim_time`. An
14777 /// application that calls
14778 /// [`Node::install_ros_time_source`](super::node::Node::install_ros_time_source)
14779 /// directly is not watched, because the line this would print names
14780 /// `use_sim_time`, and that application never set it — the honest
14781 /// diagnostic for the explicit path would be a different sentence, and
14782 /// inventing one nobody asked for is how a warning becomes noise.
14783 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14784 fn note_sim_time_silence(&mut self, delta_us: u64) {
14785 if self.sim_time_silence.tick(
14786 delta_us,
14787 nros_core::clock::Clock::is_ros_time_override_active(),
14788 ) {
14789 crate::time_source::report_silence(crate::time_source::CLOCK_TOPIC);
14790 }
14791 }
14792
14793 /// phase-430 W1 — how many times this executor has reported that
14794 /// `use_sim_time` is on with no `/clock` sample.
14795 ///
14796 /// The count rather than a bool, and an accessor rather than a log grep: a
14797 /// diagnostic whose contract is "exactly once" needs an assertion that can
14798 /// tell one from two, and a log sink is not present on every target that
14799 /// runs the test. It is also the question an application supervising its
14800 /// own configuration would ask.
14801 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14802 pub fn sim_time_silence_warnings(&self) -> u32 {
14803 self.sim_time_silence.reports()
14804 }
14805
14806 /// phase-425 W3b — re-read `use_sim_time` from the parameter store.
14807 ///
14808 /// Called after a parameter service actually handled something, which is
14809 /// what makes a runtime `ros2 param set <node> use_sim_time true` work
14810 /// without a per-spin scan of the store.
14811 ///
14812 /// phase-430 W3 / issue 1202 — the declaration path calls it too, through
14813 /// `note_reserved_parameter`, once the store has accepted the write. It
14814 /// used to record the value directly from the caller's argument, which is
14815 /// the same thing only while the store never refuses. One read for both
14816 /// paths; a second spelling is the drift this function exists to prevent.
14817 /// `pub(crate)` for the same reason: a test that changes the store directly
14818 /// needs the entry point a parameter service uses, not a copy of it.
14819 #[cfg(all(feature = "sim-time", feature = "param-store", any(has_rmw, test)))]
14820 pub(crate) fn refresh_use_sim_time_from_store(&mut self) {
14821 // phase-426 W1 — the store is keyed by NODE, and the simulated clock is
14822 // not. This function is where those two facts meet, so the rule is
14823 // stated here rather than left to whichever call site got there first.
14824 //
14825 // `use_sim_time` is a per-node parameter in ROS 2, and rclcpp means it:
14826 // every `Node` builds its own `TimeSource` over its own `Clock`, so two
14827 // nodes composed into one process CAN be configured differently and
14828 // stamp messages from different clocks at the same instant. This tree
14829 // deliberately does not follow that (phase-425): "the override is
14830 // process-global — one simulated clock per image, the model
14831 // `nros_core::Clock` already documents — so there is nothing to attach
14832 // to and nothing to fan out." That is the right trade for an image with
14833 // a fixed RAM budget, and it makes "whose `use_sim_time`?" a question
14834 // rclcpp never has to answer and this executor now does.
14835 //
14836 // ANY node asking arms the one source. Two reasons, and the second is
14837 // the binding one:
14838 //
14839 // * it is the only rule the CALLERS can implement. `note_param_
14840 // services_ran` fires after a runtime `ros2 param set` and knows
14841 // only that N requests were handled — not which node was written.
14842 // A last-writer-wins rule would need a writer identity that does
14843 // not reach here, so it would silently degrade to "whatever node 0
14844 // says", which is the default-to-primary bug phase-426 W1 exists to
14845 // remove.
14846 // * one node still wanting simulated time is a reason to keep the
14847 // source attached, and detaching is the destructive direction:
14848 // phase-425 already records that turning it off "stops SAMPLES, not
14849 // the subscription", precisely so a mid-run change does not read as
14850 // a clock jump.
14851 //
14852 // The scan is over the executor's own node table, which is the same
14853 // `u8` index space `NodeKey` wraps, so there is one node identity here
14854 // and not two.
14855 let Some(params) = self.params.as_deref() else {
14856 return;
14857 };
14858 let mut stated = false;
14859 let mut enable = false;
14860 // A `NodeId` IS the index into this table (`NodeId::index`), so the
14861 // enumeration is the key space — no second derivation of the number.
14862 //
14863 // `.max(1)` because `NodeId::PRIMARY` is IMPLICIT: the executor's node
14864 // table starts empty and a parameter can be declared on the primary
14865 // node before any `create_node` puts a record in it. Scanning only the
14866 // table would then find nothing and leave `sim_time_stated` false, so a
14867 // `use_sim_time` declared before the first node would never arm the
14868 // source — which `use_sim_time_attaches_and_detaches_the_clock_source`
14869 // catches, since its whole first half runs while "there is still no
14870 // node". Slot 0 is always a legitimate key; at worst the lookup misses.
14871 for idx in 0..self.nodes.len().max(1) {
14872 let key = nros_params::NodeKey::new(idx as u8);
14873 if let Some(v) = params
14874 .server
14875 .get_bool(key, crate::time_source::USE_SIM_TIME_PARAM)
14876 {
14877 stated = true;
14878 enable |= v;
14879 }
14880 }
14881 if stated {
14882 self.sim_time_requested = enable;
14883 self.sim_time_stated = true;
14884 }
14885 }
14886
14887 /// phase-425 W3b — the installed `/clock` subscription, if any.
14888 ///
14889 /// The HANDLE rather than a bool, because "did we subscribe twice" is the
14890 /// question a reconciliation loop has to be able to answer: a second
14891 /// registration takes a new slot, so a stable handle across spins is the
14892 /// evidence that the loop is idempotent.
14893 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14894 pub fn ros_time_source_handle(&self) -> Option<HandleId> {
14895 self.sim_time_source
14896 }
14897
14898 /// phase-425 W3b — whether a `/clock` subscription is currently installed.
14899 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14900 pub fn ros_time_source_installed(&self) -> bool {
14901 self.sim_time_source.is_some()
14902 }
14903
14904 /// phase-425 W3 — subscribe `topic` and install every sample as this
14905 /// image's ROS time. The registration behind
14906 /// [`Node::install_ros_time_source`](super::node::Node::install_ros_time_source)
14907 /// and behind the `use_sim_time` reconciliation, so both spell the QoS and
14908 /// the conversion exactly once.
14909 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14910 pub(crate) fn install_ros_time_source(
14911 &mut self,
14912 node_id: super::node_record::NodeId,
14913 topic: &str,
14914 ) -> Result<HandleId, NodeError> {
14915 self.register_subscription_buffered_on::<
14916 nros_rosgraph_msgs::msg::Clock,
14917 _,
14918 { crate::config::DEFAULT_RX_BUF_SIZE },
14919 >(
14920 node_id,
14921 topic,
14922 QoSProfile::clock_default(),
14923 |msg: &nros_rosgraph_msgs::msg::Clock| {
14924 // The `use_sim_time` gate is read HERE rather than by removing
14925 // the subscription, because there is no entity removal. An
14926 // image that never touches `use_sim_time` and installs the
14927 // source explicitly is active by default.
14928 if !crate::time_source::is_active() {
14929 return;
14930 }
14931 if let Some(nanos) =
14932 crate::time_source::override_nanos(msg.clock.sec, msg.clock.nanosec)
14933 {
14934 nros_core::clock::Clock::set_ros_time_override(nanos);
14935 }
14936 },
14937 None, // no group — node default
14938 None, // the configured default RX buffer, unchanged
14939 )
14940 }
14941}
14942
14943// ============================================================================
14944// phase-417 W4.c — cancel / is_spinning
14945// ============================================================================
14946//
14947// Same gate as `executor::tests`, exactly: `ConcreteSession` is `MockSession`
14948// only while no rmw-* feature is unified in (see `executor/mod.rs`), and
14949// `from_session_with` / `halt_flag` are `alloc`. No `std` gate — `cfg(test)`
14950// already means a hosted harness, which is why `lib.rs` brings `std` in under
14951// `test` as well. (It said "exactly" while missing `alloc`, and `std` was not in
14952// scope: 20 errors, hidden by feature unification under `--workspace`.)
14953#[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
14954mod cancel_tests {
14955 use super::Executor;
14956 use crate::{executor::types::ExecutorConfig, mock::MockSession};
14957
14958 /// The core has no default clock and says so (issue 0709), so a test that
14959 /// measures elapsed time brings its own — the same seam a board uses.
14960 fn test_clock_us() -> u64 {
14961 use std::{sync::OnceLock, time::Instant};
14962 static EPOCH: OnceLock<Instant> = OnceLock::new();
14963 EPOCH.get_or_init(Instant::now).elapsed().as_micros() as u64
14964 }
14965
14966 fn executor() -> Executor<'static> {
14967 let cfg = ExecutorConfig::default().clock_us(test_clock_us);
14968 Executor::from_session_with(MockSession::new(), &cfg)
14969 }
14970
14971 /// A fresh executor is neither cancelled nor spinning — the two questions
14972 /// are independent, and this is the state where both answers are `false`.
14973 #[test]
14974 fn a_fresh_executor_is_neither_cancelled_nor_spinning() {
14975 let exec = executor();
14976 assert!(!exec.is_halted(), "nothing has asked it to stop");
14977 assert!(!exec.is_spinning(), "no spin loop is running");
14978 }
14979
14980 /// `cancel()` raises the request bit without starting or stopping anything.
14981 #[test]
14982 fn cancel_requests_without_spinning() {
14983 let exec = executor();
14984 exec.cancel();
14985 assert!(exec.is_halted(), "cancel() must be observable immediately");
14986 assert!(
14987 !exec.is_spinning(),
14988 "cancel() must not make a non-spinning executor claim to spin"
14989 );
14990 }
14991
14992 /// The load-bearing one: `cancel()` from a peer thread TERMINATES a running
14993 /// `spin`, `is_spinning()` is true while the loop runs, and false
14994 /// once it has returned.
14995 ///
14996 /// The peer asserts `is_spinning()` from OUTSIDE the spinning thread, which
14997 /// is the whole point of the observable — it is what a signal handler or a
14998 /// supervisor sees.
14999 #[test]
15000 fn cancel_terminates_a_spin_and_is_spinning_tracks_it() {
15001 use std::{
15002 sync::{
15003 Arc,
15004 atomic::{AtomicBool, Ordering},
15005 },
15006 thread,
15007 time::{Duration, Instant},
15008 };
15009
15010 let mut exec = executor();
15011 assert!(!exec.is_spinning());
15012
15013 // `Executor` is not `Sync`, so the peer thread cannot hold a reference
15014 // to it. It observes and drives through the two flag handles instead —
15015 // which is the supported cross-thread surface (`halt_flag()`), and
15016 // keeps this test on the same seam a signal handler uses.
15017 let cancel_bit = exec.halt_flag();
15018 let saw_spinning = Arc::new(AtomicBool::new(false));
15019 let saw_spinning_peer = Arc::clone(&saw_spinning);
15020
15021 let peer = thread::spawn(move || {
15022 // Give the loop time to enter. Bounded, and the assertion below is
15023 // on what was OBSERVED, so a slow host cannot turn this green by
15024 // accident — it fails instead.
15025 thread::sleep(Duration::from_millis(50));
15026 saw_spinning_peer.store(true, Ordering::SeqCst);
15027 cancel_bit.store(true, Ordering::SeqCst);
15028 });
15029
15030 let started = Instant::now();
15031 exec.spin(crate::executor::types::SpinOptions::default())
15032 .expect("an untimed spin runs until cancelled");
15033 let elapsed = started.elapsed();
15034
15035 peer.join().expect("peer thread must not panic");
15036
15037 assert!(
15038 saw_spinning.load(Ordering::SeqCst),
15039 "the peer must have run before spin returned"
15040 );
15041 assert!(
15042 !exec.is_spinning(),
15043 "is_spinning() must be false once the loop has returned"
15044 );
15045 assert!(
15046 exec.is_halted(),
15047 "the cancel request survives the loop it ended"
15048 );
15049 // LOWER bound too: a spin that never waited would prove nothing about
15050 // termination. The peer sleeps 50 ms before cancelling.
15051 assert!(
15052 elapsed >= Duration::from_millis(40),
15053 "spin returned in {elapsed:?} — it cannot have spun"
15054 );
15055 // ...and an UPPER bound, because "terminated" is the claim: without the
15056 // cancel check this loop never returns and the test would hang rather
15057 // than fail. 10 s is far past any scheduling noise.
15058 assert!(
15059 elapsed < Duration::from_secs(10),
15060 "spin took {elapsed:?} to observe the cancel"
15061 );
15062 }
15063
15064 /// `is_spinning()` is true from INSIDE the loop. Read from a timer callback,
15065 /// which is the only place the executor is genuinely mid-spin, and asserted
15066 /// after the loop so a callback that never fired cannot pass silently.
15067 #[test]
15068 fn is_spinning_is_true_inside_the_loop() {
15069 use std::time::Duration;
15070
15071 let mut exec = executor();
15072 // `only_next` runs exactly one iteration, so this is a single poll
15073 // inside a spin scope — bounded, no thread, no clock dependency beyond
15074 // the one the fixture installs.
15075 assert!(!exec.is_spinning());
15076 exec.spin(crate::executor::types::SpinOptions::spin_once())
15077 .expect("a single-iteration spin must succeed");
15078 assert!(
15079 !exec.is_spinning(),
15080 "the scope must close when the loop returns"
15081 );
15082
15083 // The inside-the-loop reading needs a second thread to do it, because
15084 // the spinning thread is blocked in the loop and `Executor` is `!Sync`
15085 // so no `&Executor` may cross. What crosses is a pointer to the one
15086 // atomic, which is the same thing the C and C++ APIs hold: they observe
15087 // `is_spinning` through a raw executor handle, and that is the case this
15088 // flag exists to serve.
15089 struct FlagPtr(*const portable_atomic::AtomicBool);
15090 // SAFETY: the pointee is an `AtomicBool`, which is `Sync`; the only
15091 // operation performed through it is a relaxed-or-stronger load. The
15092 // executor outlives the thread — `peer.join()` below happens before
15093 // `exec` is dropped at the end of the test.
15094 unsafe impl Send for FlagPtr {}
15095
15096 let cancel_bit = exec.halt_flag();
15097 let observed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
15098 let spinning_bit_seen = std::sync::Arc::clone(&observed);
15099 let flag = FlagPtr(&exec.spinning as *const portable_atomic::AtomicBool);
15100 let peer = std::thread::spawn(move || {
15101 let flag = flag;
15102 for _ in 0..500 {
15103 // SAFETY: see the `unsafe impl Send` above.
15104 if unsafe { &*flag.0 }.load(core::sync::atomic::Ordering::SeqCst) {
15105 spinning_bit_seen.store(true, std::sync::atomic::Ordering::SeqCst);
15106 break;
15107 }
15108 std::thread::sleep(Duration::from_millis(2));
15109 }
15110 cancel_bit.store(true, std::sync::atomic::Ordering::SeqCst);
15111 });
15112
15113 exec.spin(crate::executor::types::SpinOptions::default())
15114 .expect("an untimed spin runs until cancelled");
15115 peer.join().expect("peer thread must not panic");
15116
15117 assert!(
15118 observed.load(std::sync::atomic::Ordering::SeqCst),
15119 "is_spinning() must read true while spin is running"
15120 );
15121 }
15122
15123 /// `spin_period` maintains the same scope, and a period this build cannot
15124 /// pace is an error that must NOT leave the executor claiming to spin.
15125 #[test]
15126 fn spin_period_with_no_clock_leaves_is_spinning_false() {
15127 let mut exec = executor();
15128 exec.clock_us_fn = None;
15129 let err = exec
15130 .spin_period(core::time::Duration::from_millis(10))
15131 .expect_err("a period with no clock must fail (issue 0709)");
15132 assert_eq!(err, crate::NodeError::NotInitialized);
15133 assert!(
15134 !exec.is_spinning(),
15135 "a spin that never started must not report that it did"
15136 );
15137 }
15138
15139 /// Entering a spin CLEARS a stale cancel: a cancel requested before this
15140 /// spin is not a cancel of this spin. Documents the ADOPT-BOUNDED envelope's
15141 /// one sharp edge.
15142 #[test]
15143 fn entering_a_spin_clears_a_stale_cancel() {
15144 let exec = executor();
15145 exec.cancel();
15146 assert!(exec.is_halted());
15147 exec.enter_spin_loop();
15148 assert!(
15149 !exec.is_halted(),
15150 "a cancel raised before the loop started must not end it instantly"
15151 );
15152 assert!(exec.is_spinning());
15153 exec.exit_spin_loop();
15154 assert!(!exec.is_spinning());
15155 }
15156}